From 1cffe564fc4a51b3fe325d770fb5691e81f248c2 Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:23:19 +0700 Subject: [PATCH 1/8] feat(find): identifier index + matching engine --- src/find.ts | 129 +++++++++++++++++++++++++ src/query.ts | 2 +- test/find.test.ts | 86 +++++++++++++++++ test/fixtures/find-overlays/orders.yml | 9 ++ test/fixtures/find.dbml | 29 ++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/find.ts create mode 100644 test/find.test.ts create mode 100644 test/fixtures/find-overlays/orders.yml create mode 100644 test/fixtures/find.dbml diff --git a/src/find.ts b/src/find.ts new file mode 100644 index 0000000..03f57d4 --- /dev/null +++ b/src/find.ts @@ -0,0 +1,129 @@ +import type { IR } from './ir.js'; +import { columnConstraints } from './render/table.js'; +import { editDistance } from './query.js'; + +export type FindKind = 'table' | 'column' | 'enum' | 'enum_value' | 'domain'; +export type MatchLevel = 'exact' | 'glob' | 'fuzzy'; + +export interface FindHit { + kind: FindKind; + match: MatchLevel; + name: string; // the matched identifier itself + table?: string; // column hits: owning table + type?: string; // column hits + constraints?: string; // column hits, incl. `FK → target` + meaning?: string; // overlay meaning, untruncated (renderer truncates for md) + enum?: string; // enum_value hits from a DBML enum: the enum name + usedBy?: string[]; // enum / enum_value: `table.column`; domain: member tables + domain?: string; // table hits: domain slug +} + +interface IndexEntry { key: string; hit: FindHit } + +export function buildFindIndex(ir: IR): IndexEntry[] { + const entries: IndexEntry[] = []; + + // enum name -> table.column users + const enumUsers = new Map(); + for (const t of ir.tables) for (const c of t.columns) { + if (!c.enumName) continue; + const arr = enumUsers.get(c.enumName) ?? []; + arr.push(`${t.name}.${c.name}`); + enumUsers.set(c.enumName, arr); + } + + // table -> column -> FK target tables + const fkOf = new Map>(); + for (const r of ir.refs) { + const m = fkOf.get(r.fromTable) ?? new Map(); + for (const c of r.fromColumns) { + const arr = m.get(c) ?? []; + if (!arr.includes(r.toTable)) arr.push(r.toTable); + m.set(c, arr); + } + fkOf.set(r.fromTable, m); + } + + for (const t of ir.tables) { + entries.push({ key: t.name, hit: { kind: 'table', match: 'exact', name: t.name, domain: t.domain } }); + for (const c of t.columns) { + const fk = fkOf.get(t.name)?.get(c.name); + const constraints = [columnConstraints(c), fk ? `FK → ${fk.join('/')}` : null] + .filter(Boolean).join(', '); + entries.push({ key: c.name, hit: { + kind: 'column', match: 'exact', name: c.name, table: t.name, + type: c.type, constraints, meaning: c.meaning ?? undefined, + }}); + if (c.valueSet) for (const v of c.valueSet.values) { + entries.push({ key: v.value, hit: { + kind: 'enum_value', match: 'exact', name: v.value, + usedBy: [`${t.name}.${c.name}`], meaning: v.meaning || undefined, + }}); + } + } + } + + for (const e of ir.enums) { + entries.push({ key: e.name, hit: { kind: 'enum', match: 'exact', name: e.name, usedBy: enumUsers.get(e.name) ?? [] } }); + for (const v of e.values) { + entries.push({ key: v.name, hit: { + kind: 'enum_value', match: 'exact', name: v.name, enum: e.name, + usedBy: enumUsers.get(e.name) ?? [], meaning: v.meaning ?? undefined, + }}); + } + } + + for (const d of ir.domains) { + entries.push({ key: d.slug, hit: { kind: 'domain', match: 'exact', name: d.slug, usedBy: [...d.tables] } }); + } + + return entries; +} + +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** Merge hits that describe the same node (same kind+name+table+enum), unioning usedBy. */ +function dedupe(hits: FindHit[]): FindHit[] { + const byKey = new Map(); + for (const h of hits) { + const key = `${h.kind}|${h.name}|${h.table ?? ''}|${h.enum ?? ''}`; + const cur = byKey.get(key); + if (!cur) { byKey.set(key, { ...h, usedBy: h.usedBy ? [...h.usedBy] : undefined }); continue; } + if (h.usedBy) cur.usedBy = [...new Set([...(cur.usedBy ?? []), ...h.usedBy])]; + } + return [...byKey.values()]; +} + +/** + * Exact (incl. case-insensitive) wins; a term containing `*` is a glob; + * fuzzy (edit distance <= 2) only runs when exact found nothing and not strict. + */ +export function findHits(ir: IR, term: string, strict: boolean): FindHit[] { + const entries = buildFindIndex(ir); + + if (term.includes('*')) { + const re = new RegExp(`^${term.split('*').map(escapeRe).join('.*')}$`, 'i'); + return dedupe(entries.filter(e => re.test(e.key)).map(e => ({ ...e.hit, match: 'glob' as const }))); + } + + const exact = entries.filter(e => e.key === term); + if (exact.length) return dedupe(exact.map(e => e.hit)); + + const ci = entries.filter(e => e.key.toLowerCase() === term.toLowerCase()); + if (ci.length) return dedupe(ci.map(e => ({ ...e.hit, match: 'exact' as const }))); + + if (strict) return []; + + const fuzzy = entries.filter(e => editDistance(term.toLowerCase(), e.key.toLowerCase()) <= 2); + return dedupe(fuzzy.map(e => ({ ...e.hit, match: 'fuzzy' as const }))); +} + +/** Top-n closest identifier names, for the no-match suggestion line. */ +export function closestIdentifiers(ir: IR, term: string, n: number): string[] { + const keys = [...new Set(buildFindIndex(ir).map(e => e.key))]; + return keys + .map(k => ({ k, d: editDistance(term.toLowerCase(), k.toLowerCase()) })) + .sort((a, b) => a.d - b.d || a.k.localeCompare(b.k)) + .slice(0, n) + .map(x => x.k); +} diff --git a/src/query.ts b/src/query.ts index 710ba9b..1fc9259 100644 --- a/src/query.ts +++ b/src/query.ts @@ -71,7 +71,7 @@ export interface QueryPack { } /** Levenshtein distance, iterative two-row. Only used for short table names. */ -function editDistance(a: string, b: string): number { +export function editDistance(a: string, b: string): number { let prev = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) { const row = [i]; diff --git a/test/find.test.ts b/test/find.test.ts new file mode 100644 index 0000000..b415700 --- /dev/null +++ b/test/find.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { parseDbml } from '../src/parse.js'; +import { loadOverlays, mergeOverlays } from '../src/overlay.js'; +import { findHits } from '../src/find.js'; + +function fixtureIr(withOverlays = true) { + const ir = parseDbml(readFileSync(new URL('./fixtures/find.dbml', import.meta.url), 'utf8')); + if (withOverlays) { + mergeOverlays(ir, loadOverlays(fileURLToPath(new URL('./fixtures/find-overlays', import.meta.url)))); + } + return ir; +} + +describe('findHits — matching', () => { + const ir = fixtureIr(); + + it('column exact match across tables, with FK constraint and overlay meaning', () => { + const hits = findHits(ir, 'cycle_id', false); + const cols = hits.filter(h => h.kind === 'column'); + expect(cols.map(c => c.table).sort()).toEqual(['order_items', 'orders']); + const orders = cols.find(c => c.table === 'orders')!; + expect(orders.match).toBe('exact'); + expect(orders.type).toBe('bigint'); + expect(orders.constraints).toContain('FK → cycles'); + expect(orders.meaning).toBe('Owning planning cycle'); + }); + + it('table exact match', () => { + const hits = findHits(ir, 'orders', false); + const t = hits.find(h => h.kind === 'table'); + expect(t).toMatchObject({ name: 'orders', match: 'exact' }); + }); + + it('enum name match carries usedBy', () => { + const hits = findHits(ir, 'item_type_enum', false); + const e = hits.find(h => h.kind === 'enum')!; + expect(e.usedBy).toEqual(['order_items.item_type']); + }); + + it('DBML enum value match', () => { + const hits = findHits(ir, 'PHYSICAL', false); + const v = hits.find(h => h.kind === 'enum_value')!; + expect(v.enum).toBe('item_type_enum'); + expect(v.usedBy).toEqual(['order_items.item_type']); + }); + + it('overlay value-set value matches as enum_value (the KICKOFF case)', () => { + const hits = findHits(ir, 'ACTIVE', false); + const v = hits.find(h => h.kind === 'enum_value')!; + expect(v.enum).toBeUndefined(); + expect(v.usedBy).toEqual(['orders.status']); + expect(v.meaning).toBe('input window open'); + }); + + it('glob match', () => { + const hits = findHits(ir, 'cycle_*', false); + expect(hits.every(h => h.match === 'glob')).toBe(true); + // cycle_id exists in two tables (orders, order_items) and dedupe keys on + // (kind,name,table), so 3 hits survive even though only 2 distinct names match. + expect([...new Set(hits.map(h => h.name))].sort()).toEqual(['cycle_code', 'cycle_id']); + expect(hits).toHaveLength(3); + }); + + it('fuzzy only when no exact/glob hit', () => { + const hits = findHits(ir, 'cycl_id', false); // distance 1 from cycle_id + expect(hits.length).toBeGreaterThan(0); + expect(hits.every(h => h.match === 'fuzzy')).toBe(true); + }); + + it('strict disables fuzzy', () => { + expect(findHits(ir, 'cycl_id', true)).toEqual([]); + }); + + it('case-insensitive exact accepted, labeled exact', () => { + const hits = findHits(ir, 'ORDERS', false); + expect(hits.find(h => h.kind === 'table')!.match).toBe('exact'); + }); + + it('same value in multiple places merges usedBy into one hit', () => { + // status ACTIVE only lives on orders.status in this fixture — assert single hit, not duplicates + const hits = findHits(ir, 'ACTIVE', false).filter(h => h.kind === 'enum_value'); + expect(hits).toHaveLength(1); + }); +}); diff --git a/test/fixtures/find-overlays/orders.yml b/test/fixtures/find-overlays/orders.yml new file mode 100644 index 0000000..ad400ca --- /dev/null +++ b/test/fixtures/find-overlays/orders.yml @@ -0,0 +1,9 @@ +purpose: Order header row +columns: + cycle_id: + meaning: Owning planning cycle + status: + values: + DRAFT: not yet submitted + ACTIVE: input window open + CLOSED: force-closed diff --git a/test/fixtures/find.dbml b/test/fixtures/find.dbml new file mode 100644 index 0000000..b153fe1 --- /dev/null +++ b/test/fixtures/find.dbml @@ -0,0 +1,29 @@ +Table orders { + id bigint [pk, increment] + customer_id bigint [not null] + cycle_id bigint [not null] + status varchar(20) [not null, note: 'DRAFT | ACTIVE | CLOSED'] + Note: 'Order header' +} + +Table order_items { + id bigint [pk, increment] + order_id bigint [not null] + cycle_id bigint [not null] + item_type item_type_enum [not null] +} + +Table cycles { + id bigint [pk, increment] + cycle_code varchar(50) [unique, not null] +} + +Enum item_type_enum { + PHYSICAL [note: 'shipped goods'] + DIGITAL +} + +Ref: orders.customer_id > cycles.id +Ref: orders.cycle_id > cycles.id +Ref: order_items.order_id > orders.id +Ref: order_items.cycle_id > cycles.id From b38bd79969e4006d6792a29fa77107dce89a59e7 Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:29:05 +0700 Subject: [PATCH 2/8] feat(find): md/json rendering + CLI command --- src/cli.ts | 18 +++++++++++++ src/find.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++ test/find.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index a6c0335..78123f6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { exportJsonl } from './export.js'; import { generate } from './generate.js'; import { init } from './init.js'; import { query, QueryResolveError, MAX_DEPTH, DEFAULT_DEPTH, DEFAULT_BUDGET } from './query.js'; +import { find } from './find.js'; import { loadConfig, pick, require_, ConfigFormatError } from './config.js'; // comma-string flag → array, matching config's excludeColumns shape @@ -120,6 +121,23 @@ program.command('query') })); }); +program.command('find') + .description('locate identifiers — tables, columns, enums, enum values, domains — across the schema') + .argument('', 'identifier names or globs (e.g. cycle_id, "ratio_*")') + .option('-i, --input ', 'DBML file') + .option('--overlays ', 'overlay YAML directory') + .addOption(new Option('--format ', 'md | json').choices(['md', 'json'])) + .option('--strict', 'exact + glob only, no fuzzy match') + .option('-c, --config ', 'config file (default .dbmlgraph.yml)') + .action((terms: string[], o) => { + const cfg = loadConfig(o.config); + const input = require_(pick(o.input, cfg.input), 'input'); + const overlays = pick(o.overlays, cfg.overlays); + const ir = parseDbmlFile(input); + if (overlays) mergeOverlays(ir, loadOverlays(overlays)); + console.log(find(ir, { terms, format: o.format, strict: !!o.strict })); + }); + try { program.parse(); } catch (e: any) { diff --git a/src/find.ts b/src/find.ts index 03f57d4..7c5bcf1 100644 --- a/src/find.ts +++ b/src/find.ts @@ -127,3 +127,72 @@ export function closestIdentifiers(ir: IR, term: string, n: number): string[] { .slice(0, n) .map(x => x.k); } + +export interface FindOptions { + terms: string[]; + format?: 'md' | 'json'; + strict?: boolean; +} + +const MEANING_MAX = 120; +const clip = (s: string | undefined) => + s && s.length > MEANING_MAX ? s.slice(0, MEANING_MAX - 3) + '…' : (s ?? ''); + +const KIND_ORDER: { kind: FindKind; title: string }[] = [ + { kind: 'column', title: 'Column matches' }, + { kind: 'table', title: 'Table matches' }, + { kind: 'enum', title: 'Enum matches' }, + { kind: 'enum_value', title: 'Enum value matches' }, + { kind: 'domain', title: 'Domain matches' }, +]; + +function renderTermMd(ir: IR, term: string, hits: FindHit[], strict: boolean): string { + const L: string[] = []; + if (!hits.length) { + L.push(`no matches for "${term}"`); + if (!strict) L.push(`closest: ${closestIdentifiers(ir, term, 3).join(', ')}`); + return L.join('\n'); + } + const fuzzy = hits.every(h => h.match === 'fuzzy'); + L.push(`find: ${term} · ${hits.length} hit${hits.length === 1 ? '' : 's'}${fuzzy ? ' (fuzzy)' : ''}`); + for (const { kind, title } of KIND_ORDER) { + const group = hits.filter(h => h.kind === kind); + if (!group.length) continue; // empty kinds omitted entirely + L.push(''); + L.push(`## ${title}`); + if (kind === 'column') { + L.push('| table | column | type | constraints | meaning |'); + L.push('| --- | --- | --- | --- | --- |'); + for (const h of [...group].sort((a, b) => a.table!.localeCompare(b.table!))) { + L.push(`| ${h.table} | ${h.name} | ${h.type} | ${h.constraints} | ${clip(h.meaning)} |`); + } + } else if (kind === 'table') { + for (const h of group) { + L.push(`- ${h.name} (domain: ${h.domain}) — run \`dbmlgraph query ${h.name}\` for full context`); + } + } else if (kind === 'enum') { + for (const h of group) L.push(`- enum ${h.name} — used by: ${(h.usedBy ?? []).join(', ') || '(unused)'}`); + } else if (kind === 'enum_value') { + for (const h of group) { + const where = h.enum ? `in ${h.enum}` : 'in value-set'; + const meaning = h.meaning ? ` — ${clip(h.meaning)}` : ''; + L.push(`- ${h.name} ${where} — used by: ${(h.usedBy ?? []).join(', ')}${meaning}`); + } + } else { + for (const h of group) L.push(`- ${h.name} — ${(h.usedBy ?? []).length} tables`); + } + } + return L.join('\n'); +} + +/** buildIndex + match + render in the caller's format. Pure; the CLI just prints. */ +export function find(ir: IR, opts: FindOptions): string { + const strict = opts.strict ?? false; + const perTerm = opts.terms.map(term => ({ term, hits: findHits(ir, term, strict) })); + + if (opts.format === 'json') { + const flat = perTerm.flatMap(({ term, hits }) => hits.map(h => ({ term, ...h }))); + return JSON.stringify(flat, null, 2); + } + return perTerm.map(({ term, hits }) => renderTermMd(ir, term, hits, strict)).join('\n\n---\n\n'); +} diff --git a/test/find.test.ts b/test/find.test.ts index b415700..7410062 100644 --- a/test/find.test.ts +++ b/test/find.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { parseDbml } from '../src/parse.js'; import { loadOverlays, mergeOverlays } from '../src/overlay.js'; -import { findHits } from '../src/find.js'; +import { findHits, find } from '../src/find.js'; function fixtureIr(withOverlays = true) { const ir = parseDbml(readFileSync(new URL('./fixtures/find.dbml', import.meta.url), 'utf8')); @@ -84,3 +84,66 @@ describe('findHits — matching', () => { expect(hits).toHaveLength(1); }); }); + +describe('find — rendering', () => { + const ir = fixtureIr(); + + it('md: grouped by kind, header count, table pointer line', () => { + const md = find(ir, { terms: ['cycle_id'] }); + expect(md).toContain('find: cycle_id ·'); + expect(md).toContain('## Column matches'); + expect(md).toContain('| orders | cycle_id | bigint |'); + expect(md).toContain('Owning planning cycle'); + expect(md).not.toContain('## Table matches'); // empty kinds omitted when others matched + }); + + it('md: table hit renders pointer to query', () => { + const md = find(ir, { terms: ['orders'] }); + expect(md).toContain('run `dbmlgraph query orders` for full context'); + }); + + it('md: enum value hit', () => { + const md = find(ir, { terms: ['PHYSICAL'] }); + expect(md).toContain('PHYSICAL in item_type_enum'); + expect(md).toContain('used by: order_items.item_type'); + }); + + it('md: no matches prints suggestions', () => { + const md = find(ir, { terms: ['zzzzzz'] }); + expect(md).toContain('no matches for "zzzzzz"'); + expect(md).toMatch(/closest: /i); + }); + + it('md: no matches with --strict has no suggestions', () => { + const md = find(ir, { terms: ['zzzzzz'], strict: true }); + expect(md).toContain('no matches for "zzzzzz"'); + expect(md).not.toMatch(/closest: /i); + }); + + it('md: meaning truncated at ~120 chars', () => { + const longIr = fixtureIr(); + const col = longIr.tables.find(t => t.name === 'orders')!.columns.find(c => c.name === 'cycle_id')!; + col.meaning = 'x'.repeat(200); + const md = find(longIr, { terms: ['cycle_id'] }); + expect(md).toContain('x'.repeat(117) + '…'); + expect(md).not.toContain('x'.repeat(121)); + }); + + it('json: array of hits with term field, meaning untruncated', () => { + const longIr = fixtureIr(); + longIr.tables.find(t => t.name === 'orders')!.columns.find(c => c.name === 'cycle_id')!.meaning = 'x'.repeat(200); + const out = JSON.parse(find(longIr, { terms: ['cycle_id'], format: 'json' })); + expect(Array.isArray(out)).toBe(true); + expect(out[0].term).toBe('cycle_id'); + const orders = out.find((h: any) => h.table === 'orders'); + expect(orders.meaning).toHaveLength(200); + }); + + it('multi-term: one section per term (md), flat array with term field (json)', () => { + const md = find(ir, { terms: ['orders', 'PHYSICAL'] }); + expect(md).toContain('find: orders ·'); + expect(md).toContain('find: PHYSICAL ·'); + const out = JSON.parse(find(ir, { terms: ['orders', 'PHYSICAL'], format: 'json' })); + expect(new Set(out.map((h: any) => h.term))).toEqual(new Set(['orders', 'PHYSICAL'])); + }); +}); From 5cd612b7c12b8031478cbe270d63da7ab79d4e05 Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:35:05 +0700 Subject: [PATCH 3/8] feat(install): schema-dir resolution + claude skill target --- src/cli.ts | 14 ++++++++ src/install.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++ test/install.test.ts | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/install.ts create mode 100644 test/install.test.ts diff --git a/src/cli.ts b/src/cli.ts index 78123f6..c8334b6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { init } from './init.js'; import { query, QueryResolveError, MAX_DEPTH, DEFAULT_DEPTH, DEFAULT_BUDGET } from './query.js'; import { find } from './find.js'; import { loadConfig, pick, require_, ConfigFormatError } from './config.js'; +import { installClaude, resolveSchemaDir, InstallError } from './install.js'; // comma-string flag → array, matching config's excludeColumns shape const asCols = (v: string | undefined): string[] | undefined => v?.split(','); @@ -138,11 +139,24 @@ program.command('find') console.log(find(ir, { terms, format: o.format, strict: !!o.strict })); }); +program.command('install') + .description('install AI-agent integration (claude | agents)') + .argument('[agent]', 'claude | agents') + .option('--schema-dir ', 'directory containing .dbmlgraph.yml (default: walk up from cwd)') + .option('--global', 'claude only: install to ~/.claude/skills instead of ./.claude/skills') + .action((agent: string | undefined, o) => { + if (agent !== 'claude') throw new InstallError(`unknown agent "${agent ?? ''}": expected claude | agents`); + const schemaDir = resolveSchemaDir(o.schemaDir, process.cwd()); + const file = installClaude({ schemaDir, cwd: process.cwd(), global: !!o.global }); + console.log(`wrote ${file}\nschema dir baked: ${schemaDir}`); + }); + try { program.parse(); } catch (e: any) { if (e instanceof ConfigFormatError) { console.error(`error: ${e.message}`); process.exit(1); } if (e instanceof QueryResolveError) { console.error(e.message); process.exit(1); } + if (e instanceof InstallError) { console.error(`error: ${e.message}`); process.exit(1); } if (e instanceof DbmlParseError) { const loc = [e.file, e.line != null ? `line ${e.line}` : null].filter(Boolean).join(', '); const hint = e.markdownNoFence ? ' — input looks like markdown without a ```dbml fence?' : ''; diff --git a/src/install.ts b/src/install.ts new file mode 100644 index 0000000..a2e277b --- /dev/null +++ b/src/install.ts @@ -0,0 +1,86 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; + +export class InstallError extends Error {} + +export const MARKER_START = ''; +export const MARKER_END = ''; + +/** + * Explicit dir must contain .dbmlgraph.yml. Without one, walk up from cwd + * (git-style) until a .dbmlgraph.yml appears. + */ +export function resolveSchemaDir(explicit: string | undefined, cwd: string): string { + if (explicit) { + const dir = resolve(cwd, explicit); + if (!existsSync(join(dir, '.dbmlgraph.yml'))) { + throw new InstallError(`no .dbmlgraph.yml in ${dir}`); + } + return dir; + } + let dir = resolve(cwd); + for (;;) { + if (existsSync(join(dir, '.dbmlgraph.yml'))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new InstallError('missing schema dir: pass --schema-dir '); +} + +/** Shared usage guidance — the skill body and the AGENTS.md section render from this. */ +function usageBody(schemaDir: string): string { + return `Schema source: \`${schemaDir}\` (contains \`.dbmlgraph.yml\`). Run all commands from that directory: + +\`\`\`bash +cd "${schemaDir}" +\`\`\` + +**Locate an identifier** (wide, cheap — "which tables have column X", impact scans, enum sweeps): + +\`\`\`bash +dbmlgraph find # table, column, enum, enum value, domain — kind auto-detected +dbmlgraph find "ratio_*" # glob +dbmlgraph find --format json +\`\`\` + +**Full context for a table** (deep — structure, relationships, business meaning): + +\`\`\`bash +dbmlgraph query +dbmlgraph query
--columns key # slimmer neighbors for hub tables +\`\`\` + +\`find\` locates; \`query\` explains. Start with \`find\` when unsure of the exact name. Prefer these over grepping schema markdown.`; +} + +export function skillTemplate(schemaDir: string): string { + return `--- +name: dbmlgraph +description: Schema lookup for this project's database. Use when asked about table structure, columns, relationships, enums, which tables carry a field, or before writing SQL against the schema. +--- + +# dbmlgraph — schema knowledge graph + +${usageBody(schemaDir)} +`; +} + +export function agentsSection(schemaDir: string): string { + return `${MARKER_START} +## Database schema lookup (dbmlgraph) + +${usageBody(schemaDir)} +${MARKER_END}`; +} + +export function installClaude(o: { schemaDir: string; cwd: string; global?: boolean; homeDir?: string }): string { + const base = o.global + ? join(o.homeDir ?? homedir(), '.claude', 'skills', 'dbmlgraph') + : join(o.cwd, '.claude', 'skills', 'dbmlgraph'); + mkdirSync(base, { recursive: true }); + const file = join(base, 'SKILL.md'); + writeFileSync(file, skillTemplate(o.schemaDir)); + return file; +} diff --git a/test/install.test.ts b/test/install.test.ts new file mode 100644 index 0000000..74361b2 --- /dev/null +++ b/test/install.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveSchemaDir, installClaude, InstallError } from '../src/install.js'; + +let root: string; +beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dbmlgraph-install-')); }); +afterEach(() => { rmSync(root, { recursive: true, force: true }); }); + +const mkSchemaDir = (rel: string) => { + const dir = join(root, rel); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, '.dbmlgraph.yml'), 'input: schema.dbml\n'); + return dir; +}; + +describe('resolveSchemaDir', () => { + it('explicit --schema-dir wins, validated', () => { + const dir = mkSchemaDir('schema'); + expect(resolveSchemaDir('schema', root)).toBe(dir); + }); + + it('explicit dir without config throws', () => { + mkdirSync(join(root, 'empty')); + expect(() => resolveSchemaDir('empty', root)).toThrow(InstallError); + }); + + it('walks up from cwd to find .dbmlgraph.yml', () => { + const dir = mkSchemaDir('.'); + const nested = join(root, 'a/b'); + mkdirSync(nested, { recursive: true }); + expect(resolveSchemaDir(undefined, nested)).toBe(dir); + }); + + it('no config anywhere throws with hint', () => { + const nested = join(root, 'a'); + mkdirSync(nested); + expect(() => resolveSchemaDir(undefined, nested)).toThrow(/--schema-dir/); + }); +}); + +describe('installClaude', () => { + it('writes project skill with baked schema dir', () => { + const schemaDir = mkSchemaDir('schema'); + const file = installClaude({ schemaDir, cwd: root }); + expect(file).toBe(join(root, '.claude/skills/dbmlgraph/SKILL.md')); + const content = readFileSync(file, 'utf8'); + expect(content).toMatch(/^---\nname: dbmlgraph\n/); + expect(content).toContain(schemaDir); + expect(content).toContain('dbmlgraph find'); + expect(content).toContain('dbmlgraph query'); + }); + + it('--global writes under homeDir', () => { + const schemaDir = mkSchemaDir('schema'); + const home = join(root, 'home'); + const file = installClaude({ schemaDir, cwd: root, global: true, homeDir: home }); + expect(file).toBe(join(home, '.claude/skills/dbmlgraph/SKILL.md')); + expect(existsSync(file)).toBe(true); + }); + + it('re-install overwrites its own file only', () => { + const schemaDir = mkSchemaDir('schema'); + installClaude({ schemaDir, cwd: root }); + const other = join(root, '.claude/skills/other/SKILL.md'); + mkdirSync(join(root, '.claude/skills/other'), { recursive: true }); + writeFileSync(other, 'untouched'); + installClaude({ schemaDir: mkSchemaDir('schema2'), cwd: root }); + expect(readFileSync(join(root, '.claude/skills/dbmlgraph/SKILL.md'), 'utf8')).toContain('schema2'); + expect(readFileSync(other, 'utf8')).toBe('untouched'); + }); +}); From d52b4015967c26c3998edac8bd571befe2d15083 Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:41:01 +0700 Subject: [PATCH 4/8] feat(install): agents target, uninstall, --list --- src/cli.ts | 29 ++++++++++++++++-- src/install.ts | 45 +++++++++++++++++++++++++++ test/install.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index c8334b6..2494928 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,7 +11,7 @@ import { init } from './init.js'; import { query, QueryResolveError, MAX_DEPTH, DEFAULT_DEPTH, DEFAULT_BUDGET } from './query.js'; import { find } from './find.js'; import { loadConfig, pick, require_, ConfigFormatError } from './config.js'; -import { installClaude, resolveSchemaDir, InstallError } from './install.js'; +import { installClaude, installAgents, uninstall, installStatus, resolveSchemaDir, InstallError } from './install.js'; // comma-string flag → array, matching config's excludeColumns shape const asCols = (v: string | undefined): string[] | undefined => v?.split(','); @@ -144,13 +144,36 @@ program.command('install') .argument('[agent]', 'claude | agents') .option('--schema-dir ', 'directory containing .dbmlgraph.yml (default: walk up from cwd)') .option('--global', 'claude only: install to ~/.claude/skills instead of ./.claude/skills') + .option('--list', 'show install status for all targets') .action((agent: string | undefined, o) => { - if (agent !== 'claude') throw new InstallError(`unknown agent "${agent ?? ''}": expected claude | agents`); + if (o.list) { + const s = installStatus({ cwd: process.cwd() }); + console.log(`claude ${s.claude ? 'installed' : 'not installed'}${s.claudeGlobal ? ' (global installed)' : ''}`); + console.log(`agents ${s.agents ? 'installed' : 'not installed'}`); + return; + } + if (agent !== 'claude' && agent !== 'agents') { + throw new InstallError(`unknown agent "${agent ?? ''}": expected claude | agents`); + } const schemaDir = resolveSchemaDir(o.schemaDir, process.cwd()); - const file = installClaude({ schemaDir, cwd: process.cwd(), global: !!o.global }); + const file = agent === 'claude' + ? installClaude({ schemaDir, cwd: process.cwd(), global: !!o.global }) + : installAgents({ schemaDir, cwd: process.cwd() }); console.log(`wrote ${file}\nschema dir baked: ${schemaDir}`); }); +program.command('uninstall') + .description('remove AI-agent integration (claude | agents)') + .argument('', 'claude | agents') + .option('--global', 'claude only: remove from ~/.claude/skills instead of ./.claude/skills') + .action((agent: string, o) => { + if (agent !== 'claude' && agent !== 'agents') { + throw new InstallError(`unknown agent "${agent}": expected claude | agents`); + } + const removed = uninstall(agent, { cwd: process.cwd(), global: !!o.global }); + console.log(removed ? `removed ${removed}` : 'nothing installed'); + }); + try { program.parse(); } catch (e: any) { diff --git a/src/install.ts b/src/install.ts index a2e277b..40ee63e 100644 --- a/src/install.ts +++ b/src/install.ts @@ -84,3 +84,48 @@ export function installClaude(o: { schemaDir: string; cwd: string; global?: bool writeFileSync(file, skillTemplate(o.schemaDir)); return file; } + +const sectionRe = () => new RegExp(`${MARKER_START}[\\s\\S]*?${MARKER_END}\\n?`, ''); + +export function installAgents(o: { schemaDir: string; cwd: string }): string { + const file = join(o.cwd, 'AGENTS.md'); + const section = agentsSection(o.schemaDir); + if (!existsSync(file)) { + writeFileSync(file, `${section}\n`); + return file; + } + const cur = readFileSync(file, 'utf8'); + const next = sectionRe().test(cur) + ? cur.replace(sectionRe(), `${section}\n`) + : `${cur.trimEnd()}\n\n${section}\n`; + writeFileSync(file, next); + return file; +} + +export function uninstall(agent: 'claude' | 'agents', o: { cwd: string; global?: boolean; homeDir?: string }): string | null { + if (agent === 'claude') { + const base = o.global + ? join(o.homeDir ?? homedir(), '.claude', 'skills', 'dbmlgraph') + : join(o.cwd, '.claude', 'skills', 'dbmlgraph'); + if (!existsSync(base)) return null; + rmSync(base, { recursive: true, force: true }); + return base; + } + const file = join(o.cwd, 'AGENTS.md'); + if (!existsSync(file)) return null; + const cur = readFileSync(file, 'utf8'); + if (!sectionRe().test(cur)) return null; + const next = cur.replace(sectionRe(), ''); + if (next.trim() === '') rmSync(file); + else writeFileSync(file, next); + return file; +} + +export function installStatus(o: { cwd: string; homeDir?: string }): { claude: boolean; claudeGlobal: boolean; agents: boolean } { + const agentsFile = join(o.cwd, 'AGENTS.md'); + return { + claude: existsSync(join(o.cwd, '.claude', 'skills', 'dbmlgraph', 'SKILL.md')), + claudeGlobal: existsSync(join(o.homeDir ?? homedir(), '.claude', 'skills', 'dbmlgraph', 'SKILL.md')), + agents: existsSync(agentsFile) && sectionRe().test(readFileSync(agentsFile, 'utf8')), + }; +} diff --git a/test/install.test.ts b/test/install.test.ts index 74361b2..04a840a 100644 --- a/test/install.test.ts +++ b/test/install.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { resolveSchemaDir, installClaude, InstallError } from '../src/install.js'; +import { resolveSchemaDir, installClaude, InstallError, installAgents, uninstall, installStatus, MARKER_START, MARKER_END } from '../src/install.js'; let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dbmlgraph-install-')); }); @@ -71,3 +71,74 @@ describe('installClaude', () => { expect(readFileSync(other, 'utf8')).toBe('untouched'); }); }); + +describe('installAgents', () => { + it('creates AGENTS.md when marker section absent', () => { + const schemaDir = mkSchemaDir('schema'); + const file = installAgents({ schemaDir, cwd: root }); + const content = readFileSync(file, 'utf8'); + expect(content).toContain(MARKER_START); + expect(content).toContain(MARKER_END); + expect(content).toContain(schemaDir); + }); + + it('appends section to existing AGENTS.md, preserving user content', () => { + const schemaDir = mkSchemaDir('schema'); + writeFileSync(join(root, 'AGENTS.md'), '# project\n\nUser rules here.\n'); + installAgents({ schemaDir, cwd: root }); + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + expect(content).toContain('User rules here.'); + expect(content).toContain(MARKER_START); + }); + + it('re-install replaces existing section instead of duplicating', () => { + const schemaDir = mkSchemaDir('schema'); + installAgents({ schemaDir, cwd: root }); + installAgents({ schemaDir: mkSchemaDir('schema2'), cwd: root }); + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + expect(content.split(MARKER_START)).toHaveLength(2); + expect(content).toContain('schema2'); + }); +}); + +describe('uninstall', () => { + it('claude: removes project skill dir, returns removed path', () => { + const schemaDir = mkSchemaDir('schema'); + installClaude({ schemaDir, cwd: root }); + const removed = uninstall('claude', { cwd: root }); + expect(removed).toContain('.claude/skills/dbmlgraph'); + expect(existsSync(join(root, '.claude/skills/dbmlgraph'))).toBe(false); + }); + + it('claude: returns null when nothing installed', () => { + expect(uninstall('claude', { cwd: root })).toBeNull(); + }); + + it('agents: strips marker section, preserving user content', () => { + const schemaDir = mkSchemaDir('schema'); + writeFileSync(join(root, 'AGENTS.md'), '# Keep me\n'); + installAgents({ schemaDir, cwd: root }); + uninstall('agents', { cwd: root }); + const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + expect(content).toContain('# Keep me'); + expect(content).not.toContain(MARKER_START); + }); + + it('agents: deletes AGENTS.md when section was its only content', () => { + const schemaDir = mkSchemaDir('schema'); + installAgents({ schemaDir, cwd: root }); + uninstall('agents', { cwd: root }); + expect(existsSync(join(root, 'AGENTS.md'))).toBe(false); + }); +}); + +describe('installStatus', () => { + it('reports per-target install state', () => { + const home = join(root, 'home'); + expect(installStatus({ cwd: root, homeDir: home })).toEqual({ claude: false, claudeGlobal: false, agents: false }); + const schemaDir = mkSchemaDir('schema'); + installClaude({ schemaDir, cwd: root }); + installAgents({ schemaDir, cwd: root }); + expect(installStatus({ cwd: root, homeDir: home })).toEqual({ claude: true, claudeGlobal: false, agents: true }); + }); +}); From 8da82c95439eca80ba8f4f65d4acacd99279aaee Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:45:47 +0700 Subject: [PATCH 5/8] fix(install): align --list output and tests with plan --- src/cli.ts | 4 ++-- test/install.test.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 2494928..a3e4bbc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -148,8 +148,8 @@ program.command('install') .action((agent: string | undefined, o) => { if (o.list) { const s = installStatus({ cwd: process.cwd() }); - console.log(`claude ${s.claude ? 'installed' : 'not installed'}${s.claudeGlobal ? ' (global installed)' : ''}`); - console.log(`agents ${s.agents ? 'installed' : 'not installed'}`); + console.log(`claude ${s.claude ? 'installed' : (s.claudeGlobal ? 'installed (global)' : 'not installed')}`); + console.log(`agents ${s.agents ? 'installed' : 'not installed'}`); return; } if (agent !== 'claude' && agent !== 'agents') { diff --git a/test/install.test.ts b/test/install.test.ts index 04a840a..79c2143 100644 --- a/test/install.test.ts +++ b/test/install.test.ts @@ -87,6 +87,7 @@ describe('installAgents', () => { writeFileSync(join(root, 'AGENTS.md'), '# project\n\nUser rules here.\n'); installAgents({ schemaDir, cwd: root }); const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); + expect(content).toMatch(/^# project/); expect(content).toContain('User rules here.'); expect(content).toContain(MARKER_START); }); @@ -98,6 +99,7 @@ describe('installAgents', () => { const content = readFileSync(join(root, 'AGENTS.md'), 'utf8'); expect(content.split(MARKER_START)).toHaveLength(2); expect(content).toContain('schema2'); + expect(content).not.toContain(join(root, 'schema') + '`'); }); }); From 4e7e93ba96216c7bd25a2993c45fbca6ebdabcbe Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:53:53 +0700 Subject: [PATCH 6/8] =?UTF-8?q?chore(release):=200.6.0=20=E2=80=94=20find?= =?UTF-8?q?=20+=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 ++++++++++++++ README.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8362f..9255639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.6.0] - 2026-08-16 + +### Added +- `find ` — identifier search across all node kinds (tables, columns, enums, enum values incl. overlay value-sets, domains). Exact → glob → fuzzy precedence, `--strict`, `--format json`. +- `install claude [--global]` — generate a Claude Code skill file with the schema directory baked in. +- `install agents` — marker-delimited AGENTS.md section (covers Codex, opencode, and other AGENTS.md-standard agents). Idempotent re-install. +- `install --list`, `uninstall claude|agents`. + +## [0.5.0] - 2026-08-15 + +### Added +- `dbmlgraph query ` — context pack for agents: full nodes for the queried tables, one-line neighbor summaries, deduped merged rules, `--depth`/`--budget`/`--columns`/`--format`/`--strict`. +- `generate` now always writes `AGENTS.md` at the output root, a navigation landing doc for AI agents dropped into the directory. + ## [0.4.0] - 2026-08-03 ### Added diff --git a/README.md b/README.md index 76ec9a1..314cce5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ its whole point is the business-context layer a live connection can't provide. | Output split by grain (index / domain / table) | Yes | Per-table pages | No | | Lint for documentation gaps | Yes (`W001`–`W003`) | Yes (column comments) | No | | ER diagrams | Per-domain (Mermaid) | Yes (many formats) | No | +| Identifier search across the whole schema | Yes (`find`) | No | No | +| One-command agent wiring (Claude Code, AGENTS.md) | Yes (`install`) | No | No | | Primary job | Feed schema *meaning* to AI agents | Document a live DB in CI | Convert DBML ↔ SQL | Use `tbls` when you have a running database and want rich human documentation @@ -295,6 +297,58 @@ accuracy at 3.5–6x less context, and on the hardest rule-dependent task the pack arm produced the only fully-correct answer. Method, results, and the two design changes the benchmark forced: [docs/benchmark.md](docs/benchmark.md). +## Find an identifier + +`find` locates, `query` explains. Reach for `find` first when an agent doesn't yet know +the exact table/column/enum name it needs — it's a wide, cheap lookup across every node +kind (tables, columns, enums, enum values including overlay `values:` sets, domains), not +a deep dump of one table's context. + +```bash +dbmlgraph find cycle_id # every column named cycle_id, across every table +dbmlgraph find KICKOFF # an enum or enum-value hit — DBML enum or overlay values: set +dbmlgraph find planning_cycle # a table hit (plus anything else matching the name) +dbmlgraph find "ratio_*" # glob — quote it so the shell doesn't expand * +``` + +Matching runs in precedence order and stops at the first level that finds something: +exact name (case-insensitive counts as exact) → glob (a term containing `*`) → fuzzy +(edit distance ≤ 2), unless `--strict` is set. Multiple terms in one invocation are +matched independently and their results concatenated. + +| flag | default | meaning | +|---|---|---| +| `--format md\|json` | `md` | `json` mirrors the same hit groups as keys | +| `--strict` | off | exact + glob only — skip the fuzzy fallback | + +## Install AI-agent integration + +`install` wires an agent up to `find`/`query` so it reaches for the schema graph +instead of grepping markdown or DDL by hand. + +```bash +dbmlgraph install claude [--global] # writes .claude/skills/dbmlgraph/SKILL.md +dbmlgraph install agents # writes/updates a marker-delimited AGENTS.md section +dbmlgraph install --list # show install status for all targets +dbmlgraph uninstall claude|agents [--global] +``` + +`install claude` writes a Claude Code skill file — local by default (`./.claude/skills/`), +or `~/.claude/skills/` with `--global`. `install agents` writes a +`` … `` section into `AGENTS.md` at the +project root, covering Codex, opencode, and any other agent that reads the AGENTS.md +standard. Both installs bake in the resolved schema directory so the agent never has to +guess `-i`/`--overlays` — one absolute `cd` in the generated instructions. + +Schema directory resolution: `--schema-dir ` if given, otherwise walk up from the +current directory looking for a `.dbmlgraph.yml`; no match in either case is an error. + +`install agents` is idempotent — re-running it replaces only the content between the +markers, so re-install after a schema move never duplicates the section or disturbs +anything else in `AGENTS.md`. `uninstall claude` removes the skill directory; +`uninstall agents` removes the marked section (and the whole file if nothing else is +in it). + ## Roadmap Ideas for future versions, roughly ordered by leverage. None are promises — diff --git a/package.json b/package.json index 672c6bc..fa15bd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dbmlgraph", - "version": "0.5.0", + "version": "0.6.0", "description": "DBML → LLM-ready markdown knowledge graph (index, per-table, per-domain) + RAG export", "type": "module", "license": "MIT", From 3bcd1217bfd7175682ff94c2fdd5fc32c38d51a7 Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:03:25 +0700 Subject: [PATCH 7/8] docs(changelog): cover unreleased query/parse features in 0.6.0 --- CHANGELOG.md | 2 ++ README.md | 24 +++++++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9255639..f2176b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `install claude [--global]` — generate a Claude Code skill file with the schema directory baked in. - `install agents` — marker-delimited AGENTS.md section (covers Codex, opencode, and other AGENTS.md-standard agents). Idempotent re-install. - `install --list`, `uninstall claude|agents`. +- `-i/--input` now accepts DBML embedded in a Markdown file — extracts ```` ```dbml ```` fences (or, for `.md`/`.markdown` files, generic fences whose body looks like DBML), so a `.dbml` schema living inside an Obsidian/docs note can be pointed at directly. Parse errors are cleaner: file path, line number, and a hint when a markdown input has no fence to extract. +- `query` neighbors now carry a `keys:` line (PK flag, FK target table(s), enum type) so a solver can join through a neighbor without opening its file, and the default `--budget` is adaptive — sized to fit every depth-1 neighbor in full — so a hub table's direct partners no longer degrade on a plain invocation. Under pressure, neighbors degrade (lose their `keys:` line) before they're dropped, and depth-1 direct FK partners are never dropped even if the pack ships oversize. ## [0.5.0] - 2026-08-15 diff --git a/README.md b/README.md index 314cce5..4bb1a66 100644 --- a/README.md +++ b/README.md @@ -272,23 +272,29 @@ built for pasting into an agent, so it never has to guess which files to open: dbmlgraph query order_items orders -i schema.dbml --overlays overlays/ ``` -Full nodes for the queried tables, then one-line summaries of their neighbors, -then merged rules — deduped across the whole pack. A queried table never shows up -as its own neighbor, a neighbor shared by two queried tables prints once, and -repeated FKs between the same pair collapse with a `(2 refs collapsed)` note. -The footer names the tables just outside the pack and the command that fetches them. +Full nodes for the queried tables, then a one-line hook per neighbor plus its +`keys:` line (PK flag, FK target table(s), enum type — enough to join through +the neighbor without opening its file), then merged rules — deduped across the +whole pack. A queried table never shows up as its own neighbor, a neighbor +shared by two queried tables prints once, and repeated FKs between the same +pair collapse with a `(2 refs collapsed)` note. The footer names the tables +just outside the pack and the command that fetches them. | flag | default | meaning | |---|---|---| | `--depth ` | `1` | neighbor hops, capped at 2. `0` drops the Neighbors section | -| `--budget ` | adaptive | by default the cap is sized to fit the queried nodes and every depth-1 neighbor in full (min `4000`), so a hub table is never degraded on a plain invocation; pass a number for a hard cap. Neighbors are ranked (distance, then FK degree, then name) and cut from the tail; queried nodes are never cut | +| `--budget ` | adaptive | by default the cap is sized to fit the queried nodes and every depth-1 neighbor's hook line + `keys:` line in full (min `4000`), so a hub table's direct partners are never degraded on a plain invocation; pass a number for a hard cap. Under pressure, neighbors degrade before they drop: depth-2+ entries lose their `keys:` line first (a direct depth-1 FK partner never does under the adaptive default), and only once nothing more can be trimmed does the tail get cut, ranked by distance then FK degree then name — queried nodes are never cut or degraded | | `--columns key\|all` | `key` | `all` gives each neighbor a compact PK/FK/enum column table | -| `--format md\|json` | `md` | `json` mirrors the same sections as keys | +| `--format md\|json` | `md` | `json` mirrors the same sections as keys, plus `resolvedBudget` (the adaptive value actually used) | | `--strict` | off | exact names only. Without it, a close name (case, or edit distance ≤2) is accepted when unambiguous | Truncation is never silent — a cut list always ends in a counted -`… and N more (raise --budget or --depth)` label. An unresolvable name exits `1` -after printing the three closest table names. +`… and N more (raise --budget or --depth)` label, and a run of key-column +degradation ends in `(N neighbors shown without columns — raise --budget)`. +If even that isn't enough room for every direct relationship, the pack ships +oversize rather than dropping one, with `(budget exceeded to preserve direct +relationships)` noting why. An unresolvable name exits `1` after printing the +three closest table names. Does the pack actually work? We benchmarked it blind against a 70-table production schema: fresh agent sessions wrote SQL from either a pack (4–8k From f4caff0f3375c8b612dbad6f58c244cf418af2a2 Mon Sep 17 00:00:00 2001 From: verryp <32357603+verryp@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:28:07 +0700 Subject: [PATCH 8/8] fix(find): DBML enum-value note fallback + domain kind coverage DBML enum-value hits now fall back to the EnumDef value's `note` when no overlay meaning covers it (matches render/table.ts:58 behavior), instead of silently dropping it. Column entries stay overlay-only per spec. Added coverage for the previously-untested `domain` FindKind path (usedBy resolves to member tables, md renderer prints "## Domain matches"). --- src/find.ts | 2 +- test/find.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/find.ts b/src/find.ts index 7c5bcf1..b289c54 100644 --- a/src/find.ts +++ b/src/find.ts @@ -68,7 +68,7 @@ export function buildFindIndex(ir: IR): IndexEntry[] { for (const v of e.values) { entries.push({ key: v.name, hit: { kind: 'enum_value', match: 'exact', name: v.name, enum: e.name, - usedBy: enumUsers.get(e.name) ?? [], meaning: v.meaning ?? undefined, + usedBy: enumUsers.get(e.name) ?? [], meaning: v.meaning ?? v.note ?? undefined, }}); } } diff --git a/test/find.test.ts b/test/find.test.ts index 7410062..4f37bce 100644 --- a/test/find.test.ts +++ b/test/find.test.ts @@ -46,6 +46,23 @@ describe('findHits — matching', () => { expect(v.usedBy).toEqual(['order_items.item_type']); }); + it('DBML enum value falls back to DBML note when no overlay meaning covers it', () => { + // fixture: `PHYSICAL [note: 'shipped goods']`, no overlay for item_type_enum values + const hits = findHits(ir, 'PHYSICAL', false); + const v = hits.find(h => h.kind === 'enum_value')!; + expect(v.meaning).toBe('shipped goods'); + }); + + it('DBML enum value prefers overlay meaning over the DBML note when both exist', () => { + const withOverlay = fixtureIr(); + const enumDef = withOverlay.enums.find(e => e.name === 'item_type_enum')!; + const val = enumDef.values.find(v => v.name === 'PHYSICAL')!; + val.meaning = 'overlay wins'; + const hits = findHits(withOverlay, 'PHYSICAL', false); + const v = hits.find(h => h.kind === 'enum_value')!; + expect(v.meaning).toBe('overlay wins'); + }); + it('overlay value-set value matches as enum_value (the KICKOFF case)', () => { const hits = findHits(ir, 'ACTIVE', false); const v = hits.find(h => h.kind === 'enum_value')!; @@ -83,6 +100,14 @@ describe('findHits — matching', () => { const hits = findHits(ir, 'ACTIVE', false).filter(h => h.kind === 'enum_value'); expect(hits).toHaveLength(1); }); + + it('domain exact match resolves usedBy to member tables', () => { + // fixture has no TableGroup/banner, so all tables land in the default 'ungrouped' domain + const hits = findHits(ir, 'ungrouped', false); + const d = hits.find(h => h.kind === 'domain')!; + expect(d.name).toBe('ungrouped'); + expect(d.usedBy?.sort()).toEqual(['cycles', 'order_items', 'orders']); + }); }); describe('find — rendering', () => { @@ -146,4 +171,10 @@ describe('find — rendering', () => { const out = JSON.parse(find(ir, { terms: ['orders', 'PHYSICAL'], format: 'json' })); expect(new Set(out.map((h: any) => h.term))).toEqual(new Set(['orders', 'PHYSICAL'])); }); + + it('md: domain hit prints Domain matches header with table count', () => { + const md = find(ir, { terms: ['ungrouped'] }); + expect(md).toContain('## Domain matches'); + expect(md).toContain('- ungrouped — 3 tables'); + }); });