@shrkcrft/cli 0.1.0-alpha.27 → 0.1.0-alpha.29

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 (46) hide show
  1. package/dist/commands/baseline.command.d.ts +8 -0
  2. package/dist/commands/baseline.command.d.ts.map +1 -0
  3. package/dist/commands/baseline.command.js +542 -0
  4. package/dist/commands/changelog-data.d.ts.map +1 -1
  5. package/dist/commands/changelog-data.js +42 -0
  6. package/dist/commands/check.command.d.ts.map +1 -1
  7. package/dist/commands/check.command.js +147 -12
  8. package/dist/commands/command-catalog.d.ts.map +1 -1
  9. package/dist/commands/command-catalog.js +120 -0
  10. package/dist/commands/daily.commands.d.ts.map +1 -1
  11. package/dist/commands/daily.commands.js +11 -1
  12. package/dist/commands/gates.command.d.ts +16 -0
  13. package/dist/commands/gates.command.d.ts.map +1 -0
  14. package/dist/commands/gates.command.js +377 -0
  15. package/dist/commands/generated.command.d.ts +6 -0
  16. package/dist/commands/generated.command.d.ts.map +1 -0
  17. package/dist/commands/generated.command.js +544 -0
  18. package/dist/commands/help.command.d.ts.map +1 -1
  19. package/dist/commands/help.command.js +64 -2
  20. package/dist/commands/ingest.command.d.ts +11 -0
  21. package/dist/commands/ingest.command.d.ts.map +1 -1
  22. package/dist/commands/ingest.command.js +49 -23
  23. package/dist/commands/policy-lint.command.d.ts +37 -0
  24. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  25. package/dist/commands/policy-lint.command.js +167 -8
  26. package/dist/commands/registry.command.d.ts.map +1 -1
  27. package/dist/commands/registry.command.js +70 -13
  28. package/dist/commands/wiring.command.d.ts.map +1 -1
  29. package/dist/commands/wiring.command.js +25 -0
  30. package/dist/exit-codes.d.ts +27 -7
  31. package/dist/exit-codes.d.ts.map +1 -1
  32. package/dist/exit-codes.js +47 -8
  33. package/dist/finish/run-finish.d.ts.map +1 -1
  34. package/dist/finish/run-finish.js +9 -3
  35. package/dist/gates/gate-envelope.d.ts +64 -0
  36. package/dist/gates/gate-envelope.d.ts.map +1 -0
  37. package/dist/gates/gate-envelope.js +26 -0
  38. package/dist/gates/gate-rule-view.d.ts +35 -0
  39. package/dist/gates/gate-rule-view.d.ts.map +1 -0
  40. package/dist/gates/gate-rule-view.js +81 -0
  41. package/dist/gates/rule-coverage.d.ts +53 -0
  42. package/dist/gates/rule-coverage.d.ts.map +1 -0
  43. package/dist/gates/rule-coverage.js +165 -0
  44. package/dist/main.d.ts.map +1 -1
  45. package/dist/main.js +27 -3
  46. package/package.json +33 -33
