@celilo/cli 1.1.0 → 1.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 (43) hide show
  1. package/CELILO_CORE_MODULES.md +2 -2
  2. package/CELILO_SUBSYSTEMS.md +16 -1
  3. package/package.json +4 -4
  4. package/src/cli/commands/hook-run.ts +5 -8
  5. package/src/cli/commands/ipam.ts +93 -0
  6. package/src/cli/commands/machine-add.ts +22 -0
  7. package/src/cli/commands/system-audit.ts +2 -0
  8. package/src/cli/commands/system-doctor.ts +148 -5
  9. package/src/cli/commands/system-update.ts +2 -0
  10. package/src/cli/completion.ts +38 -5
  11. package/src/cli/index.ts +10 -1
  12. package/src/cli/tui/audit-state.ts +2 -0
  13. package/src/db/schema.ts +41 -1
  14. package/src/hooks/artifact-retention.test.ts +136 -0
  15. package/src/hooks/artifact-retention.ts +159 -0
  16. package/src/hooks/executor.test.ts +80 -0
  17. package/src/hooks/executor.ts +68 -23
  18. package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
  19. package/src/hooks/types.ts +20 -2
  20. package/src/ipam/allocator.test.ts +38 -0
  21. package/src/ipam/allocator.ts +63 -1
  22. package/src/ipam/auto-allocator.ts +7 -0
  23. package/src/policy/module-business-baseline.ts +404 -0
  24. package/src/policy/no-module-business-in-core.test.ts +504 -0
  25. package/src/services/alerting/keys.ts +21 -1
  26. package/src/services/alerting/run-monitor.ts +6 -1
  27. package/src/services/aspect-reconcile.test.ts +460 -0
  28. package/src/services/aspect-runner.test.ts +1 -0
  29. package/src/services/aspect-runner.ts +408 -37
  30. package/src/services/audit/browser-pin.test.ts +167 -0
  31. package/src/services/audit/browser-pin.ts +185 -0
  32. package/src/services/audit/index.test.ts +1 -0
  33. package/src/services/audit/index.ts +3 -0
  34. package/src/services/audit/types.ts +1 -0
  35. package/src/services/deploy-ansible-recap.test.ts +76 -0
  36. package/src/services/deploy-ansible.ts +56 -1
  37. package/src/services/health-runner.ts +15 -1
  38. package/src/services/module-deploy.ts +70 -16
  39. package/src/services/update/orchestrator.test.ts +1 -0
  40. package/src/system/browser-provisioning.test.ts +67 -0
  41. package/src/system/prereqs.test.ts +73 -0
  42. package/src/system/prereqs.ts +89 -12
  43. package/src/templates/ingress-ip.test.ts +108 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Recurrence gate for openspec/changes/module-business-barrier/inventory.md
