@event4u/agent-config 14.17.0 → 14.18.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 (74) 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 +170 -79
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +35 -44
  7. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  8. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  10. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  11. package/dist/agent-src/skills/design-review/SKILL.md +2 -0
  12. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  13. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  14. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  15. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  16. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  17. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
  18. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  19. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/cmd_conformance.js +335 -659
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +15 -15
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +1 -1
  26. package/dist/discovery/dormancy-report.md +1 -1
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +2 -2
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +2 -2
  31. package/dist/hooks/dispatch.js +959 -1304
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +671 -1002
  35. package/dist/shared/settingsConsent.js +1 -1
  36. package/package.json +1 -1
  37. package/src/config/conformance-claim-baseline.json +9 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/release-gate-locality.yml +129 -0
  40. package/src/scripts/_lib/release_highlights.ts +172 -1
  41. package/src/scripts/_lib/release_material.ts +66 -0
  42. package/src/scripts/_lib/repeated_run.ts +77 -0
  43. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  44. package/src/scripts/_lib/unified_diff.ts +121 -0
  45. package/src/scripts/check_finding_dispositions.ts +179 -3
  46. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  47. package/src/scripts/check_release_highlights.ts +25 -13
  48. package/src/scripts/check_suppression_hygiene.ts +11 -0
  49. package/src/scripts/code_graph/build.ts +230 -33
  50. package/src/scripts/code_graph/extract.ts +168 -33
  51. package/src/scripts/code_graph/types.ts +37 -2
  52. package/src/scripts/code_graph/validate.ts +12 -1
  53. package/src/scripts/conformance_scan.ts +2 -2
  54. package/src/scripts/detect_silent_catch.ts +403 -0
  55. package/src/scripts/detect_verification_tampering.ts +538 -0
  56. package/src/scripts/git_authorization_hook.ts +153 -17
  57. package/src/scripts/hook_manifest.json +1 -1
  58. package/src/scripts/hook_manifest.yaml +33 -19
  59. package/src/scripts/hooks/block_no_verify.ts +3 -3
  60. package/src/scripts/hooks/concern_registry.ts +2 -2
  61. package/src/scripts/hooks/evidence_independence.ts +21 -2
  62. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
  63. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  64. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  65. package/src/scripts/install-hooks.sh +134 -1
  66. package/src/scripts/language_mirror_hook.ts +27 -5
  67. package/src/scripts/release.ts +31 -143
  68. package/src/scripts/release_drill.ts +71 -1
  69. package/src/scripts/release_publication.ts +243 -3
  70. package/src/scripts/release_verify.ts +220 -0
  71. package/src/scripts/schemas/review-findings.schema.json +1 -1
  72. package/src/scripts/schemas/skill.schema.json +11 -1
  73. package/src/scripts/self_review_gate.ts +10 -1
  74. package/src/shared/settingsConsent.ts +1 -1
