@shrkcrft/cli 0.1.0-alpha.26 → 0.1.0-alpha.28
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/command-registry.d.ts +12 -0
- package/dist/command-registry.d.ts.map +1 -1
- package/dist/command-registry.js +25 -0
- 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 +511 -0
- package/dist/commands/changelog-data.d.ts.map +1 -1
- package/dist/commands/changelog-data.js +43 -0
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +28 -1
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +112 -0
- package/dist/commands/delegate.command.d.ts +76 -1
- package/dist/commands/delegate.command.d.ts.map +1 -1
- package/dist/commands/delegate.command.js +585 -25
- package/dist/commands/finish.command.js +4 -4
- package/dist/commands/gates.command.d.ts +6 -0
- package/dist/commands/gates.command.d.ts.map +1 -0
- package/dist/commands/gates.command.js +334 -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 +514 -0
- package/dist/commands/help.command.d.ts.map +1 -1
- package/dist/commands/help.command.js +73 -0
- 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 +119 -2
- package/dist/commands/registry-resolve.d.ts +11 -4
- package/dist/commands/registry-resolve.d.ts.map +1 -1
- package/dist/commands/registry-resolve.js +50 -24
- package/dist/commands/registry.command.d.ts.map +1 -1
- package/dist/commands/registry.command.js +36 -4
- package/dist/commands/trace.command.d.ts.map +1 -1
- package/dist/commands/trace.command.js +7 -1
- package/dist/commands/wiring.command.d.ts.map +1 -1
- package/dist/commands/wiring.command.js +113 -14
- package/dist/exit-codes.d.ts +41 -0
- package/dist/exit-codes.d.ts.map +1 -1
- package/dist/exit-codes.js +85 -0
- package/dist/finish/run-finish.d.ts +22 -3
- package/dist/finish/run-finish.d.ts.map +1 -1
- package/dist/finish/run-finish.js +194 -18
- 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 +80 -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 +46 -6
- package/dist/output/output-compression.d.ts.map +1 -1
- package/dist/output/output-compression.js +4 -1
- package/package.json +33 -33
|
@@ -0,0 +1,514 @@
|
|
|
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 { checkProvenanceHeaders, compareGeneratedTrees, scanGeneratedFiles, } from '@shrkcrft/boundaries';
|
|
24
|
+
import { resolveProjectConfig } from '@shrkcrft/inspector';
|
|
25
|
+
import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
|
|
26
|
+
import { ExitCode } from "../exit-codes.js";
|
|
27
|
+
import { asJson, header, kv } from "../output/format-output.js";
|
|
28
|
+
const SCHEMA = 'sharkcraft.generated-drift/v1';
|
|
29
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
30
|
+
/** Cap on the temp tree read — a runaway regen must not be read into memory whole. */
|
|
31
|
+
const MAX_REGEN_FILE_BYTES = 2_000_000;
|
|
32
|
+
async function loadRules(cwd) {
|
|
33
|
+
const loaded = await resolveProjectConfig(cwd);
|
|
34
|
+
if (!loaded.ok)
|
|
35
|
+
return { ok: false, message: loaded.error.message };
|
|
36
|
+
const rel = nodePath.relative(cwd, loaded.value.sharkcraftDir).split(nodePath.sep).join('/');
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
value: {
|
|
40
|
+
rules: loaded.value.config.generatedArtifacts ?? [],
|
|
41
|
+
planeDiagnostics: loaded.value.planeDiagnostics,
|
|
42
|
+
excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Read a regenerated temp tree into path→content, relative to `root`. */
|
|
47
|
+
function readTree(root) {
|
|
48
|
+
const out = new Map();
|
|
49
|
+
const visit = (abs) => {
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const child = nodePath.join(abs, e.name);
|
|
59
|
+
if (e.isDirectory()) {
|
|
60
|
+
visit(child);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!e.isFile())
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(child).size > MAX_REGEN_FILE_BYTES)
|
|
67
|
+
continue;
|
|
68
|
+
out.set(nodePath.relative(root, child).split(nodePath.sep).join('/'), readFileSync(child, 'utf8'));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// unreadable — skip
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
visit(root);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Number of trailing path SEGMENTS `a` and `b` share (0 when none). */
|
|
79
|
+
function sharedSuffixSegments(a, b) {
|
|
80
|
+
const x = a.split('/');
|
|
81
|
+
const y = b.split('/');
|
|
82
|
+
let n = 0;
|
|
83
|
+
while (n < x.length && n < y.length && x[x.length - 1 - n] === y[y.length - 1 - n])
|
|
84
|
+
n += 1;
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Re-key a regenerated tree onto the committed paths.
|
|
89
|
+
*
|
|
90
|
+
* A regen writes into `{TMP}` under its own root, which is rarely the repo
|
|
91
|
+
* root, while committed files are keyed project-relative. The two sets are
|
|
92
|
+
* matched on the LONGEST shared path suffix — not the first suffix that
|
|
93
|
+
* happens to match, because `a.json` alone would otherwise bind to whichever
|
|
94
|
+
* `…/a.json` the iteration reached first and silently compare two unrelated
|
|
95
|
+
* files. Ties break lexically so the mapping is deterministic, each committed
|
|
96
|
+
* path is claimed at most once, and anything unmatched keeps its temp-relative
|
|
97
|
+
* key and surfaces as `only-regenerated` rather than disappearing.
|
|
98
|
+
*/
|
|
99
|
+
function alignToCommitted(temp, committed) {
|
|
100
|
+
const committedPaths = [...committed.keys()].sort();
|
|
101
|
+
const claimed = new Set();
|
|
102
|
+
const out = new Map();
|
|
103
|
+
// Best-match first: a temp path with a longer shared suffix has the stronger
|
|
104
|
+
// claim on a committed path, so resolve those before the weaker ones.
|
|
105
|
+
const scored = [...temp.keys()]
|
|
106
|
+
.map((tempPath) => {
|
|
107
|
+
let best;
|
|
108
|
+
for (const c of committedPaths) {
|
|
109
|
+
const score = sharedSuffixSegments(tempPath, c);
|
|
110
|
+
if (score > 0 && (best === undefined || score > best.score))
|
|
111
|
+
best = { path: c, score };
|
|
112
|
+
}
|
|
113
|
+
return { tempPath, best };
|
|
114
|
+
})
|
|
115
|
+
.sort((a, b) => (b.best?.score ?? 0) - (a.best?.score ?? 0) || a.tempPath.localeCompare(b.tempPath));
|
|
116
|
+
for (const { tempPath, best } of scored) {
|
|
117
|
+
const content = temp.get(tempPath);
|
|
118
|
+
if (best && !claimed.has(best.path)) {
|
|
119
|
+
claimed.add(best.path);
|
|
120
|
+
out.set(best.path, content);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
out.set(tempPath, content);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/** Run `regen` into a fresh temp dir and read the result back. Always cleans up. */
|
|
129
|
+
function runRegen(cwd, rule, committed) {
|
|
130
|
+
const tmp = mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'shrk-generated-'));
|
|
131
|
+
try {
|
|
132
|
+
const command = rule.regen.split('{TMP}').join(tmp);
|
|
133
|
+
const child = spawnSync(command, {
|
|
134
|
+
cwd,
|
|
135
|
+
shell: true,
|
|
136
|
+
encoding: 'utf8',
|
|
137
|
+
timeout: rule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
138
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
139
|
+
});
|
|
140
|
+
if (child.error)
|
|
141
|
+
return { error: `regen failed to start: ${child.error.message}` };
|
|
142
|
+
if (child.status !== 0) {
|
|
143
|
+
const tail = String(child.stderr ?? '').trim().split('\n').slice(-3).join(' | ');
|
|
144
|
+
return { error: `regen exited ${child.status ?? 'null'}${tail ? ` — ${tail}` : ''}` };
|
|
145
|
+
}
|
|
146
|
+
const tree = readTree(tmp);
|
|
147
|
+
if (tree.size === 0) {
|
|
148
|
+
return { error: 'regen wrote no files into {TMP} — the command probably ignores the output path' };
|
|
149
|
+
}
|
|
150
|
+
return { files: alignToCommitted(tree, committed) };
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function evaluateRule(cwd, rule, excludeDirs, headersOnly) {
|
|
157
|
+
const scan = scanGeneratedFiles(cwd, rule, excludeDirs);
|
|
158
|
+
const severity = rule.severity ?? 'error';
|
|
159
|
+
if (scan.generated.size === 0) {
|
|
160
|
+
const failed = rule.failOnEmpty === true;
|
|
161
|
+
return {
|
|
162
|
+
rule,
|
|
163
|
+
status: failed ? 'failed' : 'skipped',
|
|
164
|
+
committedCount: 0,
|
|
165
|
+
provenance: [],
|
|
166
|
+
driftChecked: false,
|
|
167
|
+
skipReason: `0 files matched generatedGlob (${rule.generatedGlob.join(', ')})`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const headers = checkProvenanceHeaders(rule, scan.generated, scan.outside);
|
|
171
|
+
if (headers.error) {
|
|
172
|
+
return {
|
|
173
|
+
rule,
|
|
174
|
+
status: 'error',
|
|
175
|
+
committedCount: scan.generated.size,
|
|
176
|
+
provenance: [],
|
|
177
|
+
driftChecked: false,
|
|
178
|
+
error: headers.error,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
let treeDiff;
|
|
182
|
+
let error;
|
|
183
|
+
const wantDrift = !headersOnly && rule.regen !== undefined;
|
|
184
|
+
if (wantDrift) {
|
|
185
|
+
const regen = runRegen(cwd, rule, scan.generated);
|
|
186
|
+
if (regen.error)
|
|
187
|
+
error = regen.error;
|
|
188
|
+
else
|
|
189
|
+
treeDiff = compareGeneratedTrees(scan.generated, regen.files, rule.compare ?? 'bytes');
|
|
190
|
+
}
|
|
191
|
+
const hardFindings = headers.findings.filter((f) => f.severity === 'error');
|
|
192
|
+
const drifted = (treeDiff?.differences.length ?? 0) > 0;
|
|
193
|
+
const status = error !== undefined
|
|
194
|
+
? 'error'
|
|
195
|
+
: drifted || (hardFindings.length > 0 && severity === 'error')
|
|
196
|
+
? 'failed'
|
|
197
|
+
: 'passed';
|
|
198
|
+
return {
|
|
199
|
+
rule,
|
|
200
|
+
status,
|
|
201
|
+
committedCount: scan.generated.size,
|
|
202
|
+
...(treeDiff ? { treeDiff } : {}),
|
|
203
|
+
provenance: headers.findings,
|
|
204
|
+
...(error ? { error } : {}),
|
|
205
|
+
driftChecked: wantDrift && error === undefined,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function hintFor(rule) {
|
|
209
|
+
return (rule.hint ??
|
|
210
|
+
(rule.regen
|
|
211
|
+
? `regenerate with \`shrk generated update --id ${rule.id}\` and commit the result`
|
|
212
|
+
: 'add the provenance header to the generated file, or move it out of the generated glob'));
|
|
213
|
+
}
|
|
214
|
+
function outcomeJson(o) {
|
|
215
|
+
return {
|
|
216
|
+
id: o.rule.id,
|
|
217
|
+
...(o.rule.description ? { description: o.rule.description } : {}),
|
|
218
|
+
status: o.status,
|
|
219
|
+
severity: o.rule.severity ?? 'error',
|
|
220
|
+
committedCount: o.committedCount,
|
|
221
|
+
driftChecked: o.driftChecked,
|
|
222
|
+
...(o.treeDiff ? { differences: o.treeDiff.differences, regeneratedCount: o.treeDiff.regeneratedCount } : {}),
|
|
223
|
+
provenance: o.provenance,
|
|
224
|
+
...(o.error ? { error: o.error } : {}),
|
|
225
|
+
...(o.skipReason ? { skipReason: o.skipReason } : {}),
|
|
226
|
+
hint: hintFor(o.rule),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async function prepare(args) {
|
|
230
|
+
const cwd = resolveCwd(args);
|
|
231
|
+
const json = flagBool(args, 'json');
|
|
232
|
+
const loaded = await loadRules(cwd);
|
|
233
|
+
if (!loaded.ok) {
|
|
234
|
+
if (json)
|
|
235
|
+
process.stdout.write(asJson({ schema: SCHEMA, error: loaded.message }) + '\n');
|
|
236
|
+
else
|
|
237
|
+
process.stderr.write(`Could not load config: ${loaded.message}\n Run \`shrk doctor\` for details.\n`);
|
|
238
|
+
return { ok: false, code: ExitCode.NotVerified };
|
|
239
|
+
}
|
|
240
|
+
const id = flagString(args, 'id');
|
|
241
|
+
let rules = loaded.value.rules;
|
|
242
|
+
if (id) {
|
|
243
|
+
const wanted = id.split(',').map((s) => s.trim()).filter(Boolean);
|
|
244
|
+
const known = new Set(rules.map((r) => r.id));
|
|
245
|
+
const unknown = wanted.filter((w) => !known.has(w));
|
|
246
|
+
if (unknown.length > 0) {
|
|
247
|
+
process.stderr.write(`Unknown generated-artifact id(s): ${unknown.join(', ')}. Declared: ${[...known].join(', ') || '(none)'}\n`);
|
|
248
|
+
return { ok: false, code: ExitCode.NotVerified };
|
|
249
|
+
}
|
|
250
|
+
rules = rules.filter((r) => wanted.includes(r.id));
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
cwd,
|
|
255
|
+
rules,
|
|
256
|
+
all: loaded.value.rules,
|
|
257
|
+
excludeDirs: loaded.value.excludeDirs,
|
|
258
|
+
planeDiagnostics: loaded.value.planeDiagnostics,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function writeNoRules(json) {
|
|
262
|
+
if (json) {
|
|
263
|
+
process.stdout.write(asJson({ schema: SCHEMA, results: [], evaluated: 0, verdict: 'not-verified' }) + '\n');
|
|
264
|
+
return ExitCode.NotVerified;
|
|
265
|
+
}
|
|
266
|
+
process.stdout.write(header('Generated artifacts'));
|
|
267
|
+
process.stdout.write(' No generated-artifact rules declared. Add `generatedArtifacts[]` to\n' +
|
|
268
|
+
' sharkcraft.config.ts to catch hand-edited generated files and missing\n' +
|
|
269
|
+
' "do not edit" headers (see docs/generated-drift.md).\n');
|
|
270
|
+
return ExitCode.NotVerified;
|
|
271
|
+
}
|
|
272
|
+
export const generatedListCommand = {
|
|
273
|
+
name: 'list',
|
|
274
|
+
description: 'List every declared generated-artifact rule: its globs, regen command, and header contract.',
|
|
275
|
+
usage: 'shrk generated list [--json]',
|
|
276
|
+
booleanFlags: new Set(['json']),
|
|
277
|
+
async run(args) {
|
|
278
|
+
const prep = await prepare(args);
|
|
279
|
+
if (!prep.ok)
|
|
280
|
+
return prep.code;
|
|
281
|
+
const json = flagBool(args, 'json');
|
|
282
|
+
if (prep.all.length === 0)
|
|
283
|
+
return writeNoRules(json);
|
|
284
|
+
if (json) {
|
|
285
|
+
process.stdout.write(asJson({
|
|
286
|
+
schema: SCHEMA,
|
|
287
|
+
rules: prep.all.map((r) => ({
|
|
288
|
+
id: r.id,
|
|
289
|
+
description: r.description ?? null,
|
|
290
|
+
generatedGlob: r.generatedGlob,
|
|
291
|
+
regen: r.regen ?? null,
|
|
292
|
+
compare: r.compare ?? 'bytes',
|
|
293
|
+
provenanceHeader: r.provenanceHeader ?? null,
|
|
294
|
+
failOnEmpty: r.failOnEmpty === true,
|
|
295
|
+
})),
|
|
296
|
+
diagnostics: prep.planeDiagnostics,
|
|
297
|
+
}) + '\n');
|
|
298
|
+
return ExitCode.VerifiedPass;
|
|
299
|
+
}
|
|
300
|
+
process.stdout.write(header(`Generated artifacts (${prep.all.length})`));
|
|
301
|
+
for (const r of prep.all) {
|
|
302
|
+
process.stdout.write(` • ${r.id}\n`);
|
|
303
|
+
process.stdout.write(` glob ${r.generatedGlob.join(', ')}\n`);
|
|
304
|
+
process.stdout.write(` regen ${r.regen ?? '(header-only — never spawns)'}\n`);
|
|
305
|
+
if (r.provenanceHeader) {
|
|
306
|
+
process.stdout.write(` header /${r.provenanceHeader.mustMatch}/${r.provenanceHeader.forbidOutside ? ' + mislabel check' : ''}\n`);
|
|
307
|
+
}
|
|
308
|
+
if (r.description)
|
|
309
|
+
process.stdout.write(` ${r.description}\n`);
|
|
310
|
+
}
|
|
311
|
+
for (const d of prep.planeDiagnostics)
|
|
312
|
+
process.stdout.write(` ! ${d}\n`);
|
|
313
|
+
return ExitCode.VerifiedPass;
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
export const generatedCheckCommand = {
|
|
317
|
+
name: 'check',
|
|
318
|
+
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.',
|
|
319
|
+
usage: 'shrk generated check [--id <ids>] [--headers-only] [--json]',
|
|
320
|
+
booleanFlags: new Set(['json', 'headers-only']),
|
|
321
|
+
async run(args) {
|
|
322
|
+
const prep = await prepare(args);
|
|
323
|
+
if (!prep.ok)
|
|
324
|
+
return prep.code;
|
|
325
|
+
const json = flagBool(args, 'json');
|
|
326
|
+
const headersOnly = flagBool(args, 'headers-only');
|
|
327
|
+
if (prep.rules.length === 0)
|
|
328
|
+
return writeNoRules(json);
|
|
329
|
+
const outcomes = prep.rules.map((r) => evaluateRule(prep.cwd, r, prep.excludeDirs, headersOnly));
|
|
330
|
+
const failed = outcomes.filter((o) => o.status === 'failed' || (o.status === 'error' && (o.rule.severity ?? 'error') === 'error'));
|
|
331
|
+
const evaluated = outcomes.filter((o) => o.status !== 'skipped').length;
|
|
332
|
+
const exit = failed.length > 0
|
|
333
|
+
? ExitCode.Failure
|
|
334
|
+
: evaluated === 0
|
|
335
|
+
? ExitCode.NotVerified
|
|
336
|
+
: ExitCode.VerifiedPass;
|
|
337
|
+
if (json) {
|
|
338
|
+
process.stdout.write(asJson({
|
|
339
|
+
schema: SCHEMA,
|
|
340
|
+
headersOnly,
|
|
341
|
+
results: outcomes.map(outcomeJson),
|
|
342
|
+
evaluated,
|
|
343
|
+
skipped: outcomes.filter((o) => o.status === 'skipped').length,
|
|
344
|
+
verdict: failed.length > 0 ? 'errors' : evaluated === 0 ? 'not-verified' : 'pass',
|
|
345
|
+
diagnostics: prep.planeDiagnostics,
|
|
346
|
+
}) + '\n');
|
|
347
|
+
return exit;
|
|
348
|
+
}
|
|
349
|
+
process.stdout.write(header('Generated-artifact drift'));
|
|
350
|
+
process.stdout.write(kv('evaluated', `${evaluated} of ${prep.rules.length}`) + '\n');
|
|
351
|
+
if (headersOnly)
|
|
352
|
+
process.stdout.write(kv('scope', 'headers only — no regen was run') + '\n');
|
|
353
|
+
for (const o of outcomes) {
|
|
354
|
+
if (o.status === 'skipped') {
|
|
355
|
+
process.stdout.write(` – ${o.rule.id} SKIPPED — ${o.skipReason}\n`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (o.status === 'error') {
|
|
359
|
+
process.stdout.write(` ! ${o.rule.id} ${o.error}\n`);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const diffs = o.treeDiff?.differences ?? [];
|
|
363
|
+
if (o.status === 'passed') {
|
|
364
|
+
process.stdout.write(` ✓ ${o.rule.id} (${o.committedCount} files` +
|
|
365
|
+
`${o.driftChecked ? ', byte-identical to a fresh regen' : ', headers only'})\n`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
process.stdout.write(` ✗ ${o.rule.id} ${diffs.length} file(s) differ from a fresh regen\n`);
|
|
369
|
+
for (const d of diffs.slice(0, 25)) {
|
|
370
|
+
const label = d.kind === 'content'
|
|
371
|
+
? 'hand-edited (or source changed)'
|
|
372
|
+
: d.kind === 'only-committed'
|
|
373
|
+
? 'committed but regen no longer produces it'
|
|
374
|
+
: 'regen produces it but it is not committed';
|
|
375
|
+
process.stdout.write(` • ${d.file} — ${label}\n`);
|
|
376
|
+
}
|
|
377
|
+
if (diffs.length > 25)
|
|
378
|
+
process.stdout.write(` … (${diffs.length - 25} more)\n`);
|
|
379
|
+
}
|
|
380
|
+
for (const f of o.provenance.slice(0, 25)) {
|
|
381
|
+
process.stdout.write(` [${f.severity}] ${f.file} — ${f.message}\n`);
|
|
382
|
+
}
|
|
383
|
+
if (o.provenance.length > 25) {
|
|
384
|
+
process.stdout.write(` … (${o.provenance.length - 25} more header finding(s))\n`);
|
|
385
|
+
}
|
|
386
|
+
if (o.status === 'failed')
|
|
387
|
+
process.stdout.write(` → ${hintFor(o.rule)}\n`);
|
|
388
|
+
}
|
|
389
|
+
for (const d of prep.planeDiagnostics)
|
|
390
|
+
process.stdout.write(` ! ${d}\n`);
|
|
391
|
+
if (exit === ExitCode.NotVerified) {
|
|
392
|
+
process.stdout.write('\nNothing was checked — this is NOT a pass. Every rule matched 0 files.\n');
|
|
393
|
+
}
|
|
394
|
+
else if (exit === ExitCode.VerifiedPass) {
|
|
395
|
+
process.stdout.write('\nEvery generated artifact matches its source. ✓\n');
|
|
396
|
+
}
|
|
397
|
+
return exit;
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
export const generatedUpdateCommand = {
|
|
401
|
+
name: 'update',
|
|
402
|
+
description: 'Run the declared regen command in place — the one-command bless step after an intentional source change. Writes files.',
|
|
403
|
+
usage: 'shrk generated update [--id <ids>] [--json]',
|
|
404
|
+
booleanFlags: new Set(['json']),
|
|
405
|
+
async run(args) {
|
|
406
|
+
const prep = await prepare(args);
|
|
407
|
+
if (!prep.ok)
|
|
408
|
+
return prep.code;
|
|
409
|
+
const json = flagBool(args, 'json');
|
|
410
|
+
if (prep.rules.length === 0)
|
|
411
|
+
return writeNoRules(json);
|
|
412
|
+
const results = [];
|
|
413
|
+
for (const rule of prep.rules) {
|
|
414
|
+
if (!rule.regen) {
|
|
415
|
+
results.push({ id: rule.id, ran: false, error: 'header-only rule — nothing to regenerate' });
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// `{TMP}` is the CHECK contract; `update` writes in place, so it is
|
|
419
|
+
// substituted with the project root and the regen writes its real output.
|
|
420
|
+
const command = rule.regen.split('{TMP}').join(prep.cwd);
|
|
421
|
+
const child = spawnSync(command, {
|
|
422
|
+
cwd: prep.cwd,
|
|
423
|
+
shell: true,
|
|
424
|
+
encoding: 'utf8',
|
|
425
|
+
timeout: rule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
426
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
427
|
+
stdio: json ? 'pipe' : 'inherit',
|
|
428
|
+
});
|
|
429
|
+
if (child.error) {
|
|
430
|
+
results.push({ id: rule.id, ran: false, error: child.error.message });
|
|
431
|
+
}
|
|
432
|
+
else if (child.status !== 0) {
|
|
433
|
+
results.push({ id: rule.id, ran: true, error: `exited ${child.status ?? 'null'}` });
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
results.push({ id: rule.id, ran: true });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const failed = results.filter((r) => r.error !== undefined);
|
|
440
|
+
if (json) {
|
|
441
|
+
process.stdout.write(asJson({ schema: SCHEMA, results }) + '\n');
|
|
442
|
+
return failed.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
|
|
443
|
+
}
|
|
444
|
+
process.stdout.write(header('Generated update'));
|
|
445
|
+
for (const r of results) {
|
|
446
|
+
process.stdout.write(` ${r.error ? '!' : '✓'} ${r.id}${r.error ? ` — ${r.error}` : ''}\n`);
|
|
447
|
+
}
|
|
448
|
+
if (failed.length === 0) {
|
|
449
|
+
process.stdout.write('\nRegenerated. Review `git diff` before committing.\n');
|
|
450
|
+
}
|
|
451
|
+
return failed.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
export const generatedExplainCommand = {
|
|
455
|
+
name: 'explain',
|
|
456
|
+
description: 'Show what ONE generated-artifact rule sees right now: files matched, header contract results, mislabel candidates — without running the regen.',
|
|
457
|
+
usage: 'shrk generated explain --id <id> [--json]',
|
|
458
|
+
booleanFlags: new Set(['json']),
|
|
459
|
+
async run(args) {
|
|
460
|
+
const id = flagString(args, 'id') ?? args.positional[0];
|
|
461
|
+
if (!id) {
|
|
462
|
+
process.stderr.write('Usage: shrk generated explain --id <id>\n');
|
|
463
|
+
return ExitCode.NotVerified;
|
|
464
|
+
}
|
|
465
|
+
const prep = await prepare(args);
|
|
466
|
+
if (!prep.ok)
|
|
467
|
+
return prep.code;
|
|
468
|
+
const rule = prep.all.find((r) => r.id === id);
|
|
469
|
+
if (!rule) {
|
|
470
|
+
process.stderr.write(`No generated-artifact rule "${id}". Declared: ${prep.all.map((r) => r.id).join(', ') || '(none)'}\n`);
|
|
471
|
+
return ExitCode.NotVerified;
|
|
472
|
+
}
|
|
473
|
+
// explain never spawns — the point is to show what the rule SEES.
|
|
474
|
+
const scan = scanGeneratedFiles(prep.cwd, rule, prep.excludeDirs);
|
|
475
|
+
const outcome = evaluateRule(prep.cwd, rule, prep.excludeDirs, true);
|
|
476
|
+
if (flagBool(args, 'json')) {
|
|
477
|
+
process.stdout.write(asJson({
|
|
478
|
+
schema: 'sharkcraft.generated-explain/v1',
|
|
479
|
+
...outcomeJson(outcome),
|
|
480
|
+
files: [...scan.generated.keys()],
|
|
481
|
+
outsideScanned: scan.outside.size,
|
|
482
|
+
outsideGlobs: scan.outsideGlobs,
|
|
483
|
+
regen: rule.regen ?? null,
|
|
484
|
+
}) + '\n');
|
|
485
|
+
return ExitCode.VerifiedPass;
|
|
486
|
+
}
|
|
487
|
+
process.stdout.write(header(`Generated artifact: ${rule.id}`));
|
|
488
|
+
if (rule.description)
|
|
489
|
+
process.stdout.write(` ${rule.description}\n`);
|
|
490
|
+
process.stdout.write(kv('glob', rule.generatedGlob.join(', ')) + '\n');
|
|
491
|
+
process.stdout.write(kv('files matched', String(scan.generated.size)) + '\n');
|
|
492
|
+
process.stdout.write(kv('regen', rule.regen ?? '(header-only)') + '\n');
|
|
493
|
+
process.stdout.write(kv('compare', rule.compare ?? 'bytes') + '\n');
|
|
494
|
+
if (rule.provenanceHeader) {
|
|
495
|
+
process.stdout.write(kv('header', `/${rule.provenanceHeader.mustMatch}/`) + '\n');
|
|
496
|
+
if (rule.provenanceHeader.forbidOutside) {
|
|
497
|
+
process.stdout.write(kv('mislabel scan', `${scan.outside.size} file(s) via ${scan.outsideGlobs.join(', ')}`) + '\n');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
process.stdout.write(kv('header findings', String(outcome.provenance.length)) + '\n');
|
|
501
|
+
for (const f of outcome.provenance.slice(0, 50)) {
|
|
502
|
+
process.stdout.write(` [${f.kind}] ${f.file} — ${f.message}\n`);
|
|
503
|
+
}
|
|
504
|
+
if (scan.generated.size > 0) {
|
|
505
|
+
process.stdout.write('\n files:\n');
|
|
506
|
+
for (const f of [...scan.generated.keys()].slice(0, 50))
|
|
507
|
+
process.stdout.write(` ${f}\n`);
|
|
508
|
+
if (scan.generated.size > 50)
|
|
509
|
+
process.stdout.write(` … (${scan.generated.size - 50} more)\n`);
|
|
510
|
+
}
|
|
511
|
+
process.stdout.write(`\n Run \`shrk generated check --id ${rule.id}\` to regenerate into a temp dir and diff.\n`);
|
|
512
|
+
return ExitCode.VerifiedPass;
|
|
513
|
+
},
|
|
514
|
+
};
|
|
@@ -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;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"}
|
|
@@ -6,6 +6,65 @@ function firstSentence(description) {
|
|
|
6
6
|
const head = dot > 0 ? description.slice(0, dot + 1) : description;
|
|
7
7
|
return head.length > 100 ? head.slice(0, 97).trimEnd() + '…' : head;
|
|
8
8
|
}
|
|
9
|
+
/** Levenshtein edit distance — small, local helper for the unknown-topic guard. */
|
|
10
|
+
function editDistance(a, b) {
|
|
11
|
+
const m = a.length;
|
|
12
|
+
const n = b.length;
|
|
13
|
+
if (m === 0)
|
|
14
|
+
return n;
|
|
15
|
+
if (n === 0)
|
|
16
|
+
return m;
|
|
17
|
+
let prev = Array.from({ length: n + 1 }, (_, i) => i);
|
|
18
|
+
let curr = new Array(n + 1);
|
|
19
|
+
for (let i = 1; i <= m; i += 1) {
|
|
20
|
+
curr[0] = i;
|
|
21
|
+
for (let j = 1; j <= n; j += 1) {
|
|
22
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
23
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
24
|
+
}
|
|
25
|
+
[prev, curr] = [curr, prev];
|
|
26
|
+
}
|
|
27
|
+
return prev[n];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The set of real, callable help topics: every registered top-level command
|
|
31
|
+
* and group name, plus every top-level verb in the catalog. Used only to
|
|
32
|
+
* suggest a near-typo when an unknown topic is requested — never to fabricate
|
|
33
|
+
* one that isn't real.
|
|
34
|
+
*/
|
|
35
|
+
function realHelpTopics(registry) {
|
|
36
|
+
const topics = new Set();
|
|
37
|
+
for (const c of registry.list())
|
|
38
|
+
topics.add(c.name);
|
|
39
|
+
for (const g of registry.listGroups())
|
|
40
|
+
topics.add(g);
|
|
41
|
+
for (const entry of COMMAND_CATALOG) {
|
|
42
|
+
const verb = entry.command.split(/\s+/)[0];
|
|
43
|
+
if (verb)
|
|
44
|
+
topics.add(verb);
|
|
45
|
+
}
|
|
46
|
+
return topics;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Nearest real topic to `attempt` within a typo-tolerant edit-distance bound,
|
|
50
|
+
* or undefined when nothing is close enough. Mirrors main.ts's confidence
|
|
51
|
+
* tolerance (`max(1, len/4)` edits) so a fingers-on-keys typo suggests but a
|
|
52
|
+
* genuinely-unrelated token does not. Deterministic: ties break lexically.
|
|
53
|
+
*/
|
|
54
|
+
function nearestHelpTopic(attempt, topics) {
|
|
55
|
+
const lower = attempt.toLowerCase();
|
|
56
|
+
const tolerance = Math.max(1, Math.floor(lower.length / 4));
|
|
57
|
+
let best;
|
|
58
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
59
|
+
for (const topic of topics) {
|
|
60
|
+
const dist = editDistance(lower, topic.toLowerCase());
|
|
61
|
+
if (dist < bestDist || (dist === bestDist && best !== undefined && topic < best)) {
|
|
62
|
+
bestDist = dist;
|
|
63
|
+
best = topic;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return best !== undefined && bestDist <= tolerance ? best : undefined;
|
|
67
|
+
}
|
|
9
68
|
const EXTRA_HELP_LINES = Object.freeze({
|
|
10
69
|
graph: [
|
|
11
70
|
'',
|
|
@@ -89,6 +148,20 @@ export function makeHelpCommand(registry) {
|
|
|
89
148
|
? args.positional[0].split(/\s+/).filter(Boolean)
|
|
90
149
|
: args.positional.filter(Boolean);
|
|
91
150
|
const { handler, matchedPath, node } = registry.resolve(tokens);
|
|
151
|
+
if (matchedPath.length === 0 && tokens.length > 0) {
|
|
152
|
+
// Unknown topic: the descent matched NOTHING and stopped at the root
|
|
153
|
+
// (which carries every top-level verb as a child). Do NOT fall through
|
|
154
|
+
// to the group-listing branch below — that reprints the entire real
|
|
155
|
+
// catalog re-prefixed with the bogus token, a false self-discovery
|
|
156
|
+
// that exits 0. Error out honestly instead, with a did-you-mean when
|
|
157
|
+
// a real topic is a near-typo of the request.
|
|
158
|
+
const attempt = tokens.join(' ');
|
|
159
|
+
process.stderr.write(`no such help topic: '${attempt}'\n`);
|
|
160
|
+
const suggestion = nearestHelpTopic(attempt, realHelpTopics(registry));
|
|
161
|
+
if (suggestion)
|
|
162
|
+
process.stderr.write(`Did you mean: ${suggestion}?\n`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
92
165
|
if (handler && matchedPath.join(' ') === tokens.join(' ') && node.children.size === 0) {
|
|
93
166
|
// Exact match on a callable command.
|
|
94
167
|
const canonical = registry.listCommandAliases().get(tokens[0]);
|
|
@@ -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"}
|