@event4u/agent-config 17.2.1 → 17.4.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 (225) 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/.claude-plugin/skills/install-agent-config/SKILL.md +9 -9
  5. package/CHANGELOG.md +167 -193
  6. package/CREDITS.md +2 -2
  7. package/README.md +1 -1
  8. package/dist/agent-src/skills/code-intelligence/SKILL.md +16 -12
  9. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  10. package/dist/agent-src/templates/scripts/check_memory.ts +1 -1
  11. package/dist/cli/commands/recordTriggerEval.js +1 -1
  12. package/dist/cli/registry.js +2 -2
  13. package/dist/cli-delegate/{chunk-KR5ZIFPO.js → chunk-5AYJGUA3.js} +1 -3
  14. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  15. package/dist/cli-delegate/chunk-E7ACTJTJ.js +6792 -0
  16. package/dist/cli-delegate/{chunk-VYZGJLCA.js → chunk-RKNT4LIK.js} +22 -1286
  17. package/dist/cli-delegate/{chunk-BZJ4Y3CI.js → chunk-WY5ZZPGL.js} +2 -2
  18. package/dist/cli-delegate/{chunk-CMZEKSNN.js → chunk-X6LZ7W5A.js} +73 -10
  19. package/dist/cli-delegate/cmd_conformance.js +190 -192
  20. package/dist/cli-delegate/cmd_doctor.js +5 -5
  21. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  22. package/dist/cli-delegate/cmd_explain.js +1 -1
  23. package/dist/cli-delegate/cmd_export.js +2 -2
  24. package/dist/cli-delegate/cmd_handoff.js +2 -2
  25. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  26. package/dist/cli-delegate/cmd_prune.js +2 -2
  27. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  28. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  29. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  30. package/dist/cli-delegate/cmd_settings_set.js +8 -4
  31. package/dist/cli-delegate/cmd_sync.js +2 -2
  32. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  33. package/dist/cli-delegate/cmd_update.js +24 -13
  34. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  35. package/dist/cli-delegate/cmd_validate.js +2 -2
  36. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  37. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  38. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  39. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  40. package/dist/discovery/deprecation-report.md +1 -1
  41. package/dist/discovery/discovery-manifest.json +3 -3
  42. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  43. package/dist/discovery/discovery-manifest.summary.md +1 -1
  44. package/dist/discovery/dormancy-report.md +1 -1
  45. package/dist/discovery/orphan-report.md +1 -1
  46. package/dist/discovery/packs.json +2 -2
  47. package/dist/discovery/trust-report.md +1 -1
  48. package/dist/discovery/workspaces.json +2 -2
  49. package/dist/hooks/code_graph_refresh.chunk.cjs +443 -0
  50. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -0
  51. package/dist/hooks/dispatch.js +1001 -938
  52. package/dist/hooks/dispatch.sha256 +1 -1
  53. package/dist/hooks/egress_engine.chunk.cjs +57 -7
  54. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  55. package/dist/hooks/update_notice.chunk.cjs +914 -0
  56. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  57. package/dist/install/install.mjs +6974 -6037
  58. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -0
  59. package/dist/mcp/registry-manifest.json +3 -2
  60. package/dist/mcp/server.json +13 -4
  61. package/dist/mcp/server.mjs +71202 -63282
  62. package/dist/scripts/_lib/egress_rules.js +8 -2
  63. package/dist/scripts/condense.js +18 -15
  64. package/dist/server/routes/discovery.js +1 -1
  65. package/dist/server/routes/install.js +1 -1
  66. package/dist/server/routes/ping.js +1 -1
  67. package/dist/server/routes/schema.js +4 -4
  68. package/dist/server/routes/settings.js +2 -6
  69. package/dist/server/routes/wizard.js +6 -6
  70. package/dist/server/schemas/settings.js +42 -42
  71. package/dist/shared/settingsSurface.js +1 -1
  72. package/dist/shared/userMd/schema.js +1 -1
  73. package/dist/shared/zodJsonSchema.js +47 -0
  74. package/dist/ui/assets/index-B0bcIgi0.js +77 -0
  75. package/dist/ui/index.html +1 -1
  76. package/package.json +6 -3
  77. package/src/agent-src/templates/scripts/check_memory.ts +1 -1
  78. package/src/config/ci-local-parity.yml +11 -0
  79. package/src/config/continuity-surface.json +9 -2
  80. package/src/config/evaluator-budgets.json +9 -8
  81. package/src/config/gate-violation-baselines.json +3 -3
  82. package/src/config/gitignore-block.txt +6 -5
  83. package/src/config/hook-bundle-budget.json +46 -6
  84. package/src/config/pack-size-budget.json +12 -1
  85. package/src/config/packed-binary-manifest.json +18 -0
  86. package/src/config/sigstore/npm_keys.json +50 -0
  87. package/src/config/sigstore/trust-material.json +9 -0
  88. package/src/config/sigstore/trusted_root.json +126 -0
  89. package/src/config/skill-body-budget.json +7 -1
  90. package/src/scripts/_cli/cmd_settings_set.ts +3 -2
  91. package/src/scripts/_cli/cmd_update.ts +48 -20
  92. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  93. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  94. package/src/scripts/_dispatch.bash +4 -3
  95. package/src/scripts/_lib/confusables.ts +0 -19
  96. package/src/scripts/_lib/egress_normalize.ts +53 -23
  97. package/src/scripts/_lib/egress_rules.ts +8 -2
  98. package/src/scripts/_lib/egress_text.ts +5 -3
  99. package/src/scripts/_lib/egress_token.ts +7 -5
  100. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  101. package/src/scripts/_lib/git_common_dir.ts +10 -0
  102. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  103. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  104. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  105. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  106. package/src/scripts/_lib/lineage_map.ts +55 -0
  107. package/src/scripts/_lib/lineage_query.ts +140 -0
  108. package/src/scripts/_lib/lineage_seed.ts +116 -0
  109. package/src/scripts/_lib/lineage_watch.ts +249 -0
  110. package/src/scripts/_lib/link_crypto.ts +8 -4
  111. package/src/scripts/_lib/listing_manifests.ts +85 -0
  112. package/src/scripts/_lib/neighbour_census.ts +1 -0
  113. package/src/scripts/_lib/production_reach.ts +3 -3
  114. package/src/scripts/_lib/project_registry.ts +227 -0
  115. package/src/scripts/_lib/provenance_verify.ts +14 -242
  116. package/src/scripts/_lib/release_settings_surface.ts +2 -6
  117. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  118. package/src/scripts/_lib/source_id.ts +48 -0
  119. package/src/scripts/_lib/source_shape.ts +4 -0
  120. package/src/scripts/_lib/unattended_gates.ts +6 -1
  121. package/src/scripts/adoption_status.ts +231 -27
  122. package/src/scripts/advisories.ts +26 -10
  123. package/src/scripts/ai_council/council_egress.ts +5 -6
  124. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  125. package/src/scripts/build_mcp_registry_manifest.ts +23 -12
  126. package/src/scripts/check_dependency_floors.ts +14 -0
  127. package/src/scripts/check_egress_mutations.ts +1 -0
  128. package/src/scripts/check_release_manifest_content.ts +17 -2
  129. package/src/scripts/check_release_pr_shape.ts +5 -0
  130. package/src/scripts/code_graph/build.ts +145 -45
  131. package/src/scripts/code_graph/cli.ts +382 -35
  132. package/src/scripts/code_graph/conventions_default.ts +247 -0
  133. package/src/scripts/code_graph/detect.ts +5 -5
  134. package/src/scripts/code_graph/estate_cli.ts +135 -0
  135. package/src/scripts/code_graph/estate_index.ts +394 -0
  136. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  137. package/src/scripts/code_graph/estate_map.ts +141 -0
  138. package/src/scripts/code_graph/extract.ts +8 -2
  139. package/src/scripts/code_graph/extract_more.ts +241 -2
  140. package/src/scripts/code_graph/graph_budget.ts +158 -0
  141. package/src/scripts/code_graph/graph_home.ts +80 -0
  142. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  143. package/src/scripts/code_graph/loader.ts +10 -2
  144. package/src/scripts/code_graph/map.ts +195 -53
  145. package/src/scripts/code_graph/map_model.ts +113 -20
  146. package/src/scripts/code_graph/map_verify.ts +80 -5
  147. package/src/scripts/code_graph/map_viewer/viewer.css +18 -0
  148. package/src/scripts/code_graph/map_viewer/viewer.js +146 -15
  149. package/src/scripts/code_graph/passes.ts +173 -0
  150. package/src/scripts/code_graph/project_ref.ts +25 -0
  151. package/src/scripts/code_graph/projects_cli.ts +254 -0
  152. package/src/scripts/code_graph/providers/angular.ts +55 -0
  153. package/src/scripts/code_graph/providers/aspnetcore.ts +182 -0
  154. package/src/scripts/code_graph/providers/catalog.ts +299 -0
  155. package/src/scripts/code_graph/providers/django.ts +206 -0
  156. package/src/scripts/code_graph/providers/express.ts +25 -0
  157. package/src/scripts/code_graph/providers/fastapi.ts +49 -0
  158. package/src/scripts/code_graph/providers/flask.ts +99 -0
  159. package/src/scripts/code_graph/providers/go_http.ts +121 -0
  160. package/src/scripts/code_graph/providers/index.ts +103 -0
  161. package/src/scripts/code_graph/{laravel_overlay.ts → providers/laravel.ts} +113 -7
  162. package/src/scripts/code_graph/providers/nestjs.ts +34 -0
  163. package/src/scripts/code_graph/providers/nextjs.ts +91 -0
  164. package/src/scripts/code_graph/providers/nuxt.ts +55 -0
  165. package/src/scripts/code_graph/providers/rails.ts +204 -0
  166. package/src/scripts/code_graph/providers/react-router.ts +58 -0
  167. package/src/scripts/code_graph/providers/shared/detect.ts +78 -0
  168. package/src/scripts/code_graph/providers/shared/extractors.ts +249 -0
  169. package/src/scripts/code_graph/providers/shared/js.ts +252 -0
  170. package/src/scripts/code_graph/providers/shared/kit.ts +210 -0
  171. package/src/scripts/code_graph/providers/shared/php.ts +64 -0
  172. package/src/scripts/code_graph/providers/shared/python.ts +223 -0
  173. package/src/scripts/code_graph/providers/spring.ts +175 -0
  174. package/src/scripts/code_graph/providers/sqlalchemy.ts +62 -0
  175. package/src/scripts/code_graph/providers/sveltekit.ts +59 -0
  176. package/src/scripts/code_graph/providers/symfony.ts +142 -0
  177. package/src/scripts/code_graph/providers/typeorm.ts +58 -0
  178. package/src/scripts/code_graph/providers/types.ts +56 -0
  179. package/src/scripts/code_graph/providers/vue-router.ts +21 -0
  180. package/src/scripts/code_graph/providers/wordpress.ts +128 -0
  181. package/src/scripts/code_graph/query.ts +9 -9
  182. package/src/scripts/code_graph/refresh_cli.ts +81 -0
  183. package/src/scripts/code_graph/refresh_lock.ts +197 -0
  184. package/src/scripts/code_graph/repo_identity.ts +169 -0
  185. package/src/scripts/code_graph/resolution_tiers.ts +19 -7
  186. package/src/scripts/code_graph/scip_reader.ts +2 -2
  187. package/src/scripts/code_graph/sqlite_store.ts +31 -28
  188. package/src/scripts/code_graph/types.ts +79 -41
  189. package/src/scripts/code_graph/validate.ts +6 -8
  190. package/src/scripts/condense.ts +15 -15
  191. package/src/scripts/generate_settings_reference.ts +2 -6
  192. package/src/scripts/hook_chunks.mjs +11 -0
  193. package/src/scripts/hook_manifest.json +1 -1
  194. package/src/scripts/hook_manifest.yaml +43 -23
  195. package/src/scripts/hooks/code_graph_refresh_hook.ts +163 -0
  196. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  197. package/src/scripts/hooks/host_semantics.ts +72 -0
  198. package/src/scripts/hooks/lazy_concern.ts +8 -0
  199. package/src/scripts/hooks/user_notice.ts +105 -0
  200. package/src/scripts/install-hooks.sh +54 -33
  201. package/src/scripts/install.ts +2 -6
  202. package/src/scripts/lint_harvest_provenance.ts +137 -11
  203. package/src/scripts/lint_hook_manifest.ts +2 -0
  204. package/src/scripts/lint_marketplace.ts +33 -0
  205. package/src/scripts/lint_mcp_registry_manifest.ts +36 -0
  206. package/src/scripts/mcp_server/consumer_tool_catalog.json +70 -15
  207. package/src/scripts/mcp_server/graph_tools.ts +165 -41
  208. package/src/scripts/network_sources.ts +24 -6
  209. package/src/scripts/release.ts +2 -5
  210. package/src/scripts/release_drill.ts +4 -1
  211. package/src/scripts/release_env.ts +17 -0
  212. package/src/scripts/release_mix_taxonomy.json +3 -0
  213. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  214. package/src/scripts/update_notice_hook.ts +75 -7
  215. package/src/server/schemas/settings.ts +42 -42
  216. package/src/shared/settingsSurface.ts +1 -1
  217. package/src/shared/userMd/schema.ts +1 -1
  218. package/src/shared/zodJsonSchema.ts +41 -0
  219. package/src/vendor/grammars/README.md +8 -6
  220. package/src/vendor/grammars/tree-sitter-c_sharp.wasm +0 -0
  221. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  222. package/dist/cli-delegate/chunk-XIAPY44N.js +0 -4587
  223. package/dist/ui/assets/index-w70Ado09.js +0 -23
  224. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  225. /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
