@event4u/agent-config 17.2.1 → 17.3.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 (152) 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 +152 -0
  6. package/dist/agent-src/skills/code-intelligence/SKILL.md +13 -12
  7. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  8. package/dist/agent-src/templates/scripts/check_memory.ts +1 -1
  9. package/dist/cli/commands/recordTriggerEval.js +1 -1
  10. package/dist/cli-delegate/{chunk-KR5ZIFPO.js → chunk-DHSSP7V3.js} +1 -1
  11. package/dist/cli-delegate/chunk-E7ACTJTJ.js +6792 -0
  12. package/dist/cli-delegate/{chunk-BZJ4Y3CI.js → chunk-MT2WOOCY.js} +1 -1
  13. package/dist/cli-delegate/{chunk-CMZEKSNN.js → chunk-ORJPBER2.js} +2 -2
  14. package/dist/cli-delegate/{chunk-VYZGJLCA.js → chunk-RKNT4LIK.js} +22 -1286
  15. package/dist/cli-delegate/cmd_conformance.js +20 -14
  16. package/dist/cli-delegate/cmd_doctor.js +3 -3
  17. package/dist/cli-delegate/cmd_doctor_neighbours.js +2 -2
  18. package/dist/cli-delegate/cmd_explain.js +1 -1
  19. package/dist/cli-delegate/cmd_export.js +2 -2
  20. package/dist/cli-delegate/cmd_prune.js +2 -2
  21. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  22. package/dist/cli-delegate/cmd_settings_set.js +8 -4
  23. package/dist/cli-delegate/cmd_sync.js +2 -2
  24. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  25. package/dist/cli-delegate/cmd_validate.js +2 -2
  26. package/dist/discovery/deprecation-report.md +1 -1
  27. package/dist/discovery/discovery-manifest.json +3 -3
  28. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  29. package/dist/discovery/discovery-manifest.summary.md +1 -1
  30. package/dist/discovery/dormancy-report.md +1 -1
  31. package/dist/discovery/orphan-report.md +1 -1
  32. package/dist/discovery/packs.json +2 -2
  33. package/dist/discovery/trust-report.md +1 -1
  34. package/dist/discovery/workspaces.json +2 -2
  35. package/dist/hooks/code_graph_refresh.chunk.cjs +414 -0
  36. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -0
  37. package/dist/hooks/dispatch.js +21 -11
  38. package/dist/hooks/dispatch.sha256 +1 -1
  39. package/dist/hooks/egress_engine.chunk.cjs +1 -1
  40. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  41. package/dist/install/install.mjs +6974 -6037
  42. package/dist/mcp/mcp-cloudflare-catalogue.json +1 -0
  43. package/dist/mcp/registry-manifest.json +3 -2
  44. package/dist/mcp/server.json +13 -4
  45. package/dist/mcp/server.mjs +5561 -2463
  46. package/dist/scripts/_lib/egress_rules.js +8 -2
  47. package/dist/scripts/condense.js +9 -9
  48. package/dist/server/routes/discovery.js +1 -1
  49. package/dist/server/routes/install.js +1 -1
  50. package/dist/server/routes/ping.js +1 -1
  51. package/dist/server/routes/schema.js +4 -4
  52. package/dist/server/routes/settings.js +2 -6
  53. package/dist/server/routes/wizard.js +6 -6
  54. package/dist/server/schemas/settings.js +42 -42
  55. package/dist/shared/settingsSurface.js +1 -1
  56. package/dist/shared/userMd/schema.js +1 -1
  57. package/dist/shared/zodJsonSchema.js +47 -0
  58. package/dist/ui/assets/index-B0bcIgi0.js +77 -0
  59. package/dist/ui/index.html +1 -1
  60. package/package.json +3 -3
  61. package/src/agent-src/templates/scripts/check_memory.ts +1 -1
  62. package/src/config/ci-local-parity.yml +11 -0
  63. package/src/config/continuity-surface.json +9 -2
  64. package/src/config/gate-violation-baselines.json +3 -3
  65. package/src/config/gitignore-block.txt +6 -5
  66. package/src/config/hook-bundle-budget.json +16 -1
  67. package/src/config/pack-size-budget.json +12 -1
  68. package/src/config/packed-binary-manifest.json +9 -0
  69. package/src/scripts/_cli/cmd_settings_set.ts +3 -2
  70. package/src/scripts/_dispatch.bash +1 -1
  71. package/src/scripts/_lib/egress_rules.ts +8 -2
  72. package/src/scripts/_lib/listing_manifests.ts +85 -0
  73. package/src/scripts/_lib/neighbour_census.ts +1 -0
  74. package/src/scripts/_lib/release_settings_surface.ts +2 -6
  75. package/src/scripts/adoption_status.ts +231 -27
  76. package/src/scripts/build_mcp_registry_manifest.ts +23 -12
  77. package/src/scripts/check_dependency_floors.ts +7 -0
  78. package/src/scripts/check_release_manifest_content.ts +17 -2
  79. package/src/scripts/check_release_pr_shape.ts +5 -0
  80. package/src/scripts/code_graph/build.ts +80 -44
  81. package/src/scripts/code_graph/cli.ts +260 -16
  82. package/src/scripts/code_graph/conventions_default.ts +228 -0
  83. package/src/scripts/code_graph/extract.ts +5 -2
  84. package/src/scripts/code_graph/extract_more.ts +105 -1
  85. package/src/scripts/code_graph/loader.ts +10 -2
  86. package/src/scripts/code_graph/map.ts +174 -41
  87. package/src/scripts/code_graph/map_model.ts +53 -15
  88. package/src/scripts/code_graph/map_verify.ts +12 -0
  89. package/src/scripts/code_graph/map_viewer/viewer.css +6 -0
  90. package/src/scripts/code_graph/map_viewer/viewer.js +114 -13
  91. package/src/scripts/code_graph/passes.ts +173 -0
  92. package/src/scripts/code_graph/providers/angular.ts +55 -0
  93. package/src/scripts/code_graph/providers/aspnetcore.ts +182 -0
  94. package/src/scripts/code_graph/providers/catalog.ts +290 -0
  95. package/src/scripts/code_graph/providers/django.ts +163 -0
  96. package/src/scripts/code_graph/providers/express.ts +25 -0
  97. package/src/scripts/code_graph/providers/fastapi.ts +49 -0
  98. package/src/scripts/code_graph/providers/flask.ts +99 -0
  99. package/src/scripts/code_graph/providers/go_http.ts +121 -0
  100. package/src/scripts/code_graph/providers/index.ts +101 -0
  101. package/src/scripts/code_graph/{laravel_overlay.ts → providers/laravel.ts} +113 -7
  102. package/src/scripts/code_graph/providers/nestjs.ts +34 -0
  103. package/src/scripts/code_graph/providers/nextjs.ts +91 -0
  104. package/src/scripts/code_graph/providers/nuxt.ts +55 -0
  105. package/src/scripts/code_graph/providers/react-router.ts +58 -0
  106. package/src/scripts/code_graph/providers/shared/detect.ts +78 -0
  107. package/src/scripts/code_graph/providers/shared/extractors.ts +249 -0
  108. package/src/scripts/code_graph/providers/shared/js.ts +252 -0
  109. package/src/scripts/code_graph/providers/shared/kit.ts +210 -0
  110. package/src/scripts/code_graph/providers/shared/php.ts +64 -0
  111. package/src/scripts/code_graph/providers/shared/python.ts +223 -0
  112. package/src/scripts/code_graph/providers/spring.ts +138 -0
  113. package/src/scripts/code_graph/providers/sqlalchemy.ts +62 -0
  114. package/src/scripts/code_graph/providers/sveltekit.ts +59 -0
  115. package/src/scripts/code_graph/providers/symfony.ts +142 -0
  116. package/src/scripts/code_graph/providers/typeorm.ts +58 -0
  117. package/src/scripts/code_graph/providers/types.ts +56 -0
  118. package/src/scripts/code_graph/providers/vue-router.ts +21 -0
  119. package/src/scripts/code_graph/providers/wordpress.ts +128 -0
  120. package/src/scripts/code_graph/refresh_cli.ts +81 -0
  121. package/src/scripts/code_graph/refresh_lock.ts +193 -0
  122. package/src/scripts/code_graph/resolution_tiers.ts +19 -7
  123. package/src/scripts/code_graph/scip_reader.ts +2 -2
  124. package/src/scripts/code_graph/sqlite_store.ts +10 -4
  125. package/src/scripts/code_graph/types.ts +75 -41
  126. package/src/scripts/code_graph/validate.ts +6 -8
  127. package/src/scripts/condense.ts +9 -9
  128. package/src/scripts/generate_settings_reference.ts +2 -6
  129. package/src/scripts/hook_chunks.mjs +4 -0
  130. package/src/scripts/hook_manifest.json +1 -1
  131. package/src/scripts/hook_manifest.yaml +24 -7
  132. package/src/scripts/hooks/code_graph_refresh_hook.ts +160 -0
  133. package/src/scripts/hooks/lazy_concern.ts +4 -0
  134. package/src/scripts/install-hooks.sh +54 -33
  135. package/src/scripts/install.ts +2 -6
  136. package/src/scripts/lint_hook_manifest.ts +2 -0
  137. package/src/scripts/lint_marketplace.ts +33 -0
  138. package/src/scripts/lint_mcp_registry_manifest.ts +36 -0
  139. package/src/scripts/mcp_server/consumer_tool_catalog.json +1 -0
  140. package/src/scripts/mcp_server/graph_tools.ts +5 -1
  141. package/src/scripts/release.ts +2 -5
  142. package/src/scripts/release_drill.ts +4 -1
  143. package/src/scripts/release_env.ts +17 -0
  144. package/src/scripts/release_mix_taxonomy.json +3 -0
  145. package/src/server/schemas/settings.ts +42 -42
  146. package/src/shared/settingsSurface.ts +1 -1
  147. package/src/shared/userMd/schema.ts +1 -1
  148. package/src/shared/zodJsonSchema.ts +41 -0
  149. package/src/vendor/grammars/README.md +6 -5
  150. package/src/vendor/grammars/tree-sitter-c_sharp.wasm +0 -0
  151. package/dist/cli-delegate/chunk-XIAPY44N.js +0 -4587
  152. package/dist/ui/assets/index-w70Ado09.js +0 -23
