cadet-agent 0.60.0 → 0.63.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.
@@ -0,0 +1,735 @@
1
+ /**
2
+ * Component-diagram model, UML source, and HTML navigation.
3
+ *
4
+ * One diagram is produced per (package × epic). The diagram is scoped to the
5
+ * components that epic's work touched, plus their one-hop dependencies, so a
6
+ * package with many namespaces still yields a small, relevant picture. Anything
7
+ * left out is reported as an elision marker rather than silently dropped.
8
+ *
9
+ * Progress is derived from the framework's own record: a component is coloured by
10
+ * the stories of that epic whose evidence `relevantFiles` land in it. That keeps
11
+ * the diagram tied to the evidence that satisfied the gates, not to a separate
12
+ * hand-maintained map.
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, unlinkSync, rmdirSync, existsSync } from 'node:fs';
16
+ import { join, dirname, relative, resolve } from 'node:path';
17
+ import { scanPackages, scanComponents, componentEdges, toPosix } from './csharp.mjs';
18
+ import { renderSvg } from './noml.mjs';
19
+ import { readState } from './state.mjs';
20
+
21
+ /** Where the generated tree lands, relative to the target repository. */
22
+ export const DIAGRAM_OUT_DIR = '.cadet/diagrams';
23
+
24
+ /** Records the files a run wrote, so the next run can prune only its own output. */
25
+ export const DIAGRAM_MANIFEST = '.diagrams-manifest.json';
26
+
27
+ /** The three progress states and their colours, as specified. */
28
+ export const STATUS_COLORS = Object.freeze({
29
+ 'not-started': '#E05555',
30
+ 'in-progress': '#E8A840',
31
+ done: '#4A9A5A',
32
+ });
33
+
34
+ /** nomnoml classifier-style names, one per status. */
35
+ export const STATUS_CLASS = Object.freeze({
36
+ 'not-started': 'notstarted',
37
+ 'in-progress': 'inprogress',
38
+ done: 'done',
39
+ });
40
+
41
+ const PNG = Object.freeze({ 'not-started': 'not started', 'in-progress': 'in progress', done: 'done' });
42
+
43
+ function rel(rootDir, path) {
44
+ return toPosix(relative(rootDir, path));
45
+ }
46
+
47
+ /** A filesystem-safe name for a package id or epic id used as a path segment. */
48
+ function safeName(value) {
49
+ const s = String(value).replace(/[^A-Za-z0-9._-]+/g, '_').replace(/^[_.]+|[_.]+$/g, '').slice(0, 100);
50
+ return s || 'unnamed';
51
+ }
52
+
53
+ function escapeHtml(value) {
54
+ return String(value).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
55
+ }
56
+
57
+ /** A display name safe to embed inside a nomnoml `[...]` node. */
58
+ function nomlName(value) {
59
+ return String(value).replace(/[[\]|\r\n\t]/g, ' ').replace(/\s+/g, ' ').trim() || 'unnamed';
60
+ }
61
+
62
+ /**
63
+ * Deterministic, nomnoml-safe node ids, one per component.
64
+ *
65
+ * A node rendered as `[<done id=c3> Name]` carries `data-name="c3"` in the SVG
66
+ * — nomnoml sets the node group's data-name to the id — which is what lets a box
67
+ * be made clickable without parsing the diagram's geometry. Edges must reference
68
+ * the id too: a plain `[Name]` reference would be a different node to nomnoml,
69
+ * and it would draw a duplicate box.
70
+ */
71
+ function nomlIds(model) {
72
+ const ids = new Map();
73
+ [...model.components].sort((a, b) => a.id.localeCompare(b.id)).forEach((c, i) => ids.set(c.id, `c${i}`));
74
+ return ids;
75
+ }
76
+
77
+ /** Readable, unique file slugs for component pages, assigned in sorted order. */
78
+ export function componentSlugs(components) {
79
+ const used = new Set();
80
+ const out = new Map();
81
+ for (const c of [...components].sort((a, b) => a.id.localeCompare(b.id))) {
82
+ const base = c.id.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'component';
83
+ let slug = base;
84
+ let n = 2;
85
+ while (used.has(slug.toLowerCase())) slug = `${base}-${n++}`;
86
+ used.add(slug.toLowerCase());
87
+ out.set(c.id, slug);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * The types a component declares, best-effort, read from its files.
94
+ *
95
+ * A declaration must START the line (after whitespace and modifiers) and name a
96
+ * PascalCase identifier. That shape is what keeps the word "record" in a comment
97
+ * from being read as a `record` declaration — a false positive a real project
98
+ * produced, listing types literally called "and" and "the".
99
+ *
100
+ * This is still a text scan and not a parse, and the docs say so: an unusual
101
+ * style (a lowercase type name) is missed rather than guessed at.
102
+ */
103
+ const TYPE_RE = /^[ \t]*(?:(?:public|internal|private|protected|sealed|abstract|static|partial|readonly|unsafe|ref|file)\s+)*(class|struct|interface|enum|record)\s+(?:class\s+|struct\s+)?([A-Z]\w*)/gm;
104
+
105
+ function parseTypes(files) {
106
+ const types = [];
107
+ const seen = new Set();
108
+ for (const file of files) {
109
+ let text;
110
+ try {
111
+ text = readFileSync(file, 'utf-8');
112
+ } catch {
113
+ continue;
114
+ }
115
+ TYPE_RE.lastIndex = 0;
116
+ let m;
117
+ while ((m = TYPE_RE.exec(text)) !== null) {
118
+ if (seen.has(m[2])) continue;
119
+ seen.add(m[2]);
120
+ types.push({ kind: m[1], name: m[2], file: toPosix(file) });
121
+ }
122
+ }
123
+ return types.sort((a, b) => a.name.localeCompare(b.name));
124
+ }
125
+
126
+ /**
127
+ * Evidence files per work item: inline records first, then the archive.
128
+ *
129
+ * The archive matters because a closed work item's evidence leaves `state.json`
130
+ * (contract v5); without reading it, a finished story would look like it touched
131
+ * nothing and its components would read red.
132
+ */
133
+ export function collectWorkItemFiles(rootDir, state) {
134
+ const byItem = new Map();
135
+ const add = (workItemId, files) => {
136
+ if (!workItemId || !Array.isArray(files)) return;
137
+ if (!byItem.has(workItemId)) byItem.set(workItemId, new Set());
138
+ const set = byItem.get(workItemId);
139
+ for (const file of files) if (typeof file === 'string' && file) set.add(toPosix(file));
140
+ };
141
+
142
+ for (const record of Array.isArray(state?.gateEvidence) ? state.gateEvidence : []) {
143
+ add(record?.workItemId, record?.relevantFiles);
144
+ }
145
+ for (const record of readArchivedEvidence(rootDir)) add(record?.workItemId, record?.relevantFiles);
146
+ return byItem;
147
+ }
148
+
149
+ function readArchivedEvidence(rootDir) {
150
+ const dir = join(rootDir, '.cadet', 'archive', 'evidence');
151
+ if (!existsSync(dir)) return [];
152
+ const records = [];
153
+ for (const file of readdirSync(dir)) {
154
+ if (!file.endsWith('.jsonl')) continue;
155
+ let text;
156
+ try {
157
+ text = readFileSync(join(dir, file), 'utf-8');
158
+ } catch {
159
+ continue;
160
+ }
161
+ for (const line of text.split(/\r?\n/)) {
162
+ if (!line.trim()) continue;
163
+ try {
164
+ records.push(JSON.parse(line));
165
+ } catch {
166
+ // Append-only log: a malformed line is skipped, not fatal.
167
+ }
168
+ }
169
+ }
170
+ return records;
171
+ }
172
+
173
+ /** Scan every package once: its components, the component edges, and a file → owner index. */
174
+ export function indexComponents(rootDir, packages) {
175
+ const byFile = new Map();
176
+ const componentsByPkg = new Map();
177
+ const edgesByPkg = new Map();
178
+ for (const pkg of packages) {
179
+ const components = scanComponents(pkg, { packages });
180
+ componentsByPkg.set(pkg.id, components);
181
+ edgesByPkg.set(pkg.id, componentEdges(components));
182
+ for (const component of components) {
183
+ for (const file of component.files) {
184
+ byFile.set(rel(rootDir, file), { packageId: pkg.id, componentId: component.id });
185
+ }
186
+ }
187
+ }
188
+ return { byFile, componentsByPkg, edgesByPkg };
189
+ }
190
+
191
+ function externalOf(pkg, allPackages) {
192
+ const byId = new Map(allPackages.map((p) => [p.id, p.name]));
193
+ return (pkg.references || []).map((id) => ({ id, name: byId.get(id) || id }));
194
+ }
195
+
196
+ function statusFromStatuses(statuses) {
197
+ if (!statuses || statuses.length === 0) return 'not-started';
198
+ return statuses.every((s) => s === 'done') ? 'done' : 'in-progress';
199
+ }
200
+
201
+ /**
202
+ * The work items that touched each component, from every epic.
203
+ *
204
+ * Cumulative on purpose: a component that is finished is green. Deriving the
205
+ * status from only the current epic made a built component read red in a later
206
+ * epic's diagram — measured on a real project, where one component was green in
207
+ * one epic and red in the next. The epic still decides what a diagram SHOWS; it
208
+ * does not decide the colour.
209
+ *
210
+ * Keyed by `<packageId>::<componentId>`; the value is a Map of work-item id to
211
+ * `{ epicId, storyId, status }`, so a component page can name the stories behind
212
+ * a component and not only its colour.
213
+ */
214
+ export function componentWorkMap({ state, byFile, workItemFiles }) {
215
+ const byComponent = new Map();
216
+ const epics = state?.epics && typeof state.epics === 'object' ? state.epics : {};
217
+ for (const [epicId, epic] of Object.entries(epics)) {
218
+ const stories = epic?.stories && typeof epic.stories === 'object' ? epic.stories : {};
219
+ for (const [storyFile, status] of Object.entries(stories)) {
220
+ const files = workItemFiles.get(`${epicId}::${storyFile}`);
221
+ if (!files) continue;
222
+ for (const file of files) {
223
+ const owner = byFile.get(toPosix(file));
224
+ if (!owner) continue;
225
+ const key = `${owner.packageId}::${owner.componentId}`;
226
+ if (!byComponent.has(key)) byComponent.set(key, new Map());
227
+ byComponent.get(key).set(`${epicId}::${storyFile}`, { epicId, storyId: storyFile, status });
228
+ }
229
+ }
230
+ }
231
+ return byComponent;
232
+ }
233
+
234
+ /** Every component's status, derived from the work that touched it. */
235
+ export function statusesFromWork(work) {
236
+ const statuses = new Map();
237
+ for (const [key, items] of work) {
238
+ statuses.set(key, statusFromStatuses([...items.values()].map((w) => w.status)));
239
+ }
240
+ return statuses;
241
+ }
242
+
243
+ /** Convenience: the work map and the status map together. */
244
+ export function componentStatusMap(args) {
245
+ return statusesFromWork(componentWorkMap(args));
246
+ }
247
+
248
+ function makeModel({ pkg, epicId, epicStatus, components, edges, included, statusByKey, allPackages }) {
249
+ const inScope = components.filter((c) => included.has(c.id));
250
+ const elided = components.filter((c) => !included.has(c.id));
251
+ return {
252
+ package: { id: pkg.id, name: pkg.name, asmdefPath: pkg.asmdefPath ? toPosix(pkg.asmdefPath) : null },
253
+ epic: { id: epicId, status: epicStatus },
254
+ components: inScope.map((c) => ({
255
+ id: c.id,
256
+ name: c.name,
257
+ namespace: c.namespace,
258
+ status: statusByKey.get(`${pkg.id}::${c.id}`) || 'not-started',
259
+ files: c.files.map((f) => toPosix(f)),
260
+ })),
261
+ edges: edges.filter((e) => included.has(e.from) && included.has(e.to)),
262
+ external: externalOf(pkg, allPackages),
263
+ elided: { count: elided.length, names: elided.map((c) => c.name).sort() },
264
+ legend: { ...STATUS_COLORS },
265
+ };
266
+ }
267
+
268
+ /**
269
+ * The model for one (package × epic), scoped to touched components + one hop.
270
+ * Returns a model with no components when the epic touched nothing in the package,
271
+ * which the caller reads as "do not emit a diagram".
272
+ */
273
+ export function buildDiagramModel({ pkg, epicId, state, components, edges, byFile, workItemFiles, allPackages, componentStatus = null }) {
274
+ const epic = state?.epics?.[epicId] || null;
275
+ const stories = epic?.stories && typeof epic.stories === 'object' ? epic.stories : {};
276
+
277
+ // Scope is this epic's work: the components its stories touched.
278
+ const touched = new Set();
279
+ for (const storyFile of Object.keys(stories)) {
280
+ const files = workItemFiles.get(`${epicId}::${storyFile}`);
281
+ if (!files) continue;
282
+ for (const file of files) {
283
+ const owner = byFile.get(toPosix(file));
284
+ if (owner && owner.packageId === pkg.id) touched.add(owner.componentId);
285
+ }
286
+ }
287
+
288
+ // Exactly one hop: neighbours of the touched set, computed before anything is
289
+ // added to it, so a chain of three is not pulled in by transitivity.
290
+ const oneHop = new Set();
291
+ for (const edge of edges) {
292
+ if (touched.has(edge.from)) oneHop.add(edge.to);
293
+ if (touched.has(edge.to)) oneHop.add(edge.from);
294
+ }
295
+ const included = new Set([...touched, ...oneHop]);
296
+ const statusByKey = componentStatus || componentStatusMap({ state, byFile, workItemFiles });
297
+
298
+ return makeModel({ pkg, epicId, epicStatus: epic?.status || 'planned', components, edges, included, statusByKey, allPackages });
299
+ }
300
+
301
+ /** Structure-only model: every component shown, every one "not started". */
302
+ export function buildStructureModel({ pkg, components, edges, allPackages }) {
303
+ const included = new Set(components.map((c) => c.id));
304
+ return makeModel({
305
+ pkg,
306
+ epicId: 'structure',
307
+ epicStatus: 'planned',
308
+ components,
309
+ edges,
310
+ included,
311
+ statusByKey: new Map(),
312
+ allPackages,
313
+ });
314
+ }
315
+
316
+ /** The UML artifact: deterministic nomnoml source for a model. */
317
+ export function renderNoml(model) {
318
+ const ids = nomlIds(model);
319
+ const lines = [
320
+ '#direction: right',
321
+ `#.${STATUS_CLASS['not-started']}: fill=${STATUS_COLORS['not-started']}`,
322
+ `#.${STATUS_CLASS['in-progress']}: fill=${STATUS_COLORS['in-progress']}`,
323
+ `#.${STATUS_CLASS.done}: fill=${STATUS_COLORS.done}`,
324
+ `[<frame>${nomlName(model.package.name)}|`,
325
+ ];
326
+ for (const component of [...model.components].sort((a, b) => a.id.localeCompare(b.id))) {
327
+ const name = nomlName(component.namespace || component.name);
328
+ lines.push(` [<${STATUS_CLASS[component.status]} id=${ids.get(component.id)}> ${name}]`);
329
+ }
330
+ for (const edge of [...model.edges].sort((a, b) => `${a.from}\u0000${a.to}`.localeCompare(`${b.from}\u0000${b.to}`))) {
331
+ lines.push(` [${ids.get(edge.from)}] uses --> [${ids.get(edge.to)}]`);
332
+ }
333
+ lines.push(']');
334
+ if (model.elided.count > 0) {
335
+ lines.push(`// elided ${model.elided.count} component(s) touched by no story and no 1-hop dependency`);
336
+ }
337
+ return `${lines.join('\n')}\n`;
338
+ }
339
+
340
+ function statusCounts(models) {
341
+ const counts = { done: 0, 'in-progress': 0, 'not-started': 0 };
342
+ for (const model of models) for (const c of model.components) counts[c.status] += 1;
343
+ return counts;
344
+ }
345
+
346
+ const STYLE = `
347
+ body { font-family: system-ui, sans-serif; margin: 2rem; color: #222; }
348
+ h1 { font-size: 1.4rem; } h2 { font-size: 1.1rem; margin-top: 2rem; }
349
+ a { color: #2a5db0; }
350
+ .legend span { display: inline-block; margin-right: 1rem; }
351
+ .sw { display: inline-block; width: .8rem; height: .8rem; border: 1px solid #333; vertical-align: middle; margin-right: .3rem; }
352
+ .notstarted { background: ${STATUS_COLORS['not-started']}; }
353
+ .inprogress { background: ${STATUS_COLORS['in-progress']}; }
354
+ .done { background: ${STATUS_COLORS.done}; }
355
+ .diagram { border: 1px solid #ddd; padding: 1rem; margin: .5rem 0 1.5rem; overflow: auto; }
356
+ .diagram g[title] { cursor: pointer; }
357
+ .diagram g[title]:hover { opacity: .82; }
358
+ ul { padding-left: 1.1rem; } ul.components { list-style: none; padding-left: 0; columns: 2; }
359
+ table { border-collapse: collapse; } td, th { text-align: left; padding: .2rem .8rem .2rem 0; }
360
+ .note { color: #666; }
361
+ `;
362
+
363
+ function legendHtml() {
364
+ return `<p class="legend"><span><span class="sw notstarted"></span>${PNG['not-started']}</span>`
365
+ + `<span><span class="sw inprogress"></span>${PNG['in-progress']}</span>`
366
+ + `<span><span class="sw done"></span>${PNG.done}</span></p>`;
367
+ }
368
+
369
+ /** The root page: one link per package, with a status rollup. */
370
+ export function renderIndexHtml(packages, { warning = null } = {}) {
371
+ const rows = packages.map((p) => {
372
+ const c = p.counts;
373
+ return `<tr><td><a href="${escapeHtml(safeName(p.id))}/index.html">${escapeHtml(p.name)}</a></td>`
374
+ + `<td>${p.diagrams.length}</td><td class="done">${c.done}</td>`
375
+ + `<td class="inprogress">${c['in-progress']}</td><td class="notstarted">${c['not-started']}</td></tr>`;
376
+ }).join('\n');
377
+ return `<!doctype html>
378
+ <html lang="en"><head><meta charset="utf-8"><title>Cadet component diagrams</title><style>${STYLE}</style></head>
379
+ <body>
380
+ <h1>Component diagrams</h1>
381
+ ${legendHtml()}
382
+ ${warning ? `<p class="note">⚠️ ${escapeHtml(warning)}</p>` : ''}
383
+ ${packages.length === 0
384
+ ? '<p class="note">No packages with C# sources were found.</p>'
385
+ : `<table><thead><tr><th>Package</th><th>Diagrams</th><th>done</th><th>in&nbsp;progress</th><th>not&nbsp;started</th></tr></thead><tbody>\n${rows}\n</tbody></table>`}
386
+ </body></html>
387
+ `;
388
+ }
389
+
390
+ /**
391
+ * Click-to-drill: nomnoml sets each node group's `data-name` to the node's id,
392
+ * so the mapping from id to page is enough to make a box a link. Attached to the
393
+ * inline SVG, so it works from a plain double-click on the file.
394
+ */
395
+ const DRILL_SCRIPT = `document.querySelectorAll('.diagram[data-drill]').forEach(function (box) {
396
+ var map = JSON.parse(box.getAttribute('data-drill'));
397
+ box.querySelectorAll('g[data-name]').forEach(function (g) {
398
+ var hit = map[g.getAttribute('data-name')];
399
+ if (!hit) return;
400
+ g.setAttribute('title', 'Open ' + hit.n);
401
+ g.addEventListener('click', function (e) { e.stopPropagation(); location.href = hit.u; });
402
+ });
403
+ });`;
404
+
405
+ function statusName(status) {
406
+ return PNG[status] || status;
407
+ }
408
+
409
+ /** A package page: a component index, then one clickable diagram per epic. */
410
+ export function renderPackageHtml({ pkg, components, slugs, componentStatus, entries }) {
411
+ const statusOf = (id) => componentStatus.get(`${pkg.id}::${id}`) || 'not-started';
412
+ const index = [...components]
413
+ .sort((a, b) => a.id.localeCompare(b.id))
414
+ .map((c) => `<li><span class="sw ${STATUS_CLASS[statusOf(c.id)]}"></span>`
415
+ + `<a href="components/${escapeHtml(slugs.get(c.id))}.html">${escapeHtml(c.namespace || c.name)}</a>`
416
+ + ` <span class="note">${escapeHtml(statusName(statusOf(c.id)))}</span></li>`)
417
+ .join('\n');
418
+
419
+ const sections = entries.map(({ epicId, model, svg }) => {
420
+ const counts = statusCounts([model]);
421
+ const ids = nomlIds(model);
422
+ const drill = {};
423
+ for (const c of model.components) {
424
+ drill[ids.get(c.id)] = { u: `components/${slugs.get(c.id)}.html`, n: c.namespace || c.name };
425
+ }
426
+ const elision = model.elided.count > 0
427
+ ? `<p class="note">${model.elided.count} component(s) elided (touched by no story and no 1-hop dependency).</p>`
428
+ : '';
429
+ return `<h2>${escapeHtml(epicId)}</h2>
430
+ <p class="note">epic status: ${escapeHtml(model.epic.status)} — ${counts.done} done, ${counts['in-progress']} in progress, ${counts['not-started']} not started. Click a component to open it.</p>
431
+ <div class="diagram" data-drill="${escapeHtml(JSON.stringify(drill))}">${svg}</div>
432
+ ${elision}`;
433
+ }).join('\n');
434
+
435
+ return `<!doctype html>
436
+ <html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(pkg.name)} — component diagrams</title><style>${STYLE}</style></head>
437
+ <body>
438
+ <p><a href="../index.html">&larr; all packages</a></p>
439
+ <h1>${escapeHtml(pkg.name)}</h1>
440
+ ${legendHtml()}
441
+ <h2>Components</h2>
442
+ <ul class="components">
443
+ ${index}
444
+ </ul>
445
+ ${sections}
446
+ ${entries.length ? `<script>${DRILL_SCRIPT}</script>` : ''}
447
+ </body></html>
448
+ `;
449
+ }
450
+
451
+ /** The drill-down target: what a component is, contains, depends on, and comes from. */
452
+ export function renderComponentHtml({ pkg, component, status, slugs, components, edges, work, files, types, rootRel }) {
453
+ const name = component.namespace || component.name;
454
+ const byId = new Map(components.map((c) => [c.id, c]));
455
+ const link = (c) => `<a href="${escapeHtml(slugs.get(c.id))}.html">${escapeHtml(c.namespace || c.name)}</a>`;
456
+
457
+ const dependsOn = edges.filter((e) => e.from === component.id).map((e) => byId.get(e.to)).filter(Boolean);
458
+ const usedBy = edges.filter((e) => e.to === component.id).map((e) => byId.get(e.from)).filter(Boolean);
459
+ const toHref = (file) => (rootRel && rootRel !== '.' ? `${rootRel}/${file}` : file);
460
+
461
+ const list = (items, empty) => (items.length
462
+ ? `<ul>${items.map((i) => `<li>${i}</li>`).join('')}</ul>`
463
+ : `<p class="note">${empty}</p>`);
464
+
465
+ return `<!doctype html>
466
+ <html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(name)} — component</title><style>${STYLE}</style></head>
467
+ <body>
468
+ <p><a href="../index.html">&larr; ${escapeHtml(pkg.name)}</a> · <a href="../../index.html">all packages</a></p>
469
+ <h1>${escapeHtml(name)}</h1>
470
+ <p><span class="sw ${STATUS_CLASS[status]}"></span> ${escapeHtml(statusName(status))} · package ${escapeHtml(pkg.name)}</p>
471
+
472
+ <h2>Files (${files.length})</h2>
473
+ ${list(files.map((f) => `<a href="${escapeHtml(toHref(f))}">${escapeHtml(f)}</a>`), 'No files.')}
474
+
475
+ <h2>Types (${types.length})</h2>
476
+ ${list(types.map((t) => `${escapeHtml(t.kind)} <strong>${escapeHtml(t.name)}</strong> <span class="note">${escapeHtml(t.file)}</span>`), 'No type declarations were found.')}
477
+
478
+ <h2>Depends on (${dependsOn.length})</h2>
479
+ ${list(dependsOn.map(link), 'Nothing else in this package.')}
480
+
481
+ <h2>Used by (${usedBy.length})</h2>
482
+ ${list(usedBy.map(link), 'Nothing else in this package.')}
483
+
484
+ <h2>Work (${work.length})</h2>
485
+ ${list(work.map((w) => `${escapeHtml(w.epicId)} / ${escapeHtml(w.storyId)} — ${escapeHtml(statusName(w.status))}`), 'No story has touched this component yet.')}
486
+ </body></html>
487
+ `;
488
+ }
489
+
490
+ /** Write generated files atomically, creating directories as needed. */
491
+ export function writeDiagramFiles(files) {
492
+ for (const file of files) writeFileAtomic(file.path, file.content);
493
+ }
494
+
495
+ function writeFileAtomic(path, content) {
496
+ mkdirSync(dirname(path), { recursive: true });
497
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
498
+ writeFileSync(tmp, content, 'utf-8');
499
+ renameSync(tmp, path);
500
+ }
501
+
502
+ /** Remove empty directories under `dir`, deepest first. `dir` itself is kept. */
503
+ function pruneEmptyDirs(dir) {
504
+ let entries;
505
+ try {
506
+ entries = readdirSync(dir, { withFileTypes: true });
507
+ } catch {
508
+ return;
509
+ }
510
+ for (const entry of entries) {
511
+ if (!entry.isDirectory()) continue;
512
+ const full = join(dir, entry.name);
513
+ pruneEmptyDirs(full);
514
+ try {
515
+ if (readdirSync(full).length === 0) rmdirSync(full);
516
+ } catch {
517
+ // Left in place if it is not empty or cannot be removed.
518
+ }
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Write the tree, and remove what a PREVIOUS run wrote and this one did not.
524
+ *
525
+ * Without this a re-run leaves stale artifacts: running once with test
526
+ * assemblies and then again without left every test-assembly directory in place,
527
+ * so the tree claimed 30 packages when the model had 6. A manifest of the files
528
+ * this command wrote is the only safe way to prune — it deletes its own output
529
+ * and nothing else, even when `--out` points into a shared directory.
530
+ */
531
+ export function syncDiagramTree(outDir, files) {
532
+ const manifestPath = join(outDir, DIAGRAM_MANIFEST);
533
+ let previous = [];
534
+ try {
535
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
536
+ if (Array.isArray(parsed?.files)) previous = parsed.files;
537
+ } catch {
538
+ // No manifest, or an unreadable one: nothing can be proven ours to delete.
539
+ }
540
+
541
+ const next = files.map((file) => toPosix(relative(outDir, file.path)));
542
+ const keep = new Set(next);
543
+
544
+ let removed = 0;
545
+ for (const rel of previous) {
546
+ if (keep.has(rel)) continue;
547
+ try {
548
+ unlinkSync(join(outDir, rel));
549
+ removed += 1;
550
+ } catch {
551
+ // Already gone; the count is of files actually removed.
552
+ }
553
+ }
554
+ if (removed > 0) pruneEmptyDirs(outDir);
555
+
556
+ writeDiagramFiles(files);
557
+ writeFileAtomic(manifestPath, `${JSON.stringify({ generatedBy: 'cadet-agent harness diagram', files: [...next].sort() }, null, 2)}\n`);
558
+ return { written: files.length, removed };
559
+ }
560
+
561
+ /**
562
+ * Build the whole diagram tree without touching disk.
563
+ *
564
+ * Returns `{ files, summary, warning }`; the caller writes `files` and reports
565
+ * `summary`. `warning` is non-null when the run could not see any work, so the
566
+ * output says why everything is red instead of implying nothing was built.
567
+ */
568
+ export function generateDiagrams({ rootDir, outDir, csRoot = rootDir, state = null, packageFilter = null, includeTests = false }) {
569
+ const packages = scanPackages(csRoot)
570
+ // Test assemblies are excluded by default: they are numerous, each holds a
571
+ // single namespace, and their one-component diagrams crowd the index. The
572
+ // selection is by the assembly's TestRunner/nunit references, not its name.
573
+ .filter((p) => includeTests || !p.isTest)
574
+ .filter((p) => !packageFilter || p.id === packageFilter);
575
+ const { byFile, componentsByPkg, edgesByPkg } = indexComponents(csRoot, packages);
576
+ const workItemFiles = collectWorkItemFiles(rootDir, state);
577
+ const componentWork = componentWorkMap({ state, byFile, workItemFiles });
578
+ const componentStatus = statusesFromWork(componentWork);
579
+ const epicIds = state?.epics && typeof state.epics === 'object' ? Object.keys(state.epics).sort() : [];
580
+
581
+ const files = [];
582
+ const summaryPackages = [];
583
+ let warning = null;
584
+
585
+ for (const pkg of packages) {
586
+ const components = componentsByPkg.get(pkg.id) || [];
587
+ const edges = edgesByPkg.get(pkg.id) || [];
588
+ if (components.length === 0) continue;
589
+
590
+ const entries = [];
591
+ if (epicIds.length === 0) {
592
+ const model = buildStructureModel({ pkg, components, edges, allPackages: packages });
593
+ entries.push({ epicId: 'structure', model });
594
+ warning = warning || 'no work record found (.cadet/state.json has no epics): rendering structure only — every component reads "not started".';
595
+ } else {
596
+ for (const epicId of epicIds) {
597
+ const model = buildDiagramModel({ pkg, epicId, state, components, edges, byFile, workItemFiles, allPackages: packages, componentStatus });
598
+ if (model.components.length > 0) entries.push({ epicId, model });
599
+ }
600
+ }
601
+
602
+ const rendered = entries.map(({ epicId, model }) => {
603
+ const noml = renderNoml(model);
604
+ return { epicId, model, noml, svg: renderSvg(noml) };
605
+ });
606
+
607
+ const pkgDir = join(outDir, safeName(pkg.id));
608
+ for (const { epicId, svg, noml } of rendered) {
609
+ files.push({ path: join(pkgDir, `${safeName(epicId)}.noml`), content: noml });
610
+ files.push({ path: join(pkgDir, `${safeName(epicId)}.svg`), content: svg });
611
+ }
612
+
613
+ // A page per component, for every component in the package: the package index
614
+ // links them all, so no component page is unreachable, and a diagram node can
615
+ // point at one without the link ever dangling.
616
+ const slugs = componentSlugs(components);
617
+ const componentDir = join(pkgDir, 'components');
618
+ const rootRel = toPosix(relative(componentDir, rootDir)) || '.';
619
+ for (const component of components) {
620
+ const key = `${pkg.id}::${component.id}`;
621
+ files.push({
622
+ path: join(componentDir, `${slugs.get(component.id)}.html`),
623
+ content: renderComponentHtml({
624
+ pkg,
625
+ component,
626
+ status: componentStatus.get(key) || 'not-started',
627
+ slugs,
628
+ components,
629
+ edges,
630
+ work: [...(componentWork.get(key)?.values() || [])]
631
+ .sort((a, b) => `${a.epicId}/${a.storyId}`.localeCompare(`${b.epicId}/${b.storyId}`)),
632
+ files: component.files.map((f) => rel(rootDir, f)),
633
+ types: parseTypes(component.files).map((t) => ({ ...t, file: rel(rootDir, t.file) })),
634
+ rootRel,
635
+ }),
636
+ });
637
+ }
638
+
639
+ files.push({
640
+ path: join(pkgDir, 'index.html'),
641
+ content: renderPackageHtml({ pkg, components, slugs, componentStatus, entries: rendered }),
642
+ });
643
+
644
+ summaryPackages.push({
645
+ id: pkg.id,
646
+ name: pkg.name,
647
+ components: components.length,
648
+ diagrams: rendered.map((r) => ({
649
+ epic: r.epicId,
650
+ components: r.model.components.length,
651
+ elided: r.model.elided.count,
652
+ counts: statusCounts([r.model]),
653
+ })),
654
+ counts: statusCounts(rendered.map((r) => r.model)),
655
+ });
656
+ }
657
+
658
+ files.push({ path: join(outDir, 'index.html'), content: renderIndexHtml(summaryPackages, { warning }) });
659
+
660
+ const summary = {
661
+ outDir,
662
+ packages: summaryPackages,
663
+ diagrams: summaryPackages.reduce((n, p) => n + p.diagrams.length, 0),
664
+ files: files.length,
665
+ warning,
666
+ };
667
+ return { files, summary };
668
+ }
669
+
670
+ /**
671
+ * Compare the tree on disk with a freshly computed model.
672
+ *
673
+ * Read-only: it regenerates in memory and reads what is committed. Both
674
+ * `harness verify-diagrams` and `harness reconcile` call it, so the standalone
675
+ * check and the closure finding cannot disagree about the same tree.
676
+ *
677
+ * Opt-in by PRESENCE: a repository that has never generated the tree has no
678
+ * manifest, is not checked, and sees reconcile exactly as it was.
679
+ *
680
+ * The comparison is by bytes. That is sound only because the tree is
681
+ * deterministic — our `.noml` and pages, nomnoml pinned by the lockfile, posix
682
+ * paths, and relative links, so no absolute path or timestamp reaches the
683
+ * output. A nomnoml bump that changes the SVG therefore reads as drift, which is
684
+ * honest: the committed artifact really has changed.
685
+ */
686
+ export function diagramDrift({ rootDir, outDir = DIAGRAM_OUT_DIR, csRoot = rootDir, state = null, includeTests = false, packageFilter = null }) {
687
+ const outDirAbs = resolve(rootDir, outDir);
688
+ const manifestPath = join(outDirAbs, DIAGRAM_MANIFEST);
689
+ if (!existsSync(manifestPath)) {
690
+ return { available: false, ok: true, reason: 'no diagram tree was generated', missing: [], changed: [], extra: [], expected: 0, recorded: 0 };
691
+ }
692
+
693
+ // Read the record when the caller did not supply one. Comparing against a
694
+ // structure-only model would report every work-coloured tree as drifted, and a
695
+ // false verdict here would stop a correct project at closure.
696
+ const doc = state ?? readState(rootDir).state ?? null;
697
+ const { files } = generateDiagrams({ rootDir, outDir: outDirAbs, csRoot, state: doc, includeTests, packageFilter });
698
+ const expected = new Map(files.map((file) => [toPosix(relative(outDirAbs, file.path)), file.content]));
699
+
700
+ const missing = [];
701
+ const changed = [];
702
+ for (const [rel, content] of expected) {
703
+ let onDisk;
704
+ try {
705
+ onDisk = readFileSync(join(outDirAbs, rel), 'utf-8');
706
+ } catch {
707
+ missing.push(rel);
708
+ continue;
709
+ }
710
+ if (onDisk !== content) changed.push(rel);
711
+ }
712
+
713
+ let recorded = [];
714
+ try {
715
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8'));
716
+ if (Array.isArray(parsed?.files)) recorded = parsed.files;
717
+ } catch {
718
+ // An unreadable manifest is itself drift; it is reported as `changed` below.
719
+ }
720
+ const extra = recorded.filter((rel) => !expected.has(rel));
721
+
722
+ missing.sort();
723
+ changed.sort();
724
+ extra.sort();
725
+ return {
726
+ available: true,
727
+ ok: missing.length === 0 && changed.length === 0 && extra.length === 0,
728
+ reason: null,
729
+ missing,
730
+ changed,
731
+ extra,
732
+ expected: expected.size,
733
+ recorded: recorded.length,
734
+ };
735
+ }