@@ -0,0 +1,544 @@
1
+ /**
2
+ * `shrk generated {list,check,update,explain}` — the generated-artifact drift +
3
+ * provenance GATE. It shares the `generated` noun with the classifier verbs
4
+ * (`report` / `protect`, in `ingest.command.ts`) on purpose: that half FINDS
5
+ * what is generated, this half proves it has not drifted.
6
+ *
7
+ * shrk generated list # every declared artifact set
8
+ * shrk generated check [--id X] # regen → temp dir → diff BOTH ways + headers
9
+ * [--headers-only] # header contract only; never spawns
10
+ * shrk generated update [--id X] # run regen in place (the bless step)
11
+ * shrk generated explain --id X # what it will run + what it currently sees
12
+ *
13
+ * The build compiles a hand-edited generated file perfectly happily; only
14
+ * regenerate-into-a-temp-dir-and-diff catches the edit. `regen` therefore
15
+ * SPAWNS a shell command, which is why the pack-plane merge seam drops any
16
+ * pack-contributed rule declaring one — a header-only rule (no `regen`) is
17
+ * fully useful and never spawns, so packs can still ship it.
18
+ */
19
+ import { spawnSync } from 'node:child_process';
20
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
21
+ import * as nodeOs from 'node:os';
22
+ import * as nodePath from 'node:path';
23
+ import { failsWhenEmpty } from '@shrkcrft/core';
24
+ import { checkProvenanceHeaders, compareGeneratedTrees, scanGeneratedFiles, } from '@shrkcrft/boundaries';
25
+ import { resolveProjectConfig } from '@shrkcrft/inspector';
26
+ import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
27
+ import { ExitCode } from "../exit-codes.js";
28
+ import { asJson, header, kv } from "../output/format-output.js";
29
+ import { buildGateEnvelope } from "../gates/gate-envelope.js";
30
+ const SCHEMA = 'sharkcraft.generated-drift/v1';
31
+ const DEFAULT_TIMEOUT_MS = 120_000;
32
+ /** Cap on the temp tree read — a runaway regen must not be read into memory whole. */
33
+ const MAX_REGEN_FILE_BYTES = 2_000_000;
34
+ async function loadRules(cwd) {
35
+ const loaded = await resolveProjectConfig(cwd);
36
+ if (!loaded.ok)
37
+ return { ok: false, message: loaded.error.message };
38
+ const rel = nodePath.relative(cwd, loaded.value.sharkcraftDir).split(nodePath.sep).join('/');
39
+ return {
40
+ ok: true,
41
+ value: {
42
+ rules: loaded.value.config.generatedArtifacts ?? [],
43
+ planeDiagnostics: loaded.value.planeDiagnostics,
44
+ excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
45
+ },
46
+ };
47
+ }
48
+ /** Read a regenerated temp tree into path→content, relative to `root`. */
49
+ function readTree(root) {
50
+ const out = new Map();
51
+ const visit = (abs) => {
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(abs, { withFileTypes: true });
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ for (const e of entries) {
60
+ const child = nodePath.join(abs, e.name);
61
+ if (e.isDirectory()) {
62
+ visit(child);
63
+ continue;
64
+ }
65
+ if (!e.isFile())
66
+ continue;
67
+ try {
68
+ if (statSync(child).size > MAX_REGEN_FILE_BYTES)
69
+ continue;
70
+ out.set(nodePath.relative(root, child).split(nodePath.sep).join('/'), readFileSync(child, 'utf8'));
71
+ }
72
+ catch {
73
+ // unreadable — skip
74
+ }
75
+ }
76
+ };
77
+ visit(root);
78
+ return out;
79
+ }
80
+ /** Number of trailing path SEGMENTS `a` and `b` share (0 when none). */
81
+ function sharedSuffixSegments(a, b) {
82
+ const x = a.split('/');
83
+ const y = b.split('/');
84
+ let n = 0;
85
+ while (n < x.length && n < y.length && x[x.length - 1 - n] === y[y.length - 1 - n])
86
+ n += 1;
87
+ return n;
88
+ }
89
+ /**
90
+ * Re-key a regenerated tree onto the committed paths.
91
+ *
92
+ * A regen writes into `{TMP}` under its own root, which is rarely the repo
93
+ * root, while committed files are keyed project-relative. The two sets are
94
+ * matched on the LONGEST shared path suffix — not the first suffix that
95
+ * happens to match, because `a.json` alone would otherwise bind to whichever
96
+ * `…/a.json` the iteration reached first and silently compare two unrelated
97
+ * files. Ties break lexically so the mapping is deterministic, each committed
98
+ * path is claimed at most once, and anything unmatched keeps its temp-relative
99
+ * key and surfaces as `only-regenerated` rather than disappearing.
100
+ */
101
+ function alignToCommitted(temp, committed) {
102
+ const committedPaths = [...committed.keys()].sort();
103
+ const claimed = new Set();
104
+ const out = new Map();
105
+ // Best-match first: a temp path with a longer shared suffix has the stronger
106
+ // claim on a committed path, so resolve those before the weaker ones.
107
+ const scored = [...temp.keys()]
108
+ .map((tempPath) => {
109
+ let best;
110
+ for (const c of committedPaths) {
111
+ const score = sharedSuffixSegments(tempPath, c);
112
+ if (score > 0 && (best === undefined || score > best.score))
113
+ best = { path: c, score };
114
+ }
115
+ return { tempPath, best };
116
+ })
117
+ .sort((a, b) => (b.best?.score ?? 0) - (a.best?.score ?? 0) || a.tempPath.localeCompare(b.tempPath));
118
+ for (const { tempPath, best } of scored) {
119
+ const content = temp.get(tempPath);
120
+ if (best && !claimed.has(best.path)) {
121
+ claimed.add(best.path);
122
+ out.set(best.path, content);
123
+ }
124
+ else {
125
+ out.set(tempPath, content);
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+ /** Run `regen` into a fresh temp dir and read the result back. Always cleans up. */
131
+ function runRegen(cwd, rule, committed) {
132
+ const tmp = mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'shrk-generated-'));
133
+ try {
134
+ const command = rule.regen.split('{TMP}').join(tmp);
135
+ const child = spawnSync(command, {
136
+ cwd,
137
+ shell: true,
138
+ encoding: 'utf8',
139
+ timeout: rule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
140
+ maxBuffer: 16 * 1024 * 1024,
141
+ });
142
+ if (child.error)
143
+ return { error: `regen failed to start: ${child.error.message}` };
144
+ if (child.status !== 0) {
145
+ const tail = String(child.stderr ?? '').trim().split('\n').slice(-3).join(' | ');
146
+ return { error: `regen exited ${child.status ?? 'null'}${tail ? ` — ${tail}` : ''}` };
147
+ }
148
+ const tree = readTree(tmp);
149
+ if (tree.size === 0) {
150
+ return { error: 'regen wrote no files into {TMP} — the command probably ignores the output path' };
151
+ }
152
+ return { files: alignToCommitted(tree, committed) };
153
+ }
154
+ finally {
155
+ rmSync(tmp, { recursive: true, force: true });
156
+ }
157
+ }
158
+ function evaluateRule(cwd, rule, excludeDirs, headersOnly) {
159
+ const scan = scanGeneratedFiles(cwd, rule, excludeDirs);
160
+ const severity = rule.severity ?? 'error';
161
+ if (scan.generated.size === 0) {
162
+ const failed = failsWhenEmpty(rule);
163
+ return {
164
+ rule,
165
+ status: failed ? 'failed' : 'skipped',
166
+ committedCount: 0,
167
+ provenance: [],
168
+ driftChecked: false,
169
+ skipReason: `0 files matched generatedGlob (${rule.generatedGlob.join(', ')})`,
170
+ };
171
+ }
172
+ const headers = checkProvenanceHeaders(rule, scan.generated, scan.outside);
173
+ if (headers.error) {
174
+ return {
175
+ rule,
176
+ status: 'error',
177
+ committedCount: scan.generated.size,
178
+ provenance: [],
179
+ driftChecked: false,
180
+ error: headers.error,
181
+ };
182
+ }
183
+ let treeDiff;
184
+ let error;
185
+ const wantDrift = !headersOnly && rule.regen !== undefined;
186
+ if (wantDrift) {
187
+ const regen = runRegen(cwd, rule, scan.generated);
188
+ if (regen.error)
189
+ error = regen.error;
190
+ else
191
+ treeDiff = compareGeneratedTrees(scan.generated, regen.files, rule.compare ?? 'bytes');
192
+ }
193
+ const hardFindings = headers.findings.filter((f) => f.severity === 'error');
194
+ const drifted = (treeDiff?.differences.length ?? 0) > 0;
195
+ const status = error !== undefined
196
+ ? 'error'
197
+ : drifted || (hardFindings.length > 0 && severity === 'error')
198
+ ? 'failed'
199
+ : 'passed';
200
+ return {
201
+ rule,
202
+ status,
203
+ committedCount: scan.generated.size,
204
+ ...(treeDiff ? { treeDiff } : {}),
205
+ provenance: headers.findings,
206
+ ...(error ? { error } : {}),
207
+ driftChecked: wantDrift && error === undefined,
208
+ };
209
+ }
210
+ function hintFor(rule) {
211
+ return (rule.hint ??
212
+ (rule.regen
213
+ ? `regenerate with \`shrk generated update --id ${rule.id}\` and commit the result`
214
+ : 'add the provenance header to the generated file, or move it out of the generated glob'));
215
+ }
216
+ function outcomeJson(o) {
217
+ return {
218
+ id: o.rule.id,
219
+ ...(o.rule.description ? { description: o.rule.description } : {}),
220
+ status: o.status,
221
+ severity: o.rule.severity ?? 'error',
222
+ committedCount: o.committedCount,
223
+ driftChecked: o.driftChecked,
224
+ ...(o.treeDiff ? { differences: o.treeDiff.differences, regeneratedCount: o.treeDiff.regeneratedCount } : {}),
225
+ provenance: o.provenance,
226
+ ...(o.error ? { error: o.error } : {}),
227
+ ...(o.skipReason ? { skipReason: o.skipReason } : {}),
228
+ hint: hintFor(o.rule),
229
+ };
230
+ }
231
+ async function prepare(args) {
232
+ const cwd = resolveCwd(args);
233
+ const json = flagBool(args, 'json');
234
+ const loaded = await loadRules(cwd);
235
+ if (!loaded.ok) {
236
+ if (json)
237
+ process.stdout.write(asJson({ schema: SCHEMA, error: loaded.message }) + '\n');
238
+ else
239
+ process.stderr.write(`Could not load config: ${loaded.message}\n Run \`shrk doctor\` for details.\n`);
240
+ return { ok: false, code: ExitCode.UsageError };
241
+ }
242
+ const id = flagString(args, 'id');
243
+ let rules = loaded.value.rules;
244
+ if (id) {
245
+ const wanted = id.split(',').map((s) => s.trim()).filter(Boolean);
246
+ const known = new Set(rules.map((r) => r.id));
247
+ const unknown = wanted.filter((w) => !known.has(w));
248
+ if (unknown.length > 0) {
249
+ process.stderr.write(`Unknown generated-artifact id(s): ${unknown.join(', ')}. Declared: ${[...known].join(', ') || '(none)'}\n`);
250
+ return { ok: false, code: ExitCode.UsageError };
251
+ }
252
+ rules = rules.filter((r) => wanted.includes(r.id));
253
+ }
254
+ return {
255
+ ok: true,
256
+ cwd,
257
+ rules,
258
+ all: loaded.value.rules,
259
+ excludeDirs: loaded.value.excludeDirs,
260
+ planeDiagnostics: loaded.value.planeDiagnostics,
261
+ };
262
+ }
263
+ function writeNoRules(json) {
264
+ if (json) {
265
+ process.stdout.write(asJson({ schema: SCHEMA, results: [], evaluated: 0, verdict: 'not-verified' }) + '\n');
266
+ return ExitCode.NotVerified;
267
+ }
268
+ process.stdout.write(header('Generated artifacts'));
269
+ process.stdout.write(' No generated-artifact rules declared. Add `generatedArtifacts[]` to\n' +
270
+ ' sharkcraft.config.ts to catch hand-edited generated files and missing\n' +
271
+ ' "do not edit" headers (see docs/generated-drift.md).\n');
272
+ return ExitCode.NotVerified;
273
+ }
274
+ export const generatedListCommand = {
275
+ name: 'list',
276
+ description: 'List every declared generated-artifact rule: its globs, regen command, and header contract.',
277
+ usage: 'shrk generated list [--json]',
278
+ booleanFlags: new Set(['json']),
279
+ async run(args) {
280
+ const prep = await prepare(args);
281
+ if (!prep.ok)
282
+ return prep.code;
283
+ const json = flagBool(args, 'json');
284
+ if (prep.all.length === 0)
285
+ return writeNoRules(json);
286
+ if (json) {
287
+ process.stdout.write(asJson({
288
+ schema: SCHEMA,
289
+ rules: prep.all.map((r) => ({
290
+ id: r.id,
291
+ description: r.description ?? null,
292
+ generatedGlob: r.generatedGlob,
293
+ regen: r.regen ?? null,
294
+ compare: r.compare ?? 'bytes',
295
+ provenanceHeader: r.provenanceHeader ?? null,
296
+ failOnEmpty: r.failOnEmpty === true,
297
+ })),
298
+ diagnostics: prep.planeDiagnostics,
299
+ }) + '\n');
300
+ return ExitCode.VerifiedPass;
301
+ }
302
+ process.stdout.write(header(`Generated artifacts (${prep.all.length})`));
303
+ for (const r of prep.all) {
304
+ process.stdout.write(` • ${r.id}\n`);
305
+ process.stdout.write(` glob ${r.generatedGlob.join(', ')}\n`);
306
+ process.stdout.write(` regen ${r.regen ?? '(header-only — never spawns)'}\n`);
307
+ if (r.provenanceHeader) {
308
+ process.stdout.write(` header /${r.provenanceHeader.mustMatch}/${r.provenanceHeader.forbidOutside ? ' + mislabel check' : ''}\n`);
309
+ }
310
+ if (r.description)
311
+ process.stdout.write(` ${r.description}\n`);
312
+ }
313
+ for (const d of prep.planeDiagnostics)
314
+ process.stdout.write(` ! ${d}\n`);
315
+ return ExitCode.VerifiedPass;
316
+ },
317
+ };
318
+ export const generatedCheckCommand = {
319
+ name: 'check',
320
+ description: 'Regenerate into a temp dir and diff BOTH ways (hand-edited file AND regen-writes-a-subset), plus the "do not edit" header contract.',
321
+ usage: 'shrk generated check [--id <ids>] [--headers-only] [--json]',
322
+ booleanFlags: new Set(['json', 'headers-only']),
323
+ async run(args) {
324
+ const prep = await prepare(args);
325
+ if (!prep.ok)
326
+ return prep.code;
327
+ const json = flagBool(args, 'json');
328
+ const headersOnly = flagBool(args, 'headers-only');
329
+ if (prep.rules.length === 0)
330
+ return writeNoRules(json);
331
+ const outcomes = prep.rules.map((r) => evaluateRule(prep.cwd, r, prep.excludeDirs, headersOnly));
332
+ const failed = outcomes.filter((o) => o.status === 'failed' || (o.status === 'error' && (o.rule.severity ?? 'error') === 'error'));
333
+ const evaluated = outcomes.filter((o) => o.status !== 'skipped').length;
334
+ const skippedCount = outcomes.length - evaluated;
335
+ const exit = failed.length > 0
336
+ ? ExitCode.Failure
337
+ : evaluated === 0 || skippedCount > 0
338
+ ? ExitCode.NotVerified
339
+ : ExitCode.VerifiedPass;
340
+ if (json) {
341
+ process.stdout.write(asJson({
342
+ schema: SCHEMA,
343
+ headersOnly,
344
+ results: outcomes.map(outcomeJson),
345
+ evaluated,
346
+ skipped: outcomes.filter((o) => o.status === 'skipped').length,
347
+ verdict: failed.length > 0 ? 'errors' : evaluated === 0 ? 'not-verified' : 'pass',
348
+ diagnostics: prep.planeDiagnostics,
349
+ gate: buildGateEnvelope('generated check', exit, outcomes.map((o) => ({
350
+ id: o.rule.id,
351
+ type: 'generated',
352
+ status: o.status,
353
+ severity: o.rule.severity ?? 'error',
354
+ counts: {
355
+ files: o.committedCount,
356
+ differences: o.treeDiff?.differences.length ?? 0,
357
+ provenance: o.provenance.length,
358
+ },
359
+ violations: [
360
+ ...(o.treeDiff?.differences ?? []).map((d) => ({
361
+ id: d.file,
362
+ file: d.file,
363
+ message: d.kind,
364
+ hint: hintFor(o.rule),
365
+ })),
366
+ ...o.provenance.map((f) => ({
367
+ id: f.file,
368
+ file: f.file,
369
+ message: f.message,
370
+ hint: hintFor(o.rule),
371
+ })),
372
+ ],
373
+ ...(o.skipReason ? { skipReason: o.skipReason } : {}),
374
+ ...(o.error ? { error: o.error } : {}),
375
+ }))),
376
+ }) + '\n');
377
+ return exit;
378
+ }
379
+ process.stdout.write(header('Generated-artifact drift'));
380
+ process.stdout.write(kv('evaluated', `${evaluated} of ${prep.rules.length}`) + '\n');
381
+ if (headersOnly)
382
+ process.stdout.write(kv('scope', 'headers only — no regen was run') + '\n');
383
+ for (const o of outcomes) {
384
+ if (o.status === 'skipped') {
385
+ process.stdout.write(` – ${o.rule.id} SKIPPED — ${o.skipReason}\n`);
386
+ continue;
387
+ }
388
+ if (o.status === 'error') {
389
+ process.stdout.write(` ! ${o.rule.id} ${o.error}\n`);
390
+ continue;
391
+ }
392
+ const diffs = o.treeDiff?.differences ?? [];
393
+ if (o.status === 'passed') {
394
+ process.stdout.write(` ✓ ${o.rule.id} (${o.committedCount} files` +
395
+ `${o.driftChecked ? ', byte-identical to a fresh regen' : ', headers only'})\n`);
396
+ }
397
+ else {
398
+ process.stdout.write(` ✗ ${o.rule.id} ${diffs.length} file(s) differ from a fresh regen\n`);
399
+ for (const d of diffs.slice(0, 25)) {
400
+ const label = d.kind === 'content'
401
+ ? 'hand-edited (or source changed)'
402
+ : d.kind === 'only-committed'
403
+ ? 'committed but regen no longer produces it'
404
+ : 'regen produces it but it is not committed';
405
+ process.stdout.write(` • ${d.file} — ${label}\n`);
406
+ }
407
+ if (diffs.length > 25)
408
+ process.stdout.write(` … (${diffs.length - 25} more)\n`);
409
+ }
410
+ for (const f of o.provenance.slice(0, 25)) {
411
+ process.stdout.write(` [${f.severity}] ${f.file} — ${f.message}\n`);
412
+ }
413
+ if (o.provenance.length > 25) {
414
+ process.stdout.write(` … (${o.provenance.length - 25} more header finding(s))\n`);
415
+ }
416
+ if (o.status === 'failed')
417
+ process.stdout.write(` → ${hintFor(o.rule)}\n`);
418
+ }
419
+ for (const d of prep.planeDiagnostics)
420
+ process.stdout.write(` ! ${d}\n`);
421
+ if (exit === ExitCode.NotVerified) {
422
+ process.stdout.write('\nNothing was checked — this is NOT a pass. Every rule matched 0 files.\n');
423
+ }
424
+ else if (exit === ExitCode.VerifiedPass) {
425
+ process.stdout.write('\nEvery generated artifact matches its source. ✓\n');
426
+ }
427
+ return exit;
428
+ },
429
+ };
430
+ export const generatedUpdateCommand = {
431
+ name: 'update',
432
+ description: 'Run the declared regen command in place — the one-command bless step after an intentional source change. Writes files.',
433
+ usage: 'shrk generated update [--id <ids>] [--json]',
434
+ booleanFlags: new Set(['json']),
435
+ async run(args) {
436
+ const prep = await prepare(args);
437
+ if (!prep.ok)
438
+ return prep.code;
439
+ const json = flagBool(args, 'json');
440
+ if (prep.rules.length === 0)
441
+ return writeNoRules(json);
442
+ const results = [];
443
+ for (const rule of prep.rules) {
444
+ if (!rule.regen) {
445
+ results.push({ id: rule.id, ran: false, error: 'header-only rule — nothing to regenerate' });
446
+ continue;
447
+ }
448
+ // `{TMP}` is the CHECK contract; `update` writes in place, so it is
449
+ // substituted with the project root and the regen writes its real output.
450
+ const command = rule.regen.split('{TMP}').join(prep.cwd);
451
+ const child = spawnSync(command, {
452
+ cwd: prep.cwd,
453
+ shell: true,
454
+ encoding: 'utf8',
455
+ timeout: rule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
456
+ maxBuffer: 16 * 1024 * 1024,
457
+ stdio: json ? 'pipe' : 'inherit',
458
+ });
459
+ if (child.error) {
460
+ results.push({ id: rule.id, ran: false, error: child.error.message });
461
+ }
462
+ else if (child.status !== 0) {
463
+ results.push({ id: rule.id, ran: true, error: `exited ${child.status ?? 'null'}` });
464
+ }
465
+ else {
466
+ results.push({ id: rule.id, ran: true });
467
+ }
468
+ }
469
+ const failed = results.filter((r) => r.error !== undefined);
470
+ if (json) {
471
+ process.stdout.write(asJson({ schema: SCHEMA, results }) + '\n');
472
+ return failed.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
473
+ }
474
+ process.stdout.write(header('Generated update'));
475
+ for (const r of results) {
476
+ process.stdout.write(` ${r.error ? '!' : '✓'} ${r.id}${r.error ? ` — ${r.error}` : ''}\n`);
477
+ }
478
+ if (failed.length === 0) {
479
+ process.stdout.write('\nRegenerated. Review `git diff` before committing.\n');
480
+ }
481
+ return failed.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
482
+ },
483
+ };
484
+ export const generatedExplainCommand = {
485
+ name: 'explain',
486
+ description: 'Show what ONE generated-artifact rule sees right now: files matched, header contract results, mislabel candidates — without running the regen.',
487
+ usage: 'shrk generated explain --id <id> [--json]',
488
+ booleanFlags: new Set(['json']),
489
+ async run(args) {
490
+ const id = flagString(args, 'id') ?? args.positional[0];
491
+ if (!id) {
492
+ process.stderr.write('Usage: shrk generated explain --id <id>\n');
493
+ return ExitCode.UsageError;
494
+ }
495
+ const prep = await prepare(args);
496
+ if (!prep.ok)
497
+ return prep.code;
498
+ const rule = prep.all.find((r) => r.id === id);
499
+ if (!rule) {
500
+ process.stderr.write(`No generated-artifact rule "${id}". Declared: ${prep.all.map((r) => r.id).join(', ') || '(none)'}\n`);
501
+ return ExitCode.UsageError;
502
+ }
503
+ // explain never spawns — the point is to show what the rule SEES.
504
+ const scan = scanGeneratedFiles(prep.cwd, rule, prep.excludeDirs);
505
+ const outcome = evaluateRule(prep.cwd, rule, prep.excludeDirs, true);
506
+ if (flagBool(args, 'json')) {
507
+ process.stdout.write(asJson({
508
+ schema: 'sharkcraft.generated-explain/v1',
509
+ ...outcomeJson(outcome),
510
+ files: [...scan.generated.keys()],
511
+ outsideScanned: scan.outside.size,
512
+ outsideGlobs: scan.outsideGlobs,
513
+ regen: rule.regen ?? null,
514
+ }) + '\n');
515
+ return ExitCode.VerifiedPass;
516
+ }
517
+ process.stdout.write(header(`Generated artifact: ${rule.id}`));
518
+ if (rule.description)
519
+ process.stdout.write(` ${rule.description}\n`);
520
+ process.stdout.write(kv('glob', rule.generatedGlob.join(', ')) + '\n');
521
+ process.stdout.write(kv('files matched', String(scan.generated.size)) + '\n');
522
+ process.stdout.write(kv('regen', rule.regen ?? '(header-only)') + '\n');
523
+ process.stdout.write(kv('compare', rule.compare ?? 'bytes') + '\n');
524
+ if (rule.provenanceHeader) {
525
+ process.stdout.write(kv('header', `/${rule.provenanceHeader.mustMatch}/`) + '\n');
526
+ if (rule.provenanceHeader.forbidOutside) {
527
+ process.stdout.write(kv('mislabel scan', `${scan.outside.size} file(s) via ${scan.outsideGlobs.join(', ')}`) + '\n');
528
+ }
529
+ }
530
+ process.stdout.write(kv('header findings', String(outcome.provenance.length)) + '\n');
531
+ for (const f of outcome.provenance.slice(0, 50)) {
532
+ process.stdout.write(` [${f.kind}] ${f.file} — ${f.message}\n`);
533
+ }
534
+ if (scan.generated.size > 0) {
535
+ process.stdout.write('\n files:\n');
536
+ for (const f of [...scan.generated.keys()].slice(0, 50))
537
+ process.stdout.write(` ${f}\n`);
538
+ if (scan.generated.size > 50)
539
+ process.stdout.write(` … (${scan.generated.size - 50} more)\n`);
540
+ }
541
+ process.stdout.write(`\n Run \`shrk generated check --id ${rule.id}\` to regenerate into a temp dir and diff.\n`);
542
+ return ExitCode.VerifiedPass;
543
+ },
544
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"help.command.d.ts","sourceRoot":"","sources":["../../src/commands/help.command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAqF9D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAwC1C;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe;;;;cAK3C;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,GAAG,MAAM;EAqJpF"}
1
+ {"version":3,"file":"help.command.d.ts","sourceRoot":"","sources":["../../src/commands/help.command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AA4H9D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAwC1C;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe;;;;cAK3C;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,GAAG,MAAM;EA2KpF"}
@@ -1,5 +1,45 @@
1
1
  import { header } from "../output/format-output.js";
2
2
  import { COMMAND_CATALOG, defaultShowInHelp, listExplainFamily } from "./command-catalog.js";
3
+ /**
4
+ * Every multi-token command path the catalog documents. These are real,
5
+ * callable verbs that the command trie never sees, because their parent
6
+ * dispatches them from a positional argument rather than registering them.
7
+ */
8
+ function catalogHelpPaths() {
9
+ const paths = new Set();
10
+ for (const entry of COMMAND_CATALOG) {
11
+ // Strip the flag/argument tail a few catalog entries carry in `command`.
12
+ const clean = entry.command.split(/\s+--/)[0].trim();
13
+ if (clean.length > 0)
14
+ paths.add(clean);
15
+ }
16
+ return paths;
17
+ }
18
+ /**
19
+ * Render help for a command path the trie could not resolve but the catalog
20
+ * documents, plus its sibling verbs under the same parent so the family is
21
+ * discoverable from any one member.
22
+ */
23
+ function renderCatalogHelp(tokens) {
24
+ const want = tokens.join(' ');
25
+ const entry = COMMAND_CATALOG.find((e) => e.command.split(/\s+--/)[0].trim() === want);
26
+ if (!entry)
27
+ return undefined;
28
+ let out = `${want} — ${entry.description}\n`;
29
+ const extra = EXTRA_HELP_LINES[want];
30
+ if (extra)
31
+ out += extra.join('\n') + '\n';
32
+ const parent = tokens.slice(0, -1).join(' ');
33
+ if (parent.length > 0) {
34
+ const siblings = [...catalogHelpPaths()]
35
+ .filter((p) => p !== want && p.startsWith(parent + ' ') && !p.slice(parent.length + 1).includes(' '))
36
+ .sort();
37
+ if (siblings.length > 0) {
38
+ out += `\nSiblings: ${siblings.join(', ')}\n`;
39
+ }
40
+ }
41
+ return out;
42
+ }
3
43
  /** First sentence of a catalog description, for the compact explain-family list. */
4
44
  function firstSentence(description) {
5
45
  const dot = description.indexOf('. ');
@@ -156,8 +196,17 @@ export function makeHelpCommand(registry) {
156
196
  // that exits 0. Error out honestly instead, with a did-you-mean when
157
197
  // a real topic is a near-typo of the request.
158
198
  const attempt = tokens.join(' ');
199
+ // The trie matched nothing, but the CATALOG may still document this
200
+ // path — a documented verb whose parent isn't a registered command
201
+ // is still real and callable.
202
+ const viaCatalog = renderCatalogHelp(tokens);
203
+ if (viaCatalog !== undefined) {
204
+ process.stdout.write(viaCatalog);
205
+ return 0;
206
+ }
159
207
  process.stderr.write(`no such help topic: '${attempt}'\n`);
160
- const suggestion = nearestHelpTopic(attempt, realHelpTopics(registry));
208
+ const suggestion = nearestHelpTopic(attempt, realHelpTopics(registry)) ??
209
+ nearestHelpTopic(attempt, catalogHelpPaths());
161
210
  if (suggestion)
162
211
  process.stderr.write(`Did you mean: ${suggestion}?\n`);
163
212
  return 1;
@@ -199,7 +248,20 @@ export function makeHelpCommand(registry) {
199
248
  }
200
249
  return 0;
201
250
  }
202
- process.stderr.write(`Unknown command: ${tokens.join(' ')}\n`);
251
+ // The trie could not resolve the full path, but the CATALOG may still
252
+ // document it. Verbs like `check wiring` are dispatched from inside
253
+ // their parent's handler on a positional, so they are real, callable
254
+ // and documented — yet never trie nodes. Falling through to "Unknown
255
+ // command" made an entire documented surface look non-existent.
256
+ const catalogHelp = renderCatalogHelp(tokens);
257
+ if (catalogHelp !== undefined) {
258
+ process.stdout.write(catalogHelp);
259
+ return 0;
260
+ }
261
+ process.stderr.write(`no such help topic: '${tokens.join(' ')}'\n`);
262
+ const near = nearestHelpTopic(tokens.join(' '), catalogHelpPaths());
263
+ if (near)
264
+ process.stderr.write(`Did you mean: shrk help ${near}?\n`);
203
265
  return 1;
204
266
  }
205
267
  if (!wantsFull) {
@@ -1,6 +1,17 @@
1
1
  import { type ICommandHandler } from '../command-registry.js';
2
2
  export declare const ingestCommand: ICommandHandler;
3
3
  export declare const contradictionsCommand: ICommandHandler;
4
+ /** Classify the tree's generated code (the `report` half of `shrk generated`). */
5
+ export declare const generatedReportCommand: ICommandHandler;
6
+ /** Recommend protect rules for the classified generated roots. */
7
+ export declare const generatedProtectCommand: ICommandHandler;
8
+ /**
9
+ * The `generated` group. The bare verb keeps its historical behaviour (the
10
+ * classifier report); `report` / `protect` are the classifier subverbs, and
11
+ * `list` / `check` / `update` / `explain` are the drift GATE (registered from
12
+ * `generated.command.ts`). One noun: that half FINDS what is generated, this
13
+ * half proves it has not drifted.
14
+ */
4
15
  export declare const generatedCommand: ICommandHandler;
5
16
  export declare const stabilityCommand: ICommandHandler;
6
17
  //# sourceMappingURL=ingest.command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ingest.command.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.command.ts"],"names":[],"mappings":"AA0CA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAOhC,eAAO,MAAM,aAAa,EAAE,eA4B3B,CAAC;AAmZF,eAAO,MAAM,qBAAqB,EAAE,eAkBnC,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,eA8B9B,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,eAkC9B,CAAC"}
1
+ {"version":3,"file":"ingest.command.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.command.ts"],"names":[],"mappings":"AA0CA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAOhC,eAAO,MAAM,aAAa,EAAE,eA4B3B,CAAC;AAmZF,eAAO,MAAM,qBAAqB,EAAE,eAkBnC,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,sBAAsB,EAAE,eAcpC,CAAC;AAEF,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,EAAE,eAsBrC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,eAY9B,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,eAkC9B,CAAC"}