@@ -14,14 +14,15 @@
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';
24
+ import { buildFromRepo, readProjectProbe } from './build.js';
25
+ import { acquire as acquireRefreshLock, heldByThisProcess } from './refresh_lock.js';
25
26
  import { computeVerdict, graphState, NATIVE_CACHE_REL } from './detect.js';
26
27
  import {
27
28
  areaDrift,
@@ -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
  }
@@ -239,8 +283,33 @@ export function openPrs(root: string): Array<{ number: number; title: string; fi
239
283
 
240
284
  export async function buildMap(
241
285
  root: string,
242
- opts: { outDir?: string; cachePath?: string; diffRev?: string; prs?: boolean; noBuild?: boolean } = {},
286
+ opts: {
287
+ outDir?: string;
288
+ cachePath?: string;
289
+ diffRev?: string;
290
+ prs?: boolean;
291
+ noBuild?: boolean;
292
+ /** Test seam: called after each artefact lands in the new generation. */
293
+ _afterWrite?: (rel: string) => void;
294
+ } = {},
243
295
  ): Promise<MapBuildResult> {
296
+ // Single writer, enforced here rather than trusted to callers: one redraw per
297
+ // process at a time, and none while another process holds the refresh lock.
298
+ if (drawing) throw new Error('a map redraw is already running in this process');
299
+ const release = heldByThisProcess(root) ? (): void => {} : acquireRefreshLock(root);
300
+ if (release === null) throw new Error('another process holds the code-graph refresh lock');
301
+ drawing = true;
302
+ try {
303
+ return await drawGeneration(root, opts);
304
+ } finally {
305
+ drawing = false;
306
+ release();
307
+ }
308
+ }
309
+
310
+ let drawing = false;
311
+
312
+ async function drawGeneration(root: string, opts: Parameters<typeof buildMap>[1] = {}): Promise<MapBuildResult> {
244
313
  const outDir = opts.outDir ?? path.join(root, MAP_DIR_REL);
245
314
  // A chosen --out directory is only ever written into when it is empty or
246
315
  // already holds a map: the writer clears stale area pages, and that must
@@ -283,31 +352,88 @@ export async function buildMap(
283
352
  commit: gitHead(root),
284
353
  state: graphState(root, cachePath),
285
354
  summary: '',
355
+ graph_generation: graphGeneration(cachePath) ?? createHash('sha256').update(JSON.stringify(graph)).digest('hex').slice(0, 16),
286
356
  };
287
357
  meta.summary =
288
358
  `${model.files.length} files · ${model.areas.length} areas · ` +
289
359
  `${model.analyses.trustedEdges} trusted links · guess share ${pct(model.analyses.guessShare)}`;
290
360
 
361
+ // One generation at a time: everything is written into a sibling directory,
362
+ // which then replaces the live one, so a reader never sees `map.json` of one
363
+ // build beside pages of another, and a failed redraw leaves the last one whole.
364
+ // The default map stages under the gitignored runtime dir, so a killed redraw
365
+ // leaves nothing in agents/ itself; a chosen --out stages beside itself.
366
+ const suffix = `${process.pid}-${randomBytes(4).toString('hex')}`;
367
+ const stage = opts.outDir === undefined ? path.join(root, 'agents', 'runtime', 'state') : path.dirname(outDir);
368
+ fs.mkdirSync(stage, { recursive: true });
369
+ const next = path.join(stage, `.map-next-${suffix}`);
370
+ const prev = path.join(stage, `.map-prev-${suffix}`);
291
371
  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));
