@event4u/agent-config 17.1.0 → 17.2.1

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 (73) 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 +92 -0
  5. package/README.md +2 -2
  6. package/dist/agent-src/commands/project/analyze.md +9 -6
  7. package/dist/agent-src/commands/project/map.md +58 -0
  8. package/dist/agent-src/commands/project.md +5 -3
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +6 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -1
  12. package/dist/cli-delegate/cmd_conformance.js +23 -39
  13. package/dist/discovery/deprecation-report.md +1 -1
  14. package/dist/discovery/discovery-manifest.json +39 -13
  15. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  16. package/dist/discovery/discovery-manifest.summary.md +3 -3
  17. package/dist/discovery/dormancy-report.md +1 -1
  18. package/dist/discovery/orphan-report.md +1 -1
  19. package/dist/discovery/packs.json +6 -5
  20. package/dist/discovery/trust-report.md +2 -2
  21. package/dist/discovery/workspaces.json +5 -4
  22. package/dist/hooks/dispatch.js +65 -74
  23. package/dist/hooks/dispatch.sha256 +1 -1
  24. package/dist/hooks/instructions_observer.chunk.cjs +301 -0
  25. package/dist/hooks/instructions_observer.chunk.cjs.sha256 +1 -0
  26. package/dist/mcp/registry-manifest.json +2 -2
  27. package/dist/mcp/server.json +2 -2
  28. package/dist/mcp/server.mjs +33058 -22930
  29. package/package.json +4 -1
  30. package/src/config/continuity-surface.json +7 -0
  31. package/src/config/evaluator-budgets.json +4 -4
  32. package/src/config/gate-violation-baselines.json +3 -3
  33. package/src/config/gitignore-block.txt +6 -0
  34. package/src/config/hook-bundle-budget.json +17 -2
  35. package/src/config/hook-token-budget.json +1 -1
  36. package/src/config/packed-binary-manifest.json +27 -0
  37. package/src/config/skill-body-budget.json +7 -1
  38. package/src/scripts/_lib/bench_ab_complexity.ts +14 -2
  39. package/src/scripts/_lib/collector_record.ts +1 -0
  40. package/src/scripts/_lib/runtime_journal.ts +7 -7
  41. package/src/scripts/code_graph/build.ts +210 -10
  42. package/src/scripts/code_graph/cli.ts +168 -46
  43. package/src/scripts/code_graph/detect.ts +5 -4
  44. package/src/scripts/code_graph/docs_layer.ts +57 -0
  45. package/src/scripts/code_graph/extract.ts +58 -13
  46. package/src/scripts/code_graph/extract_more.ts +314 -0
  47. package/src/scripts/code_graph/laravel_overlay.ts +454 -0
  48. package/src/scripts/code_graph/map.ts +846 -0
  49. package/src/scripts/code_graph/map_model.ts +977 -0
  50. package/src/scripts/code_graph/map_verify.ts +140 -0
  51. package/src/scripts/code_graph/map_viewer/viewer.css +278 -0
  52. package/src/scripts/code_graph/map_viewer/viewer.js +756 -0
  53. package/src/scripts/code_graph/scip_reader.ts +201 -0
  54. package/src/scripts/code_graph/sqlite_store.ts +8 -1
  55. package/src/scripts/code_graph/types.ts +81 -15
  56. package/src/scripts/code_graph/validate.ts +16 -26
  57. package/src/scripts/code_graph/verbs.ts +7 -0
  58. package/src/scripts/hook_chunks.mjs +7 -0
  59. package/src/scripts/hook_manifest.json +1 -1
  60. package/src/scripts/hook_manifest.yaml +20 -0
  61. package/src/scripts/hooks/dispatch_hook.ts +2 -2
  62. package/src/scripts/hooks/instructions_observer_hook.ts +25 -6
  63. package/src/scripts/hooks/lazy_concern.ts +4 -0
  64. package/src/scripts/install-hooks.sh +5 -0
  65. package/src/scripts/lint_agents_layout.ts +2 -0
  66. package/src/scripts/lint_hook_manifest.ts +1 -0
  67. package/src/scripts/mcp_server/consumer_tool_catalog.json +53 -6
  68. package/src/scripts/mcp_server/graph_tools.ts +73 -1
  69. package/src/scripts/mcp_server/tools.ts +19 -36
  70. package/src/vendor/grammars/README.md +10 -5
  71. package/src/vendor/grammars/tree-sitter-go.wasm +0 -0
  72. package/src/vendor/grammars/tree-sitter-java.wasm +0 -0
  73. package/src/vendor/grammars/tree-sitter-python.wasm +0 -0