3
+ * section 5: **celilo orchestrates modules; it does not know what they do.**
4
+ *
5
+ * Core may record who asked for what, who owns it, and when it dies. It may not
6
+ * model a provider's own configuration. The tell is a column, branch or literal
7
+ * that only makes sense once you know the implementation — `websocket`,
8
+ * `content_hash`, `iptables-save`, `/srv/www/<slug>`, port `9000`.
9
+ *
10
+ * Four scans, one baseline (`./module-business-baseline.ts`), one sanity test.
11
+ * The baseline may shrink freely and may only grow with a justification a
12
+ * reviewer sees, so the gate ratchets rather than demanding the migration land
13
+ * first — a gate that demanded that would be deleted before the migration
14
+ * arrived, which is how this pattern regrew the first time.
15
+ *
16
+ * Why a gate at all: the pattern regrew INSIDE the change that was removing it.
17
+ * `capability-loader.ts`'s capability-name branches went 6 -> 7 in `baf2c70d`,
18
+ * celilo#847 part 1, whose stated purpose was deleting the one hardcoded
19
+ * capability call from core. Nobody was careless. Each addition looks reasonable
20
+ * on its own, which is exactly why something mechanical has to stop it.
21
+ */
22
+
23
+ import { describe, expect, test } from 'bun:test';
24
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
+ import { join, relative, resolve } from 'node:path';
26
+ import { KNOWN_CAPABILITY_NAMES } from '@celilo/capabilities';
27
+ import {
28
+ CAPABILITY_NAME_BASELINE,
29
+ CAPABILITY_OWNED_TABLES,
30
+ PROVIDER_LITERAL_BASELINE,
31
+ SERVICE_FILENAME_BASELINE,
32
+ } from './module-business-baseline';
33
+
34
+ /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
35
+ function repoRoot(): string {
36
+ let dir = import.meta.dir;
37
+ for (let i = 0; i < 8; i++) {
38
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
39
+ dir = resolve(dir, '..');
40
+ }
41
+ throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
42
+ }
43
+
44
+ const ROOT = repoRoot();
45
+ const SCHEMA_PATH = join(ROOT, 'apps/celilo/src/db/schema.ts');
46
+
47
+ /**
48
+ * Roots that make up "core" for scans B and D: the celilo backend, and the
49
+ * shared package every module bundles.
50
+ */
51
+ const CORE_ROOTS = ['apps/celilo/src', 'packages/capabilities/src'];
52
+
53
+ /**
54
+ * Excluded from the name/literal scans, both load-bearing:
55
+ *
56
+ * - `capability-registry.ts` is where the names are SOURCED from. It contains
57
+ * every name twice by construction (interface key + array entry) — 26 rows
58
+ * that can never shrink because they are the definition, not the debt.
59
+ * - `src/policy/` is this gate. Its baseline necessarily names every capability
60
+ * and every seed literal; without this the gate fails on itself.
61
+ */
62
+ const SCAN_EXCLUSIONS = [
63
+ 'packages/capabilities/src/capability-registry.ts',
64
+ 'apps/celilo/src/policy',
65
+ ];
66
+
67
+ /** Repo-relative POSIX path, so baseline entries are stable across checkouts. */
68
+ function rel(absolute: string): string {
69
+ return relative(ROOT, absolute).split('\\').join('/');
70
+ }
71
+
72
+ function isExcluded(file: string): boolean {
73
+ const r = rel(file);
74
+ return SCAN_EXCLUSIONS.some((ex) => r === ex || r.startsWith(`${ex}/`));
75
+ }
76
+
77
+ /** Every production `.ts` under a root (excludes node_modules, tests, declarations). */
78
+ function productionSources(root: string): string[] {
79
+ const out: string[] = [];
80
+ const walk = (dir: string) => {
81
+ for (const entry of readdirSync(dir)) {
82
+ if (entry === 'node_modules') continue;
83
+ const p = join(dir, entry);
84
+ if (statSync(p).isDirectory()) walk(p);
85
+ else if (p.endsWith('.ts') && !p.endsWith('.test.ts') && !p.endsWith('.d.ts')) out.push(p);
86
+ }
87
+ };
88
+ const abs = join(ROOT, root);
89
+ if (existsSync(abs)) walk(abs);
90
+ return out.filter((f) => !isExcluded(f));
91
+ }
92
+
93
+ /**
94
+ * Remove comments, keep string literals.
95
+ *
96
+ * Comments must not count: schema.ts and the loader both discuss capabilities in
97
+ * prose, and a gate that fires on an edited sentence gets deleted. Strings MUST
98
+ * count — `capName === 'firewall'` is the primary violation shape.
99
+ *
100
+ * Known limit: a regex literal containing `//` or `/*` would be mis-read as a
101
+ * comment. None exists in the scanned roots, and the failure direction is
102
+ * under-reporting, which the baseline's exact counts surface as a spurious
103
+ * "improvement" rather than a silent pass.
104
+ */
105
+ function stripComments(src: string): string {
106
+ let out = '';
107
+ let i = 0;
108
+ const n = src.length;
109
+ while (i < n) {
110
+ const c = src[i];
111
+ const d = src[i + 1];
112
+ if (c === '/' && d === '/') {
113
+ while (i < n && src[i] !== '\n') i++;
114
+ continue;
115
+ }
116
+ if (c === '/' && d === '*') {
117
+ i += 2;
118
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
119
+ i += 2;
120
+ continue;
121
+ }
122
+ if (c === "'" || c === '"' || c === '`') {
123
+ const quote = c;
124
+ out += c;
125
+ i++;
126
+ while (i < n) {
127
+ if (src[i] === '\\') {
128
+ out += src[i] + (src[i + 1] ?? '');
129
+ i += 2;
130
+ continue;
131
+ }
132
+ if (src[i] === quote) break;
133
+ out += src[i];
134
+ i++;
135
+ }
136
+ out += quote;
137
+ i++;
138
+ continue;
139
+ }
140
+ out += c;
141
+ i++;
142
+ }
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * Compare a measured set against the baseline and describe the difference the
148
+ * way the reader needs to hear it.
149
+ *
150
+ * Growth is a violation. Shrinkage is an improvement and still fails, because a
151
+ * baseline that silently keeps headroom lets the count creep back up later — the
152
+ * ratchet only holds if the recorded number is the real one.
153
+ */
154
+ function diffAgainstBaseline<T>(
155
+ measured: Map<string, T>,
156
+ baseline: Map<string, T>,
157
+ describe: (key: string, value: T) => string,
158
+ fix: string,
159
+ ): string[] {
160
+ const problems: string[] = [];
161
+ for (const [key, value] of measured) {
162
+ if (!baseline.has(key)) problems.push(`NEW ${describe(key, value)}\n ${fix}`);
163
+ else if (JSON.stringify(baseline.get(key)) !== JSON.stringify(value)) {
164
+ problems.push(
165
+ `CHANGED ${describe(key, value)}\n baseline says ${JSON.stringify(baseline.get(key))}, tree has ${JSON.stringify(value)}.\n If you REMOVED some, lower the baseline — thank you. If you added some, ${fix}`,
166
+ );
167
+ }
168
+ }
169
+ for (const [key, value] of baseline) {
170
+ if (!measured.has(key)) {
171
+ problems.push(
172
+ `GONE ${describe(key, value)}\n Fixed — delete this row from module-business-baseline.ts.`,
173
+ );
174
+ }
175
+ }
176
+ return problems;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Scan A — every table declares an owner
181
+ // ---------------------------------------------------------------------------
182
+
183
+ interface TableDeclaration {
184
+ readonly name: string;
185
+ /** 1-based line of the `export const … = sqliteTable(` declaration. */
186
+ readonly line: number;
187
+ readonly owner: string | null;
188
+ }
189
+
190
+ /**
191
+ * Parse every `sqliteTable(` in schema.ts and resolve its owner tag.
192
+ *
193
+ * Anchors on the `sqliteTable(` token — the one thing definitionally present —
194
+ * and walks BACK to the declaration and then to the docblock. The obvious
195
+ * alternative, matching `^export const [A-Za-z]+ = sqliteTable\(`, is the regex
196
+ * that checks the audit's appendix and it must not be reused here: it needs
197
+ * column zero, `export`, and a letters-only const name. In a checker each miss
198
+ * fails loudly (a short count, a visible diff row); in a GATE the identical miss
199
+ * fails OPEN — an unmatched table is simply never required to carry a tag and
200
+ * nothing goes red. `export const oauth2Tokens = sqliteTable(` defeats it on the
201
+ * digit alone.
202
+ *
203
+ * So an unparseable declaration THROWS rather than being skipped: the gate must
204
+ * distinguish "nothing to check" from "could not check".
205
+ */
206
+ function parseTableDeclarations(): TableDeclaration[] {
207
+ const lines = readFileSync(SCHEMA_PATH, 'utf-8').split('\n');
208
+ const tables: TableDeclaration[] = [];
209
+
210
+ for (let i = 0; i < lines.length; i++) {
211
+ if (!lines[i].includes('sqliteTable(')) continue;
212
+
213
+ // 22 tables are single-line (`= sqliteTable('name', {`), 15 put the name on
214
+ // the following line. Both forms, no exceptions.
215
+ const name =
216
+ lines[i].match(/sqliteTable\(\s*'([a-z0-9_]+)'/)?.[1] ??
217
+ lines[i + 1]?.match(/^\s*'([a-z0-9_]+)',/)?.[1];
218
+ if (!name) {
219
+ throw new Error(
220
+ `schema.ts:${i + 1}: sqliteTable( whose table name could not be resolved.
221
+ The gate cannot check a table it cannot name. Declare it in one of the two
222
+ existing forms: sqliteTable('name', { … }) or the multi-line form with the name
223
+ on the following line.`,
224
+ );
225
+ }
226
+
227
+ // Walk back to the docblock. Exported type aliases sit between docblock and
228
+ // table for module_builds, backup_storages, backups and module_operations —
229
+ // a naive "docblock immediately above" parser reports those four as missing
230
+ // a tag they carry, and the natural fix is to paste a second docblock.
231
+ let j = i - 1;
232
+ while (j >= 0 && !lines[j].trimEnd().endsWith('*/')) {
233
+ const text = lines[j].trim();
234
+ const skippable =
235
+ text === '' || text.startsWith('export type') || text.startsWith('|') || text.endsWith(';');
236
+ if (!skippable) break;
237
+ j--;
238
+ }
239
+
240
+ let owner: string | null = null;
241
+ if (j >= 0 && lines[j].trimEnd().endsWith('*/')) {
242
+ for (let k = j; k >= 0; k--) {
243
+ const tag = lines[k].match(/@owner\s+(\S+)/);
244
+ if (tag) {
245
+ owner = tag[1];
246
+ break;
247
+ }
248
+ if (lines[k].includes('/**')) break;
249
+ }
250
+ }
251
+
252
+ tables.push({ name, line: i + 1, owner });
253
+ }
254
+
255
+ return tables;
256
+ }
257
+
258
+ describe('recurrence gate: celilo core holds no module business — Scan A (table owners)', () => {
259
+ const tables = parseTableDeclarations();
260
+
261
+ test('scans every table in schema.ts (sanity — the scan actually ran)', () => {
262
+ expect(tables.length).toBeGreaterThan(30);
263
+ });
264
+
265
+ test('every sqliteTable declares an @owner', () => {
266
+ const untagged = tables
267
+ .filter((t) => t.owner === null)
268
+ .map(
269
+ (t) =>
270
+ `schema.ts:${t.line} '${t.name}' has no @owner tag.
271
+ Add one to its docblock: ' * @owner celilo — <why this is cross-module bookkeeping>'
272
+ or ' * @owner capability:<name> — <which provider's domain this models>'.
273
+ See openspec/changes/module-business-barrier/inventory.md section 5.`,
274
+ );
275
+ expect(untagged, `Tables missing an @owner tag:\n ${untagged.join('\n ')}`).toEqual([]);
276
+ });
277
+
278
+ test('@owner capability:<name> names a real capability', () => {
279
+ const known = new Set<string>(KNOWN_CAPABILITY_NAMES);
280
+ const bogus = tables
281
+ .filter((t) => t.owner?.startsWith('capability:'))
282
+ .filter((t) => !known.has(t.owner?.slice('capability:'.length) ?? ''))
283
+ .map(
284
+ (t) =>
285
+ `schema.ts:${t.line} '${t.name}' is tagged '${t.owner}', which is not in KNOWN_CAPABILITY_NAMES.`,
286
+ );
287
+ expect(bogus, `Unknown capability in an @owner tag:\n ${bogus.join('\n ')}`).toEqual([]);
288
+ });
289
+
290
+ test('the set of capability-owned tables equals the baseline', () => {
291
+ const measured = new Map(
292
+ tables
293
+ .filter((t) => t.owner?.startsWith('capability:'))
294
+ .map((t) => [t.name, t.owner?.slice('capability:'.length) ?? ''] as const),
295
+ );
296
+ const baseline = new Map(Object.entries(CAPABILITY_OWNED_TABLES));
297
+
298
+ const problems = diffAgainstBaseline(
299
+ measured,
300
+ baseline,
301
+ (table, capability) => `table '${table}' is owned by capability '${capability}'`,
302
+ "A new table modelling one capability's domain belongs in that provider's module_configs\n (the wireguard-manager D2 precedent), not in core's schema. If core genuinely must\n hold it, add it to CAPABILITY_OWNED_TABLES with the reason.",
303
+ );
304
+ expect(
305
+ problems,
306
+ `Scan A — capability-owned tables changed:\n ${problems.join('\n ')}`,
307
+ ).toEqual([]);
308
+ });
309
+ });
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Scan B — capability names in core
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /**
316
+ * Three forms, stated as regex because "string literal or object key" is where
317
+ * an implementer silently guesses. Property access is not optional: audit row X3
318
+ * (`capabilitiesMap.dns_internal.server.ip`) is a violation the first two forms
319
+ * do not see.
320
+ */
321
+ function capabilityNameOccurrences(code: string, capability: string): number {
322
+ const stringLiteral = `['"\`]${capability}['"\`]`; // capName === 'firewall'
323
+ const objectKey = `(?<![A-Za-z0-9_$])${capability}\\s*:`; // public_web: 'dmz'
324
+ const propertyAccess = `\\.${capability}`; // caps.dns_internal
325
+ const pattern = new RegExp(
326
+ `(?:${stringLiteral}|${objectKey}|${propertyAccess})(?![A-Za-z0-9_])`,
327
+ 'g',
328
+ );
329
+ return (code.match(pattern) ?? []).length;
330
+ }
331
+
332
+ function scanCapabilityNames(): Map<string, number> {
333
+ const measured = new Map<string, number>();
334
+ for (const root of CORE_ROOTS) {
335
+ for (const file of productionSources(root)) {
336
+ const code = stripComments(readFileSync(file, 'utf-8'));
337
+ for (const capability of KNOWN_CAPABILITY_NAMES) {
338
+ const count = capabilityNameOccurrences(code, capability);
339
+ if (count > 0) measured.set(`${rel(file)}::${capability}`, count);
340
+ }
341
+ }
342
+ }
343
+ return measured;
344
+ }
345
+
346
+ describe('recurrence gate: celilo core holds no module business — Scan B (capability names)', () => {
347
+ const measured = scanCapabilityNames();
348
+
349
+ test('scans a non-trivial set of core sources (sanity — the scan actually ran)', () => {
350
+ const scanned = CORE_ROOTS.flatMap(productionSources);
351
+ expect(scanned.length).toBeGreaterThan(100);
352
+ expect(KNOWN_CAPABILITY_NAMES.length).toBeGreaterThan(5);
353
+ });
354
+
355
+ test('no new capability name appears in core', () => {
356
+ const baseline = new Map(
357
+ CAPABILITY_NAME_BASELINE.map((row) => [`${row.file}::${row.capability}`, row.count] as const),
358
+ );
359
+ const problems = diffAgainstBaseline(
360
+ measured,
361
+ baseline,
362
+ (key, count) => {
363
+ const [file, capability] = key.split('::');
364
+ return `${file} names '${capability}' ${count}x`;
365
+ },
366
+ 'Core branching on a capability NAME is the pattern this gate exists to stop. Let the\n provider declare the behaviour in its manifest instead. If it truly must land here,\n add the row to CAPABILITY_NAME_BASELINE with a one-line justification.',
367
+ );
368
+ expect(problems, `Scan B — capability names in core:\n ${problems.join('\n ')}`).toEqual([]);
369
+ });
370
+ });
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Scan C — service filenames
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /** `public_web` -> `public-web`: capability names are snake_case, filenames kebab. */
377
+ function kebab(capability: string): string {
378
+ return capability.split('_').join('-');
379
+ }
380
+
381
+ /**
382
+ * Recurses over `services/**` rather than naming `services/` + `services/audit/`.
383
+ * Non-recursive gives 4 hits and recursive 5, and the extra one
384
+ * (`alerting/notification-responder.ts`) is a false positive — but narrowing to
385
+ * dodge one baseline row would leave five subdirectories unwatched, including
386
+ * `alerting/`, which is precisely where a `notification`-capability leak lands.
387
+ */
388
+ function scanServiceFilenames(): Map<string, string> {
389
+ const measured = new Map<string, string>();
390
+ for (const file of productionSources('apps/celilo/src/services')) {
391
+ const stem = (file.split('/').pop() ?? '').replace(/\.ts$/, '');
392
+ const segments = stem.split('-');
393
+ for (const capability of KNOWN_CAPABILITY_NAMES) {
394
+ const wanted = kebab(capability).split('-');
395
+ const present = segments.some((_, i) =>
396
+ wanted.every((part, offset) => segments[i + offset] === part),
397
+ );
398
+ if (present) measured.set(`${rel(file)}::${capability}`, capability);
399
+ }
400
+ }
401
+ return measured;
402
+ }
403
+
404
+ describe('recurrence gate: celilo core holds no module business — Scan C (service filenames)', () => {
405
+ const measured = scanServiceFilenames();
406
+
407
+ test('scans the whole services tree (sanity — the scan actually ran)', () => {
408
+ const scanned = productionSources('apps/celilo/src/services');
409
+ expect(scanned.length).toBeGreaterThan(50);
410
+ // Recursion is the point: subdirectories must be in the walk.
411
+ expect(scanned.some((f) => rel(f).includes('/services/audit/'))).toBe(true);
412
+ expect(scanned.some((f) => rel(f).includes('/services/alerting/'))).toBe(true);
413
+ });
414
+
415
+ test('no new service file is named after a capability', () => {
416
+ const baseline = new Map(
417
+ SERVICE_FILENAME_BASELINE.map(
418
+ (row) => [`${row.file}::${row.capability}`, row.capability] as const,
419
+ ),
420
+ );
421
+ const problems = diffAgainstBaseline(
422
+ measured,
423
+ baseline,
424
+ (key, capability) => `${key.split('::')[0]} is named after capability '${capability}'`,
425
+ "A core service named after one capability is core doing that provider's work. Name it for\n the generic mechanism, or move it into the provider. If it must stay, baseline it.",
426
+ );
427
+ expect(
428
+ problems,
429
+ `Scan C — capability-named service files:\n ${problems.join('\n ')}`,
430
+ ).toEqual([]);
431
+ });
432
+ });
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // Scan D — provider-implementation literals
436
+ // ---------------------------------------------------------------------------
437
+
438
+ /**
439
+ * Provider knowledge does not always name the capability. Audit row X11 (core
440
+ * shelling `wg pubkey`) has zero capability-name hits, is not a table and is not
441
+ * a service file — it is invisible to A, B and C, so without this scan the gate
442
+ * covers 47 of 48 rows.
443
+ *
444
+ * A real gate, not a warning: three known-true seeds plus a baseline has no
445
+ * false-positive problem, and "a gate nobody has seen fail is not a gate"
446
+ * (Rule 7.6) is the argument section 5 makes everywhere else.
447
+ *
448
+ * Seeded from what the audit actually found. Grow it only with a literal someone
449
+ * has seen in a real violation — this is a ratchet, not a predictor.
450
+ */
451
+ const PROVIDER_LITERALS = [
452
+ { literal: 'iptables-save', catches: "S8 — core parsing a firewall's live ruleset" },
453
+ { literal: 'wg pubkey', catches: "X11 — core shelling WireGuard's CLI" },
454
+ { literal: '/srv/www', catches: "X8/S2 — core knowing caddy's on-disk asset layout" },
455
+ ] as const;
456
+
457
+ function scanProviderLiterals(): Map<string, number> {
458
+ const measured = new Map<string, number>();
459
+ for (const root of CORE_ROOTS) {
460
+ for (const file of productionSources(root)) {
461
+ const code = stripComments(readFileSync(file, 'utf-8'));
462
+ for (const { literal } of PROVIDER_LITERALS) {
463
+ const count = code.split(literal).length - 1;
464
+ if (count > 0) measured.set(`${rel(file)}::${literal}`, count);
465
+ }
466
+ }
467
+ }
468
+ return measured;
469
+ }
470
+
471
+ describe('recurrence gate: celilo core holds no module business — Scan D (provider literals)', () => {
472
+ const measured = scanProviderLiterals();
473
+
474
+ test('every seed literal is a known-true violation somewhere (sanity — the scan can fire)', () => {
475
+ // If a seed stops matching anywhere, either the violation was fixed (delete
476
+ // the seed) or the scan broke. Silence must not be mistaken for cleanliness.
477
+ for (const { literal } of PROVIDER_LITERALS) {
478
+ const hits = [...measured.keys()].filter((k) => k.endsWith(`::${literal}`));
479
+ expect(
480
+ hits.length,
481
+ `seed literal '${literal}' matches nothing — fix the scan or drop the seed`,
482
+ ).toBeGreaterThan(0);
483
+ }
484
+ });
485
+
486
+ test('no new provider-implementation literal appears in core', () => {
487
+ const baseline = new Map(
488
+ PROVIDER_LITERAL_BASELINE.map((row) => [`${row.file}::${row.literal}`, row.count] as const),
489
+ );
490
+ const problems = diffAgainstBaseline(
491
+ measured,
492
+ baseline,
493
+ (key, count) => {
494
+ const [file, literal] = key.split('::');
495
+ return `${file} contains '${literal}' ${count}x`;
496
+ },
497
+ "Core executing or hardcoding one provider's implementation detail belongs in that\n provider's module. If it must stay, baseline it with the reason.",
498
+ );
499
+ expect(
500
+ problems,
501
+ `Scan D — provider-implementation literals in core:\n ${problems.join('\n ')}`,
502
+ ).toEqual([]);
503
+ });
504
+ });
@@ -174,6 +174,7 @@ export function failingKeysFromHealthItems(
174
174
  moduleId: string,
175
175
  items: HealthCheckItemLike[],
176
176
  monitorSeverity: AlertSeverity,
177
+ artifactPaths?: string[],
177
178
  ): FailingKey[] {
178
179
  const failing: FailingKey[] = [];
179
180
  for (const item of items) {
@@ -183,8 +184,27 @@ export function failingKeysFromHealthItems(
183
184
  key: moduleCheckAlertKey(moduleId, item.name),
184
185
  severity,
185
186
  message: item.message,
186
- details: item.details,
187
+ details: withArtifacts(item.details, artifactPaths),
187
188
  });
188
189
  }
189
190
  return failing;
190
191
  }
192
+
193
+ /**
194
+ * Append the run's artifact paths to an item's details.
195
+ *
196
+ * `details` is the field that reaches the operator through alerting, so
197
+ * this is the last link in the chain — collecting artifacts and carrying
198
+ * them on the result accomplishes nothing if they stop here.
199
+ *
200
+ * They go on EVERY failing item of the run rather than being attributed to
201
+ * one, because the framework collects per RUN and cannot know which check
202
+ * wrote which file. A consumer that wants a specific attribution already
203
+ * has one: it names the artifact in its own `details`, which is preserved
204
+ * above whatever the framework appends.
205
+ */
206
+ function withArtifacts(details: string | undefined, artifactPaths?: string[]): string | undefined {
207
+ if (!artifactPaths || artifactPaths.length === 0) return details;
208
+ const rendered = `Artifacts:\n ${artifactPaths.join('\n ')}`;
209
+ return details ? `${details}\n\n${rendered}` : rendered;
210
+ }
@@ -77,7 +77,12 @@ async function runCheck(
77
77
  }
78
78
  return {
79
79
  outcome: 'success',
80
- failingKeys: failingKeysFromHealthItems(monitor.target, result.checks, severity),
80
+ failingKeys: failingKeysFromHealthItems(
81
+ monitor.target,
82
+ result.checks,
83
+ severity,
84
+ result.artifactPaths,
85
+ ),
81
86
  };
82
87
  }
83
88