372
+ try {
373
+ const write = (rel: string, content: string): void => {
374
+ const p = path.join(next, rel);
375
+ fs.mkdirSync(path.dirname(p), { recursive: true });
376
+ write_atomic(p, content);
377
+ written.push(rel);
378
+ opts._afterWrite?.(rel);
379
+ };
380
+ write('map.json', `${JSON.stringify({ meta, model })}\n`);
381
+ write('MAP.md', renderMapMd(model, meta));
382
+ for (const [i, a] of model.areas.entries()) {
383
+ if (a.files.length >= AREA_PAGE_MIN_FILES) write(path.join('areas', `${a.id}.md`), renderAreaMd(model, i, meta));
384
+ }
385
+ write('index.html', renderHtml(model, meta));
386
+ carryForeignEntries(outDir, next);
387
+ swapIn(next, outDir, prev);
388
+ } finally {
389
+ fs.rmSync(next, { recursive: true, force: true });
306
390
  }
307
- write('index.html', renderHtml(model, meta));
308
391
  return { outDir, model, meta, written, graphBuilt: built };
309
392
  }
310
393
 
394
+ /**
395
+ * What a map generation consists of; anything else in the directory is someone else's and is
396
+ * carried over. `map-render.json` belongs to a generation although `buildMap` does not write it:
397
+ * it is `code-graph map`'s render check OF that generation, written after the swap, so the
398
+ * previous one's is dropped rather than left describing pages it never saw.
399
+ */
400
+ const GENERATION_ENTRIES = new Set(['map.json', 'MAP.md', 'index.html', 'areas', 'map-render.json']);
401
+
402
+ /** Copy entries the writer does not produce into the new generation, so the swap drops nothing. */
403
+ function carryForeignEntries(live: string, next: string): void {
404
+ if (!fs.existsSync(live)) return;
405
+ for (const entry of fs.readdirSync(live)) {
406
+ if (GENERATION_ENTRIES.has(entry)) continue;
407
+ fs.cpSync(path.join(live, entry), path.join(next, entry), { recursive: true, verbatimSymlinks: true });
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Replace `live` by `next` with two renames; on a failed second rename the old generation goes
413
+ * back. Consistent, not atomic: between the renames `live` is briefly absent, and a process
414
+ * killed there leaves the old generation at `prev` (under the gitignored runtime dir for the
415
+ * default map) — a reader sees no map, never a mixed one.
416
+ */
417
+ export function swapIn(next: string, live: string, prev: string, rename: typeof fs.renameSync = fs.renameSync): void {
418
+ fs.mkdirSync(path.dirname(live), { recursive: true });
419
+ const hadLive = fs.existsSync(live);
420
+ if (hadLive) rename(live, prev);
421
+ try {
422
+ rename(next, live);
423
+ } catch (e) {
424
+ if (!hadLive) throw e;
425
+ try {
426
+ rename(prev, live);
427
+ } catch {
428
+ throw new Error(`map swap failed and the restore failed too; the previous generation is at ${prev}`, {
429
+ cause: e,
430
+ });
431
+ }
432
+ throw e;
433
+ }
434
+ if (hadLive) fs.rmSync(prev, { recursive: true, force: true });
435
+ }
436
+
311
437
  function pct(x: number): string {
312
438
  return `${Math.round(x * 1000) / 10} %`;
313
439
  }
@@ -363,6 +489,12 @@ export function renderMapMd(m: MapModel, meta: MapMeta): string {
363
489
  `areas under ${m.thresholds.smallArea} files fold into "small parts" · an area above ` +
364
490
  `${Math.round(m.thresholds.maxAreaShare * 100)} % is split once · lists capped at ${m.thresholds.listCap}.`,
365
491
  '',
492
+ m.providers && m.providers.length > 0
493
+ ? `Convention providers: ${m.providers
494
+ .map((p) => `${md(p.name)} (${p.status}${p.roots.some((r) => r !== '') ? `, ${p.roots.map((r) => code(r || '.')).join(', ')}` : ''})`)
495
+ .join(' · ')} — plus the framework-neutral default.`
496
+ : 'Convention providers: none detected — only the framework-neutral default ran.',
497
+ '',
366
498
  '',
367
499
  ].join('\n');
368
500
 
@@ -721,6 +853,7 @@ export function renderHtml(m: MapModel, meta: MapMeta): string {
721
853
  '<button type="button" id="z-out">−</button>',
722
854
  '<button type="button" id="z-in">+</button>',
723
855
  '<button type="button" id="z-fit"></button>',
856
+ '<button type="button" id="dim" aria-pressed="false"></button>',
724
857
  '<button type="button" id="theme"></button>',
725
858
  '<button type="button" id="lang"></button>',
726
859
  '</div>',
@@ -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;
@@ -57,6 +58,16 @@ export interface MapFile {
57
58
  externals: string[];
58
59
  /** Test files that cover this file (`tests` relation). */
59
60
  tests: number[];
61
+ /** Convention providers that drew an edge into or out of this file; absent when none did. */
62
+ linkedBy?: string[];
63
+ }
64
+
65
+ /** A convention provider active in the graph, with its catalog status. */
66
+ export interface MapProvider {
67
+ id: string;
68
+ name: string;
69
+ status: CoverageStatus | 'uncatalogued';
70
+ roots: string[];
60
71
  }
61
72
 
62
73
  export interface MapArea {
@@ -81,7 +92,7 @@ export interface MapArea {
81
92
  neighbours: number[];
82
93
  }
83
94
 
84
- /** One declared route and what its handler reaches (Laravel overlay). */
95
+ /** One declared route and what its handler reaches (a convention provider). */
85
96
  export interface RequestPath {
86
97
  route: string;
87
98
  /** Index of the routes file. */
@@ -118,6 +129,8 @@ export interface MapModel {
118
129
  areaEdges: Array<[number, number, number]>;
119
130
  diff?: MapDiff;
120
131
  artefacts?: MapArtefacts;
132
+ /** Convention providers active in the graph; absent when none was. */
133
+ providers?: MapProvider[];
121
134
  /** Set by the map writer, not by `buildMapModel`: they need IO. */
122
135
  drift?: AreaDrift;
123
136
  prOverlap?: PrOverlap;
@@ -208,16 +221,19 @@ const CONFIG_BASENAME =
208
221
  * The role a path has before any edge is looked at. `infra` and `isolated`
209
222
  * depend on edges and are decided later; everything here is a name fact.
210
223
  */
211
- export function staticRole(p: string, entries: ReadonlySet<string>): FileRole {
224
+ export function staticRole(p: string, entries: ReadonlySet<string>, active?: readonly ActiveProviderRecord[]): FileRole {
212
225
  if (/\.mdx?$/i.test(p)) return 'doc';
213
226
  if (isTestPath(p)) return 'test';
214
227
  const base = path.posix.basename(p);
215
- if (base.endsWith('.blade.php') || p.startsWith('resources/views/') || p.includes('/resources/views/')) return 'view';
228
+ // A provider's view role outranks the neutral rules below; any other role it
229
+ // gives sits where an entry would.
230
+ const byProvider = providerRole(p, active);
231
+ if (byProvider === 'view') return 'view';
216
232
  if (/(^|\/)(database\/)?migrations\//.test(p)) return 'migration';
217
233
  if (/(^|\/)(resources\/)?lang\//.test(p)) return 'translation';
218
234
  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';
235
+ if (entries.has(p)) return 'entry';
236
+ return byProvider ?? 'code';
221
237
  }
222
238
 
223
239
 
@@ -307,7 +323,8 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
307
323
  }
308
324
 
309
325
  // 3. Roles: names first, then the two edge-dependent ones.
310
- const roles: FileRole[] = paths.map((p) => staticRole(p, entries));
326
+ const active = graph.providers ?? [];
327
+ const roles: FileRole[] = paths.map((p) => staticRole(p, entries, active));
311
328
  // A layout every view extends couples views exactly the way a utility
312
329
  // couples code, so views are eligible for the hold-out too.
313
330
  const holdable = (r: FileRole | undefined): boolean => r === 'code' || r === 'view';
@@ -506,6 +523,22 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
506
523
  const trustedTotal = edges.reduce((n, e) => n + e[2], 0);
507
524
  const guessTotal = guessEdges.reduce((n, e) => n + e[2], 0);
508
525
 
526
+ const linkedBy = new Map<number, Set<string>>();
527
+ const sourceFileOf = new Map(graph.nodes.map((n) => [n.id, n.source_file] as const));
528
+ const fileIndexOf = (id: string): number | undefined => {
529
+ const f = sourceFileOf.get(id);
530
+ return f === undefined ? undefined : idx.get(f);
531
+ };
532
+ for (const e of graph.edges) {
533
+ if (!e.provider_id) continue;
534
+ for (const i of [fileIndexOf(e.source), fileIndexOf(e.target)]) {
535
+ if (i === undefined) continue;
536
+ const set = linkedBy.get(i) ?? new Set<string>();
537
+ set.add(e.provider_id);
538
+ linkedBy.set(i, set);
539
+ }
540
+ }
541
+
509
542
  const symbolsPerFile = 40;
510
543
  const files: MapFile[] = paths.map((p, i) => {
511
544
  const syms = (symbolsOf.get(p) ?? []).sort((a, b) => a[2] - b[2] || byPath(a[0], b[0]));
@@ -520,8 +553,13 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
520
553
  symbolsOmitted: Math.max(0, syms.length - symbolsPerFile),
521
554
  externals: [...(externals.get(i) ?? [])].sort(byPath),
522
555
  tests: [...(testsOf.get(i) ?? [])].sort((a, b) => a - b),
556
+ ...(linkedBy.has(i) ? { linkedBy: [...(linkedBy.get(i) as Set<string>)].sort() } : {}),
523
557
  };
524
558
  });
559
+ const providers: MapProvider[] = (graph.providers ?? []).map((p) => {
560
+ const row = catalogRow(p.id);
561
+ return { id: p.id, name: row?.name ?? p.id, status: row?.status ?? 'uncatalogued', roots: p.roots };
562
+ });
525
563
 
526
564
  let diff: MapDiff | undefined;
527
565
  if (inputs.diff) {
@@ -599,6 +637,7 @@ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapMode
599
637
  guessEdges,
600
638
  areas,
601
639
  areaEdges,
640
+ ...(providers.length > 0 ? { providers } : {}),
602
641
  ...(diff ? { diff } : {}),
603
642
  ...(artefacts ? { artefacts } : {}),
604
643
  analyses: {
@@ -649,17 +688,16 @@ function collectRequestPaths(graph: CodeGraph, idx: ReadonlyMap<string, number>)
649
688
  const models = new Set<number>();
650
689
  const tables = new Set<string>();
651
690
  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) {
691
+ // The handler's own links only: what this route's code renders and
692
+ // names. Its file's links would hand every model the controller
693
+ // imports to every route the controller serves.
694
+ for (const e of out.get(target) ?? []) {
658
695
  if (!isStatedResolution(e.resolved_via as ResolvedVia)) continue;
659
696
  if (e.relation === 'renders') views.add(fileIdx(e.target));
660
- const modelTables = tableOf.get(e.target);
697
+ const model = tableOf.has(e.target) ? e.target : e.target.split('::')[0];
698
+ const modelTables = model ? tableOf.get(model) : undefined;
661
699
  if (modelTables) {
662
- models.add(fileIdx(e.target));
700
+ models.add(fileIdx(model as string));
663
701
  for (const t of modelTables) tables.add(t);
664
702
  }
665
703
  }
@@ -121,6 +121,18 @@ export async function verifyMap(htmlPath: string): Promise<MapRenderCheck> {
121
121
  return { view, labels: r.boxes.length, ...labelProblems(r.boxes, r.width, r.height) };
122
122
  };
123
123
  const views = [await read('overview')];
124
+ // The orb, as it opens and turned half a revolution: the far side's labels
125
+ // must stay hidden and the near side's must still fit and not overlap.
126
+ const orb = page.locator('#dim');
127
+ if ((await orb.count()) > 0) {
128
+ await orb.click();
129
+ views.push(await read('overview-orb'));
130
+ await page.evaluate(() => {
131
+ for (let i = 0; i < 21; i++) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
132
+ });
133
+ views.push(await read('overview-orb-turned'));
134
+ await orb.click();
135
+ }
124
136
  const firstArea = page.locator('#panel .plist button').first();
125
137
  if ((await firstArea.count()) > 0) {
126
138
  await firstArea.click();
@@ -12,6 +12,8 @@
12
12
  --change: #d1495b;
13
13
  --good: #2e8b57;
14
14
  --contested: #d98b1d;
15
+ --orb-light: rgba(255, 255, 255, 0.95);
16
+ --orb-dark: rgba(214, 220, 232, 0.55);
15
17
  }
16
18
  @media (prefers-color-scheme: dark) {
17
19
  :root:not([data-theme='light']) {
@@ -28,6 +30,8 @@
28
30
  --change: #ff7a8a;
29
31
  --good: #5fd39a;
30
32
  --contested: #ffb347;
33
+ --orb-light: rgba(64, 72, 90, 0.55);
34
+ --orb-dark: rgba(20, 22, 26, 0.2);
31
35
  }
32
36
  }
33
37
  :root[data-theme='dark'] {
@@ -44,6 +48,8 @@
44
48
  --change: #ff7a8a;
45
49
  --good: #5fd39a;
46
50
  --contested: #ffb347;
51
+ --orb-light: rgba(64, 72, 90, 0.55);
52
+ --orb-dark: rgba(20, 22, 26, 0.2);
47
53
  }
48
54
  * {
49
55
  box-sizing: border-box;