@event4u/agent-config 14.20.0 → 14.22.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 (242) 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 +441 -0
  5. package/CREDITS.md +37 -0
  6. package/README.md +1 -1
  7. package/agents/templates/.ai-council.yml.example +10 -0
  8. package/dist/agent-src/commands/analyze/inbox.md +381 -6
  9. package/dist/agent-src/commands/analyze/{reference-repo.md → repo.md} +251 -34
  10. package/dist/agent-src/commands/analyze/roadmap-repos.md +129 -0
  11. package/dist/agent-src/commands/analyze.md +12 -7
  12. package/dist/agent-src/commands/feature/plan.md +55 -16
  13. package/dist/agent-src/commands/feature/refactor.md +8 -3
  14. package/dist/agent-src/commands/humanize.md +48 -4
  15. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +69 -0
  16. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -5
  17. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +22 -1
  18. package/dist/agent-src/contexts/execution/host-capability-manifest.md +4 -1
  19. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +35 -2
  20. package/dist/agent-src/contexts/execution/terminal-states.md +4 -1
  21. package/dist/agent-src/rules/evaluator-independence.md +4 -4
  22. package/dist/agent-src/rules/external-code-graph-interop.md +19 -14
  23. package/dist/agent-src/rules/external-reference-deep-dive.md +2 -2
  24. package/dist/agent-src/rules/token-efficiency.md +26 -3
  25. package/dist/agent-src/scripts/_cli_entry.ts +51 -0
  26. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +14 -23
  27. package/dist/agent-src/scripts/roadmap_gates.ts +4 -20
  28. package/dist/agent-src/scripts/roadmap_set_step.ts +5 -2
  29. package/dist/agent-src/scripts/stubs_due.ts +4 -18
  30. package/dist/agent-src/scripts/update_roadmap_progress.ts +7 -24
  31. package/dist/agent-src/skills/code-intelligence/SKILL.md +25 -0
  32. package/dist/agent-src/skills/competitive-positioning/SKILL.md +2 -2
  33. package/dist/agent-src/skills/humanizer/references/anti-aiisms.md +27 -11
  34. package/dist/agent-src/skills/learning-to-rule-or-skill/SKILL.md +1 -1
  35. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  36. package/dist/agent-src/skills/repomix-packer/SKILL.md +1 -1
  37. package/dist/agent-src/skills/roadmap-writing/SKILL.md +17 -0
  38. package/dist/agent-src/skills/technical-specification/SKILL.md +4 -0
  39. package/dist/agent-src/templates/agent-settings.md +0 -1
  40. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  41. package/dist/agent-src/templates/features.md +5 -0
  42. package/dist/agent-src/templates/roadmaps.md +15 -0
  43. package/dist/cli-delegate/_cli_entry.js +8 -0
  44. package/dist/cli-delegate/_lib/pin_resolver.js +170 -0
  45. package/dist/cli-delegate/archival_sweep.js +8 -0
  46. package/dist/cli-delegate/archive_completed_roadmaps.js +494 -0
  47. package/dist/cli-delegate/check_update_banner.js +923 -0
  48. package/dist/cli-delegate/{chunk-KPBWFOPZ.js → chunk-2FTMENJL.js} +194 -37
  49. package/dist/cli-delegate/{chunk-PI2GU3DP.js → chunk-2TAQ2K4V.js} +4 -1
  50. package/dist/cli-delegate/chunk-5EK5WQN6.js +28 -0
  51. package/dist/cli-delegate/chunk-7XGD2OEU.js +96 -0
  52. package/dist/cli-delegate/{chunk-C6IPZRDA.js → chunk-BIXEDQPZ.js} +7 -1
  53. package/dist/cli-delegate/chunk-DBH4AHDK.js +155 -0
  54. package/dist/cli-delegate/{chunk-NUMQHLS5.js → chunk-J3RFNKG4.js} +21 -2
  55. package/dist/cli-delegate/chunk-JO25G5H7.js +20 -0
  56. package/dist/cli-delegate/chunk-LTYVIY76.js +7961 -0
  57. package/dist/cli-delegate/chunk-QAJS5YWY.js +999 -0
  58. package/dist/cli-delegate/{chunk-ZVBOHSVF.js → chunk-TGIPZBCW.js} +1 -1
  59. package/dist/cli-delegate/{chunk-YS6XTLEK.js → chunk-TYSY33MD.js} +56 -6
  60. package/dist/cli-delegate/chunk-ULET4BYS.js +328 -0
  61. package/dist/cli-delegate/chunk-YDQSHTD7.js +187 -0
  62. package/dist/cli-delegate/chunk-YWVXLR44.js +48 -0
  63. package/dist/cli-delegate/chunk-ZZQBUHFK.js +273 -0
  64. package/dist/cli-delegate/cmd_conformance.js +19 -5
  65. package/dist/cli-delegate/cmd_doctor.js +3 -3
  66. package/dist/cli-delegate/cmd_export.js +2 -2
  67. package/dist/cli-delegate/cmd_handoff.js +27 -3
  68. package/dist/cli-delegate/cmd_prune.js +2 -2
  69. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  70. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  71. package/dist/cli-delegate/cmd_sync.js +2 -2
  72. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  73. package/dist/cli-delegate/cmd_validate.js +2 -2
  74. package/dist/cli-delegate/dashboard_mode.js +16 -0
  75. package/dist/cli-delegate/gate_budget.js +24 -0
  76. package/dist/cli-delegate/gate_execute.js +25 -0
  77. package/dist/cli-delegate/guarded_baseline.js +18 -0
  78. package/dist/cli-delegate/resume_probe.js +22 -0
  79. package/dist/cli-delegate/roadmap_gates.js +638 -0
  80. package/dist/cli-delegate/roadmap_set_step.js +173 -0
  81. package/dist/cli-delegate/stub_queue.js +47 -0
  82. package/dist/cli-delegate/stubs_due.js +38 -0
  83. package/dist/cli-delegate/ticket_bundles.js +59 -0
  84. package/dist/cli-delegate/update_roadmap_progress.js +60 -0
  85. package/dist/discovery/deprecation-report.md +1 -1
  86. package/dist/discovery/discovery-manifest.json +60 -29
  87. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  88. package/dist/discovery/discovery-manifest.summary.md +3 -3
  89. package/dist/discovery/dormancy-report.md +1 -1
  90. package/dist/discovery/orphan-report.md +1 -1
  91. package/dist/discovery/packs.json +7 -6
  92. package/dist/discovery/trust-report.md +2 -2
  93. package/dist/discovery/workspaces.json +6 -5
  94. package/dist/hooks/dispatch.js +1522 -1008
  95. package/dist/install/claudeLayerCarriage.js +237 -0
  96. package/dist/install/claudePathsPlan.js +79 -0
  97. package/dist/install/hostLayerFingerprint.js +1 -1
  98. package/dist/install/install.mjs +10 -1
  99. package/dist/install/partitionEligibility.js +231 -117
  100. package/dist/install/ruleLayerPartition.js +10 -7
  101. package/dist/mcp/registry-manifest.json +3 -3
  102. package/dist/mcp/server.json +2 -2
  103. package/dist/mcp/server.mjs +39376 -38902
  104. package/dist/scripts/ai_council/content_ceiling.js +63 -0
  105. package/dist/scripts/ai_council/seat_policy.js +106 -0
  106. package/dist/scripts/ai_council/transport_resolver.js +21 -0
  107. package/dist/scripts/condense.js +32 -25
  108. package/docs/MIGRATION.md +49 -1
  109. package/docs/contracts/settings-classes.md +1 -1
  110. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +73 -0
  111. package/llms.txt +14 -14
  112. package/package.json +7 -4
  113. package/src/agent-src/scripts/_cli_entry.ts +51 -0
  114. package/src/agent-src/scripts/archive_completed_roadmaps.ts +14 -23
  115. package/src/agent-src/scripts/roadmap_gates.ts +4 -20
  116. package/src/agent-src/scripts/roadmap_set_step.ts +5 -2
  117. package/src/agent-src/scripts/stubs_due.ts +4 -18
  118. package/src/agent-src/scripts/update_roadmap_progress.ts +7 -24
  119. package/src/config/continuity-surface.json +509 -0
  120. package/src/config/estate-count-budget.json +1 -1
  121. package/src/config/gate-coverage.yml +29 -8
  122. package/src/config/gate-violation-baselines.json +8 -8
  123. package/src/config/gitignore-block.txt +19 -0
  124. package/src/config/host-injection-effect.json +73 -0
  125. package/src/config/packed-binary-manifest.json +33 -0
  126. package/src/config/preamble-payload-budget.json +112 -105
  127. package/src/config/publish-surface.json +4 -2
  128. package/src/config/release-gate-locality.yml +4 -3
  129. package/src/install/claudeLayerCarriage.ts +273 -0
  130. package/src/install/claudePathsPlan.ts +76 -0
  131. package/src/install/hostLayerFingerprint.ts +1 -1
  132. package/src/install/partitionEligibility.ts +261 -127
  133. package/src/install/ruleLayerPartition.ts +19 -23
  134. package/src/scripts/_dispatch.bash +77 -27
  135. package/src/scripts/_lib/auto_dispatch.ts +2 -2
  136. package/src/scripts/_lib/blocked_by_marker.ts +189 -0
  137. package/src/scripts/_lib/catalogue_layer_parity.ts +146 -0
  138. package/src/scripts/_lib/continuity_surface.ts +296 -0
  139. package/src/scripts/_lib/corpus_manifest.ts +26 -9
  140. package/src/scripts/_lib/hook_settings.ts +1 -1
  141. package/src/scripts/_lib/host_capability.ts +38 -1
  142. package/src/scripts/_lib/injection_effect.ts +151 -0
  143. package/src/scripts/_lib/layer_overlap_notice.ts +50 -6
  144. package/src/scripts/_lib/packed_binary_predicate.ts +225 -0
  145. package/src/scripts/_lib/pin_resolver.ts +13 -0
  146. package/src/scripts/_lib/regression_neighbourhood.ts +1 -1
  147. package/src/scripts/_lib/release_highlights.ts +19 -211
  148. package/src/scripts/_lib/release_material.ts +27 -77
  149. package/src/scripts/_lib/retired_status.ts +74 -0
  150. package/src/scripts/_lib/skill_catalogue.ts +156 -21
  151. package/src/scripts/_lib/structured_ask.ts +117 -0
  152. package/src/scripts/_lib/subagent_capsule.ts +117 -8
  153. package/src/scripts/_lib/tty_prompt.ts +76 -0
  154. package/src/scripts/ai_council/api_transport.ts +168 -0
  155. package/src/scripts/ai_council/clients.ts +58 -63
  156. package/src/scripts/ai_council/config.ts +13 -0
  157. package/src/scripts/ai_council/content_ceiling.ts +79 -0
  158. package/src/scripts/ai_council/deliberation_metrics.ts +353 -0
  159. package/src/scripts/ai_council/jury_aggregate.ts +178 -0
  160. package/src/scripts/ai_council/prompts.ts +9 -0
  161. package/src/scripts/ai_council/seat_policy.ts +137 -0
  162. package/src/scripts/ai_council/status_surface.ts +79 -0
  163. package/src/scripts/ai_council/synthesis_disagreement.ts +206 -0
  164. package/src/scripts/ai_council/transport_resolver.ts +59 -1
  165. package/src/scripts/ai_tells_rules.ts +446 -5
  166. package/src/scripts/ask_block_census.ts +499 -0
  167. package/src/scripts/bench_humanizer_eval.ts +264 -40
  168. package/src/scripts/bench_prose_tells_fp.ts +198 -0
  169. package/src/scripts/capture_skill_catalogue.ts +24 -11
  170. package/src/scripts/check_branch_work_committed.ts +234 -0
  171. package/src/scripts/check_candidate_lines.ts +499 -0
  172. package/src/scripts/check_continuity_surface.ts +475 -0
  173. package/src/scripts/check_dependency_floors.ts +13 -6
  174. package/src/scripts/check_estate_count.ts +40 -79
  175. package/src/scripts/check_pack_size.ts +263 -24
  176. package/src/scripts/check_release_highlights.ts +0 -157
  177. package/src/scripts/check_roadmap_trackable.ts +19 -16
  178. package/src/scripts/check_rule_layer_partition.ts +54 -34
  179. package/src/scripts/check_single_delivery.ts +97 -31
  180. package/src/scripts/check_update_banner.ts +13 -0
  181. package/src/scripts/code_graph/build.ts +138 -15
  182. package/src/scripts/code_graph/cli.ts +76 -8
  183. package/src/scripts/code_graph/extract.ts +11 -0
  184. package/src/scripts/code_graph/grammar_probe.ts +43 -0
  185. package/src/scripts/code_graph/loader.ts +88 -14
  186. package/src/scripts/code_graph/query.ts +121 -11
  187. package/src/scripts/code_graph/resolution_tiers.ts +238 -0
  188. package/src/scripts/code_graph/sqlite_store.ts +247 -6
  189. package/src/scripts/code_graph/types.ts +66 -1
  190. package/src/scripts/code_graph/validate.ts +23 -0
  191. package/src/scripts/condense.ts +34 -22
  192. package/src/scripts/corpus_manifest.ts +5 -5
  193. package/src/scripts/council_cli.ts +29 -39
  194. package/src/scripts/count_intent_disagreement.ts +426 -0
  195. package/src/scripts/detect_ai_tells.ts +142 -20
  196. package/src/scripts/handoff_context_hook.ts +79 -0
  197. package/src/scripts/harvest_reference_tokens.ts +698 -0
  198. package/src/scripts/hook_effect_doctor.ts +82 -1
  199. package/src/scripts/hook_manifest.json +1 -1
  200. package/src/scripts/hook_manifest.yaml +94 -12
  201. package/src/scripts/hooks/chain_nudge_hook.ts +282 -0
  202. package/src/scripts/hooks/code_graph_context_hook.ts +212 -0
  203. package/src/scripts/hooks/concern_registry.ts +6 -2
  204. package/src/scripts/hooks/delegation_nudge_hook.ts +45 -1
  205. package/src/scripts/hooks/host_semantics.ts +1 -1
  206. package/src/scripts/hooks/one_question_per_ask_hook.ts +191 -0
  207. package/src/scripts/hooks/orchestration_record_hook.ts +2 -2
  208. package/src/scripts/hooks/rule_inject_hook.ts +1 -1
  209. package/src/scripts/hooks/skill_route_hook.ts +11 -4
  210. package/src/scripts/inbox_source_census.ts +322 -0
  211. package/src/scripts/install-hooks.sh +134 -0
  212. package/src/scripts/{lint_carrier_integrity.ts → lint_deferral_integrity.ts} +60 -214
  213. package/src/scripts/lint_plan_risk_register.ts +13 -17
  214. package/src/scripts/lint_roadmap_blockers.ts +51 -1
  215. package/src/scripts/mcp_server/suggest_skill.ts +72 -0
  216. package/src/scripts/mcp_server/tools.ts +2 -56
  217. package/src/scripts/media/lib/adapter-contract.md +7 -2
  218. package/src/scripts/media/lib/fixtures/flux/asset-0001.png +0 -0
  219. package/src/scripts/media/lib/fixtures/gemini-image/asset-0001.png +0 -0
  220. package/src/scripts/media/lib/fixtures/ideogram/asset-0001.png +0 -0
  221. package/src/scripts/migrate_command_suggestions.ts +1 -1
  222. package/src/scripts/probe_unblocked_ask.ts +135 -2
  223. package/src/scripts/rdp_candidates_delta.ts +276 -0
  224. package/src/scripts/rdp_quality_eval.ts +217 -19
  225. package/src/scripts/release.ts +26 -71
  226. package/src/scripts/release_drill.ts +6 -10
  227. package/src/scripts/release_env.ts +1 -0
  228. package/src/scripts/release_mix_taxonomy.json +3 -2
  229. package/src/scripts/release_publication.ts +34 -29
  230. package/src/scripts/report_host_injection_effect.ts +189 -0
  231. package/src/scripts/report_layer_overlap.ts +41 -0
  232. package/src/scripts/report_skill_menu_census.ts +341 -0
  233. package/src/scripts/self_review_gate.ts +409 -15
  234. package/src/scripts/skill_tools/score_skill_relevance.ts +70 -6
  235. package/src/scripts/skill_tools/suggest_skill_for_task.ts +37 -3
  236. package/src/vendor/grammars/README.md +61 -0
  237. package/src/vendor/grammars/tree-sitter-javascript.wasm +0 -0
  238. package/src/vendor/grammars/tree-sitter-php.wasm +0 -0
  239. package/src/vendor/grammars/tree-sitter-typescript.wasm +0 -0
  240. package/src/scripts/_lib/carrier_status.ts +0 -118
  241. package/src/scripts/_lib/estate_carrier_cases.ts +0 -72
  242. package/src/scripts/hooks/code_graph_nudge_hook.ts +0 -191