@@ -0,0 +1,977 @@
1
+ /**
2
+ * The map model: a pure projection `CodeGraph → MapModel`.
3
+ *
4
+ * Not a second engine. Every file, edge and count here is read off the graph
5
+ * the engine already built; this module only folds symbols into files, gives
6
+ * each file one role, groups the coupled files into areas and lays them out.
7
+ * Same graph in, same bytes out: the clustering RNG is seeded, every list is
8
+ * sorted by path before it is consumed, and coordinates are rounded.
9
+ *
10
+ * What it deliberately does NOT do (road-to-a-map-that-draws-itself K1-K4):
11
+ * no model call, no network, no physics left for the browser.
12
+ */
13
+ import * as path from 'node:path';
14
+
15
+ import Graph from 'graphology';
16
+ import louvain from 'graphology-communities-louvain';
17
+ import forceAtlas2 from 'graphology-layout-forceatlas2';
18
+
19
+ import type { CodeGraph, ResolvedVia } from './types.js';
20
+ import { isStatedResolution } from './types.js';
21
+
22
+ export const MAP_MODEL_VERSION = 1;
23
+
24
+ export type FileRole =
25
+ | 'code'
26
+ | 'test'
27
+ | 'entry'
28
+ | 'view'
29
+ | 'migration'
30
+ | 'translation'
31
+ | 'config'
32
+ | 'doc'
33
+ | 'infra'
34
+ | 'isolated';
35
+
36
+ /** Roles whose files take part in clustering. */
37
+ const CLUSTER_ROLES: ReadonlySet<FileRole> = new Set(['code', 'test', 'entry', 'view']);
38
+
39
+ /** Compact symbol: [name, kind, line]. */
40
+ export type MapSymbol = [string, string, number];
41
+
42
+ export interface MapFile {
43
+ path: string;
44
+ role: FileRole;
45
+ /** Index into `areas`, or -1 when the file belongs to no coupling area. */
46
+ area: number;
47
+ /** Distinct files with a trusted edge INTO this file. */
48
+ inFiles: number;
49
+ /** Distinct files this file has a trusted edge TO. */
50
+ outFiles: number;
51
+ /** Guess edges (name-lookup / dynamic / foreign) touching this file. */
52
+ guesses: number;
53
+ symbols: MapSymbol[];
54
+ /** Symbols beyond the per-file cap that `symbols` omits. */
55
+ symbolsOmitted: number;
56
+ /** External module specifiers this file imports. */
57
+ externals: string[];
58
+ /** Test files that cover this file (`tests` relation). */
59
+ tests: number[];
60
+ }
61
+
62
+ export interface MapArea {
63
+ id: string;
64
+ name: string;
65
+ /** Index into `files`. */
66
+ hub: number;
67
+ /** Indexes into `files`, sorted by path. */
68
+ files: number[];
69
+ kind: 'area' | 'small-parts';
70
+ /** Internal trusted weight over internal + boundary weight. */
71
+ cohesion: number;
72
+ testShare: number;
73
+ /** Share of non-test files whose directory differs from the hub's. */
74
+ folderMismatch: number;
75
+ /** Overview position, in [-1, 1]. */
76
+ x: number;
77
+ y: number;
78
+ /** Local layout: one entry per member file, then the outside neighbours. */
79
+ local: Array<[number, number, number]>;
80
+ /** Indexes of the outside neighbours drawn grey in the local view. */
81
+ neighbours: number[];
82
+ }
83
+
84
+ /** One declared route and what its handler reaches (Laravel overlay). */
85
+ export interface RequestPath {
86
+ route: string;
87
+ /** Index of the routes file. */
88
+ file: number;
89
+ /** Index of the handler's file, or -1 when the route names no resolvable handler. */
90
+ handler: number;
91
+ handlerSymbol: string;
92
+ views: number[];
93
+ models: number[];
94
+ tables: string[];
95
+ }
96
+
97
+ export interface MapThresholds {
98
+ seed: number;
99
+ /** In-degree above which a code file is held out as infrastructure. */
100
+ infraInDegree: number;
101
+ smallArea: number;
102
+ maxAreaShare: number;
103
+ symbolsPerFile: number;
104
+ listCap: number;
105
+ neighboursPerArea: number;
106
+ }
107
+
108
+ export interface MapModel {
109
+ version: number;
110
+ thresholds: MapThresholds;
111
+ files: MapFile[];
112
+ /** Trusted file→file edges `[source, target, weight]`, sorted. */
113
+ edges: Array<[number, number, number]>;
114
+ /** Guess file→file edges, same shape, drawn dashed. */
115
+ guessEdges: Array<[number, number, number]>;
116
+ areas: MapArea[];
117
+ /** Area→area weights `[a, b, weight]`, a < b. */
118
+ areaEdges: Array<[number, number, number]>;
119
+ diff?: MapDiff;
120
+ artefacts?: MapArtefacts;
121
+ /** Set by the map writer, not by `buildMapModel`: they need IO. */
122
+ drift?: AreaDrift;
123
+ prOverlap?: PrOverlap;
124
+ outcomes?: Array<[number, number]>;
125
+ analyses: {
126
+ hubs: number[];
127
+ surprises: Array<[number, number, number]>;
128
+ cycles: number[][];
129
+ cyclesTotal: number;
130
+ roleCounts: Record<FileRole, number>;
131
+ externals: Array<[string, number]>;
132
+ guessShare: number;
133
+ trustedEdges: number;
134
+ guessEdgeCount: number;
135
+ requestPaths: RequestPath[];
136
+ requestPathsTotal: number;
137
+ requestPathsResolved: number;
138
+ };
139
+ }
140
+
141
+ export interface MapInputs {
142
+ /** Repo-relative paths declared as entry points (manifests, route files). */
143
+ entryFiles?: readonly string[];
144
+ seed?: number;
145
+ /** `map --diff <rev>`: the rev and the files it changed. */
146
+ diff?: { rev: string; changed: readonly string[] };
147
+ /** On an agent-config source tree: the artefact relation graph (skills, rules, ADRs). */
148
+ artefacts?: {
149
+ nodes: ReadonlyArray<{ id: string; path: string | null; category: string | null }>;
150
+ edges: ReadonlyArray<{ from: string; to: string; rel: string }>;
151
+ };
152
+ }
153
+
154
+ /** The artefact layer: artefacts as map files, related by their declared fields. */
155
+ export interface MapArtefacts {
156
+ /** `[name, file index or -1, category]`. */
157
+ nodes: Array<[string, number, string]>;
158
+ /** `[from node, to node, relation]`, indexes into `nodes`. */
159
+ edges: Array<[number, number, string]>;
160
+ relationCounts: Record<string, number>;
161
+ }
162
+
163
+ /** What a diff touches on the map: the reviewer's view of the same picture. */
164
+ export interface MapDiff {
165
+ rev: string;
166
+ changed: number[];
167
+ /** Files with a trusted link into a changed file (one hop), not themselves changed. */
168
+ impacted: number[];
169
+ /** `[area, changed files in it, impacted files in it]`, most changed first. */
170
+ areas: Array<[number, number, number]>;
171
+ /** Changed paths the graph does not hold (deleted, or not a source file). */
172
+ outsideGraph: string[];
173
+ }
174
+
175
+ const DEFAULT_SEED = 42;
176
+
177
+
178
+ /** mulberry32 — a 32-bit seeded PRNG; Louvain's only source of randomness. */
179
+ function seededRng(seed: number): () => number {
180
+ let a = seed >>> 0;
181
+ return () => {
182
+ a = (a + 0x6d2b79f5) >>> 0;
183
+ let t = a;
184
+ t = Math.imul(t ^ (t >>> 15), t | 1);
185
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
186
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
187
+ };
188
+ }
189
+
190
+ const round = (n: number): number => Math.round(n * 1000) / 1000;
191
+
192
+ function byPath(a: string, b: string): number {
193
+ return a < b ? -1 : a > b ? 1 : 0;
194
+ }
195
+
196
+
197
+ export function isTestPath(p: string): boolean {
198
+ const segments = p.split('/');
199
+ if (segments.some((s) => s === 'tests' || s === 'test' || s === '__tests__')) return true;
200
+ const base = segments[segments.length - 1] ?? '';
201
+ return /\.(test|spec)\./i.test(base) || /(^|[._-])test\.php$/i.test(base) || /Test\.php$/.test(base);
202
+ }
203
+
204
+ const CONFIG_BASENAME =
205
+ /^(?:[\w.-]+\.config\.(?:[cm]?[jt]s)|\.eslintrc\.(?:[cm]?js)|\.prettierrc\.(?:[cm]?js)|server\.php)$/i;
206
+
207
+ /**
208
+ * The role a path has before any edge is looked at. `infra` and `isolated`
209
+ * depend on edges and are decided later; everything here is a name fact.
210
+ */
211
+ export function staticRole(p: string, entries: ReadonlySet<string>): FileRole {
212
+ if (/\.mdx?$/i.test(p)) return 'doc';
213
+ if (isTestPath(p)) return 'test';
214
+ const base = path.posix.basename(p);
215
+ if (base.endsWith('.blade.php') || p.startsWith('resources/views/') || p.includes('/resources/views/')) return 'view';
216
+ if (/(^|\/)(database\/)?migrations\//.test(p)) return 'migration';
217
+ if (/(^|\/)(resources\/)?lang\//.test(p)) return 'translation';
218
+ 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';
221
+ }
222
+
223
+
224
+ interface PairAcc {
225
+ trusted: Map<string, number>;
226
+ guess: Map<string, number>;
227
+ }
228
+
229
+ function pairKey(a: number, b: number): string {
230
+ return `${a} ${b}`;
231
+ }
232
+
233
+ function quantile(sorted: number[], q: number): number {
234
+ if (sorted.length === 0) return 0;
235
+ const i = Math.min(sorted.length - 1, Math.floor(q * (sorted.length - 1)));
236
+ return sorted[i] as number;
237
+ }
238
+
239
+ export function buildMapModel(graph: CodeGraph, inputs: MapInputs = {}): MapModel {
240
+ const seed = inputs.seed ?? DEFAULT_SEED;
241
+ const entries = new Set(inputs.entryFiles ?? []);
242
+
243
+ // 1. Files, sorted by path, and the symbol table.
244
+ const fileOfNode = new Map<string, string>();
245
+ const symbolsOf = new Map<string, MapSymbol[]>();
246
+ const filePaths = new Set<string>();
247
+ for (const n of graph.nodes) {
248
+ fileOfNode.set(n.id, n.source_file);
249
+ filePaths.add(n.source_file);
250
+ if (n.kind === 'file' || n.kind === 'skipped') continue;
251
+ const list = symbolsOf.get(n.source_file) ?? [];
252
+ list.push([n.label, n.kind, (n.source_location[0] as number | undefined) ?? 0]);
253
+ symbolsOf.set(n.source_file, list);
254
+ }
255
+ const paths = [...filePaths].sort(byPath);
256
+ const idx = new Map(paths.map((p, i) => [p, i] as const));
257
+
258
+ // 2. Fold symbol edges into file pairs.
259
+ const acc: PairAcc = { trusted: new Map(), guess: new Map() };
260
+ const externals = new Map<number, Set<string>>();
261
+ const testsOf = new Map<number, Set<number>>();
262
+ const bump = (m: Map<string, number>, k: string): void => {
263
+ m.set(k, (m.get(k) ?? 0) + 1);
264
+ };
265
+ for (const e of graph.edges) {
266
+ const sf = fileOfNode.get(e.source);
267
+ if (sf === undefined) continue;
268
+ const s = idx.get(sf) as number;
269
+ if (e.target.startsWith('external:')) {
270
+ const spec = e.target.slice('external:'.length).split('#')[0] as string;
271
+ const set = externals.get(s) ?? new Set<string>();
272
+ set.add(spec);
273
+ externals.set(s, set);
274
+ continue;
275
+ }
276
+ const tf = fileOfNode.get(e.target);
277
+ if (tf === undefined || tf === sf) continue;
278
+ const t = idx.get(tf) as number;
279
+ if (e.relation === 'tests') {
280
+ const set = testsOf.get(t) ?? new Set<number>();
281
+ set.add(s);
282
+ testsOf.set(t, set);
283
+ }
284
+ bump(isStatedResolution(e.resolved_via as ResolvedVia) ? acc.trusted : acc.guess, pairKey(s, t));
285
+ }
286
+
287
+ const toEdges = (m: Map<string, number>): Array<[number, number, number]> =>
288
+ [...m.entries()]
289
+ .map(([k, w]) => {
290
+ const [a, b] = k.split(' ').map(Number) as [number, number];
291
+ return [a, b, w] as [number, number, number];
292
+ })
293
+ .sort((x, y) => x[0] - y[0] || x[1] - y[1]);
294
+ const edges = toEdges(acc.trusted);
295
+ const guessEdges = toEdges(acc.guess);
296
+
297
+ const inFiles = new Array<number>(paths.length).fill(0);
298
+ const outFiles = new Array<number>(paths.length).fill(0);
299
+ const guesses = new Array<number>(paths.length).fill(0);
300
+ for (const [s, t] of edges) {
301
+ outFiles[s] = (outFiles[s] as number) + 1;
302
+ inFiles[t] = (inFiles[t] as number) + 1;
303
+ }
304
+ for (const [s, t, w] of guessEdges) {
305
+ guesses[s] = (guesses[s] as number) + w;
306
+ guesses[t] = (guesses[t] as number) + w;
307
+ }
308
+
309
+ // 3. Roles: names first, then the two edge-dependent ones.
310
+ const roles: FileRole[] = paths.map((p) => staticRole(p, entries));
311
+ // A layout every view extends couples views exactly the way a utility
312
+ // couples code, so views are eligible for the hold-out too.
313
+ const holdable = (r: FileRole | undefined): boolean => r === 'code' || r === 'view';
314
+ const codeInDegrees = paths
315
+ .map((_, i) => (holdable(roles[i]) ? (inFiles[i] as number) : 0))
316
+ .filter((d) => d > 0)
317
+ .sort((a, b) => a - b);
318
+ // p99, floored at 10 so a small project never loses a file to the hold-out.
319
+ const infraInDegree = Math.max(10, quantile(codeInDegrees, 0.99));
320
+ for (let i = 0; i < paths.length; i++) {
321
+ if (!holdable(roles[i])) continue;
322
+ if ((inFiles[i] as number) > infraInDegree) roles[i] = 'infra';
323
+ else if (roles[i] === 'code' && inFiles[i] === 0 && outFiles[i] === 0) roles[i] = 'isolated';
324
+ }
325
+
326
+ // 4. Areas: seeded Louvain over the coupled roles.
327
+ const clusterable = (i: number): boolean => CLUSTER_ROLES.has(roles[i] as FileRole);
328
+ const undirected = new Map<string, number>();
329
+ for (const [s, t, w] of edges) {
330
+ if (!clusterable(s) || !clusterable(t)) continue;
331
+ // A test's link to another test file (a base TestCase) says nothing
332
+ // about what it tests; kept, it pulled 245 tests into one area.
333
+ if (roles[s] === 'test' && roles[t] === 'test') continue;
334
+ const k = s < t ? pairKey(s, t) : pairKey(t, s);
335
+ undirected.set(k, (undirected.get(k) ?? 0) + w);
336
+ }
337
+ const members = new Set<number>();
338
+ for (const k of undirected.keys()) for (const n of k.split(' ')) members.add(Number(n));
339
+ for (let i = 0; i < paths.length; i++) if (clusterable(i) && !members.has(i)) members.add(i);
340
+
341
+ const communities = clusterOnce([...members].sort((a, b) => a - b), undirected, seed, 1);
342
+ const total = members.size;
343
+ const maxShare = 0.25;
344
+ const groups: number[][] = [];
345
+ for (const g of communities) {
346
+ if (g.length > maxShare * total && g.length > 8) {
347
+ const sub = clusterOnce(g, undirected, seed, 2);
348
+ groups.push(...sub);
349
+ } else groups.push(g);
350
+ }
351
+
352
+ // 5. Fold areas under three files into per-directory small-parts buckets.
353
+ const weightedDegree = new Map<number, number>();
354
+ for (const [k, w] of undirected) {
355
+ for (const n of k.split(' ').map(Number)) weightedDegree.set(n, (weightedDegree.get(n) ?? 0) + w);
356
+ }
357
+ const pickHub = (fs: number[]): number => {
358
+ const pool = fs.filter((f) => roles[f] !== 'test');
359
+ const cand = pool.length ? pool : fs;
360
+ return [...cand].sort(
361
+ (a, b) =>
362
+ (weightedDegree.get(b) ?? 0) - (weightedDegree.get(a) ?? 0) || byPath(paths[a] as string, paths[b] as string),
363
+ )[0] as number;
364
+ };
365
+
366
+ const smallArea = 3;
367
+ const big = groups.filter((g) => g.length >= smallArea);
368
+ const buckets = new Map<string, number[]>();
369
+ for (const g of groups.filter((g) => g.length < smallArea)) {
370
+ for (const f of g) {
371
+ // A test with no import of anything it could be grouped with (it
372
+ // spawns a CLI by path, say) has no subject on the map; a bucket
373
+ // would pretend it belongs to its neighbours by directory.
374
+ if (roles[f] === 'test' && !weightedDegree.has(f)) continue;
375
+ // Keyed on the group's hub, so a script and its one test stay
376
+ // together instead of splitting into a src/ and a tests/ bucket.
377
+ const dir = topDir(paths[pickHub(g)] as string);
378
+ const b = buckets.get(dir) ?? [];
379
+ b.push(f);
380
+ buckets.set(dir, b);
381
+ }
382
+ }
383
+
384
+ type Draft = { files: number[]; kind: MapArea['kind']; hub: number; name: string };
385
+ const drafts: Draft[] = [];
386
+ for (const g of big) {
387
+ const files = [...g].sort((a, b) => byPath(paths[a] as string, paths[b] as string));
388
+ const hub = pickHub(files);
389
+ drafts.push({ files, kind: 'area', hub, name: paths[hub] as string });
390
+ }
391
+ for (const [dir, fs] of [...buckets.entries()].sort((a, b) => byPath(a[0], b[0]))) {
392
+ const files = [...fs].sort((a, b) => byPath(paths[a] as string, paths[b] as string));
393
+ drafts.push({ files, kind: 'small-parts', hub: pickHub(files), name: `small parts · ${dir}` });
394
+ }
395
+ drafts.sort((a, b) => b.files.length - a.files.length || byPath(a.name, b.name));
396
+
397
+ const areaOf = new Array<number>(paths.length).fill(-1);
398
+ drafts.forEach((d, ai) => {
399
+ for (const f of d.files) areaOf[f] = ai;
400
+ });
401
+
402
+ // 6. Area metrics, area→area weights, layouts.
403
+ const areaWeights = new Map<string, number>();
404
+ const internal = new Array<number>(drafts.length).fill(0);
405
+ const boundary = new Array<number>(drafts.length).fill(0);
406
+ for (const [s, t, w] of edges) {
407
+ const a = areaOf[s] as number;
408
+ const b = areaOf[t] as number;
409
+ if (a < 0 && b < 0) continue;
410
+ if (a === b) internal[a] = (internal[a] as number) + w;
411
+ else {
412
+ if (a >= 0) boundary[a] = (boundary[a] as number) + w;
413
+ if (b >= 0) boundary[b] = (boundary[b] as number) + w;
414
+ if (a >= 0 && b >= 0) {
415
+ const k = a < b ? pairKey(a, b) : pairKey(b, a);
416
+ areaWeights.set(k, (areaWeights.get(k) ?? 0) + w);
417
+ }
418
+ }
419
+ }
420
+ const areaEdges = toEdges(areaWeights);
421
+ const overview = layoutOverview(
422
+ drafts.map((d) => d.files.length),
423
+ areaEdges,
424
+ );
425
+
426
+ const neighboursPerArea = 12;
427
+ const areas: MapArea[] = drafts.map((d, ai) => {
428
+ const hubDir = path.posix.dirname(paths[d.hub] as string);
429
+ // Tests live under their own tree by convention, so they say nothing
430
+ // about whether the code's folders match its coupling.
431
+ const nonTest = d.files.filter((f) => roles[f] !== 'test');
432
+ const mismatch = nonTest.filter((f) => path.posix.dirname(paths[f] as string) !== hubDir).length;
433
+ const tests = d.files.length - nonTest.length;
434
+ const inner = internal[ai] as number;
435
+ const outer = boundary[ai] as number;
436
+ const { local, neighbours } = layoutLocal(d.files, edges, areaOf, ai, neighboursPerArea);
437
+ const pos = overview[ai] as [number, number];
438
+ return {
439
+ id: `a${ai + 1}`,
440
+ name: d.name,
441
+ hub: d.hub,
442
+ files: d.files,
443
+ kind: d.kind,
444
+ cohesion: inner + outer === 0 ? 0 : round(inner / (inner + outer)),
445
+ testShare: round(tests / d.files.length),
446
+ folderMismatch: nonTest.length === 0 ? 0 : round(mismatch / nonTest.length),
447
+ x: pos[0],
448
+ y: pos[1],
449
+ local,
450
+ neighbours,
451
+ };
452
+ });
453
+
454
+ // 7. Analyses, every list capped.
455
+ const listCap = 15;
456
+ const hubs = paths
457
+ .map((_, i) => i)
458
+ .filter((i) => (inFiles[i] as number) > 0)
459
+ .sort((a, b) => (inFiles[b] as number) - (inFiles[a] as number) || byPath(paths[a] as string, paths[b] as string))
460
+ .slice(0, listCap);
461
+
462
+ const surprises = edges
463
+ .filter(([s, t]) => (areaOf[s] as number) >= 0 && (areaOf[t] as number) >= 0 && areaOf[s] !== areaOf[t])
464
+ .map(([s, t, w]) => {
465
+ const a = areaOf[s] as number;
466
+ const b = areaOf[t] as number;
467
+ const between = areaWeights.get(a < b ? pairKey(a, b) : pairKey(b, a)) ?? w;
468
+ return { s, t, w, between };
469
+ })
470
+ .sort(
471
+ (x, y) =>
472
+ x.between - y.between ||
473
+ y.w - x.w ||
474
+ byPath(paths[x.s] as string, paths[y.s] as string) ||
475
+ byPath(paths[x.t] as string, paths[y.t] as string),
476
+ )
477
+ .slice(0, listCap)
478
+ .map((x) => [x.s, x.t, x.w] as [number, number, number]);
479
+
480
+ const allCycles = tarjan(paths.length, edges).filter((c) => c.length > 1);
481
+ allCycles.sort((a, b) => b.length - a.length || (a[0] as number) - (b[0] as number));
482
+ const cycles = allCycles.slice(0, 10).map((c) => c.slice(0, 8));
483
+
484
+ const roleCounts = {
485
+ code: 0,
486
+ test: 0,
487
+ entry: 0,
488
+ view: 0,
489
+ migration: 0,
490
+ translation: 0,
491
+ config: 0,
492
+ doc: 0,
493
+ infra: 0,
494
+ isolated: 0,
495
+ } as Record<FileRole, number>;
496
+ for (const r of roles) roleCounts[r] += 1;
497
+
498
+ const extCount = new Map<string, number>();
499
+ for (const set of externals.values()) for (const s of set) extCount.set(s, (extCount.get(s) ?? 0) + 1);
500
+ const externalsTop = [...extCount.entries()]
501
+ .sort((a, b) => b[1] - a[1] || byPath(a[0], b[0]))
502
+ .slice(0, 25) as Array<[string, number]>;
503
+
504
+ const requestPaths = collectRequestPaths(graph, idx);
505
+
506
+ const trustedTotal = edges.reduce((n, e) => n + e[2], 0);
507
+ const guessTotal = guessEdges.reduce((n, e) => n + e[2], 0);
508
+
509
+ const symbolsPerFile = 40;
510
+ const files: MapFile[] = paths.map((p, i) => {
511
+ const syms = (symbolsOf.get(p) ?? []).sort((a, b) => a[2] - b[2] || byPath(a[0], b[0]));
512
+ return {
513
+ path: p,
514
+ role: roles[i] as FileRole,
515
+ area: areaOf[i] as number,
516
+ inFiles: inFiles[i] as number,
517
+ outFiles: outFiles[i] as number,
518
+ guesses: guesses[i] as number,
519
+ symbols: syms.slice(0, symbolsPerFile),
520
+ symbolsOmitted: Math.max(0, syms.length - symbolsPerFile),
521
+ externals: [...(externals.get(i) ?? [])].sort(byPath),
522
+ tests: [...(testsOf.get(i) ?? [])].sort((a, b) => a - b),
523
+ };
524
+ });
525
+
526
+ let diff: MapDiff | undefined;
527
+ if (inputs.diff) {
528
+ const changed = new Set<number>();
529
+ const outsideGraph: string[] = [];
530
+ for (const p of [...inputs.diff.changed].sort(byPath)) {
531
+ const i = idx.get(p);
532
+ if (i === undefined) outsideGraph.push(p);
533
+ else changed.add(i);
534
+ }
535
+ const impacted = new Set<number>();
536
+ for (const [s, t] of edges) if (changed.has(t) && !changed.has(s)) impacted.add(s);
537
+ const perArea = new Map<number, [number, number]>();
538
+ const bumpArea = (f: number, k: 0 | 1): void => {
539
+ const a = areaOf[f] as number;
540
+ if (a < 0) return;
541
+ const v = perArea.get(a) ?? [0, 0];
542
+ v[k] += 1;
543
+ perArea.set(a, v);
544
+ };
545
+ for (const f of changed) bumpArea(f, 0);
546
+ for (const f of impacted) bumpArea(f, 1);
547
+ diff = {
548
+ rev: inputs.diff.rev,
549
+ changed: [...changed].sort((a, b) => a - b),
550
+ impacted: [...impacted].sort((a, b) => a - b),
551
+ areas: [...perArea.entries()]
552
+ .map(([a, [c, i]]) => [a, c, i] as [number, number, number])
553
+ .sort((x, y) => y[1] - x[1] || y[2] - x[2] || x[0] - y[0]),
554
+ outsideGraph,
555
+ };
556
+ }
557
+
558
+ let artefacts: MapArtefacts | undefined;
559
+ if (inputs.artefacts) {
560
+ // Membership edges (pack, workspace) say where an artefact ships, not
561
+ // what it relates to, and outnumber the rest ten to one.
562
+ const RELS = new Set(['supersedes', 'superseded_by', 'routes_to', 'references_adr']);
563
+ const byId = new Map<string, number>();
564
+ const nodesOut: Array<[string, number, string]> = [];
565
+ const node = (id: string): number => {
566
+ const known = byId.get(id);
567
+ if (known !== undefined) return known;
568
+ const meta = inputs.artefacts?.nodes.find((n) => n.id === id);
569
+ const p = meta?.path ?? (idx.has(id) ? id : null);
570
+ nodesOut.push([id, p !== null ? (idx.get(p) ?? -1) : -1, meta?.category ?? (p ? 'file' : '')]);
571
+ byId.set(id, nodesOut.length - 1);
572
+ return nodesOut.length - 1;
573
+ };
574
+ const edgesOut: Array<[number, number, string]> = [];
575
+ const relationCounts: Record<string, number> = {};
576
+ for (const e of [...inputs.artefacts.edges].sort((a, b) =>
577
+ a.from < b.from ? -1 : a.from > b.from ? 1 : a.to < b.to ? -1 : a.to > b.to ? 1 : a.rel < b.rel ? -1 : 1,
578
+ )) {
579
+ if (!RELS.has(e.rel)) continue;
580
+ edgesOut.push([node(e.from), node(e.to), e.rel]);
581
+ relationCounts[e.rel] = (relationCounts[e.rel] ?? 0) + 1;
582
+ }
583
+ artefacts = { nodes: nodesOut, edges: edgesOut, relationCounts };
584
+ }
585
+
586
+ return {
587
+ version: MAP_MODEL_VERSION,
588
+ thresholds: {
589
+ seed,
590
+ infraInDegree,
591
+ smallArea,
592
+ maxAreaShare: maxShare,
593
+ symbolsPerFile,
594
+ listCap,
595
+ neighboursPerArea,
596
+ },
597
+ files,
598
+ edges,
599
+ guessEdges,
600
+ areas,
601
+ areaEdges,
602
+ ...(diff ? { diff } : {}),
603
+ ...(artefacts ? { artefacts } : {}),
604
+ analyses: {
605
+ hubs,
606
+ surprises,
607
+ cycles,
608
+ cyclesTotal: allCycles.length,
609
+ roleCounts,
610
+ externals: externalsTop,
611
+ guessShare: trustedTotal + guessTotal === 0 ? 0 : round(guessTotal / (trustedTotal + guessTotal)),
612
+ trustedEdges: trustedTotal,
613
+ guessEdgeCount: guessTotal,
614
+ requestPaths: requestPaths.slice(0, 200),
615
+ requestPathsTotal: requestPaths.length,
616
+ requestPathsResolved: requestPaths.filter((r) => r.handler >= 0).length,
617
+ },
618
+ };
619
+ }
620
+
621
+ /**
622
+ * Route → handler → the views it renders, the models it touches and their
623
+ * tables. Read off the overlay's edges only; a route whose handler did not
624
+ * resolve keeps `handler: -1` rather than a guess.
625
+ */
626
+ function collectRequestPaths(graph: CodeGraph, idx: ReadonlyMap<string, number>): RequestPath[] {
627
+ const nodeById = new Map(graph.nodes.map((n) => [n.id, n] as const));
628
+ const out = new Map<string, CodeGraph['edges']>();
629
+ for (const e of graph.edges) {
630
+ const list = out.get(e.source) ?? [];
631
+ list.push(e);
632
+ out.set(e.source, list);
633
+ }
634
+ const tableOf = new Map<string, string[]>();
635
+ for (const e of graph.edges) {
636
+ if (e.relation !== 'persists') continue;
637
+ const t = nodeById.get(e.target);
638
+ if (t) tableOf.set(e.source, [...(tableOf.get(e.source) ?? []), t.label]);
639
+ }
640
+ const fileIdx = (id: string): number => {
641
+ const n = nodeById.get(id);
642
+ return n ? (idx.get(n.source_file) ?? -1) : -1;
643
+ };
644
+ const paths: RequestPath[] = [];
645
+ for (const n of graph.nodes) {
646
+ if (n.kind !== 'route') continue;
647
+ const target = (out.get(n.id) ?? []).find((e) => e.relation === 'routes-to')?.target;
648
+ const views = new Set<number>();
649
+ const models = new Set<number>();
650
+ const tables = new Set<string>();
651
+ 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);
664
+ }
665
+ }
666
+ }
667
+ paths.push({
668
+ route: n.label,
669
+ file: idx.get(n.source_file) ?? -1,
670
+ handler: target ? fileIdx(target) : -1,
671
+ handlerSymbol: target ? (nodeById.get(target)?.label ?? '') : '',
672
+ views: [...views].filter((v) => v >= 0).sort((a, b) => a - b),
673
+ models: [...models].filter((v) => v >= 0).sort((a, b) => a - b),
674
+ tables: [...tables].sort(),
675
+ });
676
+ }
677
+ return paths.sort((a, b) => (a.route < b.route ? -1 : a.route > b.route ? 1 : a.file - b.file));
678
+ }
679
+
680
+ function topDir(p: string): string {
681
+ const parts = p.split('/');
682
+ if (parts.length <= 1) return '.';
683
+ return parts.slice(0, Math.min(2, parts.length - 1)).join('/');
684
+ }
685
+
686
+ /** One seeded Louvain pass over `nodes`; communities sorted by first member. */
687
+ function clusterOnce(nodes: number[], weights: Map<string, number>, seed: number, resolution: number): number[][] {
688
+ const set = new Set(nodes);
689
+ const g = new Graph({ type: 'undirected' });
690
+ for (const n of nodes) g.addNode(String(n));
691
+ for (const [k, w] of weights) {
692
+ const [a, b] = k.split(' ').map(Number) as [number, number];
693
+ if (set.has(a) && set.has(b)) g.addEdge(String(a), String(b), { weight: w });
694
+ }
695
+ if (g.size === 0) return nodes.map((n) => [n]);
696
+ const assignment = louvain(g, { rng: seededRng(seed), resolution, getEdgeWeight: 'weight' });
697
+ const byCommunity = new Map<number, number[]>();
698
+ for (const n of nodes) {
699
+ const c = assignment[String(n)] as number;
700
+ const list = byCommunity.get(c) ?? [];
701
+ list.push(n);
702
+ byCommunity.set(c, list);
703
+ }
704
+ return [...byCommunity.values()]
705
+ .map((l) => l.sort((a, b) => a - b))
706
+ .sort((a, b) => (a[0] as number) - (b[0] as number));
707
+ }
708
+
709
+ /** Golden-angle spiral start: deterministic, spread, no two points equal. */
710
+ function spiral(i: number, scale: number): [number, number] {
711
+ const angle = i * 2.399963229728653;
712
+ const r = scale * Math.sqrt(i + 0.5);
713
+ return [r * Math.cos(angle), r * Math.sin(angle)];
714
+ }
715
+
716
+ function normalise(points: Array<[number, number]>): Array<[number, number]> {
717
+ if (points.length === 0) return [];
718
+ if (points.length === 1) return [[0, 0]];
719
+ let max = 0;
720
+ let cx = 0;
721
+ let cy = 0;
722
+ for (const [x, y] of points) {
723
+ cx += x;
724
+ cy += y;
725
+ }
726
+ cx /= points.length;
727
+ cy /= points.length;
728
+ for (const [x, y] of points) max = Math.max(max, Math.abs(x - cx), Math.abs(y - cy));
729
+ const s = max === 0 ? 1 : max;
730
+ return points.map(([x, y]) => [round((x - cx) / s), round((y - cy) / s)]);
731
+ }
732
+
733
+ function runLayout(count: number, links: Array<[number, number, number]>, sizes?: number[]): Array<[number, number]> {
734
+ const g = new Graph({ type: 'undirected' });
735
+ for (let i = 0; i < count; i++) {
736
+ const [x, y] = spiral(i, 10);
737
+ g.addNode(String(i), { x, y, size: sizes ? Math.sqrt(sizes[i] as number) : 1 });
738
+ }
739
+ for (const [a, b, w] of links) {
740
+ if (a === b || g.hasEdge(String(a), String(b))) continue;
741
+ g.addEdge(String(a), String(b), { weight: w });
742
+ }
743
+ if (g.size > 0) {
744
+ forceAtlas2.assign(g, {
745
+ iterations: count > 400 ? 150 : 300,
746
+ getEdgeWeight: 'weight',
747
+ settings: { ...forceAtlas2.inferSettings(g), adjustSizes: false, barnesHutOptimize: false },
748
+ });
749
+ }
750
+ const pts: Array<[number, number]> = [];
751
+ for (let i = 0; i < count; i++) {
752
+ const a = g.getNodeAttributes(String(i)) as { x: number; y: number };
753
+ pts.push([a.x, a.y]);
754
+ }
755
+ return normalise(pts);
756
+ }
757
+
758
+ function layoutOverview(sizes: number[], areaEdges: Array<[number, number, number]>): Array<[number, number]> {
759
+ return runLayout(sizes.length, areaEdges, sizes);
760
+ }
761
+
762
+ function layoutLocal(
763
+ files: number[],
764
+ edges: Array<[number, number, number]>,
765
+ areaOf: number[],
766
+ area: number,
767
+ cap: number,
768
+ ): { local: Array<[number, number, number]>; neighbours: number[] } {
769
+ const inArea = new Set(files);
770
+ const outsideWeight = new Map<number, number>();
771
+ for (const [s, t, w] of edges) {
772
+ if (inArea.has(s) && !inArea.has(t)) outsideWeight.set(t, (outsideWeight.get(t) ?? 0) + w);
773
+ else if (inArea.has(t) && !inArea.has(s)) outsideWeight.set(s, (outsideWeight.get(s) ?? 0) + w);
774
+ }
775
+ const neighbours = [...outsideWeight.entries()]
776
+ .filter(([f]) => areaOf[f] !== area)
777
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
778
+ .slice(0, cap)
779
+ .map(([f]) => f)
780
+ .sort((a, b) => a - b);
781
+ const nodes = [...files, ...neighbours];
782
+ const pos = new Map(nodes.map((f, i) => [f, i] as const));
783
+ const links: Array<[number, number, number]> = [];
784
+ for (const [s, t, w] of edges) {
785
+ const a = pos.get(s);
786
+ const b = pos.get(t);
787
+ if (a !== undefined && b !== undefined) links.push([a, b, w]);
788
+ }
789
+ const pts = runLayout(nodes.length, links);
790
+ return { local: nodes.map((f, i) => [f, (pts[i] as [number, number])[0], (pts[i] as [number, number])[1]]), neighbours };
791
+ }
792
+
793
+ /** Tarjan's strongly connected components over the directed file layer. */
794
+ function tarjan(n: number, edges: Array<[number, number, number]>): number[][] {
795
+ const adj: number[][] = Array.from({ length: n }, () => []);
796
+ for (const [s, t] of edges) (adj[s] as number[]).push(t);
797
+ const index = new Array<number>(n).fill(-1);
798
+ const low = new Array<number>(n).fill(0);
799
+ const onStack = new Array<boolean>(n).fill(false);
800
+ const stack: number[] = [];
801
+ const out: number[][] = [];
802
+ let counter = 0;
803
+ // Iterative, so a deep import chain cannot overflow the call stack.
804
+ for (let root = 0; root < n; root++) {
805
+ if (index[root] !== -1) continue;
806
+ const work: Array<[number, number]> = [[root, 0]];
807
+ while (work.length) {
808
+ const frame = work[work.length - 1] as [number, number];
809
+ const [v, i] = frame;
810
+ if (i === 0) {
811
+ index[v] = counter;
812
+ low[v] = counter;
813
+ counter += 1;
814
+ stack.push(v);
815
+ onStack[v] = true;
816
+ }
817
+ const next = adj[v] as number[];
818
+ if (i < next.length) {
819
+ frame[1] = i + 1;
820
+ const w = next[i] as number;
821
+ if (index[w] === -1) work.push([w, 0]);
822
+ else if (onStack[w]) low[v] = Math.min(low[v] as number, index[w] as number);
823
+ continue;
824
+ }
825
+ work.pop();
826
+ const parent = work[work.length - 1];
827
+ if (parent) low[parent[0]] = Math.min(low[parent[0]] as number, low[v] as number);
828
+ if (low[v] === index[v]) {
829
+ const comp: number[] = [];
830
+ let w: number;
831
+ do {
832
+ w = stack.pop() as number;
833
+ onStack[w] = false;
834
+ comp.push(w);
835
+ } while (w !== v);
836
+ out.push(comp.sort((a, b) => a - b));
837
+ }
838
+ }
839
+ }
840
+ return out;
841
+ }
842
+
843
+ /** How the areas moved since the previous map (7.3). */
844
+ export interface AreaDrift {
845
+ /** `[area, files gained, files lost]` for areas that kept their identity but changed size. */
846
+ changed: Array<[number, number, number]>;
847
+ /** New areas with no predecessor. */
848
+ appeared: number[];
849
+ /** A previous area (by name) whose files now sit in two or more areas. */
850
+ split: Array<[string, number[]]>;
851
+ /** A new area that took a large share of two or more previous areas (by name). */
852
+ merged: Array<[number, string[]]>;
853
+ /** Previous areas (by name) whose files are now in no area at all. */
854
+ vanished: string[];
855
+ }
856
+
857
+ /**
858
+ * Area membership compared with the previous map, by file path — area ids are
859
+ * positional and mean nothing across builds. An area "kept its identity" when
860
+ * its best predecessor holds half or more of its files; a share of 30 % or more
861
+ * counts as taking part in a split or a merge. Null when nothing moved, so a
862
+ * rebuild of an unchanged tree writes the same bytes.
863
+ */
864
+ export function areaDrift(prev: MapModel, next: MapModel): AreaDrift | null {
865
+ const areaFilesByPath = (m: MapModel): Array<{ name: string; files: Set<string> }> =>
866
+ m.areas.map((a) => ({ name: a.name, files: new Set(a.files.map((f) => m.files[f]?.path ?? '')) }));
867
+ const before = areaFilesByPath(prev);
868
+ const after = areaFilesByPath(next);
869
+ const overlap = (a: Set<string>, b: Set<string>): number => {
870
+ let n = 0;
871
+ for (const x of a) if (b.has(x)) n += 1;
872
+ return n;
873
+ };
874
+ const drift: AreaDrift = { changed: [], appeared: [], split: [], merged: [], vanished: [] };
875
+ after.forEach((a, i) => {
876
+ const shares = before
877
+ .map((b) => ({ name: b.name, n: overlap(a.files, b.files), size: b.files.size }))
878
+ .filter((x) => x.n > 0)
879
+ .sort((x, y) => y.n - x.n || (x.name < y.name ? -1 : 1));
880
+ const big = shares.filter((x) => x.n >= 0.3 * x.size);
881
+ if (big.length >= 2) {
882
+ drift.merged.push([i, big.map((x) => x.name)]);
883
+ return;
884
+ }
885
+ const best = shares[0];
886
+ if (!best || best.n < 0.5 * a.files.size) {
887
+ drift.appeared.push(i);
888
+ return;
889
+ }
890
+ const gained = a.files.size - best.n;
891
+ const lost = best.size - best.n;
892
+ if (gained > 0 || lost > 0) drift.changed.push([i, gained, lost]);
893
+ });
894
+ for (const b of before) {
895
+ const parts = after
896
+ .map((a, i) => ({ i, n: overlap(b.files, a.files) }))
897
+ .filter((x) => x.n >= 0.3 * b.files.size);
898
+ if (parts.length >= 2) drift.split.push([b.name, parts.map((x) => x.i)]);
899
+ if (after.every((a) => overlap(b.files, a.files) === 0)) drift.vanished.push(b.name);
900
+ }
901
+ const moved = drift.changed.length + drift.appeared.length + drift.split.length + drift.merged.length + drift.vanished.length;
902
+ return moved === 0 ? null : drift;
903
+ }
904
+
905
+ /** Open pull requests by the areas their files fall in (7.2). */
906
+ export interface PrOverlap {
907
+ prs: Array<{ number: number; title: string; areas: number[] }>;
908
+ /** `[pr a, pr b, shared areas]` — a merge-order risk, advisory only. */
909
+ conflicts: Array<[number, number, number[]]>;
910
+ }
911
+
912
+ export function prOverlap(
913
+ m: MapModel,
914
+ prs: ReadonlyArray<{ number: number; title: string; files: readonly string[] }>,
915
+ ): PrOverlap {
916
+ const idx = new Map(m.files.map((f, i) => [f.path, i] as const));
917
+ const rows = [...prs]
918
+ .sort((a, b) => a.number - b.number)
919
+ .map((p) => {
920
+ const areas = new Set<number>();
921
+ for (const f of p.files) {
922
+ const i = idx.get(f);
923
+ const a = i === undefined ? -1 : (m.files[i]?.area ?? -1);
924
+ if (a >= 0) areas.add(a);
925
+ }
926
+ return { number: p.number, title: p.title, areas: [...areas].sort((x, y) => x - y) };
927
+ });
928
+ const conflicts: Array<[number, number, number[]]> = [];
929
+ for (let i = 0; i < rows.length; i++) {
930
+ for (let j = i + 1; j < rows.length; j++) {
931
+ const a = rows[i] as (typeof rows)[number];
932
+ const b = rows[j] as (typeof rows)[number];
933
+ const shared = a.areas.filter((x) => b.areas.includes(x));
934
+ if (shared.length) conflicts.push([a.number, b.number, shared]);
935
+ }
936
+ }
937
+ return { prs: rows, conflicts };
938
+ }
939
+
940
+ /** One recorded outcome: a session found `path` useful (+1) or a dead end (-1). */
941
+ export interface OutcomeEvent {
942
+ path: string;
943
+ outcome: 'useful' | 'dead-end';
944
+ /** ISO timestamp. */
945
+ at: string;
946
+ }
947
+
948
+ /** Half-life of an outcome, in days. */
949
+ export const OUTCOME_HALF_LIFE_DAYS = 14;
950
+
951
+ /**
952
+ * A time-decayed score per file (7.1), decayed relative to the NEWEST event in
953
+ * the log rather than to the clock, so the same log yields the same scores on
954
+ * any day. `[file, score]`, rounded, zero scores dropped.
955
+ */
956
+ export function outcomeScores(m: MapModel, events: readonly OutcomeEvent[]): Array<[number, number]> {
957
+ if (events.length === 0) return [];
958
+ const idx = new Map(m.files.map((f, i) => [f.path, i] as const));
959
+ let newest = -Infinity;
960
+ for (const e of events) {
961
+ const t = Date.parse(e.at);
962
+ if (Number.isFinite(t) && t > newest) newest = t;
963
+ }
964
+ const score = new Map<number, number>();
965
+ for (const e of events) {
966
+ const i = idx.get(e.path);
967
+ const t = Date.parse(e.at);
968
+ if (i === undefined || !Number.isFinite(t)) continue;
969
+ const age = (newest - t) / 86_400_000;
970
+ const w = 0.5 ** (age / OUTCOME_HALF_LIFE_DAYS) * (e.outcome === 'useful' ? 1 : -1);
971
+ score.set(i, (score.get(i) ?? 0) + w);
972
+ }
973
+ return [...score.entries()]
974
+ .map(([i, s]) => [i, Math.round(s * 100) / 100] as [number, number])
975
+ .filter(([, s]) => s !== 0)
976
+ .sort((a, b) => a[0] - b[0]);
977
+ }