Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,15 @@ npm run isketch -- check docs/*.flow # file:line errors, exit
npm run isketch -- diff old.flow new.flow -o diff.svg # what changed, listed and drawn
npm run isketch -- brief diagram.flow # a Markdown brief for a coding agent
npm run isketch -- import prisma/schema.prisma -o db.flow # any import format, told from the name
npm run isketch -- scan . -o architecture.flow # a repo's compose, SQL, Prisma, OpenAPI, Drizzle
npm run isketch -- mcp docs # an MCP server for the diagrams in docs/
npm run examples # redraw every SVG in examples/
```

`scan` gives an agent a first draft instead of a blank page: every compose file, set of SQL
migrations, Prisma schema, OpenAPI spec and Drizzle schema it finds (dependencies, builds and tests
skipped) is imported as it would be on its own, laid out, and framed, side by side, in one `.flow`.

It needs only Node: the renderer is the same pure code the app uses, so a docs build or CI can
draw diagrams that match the editor.

Expand Down
38 changes: 33 additions & 5 deletions backlog/tasks/fl-86 - isketch-scan.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
id: FL-86
title: isketch scan
status: To Do
assignee: []
status: Done
assignee:
- '@raj-khan'
created_date: '2026-09-25 17:39'
updated_date: '2026-09-27 14:05'
labels:
- cli
milestone: m-3
Expand All @@ -25,8 +27,34 @@ Point the CLI at a repository and get a first architecture diagram from what it

<!-- AC:BEGIN -->

- [ ] #1 `isketch scan <dir>` reads compose files, SQL migrations, Prisma schemas and OpenAPI specs it finds
- [ ] #2 It writes one .flow file combining them, laid out automatically
- [ ] #3 Unit tests cover a fixture repository
- [x] #1 `isketch scan <dir>` reads compose files, SQL migrations, Prisma schemas and OpenAPI specs it finds
- [x] #2 It writes one .flow file combining them, laid out automatically
- [x] #3 Unit tests cover a fixture repository

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

1. src/domain/scan.js (pure): pickSources(paths) chooses compose files, SQL migrations (all .sql together, in name order), Prisma schemas, OpenAPI specs (by content) and Drizzle schemas (by content), skipping node_modules, .git, dist and build; combineSources lays each imported document out on its own, places them side by side, draws a frame around each named after its source, and renames only ids that collide.
2. CLI: isketch scan [dir] [-o out.flow] lists the tree through a new io.listFiles, reads the chosen files, imports each with its format, and writes one .flow; skipped items are reported per file.
3. Unit tests over a fixture repository in memory; the CLI test runs scan end to end with a fake file system.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

Each source is imported by the same readers as the Import dialog, laid out on its own, and framed side by side (frames from FL-83), so different sources never collide in space and the brief groups them; an id is renamed (prefixed with its source) only when two sources share it. SQL files are joined in name order, as migrations build on each other. Dependencies, build output and tests or fixtures are skipped; the real CLI never descends into ignored folders, so scanning this repository takes 0.25s.

Verified: scan.spec.js over the fixture repository in src/tests/fixtures/repo.js (pickSources finds compose, OpenAPI, Prisma, Drizzle and SQL but nothing in node_modules, dist or tests; frames named after sources, side by side and not overlapping; every shape inside its frame; clashing ids renamed with connections kept); CLI scan end to end with a fake file system, and the empty case. Ran the real binary on this repository: 5 shapes in 2 frames, rendered and checked. vitest 300, e2e 140, lint and typecheck.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

isketch scan [folder] [-o out.flow] turns a repositorys compose files, SQL migrations, Prisma and Drizzle schemas and OpenAPI specs into one .flow diagram, each source framed and laid out side by side. Verified with a fixture repository, CLI tests and a real scan of this repository.
<!-- SECTION:FINAL_SUMMARY:END -->
24 changes: 23 additions & 1 deletion bin/isketch.mjs
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
#!/usr/bin/env node
// A thin shell: everything that can be tested lives in src/cli/run.js.
import { readFile, writeFile } from 'node:fs/promises'
import { readdir, readFile, writeFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import process from 'node:process'

import { run } from '../src/cli/run.js'
import { IGNORED_FOLDERS } from '../src/domain/scan.js'
import { sketchFont } from './sketchFont.mjs'

/**
* Every file under a folder, relative to it, never descending into
* dependencies or build output.
* @param {string} folder
*/
async function listFiles(folder) {
const found = []
const walk = async (dir) => {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
if (!IGNORED_FOLDERS.includes(entry.name)) await walk(path)
} else if (entry.isFile()) found.push(relative(folder, path).split('\\').join('/'))
}
}
await walk(folder)
return found
}

