@event4u/agent-config 14.21.0 → 14.23.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 (235) 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 +164 -187
  5. package/CREDITS.md +37 -0
  6. package/README.md +1 -1
  7. package/dist/agent-src/commands/analyze/inbox.md +380 -5
  8. package/dist/agent-src/commands/analyze/repo.md +4 -2
  9. package/dist/agent-src/commands/pr/merge.md +47 -38
  10. package/dist/agent-src/commands/roadmap/next.md +8 -0
  11. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +69 -0
  12. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -5
  13. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
  14. package/dist/agent-src/rules/external-code-graph-interop.md +19 -14
  15. package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
  16. package/dist/agent-src/rules/token-efficiency.md +26 -3
  17. package/dist/agent-src/scripts/_cli_entry.ts +51 -0
  18. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
  19. package/dist/agent-src/scripts/roadmap_gates.ts +4 -20
  20. package/dist/agent-src/scripts/roadmap_set_step.ts +5 -2
  21. package/dist/agent-src/scripts/stubs_due.ts +4 -18
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +7 -24
  23. package/dist/agent-src/skills/code-intelligence/SKILL.md +82 -1
  24. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
  25. package/dist/agent-src/skills/roadmap-writing/SKILL.md +17 -0
  26. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
  27. package/dist/agent-src/templates/agent-settings.md +0 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/cli/registry.js +1 -1
  30. package/dist/cli-delegate/_cli_entry.js +8 -0
  31. package/dist/cli-delegate/_lib/pin_resolver.js +170 -0
  32. package/dist/cli-delegate/archival_sweep.js +8 -0
  33. package/dist/cli-delegate/archive_completed_roadmaps.js +542 -0
  34. package/dist/cli-delegate/check_update_banner.js +923 -0
  35. package/dist/cli-delegate/{chunk-ZVBOHSVF.js → chunk-2KKXJW25.js} +23 -9
  36. package/dist/cli-delegate/chunk-5EK5WQN6.js +28 -0
  37. package/dist/cli-delegate/chunk-7XGD2OEU.js +96 -0
  38. package/dist/cli-delegate/chunk-DBH4AHDK.js +155 -0
  39. package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
  40. package/dist/cli-delegate/chunk-JO25G5H7.js +20 -0
  41. package/dist/cli-delegate/{chunk-643Z7FIC.js → chunk-KCNTUSPI.js} +7 -1
  42. package/dist/cli-delegate/chunk-LTYVIY76.js +7961 -0
  43. package/dist/cli-delegate/chunk-QAJS5YWY.js +999 -0
  44. package/dist/cli-delegate/{chunk-YS6XTLEK.js → chunk-TYSY33MD.js} +56 -6
  45. package/dist/cli-delegate/chunk-ULET4BYS.js +328 -0
  46. package/dist/cli-delegate/chunk-YDQSHTD7.js +187 -0
  47. package/dist/cli-delegate/chunk-YWVXLR44.js +48 -0
  48. package/dist/cli-delegate/chunk-ZZQBUHFK.js +273 -0
  49. package/dist/cli-delegate/cmd_conformance.js +2 -2
  50. package/dist/cli-delegate/cmd_export.js +2 -2
  51. package/dist/cli-delegate/cmd_handoff.js +45 -13
  52. package/dist/cli-delegate/cmd_prune.js +2 -2
  53. package/dist/cli-delegate/cmd_session_recycle.js +4 -14
  54. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  55. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  56. package/dist/cli-delegate/cmd_sync.js +2 -2
  57. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  58. package/dist/cli-delegate/cmd_validate.js +2 -2
  59. package/dist/cli-delegate/dashboard_mode.js +16 -0
  60. package/dist/cli-delegate/gate_budget.js +24 -0
  61. package/dist/cli-delegate/gate_execute.js +25 -0
  62. package/dist/cli-delegate/guarded_baseline.js +18 -0
  63. package/dist/cli-delegate/resume_probe.js +22 -0
  64. package/dist/cli-delegate/roadmap_gates.js +638 -0
  65. package/dist/cli-delegate/roadmap_set_step.js +173 -0
  66. package/dist/cli-delegate/stub_queue.js +47 -0
  67. package/dist/cli-delegate/stubs_due.js +38 -0
  68. package/dist/cli-delegate/ticket_bundles.js +59 -0
  69. package/dist/cli-delegate/update_roadmap_progress.js +60 -0
  70. package/dist/discovery/deprecation-report.md +1 -1
  71. package/dist/discovery/discovery-manifest.json +13 -13
  72. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  73. package/dist/discovery/discovery-manifest.summary.md +1 -1
  74. package/dist/discovery/dormancy-report.md +1 -1
  75. package/dist/discovery/orphan-report.md +1 -1
  76. package/dist/discovery/packs.json +2 -2
  77. package/dist/discovery/trust-report.md +1 -1
  78. package/dist/discovery/workspaces.json +2 -2
  79. package/dist/hooks/dispatch.js +1813 -946
  80. package/dist/install/claudeLayerCarriage.js +237 -0
  81. package/dist/install/claudePathsPlan.js +79 -0
  82. package/dist/install/hostLayerFingerprint.js +1 -1
  83. package/dist/install/install.mjs +21 -3
  84. package/dist/install/partitionEligibility.js +231 -117
  85. package/dist/install/ruleLayerPartition.js +10 -7
  86. package/dist/mcp/registry-manifest.json +2 -2
  87. package/dist/mcp/server.json +2 -2
  88. package/dist/mcp/server.mjs +10870 -7641
  89. package/dist/scripts/_lib/lean_projection_mode.js +108 -0
  90. package/dist/scripts/_lib/router_match.js +243 -0
  91. package/dist/scripts/_lib/rule_injection.js +216 -0
  92. package/dist/scripts/condense.js +52 -45
  93. package/dist/scripts/project_thin_rules.js +139 -19
  94. package/dist/server/schemas/settings.js +16 -3
  95. package/docs/MIGRATION.md +105 -1
  96. package/docs/contracts/settings-classes.md +40 -8
  97. package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +73 -0
  98. package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
  99. package/llms.txt +1 -1
  100. package/package.json +7 -4
  101. package/src/agent-src/scripts/_cli_entry.ts +51 -0
  102. package/src/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
  103. package/src/agent-src/scripts/roadmap_gates.ts +4 -20
  104. package/src/agent-src/scripts/roadmap_set_step.ts +5 -2
  105. package/src/agent-src/scripts/stubs_due.ts +4 -18
  106. package/src/agent-src/scripts/update_roadmap_progress.ts +7 -24
  107. package/src/config/agent-settings.template.yml +47 -1
  108. package/src/config/continuity-surface.json +516 -0
  109. package/src/config/estate-count-budget.json +1 -1
  110. package/src/config/evaluator-budgets.json +4 -4
  111. package/src/config/gate-coverage.yml +29 -8
  112. package/src/config/gate-violation-baselines.json +8 -8
  113. package/src/config/gitignore-block.txt +19 -0
  114. package/src/config/hook-token-budget.json +7 -6
  115. package/src/config/host-injection-effect.json +73 -0
  116. package/src/config/packed-binary-manifest.json +33 -0
  117. package/src/config/preamble-payload-budget.json +112 -105
  118. package/src/config/publish-surface.json +4 -2
  119. package/src/config/routing-coverage-seed.json +9 -2
  120. package/src/install/claudeLayerCarriage.ts +273 -0
  121. package/src/install/claudePathsPlan.ts +76 -0
  122. package/src/install/hostLayerFingerprint.ts +1 -1
  123. package/src/install/partitionEligibility.ts +261 -127
  124. package/src/install/ruleLayerPartition.ts +19 -23
  125. package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
  126. package/src/scripts/_cli/handoff_sessions.ts +14 -44
  127. package/src/scripts/_dispatch.bash +77 -27
  128. package/src/scripts/_lib/auto_dispatch.ts +2 -2
  129. package/src/scripts/_lib/catalogue_layer_parity.ts +146 -0
  130. package/src/scripts/_lib/continuity_slot.ts +295 -0
  131. package/src/scripts/_lib/continuity_surface.ts +296 -0
  132. package/src/scripts/_lib/continuity_writer.ts +253 -0
  133. package/src/scripts/_lib/corpus_manifest.ts +26 -9
  134. package/src/scripts/_lib/hook_settings.ts +86 -1
  135. package/src/scripts/_lib/injection_effect.ts +151 -0
  136. package/src/scripts/_lib/layer_overlap_notice.ts +50 -6
  137. package/src/scripts/_lib/lean_projection_mode.ts +127 -0
  138. package/src/scripts/_lib/packed_binary_predicate.ts +262 -0
  139. package/src/scripts/_lib/pin_resolver.ts +13 -0
  140. package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
  141. package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
  142. package/src/scripts/_lib/retired_status.ts +74 -0
  143. package/src/scripts/_lib/session_eol.ts +44 -0
  144. package/src/scripts/_lib/session_index_trust.ts +346 -0
  145. package/src/scripts/_lib/skill_catalogue.ts +156 -21
  146. package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
  147. package/src/scripts/_lib/subagent_capsule.ts +117 -8
  148. package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
  149. package/src/scripts/ai_council/api_transport.ts +5 -0
  150. package/src/scripts/ai_council/deliberation_metrics.ts +353 -0
  151. package/src/scripts/ai_council/prompts.ts +9 -0
  152. package/src/scripts/ai_council/synthesis_disagreement.ts +206 -0
  153. package/src/scripts/audit_mcp_tools.ts +69 -25
  154. package/src/scripts/build_mcp_catalog.ts +24 -1
  155. package/src/scripts/capture_skill_catalogue.ts +24 -11
  156. package/src/scripts/check_candidate_lines.ts +499 -0
  157. package/src/scripts/check_continuity_surface.ts +475 -0
  158. package/src/scripts/check_dependency_floors.ts +13 -6
  159. package/src/scripts/check_estate_count.ts +40 -79
  160. package/src/scripts/check_host_tree_parity.ts +291 -0
  161. package/src/scripts/check_pack_size.ts +262 -23
  162. package/src/scripts/check_preamble_payload_budget.ts +136 -7
  163. package/src/scripts/check_roadmap_trackable.ts +19 -16
  164. package/src/scripts/check_rule_layer_partition.ts +54 -34
  165. package/src/scripts/check_rule_projection_integrity.ts +102 -2
  166. package/src/scripts/check_single_delivery.ts +97 -31
  167. package/src/scripts/check_update_banner.ts +13 -0
  168. package/src/scripts/ci_settle.ts +205 -5
  169. package/src/scripts/code_graph/build.ts +217 -15
  170. package/src/scripts/code_graph/cli.ts +334 -15
  171. package/src/scripts/code_graph/detect.ts +38 -0
  172. package/src/scripts/code_graph/extract.ts +11 -0
  173. package/src/scripts/code_graph/grammar_probe.ts +43 -0
  174. package/src/scripts/code_graph/loader.ts +88 -14
  175. package/src/scripts/code_graph/query.ts +144 -11
  176. package/src/scripts/code_graph/resolution_tiers.ts +238 -0
  177. package/src/scripts/code_graph/sqlite_store.ts +262 -6
  178. package/src/scripts/code_graph/types.ts +117 -3
  179. package/src/scripts/code_graph/validate.ts +26 -1
  180. package/src/scripts/code_graph/verbs.ts +590 -0
  181. package/src/scripts/condense.ts +54 -42
  182. package/src/scripts/corpus_manifest.ts +5 -5
  183. package/src/scripts/council_cli.ts +11 -12
  184. package/src/scripts/count_intent_disagreement.ts +426 -0
  185. package/src/scripts/handoff_context_hook.ts +118 -1
  186. package/src/scripts/hook_effect_doctor.ts +82 -1
  187. package/src/scripts/hook_manifest.json +1 -1
  188. package/src/scripts/hook_manifest.yaml +87 -12
  189. package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
  190. package/src/scripts/hooks/chain_nudge_hook.ts +282 -0
  191. package/src/scripts/hooks/code_graph_context_hook.ts +201 -0
  192. package/src/scripts/hooks/concern_registry.ts +4 -2
  193. package/src/scripts/hooks/host_semantics.ts +1 -1
  194. package/src/scripts/hooks/orchestration_record_hook.ts +2 -2
  195. package/src/scripts/hooks/push_settle_hook.ts +3 -2
  196. package/src/scripts/hooks/rule_inject_hook.ts +108 -21
  197. package/src/scripts/hooks/session_eol_hook.ts +120 -2
  198. package/src/scripts/hooks/skill_route_hook.ts +11 -4
  199. package/src/scripts/hot_context_hook.ts +62 -7
  200. package/src/scripts/inbox_source_census.ts +322 -0
  201. package/src/scripts/install-hooks.sh +82 -0
  202. package/src/scripts/{lint_carrier_integrity.ts → lint_deferral_integrity.ts} +60 -214
  203. package/src/scripts/lint_plan_risk_register.ts +13 -17
  204. package/src/scripts/lint_roadmap_blockers.ts +164 -1
  205. package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
  206. package/src/scripts/mcp_server/graph_tools.ts +294 -0
  207. package/src/scripts/mcp_server/path_util.ts +31 -0
  208. package/src/scripts/mcp_server/suggest_skill.ts +72 -0
  209. package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
  210. package/src/scripts/mcp_server/tools.ts +5 -69
  211. package/src/scripts/media/lib/adapter-contract.md +7 -2
  212. package/src/scripts/media/lib/fixtures/flux/asset-0001.png +0 -0
  213. package/src/scripts/media/lib/fixtures/gemini-image/asset-0001.png +0 -0
  214. package/src/scripts/media/lib/fixtures/ideogram/asset-0001.png +0 -0
  215. package/src/scripts/model_rule_injection.ts +61 -4
  216. package/src/scripts/project_thin_rules.ts +149 -16
  217. package/src/scripts/rdp_candidates_delta.ts +276 -0
  218. package/src/scripts/rdp_quality_eval.ts +217 -19
  219. package/src/scripts/release_mix_taxonomy.json +3 -2
  220. package/src/scripts/report_host_injection_effect.ts +189 -0
  221. package/src/scripts/report_layer_overlap.ts +41 -0
  222. package/src/scripts/report_skill_menu_census.ts +341 -0
  223. package/src/scripts/report_standing_payload_by_host.ts +650 -0
  224. package/src/scripts/schemas/agent-settings.schema.json +7 -1
  225. package/src/scripts/session_memory_index.ts +81 -8
  226. package/src/scripts/skill_tools/score_skill_relevance.ts +70 -6
  227. package/src/scripts/skill_tools/suggest_skill_for_task.ts +37 -3
  228. package/src/server/schemas/settings.ts +23 -4
  229. package/src/vendor/grammars/README.md +86 -0
  230. package/src/vendor/grammars/tree-sitter-javascript.wasm +0 -0
  231. package/src/vendor/grammars/tree-sitter-php.wasm +0 -0
  232. package/src/vendor/grammars/tree-sitter-typescript.wasm +0 -0
  233. package/src/scripts/_lib/carrier_status.ts +0 -118
  234. package/src/scripts/_lib/estate_carrier_cases.ts +0 -72
  235. package/src/scripts/hooks/code_graph_nudge_hook.ts +0 -191