@@ -16,6 +16,17 @@ import * as path from 'node:path';
16
16
 
17
17
  import { write_atomic } from '../_lib/fs_atomic.js';
18
18
  import { extractFile, type FileExtract, type RawEdge } from './extract.js';
19
+ import {
20
+ ALIAS_VIA,
21
+ aliasRulesFrom,
22
+ EMPTY_RESOLUTION_CONFIG,
23
+ parseJsonc,
24
+ PSR4_VIA,
25
+ psr4RulesFrom,
26
+ type ResolutionConfig,
27
+ resolveAlias,
28
+ resolvePsr4,
29
+ } from './resolution_tiers.js';
19
30
  import { emitSqliteTwin } from './sqlite_store.js';
20
31
  import {
21
32
  type CodeEdge,
@@ -24,6 +35,7 @@ import {
24
35
  EXPECTED_GRAMMAR_ABI,
25
36
  EXT_LANG,
26
37
  type Lang,
38
+ type ResolvedVia,
27
39
  SCHEMA_VERSION,
28
40
  } from './types.js';
29
41
 
@@ -145,7 +157,9 @@ function satisfies(kind: string | undefined, want: Capability): boolean {
145
157
  */
146
158
  interface FileScope {
147
159
  locals: Map<string, string[]>;
148
- imports: Map<string, { target: string; exact: boolean }>;
160
+ /** `via` records WHICH tier produced the binding, so an edge built from it
161
+ * reports the mechanism instead of assuming `import-specifier`. */
162
+ imports: Map<string, { target: string; exact: boolean; via: ResolvedVia }>;
149
163
  }
150
164
 
151
165
  const REL_EXTS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
@@ -199,11 +213,40 @@ function push(map: Map<string, string[]>, k: string, v: string): void {
199
213
  } else map.set(k, [v]);
200
214
  }
201
215
 
216
+ /**
217
+ * Read the project's declared resolution tiers from `root` (2.3).
218
+ *
219
+ * IO lives here rather than in `buildGraph`, which stays pure so identical
220
+ * source plus identical config yields identical bytes. A missing or
221
+ * unparseable config is not an error — it degrades to no tiers, which is the
222
+ * behaviour every build had before this existed.
223
+ *
224
+ * `tsconfig.json` is read as JSONC: it is JSONC by convention and this
225
+ * repository's own carries `//` comments, so `JSON.parse` on it throws.
226
+ */
227
+ export function readResolutionConfig(root: string): ResolutionConfig {
228
+ const read = (name: string): unknown | null => {
229
+ try {
230
+ return parseJsonc(fs.readFileSync(path.join(root, name), 'utf-8'));
231
+ } catch {
232
+ return null;
233
+ }
234
+ };
235
+ return {
236
+ aliases: aliasRulesFrom(read('tsconfig.json')),
237
+ psr4: psr4RulesFrom(read('composer.json')),
238
+ };
239
+ }
240
+
202
241
  /**
203
242
  * Pure, deterministic graph build over an in-memory file set. Exposed for
204
243
  * tests (no IO). `buildFromRepo` is the IO wrapper.
205
244
  */
206
- export function buildGraph(files: readonly SourceFile[], extracts: readonly FileExtract[]): CodeGraph {
245
+ export function buildGraph(
246
+ files: readonly SourceFile[],
247
+ extracts: readonly FileExtract[],
248
+ resolution: ResolutionConfig = EMPTY_RESOLUTION_CONFIG,
249
+ ): CodeGraph {
207
250
  const nodes = extracts.flatMap((e) => e.nodes);
208
251
  const nodeIds = new Set(nodes.map((n) => n.id));
209
252
 
@@ -277,7 +320,7 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
277
320
  for (const ex of extracts) {
278
321
  const locals = new Map<string, string[]>();
279
322
  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 }>();
323
+ const imports = new Map<string, { target: string; exact: boolean; via: ResolvedVia }>();
281
324
  for (const r of ex.rawEdges) {
282
325
  if (r.relation !== 'imports') continue;
283
326
  const local = r.localName ?? r.targetName;
@@ -285,29 +328,58 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
285
328
  const spec = r.moduleSpecifier;
286
329
  if (spec === undefined) {
287
330
  // 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`.
331
+ //
332
+ // TIER 1 (2.3): composer PSR-4 maps the NAMESPACE to a
333
+ // directory, so the class file is named outright. That is a
334
+ // declared mapping, hence `exact: true` — and it is the only
335
+ // rung that can tell two same-named classes in different
336
+ // namespaces apart.
337
+ const fq = r.fqName ?? r.targetName;
338
+ const psr4File = resolvePsr4(fq, resolution, fileIds);
339
+ if (psr4File !== null) {
340
+ const member = `${psr4File}#${r.targetName}`;
341
+ imports.set(local, {
342
+ target: nodeKind.has(member) ? member : psr4File,
343
+ exact: true,
344
+ via: PSR4_VIA,
345
+ });
346
+ continue;
347
+ }
348
+ // TIER 2: base name, namespace discarded. A lookup, not a
349
+ // syntactic fact: `exact: false`.
290
350
  const hit =
291
351
  sym.classByName.get(k(ex.lang, r.targetName)) ??
292
352
  (sym.byName.get(k(ex.lang, r.targetName)) ?? []).find((id) => id.includes('#') && !id.includes('::'));
293
- if (hit) imports.set(local, { target: hit, exact: false });
353
+ if (hit) imports.set(local, { target: hit, exact: false, via: 'name-lookup' });
294
354
  continue;
295
355
  }
296
- const file = resolveSpecifier(ex.file, spec, fileIds);
356
+ // TIER 0: a relative specifier — the file says where it points.
357
+ // TIER 1 (2.3): tsconfig `paths`. `@shared/x` IS `src/shared/x`
358
+ // because the project declares it, so this is a fact and not a
359
+ // guess. Without it the specifier is non-relative, `resolveSpecifier`
360
+ // returns null, and the import binds to `external:@shared/x` — a
361
+ // real module, correctly named, and the wrong one.
362
+ const relFile = resolveSpecifier(ex.file, spec, fileIds);
363
+ const aliasFile = relFile === null ? resolveAlias(spec, resolution, fileIds) : null;
364
+ const file = relFile ?? aliasFile;
297
365
  if (file === null) {
298
366
  // Bare (`node:path`, `zod`) or relative-but-out-of-root. Either
299
367
  // way the module is real and NAMED, and it is not this
300
368
  // repository's same-named symbol. `import * as path from
301
369
  // 'node:path'` ends here, which is the whole of 1.1.
302
370
  const named = r.importKind === 'named';
303
- imports.set(local, { target: named ? `external:${spec}#${r.targetName}` : `external:${spec}`, exact: true });
371
+ imports.set(local, {
372
+ target: named ? `external:${spec}#${r.targetName}` : `external:${spec}`,
373
+ exact: true,
374
+ via: 'import-specifier',
375
+ });
304
376
  continue;
305
377
  }
306
378
  // A namespace or default import binds the MODULE, never a
307
379
  // same-named export inside it.
308
380
  const member = `${file}#${r.targetName}`;
309
381
  const target = r.importKind === 'named' && nodeKind.has(member) ? member : file;
310
- imports.set(local, { target, exact: true });
382
+ imports.set(local, { target, exact: true, via: aliasFile === null ? 'import-specifier' : ALIAS_VIA });
311
383
  }
312
384
  scopes.set(ex.file, { locals, imports });
313
385
  }
@@ -327,13 +399,23 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
327
399
  want: Capability,
328
400
  lang: Lang,
329
401
  scopeOnly = false,
330
- ): { target: string; confidence: EdgeConfidence } | null => {
402
+ ): { target: string; confidence: EdgeConfidence; via: ResolvedVia } | null => {
331
403
  const scope = scopes.get(file) ?? emptyScope;
404
+ // The ladder's rungs ARE the mechanism taxonomy, so `via` is read off
405
+ // the branch that succeeded rather than inferred afterwards.
332
406
  for (const id of scope.locals.get(name) ?? [])
333
- if (satisfies(nodeKind.get(id), want)) return { target: id, confidence: 'EXTRACTED' };
407
+ if (satisfies(nodeKind.get(id), want))
408
+ return { target: id, confidence: 'EXTRACTED', via: 'same-file' };
334
409
  const bound = scope.imports.get(name);
335
410
  if (bound && satisfies(nodeKind.get(bound.target), want))
336
- return { target: bound.target, confidence: bound.exact ? 'EXTRACTED' : 'INFERRED' };
411
+ return {
412
+ target: bound.target,
413
+ confidence: bound.exact ? 'EXTRACTED' : 'INFERRED',
414
+ // The binding knows how it was made — a tsconfig alias and a
415
+ // PSR-4 namespace are not import specifiers and must not be
416
+ // reported as ones.
417
+ via: bound.via,
418
+ };
337
419
  if (scopeOnly) return null;
338
420
  const wide =
339
421
  want === 'constructible'
@@ -344,10 +426,19 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
344
426
  // Reached only by matching a name across the whole repository, with no
345
427
  // binding in this file to justify it. That is not a syntactic fact and
346
428
  // no longer claims to be one.
347
- if (wide) return { target: wide, confidence: 'INFERRED' };
429
+ if (wide) return { target: wide, confidence: 'INFERRED', via: 'name-lookup' };
348
430
  return null;
349
431
  };
350
432
 
433
+ /**
434
+ * The mechanism for an edge whose target did not resolve.
435
+ *
436
+ * A `symbol:` pseudo-target is the residue of a name lookup that found
437
+ * nothing, so `name-lookup` is what actually happened. `confidence` already
438
+ * carries whether it resolved; this field carries what was tried.
439
+ */
440
+ const UNRESOLVED_VIA: ResolvedVia = 'name-lookup';
441
+
351
442
  // Deduped the same way emitted edges are, so the count is comparable to
352
443
  // `edges.length` rather than being a raw call-site tally that reads as a
353
444
  // larger loss than the graph actually took.
@@ -372,6 +463,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
372
463
  target: hit?.target ?? `symbol:${inh.parentName}`,
373
464
  relation: 'inherits',
374
465
  confidence: hit?.confidence ?? 'EXTRACTED',
466
+ resolved_via: hit?.via ?? UNRESOLVED_VIA,
467
+ provider: 'native',
375
468
  });