if (process.argv[2] === 'mcp') {
// A long-running server, not a command that exits.
const { serve } = await import('./mcp.mjs')
Expand All @@ -17,6 +38,7 @@ if (process.argv[2] === 'mcp') {
stdout: (text) => process.stdout.write(text),
stderr: (text) => process.stderr.write(text),
sketchFont,
listFiles,
})

process.exit(code)
Expand Down
26 changes: 26 additions & 0 deletions src/cli/__tests__/run.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'

import { run, USAGE } from '../run.js'
import { REPO } from '@/tests/fixtures/repo.js'

/** An in-memory file system and captured output. */
function fakeIo(files = {}) {
Expand Down Expand Up @@ -153,3 +154,28 @@ describe('isketch import', () => {
expect(await run(['import'], fakeIo().io)).toBe(2)
})
})

describe('isketch scan', () => {
it('draws a repository as one framed diagram', async () => {
const files = Object.fromEntries(
Object.entries(REPO).map(([path, text]) => [`shop/${path}`, text]),
)
const fake = fakeIo(files)
fake.io.listFiles = async (folder) =>
Object.keys(files).map((path) => path.slice(folder.length + 1))

expect(await run(['scan', 'shop', '-o', 'shop.flow'], fake.io)).toBe(0)
const flow = fake.written['shop.flow']
expect(flow).toMatch(/^title: shop architecture\n/)
expect(flow).toContain('compose-frame = frame "Services: docker-compose.yml"')
expect(flow).toContain('table-orders -> table-users : user_id')
expect(fake.err.join('')).toMatch(/Scanned shop \(.*\) to shop.flow: \d+ shapes in 5 frames/)
})

it('says so when there is nothing to draw', async () => {
const fake = fakeIo({ 'empty/readme.md': '# hi' })
fake.io.listFiles = async () => ['readme.md']
expect(await run(['scan', 'empty'], fake.io)).toBe(1)
expect(fake.err.join('')).toContain('found no compose, SQL, Prisma, OpenAPI or Drizzle files')
})
})
67 changes: 66 additions & 1 deletion src/cli/run.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parseFlow, serialiseFlow } from '../domain/flowText.js'
import { detectFormat, IMPORT_FORMATS, importFormat } from '../domain/importers.js'
import { combineSources, importSources, PEEKED, pickSources } from '../domain/scan.js'
import { describeDiff, diffDocuments, isUnchanged, mergeForDiff } from '../domain/diff.js'
import { renderSvg } from '../domain/renderSvg.js'
import { toBrief } from '../domain/brief.js'
Expand All @@ -12,6 +13,9 @@ export const USAGE = `Usage:
isketch import <file> [--from <format>] [-o <out.flow>]
Turn a schema, spec or drawing into .flow
(${IMPORT_FORMATS.map((format) => format.id).join(', ')})
isketch scan [folder] [-o <out.flow>] A first architecture diagram of a repository,
from its compose, SQL, Prisma, OpenAPI and
Drizzle files, each in a frame
isketch mcp [folder] An MCP server for the .flow files in a folder
isketch diff <before.flow> <after.flow> [-o <out.svg>] [--dark]
List what changed, and draw it
Expand All @@ -27,7 +31,9 @@ export const USAGE = `Usage:
* stdout: (text: string) => void,
* stderr: (text: string) => void,
* sketchFont?: () => Promise<string>,
* }} io `sketchFont` gives the handwriting font to embed in a sketch
* listFiles?: (folder: string) => Promise<string[]>,
* }} io `sketchFont` gives the handwriting font to embed in a sketch; `listFiles`
* every file under a folder, relative to it, for `scan`
* @returns {Promise<number>} the exit code
*/
export async function run(argv, io) {
Expand All @@ -38,6 +44,7 @@ export async function run(argv, io) {
if (command === 'diff') return diff(rest, io)
if (command === 'brief') return brief(rest, io)
if (command === 'import') return importFile(rest, io)
if (command === 'scan') return scan(rest, io)

io.stderr(USAGE)
return command === undefined || command === '--help' || command === '-h' ? 0 : 2
Expand Down Expand Up @@ -176,6 +183,64 @@ async function importFile(args, io) {
return 0
}

/**
* A repository's own files, as one framed diagram.
* @param {string[]} args
* @param {Parameters<typeof run>[1]} io
*/
async function scan(args, io) {
const { files, out } = options(args)
const folder = files[0] ?? '.'
if (files.length > 1 || out === '' || !io.listFiles) {
io.stderr(USAGE)
return 2
}

let paths
try {
paths = await io.listFiles(folder)
} catch {
io.stderr(`${folder}: cannot be read\n`)
return 1
}

const join = (/** @type {string} */ path) =>
folder === '.' ? path : `${folder.replace(/\/+$/, '')}/${path}`
/** @type {Map<string, string>} */
const contents = new Map()
for (const path of paths.filter((each) => PEEKED.test(each))) {
contents.set(path, await io.readFile(join(path)).catch(() => ''))
}

const sources = pickSources(paths, (path) => contents.get(path) ?? '')
const { parts, warnings } = await importSources(sources, async (path) =>
contents.has(path) ? /** @type {string} */ (contents.get(path)) : io.readFile(join(path)),
)
warnings.forEach(({ path, line, message }) =>
io.stderr(
line && !/ files$/.test(path)
? `${join(path)}:${line}: ${message}\n`
: `${path}: ${message}\n`,
),
)
if (!parts.length) {
io.stderr(`${folder}: found no compose, SQL, Prisma, OpenAPI or Drizzle files to draw.\n`)
return 1
}

const name = folder === '.' ? '' : folder.replace(/\/+$/, '').split('/').pop()
const document = combineSources(parts, name ? `${name} architecture` : 'Architecture')
const flow = serialiseFlow(document)
if (out) {
await io.writeFile(out, flow)
const found = parts.map(({ source }) => source.paths.join(', ')).join('; ')
io.stderr(
`Scanned ${folder} (${found}) to ${out}: ${count(document.nodes.length - parts.length, 'shape')} in ${count(parts.length, 'frame')}\n`,
)
} else io.stdout(flow)
return 0
}

/** @param {number} n @param {string} noun */
const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`

Expand Down
76 changes: 76 additions & 0 deletions src/domain/__tests__/scan.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'

import { sizeOf } from '../constants.js'
import { frameMembers } from '../frames.js'
import { combineSources, importSources, pickSources } from '../scan.js'
import { REPO } from '@/tests/fixtures/repo.js'

const paths = Object.keys(REPO)
const peek = (path) => REPO[path] ?? ''

describe('pickSources', () => {
it('finds compose, OpenAPI, Prisma, Drizzle and SQL, and nothing in dependencies or builds', () => {
expect(pickSources(paths, peek)).toEqual([
{ format: 'compose', label: 'Services', paths: ['docker-compose.yml'] },
{ format: 'openapi', label: 'API', paths: ['api/openapi.yaml'] },
{ format: 'prisma', label: 'Database', paths: ['prisma/schema.prisma'] },
{ format: 'drizzle', label: 'Database', paths: ['src/db/schema.ts'] },
{
format: 'sql',
label: 'Database',
paths: ['db/migrations/001_users.sql', 'db/migrations/002_orders.sql'],
},
])
})
})

describe('combineSources', async () => {
const { parts, warnings } = await importSources(
pickSources(paths, peek),
async (path) => REPO[path],
)
const document = combineSources(parts, 'shop architecture')
const frames = document.nodes.filter((node) => node.type === 'frame')

it('frames each source, named after it, side by side without overlapping', () => {
expect(warnings).toEqual([])
expect(frames.map((frame) => frame.name)).toEqual([
'Services: docker-compose.yml',
'API: api/openapi.yaml',
'Database: prisma/schema.prisma',
'Database: src/db/schema.ts',
'Database: 2 SQL files',
])
frames.slice(1).forEach((frame, index) => {
const before = frames[index]
expect(frame.position.x).toBeGreaterThan(before.position.x + sizeOf(before).width)
})
})

it('puts every shape inside its own frame, laid out, with ids renamed only where two collide', () => {
const members = frameMembers(document)
const inside = [...members.values()].flat()
expect(inside.length).toBe(document.nodes.length - frames.length)
expect(members.get('sql-frame')).toEqual(['table-users', 'table-orders'])
expect(members.get('compose-frame')).toEqual(['api', 'db'])
// The Prisma model called Account keeps its id; nothing else is called that.
expect(document.nodes.some((node) => node.id === 'Account')).toBe(true)
expect(new Set(document.nodes.map((node) => node.id)).size).toBe(document.nodes.length)
expect(document.edges).toContainEqual(
expect.objectContaining({
source: 'table-orders',
target: 'table-users',
label: 'user_id',
}),
)
})

it('renames a clashing id after its source, keeping its connections', () => {
const twice = combineSources([parts[0], parts[0]], 'twice')
const ids = twice.nodes.map((node) => node.id)
expect(ids).toContain('compose-api')
expect(twice.edges).toContainEqual(
expect.objectContaining({ source: 'compose-api', target: 'compose-db' }),
)
})
})
Loading
Loading