@@ -28,6 +28,20 @@
28
28
  * `agents/runtime/state/code-graph-v1.json`) is local-only and disposable —
29
29
  * gitignored, rebuildable from source at any time; never a source of truth.
30
30
  *
31
+ * impact --diff <rev> [--depth N] callers/dependents/test files
32
+ * reachable from the changed
33
+ * symbols over ACCEPTED edges
34
+ * tests-for <symbol> test files importing the symbol
35
+ * untested --diff <rev> changed symbols no test imports
36
+ * dead [--entry-points F] zero accepted in-edges, minus
37
+ * [--accept-missing-exports] declared entry points; REFUSES
38
+ * when a source is unavailable
39
+ *
40
+ * The four gate verbs (3.1–3.3) each print the `resolved_via` histogram of the
41
+ * edges they accepted AND of the edges they refused to walk, plus the graph's
42
+ * three-state staleness — so a caller can tell "no callers" from "no callers I
43
+ * would trust", and "dead" from "dead as of a graph 40 commits behind".
44
+ *
31
45
  * Query subcommands (query/path/explain/affected) are added by the Phase-3
32
46
  * query tier. Deterministic, LLM-free, no network. Exit codes:
33
47
  * 0 ok · 1 not-found / validation-failed / refresh-budget-exceeded ·