376
469
  }
377
470
  for (const r of ex.rawEdges) resolveRawEdge(r, ex);
@@ -386,6 +479,11 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
386
479
  target: nodeIds.has(target) ? target : `symbol:${r.targetName}`,
387
480
  relation: 'member',
388
481
  confidence: 'EXTRACTED',
482
+ // A member id is DERIVED from its own source node's id, so the
483
+ // declaration is in the same file by construction — no lookup
484
+ // of any kind happens here.
485
+ resolved_via: nodeIds.has(target) ? 'same-file' : UNRESOLVED_VIA,
486
+ provider: 'native',
389
487
  });
390
488
  return;
391
489
  }
@@ -400,6 +498,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
400
498
  target: bound?.target ?? `symbol:${r.targetName}`,
401
499
  relation: 'imports',
402
500
  confidence: bound ? (bound.exact ? 'EXTRACTED' : 'INFERRED') : 'EXTRACTED',
501
+ resolved_via: bound ? bound.via : UNRESOLVED_VIA,
502
+ provider: 'native',
403
503
  });
404
504
  return;
405
505
  }
@@ -416,6 +516,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
416
516
  target: hit?.target ?? `symbol:${r.targetName}`,
417
517
  relation: 'uses',
418
518
  confidence: hit?.confidence ?? 'EXTRACTED',
