@celilo/cli 1.1.0 → 1.2.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.
- package/CELILO_SUBSYSTEMS.md +12 -0
- package/package.json +2 -2
- package/src/cli/commands/hook-run.ts +5 -8
- package/src/cli/commands/system-audit.ts +2 -0
- package/src/cli/commands/system-doctor.ts +30 -1
- package/src/cli/commands/system-update.ts +2 -0
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/db/schema.ts +41 -1
- package/src/hooks/artifact-retention.test.ts +136 -0
- package/src/hooks/artifact-retention.ts +159 -0
- package/src/hooks/executor.test.ts +80 -0
- package/src/hooks/executor.ts +68 -23
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +25 -0
- package/src/hooks/types.ts +20 -2
- package/src/policy/module-business-baseline.ts +404 -0
- package/src/policy/no-module-business-in-core.test.ts +504 -0
- package/src/services/alerting/keys.ts +21 -1
- package/src/services/alerting/run-monitor.ts +6 -1
- package/src/services/audit/browser-pin.test.ts +167 -0
- package/src/services/audit/browser-pin.ts +185 -0
- package/src/services/audit/index.test.ts +1 -0
- package/src/services/audit/index.ts +3 -0
- package/src/services/audit/types.ts +1 -0
- package/src/services/health-runner.ts +15 -1
- package/src/services/module-deploy.ts +4 -4
- package/src/services/update/orchestrator.test.ts +1 -0
- package/src/system/browser-provisioning.test.ts +67 -0
- package/src/system/prereqs.test.ts +73 -0
- package/src/system/prereqs.ts +89 -12
|
@@ -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(
|
|
80
|
+
failingKeys: failingKeysFromHealthItems(
|
|
81
|
+
monitor.target,
|
|
82
|
+
result.checks,
|
|
83
|
+
severity,
|
|
84
|
+
result.artifactPaths,
|
|
85
|
+
),
|
|
81
86
|
};
|
|
82
87
|
}
|
|
83
88
|
|