@@ -49,8 +63,8 @@ import process from 'node:process';
49
63
  import { fileURLToPath } from 'node:url';
50
64
 
51
65
  import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
52
- import { buildFromRepo, sidecarPath } from './build.js';
53
- import { computeVerdict, detectSources, pickSource } from './detect.js';
66
+ import { buildFromRepo, isTestFile, sidecarPath } from './build.js';
67
+ import { computeVerdict, detectSources, type GraphState, graphState, pickSource } from './detect.js';
54
68
  import { suggestVerb } from './intent.js';
55
69
  import {
56
70
  affected,
@@ -62,7 +76,21 @@ import {
62
76
  query,
63
77
  type QueryResult,
64
78
  } from './query.js';
79
+ import { openGraphIndex, sqliteTwinPath } from './sqlite_store.js';
80
+ import type { CodeGraph } from './types.js';
65
81
  import { validateGraph } from './validate.js';
82
+ import {
83
+ dead,
84
+ type DeadResult,
85
+ entryPointsFromFile,
86
+ impact,
87
+ type ImpactResult,
88
+ repoEntryPoints,
89
+ testsFor,
90
+ type TestsForResult,
91
+ untested,
92
+ type UntestedResult,
93
+ } from './verbs.js';
66
94
 
67
95
  const _HERE = fileURLToPath(import.meta.url);
68
96
  export const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..', '..');
@@ -130,12 +158,33 @@ async function cmdBuild(argv: string[]): Promise<number> {
130
158
  `✅ code-graph ${update ? 'updated' : 'built'} — ${fileCount} files · ${graph.nodes.length} nodes · ${graph.edges.length} edges\n` +
131
159
  ` languages: ${graph.languages.join(', ') || '(none)'} · grammar ABI ${graph.grammar_abi}\n` +
132
160
  ` edges: EXTRACTED ${c.EXTRACTED} · INFERRED ${c.INFERRED} · AMBIGUOUS ${c.AMBIGUOUS}\n` +
161
+ ` resolved_via: ${resolvedViaHistogram(graph)}\n` +
133
162
  (update ? ` incremental: ${reExtracted} re-extracted · ${reused} reused\n` : '') +
134
163
  (out ? ` cache: ${path.relative(REPO_ROOT, out)}\n` : ''),
135
164
  );
136
165
  return 0;
137
166
  }