519
+ resolved_via: hit?.via ?? UNRESOLVED_VIA,
520
+ provider: 'native',
419
521
  });
420
522
  return;
421
523
  }
@@ -433,13 +535,25 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
433
535
  // EXTRACTED is a claim about a SPECIFIC in-repo node reached by
434
536
  // name alone.
435
537
  confidence: hit?.confidence ?? 'EXTRACTED',
538
+ resolved_via: hit?.via ?? UNRESOLVED_VIA,
539
+ provider: 'native',
436
540
  });
437
541
  return;
438
542
  }
439
543
  if (r.callStyle === 'this' || r.callStyle === 'self' || r.callStyle === 'static' || r.callStyle === 'parent') {
440
544
  const hit = r.enclosingClassId ? resolveHierMethod(r.enclosingClassId, r.targetName) : null;
441
545
  if (hit) {
442
- emit({ source: r.sourceId, target: hit, relation: 'calls', confidence: 'INFERRED' });
546
+ emit({
547
+ source: r.sourceId,
548
+ target: hit,
549
+ relation: 'calls',
550
+ confidence: 'INFERRED',
551
+ // Walked the class hierarchy, which is same-file only when
552
+ // the declaring class is; the honest mechanism for a
553
+ // hierarchy walk is the repo-wide method table.
554
+ resolved_via: 'name-lookup',
555
+ provider: 'native',
556
+ });
443
557
  } else {
444
558
  // enclosing class extends an out-of-repo base → honest AMBIGUOUS
445
559
  const cands = methodCandidates(r.targetName, ex.lang);
@@ -448,6 +562,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
448
562
  target: ambiguousTarget(cands, r.targetName),
449
563
  relation: 'calls',
450
564
  confidence: 'AMBIGUOUS',
565
+ resolved_via: 'dynamic',
566
+ provider: 'native',
451
567
  // The enclosing class extends a base outside this
452
568
  // repository, so the method is not findable HERE — a
453
569
  // different cause from a call whose receiver is unknown,
@@ -486,6 +602,8 @@ export function buildGraph(files: readonly SourceFile[], extracts: readonly File
486
602
  target: ambiguousTarget(cands, r.targetName),
487
603
  relation: 'calls',
488
604
  confidence: 'AMBIGUOUS',
605
+ resolved_via: 'dynamic',
606
+ provider: 'native',
489
607
  // Receiver type unknown: `$obj->m()` or an unresolved `C::m()`.
490
608
  // Resolving this needs type inference the extractor does not do,
491
609
  // which is exactly what 1.2 has to decide about rather than assume.
@@ -583,6 +701,11 @@ export function serializeGraph(g: CodeGraph): string {
583
701
  target: e.target,
584
702
  relation: e.relation,
585
703
  confidence: e.confidence,
704
+ // Unconditional, unlike the two optional fields below: these are
705
+ // required on every edge, so emitting them conditionally would make
706
+ // an absent field mean "not set" instead of being impossible.
707
+ resolved_via: e.resolved_via,
708
+ provider: e.provider,
586
709
  };
587
710
  // Ordered before `candidates` deliberately: the reason is the coarser
588
711
  // axis and a reader scanning the serialized graph sees WHY before HOW
@@ -684,7 +807,7 @@ export async function buildFromRepo(
684
807
  nextSidecar.files[f.path] = { hash, extract };
685
808
  }
686
809
 
687
- const graph = buildGraph(files, extracts);
810
+ const graph = buildGraph(files, extracts, readResolutionConfig(root));
688
811
  const json = serializeGraph(graph);
689
812
  if (cachePath) {
690
813
  write_atomic(cachePath, json);
@@ -62,6 +62,8 @@ import {
62
62
  query,
63
63
  type QueryResult,
64
64
  } from './query.js';
65
+ import { openGraphIndex, sqliteTwinPath } from './sqlite_store.js';
66
+ import type { CodeGraph } from './types.js';
65
67
  import { validateGraph } from './validate.js';
66
68
 
67
69
  const _HERE = fileURLToPath(import.meta.url);
@@ -130,12 +132,33 @@ async function cmdBuild(argv: string[]): Promise<number> {
130
132
  `✅ code-graph ${update ? 'updated' : 'built'} — ${fileCount} files · ${graph.nodes.length} nodes · ${graph.edges.length} edges\n` +
131
133
  ` languages: ${graph.languages.join(', ') || '(none)'} · grammar ABI ${graph.grammar_abi}\n` +
132
134
  ` edges: EXTRACTED ${c.EXTRACTED} · INFERRED ${c.INFERRED} · AMBIGUOUS ${c.AMBIGUOUS}\n` +
135
+ ` resolved_via: ${resolvedViaHistogram(graph)}\n` +
133
136
  (update ? ` incremental: ${reExtracted} re-extracted · ${reused} reused\n` : '') +
134
137
  (out ? ` cache: ${path.relative(REPO_ROOT, out)}\n` : ''),
135
138
  );
136
139
  return 0;
137
140
  }
138
141
 
142
+ /**
143
+ * The `resolved_via` histogram, descending by count.
144
+ *
145
+ * Printed on every build because it is the number that says whether the graph
146
+ * is getting BETTER, and the confidence split does not: 2.3's whole target is
147
+ * moving edges off `name-lookup` onto a real binding, and a reader who cannot
148
+ * see the starting distribution cannot tell whether it moved. Only non-zero
149
+ * buckets appear — a row of zeroes for the four mechanisms nothing emits yet
150
+ * would be noise on every build.
151
+ */
152
+ function resolvedViaHistogram(graph: CodeGraph): string {
153
+ const counts = new Map<string, number>();
154
+ for (const e of graph.edges) counts.set(e.resolved_via, (counts.get(e.resolved_via) ?? 0) + 1);
155
+ if (counts.size === 0) return '(no edges)';
156
+ return [...counts.entries()]
157
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
158
+ .map(([via, n]) => `${via} ${n}`)
159
+ .join(' · ');
160
+ }
161
+
139
162
  function cmdValidate(argv: string[]): number {
140
163
  const p = flag(argv, '--graph') ?? DEFAULT_CACHE;
141
164
  if (!fs.existsSync(p)) {
@@ -150,13 +173,57 @@ function cmdValidate(argv: string[]): number {
150
173
  return 1;
151
174
  }
152
175
  const r = validateGraph(parsed);
153
- if (r.ok) {
154
- process.stdout.write(`✅ graph schema valid: ${path.relative(REPO_ROOT, p)}\n`);
155
- return 0;
176
+ if (!r.ok) {
177
+ process.stderr.write(`❌ graph schema invalid (${r.errors.length}):\n`);
178
+ for (const e of r.errors) process.stderr.write(` ${e}\n`);
179
+ return 1;
180
+ }
181
+
182
+ // Twin ⇔ JSON, by checksum (road-to-a-graph-that-is-shipped 2.1).
183
+ //
184
+ // The schema check above says the JSON is well-formed. It says nothing
185
+ // about the derived store that now ANSWERS most queries, and since 2.1 the
186
+ // twin is read as an index rather than replayed as a blob — so a twin that
187
+ // disagreed with the JSON would produce different answers rather than a
188
+ // parse error. The comparison is `source_checksum`, which is the JSON's own
189
+ // content-addressed cache key, so agreement here means the twin was emitted
190
+ // from THIS graph and not merely from a graph.
191
+ const twinState = twinChecksumState(p, (parsed as CodeGraph).source_checksum);
192
+ const rel = path.relative(REPO_ROOT, p);
193
+ if (twinState.kind === 'mismatch') {
194
+ process.stderr.write(
195
+ `❌ SQLite twin disagrees with the canonical JSON: twin ${twinState.twin} ≠ json ${twinState.json}\n` +
196
+ ' The twin is derived and disposable — delete it and re-run `code-graph build`.\n' +
197
+ ` ${path.relative(REPO_ROOT, sqliteTwinPath(p))}\n`,
198
+ );
199
+ return 1;
200
+ }
201
+ process.stdout.write(`✅ graph schema valid: ${rel} · twin: ${twinState.kind}\n`);
202
+ return 0;
203
+ }
204
+
205
+ /**
206
+ * Compare the twin's stored `source_checksum` against the canonical JSON's.
207
+ *
208
+ * `absent` covers every state in which there is nothing to disagree WITH — no
209
+ * twin, no `node:sqlite`, a stale or corrupt twin the reader would refuse
210
+ * anyway. Those are not validation failures: the JSON is canonical and a
211
+ * missing accelerator changes no answer. Only a twin that is present, fresh,
212
+ * readable AND carrying a different checksum is a real contradiction.
213
+ */
214
+ function twinChecksumState(
215
+ jsonPath: string,
216
+ jsonChecksum: string,
217
+ ): { kind: 'absent' | 'match' } | { kind: 'mismatch'; twin: string; json: string } {
218
+ const index = openGraphIndex(jsonPath);
219
+ if (!index) return { kind: 'absent' };
220
+ try {
221
+ const twin = index.sourceChecksum;
222
+ if (twin && twin !== jsonChecksum) return { kind: 'mismatch', twin, json: jsonChecksum };
223
+ return { kind: 'match' };
224
+ } finally {
225
+ index.close();
156
226
  }
157
- process.stderr.write(`❌ graph schema invalid (${r.errors.length}):\n`);
158
- for (const e of r.errors) process.stderr.write(` ${e}\n`);
159
- return 1;
160
227
  }
161
228
 
162
229
  function resolveGraph(argv: string[]): { g: LoadedGraph; note: string } | { err: string } {
@@ -205,8 +272,9 @@ function changedNodeSeeds(g: LoadedGraph, root: string, ref: string): string[] {
205
272
  } catch {
206
273
  return [];
207
274
  }
208
- const set = new Set(files);
209
- return g.graph.nodes.filter((n) => set.has(n.source_file) && n.kind !== 'file').map((n) => n.id);
275
+ // Served by the `nodes_source_file` index on the SQLite path, so a wide
276
+ // diff does not force the whole node table into memory to answer it.
277
+ return g.idsInFiles(files);
210
278
  }
211
279
 
212
280
  function cmdQuery(kind: 'query' | 'explain' | 'affected' | 'path', argv: string[]): number {
@@ -67,6 +67,16 @@ export interface RawEdge {
67
67
  * searched for the remote one.
68
68
  */
69
69
  localName?: string;
70
+ /**
71
+ * `imports` only, PHP: the FULLY-QUALIFIED name as written (`App\\Svc\\Mailer`).
72
+ *
73
+ * `targetName` carries only the base name, because that is all the
74
+ * repo-wide lookup could use. Composer PSR-4 maps the NAMESPACE to a
75
+ * directory, so it needs the part `baseName()` discards — the same shape as
76
+ * `moduleSpecifier` for TS: the one piece of evidence that says where the
77
+ * name comes from, and it is right there in the source text.
78
+ */
79
+ fqName?: string;
70
80
  }
71
81
 
72
82
  export interface FileExtract {
@@ -137,6 +147,7 @@ function extractPhp(root: TsNode, file: string, out: FileExtract): void {
137
147
  relation: 'imports',
138
148
  targetName: baseName(nameNode.text),
139
149
  confidenceHint: 'EXTRACTED',
150
+ fqName: nameNode.text,
140
151
  });
141
152
  }
142
153
  }
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Report what the PRODUCTION loader makes of each grammar file given as an
4
+ * argument, as one line of JSON on stdout.
5
+ *
6
+ * A separate process because `check_pack_size.main` is synchronous while
7
+ * `Language.load` is not, and because keeping the probe out of the gate's own
8
+ * process means the gate cannot accidentally hold a loaded grammar's emscripten
9
+ * heap for the rest of its run.
10
+ *
11
+ * Never throws for a bad file: an unreadable or invalid grammar comes back as
12
+ * `{ ok: false, error }`, because "the loader refused this" is the ANSWER the
13
+ * eligibility predicate needs, not a crash it has to interpret.
14
+ */
15
+ import process from 'node:process';
16
+
17
+ import { probeGrammarFile } from './loader.js';
18
+
19
+ interface ProbeRow {
20
+ path: string;
21
+ ok: boolean;
22
+ abi?: number;
23
+ grammar_id?: string;
24
+ error?: string;
25
+ }
26
+
27
+ async function main(): Promise<number> {
28
+ const rows: ProbeRow[] = [];
29
+ for (const p of process.argv.slice(2)) {
30
+ try {
31
+ const r = await probeGrammarFile(p);
32
+ rows.push({ path: p, ok: true, abi: r.abi, grammar_id: r.grammar_id });
33
+ } catch (e) {
34
+ rows.push({ path: p, ok: false, error: (e as Error).message });
35
+ }
36
+ }
37
+ process.stdout.write(`${JSON.stringify(rows)}\n`);
38
+ return 0;
39
+ }
40
+
41
+ void main().then((c) => {
42
+ process.exitCode = c;
43
+ });
@@ -2,19 +2,29 @@
2
2
  * WASM tree-sitter loader for the code-graph engine.
3
3
  *
4
4
  * Pinned pair (ABI-verified 2026-07-23): `web-tree-sitter@0.24.7` +
5
- * `tree-sitter-wasms@0.1.13` → grammar ABI 14. The ABI smoke test lives in
6
- * `tests/scripts/code_graph.test.ts`; it runs in this repo because the pair is
7
- * a devDependency. Consumers install neither (see `INSTALL_HINT` below). No
8
- * network, no node-gyp — grammars load from `node_modules` via the resolved
9
- * path.
5
+ * grammar ABI 14. The ABI smoke test lives in
6
+ * `tests/scripts/code_graph.test.ts`.
7
+ *
8
+ * SHIPPED TO CONSUMERS (ADR-259, amended 2026-09-07). `web-tree-sitter` is a
9
+ * runtime `dependency`, and the three loadable grammars are vendored at
10
+ * `src/vendor/grammars/` and listed in `package.json` `files`. So a consumer
11
+ * install resolves both halves with no manual step. `tree-sitter-wasms` stays a
12
+ * devDependency — it is the source the vendored copies are refreshed from, and
13
+ * the fallback below keeps this repo's own dev flow (and any consumer holding
14
+ * the full 36-grammar pack) working unchanged.
15
+ *
16
+ * No network, no node-gyp, and no runtime grammar download — Kill register K1:
17
+ * both lookups below are filesystem reads of already-installed bytes.
10
18
  *
11
19
  * web-tree-sitter 0.24.x is CJS with a single default export (the Parser
12
20
  * class) and the classic `Parser.Language.load()` API; we reach it through
13
21
  * `createRequire` so this ESM module stays portable under the repo's tsx
14
22
  * runner.
15
23
  */
24
+ import * as fs from 'node:fs';
16
25
  import { createRequire } from 'node:module';
17
26
  import * as path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
18
28
 
19
29
  import { EXPECTED_GRAMMAR_ABI, GRAMMAR_WASM, type Lang } from './types.js';
20
30
 
@@ -53,22 +63,35 @@ interface ParserStatic {
53
63
  }
54
64
 
55
65
  /**
56
- * The parser pair is a devDependency, not a runtime one: this repo's tests still
57
- * exercise the loader, while a consumer install resolves neither. The engine is
58
- * permanently `enabled: false` (measured recall 0.365 vs grep 0.797), so
59
- * shipping ~51 MB of WASM to every consumer for a path none of them can reach
60
- * was pure install cost. A consumer re-enabling the engine installs the
61
- * ABI-locked pair themselves — this message carries the pin, because
62
- * `check_dependency_floors` only scans the `dependencies` block.
66
+ * Reached only when BOTH grammar sources are missing — the vendored set that
67
+ * ships in the tarball and the devDependency pack. That is a broken or
68
+ * partially-extracted install, not the ordinary consumer state, so the message
69
+ * says how to repair rather than how to opt in.
70
+ *
71
+ * It still carries the exact pin because the pair is ABI-coupled and
72
+ * `check_dependency_floors` reads only version ranges, never this string.
63
73
  */
64
74
  const INSTALL_HINT =
65
- 'the code-graph engine needs its parser pair, which this package no longer ships to consumers ' +
66
- '(the engine is disabled by a recorded null result). Install the ABI-locked pair:\n' +
75
+ 'the code-graph engine could not find its grammars. They ship with this package at ' +
76
+ '`src/vendor/grammars/`, so this usually means an incomplete install — reinstall the ' +
77
+ 'package first. To supply them manually, install the ABI-locked pack:\n' +
67
78
  ' npm i web-tree-sitter@0.24.7 tree-sitter-wasms@0.1.13\n' +
68
79
  'Both versions are exact on purpose — the pair is ABI-coupled (grammar ABI 14) ' +
69
80
  'and must move together.';
70
81
 
82
+ /**
83
+ * Directory holding the grammars that ship inside the package.
84
+ *
85
+ * `../../..` from `src/scripts/code_graph/` is the package root — the same
86
+ * walk `_lib/agent_settings.ts` uses from `src/scripts/_lib/`. A wrong answer
87
+ * here is not a failure: `grammarWasmPath` only uses this path when the file is
88
+ * actually present, and otherwise falls through to the devDependency pack,
89
+ * which is exactly today's behaviour.
90
+ */
91
+ const VENDORED_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'vendor', 'grammars');
92
+
71
93
  let _wasmDir: string | null = null;
94
+ /** The devDependency grammar pack — the fallback source, and this repo's own. */
72
95
  function wasmDir(): string {
73
96
  if (_wasmDir === null) {
74
97
  let pkgJson: string;
@@ -86,7 +109,18 @@ let _initialized = false;
86
109
  const _langCache = new Map<Lang, TsLanguage>();
87
110
  const _parserCache = new Map<Lang, TsParser>();
88
111
 
112
+ /**
113
+ * Absolute path to a grammar, vendored set first.
114
+ *
115
+ * Order is load-bearing: the vendored set is what a consumer actually has, and
116
+ * it is ABI-pinned by the same change that wrote it. The devDependency pack is
117
+ * second so this repo keeps working when `src/vendor/grammars/` has not been
118
+ * refreshed, and so a consumer holding the full 36-grammar pack can reach a
119
+ * grammar the vendored three do not cover.
120
+ */
89
121
  export function grammarWasmPath(lang: Lang): string {
122
+ const vendored = path.join(VENDORED_DIR, GRAMMAR_WASM[lang]);
123
+ if (fs.existsSync(vendored)) return vendored;
90
124
  return path.join(wasmDir(), GRAMMAR_WASM[lang]);
91
125
  }
92
126
 
@@ -150,3 +184,43 @@ export async function getParser(lang: Lang): Promise<TsParser> {
150
184
  export function nodeHasError(n: TsNode): boolean {
151
185
  return typeof n.hasError === 'function' ? n.hasError() : n.hasError;
152
186
  }
187
+
188
+ /**
189
+ * Load an ARBITRARY grammar file through the production loader and report what
190
+ * it actually is.
191
+ *
192
+ * Exists for `check_pack_size`'s binary-eligibility predicate, and the "through
193
+ * the production loader" part is the whole point: the gate must not re-implement
194
+ * grammar loading, because a second implementation could accept bytes the engine
195
+ * rejects — which is precisely the gap that would let a broken grammar ship
196
+ * while the gate reported it valid. Same `_Parser()` instance, same
197
+ * `Language.load`, same ABI reading as `loadLanguage`.
198
+ *
199
+ * `grammar_id` comes from the module's exported `tree_sitter_<id>` symbol rather
200
+ * than from the `Language` object, because web-tree-sitter 0.24's `Language`
201
+ * exposes `version` and no name. `WebAssembly.Module.exports` is the runtime's
202
+ * own parser, so the id is read rather than guessed.
203
+ *
204
+ * Throws on anything it cannot establish — a caller that wants a verdict rather
205
+ * than an exception should catch. Never silently returns a partial answer.
206
+ */
207
+ export async function probeGrammarFile(wasmPath: string): Promise<{ abi: number; grammar_id: string }> {
208
+ const bytes = fs.readFileSync(wasmPath);
209
+ const mod = new WebAssembly.Module(bytes);
210
+ // A grammar module exports `tree_sitter_<id>` plus, usually, a family of
211
+ // `tree_sitter_<id>_external_scanner_*` helpers — measured: 6 such exports
212
+ // on php, 7 on typescript and javascript. The language id is the one
213
+ // WITHOUT the scanner suffix, and filtering for it leaves exactly one.
214
+ const ids = WebAssembly.Module.exports(mod)
215
+ .map((e) => e.name)
216
+ .filter((n) => n.startsWith('tree_sitter_') && !n.includes('_external_scanner_'))
217
+ .map((n) => n.slice('tree_sitter_'.length));
218
+ if (ids.length !== 1) {
219
+ throw new Error(
220
+ `grammar probe: expected exactly one tree_sitter_* export in ${wasmPath}, found ${String(ids.length)}`,
221
+ );
222
+ }
223
+ await _init();
224
+ const language = await _Parser().Language.load(wasmPath);
225
+ return { abi: language.version, grammar_id: ids[0] as string };
226
+ }