@@ -20,6 +20,7 @@ import { emitSqliteTwin } from './sqlite_store.js';
20
20
  import {
21
21
  type CodeEdge,
22
22
  type CodeGraph,
23
+ type EdgeConfidence,
23
24
  EXPECTED_GRAMMAR_ABI,
24
25
  EXT_LANG,
25
26
  type Lang,
@@ -110,6 +111,87 @@ interface SymbolTable {
110
111
  classByName: Map<string, string>;
111
112
  }
112
113
 
114
+ /**
115
+ * What a resolved name is allowed to BE at a given use site.
116
+ *
117
+ * The council that reviewed this repair made the same point from both seats: a
118
+ * confidence label cannot contain a false edge, only describe one, so the
119
+ * candidate set has to be filtered by capability BEFORE a confidence is
120
+ * assigned. A `const` cannot satisfy `foo()`; a `type` alias cannot satisfy
121
+ * `new T()`. Without this, adding constant/type/enum nodes in 2.1 would feed
122
+ * the very same-name table that produced the false `path` edge in the first
123
+ * place.
124
+ */
125
+ type Capability = 'callable' | 'constructible' | 'any';
126
+ const CALLABLE_KINDS: ReadonlySet<string> = new Set(['function', 'method']);
127
+ const CONSTRUCTIBLE_KINDS: ReadonlySet<string> = new Set(['class']);
128
+
129
+ function satisfies(kind: string | undefined, want: Capability): boolean {
130
+ if (kind === undefined) return true; // a file id / external ref — unknown, not disqualified
131
+ if (want === 'callable') return CALLABLE_KINDS.has(kind);
132
+ if (want === 'constructible') return CONSTRUCTIBLE_KINDS.has(kind);
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * One file's name bindings — the thing this engine did not have, and whose
138
+ * absence is the whole of defect 1.1.
139
+ *
140
+ * `locals` is every symbol DECLARED in the file. `imports` is every name the
141
+ * file BINDS from elsewhere, keyed on the local binding name, carrying whether
142
+ * the binding was resolved from a module specifier (`exact`) or merely matched
143
+ * by name (PHP `use`, which names a fully-qualified symbol and is matched on
144
+ * its base name without checking the namespace).
145
+ */
146
+ interface FileScope {
147
+ locals: Map<string, string[]>;
148
+ imports: Map<string, { target: string; exact: boolean }>;
149
+ }
150
+
151
+ const REL_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
152
+
153
+ /** POSIX-only join/dirname over graph ids, which are always POSIX-relative. */
154
+ function idDirname(id: string): string {
155
+ const i = id.lastIndexOf('/');
156
+ return i === -1 ? '' : id.slice(0, i);
157
+ }
158
+ function idJoin(dir: string, rel: string): string {
159
+ const parts = (dir === '' ? [] : dir.split('/')).concat(rel.split('/'));
160
+ const out: string[] = [];
161
+ for (const p of parts) {
162
+ if (p === '' || p === '.') continue;
163
+ if (p === '..') {
164
+ // A `..` that would escape the root is KEPT, so the caller can see
165
+ // the specifier points outside the indexed tree rather than
166
+ // silently resolving to something inside it.
167
+ if (out.length && out[out.length - 1] !== '..') out.pop();
168
+ else out.push('..');
169
+ continue;
170
+ }
171
+ out.push(p);
172
+ }
173
+ return out.join('/');
174
+ }
175
+
176
+ /**
177
+ * A relative module specifier → the id of the file node it names, or `null`
178
+ * when that file is not in this graph (out of root, or an unsupported
179
+ * extension). A bare specifier (`node:path`, `js-yaml`) is external by
180
+ * definition and returns `null` too — the caller distinguishes the two by
181
+ * looking at the specifier itself.
182
+ *
183
+ * TS source imports `./types.js` and the file on disk is `types.ts`, so the
184
+ * runtime extension is stripped before the candidate list is tried.
185
+ */
186
+ function resolveSpecifier(fromFile: string, spec: string, fileIds: ReadonlySet<string>): string | null {
187
+ if (!spec.startsWith('.')) return null;
188
+ const base = idJoin(idDirname(fromFile), spec);
189
+ const stem = base.replace(/\.(js|jsx|mjs|cjs)$/, '');
190
+ const candidates = [base, ...REL_EXTS.map((e) => stem + e), ...REL_EXTS.map((e) => base + '/index' + e)];
191
+ for (const c of candidates) if (fileIds.has(c)) return c;
192
+ return null;
193
+ }
194
+
113
195
  function push(map: Map<string, string[]>, k: string, v: string): void {
114
196
  const arr = map.get(k);
115
197
  if (arr) {
@@ -146,6 +228,11 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
146
228
  };
147
229
  for (const n of nodes) {
148
230
  const lang = nodeLang.get(n.id) ?? null;
231
+ // `byName` stays the CALLABLE/CONSTRUCTIBLE table it always was. The
232
+ // kinds 2.1 added (constant / type / enum) are deliberately NOT put in
233
+ // it: they are reachable through a file's own scope and through
234
+ // `imports`, and letting them satisfy a bare `foo()` by name is the
235
+ // defect this roadmap removes, not a feature it adds.
149
236
  if (n.kind === 'class' || n.kind === 'interface' || n.kind === 'trait' || n.kind === 'function') {
150
237
  push(sym.byName, k(lang, n.label), n.id);
151
238
  if (n.kind !== 'function' && !sym.classByName.has(k(lang, n.label)))
@@ -184,6 +271,87 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
184
271
  return null;
185
272
  };
186
273
 
274
+ const nodeKind = new Map(nodes.map((n) => [n.id, n.kind as string]));
275
+ const fileIds = new Set(nodes.filter((n) => n.kind === 'file').map((n) => n.id));
276
+ const scopes = new Map<string, FileScope>();
277
+ for (const ex of extracts) {
278
+ const locals = new Map<string, string[]>();
279
+ for (const n of ex.nodes) if (n.kind !== 'file') push(locals, n.label, n.id);
280
+ const imports = new Map<string, { target: string; exact: boolean }>();
281
+ for (const r of ex.rawEdges) {
282
+ if (r.relation !== 'imports') continue;
283
+ const local = r.localName ?? r.targetName;
284
+ if (imports.has(local)) continue; // first binding wins, deterministically
285
+ const spec = r.moduleSpecifier;
286
+ if (spec === undefined) {
287
+ // PHP `use A\B\C` — a fully-qualified NAME, not a module path.
288
+ // Resolved by base name without checking the namespace, so the
289
+ // binding is a lookup, not a syntactic fact: `exact: false`.
290
+ const hit =
291
+ sym.classByName.get(k(ex.lang, r.targetName)) ??
292
+ (sym.byName.get(k(ex.lang, r.targetName)) ?? []).find((id) => id.includes('#') && !id.includes('::'));
293
+ if (hit) imports.set(local, { target: hit, exact: false });
294
+ continue;
295
+ }
296
+ const file = resolveSpecifier(ex.file, spec, fileIds);
297
+ if (file === null) {
298
+ // Bare (`node:path`, `zod`) or relative-but-out-of-root. Either
299
+ // way the module is real and NAMED, and it is not this
300
+ // repository's same-named symbol. `import * as path from
301
+ // 'node:path'` ends here, which is the whole of 1.1.
302
+ const named = r.importKind === 'named';
303
+ imports.set(local, { target: named ? `external:${spec}#${r.targetName}` : `external:${spec}`, exact: true });
304
+ continue;
305
+ }
306
+ // A namespace or default import binds the MODULE, never a
307
+ // same-named export inside it.
308
+ const member = `${file}#${r.targetName}`;
309
+ const target = r.importKind === 'named' && nodeKind.has(member) ? member : file;
310
+ imports.set(local, { target, exact: true });
311
+ }
312
+ scopes.set(ex.file, { locals, imports });
313
+ }
314
+ const emptyScope: FileScope = { locals: new Map(), imports: new Map() };
315
+
316
+ /**
317
+ * Resolve a bare name at a use site inside `file`, capability-filtered.
318
+ *
319
+ * The ladder is the language's own: a LOCAL declaration shadows an import
320
+ * (both council seats named this, and JS/TS/PHP all agree), an import
321
+ * resolved from a specifier is next, and only then the repo-wide same-name
322
+ * table — which is a guess and is labelled as one.
323
+ */
324
+ const resolveInScope = (
325
+ file: string,
326
+ name: string,
327
+ want: Capability,
328
+ lang: Lang,
329
+ scopeOnly = false,
330
+ ): { target: string; confidence: EdgeConfidence } | null => {
331
+ const scope = scopes.get(file) ?? emptyScope;
332
+ for (const id of scope.locals.get(name) ?? [])
333
+ if (satisfies(nodeKind.get(id), want)) return { target: id, confidence: 'EXTRACTED' };
334
+ const bound = scope.imports.get(name);
335
+ if (bound && satisfies(nodeKind.get(bound.target), want))
336
+ return { target: bound.target, confidence: bound.exact ? 'EXTRACTED' : 'INFERRED' };
337
+ if (scopeOnly) return null;
338
+ const wide =
339
+ want === 'constructible'
340
+ ? sym.classByName.get(k(lang, name))
341
+ : (sym.byName.get(k(lang, name)) ?? []).find(
342
+ (id) => id.includes('#') && !id.includes('::') && satisfies(nodeKind.get(id), want),
343
+ );
344
+ // Reached only by matching a name across the whole repository, with no
345
+ // binding in this file to justify it. That is not a syntactic fact and
346
+ // no longer claims to be one.
347
+ if (wide) return { target: wide, confidence: 'INFERRED' };
348
+ return null;
349
+ };
350
+
351
+ // Deduped the same way emitted edges are, so the count is comparable to
352
+ // `edges.length` rather than being a raw call-site tally that reads as a
353
+ // larger loss than the graph actually took.
354
+ const suppressedKeys = new Set<string>();
187
355
  const edges: CodeEdge[] = [];
188
356
  const seen = new Set<string>();
189
357
  const emit = (e: CodeEdge): void => {
@@ -198,8 +366,13 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
198
366
  for (const ex of extracts) {
199
367
  // structural inherits edges (same-language resolution)
200
368
  for (const inh of ex.inherits) {
201
- const parentId = sym.classByName.get(k(ex.lang, inh.parentName)) ?? `symbol:${inh.parentName}`;
202
- emit({ source: inh.classId, target: parentId, relation: 'inherits', confidence: 'EXTRACTED' });
369
+ const hit = resolveInScope(ex.file, inh.parentName, 'constructible', ex.lang);
370
+ emit({
371
+ source: inh.classId,
372
+ target: hit?.target ?? `symbol:${inh.parentName}`,
373
+ relation: 'inherits',
374
+ confidence: hit?.confidence ?? 'EXTRACTED',
375
+ });
203
376
  }
204
377
  for (const r of ex.rawEdges) resolveRawEdge(r, ex);
205
378
  }
@@ -216,50 +389,50 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
216
389
  });
217
390
  return;
218
391
  }
219
- if (r.relation === 'imports' || r.relation === 'uses') {
220
- // A class name first, which is what `imports` and the pre-existing
221
- // `uses` sites emit. Then a FREE FUNCTION, which the registry
222
- // literals added by 1.3 point at: `{ foo: handleFoo }` names a
223
- // function, not a class, so a class-only lookup left every such edge
224
- // at `symbol:<name>` — an edge that exists and resolves nowhere,
225
- // which is barely better than the missing edge it replaced.
226
- //
227
- // Same predicate as the `free` call branch below (`#` and no `::`),
228
- // so a function reference and a function call resolve identically
229
- // rather than by two rules that can drift.
230
- const resolved =
231
- sym.classByName.get(k(ex.lang, r.targetName)) ??
232
- (sym.byName.get(k(ex.lang, r.targetName)) ?? []).find(
233
- (id) => id.includes('#') && !id.includes('::'),
234
- );
392
+ if (r.relation === 'imports') {
393
+ // The import's own binding, computed once per file above. A
394
+ // specifier-resolved binding is a syntactic fact — the file says
395
+ // where the name comes from — and a PHP `use` matched by base name
396
+ // is not, and says so.
397
+ const bound = (scopes.get(ex.file) ?? emptyScope).imports.get(r.localName ?? r.targetName);
235
398
  emit({
236
399
  source: r.sourceId,
237
- target: resolved ?? `symbol:${r.targetName}`,
238
- relation: r.relation,
239
- confidence: 'EXTRACTED',
400
+ target: bound?.target ?? `symbol:${r.targetName}`,
401
+ relation: 'imports',
402
+ confidence: bound ? (bound.exact ? 'EXTRACTED' : 'INFERRED') : 'EXTRACTED',
240
403
  });
241
404
  return;
242
405
  }
243
- // relation === 'calls'
244
- if (r.callStyle === 'new') {
245
- const resolved = sym.classByName.get(k(ex.lang, r.targetName));
406
+ if (r.relation === 'uses') {
407
+ // A registry literal (`{ foo: handleFoo }`) or an object shorthand
408
+ // (`{ id, label }`) names something bound IN THIS FILE. It is
409
+ // resolved through the file's scope and NOWHERE else: a shorthand
410
+ // property almost always names a local variable, and matching it
411
+ // against a same-named declaration elsewhere in the repository is
412
+ // the defect this roadmap removes, applied to a second relation.
413
+ const hit = resolveInScope(ex.file, r.targetName, 'any', ex.lang, true);
246
414
  emit({
247
415
  source: r.sourceId,
248
- target: resolved ?? `symbol:${r.targetName}`,
249
- relation: 'calls',
250
- confidence: 'EXTRACTED',
416
+ target: hit?.target ?? `symbol:${r.targetName}`,
417
+ relation: 'uses',
418
+ confidence: hit?.confidence ?? 'EXTRACTED',
251
419
  });
252
420
  return;
253
421
  }
254
- if (r.callStyle === 'free') {
255
- const resolved = (sym.byName.get(k(ex.lang, r.targetName)) ?? []).find(
256
- (id) => id.includes('#') && !id.includes('::'),
257
- );
422
+ // relation === 'calls'
423
+ if (r.callStyle === 'new' || r.callStyle === 'free') {
424
+ const want: Capability = r.callStyle === 'new' ? 'constructible' : 'callable';
425
+ const hit = resolveInScope(ex.file, r.targetName, want, ex.lang);
258
426
  emit({
259
427
  source: r.sourceId,
260
- target: resolved ?? `symbol:${r.targetName}`,
428
+ target: hit?.target ?? `symbol:${r.targetName}`,
261
429
  relation: 'calls',
262
- confidence: 'EXTRACTED',
430
+ // An UNRESOLVED name stays EXTRACTED: `symbol:strlen` claims
431
+ // nothing about a node, it records that a call to a name this
432
+ // repository does not declare happened here. What is no longer
433
+ // EXTRACTED is a claim about a SPECIFIC in-repo node reached by
434
+ // name alone.
435
+ confidence: hit?.confidence ?? 'EXTRACTED',
263
436
  });
264
437
  return;
265
438
  }
@@ -287,6 +460,27 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
287
460
  }
288
461
  // scoped (Class::m unresolved / facade) or dynamic ($obj->m) → AMBIGUOUS
289
462
  const cands = methodCandidates(r.targetName, ex.lang);
463
+ if (r.callStyle === 'dynamic' && cands.length === 0) {
464
+ // `x.push()`, `m.get()`, `s.join()` — a dynamic member call whose
465
+ // method name matches NO method declared in this repository. The
466
+ // only target available is `symbol:push`, which is not a node, so
467
+ // no query verb can reach it: `affected` and `path` resolve seeds
468
+ // against real nodes and a pseudo-node is never one. It was 257 of
469
+ // 660 edges on this engine's own source — 39 % of the graph
470
+ // asserting "a method named push was called somewhere".
471
+ //
472
+ // NOT suppressed: a `scoped` call (`Cache::get()`) and a
473
+ // `hierarchy-unresolved` `$this->missing()` both name a real
474
+ // unresolved intent inside a known receiver, which a later
475
+ // resolution pass could settle. This drops only the class where
476
+ // there is nothing to settle.
477
+ //
478
+ // The count is published in `suppressed_edge_counts` — an engine
479
+ // that silently deletes its worst edges reports a flattering
480
+ // confidence ratio and no evidence.
481
+ suppressedKeys.add(`${r.sourceId}\0calls\0symbol:${r.targetName}`);
482
+ return;
483
+ }
290
484
  emit({
291
485
  source: r.sourceId,
292
486
  target: ambiguousTarget(cands, r.targetName),
@@ -310,6 +504,7 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
310
504
 
311
505
  const counts = { EXTRACTED: 0, INFERRED: 0, AMBIGUOUS: 0 };
312
506
  for (const e of edges) counts[e.confidence] += 1;
507
+ const suppressed = { dynamic_no_candidate: suppressedKeys.size };
313
508
 
314
509
  const languages = [...new Set(extracts.map((e) => e.lang))].sort() as Lang[];
315
510
 
@@ -328,6 +523,7 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
328
523
  languages,
329
524
  grammar_abi: EXPECTED_GRAMMAR_ABI,
330
525
  edge_confidence_counts: counts,
526
+ suppressed_edge_counts: suppressed,
331
527
  nodes,
332
528
  edges,
333
529
  };
@@ -370,6 +566,7 @@ export function serializeGraph(g: CodeGraph): string {
370
566
  'languages',
371
567
  'grammar_abi',
372
568
  'edge_confidence_counts',
569
+ 'suppressed_edge_counts',
373
570
  'nodes',
374
571
  'edges',
375
572
  ];
@@ -36,6 +36,37 @@ export interface RawEdge {
36
36
  enclosingClassId?: string;
37
37
  /** scope token for `Scope::m()` scoped calls (a class name / self / static). */
38
38
  scopeName?: string;
39
+ /**
40
+ * `imports` only: the module specifier as written (`'./types.js'`,
41
+ * `'node:path'`), stripped of its quotes.
42
+ *
43
+ * Before this field the extractor read the import CLAUSE and threw the
44
+ * `from` away, so the build pass had nothing to resolve against but a
45
+ * repo-wide same-name table — which is how `import * as path from
46
+ * 'node:path'` came to bind to this repository's own `path()` function in
47
+ * `query.ts`, at `EXTRACTED`, in four of eleven files. The specifier is the
48
+ * one piece of evidence that says WHERE the name came from, and it is in
49
+ * the source text; discarding it turned a syntactic fact into a guess.
50
+ *
51
+ * Absent for PHP `use`, which names a fully-qualified symbol rather than a
52
+ * module path — those still resolve by name, and the build pass labels them
53
+ * accordingly.
54
+ */
55
+ moduleSpecifier?: string;
56
+ /**
57
+ * `imports` only: which import form produced this binding. `namespace`
58
+ * (`import * as x`) and `default` bind the MODULE (or its default export),
59
+ * never a named export, so neither may be matched against a same-named
60
+ * symbol inside the target file.
61
+ */
62
+ importKind?: 'namespace' | 'named' | 'default';
63
+ /**
64
+ * `imports` only: the LOCAL binding name, when it differs from
65
+ * `targetName` (`import { foo as bar }` → targetName `foo`, localName
66
+ * `bar`). The file's scope is keyed on the local name; the target file is
67
+ * searched for the remote one.
68
+ */
69
+ localName?: string;
39
70
  }
40
71
 
41
72
  export interface FileExtract {
@@ -245,29 +276,101 @@ function extractPhp(root: TsNode, file: string, out: FileExtract): void {
245
276
 
246
277
  // ── TypeScript / JavaScript ───────────────────────────────────────────────────
247
278
 
279
+ /**
280
+ * Node types that open a new function or class scope. A `const` inside one of
281
+ * these is a LOCAL variable, not a module-level declaration, and emitting a
282
+ * node for it would put every loop counter in the symbol table — the
283
+ * count-inflation failure `2.1` is explicitly not allowed to cause.
284
+ */
285
+ const TS_SCOPE_INTRODUCERS = new Set([
286
+ 'arrow_function',
287
+ 'function_expression',
288
+ 'function_declaration',
289
+ 'generator_function',
290
+ 'generator_function_declaration',
291
+ 'method_definition',
292
+ 'class_declaration',
293
+ 'class',
294
+ 'class_body',
295
+ ]);
296
+
248
297
  function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
249
298
  const fileId = file;
250
299
 
251
- const walk = (n: TsNode, classId: string | null, scopeId: string): void => {
300
+ const walk = (n: TsNode, classId: string | null, scopeId: string, topLevel: boolean): void => {
301
+ // A declaration is module-level until the walk descends through a
302
+ // function or class scope; from there down it is a local.
303
+ const inner = topLevel && !TS_SCOPE_INTRODUCERS.has(n.type);
252
304
  switch (n.type) {
253
305
  case 'import_statement': {
306
+ const src = n.childForFieldName('source');
307
+ // The specifier node is a `string`; its text carries the quotes.
308
+ const specifier = src ? src.text.replace(/^['"`]/, '').replace(/['"`]$/, '') : undefined;
254
309
  const clause = firstNamed(n, new Set(['import_clause']));
310
+ const emitImport = (
311
+ remote: string,
312
+ local: string,
313
+ importKind: 'namespace' | 'named' | 'default',
314
+ ): void => {
315
+ out.rawEdges.push({
316
+ sourceId: fileId,
317
+ relation: 'imports',
318
+ targetName: baseName(remote),
319
+ confidenceHint: 'EXTRACTED',
320
+ ...(specifier === undefined ? {} : { moduleSpecifier: specifier }),
321
+ importKind,
322
+ ...(local === remote ? {} : { localName: local }),
323
+ });
324
+ };
255
325
  if (clause) {
256
- for (const spec of namedChildren(clause)) {
257
- for (const idn of [spec, ...namedChildren(spec)])
258
- if (idn.type === 'identifier' || idn.type === 'import_specifier') {
259
- const nm = idn.type === 'import_specifier' ? nameField(idn) ?? idn.text : idn.text;
260
- out.rawEdges.push({
261
- sourceId: fileId,
262
- relation: 'imports',
263
- targetName: baseName(nm),
264
- confidenceHint: 'EXTRACTED',
265
- });
326
+ for (const child of namedChildren(clause)) {
327
+ if (child.type === 'namespace_import') {
328
+ // `import * as path from 'node:path'` — the binding
329
+ // is the MODULE. Matching `path` against a local
330
+ // symbol of the same name is the false edge 1.1
331
+ // exists to remove.
332
+ const idn = firstNamed(child, new Set(['identifier']));
333
+ if (idn) emitImport(idn.text, idn.text, 'namespace');
334
+ } else if (child.type === 'named_imports') {
335
+ for (const sp of namedChildren(child)) {
336
+ if (sp.type !== 'import_specifier') continue;
337
+ const nameNode = sp.childForFieldName('name');
338
+ const aliasNode = sp.childForFieldName('alias');
339
+ const remote = nameNode ? nameNode.text : sp.text;
340
+ emitImport(remote, aliasNode ? aliasNode.text : remote, 'named');
266
341
  }
342
+ } else if (child.type === 'identifier') {
343
+ emitImport(child.text, child.text, 'default');
344
+ }
267
345
  }
268
346
  }
269
347
  return;
270
348
  }
349
+ // `const`, `type`, `interface`, `enum` — the shapes the v2 corpus
350
+ // asks `references` questions about (`EXT_LANG` is a const,
351
+ // `SettingsClass` a type alias). The extractor used to emit none of
352
+ // them, which is the named mechanical cause of `references` recall
353
+ // 0.333 against grep's 1.000.
354
+ //
355
+ // The body is not walked: a type or interface position holds no
356
+ // call sites, and an enum body holds member names rather than
357
+ // symbols this graph answers questions about.
358
+ case 'interface_declaration':
359
+ case 'type_alias_declaration':
360
+ case 'enum_declaration': {
361
+ const nmNode = n.childForFieldName('name') ?? firstNamed(n, NAME_TYPES);
362
+ const nm = nmNode ? nmNode.text : '<anon>';
363
+ const kind =
364
+ n.type === 'interface_declaration'
365
+ ? 'interface'
366
+ : n.type === 'type_alias_declaration'
367
+ ? 'type'
368
+ : 'enum';
369
+ const id = `${file}#${nm}`;
370
+ out.nodes.push({ id, label: nm, kind, source_file: file, source_location: loc(n) });
371
+ out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
372
+ return;
373
+ }
271
374
  case 'class_declaration': {
272
375
  const nm = nameField(n) ?? '<anon>';
273
376
  const id = `${file}#${nm}`;
@@ -280,7 +383,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
280
383
  if (idn.type === 'identifier' || idn.type === 'type_identifier')
281
384
  out.inherits.push({ classId: id, parentName: baseName(idn.text) });
282
385
  const body = n.childForFieldName('body');
283
- if (body) for (const c of namedChildren(body)) walk(c, id, id);
386
+ if (body) for (const c of namedChildren(body)) walk(c, id, id, false);
284
387
  return;
285
388
  }
286
389
  case 'method_definition': {
@@ -294,7 +397,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
294
397
  confidenceHint: 'EXTRACTED',
295
398
  });
296
399
  const body = n.childForFieldName('body');
297
- if (body) for (const c of namedChildren(body)) walk(c, classId, id);
400
+ if (body) for (const c of namedChildren(body)) walk(c, classId, id, false);
298
401
  return;
299
402
  }
300
403
  case 'function_declaration': {
@@ -303,7 +406,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
303
406
  out.nodes.push({ id, label: nm, kind: 'function', source_file: file, source_location: loc(n) });
304
407
  out.rawEdges.push({ sourceId: fileId, relation: 'member', targetName: nm, confidenceHint: 'EXTRACTED' });
305
408
  const body = n.childForFieldName('body');
306
- if (body) for (const c of namedChildren(body)) walk(c, classId, id);
409
+ if (body) for (const c of namedChildren(body)) walk(c, classId, id, false);
307
410
  return;
308
411
  }
309
412
  // Modern TS declares most of its functions as bindings, not as
@@ -313,17 +416,58 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
313
416
  // 170-vs-13,428 TS/PHP symbol gap: the six handled kinds simply do
314
417
  // not cover the dominant declaration form.
315
418
  //
316
- // Scoped deliberately to bindings whose value IS a function. A
317
- // `const x = 3` is data, not a symbol this graph answers questions
318
- // about, and emitting a node per constant would inflate the count
319
- // without improving recall — which is the cosmetic-improvement
320
- // failure the phase falsifier exists to catch.
419
+ // The scoping note that stood here said a `const x = 3` is "data,
420
+ // not a symbol this graph answers questions about", and that
421
+ // emitting a node per constant would raise the count without
422
+ // improving recall. **The v2 benchmark falsified the first half and
423
+ // the second half no longer follows.** Two of its three
424
+ // `references` questions probe exactly this shape — `EXT_LANG` (a
425
+ // const) and `SettingsClass` (a type alias) — and the class scored
426
+ // recall 0.333 against grep's 1.000 because neither had a node to
427
+ // resolve to. The count-inflation worry was real and is answered by
428
+ // SCOPE rather than by refusal: a binding earns a node only at
429
+ // MODULE level (`topLevel`), so a loop counter inside a function
430
+ // body still gets none. The reason is kept rather than deleted,
431
+ // because a reversed decision whose reason disappears gets re-taken.
321
432
  case 'lexical_declaration':
322
433
  case 'variable_declaration': {
323
434
  for (const d of namedChildren(n)) {
324
435
  if (d.type !== 'variable_declarator') continue;
325
436
  const value = d.childForFieldName('value');
326
- if (!value || (value.type !== 'arrow_function' && value.type !== 'function_expression')) {
437
+ const nmNode = d.childForFieldName('name');
438
+ const isFn =
439
+ value !== null &&
440
+ value !== undefined &&
441
+ (value.type === 'arrow_function' || value.type === 'function_expression');
442
+ const isClassExpr = value?.type === 'class';
443
+ if (!isFn) {
444
+ // Only a plain `identifier` name: a destructuring
445
+ // pattern (`const { a, b } = x`) binds several names and
446
+ // none of them is a declaration this graph can id.
447
+ if (topLevel && nmNode?.type === 'identifier') {
448
+ const nm = nmNode.text;
449
+ const id = classId ? `${classId}::${nm}` : `${file}#${nm}`;
450
+ out.nodes.push({
451
+ id,
452
+ label: nm,
453
+ // `const Widget = class {}` IS constructible;
454
+ // calling it a constant would let the build pass
455
+ // reject a `new Widget()` that is perfectly real.
456
+ kind: isClassExpr ? 'class' : 'constant',
457
+ source_file: file,
458
+ source_location: loc(d),
459
+ });
460
+ out.rawEdges.push({
461
+ sourceId: classId ?? fileId,
462
+ relation: 'member',
463
+ targetName: nm,
464
+ confidenceHint: 'EXTRACTED',
465
+ });
466
+ }
467
+ // Non-function declarators still need walking for their
468
+ // calls — returning early on the whole declaration
469
+ // silently drops call edges from initialisers.
470
+ walk(d, classId, scopeId, inner);
327
471
  continue;
328
472
  }
329
473
  const nm = nameField(d) ?? '<anon>';
@@ -353,16 +497,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
353
497
  // can iterate children because a `statement_block` is never
354
498
  // the interesting node; here it often is.
355
499
  const body = value.childForFieldName('body');
356
- if (body) walk(body, classId, id);
357
- }
358
- // Non-function declarators still need walking for their calls.
359
- for (const d of namedChildren(n)) {
360
- if (d.type !== 'variable_declarator') continue;
361
- const value = d.childForFieldName('value');
362
- if (value && (value.type === 'arrow_function' || value.type === 'function_expression')) {
363
- continue;
364
- }
365
- walk(d, classId, scopeId);
500
+ if (body) walk(body, classId, id, false);
366
501
  }
367
502
  return;
368
503
  }
@@ -388,7 +523,7 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
388
523
  confidenceHint: 'EXTRACTED',
389
524
  });
390
525
  const body = value.childForFieldName('body');
391
- if (body) walk(body, classId, id);
526
+ if (body) walk(body, classId, id, false);
392
527
  return;
393
528
  }
394
529
  break;
@@ -481,11 +616,11 @@ function extractTsJs(root: TsNode, file: string, out: FileExtract): void {
481
616
  default:
482
617
  break;
483
618
  }
484
- for (const c of namedChildren(n)) walk(c, classId, scopeId);
619
+ for (const c of namedChildren(n)) walk(c, classId, scopeId, inner);
485
620
  };
486
621
 
487
622
  out.nodes.push({ id: fileId, label: file, kind: 'file', source_file: file, source_location: [] });
488
- walk(root, null, fileId);
623
+ walk(root, null, fileId, true);
489
624
  }
490
625
 
491
626
  /**