@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.
- package/dist/commands/baseline.command.d.ts +8 -0
- package/dist/commands/baseline.command.d.ts.map +1 -0
- package/dist/commands/baseline.command.js +542 -0
- package/dist/commands/changelog-data.d.ts.map +1 -1
- package/dist/commands/changelog-data.js +42 -0
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +147 -12
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +120 -0
- package/dist/commands/daily.commands.d.ts.map +1 -1
- package/dist/commands/daily.commands.js +11 -1
- package/dist/commands/gates.command.d.ts +16 -0
- package/dist/commands/gates.command.d.ts.map +1 -0
- package/dist/commands/gates.command.js +377 -0
- package/dist/commands/generated.command.d.ts +6 -0
- package/dist/commands/generated.command.d.ts.map +1 -0
- package/dist/commands/generated.command.js +544 -0
- package/dist/commands/help.command.d.ts.map +1 -1
- package/dist/commands/help.command.js +64 -2
- package/dist/commands/ingest.command.d.ts +11 -0
- package/dist/commands/ingest.command.d.ts.map +1 -1
- package/dist/commands/ingest.command.js +49 -23
- package/dist/commands/policy-lint.command.d.ts +37 -0
- package/dist/commands/policy-lint.command.d.ts.map +1 -1
- package/dist/commands/policy-lint.command.js +167 -8
- package/dist/commands/registry.command.d.ts.map +1 -1
- package/dist/commands/registry.command.js +70 -13
- package/dist/commands/wiring.command.d.ts.map +1 -1
- package/dist/commands/wiring.command.js +25 -0
- package/dist/exit-codes.d.ts +27 -7
- package/dist/exit-codes.d.ts.map +1 -1
- package/dist/exit-codes.js +47 -8
- package/dist/finish/run-finish.d.ts.map +1 -1
- package/dist/finish/run-finish.js +9 -3
- package/dist/gates/gate-envelope.d.ts +64 -0
- package/dist/gates/gate-envelope.d.ts.map +1 -0
- package/dist/gates/gate-envelope.js +26 -0
- package/dist/gates/gate-rule-view.d.ts +35 -0
- package/dist/gates/gate-rule-view.d.ts.map +1 -0
- package/dist/gates/gate-rule-view.js +81 -0
- package/dist/gates/rule-coverage.d.ts +53 -0
- package/dist/gates/rule-coverage.d.ts.map +1 -0
- package/dist/gates/rule-coverage.js +165 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +27 -3
- 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;
|
|
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
|
-
|
|
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,
|
|
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"}
|