@event4u/agent-config 17.1.0 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +70 -0
  5. package/README.md +2 -2
  6. package/dist/agent-src/commands/project/analyze.md +9 -6
  7. package/dist/agent-src/commands/project/map.md +58 -0
  8. package/dist/agent-src/commands/project.md +5 -3
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +23 -39
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +39 -13
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +3 -3
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +6 -5
  20. package/dist/discovery/trust-report.md +2 -2
  21. package/dist/discovery/workspaces.json +5 -4
  22. package/dist/hooks/dispatch.js +65 -74
  23. package/dist/hooks/dispatch.sha256 +1 -1
  24. package/dist/hooks/instructions_observer.chunk.cjs +301 -0
  25. package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
  26. package/dist/mcp/registry-manifest.json +2 -2
  27. package/dist/mcp/server.json +2 -2
  28. package/dist/mcp/server.mjs +33058 -22930
  29. package/package.json +4 -1
  30. package/src/config/continuity-surface.json +7 -0
  31. package/src/config/evaluator-budgets.json +4 -4
  32. package/src/config/gate-violation-baselines.json +3 -3
  33. package/src/config/gitignore-block.txt +6 -0
  34. package/src/config/hook-bundle-budget.json +17 -2
  35. package/src/config/hook-token-budget.json +1 -1
  36. package/src/config/packed-binary-manifest.json +27 -0
  37. package/src/config/skill-body-budget.json +7 -1
  38. package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
  39. package/src/scripts/_lib/collector_record.ts +1 -0
  40. package/src/scripts/_lib/runtime_journal.ts +7 -7
  41. package/src/scripts/code_graph/build.ts +210 -10
  42. package/src/scripts/code_graph/cli.ts +168 -46
  43. package/src/scripts/code_graph/detect.ts +5 -4
  44. package/src/scripts/code_graph/docs_layer.ts +57 -0
  45. package/src/scripts/code_graph/extract.ts +58 -13
  46. package/src/scripts/code_graph/extract_more.ts +314 -0
  47. package/src/scripts/code_graph/laravel_overlay.ts +454 -0
  48. package/src/scripts/code_graph/map.ts +846 -0
  49. package/src/scripts/code_graph/map_model.ts +977 -0
  50. package/src/scripts/code_graph/map_verify.ts +140 -0
  51. package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
  52. package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
  53. package/src/scripts/code_graph/scip_reader.ts +201 -0
  54. package/src/scripts/code_graph/sqlite_store.ts +8 -1
  55. package/src/scripts/code_graph/types.ts +81 -15
  56. package/src/scripts/code_graph/validate.ts +16 -26
  57. package/src/scripts/code_graph/verbs.ts +7 -0
  58. package/src/scripts/hook_chunks.mjs +7 -0
  59. package/src/scripts/hook_manifest.json +1 -1
  60. package/src/scripts/hook_manifest.yaml +20 -0
  61. package/src/scripts/hooks/dispatch_hook.ts +2 -2
  62. package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
  63. package/src/scripts/hooks/lazy_concern.ts +4 -0
  64. package/src/scripts/install-hooks.sh +5 -0
  65. package/src/scripts/lint_agents_layout.ts +2 -0
  66. package/src/scripts/lint_hook_manifest.ts +1 -0
  67. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
  68. package/src/scripts/mcp_server/graph_tools.ts +73 -1
  69. package/src/scripts/mcp_server/tools.ts +19 -36
  70. package/src/vendor/grammars/README.md +10 -5
  71. package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
  72. package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
  73. package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
@@ -47,6 +47,12 @@
47
47
  * 0 ok · 1 not-found / validation-failed / refresh-budget-exceeded ·
48
48
  * 2 usage · 3 internal.
49
49
  *
50
+ * map [--root P] [--out DIR] [--open] draw the project map into
51
+ * [--no-verify] [--diff <rev>] agents/map/ (ADR-297); renders it
52
+ * [--prs] (open PRs by area, via gh)
53
+ * at three widths when Playwright
54
+ * is present
55
+ *
50
56
  * suggest-verb "<question>" (Phase 9 D3) print the verb
51
57
  * (query/affected/path/explain)
52
58
  * a regex table thinks best
@@ -62,10 +68,20 @@ import * as path from 'node:path';
62
68
  import process from 'node:process';
63
69
  import { fileURLToPath } from 'node:url';
64
70
 
71
+ import { resolve_project_root } from '../_lib/agent_settings.js';
65
72
  import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
66
73
  import { buildFromRepo, isTestFile, sidecarPath } from './build.js';
67
- import { computeVerdict, detectSources, type GraphState, graphState, pickSource } from './detect.js';
74
+ import {
75
+ computeVerdict,
76
+ detectSources,
77
+ type GraphState,
78
+ graphState,
79
+ NATIVE_CACHE_REL,
80
+ pickSource,
81
+ } from './detect.js';
68
82
  import { suggestVerb } from './intent.js';