@@ -14,15 +14,17 @@
14
14
  * host path or anything else that differs between two builds of one commit.
15
15
  */
16
16
  import { execFileSync } from 'node:child_process';
17
- import { createHash } from 'node:crypto';
17
+ import { createHash, randomBytes } from 'node:crypto';
18
18
  import * as fs from 'node:fs';
19
19
  import * as path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
 
22
22
  import { write_atomic } from '../_lib/fs_atomic.js';
23
23
  import { hardenedSpawnEnv } from '../_lib/spawn_env.js';
24
- import { buildFromRepo } from './build.js';
25
- import { computeVerdict, graphState, NATIVE_CACHE_REL } from './detect.js';
24
+ import { buildFromRepo, readProjectProbe } from './build.js';
25
+ import { acquire as acquireRefreshLock, heldByThisProcess } from './refresh_lock.js';
26
+ import { computeVerdict, graphState } from './detect.js';
27
+ import { graphHome } from './graph_home.js';
26
28
  import {
27
29
  areaDrift,
28
30
  buildMapModel,
@@ -38,7 +40,6 @@ import type { CodeGraph } from './types.js';
38
40
  const _HERE = path.dirname(fileURLToPath(import.meta.url));
39
41
  const VIEWER_DIR = path.join(_HERE, 'map_viewer');
40
42
 
41
- export const MAP_DIR_REL = path.join('agents', 'map');
42
43
  /** Rough characters-per-token used to hold MAP.md to its budget. */
43
44
  const CHARS_PER_TOKEN = 4;
44
45
  export const MAP_TOKEN_BUDGET = 2000;
@@ -49,6 +50,33 @@ export interface MapMeta {
49
50
  commit: string;
50
51
  state: string;
51
52
  summary: string;
53
+ /**
54
+ * The graph this map was drawn from: the first 16 hex of the SHA-256 of the
55
+ * graph cache's bytes (or of the serialized graph when there is no cache).
56
+ * `graph.json`, `map.json` and `index.html` agree exactly when this matches
57
+ * {@link graphGeneration} of the current cache — the test a reader runs
58
+ * instead of comparing modification times.
59
+ */
60
+ graph_generation?: string;
61
+ }
62
+
63
+ /** The generation of a graph cache: the first 16 hex of the SHA-256 of its bytes; null when unreadable. */
64
+ export function graphGeneration(cachePath: string): string | null {
65
+ try {
66
+ return createHash('sha256').update(fs.readFileSync(cachePath)).digest('hex').slice(0, 16);
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** The generation a drawn map records, or null when it records none (an older map) or cannot be read. */
73
+ export function mapGeneration(outDir: string): string | null {
74
+ try {
75
+ const doc = JSON.parse(fs.readFileSync(path.join(outDir, 'map.json'), 'utf-8')) as { meta?: { graph_generation?: unknown } };
76
+ return typeof doc.meta?.graph_generation === 'string' ? doc.meta.graph_generation : null;
77
+ } catch {
78
+ return null;
79
+ }
52
80
  }
53
81
 
54
82
  export interface MapBuildResult {
@@ -72,32 +100,48 @@ function gitHead(root: string): string {
72
100
  }
73
101
 
74
102
  /**
75
- * Entry points a manifest declares: `package.json` `bin` / `main` / `module`,
76
- * `composer.json` `bin`. Route files and `public/index.php` are recognised by
77
- * path in `staticRole`; this only adds what a manifest names explicitly.
103
+ * Entry points the project's manifests declare, in every package root:
104
+ * `package.json` `bin` / `main` / `module` / `exports`, `composer.json` `bin`,
105
+ * `pyproject.toml` `[project.scripts]`, a Maven `<mainClass>`. Framework entry
106
+ * files are a provider's role (`roleOf`); this only adds what a manifest names.
78
107
  */
79
108
  export function declaredEntryFiles(root: string): string[] {
80
109
  const out = new Set<string>();
81
- const add = (v: unknown): void => {
82
- if (typeof v !== 'string') return;
83
- out.add(path.posix.normalize(v.replace(/^\.\//, '')));
84
- };
85
- try {
86
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf-8')) as Record<string, unknown>;
87
- const bin = pkg['bin'];
88
- if (typeof bin === 'string') add(bin);
89
- else if (bin && typeof bin === 'object') for (const v of Object.values(bin)) add(v);
90
- add(pkg['main']);
91
- add(pkg['module']);
92
- } catch {
93
- /* no package.json */
94
- }
95
- try {
96
- const composer = JSON.parse(fs.readFileSync(path.join(root, 'composer.json'), 'utf-8')) as Record<string, unknown>;
97
- const bin = composer['bin'];
98
- if (Array.isArray(bin)) for (const v of bin) add(v);
99
- } catch {
100
- /* no composer.json */
110
+ for (const [rel, text] of readProjectProbe(root).manifests) {
111
+ const dir = path.posix.dirname(rel);
112
+ const add = (v: unknown): void => {
113
+ if (typeof v !== 'string' || v.includes('*') || /^[a-z]+:/i.test(v)) return;
114
+ out.add(path.posix.normalize(path.posix.join(dir, v.replace(/^\.\//, ''))));
115
+ };
116
+ const base = path.posix.basename(rel);
117
+ try {
118
+ if (base === 'package.json') {
119
+ const pkg = JSON.parse(text) as Record<string, unknown>;
120
+ const leaves = (v: unknown): void => {
121
+ if (typeof v === 'string') add(v);
122
+ else if (v && typeof v === 'object') for (const x of Object.values(v)) leaves(x);
123
+ };
124
+ leaves(pkg['bin']);
125
+ add(pkg['main']);
126
+ add(pkg['module']);
127
+ leaves(pkg['exports']);
128
+ } else if (base === 'composer.json') {
129
+ const bin = (JSON.parse(text) as Record<string, unknown>)['bin'];
130
+ if (Array.isArray(bin)) for (const v of bin) add(v);
131
+ } else if (base === 'pyproject.toml') {
132
+ const section = /^\[project\.scripts\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(text)?.[1] ?? '';
133
+ for (const m of section.matchAll(/=\s*["']([\w.]+):[\w.]+["']/g)) {
134
+ const mod = (m[1] as string).replace(/\./g, '/');
135
+ add(`${mod}.py`);
136
+ add(`src/${mod}.py`);
137
+ }
138
+ } else if (base === 'pom.xml') {
139
+ const main = /<mainClass>\s*([\w.]+)\s*<\/mainClass>/.exec(text)?.[1];
140
+ if (main) add(`src/main/java/${main.replace(/\./g, '/')}.java`);
141
+ }
142
+ } catch {
143
+ /* an unreadable manifest declares nothing */
144
+ }
101
145
  }
102
146
  return [...out].sort();
103
147
  }
@@ -189,12 +233,14 @@ export function diffFiles(root: string, rev: string): string[] | null {
189
233
  }
190
234
 
191
235
  /** Where `code-graph outcome` records which files a session found useful — local, never sent anywhere. */
192
- export const OUTCOMES_REL = path.join('agents', 'runtime', 'state', 'map-outcomes.jsonl');
236
+ export function outcomesPath(root: string): string {
237
+ return path.join(graphHome(root).stateDir, 'map-outcomes.jsonl');
238
+ }
193
239
 
194
240
  export function readOutcomes(root: string): OutcomeEvent[] {
195
241
  let text: string;
196
242
  try {
197
- text = fs.readFileSync(path.join(root, OUTCOMES_REL), 'utf-8');
243
+ text = fs.readFileSync(outcomesPath(root), 'utf-8');
198
244
  } catch {
199
245
  return [];
200
246
  }
@@ -215,7 +261,7 @@ export function readOutcomes(root: string): OutcomeEvent[] {
215
261
  }
216
262
 
217
263
  export function recordOutcome(root: string, file: string, outcome: OutcomeEvent['outcome'], at: Date = new Date()): void {
218
- const p = path.join(root, OUTCOMES_REL);
264
+ const p = outcomesPath(root);
219
265
  fs.mkdirSync(path.dirname(p), { recursive: true });
220
266
  const rel = path.relative(fs.realpathSync(root), fs.realpathSync(path.resolve(file)));
221
267
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(`not a file inside ${root}: ${file}`);
@@ -239,9 +285,35 @@ export function openPrs(root: string): Array<{ number: number; title: string; fi
239
285
 
240
286
  export async function buildMap(
241
287
  root: string,
242
- opts: { outDir?: string; cachePath?: string; diffRev?: string; prs?: boolean; noBuild?: boolean } = {},
288
+ opts: {
289
+ outDir?: string;
290
+ cachePath?: string;
291
+ diffRev?: string;
292
+ prs?: boolean;
293
+ noBuild?: boolean;
294
+ /** Test seam: called after each artefact lands in the new generation. */
295
+ _afterWrite?: (rel: string) => void;
296
+ } = {},
243
297
  ): Promise<MapBuildResult> {
244
- const outDir = opts.outDir ?? path.join(root, MAP_DIR_REL);
298
+ // Single writer, enforced here rather than trusted to callers: one redraw per
299
+ // process at a time, and none while another process holds the refresh lock.
300
+ if (drawing) throw new Error('a map redraw is already running in this process');
301
+ const release = heldByThisProcess(root) ? (): void => {} : acquireRefreshLock(root);
302
+ if (release === null) throw new Error('another process holds the code-graph refresh lock');
303
+ drawing = true;
304
+ try {
305
+ return await drawGeneration(root, opts);
306
+ } finally {
307
+ drawing = false;
308
+ release();
309
+ }
310
+ }
311
+
312
+ let drawing = false;
313
+
314
+ async function drawGeneration(root: string, opts: Parameters<typeof buildMap>[1] = {}): Promise<MapBuildResult> {
315
+ const home = graphHome(root);
316
+ const outDir = opts.outDir ?? home.mapDir;
245
317
  // A chosen --out directory is only ever written into when it is empty or
246
318
  // already holds a map: the writer clears stale area pages, and that must
247
319
  // never reach a directory of someone else's Markdown.
@@ -253,7 +325,7 @@ export async function buildMap(
253
325
  ) {
254
326
  throw new Error(`refusing to write a map into ${outDir}: it is not empty and holds no map.json`);
255
327
  }
256
- const cachePath = opts.cachePath ?? path.join(root, NATIVE_CACHE_REL);
328
+ const cachePath = opts.cachePath ?? home.cachePath;
257
329
  const { graph, built } = await loadOrBuildGraph(root, cachePath, opts.noBuild === true);
258
330
  let diff: { rev: string; changed: string[] } | undefined;
259
331
  if (opts.diffRev !== undefined) {
@@ -283,31 +355,88 @@ export async function buildMap(
283
355
  commit: gitHead(root),
284
356
  state: graphState(root, cachePath),
285
357
  summary: '',
358
+ graph_generation: graphGeneration(cachePath) ?? createHash('sha256').update(JSON.stringify(graph)).digest('hex').slice(0, 16),
286
359
  };
287
360
  meta.summary =
288
361
  `${model.files.length} files · ${model.areas.length} areas · ` +
289
362
  `${model.analyses.trustedEdges} trusted links · guess share ${pct(model.analyses.guessShare)}`;
290
363
 
364
+ // One generation at a time: everything is written into a sibling directory,
365
+ // which then replaces the live one, so a reader never sees `map.json` of one
366
+ // build beside pages of another, and a failed redraw leaves the last one whole.
367
+ // The default map stages under the gitignored runtime dir, so a killed redraw
368
+ // leaves nothing in agents/ itself; a chosen --out stages beside itself.
369
+ const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`;
370
+ const stage = opts.outDir === undefined ? home.stateDir : path.dirname(outDir);
371
+ fs.mkdirSync(stage, { recursive: true });
372
+ const next = path.join(stage, `.map-next-${suffix}`);
373
+ const prev = path.join(stage, `.map-prev-${suffix}`);
291
374
  const written: string[] = [];
292
- const write = (rel: string, content: string): void => {
293
- const p = path.join(outDir, rel);
294
- fs.mkdirSync(path.dirname(p), { recursive: true });
295
- write_atomic(p, content);
296
- written.push(rel);
297
- };
298
- const areasDir = path.join(outDir, 'areas');
299
- if (fs.existsSync(areasDir)) {
300
- for (const f of fs.readdirSync(areasDir)) if (f.endsWith('.md')) fs.rmSync(path.join(areasDir, f));
301
- }
302
- write('map.json', `${JSON.stringify({ meta, model })}\n`);
303
- write('MAP.md', renderMapMd(model, meta));
304
- for (const [i, a] of model.areas.entries()) {
305
- if (a.files.length >= AREA_PAGE_MIN_FILES) write(path.join('areas', `${a.id}.md`), renderAreaMd(model, i, meta));
375
+ try {
376
+ const write = (rel: string, content: string): void => {
377
+ const p = path.join(next, rel);
378
+ fs.mkdirSync(path.dirname(p), { recursive: true });
379
+ write_atomic(p, content);
380
+ written.push(rel);
381
+ opts._afterWrite?.(rel);
382
+ };
383
+ write('map.json', `${JSON.stringify({ meta, model })}\n`);
384
+ write('MAP.md', renderMapMd(model, meta));
385
+ for (const [i, a] of model.areas.entries()) {
386
+ if (a.files.length >= AREA_PAGE_MIN_FILES) write(path.join('areas', `${a.id}.md`), renderAreaMd(model, i, meta));
387
+ }
388
+ write('index.html', renderHtml(model, meta));
389
+ carryForeignEntries(outDir, next);
390
+ swapIn(next, outDir, prev);
391
+ } finally {
392
+ fs.rmSync(next, { recursive: true, force: true });
306
393
  }
307
- write('index.html', renderHtml(model, meta));
308
394
  return { outDir, model, meta, written, graphBuilt: built };
309
395
  }
310
396
 
397
+ /**
398
+ * What a map generation consists of; anything else in the directory is someone else's and is
399
+ * carried over. `map-render.json` belongs to a generation although `buildMap` does not write it:
400
+ * it is `code-graph map`'s render check OF that generation, written after the swap, so the
401
+ * previous one's is dropped rather than left describing pages it never saw.
402
+ */
403
+ const GENERATION_ENTRIES = new Set(['map.json', 'MAP.md', 'index.html', 'areas', 'map-render.json']);
404
+
405
+ /** Copy entries the writer does not produce into the new generation, so the swap drops nothing. */
406
+ function carryForeignEntries(live: string, next: string): void {
407
+ if (!fs.existsSync(live)) return;
408
+ for (const entry of fs.readdirSync(live)) {
409
+ if (GENERATION_ENTRIES.has(entry)) continue;
410
+ fs.cpSync(path.join(live, entry), path.join(next, entry), { recursive: true, verbatimSymlinks: true });
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Replace `live` by `next` with two renames; on a failed second rename the old generation goes
416
+ * back. Consistent, not atomic: between the renames `live` is briefly absent, and a process
417
+ * killed there leaves the old generation at `prev` (under the gitignored runtime dir for the
418
+ * default map) — a reader sees no map, never a mixed one.
419
+ */
420
+ export function swapIn(next: string, live: string, prev: string, rename: typeof fs.renameSync = fs.renameSync): void {
421
+ fs.mkdirSync(path.dirname(live), { recursive: true });
422
+ const hadLive = fs.existsSync(live);
423
+ if (hadLive) rename(live, prev);
424
+ try {
425
+ rename(next, live);
426
+ } catch (e) {
427
+ if (!hadLive) throw e;
428
+ try {
429
+ rename(prev, live);
430
+ } catch {
431
+ throw new Error(`map swap failed and the restore failed too; the previous generation is at ${prev}`, {
432
+ cause: e,
433
+ });
434
+ }
435
+ throw e;
436
+ }
437
+ if (hadLive) fs.rmSync(prev, { recursive: true, force: true });
438
+ }
439
+
311
440
  function pct(x: number): string {
312
441
  return `${Math.round(x * 1000) / 10} %`;
313
442
  }
@@ -363,6 +492,12 @@ export function renderMapMd(m: MapModel, meta: MapMeta): string {
363
492
  `areas under ${m.thresholds.smallArea} files fold into "small parts" · an area above ` +
364
493
  `${Math.round(m.thresholds.maxAreaShare * 100)} % is split once · lists capped at ${m.thresholds.listCap}.`,
365
494
  '',
495
+ m.providers && m.providers.length > 0
496
+ ? `Convention providers: ${m.providers
497
+ .map((p) => `${md(p.name)} (${p.status}${p.roots.some((r) => r !== '') ? `, ${p.roots.map((r) => code(r || '.')).join(', ')}` : ''})`)
498
+ .join(' · ')} — plus the framework-neutral default.`
499
+ : 'Convention providers: none detected — only the framework-neutral default ran.',
500
+ '',
366
501
  '',
367
502
  ].join('\n');
368
503
 
@@ -501,7 +636,7 @@ export function renderMapMd(m: MapModel, meta: MapMeta): string {
501
636
  const rank = (r: (typeof rp)[number]): number =>
502
637
  (r.route.startsWith('GET ') ? 0 : 2) + (r.views.length > 0 ? 0 : 1);
503
638
  const withChain = rp
504
- .filter((r) => r.views.length + r.tables.length > 0)
639
+ .filter((r) => r.views.length + r.tables.length + (r.inferredTables?.length ?? 0) > 0)
505
640
  .sort((a, b) => rank(a) - rank(b) || (a.route < b.route ? -1 : a.route > b.route ? 1 : 0));
506
641
  sections.push([
507
642
  'Request paths',
@@ -509,7 +644,8 @@ export function renderMapMd(m: MapModel, meta: MapMeta): string {
509
644
  '## Request paths',
510
645
  '',
511
646
  `${m.analyses.requestPathsTotal} declared routes; ${m.analyses.requestPathsResolved} reach a handler in this tree. ` +
512
- 'Route → handler → views it renders · tables its models persist:',
647
+ 'Route → handler → views it renders · tables its models persist, up to three calls deep ' +
648
+ '(inferred: reached through a call bound by name alone):',
513
649
  '',
514
650
  ...(withChain.length ? withChain : rp).slice(0, 10).map((r) => {
515
651
  const handlerFile = path.posix.basename(f[r.handler]?.path ?? '');
@@ -517,6 +653,7 @@ export function renderMapMd(m: MapModel, meta: MapMeta): string {
517
653
  handlerFile.replace(/\.php$/, '') === r.handlerSymbol ? handlerFile : `${handlerFile}::${r.handlerSymbol}`,
518
654
  ...r.views.map((v) => path.posix.basename(f[v]?.path ?? '')),
519
655
  ...r.tables.map((t) => `table ${code(t)}`),
656
+ ...(r.inferredTables ?? []).map((t) => `table ${code(t)} (inferred)`),
520
657
  ];
521
658
  return `- ${code(r.route)} → ${chain.map((c) => (c.startsWith('table') ? c : `${code(c)}`)).join(' → ')}`;
522
659
  }),
@@ -721,6 +858,7 @@ export function renderHtml(m: MapModel, meta: MapMeta): string {
721
858
  '<button type="button" id="z-out">−</button>',
722
859
  '<button type="button" id="z-in">+</button>',
723
860
  '<button type="button" id="z-fit"></button>',
861
+ '<button type="button" id="dim" aria-pressed="false"></button>',
724
862
  '<button type="button" id="theme"></button>',
725
863
  '<button type="button" id="lang"></button>',
726
864
  '</div>',
@@ -739,7 +877,7 @@ export function renderHtml(m: MapModel, meta: MapMeta): string {
739
877
 
740
878
  /** The map a previous `code-graph map` wrote, or null when there is none. */
741
879
  export function readMap(root: string): { meta: MapMeta; model: MapModel } | null {
742
- return readMapAt(path.join(root, MAP_DIR_REL));
880
+ return readMapAt(graphHome(root).mapDir);
743
881
  }
744
882
 
745
883
  function readMapAt(dir: string): { meta: MapMeta; model: MapModel } | null {
@@ -785,7 +923,11 @@ export interface AreaReport {
785
923
  * One area, found by id (`a3`), by exact name, or by a file it contains —
786
924
  * exact matches only, so the answer is always about the area that was named.
787
925
  */
788
- export function areaReport(m: MapModel, query: string): AreaReport | { refusal: string; candidates: string[] } {
926
+ export function areaReport(
927
+ m: MapModel,
928
+ query: string,
929
+ pageDir: string = path.posix.join('agents', 'map'),
930
+ ): AreaReport | { refusal: string; candidates: string[] } {
789
931
  let i = m.areas.findIndex((a) => a.id === query || a.name === query);
790
932
  if (i < 0) {
791
933
  const f = m.files.findIndex((x) => x.path === query);
@@ -841,6 +983,6 @@ export function areaReport(m: MapModel, query: string): AreaReport | { refusal:
841
983
  .sort((x, y) => y[1] - x[1] || x[0] - y[0])
842
984
  .slice(0, cap)
843
985
  .map(([ai, w]) => ({ area: `${m.areas[ai]?.id ?? ''} ${m.areas[ai]?.name ?? ''}`, links: w })),
844
- page: a.files.length >= AREA_PAGE_MIN_FILES ? path.posix.join('agents', 'map', 'areas', `${a.id}.md`) : null,
986
+ page: a.files.length >= AREA_PAGE_MIN_FILES ? path.posix.join(pageDir, 'areas', `${a.id}.md`) : null,
845
987
  };
846
988
  }
@@ -16,7 +16,8 @@ import Graph from 'graphology';
16
16
  import louvain from 'graphology-communities-louvain';
17
17
  import forceAtlas2 from 'graphology-layout-forceatlas2';
18
18
 
19
- import type { CodeGraph, ResolvedVia } from './types.js';
19
+ import { catalogRow, type CoverageStatus, providerRole } from './providers/index.js';
20
+ import type { ActiveProviderRecord, CodeGraph, ResolvedVia } from './types.js';
20
21
  import { isStatedResolution } from './types.js';
21
22
 
22
23
  export const MAP_MODEL_VERSION = 1;
@@ -35,6 +36,8 @@ export type FileRole =
35
36
 
36
37
  /** Roles whose files take part in clustering. */
37
38
  const CLUSTER_ROLES: ReadonlySet<FileRole> = new Set(['code', 'test', 'entry', 'view']);
39
+ /** Directories of including views at which a view counts as a shared partial. */
40
+ const SHARED_PARTIAL_DIRS = 3;
38
41
 
39
42
  /** Compact symbol: [name, kind, line]. */
40
43
  export type MapSymbol = [string, string, number];
@@ -57,6 +60,16 @@ export interface MapFile {
57
60
  externals: string[];
58
61
  /** Test files that cover this file (`tests` relation). */
59
62
  tests: number[];
63
+ /** Convention providers that drew an edge into or out of this file; absent when none did. */
64
+ linkedBy?: string[];
65
+ }
66
+
67
+ /** A convention provider active in the graph, with its catalog status. */
68
+ export interface MapProvider {
69
+ id: string;
70
+ name: string;
71
+ status: CoverageStatus | 'uncatalogued';
72
+ roots: string[];
60
73
  }
61
74
 
62
75
  export interface MapArea {
@@ -81,7 +94,7 @@ export interface MapArea {
81
94
  neighbours: number[];
82
95
  }
83
96
 
84
- /** One declared route and what its handler reaches (Laravel overlay). */
97
+ /** One declared route and what its handler reaches (a convention provider). */
85
98
  export interface RequestPath {
86
99
  route: string;
87
100
  /** Index of the routes file. */
@@ -92,6 +105,8 @@ export interface RequestPath {
92
105
  views: number[];
93
106
  models: number[];
94
107
  tables: string[];
108
+ /** Tables reached only through a call bound by name alone (a `dynamic` hop with one candidate). */
109
+ inferredTables: string[];
95
110
  }
96
111
 
97
112
  export interface MapThresholds {
@@ -118,6 +133,8 @@ export interface MapModel {
118
133
  areaEdges: Array<[number, number, number]>;
119
134
  diff?: MapDiff;
120
135
  artefacts?: MapArtefacts;
136
+ /** Convention providers active in the graph; absent when none was. */
137
+ providers?: MapProvider[];
121
138
  /** Set by the map writer, not by `buildMapModel`: they need IO. */
122
139
  drift?: AreaDrift;
123
140
  prOverlap?: PrOverlap;
@@ -208,16 +225,19 @@ const CONFIG_BASENAME =
208
225
  * The role a path has before any edge is looked at. `infra` and `isolated`
209
226
  * depend on edges and are decided later; everything here is a name fact.
210
227
  */
211
- export function staticRole(p: string, entries: ReadonlySet<string>): FileRole {
228
+ export function staticRole(p: string, entries: ReadonlySet<string>, active?: readonly ActiveProviderRecord[]): FileRole {
212
229
  if (/\.mdx?$/i.test(p)) return 'doc';
213
230
  if (isTestPath(p)) return 'test';
214
231
  const base = path.posix.basename(p);
215
- if (base.endsWith('.blade.php') || p.startsWith('resources/views/') || p.includes('/resources/views/')) return 'view';
232
+ // A provider's view role outranks the neutral rules below; any other role it
233
+ // gives sits where an entry would.
234
+ const byProvider = providerRole(p, active);
235
+ if (byProvider === 'view') return 'view';
216
236
  if (/(^|\/)(database\/)?migrations\//.test(p)) return 'migration';
217
237
  if (/(^|\/)(resources\/)?lang\//.test(p)) return 'translation';
218
238
  if (p.startsWith('config/') || CONFIG_BASENAME.test(base)) return 'config';
219
- if (entries.has(p) || p.startsWith('routes/') || p === 'public/index.php' || p === 'artisan') return 'entry';
220
- return 'code';
239
+ if (entries.has(p)) return 'entry';
240
+ return byProvider ?? 'code';
221
241
  }
222
242
 
223
243
 
@@ -307,7 +327,8 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
307
327
  }
308
328
 
309
329
  // 3. Roles: names first, then the two edge-dependent ones.
310
- const roles: FileRole[] = paths.map((p) => staticRole(p, entries));
330
+ const active = graph.providers ?? [];
331
+ const roles: FileRole[] = paths.map((p) => staticRole(p, entries, active));
311
332
  // A layout every view extends couples views exactly the way a utility
312
333
  // couples code, so views are eligible for the hold-out too.
313
334
  const holdable = (r: FileRole | undefined): boolean => r === 'code' || r === 'view';
@@ -322,9 +343,29 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
322
343
  if ((inFiles[i] as number) > infraInDegree) roles[i] = 'infra';
323
344
  else if (roles[i] === 'code' && inFiles[i] === 0 && outFiles[i] === 0) roles[i] = 'isolated';
324
345
  }
346
+ // A partial that views of SHARED_PARTIAL_DIRS directories include is shared
347
+ // the way a utility is: kept in, it glues every page that shows it into one
348
+ // area and names that area after itself. Counted by including directory,
349
+ // not by in-degree, because a project's code dwarfs its views in the p99.
350
+ const includers = new Map<number, Set<string>>();
351
+ for (const [s, t] of edges) {
352
+ if (roles[s] !== 'view' || roles[t] !== 'view') continue;
353
+ const dirs = includers.get(t) ?? new Set<string>();
354
+ dirs.add(path.posix.dirname(paths[s] as string));
355
+ includers.set(t, dirs);
356
+ }
357
+ for (const [t, dirs] of includers) if (dirs.size >= SHARED_PARTIAL_DIRS) roles[t] = 'infra';
358
+ // A routes file links to every handler it routes, so in the clustering it
359
+ // is the hub of whatever it touches. It sits out the clustering and then
360
+ // joins the area most of its handlers landed in.
361
+ const routeFiles = new Set<number>();
362
+ for (const n of graph.nodes) {
363
+ const i = n.kind === 'route' ? idx.get(n.source_file) : undefined;
364
+ if (i !== undefined && CLUSTER_ROLES.has(roles[i] as FileRole)) routeFiles.add(i);
365
+ }
325
366
 
326
367
  // 4. Areas: seeded Louvain over the coupled roles.
327
- const clusterable = (i: number): boolean => CLUSTER_ROLES.has(roles[i] as FileRole);
368
+ const clusterable = (i: number): boolean => CLUSTER_ROLES.has(roles[i] as FileRole) && !routeFiles.has(i);
328
369
  const undirected = new Map<string, number>();
329
370
  for (const [s, t, w] of edges) {
330
371
  if (!clusterable(s) || !clusterable(t)) continue;
@@ -398,6 +439,15 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
398
439
  drafts.forEach((d, ai) => {
399
440
  for (const f of d.files) areaOf[f] = ai;
400
441
  });
442
+ for (const rf of [...routeFiles].sort((a, b) => a - b)) {
443
+ const pull = new Map<number, number>();
444
+ for (const [s, t, w] of edges) if (s === rf && (areaOf[t] as number) >= 0) pull.set(areaOf[t] as number, (pull.get(areaOf[t] as number) ?? 0) + w);
445
+ const best = [...pull.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0]?.[0];
446
+ const ai = best ?? drafts.push({ files: [], kind: 'small-parts', hub: rf, name: `small parts · ${topDir(paths[rf] as string)}` }) - 1;
447
+ const d = drafts[ai] as Draft;
448
+ d.files = [...d.files, rf].sort((a, b) => byPath(paths[a] as string, paths[b] as string));
449
+ areaOf[rf] = ai;
450
+ }
401
451
 
402
452
  // 6. Area metrics, area→area weights, layouts.
403
453
  const areaWeights = new Map<string, number>();
@@ -506,6 +556,22 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
506
556
  const trustedTotal = edges.reduce((n, e) => n + e[2], 0);
507
557
  const guessTotal = guessEdges.reduce((n, e) => n + e[2], 0);
508
558
 
559
+ const linkedBy = new Map<number, Set<string>>();
560
+ const sourceFileOf = new Map(graph.nodes.map((n) => [n.id, n.source_file] as const));
561
+ const fileIndexOf = (id: string): number | undefined => {
562
+ const f = sourceFileOf.get(id);
563
+ return f === undefined ? undefined : idx.get(f);
564
+ };
565
+ for (const e of graph.edges) {
566
+ if (!e.provider_id) continue;
567
+ for (const i of [fileIndexOf(e.source), fileIndexOf(e.target)]) {
568
+ if (i === undefined) continue;
569
+ const set = linkedBy.get(i) ?? new Set<string>();
570
+ set.add(e.provider_id);
571
+ linkedBy.set(i, set);
572
+ }
573
+ }
574
+
509
575
  const symbolsPerFile = 40;
510
576
  const files: MapFile[] = paths.map((p, i) => {
511
577
  const syms = (symbolsOf.get(p) ?? []).sort((a, b) => a[2] - b[2] || byPath(a[0], b[0]));
@@ -520,8 +586,13 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
520
586
  symbolsOmitted: Math.max(0, syms.length - symbolsPerFile),
521
587
  externals: [...(externals.get(i) ?? [])].sort(byPath),
522
588
  tests: [...(testsOf.get(i) ?? [])].sort((a, b) => a - b),
589
+ ...(linkedBy.has(i) ? { linkedBy: [...(linkedBy.get(i) as Set<string>)].sort() } : {}),
523
590
  };
524
591
  });
592
+ const providers: MapProvider[] = (graph.providers ?? []).map((p) => {
593
+ const row = catalogRow(p.id);
594
+ return { id: p.id, name: row?.name ?? p.id, status: row?.status ?? 'uncatalogued', roots: p.roots };
595
+ });
525
596
 
526
597
  let diff: MapDiff | undefined;
527
598
  if (inputs.diff) {
@@ -599,6 +670,7 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
599
670
  guessEdges,
600
671
  areas,
601
672
  areaEdges,
673
+ ...(providers.length > 0 ? { providers } : {}),
602
674
  ...(diff ? { diff } : {}),
603
675
  ...(artefacts ? { artefacts } : {}),
604
676
  analyses: {
@@ -623,6 +695,9 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
623
695
  * tables. Read off the overlay's edges only; a route whose handler did not
624
696
  * resolve keeps `handler: -1` rather than a guess.
625
697
  */
698
+ /** How many calls deep a request path follows the handler: handler → service → repository → model. */
699
+ const REQUEST_HOPS = 3;
700
+
626
701
  function collectRequestPaths(graph: CodeGraph, idx: ReadonlyMap<string, number>): RequestPath[] {
627
702
  const nodeById = new Map(graph.nodes.map((n) => [n.id, n] as const));
628
703
  const out = new Map<string, CodeGraph['edges']>();
@@ -648,20 +723,37 @@ function collectRequestPaths(graph: CodeGraph, idx: ReadonlyMap<string, number>)
648
723
  const views = new Set<number>();
649
724
  const models = new Set<number>();
650
725
  const tables = new Set<string>();
726
+ const inferred = new Set<string>();
651
727
  if (target) {
652
- // The handler method's own links, plus what its file imports:
653
- // a controller names its models in `use` lines, not in calls the
654
- // extractor can bind.
655
- const handlerFile = nodeById.get(target)?.source_file;
656
- const reach = [...(out.get(target) ?? []), ...(handlerFile ? (out.get(handlerFile) ?? []) : [])];
657
- for (const e of reach) {
658
- if (!isStatedResolution(e.resolved_via as ResolvedVia)) continue;
659
- if (e.relation === 'renders') views.add(fileIdx(e.target));
660
- const modelTables = tableOf.get(e.target);
661
- if (modelTables) {
662
- models.add(fileIdx(e.target));
663
- for (const t of modelTables) tables.add(t);
728
+ // What the handler's own code reaches, up to REQUEST_HOPS calls
729
+ // deep: a service, its repository, the model. Only functions and
730
+ // methods are expanded — a class's links would hand every model it
731
+ // knows to every route it serves. A chain is stated when every hop
732
+ // is; one hop bound by name alone makes what it reaches inferred.
733
+ const best = new Map<string, boolean>([[target, true]]);
734
+ let frontier: Array<[string, boolean]> = [[target, true]];
735
+ for (let hop = 0; hop < REQUEST_HOPS && frontier.length > 0; hop++) {
736
+ const next: Array<[string, boolean]> = [];
737
+ for (const [id, stated] of frontier) {
738
+ for (const e of out.get(id) ?? []) {
739
+ const sure = isStatedResolution(e.resolved_via as ResolvedVia);
740
+ if (!sure && !(e.resolved_via === 'dynamic' && nodeById.has(e.target))) continue;
741
+ const chain = stated && sure;
742
+ if (e.relation === 'renders' && chain) views.add(fileIdx(e.target));
743
+ const model = tableOf.has(e.target) ? e.target : e.target.split('::')[0];
744
+ const modelTables = model ? tableOf.get(model) : undefined;
745
+ if (modelTables) {
746
+ if (chain) models.add(fileIdx(model as string));
747
+ for (const t of modelTables) (chain ? tables : inferred).add(t);
748
+ }
749
+ const kind = nodeById.get(e.target)?.kind;
750
+ if (e.relation !== 'calls' || (kind !== 'method' && kind !== 'function')) continue;
751
+ if (best.get(e.target) === true || best.get(e.target) === chain) continue;
752
+ best.set(e.target, chain);
753
+ next.push([e.target, chain]);
754
+ }
664
755
  }
756
+ frontier = next;
665
757
  }
666
758
  }
667
759
  paths.push({
@@ -672,6 +764,7 @@ function collectRequestPaths(graph: CodeGraph, idx: ReadonlyMap<string, number>)
672
764
  views: [...views].filter((v) => v >= 0).sort((a, b) => a - b),
673
765
  models: [...models].filter((v) => v >= 0).sort((a, b) => a - b),
674
766
  tables: [...tables].sort(),
767
+ inferredTables: [...inferred].filter((t) => !tables.has(t)).sort(),
675
768
  });
676
769
  }
677
770
  return paths.sort((a, b) => (a.route < b.route ? -1 : a.route > b.route ? 1 : a.file - b.file));