138
167
 
168
+ /**
169
+ * The `resolved_via` histogram, descending by count.
170
+ *
171
+ * Printed on every build because it is the number that says whether the graph
172
+ * is getting BETTER, and the confidence split does not: 2.3's whole target is
173
+ * moving edges off `name-lookup` onto a real binding, and a reader who cannot
174
+ * see the starting distribution cannot tell whether it moved. Only non-zero
175
+ * buckets appear — a row of zeroes for the four mechanisms nothing emits yet
176
+ * would be noise on every build.
177
+ */
178
+ function resolvedViaHistogram(graph: CodeGraph): string {
179
+ const counts = new Map<string, number>();
180
+ for (const e of graph.edges) counts.set(e.resolved_via, (counts.get(e.resolved_via) ?? 0) + 1);
181
+ if (counts.size === 0) return '(no edges)';
182
+ return [...counts.entries()]
183
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))
184
+ .map(([via, n]) => `${via} ${n}`)
185
+ .join(' · ');
186
+ }
187
+
139
188
  function cmdValidate(argv: string[]): number {
140
189
  const p = flag(argv, '--graph') ?? DEFAULT_CACHE;
141
190
  if (!fs.existsSync(p)) {
@@ -150,13 +199,57 @@ function cmdValidate(argv: string[]): number {
150
199
  return 1;
151
200
  }
152
201
  const r = validateGraph(parsed);
153
- if (r.ok) {
154
- process.stdout.write(`✅ graph schema valid: ${path.relative(REPO_ROOT, p)}\n`);
155
- return 0;
202
+ if (!r.ok) {
203
+ process.stderr.write(`❌ graph schema invalid (${r.errors.length}):\n`);
204
+ for (const e of r.errors) process.stderr.write(` ${e}\n`);
205
+ return 1;
206
+ }
207
+
208
+ // Twin ⇔ JSON, by checksum (road-to-a-graph-that-is-shipped 2.1).
209
+ //
210
+ // The schema check above says the JSON is well-formed. It says nothing
211
+ // about the derived store that now ANSWERS most queries, and since 2.1 the
212
+ // twin is read as an index rather than replayed as a blob — so a twin that
213
+ // disagreed with the JSON would produce different answers rather than a
214
+ // parse error. The comparison is `source_checksum`, which is the JSON's own
215
+ // content-addressed cache key, so agreement here means the twin was emitted
216
+ // from THIS graph and not merely from a graph.
217
+ const twinState = twinChecksumState(p, (parsed as CodeGraph).source_checksum);
218
+ const rel = path.relative(REPO_ROOT, p);
219
+ if (twinState.kind === 'mismatch') {
220
+ process.stderr.write(
221
+ `❌ SQLite twin disagrees with the canonical JSON: twin ${twinState.twin} ≠ json ${twinState.json}\n` +
222
+ ' The twin is derived and disposable — delete it and re-run `code-graph build`.\n' +
223
+ ` ${path.relative(REPO_ROOT, sqliteTwinPath(p))}\n`,
224
+ );
225
+ return 1;
226
+ }
227
+ process.stdout.write(`✅ graph schema valid: ${rel} · twin: ${twinState.kind}\n`);
228
+ return 0;
229
+ }
230
+
231
+ /**
232
+ * Compare the twin's stored `source_checksum` against the canonical JSON's.
233
+ *
234
+ * `absent` covers every state in which there is nothing to disagree WITH — no
235
+ * twin, no `node:sqlite`, a stale or corrupt twin the reader would refuse
236
+ * anyway. Those are not validation failures: the JSON is canonical and a
237
+ * missing accelerator changes no answer. Only a twin that is present, fresh,
238
+ * readable AND carrying a different checksum is a real contradiction.
239
+ */
240
+ function twinChecksumState(
241
+ jsonPath: string,
242
+ jsonChecksum: string,
243
+ ): { kind: 'absent' | 'match' } | { kind: 'mismatch'; twin: string; json: string } {
244
+ const index = openGraphIndex(jsonPath);
245
+ if (!index) return { kind: 'absent' };
246
+ try {
247
+ const twin = index.sourceChecksum;
248
+ if (twin && twin !== jsonChecksum) return { kind: 'mismatch', twin, json: jsonChecksum };
249
+ return { kind: 'match' };
250
+ } finally {
251
+ index.close();
156
252
  }
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
253
  }
161
254
 
162
255
  function resolveGraph(argv: string[]): { g: LoadedGraph; note: string } | { err: string } {
@@ -191,10 +284,18 @@ function budgetOf(argv: string[]): number {
191
284
  return Number.isFinite(b) && b > 0 ? b : 1500;
192
285
  }
193
286
 
194
- function changedNodeSeeds(g: LoadedGraph, root: string, ref: string): string[] {
195
- let files: string[];
287
+ /**
288
+ * Files changed between `ref` and `HEAD`, repo-relative.
289
+ *
290
+ * Extracted from `changedNodeSeeds` so the Phase-3 verbs seed from the same
291
+ * diff reading as `affected --since`. `null` distinguishes "git refused" (a bad
292
+ * rev, not a repository) from "nothing changed" — a verb that reports an empty
293
+ * impact set for a rev git could not resolve is asserting something it never
294
+ * measured.
295
+ */
296
+ export function changedFiles(root: string, ref: string): string[] | null {
196
297
  try {
197
- files = execFileSync('git', ['-C', root, 'diff', '--name-only', `${ref}..HEAD`], {
298
+ return execFileSync('git', ['-C', root, 'diff', '--name-only', `${ref}..HEAD`], {
198
299
  env: hardenedSpawnEnv(),
199
300
  encoding: 'utf-8',
200
301
  stdio: ['ignore', 'pipe', 'ignore'],
@@ -203,10 +304,16 @@ function changedNodeSeeds(g: LoadedGraph, root: string, ref: string): string[] {
203
304
  .map((s) => s.trim())
204
305
  .filter(Boolean);
205
306
  } catch {
206
- return [];
307
+ return null;
207
308
  }
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);
309
+ }
310
+
311
+ function changedNodeSeeds(g: LoadedGraph, root: string, ref: string): string[] {
312
+ const files = changedFiles(root, ref);
313
+ if (files === null) return [];
314
+ // Served by the `nodes_source_file` index on the SQLite path, so a wide
315
+ // diff does not force the whole node table into memory to answer it.
316
+ return g.idsInFiles(files);
210
317
  }
211
318
 
212
319
  function cmdQuery(kind: 'query' | 'explain' | 'affected' | 'path', argv: string[]): number {
@@ -254,6 +361,213 @@ function isFlagValue(argv: string[], token: string): boolean {
254
361
  return i > 0 && (argv[i - 1] as string).startsWith('--');
255
362
  }
256
363
 
364
+ // Phase-3 gate verbs (3.1-3.3).
365
+
366
+ /** The staleness token every gate verb prints, per step 3.1–3.3's shared
367
+ * verify: "each prints `resolved_via` counts and the staleness state". */
368
+ /**
369
+ * The staleness of the graph the verb is ACTUALLY reading.
370
+ *
371
+ * `answeredBy` is `resolveGraph`'s own picked path when it has one. Without it
372
+ * the two disagreed on the case `resolveRoot`'s docstring exists for: with
373
+ * `--root <other repo>` the verb answers from `DEFAULT_CACHE` (anchored to the
374
+ * package tree) while `graphState` looked under the OTHER root, so the printed
375
+ * staleness described a cache the answer never came from. An independent review
376
+ * found the `--graph` half fixed and this half open.
377
+ */
378
+ function stateOf(argv: string[], answeredBy?: string): GraphState {
379
+ const explicit = flag(argv, '--graph');
380
+ return graphState(resolveRoot(argv), explicit ?? answeredBy);
381
+ }
382
+
383
+ /** The two histogram lines + the staleness line, in one place so the four
384
+ * verbs cannot drift apart in how they report their own filtering. */
385
+ function renderEnvelope(r: { source: string; state: GraphState; accepted_via: string; rejected_via: string }): void {
386
+ process.stdout.write(`source: ${r.source}\nstaleness: ${r.state}\n`);
387
+ process.stdout.write(`accepted resolved_via: ${r.accepted_via}\n`);
388
+ process.stdout.write(`rejected resolved_via: ${r.rejected_via}\n`);
389
+ }
390
+
391
+ function renderReads(reads: readonly { path: string; lines: [number, number] | null }[]): void {
392
+ if (!reads.length) return;
393
+ process.stdout.write('minimal read set:\n');
394
+ for (const r of reads) process.stdout.write(` ${r.path}${r.lines ? `:${r.lines[0]}-${r.lines[1]}` : ''}\n`);
395
+ }
396
+
397
+ /**
398
+ * `impact --diff <rev>` (3.1) — callers, dependents and test files reachable
399
+ * from the changed symbols over ACCEPTED edges, plus the producing edges and a
400
+ * minimal read set.
401
+ */
402
+ function cmdImpact(argv: string[]): number {
403
+ const ref = flag(argv, '--diff');
404
+ if (ref === null) {
405
+ process.stderr.write('usage: code_graph/cli.ts impact --diff <rev> [--graph P] [--depth N]\n');
406
+ return 2;
407
+ }
408
+ const root = resolveRoot(argv);
409
+ const files = changedFiles(root, ref);
410
+ if (files === null) {
411
+ process.stderr.write(`code-graph: cannot resolve rev '${ref}' in ${root}\n`);
412
+ return 1;
413
+ }
414
+ const r = resolveGraph(argv);
415
+ if ('err' in r) {
416
+ process.stderr.write(`code-graph: ${r.err}\n`);
417
+ return 1;
418
+ }
419
+ try {
420
+ const depth = Number(flag(argv, '--depth'));
421
+ const res: ImpactResult = impact(
422
+ r.g,
423
+ files,
424
+ stateOf(argv, r.g.answeredBy),
425
+ Number.isFinite(depth) && depth > 0 ? depth : 2,
426
+ isTestFile,
427
+ );
428
+ renderEnvelope(res);
429
+ process.stdout.write(`changed files: ${files.length} · seeds: ${res.seeds.length}\n`);
430
+ if (res.unresolved_files.length)
431
+ process.stdout.write(`unresolved changed files (no node in the graph): ${res.unresolved_files.join(', ')}\n`);
432
+ process.stdout.write(`dependents (${res.dependents.length}):\n`);
433
+ for (const d of res.dependents) process.stdout.write(` ${d}\n`);
434
+ process.stdout.write(`test files (${res.test_files.length}):\n`);
435
+ for (const t of res.test_files) process.stdout.write(` ${t}\n`);
436
+ process.stdout.write(`producing edges (${res.producing_edges.length}):\n`);
437
+ for (const e of res.producing_edges) process.stdout.write(` ${e}\n`);
438
+ renderReads(res.recommended_reads);
439
+ return 0;
440
+ } finally {
441
+ r.g.close();
442
+ }
443
+ }
444
+
445
+ /** `tests-for <symbol>` (3.2) — the test files that import the symbol. */
446
+ function cmdTestsFor(argv: string[]): number {
447
+ const positional = argv.filter((a) => !a.startsWith('--') && !isFlagValue(argv, a));
448
+ const symbol = positional[0];
449
+ if (!symbol) {
450
+ process.stderr.write('usage: code_graph/cli.ts tests-for <symbol> [--graph P]\n');
451
+ return 2;
452
+ }
453
+ const r = resolveGraph(argv);
454
+ if ('err' in r) {
455
+ process.stderr.write(`code-graph: ${r.err}\n`);
456
+ return 1;
457
+ }
458
+ try {
459
+ const res: TestsForResult = testsFor(r.g, symbol, stateOf(argv, r.g.answeredBy));
460
+ renderEnvelope(res);
461
+ process.stdout.write(`seeds: ${res.seeds.join(', ') || '(none)'}\n`);
462
+ process.stdout.write(`tests (${res.tests.length}):\n`);
463
+ for (const t of res.tests) process.stdout.write(` ${t}\n`);
464
+ renderReads(res.recommended_reads);
465
+ return 0;
466
+ } finally {
467
+ r.g.close();
468
+ }
469
+ }
470
+
471
+ /** `untested --diff <rev>` (3.2) — changed symbols no test file imports. */
472
+ function cmdUntested(argv: string[]): number {
473
+ const ref = flag(argv, '--diff');
474
+ if (ref === null) {
475
+ process.stderr.write('usage: code_graph/cli.ts untested --diff <rev> [--graph P]\n');
476
+ return 2;
477
+ }
478
+ const root = resolveRoot(argv);
479
+ const files = changedFiles(root, ref);
480
+ if (files === null) {
481
+ process.stderr.write(`code-graph: cannot resolve rev '${ref}' in ${root}\n`);
482
+ return 1;
483
+ }
484
+ const r = resolveGraph(argv);
485
+ if ('err' in r) {
486
+ process.stderr.write(`code-graph: ${r.err}\n`);
487
+ return 1;
488
+ }
489
+ try {
490
+ const res: UntestedResult = untested(r.g, files, stateOf(argv, r.g.answeredBy));
491
+ renderEnvelope(res);
492
+ process.stdout.write(`changed files: ${files.length} · changed symbols: ${res.tested.length + res.untested.length}\n`);
493
+ if (res.unresolved_files.length)
494
+ process.stdout.write(`unresolved changed files (no node in the graph): ${res.unresolved_files.join(', ')}\n`);
495
+ process.stdout.write(`tested (${res.tested.length}) · untested (${res.untested.length}):\n`);
496
+ for (const u of res.untested) process.stdout.write(` ${u}\n`);
497
+ renderReads(res.recommended_reads);
498
+ return 0;
499
+ } finally {
500
+ r.g.close();
501
+ }
502
+ }
503
+
504
+ /**
505
+ * `dead` (3.3) — symbols with zero accepted in-edges that no declared entry
506
+ * point names. REFUSES by default when an entry-point source is unavailable;
507
+ * see `verbs.ts::dead` for why that refusal is not relaxable by an options bag.
508
+ */
509
+ function cmdDead(argv: string[]): number {
510
+ const r = resolveGraph(argv);
511
+ if ('err' in r) {
512
+ process.stderr.write(`code-graph: ${r.err}\n`);
513
+ return 1;
514
+ }
515
+ try {
516
+ const root = resolveRoot(argv);
517
+ const sources = repoEntryPoints(root);
518
+ const listFile = flag(argv, '--entry-points');
519
+ if (listFile !== null) {
520
+ if (!fs.existsSync(listFile)) {
521
+ process.stderr.write(`code-graph: entry-point list not found: ${listFile}\n`);
522
+ return 1;
523
+ }
524
+ // An operator-supplied list SUPPLIES the missing source, so the
525
+ // `exports` row stops being a gap rather than being ignored: the
526
+ // caller has stated the entry points the graph could not.
527
+ const supplied = entryPointsFromFile(listFile);
528
+ const patched = sources.map((srcRow) =>
529
+ srcRow.name === 'exports'
530
+ ? { ...srcRow, status: 'read' as const, detail: `supplied by ${listFile}`, entries: [] }
531
+ : srcRow,
532
+ );
533
+ patched.push(supplied);
534
+ const res: DeadResult = dead(r.g, patched, stateOf(argv, r.g.answeredBy));
535
+ return renderDead(res);
536
+ }
537
+ const res: DeadResult = dead(r.g, sources, stateOf(argv, r.g.answeredBy), {
538
+ acceptMissingExports: argv.includes('--accept-missing-exports'),
539
+ });
540
+ return renderDead(res);
541
+ } finally {
542
+ r.g.close();
543
+ }
544
+ }
545
+
546
+ function renderDead(res: DeadResult): number {
547
+ // The envelope FIRST, on both branches. It used to print only after the
548
+ // refusal check, so the default invocation in this repository — where
549
+ // `exports` is always unavailable — printed neither histogram nor the
550
+ // staleness line, and 3.1-3.3's shared verify ("each prints `resolved_via`
551
+ // counts and the staleness state") held only on the branch that answers.
552
+ // A reader of a refusal wants the staleness most of all: it says whether
553
+ // re-running after a rebuild could change anything.
554
+ renderEnvelope(res);
555
+ process.stdout.write('entry-point sources consulted:\n');
556
+ for (const srcRow of res.sources)
557
+ process.stdout.write(
558
+ ` ${srcRow.name.padEnd(13)} ${srcRow.status}${srcRow.detail ? ` — ${srcRow.detail}` : ''} · ${srcRow.entries.length} entr${srcRow.entries.length === 1 ? 'y' : 'ies'}\n`,
559
+ );
560
+ if (res.refusal !== null) {
561
+ process.stderr.write(`❌ dead refused: ${res.refusal}\n`);
562
+ return 1;
563
+ }
564
+ process.stdout.write(`excluded as declared entry points: ${res.excluded.length}\n`);
565
+ process.stdout.write(`dead (${res.dead.length}):\n`);
566
+ for (const d of res.dead) process.stdout.write(` ${d}\n`);
567
+ renderReads(res.recommended_reads);
568
+ return 0;
569
+ }
570
+
257
571
  function cmdDetect(argv: string[]): number {
258
572
  const root = resolveRoot(argv);
259
573
  if (flag(argv, '--format') === 'json') {
@@ -394,9 +708,14 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
394
708
  if (sub === 'refresh') return cmdRefresh(argv.slice(1));
395
709
  if (sub === 'query' || sub === 'explain' || sub === 'affected' || sub === 'path')
396
710
  return cmdQuery(sub, argv.slice(1));
711
+ if (sub === 'impact') return cmdImpact(argv.slice(1));
712
+ if (sub === 'tests-for') return cmdTestsFor(argv.slice(1));
713
+ if (sub === 'untested') return cmdUntested(argv.slice(1));
714
+ if (sub === 'dead') return cmdDead(argv.slice(1));
397
715
  if (sub === 'suggest-verb') return cmdSuggestVerb(argv.slice(1));
398
716
  process.stderr.write(
399
- 'usage: code_graph/cli.ts <build|validate|detect|refresh|query|explain|affected|path|suggest-verb> [options]\n',
717
+ 'usage: code_graph/cli.ts <build|validate|detect|refresh|query|explain|affected|path|' +
718
+ 'impact|tests-for|untested|dead|suggest-verb> [options]\n',
400
719
  );
401
720
  return 2;
402
721
  } catch (e) {
@@ -184,3 +184,41 @@ export function computeVerdict(root: string, nativeCache: string): VerdictJSON {
184
184
  sources,
185
185
  };
186
186
  }
187
+
188
+ /** Cache path a repository root's native graph lives at, repo-relative. */
189
+ export const NATIVE_CACHE_REL = path.join('agents', 'runtime', 'state', 'code-graph-v1.json');
190
+
191
+ /**
192
+ * The three-state staleness token every consumer of the graph reports.
193
+ *
194
+ * Lives HERE rather than in the PreToolUse hook that first needed it
195
+ * (`hooks/code_graph_context_hook.ts`, step 1.2). Phase 3's verbs must each
196
+ * print the staleness state, and an engine module importing a hook module to
197
+ * learn it would invert the dependency direction D9 measures — the hook imports
198
+ * the engine, never the other way round. The hook re-exports this so its own
199
+ * consumers are unaffected.
200
+ */
201
+ export type GraphState = 'absent' | 'fresh' | `behind:${number}`;
202
+
203
+ /**
204
+ * Resolve the graph's state for `root`.
205
+ *
206
+ * `absent` means no source at all — the silent case. A picked source whose
207
+ * staleness is UNKNOWN reads as `fresh`, mirroring {@link computeVerdict}'s own
208
+ * `picked.stale ? STALE : FRESH`: unknown is not treated as stale, because
209
+ * inventing a commit count would be worse than reporting none.
210
+ *
211
+ * `nativeCache` overrides where the native graph is looked for. It exists
212
+ * because a verb invoked with an explicit `--graph <path>` would otherwise
213
+ * report `absent` while answering from that very file — the state of a cache
214
+ * nobody asked about. Passing the graph the verb is actually reading makes the
215
+ * printed staleness a statement about the answer rather than about a
216
+ * convention.
217
+ */
218
+ export function graphState(root: string, nativeCache?: string): GraphState {
219
+ const picked = pickSource(detectSources(root, nativeCache ?? path.join(root, NATIVE_CACHE_REL)));
220
+ if (!picked) return 'absent';
221
+ if (picked.stale !== true) return 'fresh';
222
+ const behind = picked.commits_behind;
223
+ return `behind:${typeof behind === 'number' ? behind : 0}`;
224
+ }
@@ -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
+ }