83
+ import { buildMap, recordOutcome } from './map.js';
84
+ import { verifyMap } from './map_verify.js';
69
85
  import {
70
86
  affected,
71
87
  explain,
@@ -94,7 +110,16 @@ import {
94
110
 
95
111
  const _HERE = fileURLToPath(import.meta.url);
96
112
  export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..', '..');
97
- export const DEFAULT_CACHE = path.join(REPO_ROOT, 'agents', 'runtime', 'state', 'code-graph-v1.json');
113
+ /**
114
+ * The cache belongs to the project it describes: `<root>/agents/runtime/state/`,
115
+ * the path MCP, the context hook and `detect` already read. Anchoring it to
116
+ * the package tree put a consumer's graph inside `node_modules`, where `npm ci`
117
+ * deleted it, two projects sharing an install overwrote each other, and MCP
118
+ * answered "no graph" right after the CLI reported a fresh one.
119
+ */
120
+ export function cacheFor(root: string): string {
121
+ return path.join(root, NATIVE_CACHE_REL);
122
+ }
98
123
  /** Local `tsx` binary — `refresh` spawns `build` as a child of THIS file
99
124
  * through it, so a budget-exceeded kill only ever terminates the child. */
100
125
  const TSX_BIN = path.join(REPO_ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
@@ -106,47 +131,52 @@ function flag(argv: string[], name: string): string | null {
106
131
  }
107
132
 
108
133
  /**
109
- * Resolve the repository to operate on. `--root` > `AGENT_CONFIG_PROJECT_ROOT` >
110
- * this module's own tree.
111
- *
112
- * The middle rung is the whole point, and its absence was a confused deputy:
113
- * `_dispatch.bash` parses `--root` GLOBALLY, strips it from argv, and exports
114
- * `AGENT_CONFIG_PROJECT_ROOT` (`:1655`) — then this engine read neither and fell
115
- * back to its own module path. So `agent-config code-graph build --root <other
116
- * repo>` silently indexed THIS package. The dispatcher made the decision and the
117
- * engine substituted its own, which is why every consumer-facing invocation was
118
- * wrong about which repository it had just read.
134
+ * Resolve the repository to operate on: `--root` > the shared project-root
135
+ * resolver (`AGENT_CONFIG_PROJECT_ROOT`, then the anchor walk from `cwd`, then
136
+ * `cwd`). The package tree is never a fallback — in a consumer it is
137
+ * `node_modules/@event4u/agent-config`, and indexing it answered every
138
+ * question about the wrong repository.
119
139
  *
120
140
  * `realpathSync` rather than `resolve` alone: a symlinked root must normalise to
121
- * what will actually be walked, or the path a caller sees in the output is not
122
- * the path that was indexed. Fail-loud on a root that does not exist — a silent
123
- * fallback to the package tree is the defect this function exists to remove, and
124
- * reintroducing it for a typo'd path would be the same bug wearing a smaller
125
- * hat.
126
- *
127
- * Deliberately NOT touching `DEFAULT_CACHE`, which is anchored to `REPO_ROOT`
128
- * for the same reason and is a separate decision: it is a WRITE path, with its
129
- * own ownership, cleanup, concurrency and multi-repo namespacing questions.
130
- * Council 2026-08-22, both seats, explicitly out of scope here.
141
+ * what will actually be walked. A root that does not exist is returned
142
+ * unresolved, so the caller's existence check prints the path it was given.
131
143
  */
132
144
  function resolveRoot(argv: string[]): string {
133
145
  const explicit = flag(argv, '--root');
134
- const fromDispatcher = process.env['AGENT_CONFIG_PROJECT_ROOT'];
135
- const candidate =
136
- explicit ?? (fromDispatcher !== undefined && fromDispatcher !== '' ? fromDispatcher : REPO_ROOT);
146
+ let candidate: string;
147
+ if (explicit !== null) {
148
+ candidate = explicit;
149
+ } else {
150
+ try {
151
+ candidate = resolve_project_root(null)[0];
152
+ } catch {
153
+ candidate = process.env['AGENT_CONFIG_PROJECT_ROOT'] ?? process.cwd();
154
+ }
155
+ }
137
156
  try {
138
157
  return fs.realpathSync(path.resolve(candidate));
139
158
  } catch {
140
- // Returned unresolved on purpose: every caller checks existence and
141
- // prints the path it was given, which is more useful in the error than
142
- // a normalised form of a path that does not exist.
143
159
  return path.resolve(candidate);
144
160
  }
145
161
  }
146
162
 
163
+ /**
164
+ * A cache an older version wrote inside the package directory is named once
165
+ * per build, so a consumer can delete it. Never deleted here: the path is
166
+ * outside the project the user pointed the verb at.
167
+ */
168
+ function legacyCacheNotice(root: string): string {
169
+ const legacy = cacheFor(REPO_ROOT);
170
+ // Only an installed package is a wrong home for a cache; a source checkout
171
+ // of this repository legitimately keeps its own.
172
+ if (!REPO_ROOT.split(path.sep).includes('node_modules')) return '';
173
+ if (path.resolve(REPO_ROOT) === path.resolve(root) || !fs.existsSync(legacy)) return '';
174
+ return ` note: a stale cache from an older version sits inside the package: ${legacy} — safe to delete\n`;
175
+ }
176
+
147
177
  async function cmdBuild(argv: string[]): Promise<number> {
148
178
  const root = resolveRoot(argv);
149
- const out = argv.includes('--out') ? flag(argv, '--out') : DEFAULT_CACHE;
179
+ const out = argv.includes('--out') ? flag(argv, '--out') : cacheFor(root);
150
180
  if (!fs.existsSync(root)) {
151
181
  process.stderr.write(`code-graph: root not found: ${root}\n`);
152
182
  return 1;
@@ -160,7 +190,8 @@ async function cmdBuild(argv: string[]): Promise<number> {
160
190
  ` edges: EXTRACTED ${c.EXTRACTED} · INFERRED ${c.INFERRED} · AMBIGUOUS ${c.AMBIGUOUS}\n` +
161
191
  ` resolved_via: ${resolvedViaHistogram(graph)}\n` +
162
192
  (update ? ` incremental: ${reExtracted} re-extracted · ${reused} reused\n` : '') +
163
- (out ? ` cache: ${path.relative(REPO_ROOT, out)}\n` : ''),
193
+ (out ? ` cache: ${path.relative(root, out)}\n` : '') +
194
+ legacyCacheNotice(root),
164
195
  );
165
196
  return 0;
166
197
  }
@@ -186,7 +217,8 @@ function resolvedViaHistogram(graph: CodeGraph): string {
186
217
  }
187
218
 
188
219
  function cmdValidate(argv: string[]): number {
189
- const p = flag(argv, '--graph') ?? DEFAULT_CACHE;
220
+ const root = resolveRoot(argv);
221
+ const p = flag(argv, '--graph') ?? cacheFor(root);
190
222
  if (!fs.existsSync(p)) {
191
223
  process.stderr.write(`code-graph: graph not found: ${p}\n`);
192
224
  return 1;
@@ -215,12 +247,12 @@ function cmdValidate(argv: string[]): number {
215
247
  // content-addressed cache key, so agreement here means the twin was emitted
216
248
  // from THIS graph and not merely from a graph.
217
249
  const twinState = twinChecksumState(p, (parsed as CodeGraph).source_checksum);
218
- const rel = path.relative(REPO_ROOT, p);
250
+ const rel = path.relative(root, p);
219
251
  if (twinState.kind === 'mismatch') {
220
252
  process.stderr.write(
221
253
  `❌ SQLite twin disagrees with the canonical JSON: twin ${twinState.twin} ≠ json ${twinState.json}\n` +
222
254
  ' The twin is derived and disposable — delete it and re-run `code-graph build`.\n' +
223
- ` ${path.relative(REPO_ROOT, sqliteTwinPath(p))}\n`,
255
+ ` ${path.relative(root, sqliteTwinPath(p))}\n`,
224
256
  );
225
257
  return 1;
226
258
  }
@@ -257,13 +289,13 @@ function resolveGraph(argv: string[]): { g: LoadedGraph; note: string } | { err:
257
289
  const root = resolveRoot(argv);
258
290
  if (explicit) {
259
291
  if (!fs.existsSync(explicit)) return { err: `graph not found: ${explicit}` };
260
- return { g: loadGraph(explicit, path.relative(REPO_ROOT, explicit)), note: '' };
292
+ return { g: loadGraph(explicit, path.relative(root, explicit)), note: '' };
261
293
  }
262
- const picked = pickSource(detectSources(root, DEFAULT_CACHE));
294
+ const picked = pickSource(detectSources(root, cacheFor(root)));
263
295
  if (!picked) return { err: 'no code-graph source found — run `code_graph build` or ship a graph.json' };
264
- if (picked.kind === 'scip') return { err: `${picked.note ?? 'SCIP detected'} — pass --graph <json> or install peer tooling` };
296
+ if (picked.kind === 'scip') return { err: `${picked.note ?? 'SCIP detected'} — run \`code-graph build\` to merge it into the native graph` };
265
297
  const staleNote = picked.stale ? ` ⚠ index is ${picked.commits_behind ?? 'N'} commit(s) behind — rebuild before trusting` : '';
266
- return { g: loadGraph(picked.path, `${picked.kind}:${path.relative(REPO_ROOT, picked.path)}`), note: staleNote };
298
+ return { g: loadGraph(picked.path, `${picked.kind}:${path.relative(root, picked.path)}`), note: staleNote };
267
299
  }
268
300
 
269
301
  function render(r: QueryResult, staleNote: string): number {
@@ -376,8 +408,8 @@ function isFlagValue(argv: string[], token: string): boolean {
376
408
  *
377
409
  * `answeredBy` is `resolveGraph`'s own picked path when it has one. Without it
378
410
  * the two disagreed on the case `resolveRoot`'s docstring exists for: with
379
- * `--root <other repo>` the verb answers from `DEFAULT_CACHE` (anchored to the
380
- * package tree) while `graphState` looked under the OTHER root, so the printed
411
+ * `--root <other repo>` the verb answered from a cache anchored to the
412
+ * package tree while `graphState` looked under the OTHER root, so the printed
381
413
  * staleness described a cache the answer never came from. An independent review
382
414
  * found the `--graph` half fixed and this half open.
383
415
  */
@@ -579,17 +611,17 @@ function cmdDetect(argv: string[]): number {
579
611
  if (flag(argv, '--format') === 'json') {
580
612
  // Machine-readable three-state verdict (Phase 2). Existing human-text
581
613
  // output below is unchanged for existing consumers.
582
- process.stdout.write(`${JSON.stringify(computeVerdict(root, DEFAULT_CACHE))}\n`);
614
+ process.stdout.write(`${JSON.stringify(computeVerdict(root, cacheFor(root)))}\n`);
583
615
  return 0;
584
616
  }
585
- const verdicts = detectSources(root, DEFAULT_CACHE);
617
+ const verdicts = detectSources(root, cacheFor(root));
586
618
  if (!verdicts.length) {
587
619
  process.stdout.write('no code-graph source detected\n');
588
620
  return 1;
589
621
  }
590
622
  for (const v of verdicts) {
591
623
  const fresh = v.stale === undefined ? '' : v.stale ? ` · STALE${v.commits_behind !== undefined ? ` (${v.commits_behind} behind)` : ''}` : ' · fresh';
592
- process.stdout.write(`${v.kind.padEnd(9)} ${path.relative(REPO_ROOT, v.path)}${fresh}${v.note ? ` · ${v.note}` : ''}\n`);
624
+ process.stdout.write(`${v.kind.padEnd(9)} ${path.relative(root, v.path)}${fresh}${v.note ? ` · ${v.note}` : ''}\n`);
593
625
  }
594
626
  const picked = pickSource(verdicts);
595
627
  if (picked) process.stdout.write(`→ query would use: ${picked.kind}\n`);
@@ -644,7 +676,13 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
644
676
  }
645
677
  }
646
678
  const args = ['build', '--root', root, '--out', tmpOut, ...(update ? ['--update'] : [])];
647
- const res = spawnSync(TSX_BIN, [_HERE, ...args], {
679
+ // A hoisted consumer install has no `tsx` under the package's own
680
+ // `node_modules`; re-entering through this process's own loader flags works
681
+ // wherever the parent itself started.
682
+ const [cmd, cmdArgs] = fs.existsSync(TSX_BIN)
683
+ ? [TSX_BIN, [_HERE, ...args]]
684
+ : [process.execPath, [...process.execArgv, _HERE, ...args]];
685
+ const res = spawnSync(cmd, cmdArgs, {
648
686
  env: hardenedSpawnEnv(),
649
687
  stdio: 'ignore',
650
688
  timeout: Math.round(budgetSeconds * 1000),
@@ -670,13 +708,13 @@ function runBudgetedBuild(root: string, out: string, update: boolean, budgetSeco
670
708
  fs.renameSync(tmpOut, out); // same-dir → atomic; real cache only ever changes here
671
709
  const tmpSidecar = sidecarPath(tmpOut);
672
710
  if (fs.existsSync(tmpSidecar)) fs.renameSync(tmpSidecar, sidecarPath(out));
673
- process.stdout.write(`✅ code-graph refreshed — cache: ${path.relative(REPO_ROOT, out)}\n`);
711
+ process.stdout.write(`✅ code-graph refreshed — cache: ${path.relative(root, out)}\n`);
674
712
  return 0;
675
713
  }
676
714
 
677
715
  function cmdRefresh(argv: string[]): number {
678
716
  const root = resolveRoot(argv);
679
- const out = flag(argv, '--out') ?? DEFAULT_CACHE;
717
+ const out = flag(argv, '--out') ?? cacheFor(root);
680
718
  const budgetSeconds = budgetSecondsOf(argv);
681
719
  if (!fs.existsSync(root)) {
682
720
  process.stderr.write(`code-graph: root not found: ${root}\n`);
@@ -690,6 +728,88 @@ function cmdRefresh(argv: string[]): number {
690
728
  return runBudgetedBuild(root, out, v.verdict === 'STALE', budgetSeconds);
691
729
  }
692
730
 
731
+ /**
732
+ * `map` — draw the project map into `agents/map/` (ADR-297): `index.html`,
733
+ * `MAP.md`, one page per area and the `map.json` they are rendered from.
734
+ * Builds or updates the graph first when it is absent or behind.
735
+ */
736
+ async function cmdMap(argv: string[]): Promise<number> {
737
+ const root = resolveRoot(argv);
738
+ if (!fs.existsSync(root)) {
739
+ process.stderr.write(`code-graph: root not found: ${root}\n`);
740
+ return 1;
741
+ }
742
+ const out = flag(argv, '--out');
743
+ const diffRev = flag(argv, '--diff');
744
+ const r = await buildMap(root, {
745
+ ...(out ? { outDir: path.resolve(out) } : {}),
746
+ ...(diffRev !== null ? { diffRev } : {}),
747
+ ...(argv.includes('--prs') ? { prs: true } : {}),
748
+ ...(argv.includes('--no-build') ? { noBuild: true } : {}),
749
+ });
750
+ const rel = path.relative(process.cwd(), r.outDir) || '.';
751
+ const largest = r.model.areas.find((a) => a.kind === 'area');
752
+ process.stdout.write(
753
+ `✅ code-graph map — ${r.meta.summary}\n` +
754
+ ` graph: ${r.graphBuilt ? 'rebuilt' : 'reused'} · state ${r.meta.state} · commit ${r.meta.commit}\n` +
755
+ (largest ? ` largest area: ${largest.name} (${largest.files.length} files)\n` : '') +
756
+ (r.model.diff
757
+ ? ` diff ${r.model.diff.rev}: ${r.model.diff.changed.length} changed · ${r.model.diff.impacted.length} linked into them · ${r.model.diff.areas.length} area(s) touched\n`
758
+ : '') +
759
+ ` wrote: ${r.written.length} files under ${rel}/ — open ${path.join(rel, 'index.html')}\n`,
760
+ );
761
+ let rc = 0;
762
+ if (argv.includes('--no-verify')) {
763
+ // A check from an earlier map must not sit beside this one as if it were about it.
764
+ fs.rmSync(path.join(r.outDir, 'map-render.json'), { force: true });
765
+ } else {
766
+ const check = await verifyMap(path.join(r.outDir, 'index.html'));
767
+ fs.writeFileSync(path.join(r.outDir, 'map-render.json'), `${JSON.stringify(check, null, 2)}\n`);
768
+ const problems = check.viewports
769
+ .flatMap((v) => [
770
+ ...(v.pageErrors ? [`${v.name}: ${v.pageErrors} page error(s)`] : []),
771
+ ...(v.externalRequests ? [`${v.name}: ${v.externalRequests} network request(s)`] : []),
772
+ ...(v.horizontalScroll ? [`${v.name}: horizontal scroll`] : []),
773
+ ...v.views.filter((x) => x.outside || x.overlapping).map((x) => `${v.name}/${x.view}: ${x.outside} label(s) cut off, ${x.overlapping} overlapping`),
774
+ ])
775
+ .join('; ');
776
+ process.stdout.write(
777
+ ` render check: ${check.verification}` +
778
+ (check.reason ? ` — ${check.reason}` : '') +
779
+ (problems ? ` — ${problems}` : check.verification === 'verified' ? ` — ${check.viewports.map((v) => v.name).join(', ')}` : '') +
780
+ '\n',
781
+ );
782
+ if (check.verification === 'failed') rc = 1;
783
+ }
784
+ if (argv.includes('--open')) {
785
+ const { default: open } = await import('open');
786
+ await open(path.join(r.outDir, 'index.html'));
787
+ }
788
+ return rc;
789
+ }
790
+
791
+ /**
792
+ * `outcome <path> useful|dead-end` — record, locally, that a session found a
793
+ * file useful or a dead end. The map draws the time-decayed result as a ring;
794
+ * nothing leaves the machine and nothing is committed.
795
+ */
796
+ function cmdOutcome(argv: string[]): number {
797
+ const [file, outcome] = argv.filter((a) => !a.startsWith('--') && !isFlagValue(argv, a));
798
+ if (!file || (outcome !== 'useful' && outcome !== 'dead-end')) {
799
+ process.stderr.write('usage: code_graph/cli.ts outcome <path> useful|dead-end [--root P]\n');
800
+ return 2;
801
+ }
802
+ const root = resolveRoot(argv);
803
+ try {
804
+ recordOutcome(root, file, outcome);
805
+ } catch (e) {
806
+ process.stderr.write(`code-graph: ${(e as Error).message}\n`);
807
+ return 2;
808
+ }
809
+ process.stdout.write(`recorded: ${file} ${outcome}\n`);
810
+ return 0;
811
+ }
812
+
693
813
  /**
694
814
  * `suggest-verb "<question>"` (Phase 9 D3) — print the `code_graph` verb a
695
815
  * pure regex table thinks best answers a free-text structure question. A
@@ -719,9 +839,11 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
719
839
  if (sub === 'untested') return cmdUntested(argv.slice(1));
720
840
  if (sub === 'dead') return cmdDead(argv.slice(1));
721
841
  if (sub === 'suggest-verb') return cmdSuggestVerb(argv.slice(1));
842
+ if (sub === 'map') return await cmdMap(argv.slice(1));
843
+ if (sub === 'outcome') return cmdOutcome(argv.slice(1));
722
844
  process.stderr.write(
723
845
  'usage: code_graph/cli.ts <build|validate|detect|refresh|query|explain|affected|path|' +
724
- 'impact|tests-for|untested|dead|suggest-verb> [options]\n',
846
+ 'impact|tests-for|untested|dead|suggest-verb|map|outcome> [options]\n',
725
847
  );
726
848
  return 2;
727
849
  } catch (e) {
@@ -3,9 +3,9 @@
3
3
  * query use, and is it fresh?" across three sources:
4
4
  *
5
5
  * consumer — a `graph.json`-shaped artifact the consumer repo ships
6
- * scip — an `index.scip` / `*.scip` (presence only; peer tooling
7
- * required — an owned SCIP reader is YAGNI-gated until a
8
- * consumer actually ships one)
6
+ * scip — an `index.scip`, read into the native build as a provider of
7
+ * compiler-stated edges (`scip_reader.ts`); never queried on
8
+ * its own
9
9
  * native — the suite's own gitignored cache
10
10
  *
11
11
  * Precedence (ADR-124 § 2): a fresh consumer index wins (interop courtesy);
@@ -156,7 +156,8 @@ export function detectSources(root: string, nativeCache: string): SourceVerdict[
156
156
  /* ignore */
157
157
  }
158
158
  const scip = scipCandidates.find((p) => fs.existsSync(p));
159
- if (scip) out.push({ kind: 'scip', path: scip, present: true, note: 'SCIP detected — peer tooling required (no owned reader)' });
159
+ // Only `index.scip` is read; another `*.scip` is reported, not merged.
160
+ if (scip) out.push({ kind: 'scip', path: scip, present: true, note: 'SCIP — index.scip merges into the build' });
160
161
 
161
162
  // native cache
162
163
  if (fs.existsSync(nativeCache)) out.push({ kind: 'native', path: nativeCache, present: true, ...freshness(abs, nativeCache, null) });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The docs layer: a Markdown file that names a file gets an edge to it.
3
+ *
4
+ * Read by rule, not by a model (road-to-a-map-that-draws-itself 6.3, K10):
5
+ *
6
+ * [text](relative/or/rooted/path) a link, resolved from the document's directory
7
+ * `src/scripts/x.ts` · `x.ts:42` a backticked path, resolved repo-relative first
8
+ *
9
+ * An edge is emitted only when the named file is in the tree, so a link to a
10
+ * URL, an anchor, or a path that moved away produces nothing rather than a guess.
11
+ */
12
+ import * as path from 'node:path';
13
+
14
+ import type { SourceFile } from './build.js';
15
+ import type { CodeEdge, CodeNode } from './types.js';
16
+ import { MAX_FILE_BYTES } from './types.js';
17
+
18
+ export const DOC_EXTS: ReadonlySet<string> = new Set(['.md', '.mdx']);
19
+
20
+ function normal(p: string): string {
21
+ return path.posix.normalize(p).replace(/^\.\//, '');
22
+ }
23
+
24
+ export function docsLayer(docs: readonly SourceFile[], fileIds: ReadonlySet<string>): { nodes: CodeNode[]; edges: CodeEdge[] } {
25
+ const nodes: CodeNode[] = [];
26
+ const edges: CodeEdge[] = [];
27
+ for (const d of [...docs].sort((a, b) => (a.path < b.path ? -1 : 1))) {
28
+ nodes.push({ id: d.path, label: d.path, kind: 'file', source_file: d.path, source_location: [] });
29
+ if (Buffer.byteLength(d.source, 'utf-8') > MAX_FILE_BYTES) continue;
30
+ const dir = path.posix.dirname(d.path);
31
+ const targets = new Set<string>();
32
+ const consider = (raw: string, fromDocDir: boolean): void => {
33
+ const clean = raw.trim().replace(/[#?].*$/, '').replace(/:\d+(?:-\d+)?$/, '');
34
+ if (!clean || /^[a-z][a-z0-9+.-]*:/i.test(clean) || clean.includes('*')) return;
35
+ const candidates = clean.startsWith('/')
36
+ ? [normal(clean.slice(1))]
37
+ : fromDocDir
38
+ ? [normal(path.posix.join(dir, clean)), normal(clean)]
39
+ : [normal(clean), normal(path.posix.join(dir, clean))];
40
+ const hit = candidates.find((c) => !c.startsWith('..') && fileIds.has(c));
41
+ if (hit && hit !== d.path) targets.add(hit);
42
+ };
43
+ for (const m of d.source.matchAll(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g)) consider(m[1] as string, true);
44
+ for (const m of d.source.matchAll(/`([\w@./-]+\/[\w@.-]+\.[A-Za-z0-9]+(?::\d+(?:-\d+)?)?)`/g)) consider(m[1] as string, false);
45
+ for (const t of [...targets].sort()) {
46
+ edges.push({
47
+ source: d.path,
48
+ target: t,
49
+ relation: 'documents',
50
+ confidence: 'EXTRACTED',
51
+ resolved_via: 'directive-scan',
52
+ provider: 'native',
53
+ });
54
+ }
55
+ }
56
+ return { nodes, edges };
57
+ }
@@ -20,6 +20,7 @@ import {
20
20
  MAX_FILE_BYTES,
21
21
  type Relation,
22
22
  } from './types.js';
23
+ import { extractGo, extractJava, extractPython } from './extract_more.js';
23
24
  import { getParser, nodeHasError, type TsNode } from './loader.js';
24
25
 
25
26
  export type CallStyle = 'new' | 'free' | 'this' | 'self' | 'static' | 'parent' | 'scoped' | 'dynamic';
@@ -89,7 +90,7 @@ export interface FileExtract {
89
90
  parseError: boolean;
90
91
  }
91
92
 
92
- function loc(n: TsNode): number[] {
93
+ export function loc(n: TsNode): number[] {
93
94
  return [
94
95
  n.startPosition.row + 1,
95
96
  n.startPosition.column + 1,
@@ -98,7 +99,7 @@ function loc(n: TsNode): number[] {
98
99
  ];
99
100
  }
100
101
 
101
- function namedChildren(n: TsNode): TsNode[] {
102
+ export function namedChildren(n: TsNode): TsNode[] {
102
103
  const out: TsNode[] = [];
103
104
  for (let i = 0; i < n.namedChildCount; i += 1) {
104
105
  const c = n.namedChild(i);
@@ -117,7 +118,7 @@ function firstNamed(n: TsNode, types: ReadonlySet<string>): TsNode | null {
117
118
  return null;
118
119
  }
119
120
 
120
- function nameField(n: TsNode): string | null {
121
+ export function nameField(n: TsNode): string | null {
121
122
  const nm = n.childForFieldName('name');
122
123
  if (nm) return nm.text;
123
124
  return null;
@@ -131,6 +132,26 @@ function baseName(raw: string): string {
131
132
  return parts[parts.length - 1] ?? s;
132
133
  }
133
134
 
135
+ /**
136
+ * Ids are `${file}#${name}`, so two same-named declarations in one file — a
137
+ * `type X` beside a `const X`, a getter and its setter, two nested helpers both
138
+ * called `walk` — used to share one id. The SQLite twin's PRIMARY KEY then
139
+ * refused the insert and the index was silently never written. The second and
140
+ * later declarations get `@2`, `@3`, … in declaration order, which the walk
141
+ * visits deterministically.
142
+ */
143
+ const takenIds = new WeakMap<FileExtract, Map<string, number>>();
144
+ export function uniq(out: FileExtract, base: string): string {
145
+ let seen = takenIds.get(out);
146
+ if (!seen) {
147
+ seen = new Map();
148
+ takenIds.set(out, seen);
149
+ }
150
+ const n = (seen.get(base) ?? 0) + 1;
151
+ seen.set(base, n);
152
+ return n === 1 ? base : `${base}@${n}`;
153
+ }
154
+
134
155
  // ── PHP ──────────────────────────────────────────────────────────────────────
135
156
 
136
157
  function extractPhp(root: TsNode, file: string, out: FileExtract): void {
@@ -163,7 +184,7 @@ function extractPhp(root: TsNode, file: string, out: FileExtract): void {
163
184
  : n.type === 'interface_declaration'
164
185
  ? 'interface'
165
186
  : 'trait';
166
- const id = `${file}#${nm}`;
187
+ const id = uniq(out, `${file}#${nm}`);
167
188
  out.nodes.push({ id, label: nm, kind, source_file: file, source_location: loc(n) });
168
189
  out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
169
190
  // extends / implements
@@ -198,7 +219,7 @@ function extractPhp(root: TsNode, file: string, out: FileExtract): void {
198
219
  case 'method_declaration':
199
220
  case 'function_definition': {
200
221
  const nm = nameField(n) ?? '<anon>';
201
- const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
222
+ const id = uniq(out, classId ? `${classId}::${nm}` : `${file}#${nm}`);
202
223
  out.nodes.push({
203
224
  id,
204
225
  label: nm,
@@ -377,14 +398,14 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
377
398
  : n.type === 'type_alias_declaration'
378
399
  ? 'type'
379
400
  : 'enum';
380
- const id = `${file}#${nm}`;
401
+ const id = uniq(out, `${file}#${nm}`);
381
402
  out.nodes.push({ id, label: nm, kind, source_file: file, source_location: loc(n) });
382
403
  out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
383
404
  return;
384
405
  }
385
406
  case 'class_declaration': {
386
407
  const nm = nameField(n) ?? '<anon>';
387
- const id = `${file}#${nm}`;
408
+ const id = uniq(out, `${file}#${nm}`);
388
409
  out.nodes.push({ id, label: nm, kind: 'class', source_file: file, source_location: loc(n) });
389
410
  out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
390
411
  const heritage = firstNamed(n, new Set(['class_heritage']));
@@ -399,7 +420,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
399
420
  }
400
421
  case 'method_definition': {
401
422
  const nm = nameField(n) ?? '<anon>';
402
- const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
423
+ const id = uniq(out, classId ? `${classId}::${nm}` : `${file}#${nm}`);
403
424
  out.nodes.push({ id, label: nm, kind: 'method', source_file: file, source_location: loc(n) });
404
425
  out.rawEdges.push({
405
426
  sourceId: classId ?? fileId,
@@ -413,7 +434,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
413
434
  }
414
435
  case 'function_declaration': {
415
436
  const nm = nameField(n) ?? '<anon>';
416
- const id = `${file}#${nm}`;
437
+ const id = uniq(out, `${file}#${nm}`);
417
438
  out.nodes.push({ id, label: nm, kind: 'function', source_file: file, source_location: loc(n) });
418
439
  out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
419
440
  const body = n.childForFieldName('body');
@@ -457,7 +478,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
457
478
  // none of them is a declaration this graph can id.
458
479
  if (topLevel && nmNode?.type === 'identifier') {
459
480
  const nm = nmNode.text;
460
- const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
481
+ const id = uniq(out, classId ? `${classId}::${nm}` : `${file}#${nm}`);
461
482
  out.nodes.push({
462
483
  id,
463
484
  label: nm,
@@ -482,7 +503,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
482
503
  continue;
483
504
  }
484
505
  const nm = nameField(d) ?? '<anon>';
485
- const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
506
+ const id = uniq(out, classId ? `${classId}::${nm}` : `${file}#${nm}`);
486
507
  out.nodes.push({
487
508
  id,
488
509
  label: nm,
@@ -519,7 +540,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
519
540
  const value = n.childForFieldName('value');
520
541
  const nm = nameField(n) ?? '<anon>';
521
542
  if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
522
- const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
543
+ const id = uniq(out, classId ? `${classId}::${nm}` : `${file}#${nm}`);
523
544
  out.nodes.push({
524
545
  id,
525
546
  label: nm,
@@ -634,6 +655,27 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
634
655
  walk(root, null, fileId, true);
635
656
  }
636
657
 
658
+ /**
659
+ * A Vue single-file component reduced to its `<script>` blocks: every
660
+ * character outside them becomes a space and every newline stays, so the
661
+ * TypeScript grammar parses the script and every location still points at
662
+ * the line in the `.vue` file.
663
+ */
664
+ export function vueScript(source: string): string {
665
+ const keep = new Array<boolean>(source.length).fill(false);
666
+ for (const m of source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
667
+ const start = (m.index as number) + m[0].indexOf('>') + 1;
668
+ const end = start + (m[1] as string).length;
669
+ for (let i = start; i < end; i++) keep[i] = true;
670
+ }
671
+ let out = '';
672
+ for (let i = 0; i < source.length; i++) {
673
+ const c = source[i] as string;
674
+ out += keep[i] || c === '\n' ? c : ' ';
675
+ }
676
+ return out;
677
+ }
678
+
637
679
  /**
638
680
  * Extract one file. `source` is the file's UTF-8 content; `relPath` is the
639
681
  * repo-relative POSIX path used to build collision-free node ids.
@@ -651,10 +693,13 @@ export async function extractFile(relPath: string, source: string, lang: Lang):
651
693
  return out;
652
694
  }
653
695
  const parser = await getParser(lang);
654
- const tree = parser.parse(source);
696
+ const tree = parser.parse(relPath.endsWith('.vue') ? vueScript(source) : source);
655
697
  try {
656
698
  out.parseError = nodeHasError(tree.rootNode);
657
699
  if (lang === 'php') extractPhp(tree.rootNode, relPath, out);
700
+ else if (lang === 'python') extractPython(tree.rootNode, relPath, out);
701
+ else if (lang === 'java') extractJava(tree.rootNode, relPath, out);
702
+ else if (lang === 'go') extractGo(tree.rootNode, relPath, out);
658
703
  else extractTsJs(tree.rootNode, relPath, out);
659
704
  } finally {
660
705
  tree.delete(); // delete the tree, never the cached parser (loader.ts)