@shrkcrft/cli 0.1.0-alpha.28 → 0.1.0-alpha.30
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 +25 -0
- package/dist/commands/baseline.command.d.ts.map +1 -1
- package/dist/commands/baseline.command.js +64 -17
- package/dist/commands/changelog-data.d.ts.map +1 -1
- package/dist/commands/changelog-data.js +46 -0
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +137 -12
- package/dist/commands/code-intel.command.d.ts.map +1 -1
- package/dist/commands/code-intel.command.js +5 -1
- package/dist/commands/command-catalog.d.ts.map +1 -1
- package/dist/commands/command-catalog.js +8 -0
- package/dist/commands/daily.commands.d.ts.map +1 -1
- package/dist/commands/daily.commands.js +11 -1
- package/dist/commands/docs-references.command.d.ts +7 -0
- package/dist/commands/docs-references.command.d.ts.map +1 -0
- package/dist/commands/docs-references.command.js +323 -0
- package/dist/commands/doctor.command.d.ts.map +1 -1
- package/dist/commands/doctor.command.js +3 -2
- package/dist/commands/gates.command.d.ts +13 -1
- package/dist/commands/gates.command.d.ts.map +1 -1
- package/dist/commands/gates.command.js +693 -26
- package/dist/commands/generated.command.d.ts +32 -0
- package/dist/commands/generated.command.d.ts.map +1 -1
- package/dist/commands/generated.command.js +214 -53
- package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
- package/dist/commands/graph-code-subverbs.js +5 -1
- package/dist/commands/help.command.d.ts.map +1 -1
- package/dist/commands/help.command.js +64 -2
- package/dist/commands/policy-lint.command.d.ts.map +1 -1
- package/dist/commands/policy-lint.command.js +52 -10
- package/dist/commands/registry.command.d.ts.map +1 -1
- package/dist/commands/registry.command.js +34 -9
- package/dist/commands/wiring.command.d.ts.map +1 -1
- package/dist/commands/wiring.command.js +4 -3
- 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/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-globs.d.ts +33 -0
- package/dist/gates/gate-rule-globs.d.ts.map +1 -0
- package/dist/gates/gate-rule-globs.js +101 -0
- package/dist/gates/gate-rule-view.d.ts +9 -3
- package/dist/gates/gate-rule-view.d.ts.map +1 -1
- package/dist/gates/gate-rule-view.js +18 -4
- package/dist/gates/rule-coverage.d.ts +39 -1
- package/dist/gates/rule-coverage.d.ts.map +1 -1
- package/dist/gates/rule-coverage.js +123 -8
- package/dist/gates/run-gate-planes.d.ts +44 -0
- package/dist/gates/run-gate-planes.d.ts.map +1 -0
- package/dist/gates/run-gate-planes.js +261 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +7 -2
- package/package.json +33 -33
|
@@ -14,21 +14,34 @@
|
|
|
14
14
|
* Distinct from `shrk gate` (singular), which RUNS the quality-gate pipeline.
|
|
15
15
|
* This verb inspects the data-defined RULES themselves.
|
|
16
16
|
*/
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
17
18
|
import * as nodePath from 'node:path';
|
|
19
|
+
import { resolvePlaneExtractors } from '@shrkcrft/core';
|
|
18
20
|
import { explainWiring, inspectSource, scanRegistry } from '@shrkcrft/boundaries';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
+
import { BaselineRuleSchema, DocReferenceRuleSchema, GeneratedArtifactRuleSchema, PolicyRuleSchema, RegistrationIdiomSchema, RegistryDeclarationSchema, WiringRuleSchema, } from '@shrkcrft/config';
|
|
22
|
+
import { inspectSharkcraft, warmReferenceRegistries, refExists, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
|
|
23
|
+
import { clearFileReadCache } from '@shrkcrft/boundaries';
|
|
24
|
+
import { firstUnknownFlag, flagBool, flagString, resolveCwd, } from "../command-registry.js";
|
|
21
25
|
import { ExitCode } from "../exit-codes.js";
|
|
22
26
|
import { asJson, header, kv } from "../output/format-output.js";
|
|
23
27
|
import { collectGateRules, GATE_PLANES, } from "../gates/gate-rule-view.js";
|
|
24
28
|
import { buildGateCoverage } from "../gates/rule-coverage.js";
|
|
29
|
+
import { buildGateEnvelope } from "../gates/gate-envelope.js";
|
|
30
|
+
import { ruleTouchedBy } from "../gates/gate-rule-globs.js";
|
|
31
|
+
import { runGatePlanes } from "../gates/run-gate-planes.js";
|
|
25
32
|
import { baselineExplainCommand } from "./baseline.command.js";
|
|
26
33
|
import { generatedExplainCommand } from "./generated.command.js";
|
|
34
|
+
import { docsReferencesExplainCommand } from "./docs-references.command.js";
|
|
27
35
|
import { renderPolicyExplain, runPolicyExplain } from "./policy-lint.command.js";
|
|
28
36
|
import { renderWiringExplain } from "./wiring.command.js";
|
|
29
37
|
const SCHEMA = 'sharkcraft.gates/v1';
|
|
30
38
|
async function prepare(args) {
|
|
31
39
|
const cwd = resolveCwd(args);
|
|
40
|
+
// Coverage memoizes its tree reads inside one call (see `withFileReadCache`).
|
|
41
|
+
// `--no-cache` drops anything already memoized so a suspected caching bug can
|
|
42
|
+
// be ruled out without a code change.
|
|
43
|
+
if (flagBool(args, 'no-cache'))
|
|
44
|
+
clearFileReadCache();
|
|
32
45
|
const json = flagBool(args, 'json');
|
|
33
46
|
const loaded = await resolveProjectConfig(cwd);
|
|
34
47
|
if (!loaded.ok) {
|
|
@@ -37,7 +50,7 @@ async function prepare(args) {
|
|
|
37
50
|
process.stdout.write(asJson({ schema: SCHEMA, error: msg }) + '\n');
|
|
38
51
|
else
|
|
39
52
|
process.stderr.write(`Could not load config: ${msg}\n Run \`shrk doctor\` for details.\n`);
|
|
40
|
-
return { ok: false, code: ExitCode.
|
|
53
|
+
return { ok: false, code: ExitCode.UsageError };
|
|
41
54
|
}
|
|
42
55
|
const rel = nodePath.relative(cwd, loaded.value.sharkcraftDir).split(nodePath.sep).join('/');
|
|
43
56
|
return {
|
|
@@ -47,6 +60,7 @@ async function prepare(args) {
|
|
|
47
60
|
rules: collectGateRules(loaded.value.config),
|
|
48
61
|
excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
|
|
49
62
|
planeDiagnostics: loaded.value.planeDiagnostics,
|
|
63
|
+
extractors: loaded.value.config.extractors ?? {},
|
|
50
64
|
},
|
|
51
65
|
};
|
|
52
66
|
}
|
|
@@ -78,6 +92,119 @@ function writeNoRules(json) {
|
|
|
78
92
|
' generatedArtifacts[] generated files that must not be hand-edited\n');
|
|
79
93
|
return ExitCode.NotVerified;
|
|
80
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolve `--changed-only` / `--since <ref>` (`--base` is a synonym) into the
|
|
97
|
+
* changed-file set, or `undefined` for a whole-tree run.
|
|
98
|
+
*
|
|
99
|
+
* `error` is distinct from "no files changed": an unresolvable ref must not
|
|
100
|
+
* silently degrade to an empty diff, which would narrow every rule out of
|
|
101
|
+
* scope and produce a green run that checked nothing.
|
|
102
|
+
*/
|
|
103
|
+
function resolveScope(args, cwd) {
|
|
104
|
+
const changedOnly = flagBool(args, 'changed-only');
|
|
105
|
+
const since = flagString(args, 'since') ?? flagString(args, 'base');
|
|
106
|
+
if (!changedOnly && !since)
|
|
107
|
+
return {};
|
|
108
|
+
// The SAME helper `check wiring --changed-only` uses, so bare
|
|
109
|
+
// `--changed-only` means the working tree here too. Two surfaces disagreeing
|
|
110
|
+
// about what "changed" means is how a pre-commit hook silently checks a
|
|
111
|
+
// different set than the CI step it is supposed to mirror.
|
|
112
|
+
if (since !== undefined && !refExists(cwd, since)) {
|
|
113
|
+
return { error: `cannot resolve ref '${since}' — not a valid commit/branch` };
|
|
114
|
+
}
|
|
115
|
+
const changed = resolveChangedFiles({
|
|
116
|
+
projectRoot: cwd,
|
|
117
|
+
...(since ? { since } : { includeWorktree: true }),
|
|
118
|
+
});
|
|
119
|
+
return { files: changed.files };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Narrow rules to those whose FOOTPRINT intersects the change.
|
|
123
|
+
*
|
|
124
|
+
* Returns the surviving rules plus how many were dropped, because a scoped run
|
|
125
|
+
* must never print the same headline as a full one — "0 violations across 2 of
|
|
126
|
+
* 9 rules" and "0 violations across 9 of 9" are different facts.
|
|
127
|
+
*/
|
|
128
|
+
function narrowToScope(rules, files) {
|
|
129
|
+
if (files === undefined)
|
|
130
|
+
return { selected: rules, skippedByScope: 0 };
|
|
131
|
+
const selected = rules.filter((r) => ruleTouchedBy(r, files));
|
|
132
|
+
return { selected, skippedByScope: rules.length - selected.length };
|
|
133
|
+
}
|
|
134
|
+
/** Flags every `gates` verb accepts; anything else is a typo, not an opt-in. */
|
|
135
|
+
const GATES_FLAGS = new Set([
|
|
136
|
+
'json',
|
|
137
|
+
'strict',
|
|
138
|
+
'plane',
|
|
139
|
+
'only',
|
|
140
|
+
'changed-only',
|
|
141
|
+
'since',
|
|
142
|
+
'base',
|
|
143
|
+
'no-spawn',
|
|
144
|
+
'no-cache',
|
|
145
|
+
'full',
|
|
146
|
+
'limit',
|
|
147
|
+
'cwd',
|
|
148
|
+
'id',
|
|
149
|
+
'rule-file',
|
|
150
|
+
'wiring',
|
|
151
|
+
'no-hints',
|
|
152
|
+
'exit-trailer',
|
|
153
|
+
]);
|
|
154
|
+
/**
|
|
155
|
+
* Reject an unrecognized flag rather than ignoring it.
|
|
156
|
+
*
|
|
157
|
+
* Without this a mistyped `--changed-only` parses as an unrelated `true`, the
|
|
158
|
+
* verb runs its UNSCOPED form, and exit `0` reads as "the scoped check passed".
|
|
159
|
+
* A flag the tool does not understand must never look like a satisfied request.
|
|
160
|
+
*/
|
|
161
|
+
function rejectUnknownFlags(args) {
|
|
162
|
+
const bad = firstUnknownFlag(args, GATES_FLAGS);
|
|
163
|
+
if (bad === undefined)
|
|
164
|
+
return undefined;
|
|
165
|
+
process.stderr.write(`Unknown flag "--${bad}". \`shrk gates\` accepts: ` +
|
|
166
|
+
`${[...GATES_FLAGS].filter((f) => f !== 'cwd' && f !== 'no-hints' && f !== 'exit-trailer').map((f) => `--${f}`).join(', ')}.\n`);
|
|
167
|
+
return ExitCode.UsageError;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Apply `--plane` and `--only` narrowing.
|
|
171
|
+
*
|
|
172
|
+
* An unknown `--only` id is REFUSED rather than silently matching nothing: a
|
|
173
|
+
* typo'd rule id would otherwise narrow the run to zero rules and report a
|
|
174
|
+
* confident pass over an empty set.
|
|
175
|
+
*/
|
|
176
|
+
function filterRules(all, planes, only) {
|
|
177
|
+
let rules = planes ? all.filter((r) => planes.has(r.plane)) : all;
|
|
178
|
+
if (!only)
|
|
179
|
+
return { ok: true, rules };
|
|
180
|
+
const wanted = only.split(',').map((x) => x.trim()).filter(Boolean);
|
|
181
|
+
const known = new Set(all.map((r) => r.id));
|
|
182
|
+
const unknown = wanted.filter((w) => !known.has(w));
|
|
183
|
+
if (unknown.length > 0) {
|
|
184
|
+
process.stderr.write(`Unknown rule id(s) in --only: ${unknown.join(', ')}. Run \`shrk gates list\` to see the ${all.length} declared rule(s).\n`);
|
|
185
|
+
return { ok: false };
|
|
186
|
+
}
|
|
187
|
+
rules = rules.filter((r) => wanted.includes(r.id));
|
|
188
|
+
return { ok: true, rules };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Build the registries the doc-reference plane resolves against — only when a
|
|
192
|
+
* rule of that plane is actually in scope.
|
|
193
|
+
*
|
|
194
|
+
* Every other plane reads files; this one reads shrk's own registries, which
|
|
195
|
+
* cost a workspace inspection. Paying that on every `gates coverage` in a repo
|
|
196
|
+
* with no doc-reference rules would be a tax on the common case.
|
|
197
|
+
*/
|
|
198
|
+
async function inspectionIfNeeded(cwd, rules) {
|
|
199
|
+
if (!rules.some((r) => r.plane === 'doc-reference'))
|
|
200
|
+
return undefined;
|
|
201
|
+
// Playbook / construct ids come from a cache an ASYNC load populates; the
|
|
202
|
+
// resolver is sync. Warming here is what makes a correct pack playbook cited
|
|
203
|
+
// in prose actually resolve.
|
|
204
|
+
const inspection = await inspectSharkcraft({ cwd });
|
|
205
|
+
await warmReferenceRegistries(inspection);
|
|
206
|
+
return inspection;
|
|
207
|
+
}
|
|
81
208
|
export const gatesListCommand = {
|
|
82
209
|
name: 'list',
|
|
83
210
|
description: 'Every data-defined rule across every plane, with its severity and empty-match policy.',
|
|
@@ -89,7 +216,7 @@ export const gatesListCommand = {
|
|
|
89
216
|
return prep.code;
|
|
90
217
|
const planes = parsePlanes(args);
|
|
91
218
|
if (!planes.ok)
|
|
92
|
-
return ExitCode.
|
|
219
|
+
return ExitCode.UsageError;
|
|
93
220
|
const json = flagBool(args, 'json');
|
|
94
221
|
const rules = planes.planes
|
|
95
222
|
? prep.value.rules.filter((r) => planes.planes.has(r.plane))
|
|
@@ -138,22 +265,54 @@ export const gatesListCommand = {
|
|
|
138
265
|
export const gatesCoverageCommand = {
|
|
139
266
|
name: 'coverage',
|
|
140
267
|
description: 'What every rule MATCHED against the live tree — the stale-selector detector. A rule matching 0 files/ids is a bug in the rule, never a pass. Also runs each rule\'s declared selfTest expectations.',
|
|
141
|
-
usage: 'shrk gates coverage [--plane <p>] [--strict] [--json]',
|
|
142
|
-
booleanFlags: new Set(['json', 'strict']),
|
|
268
|
+
usage: 'shrk gates coverage [--plane <p>] [--changed-only | --since <ref>] [--only <ids>] [--strict] [--json]',
|
|
269
|
+
booleanFlags: new Set(['json', 'strict', 'changed-only', 'no-cache']),
|
|
143
270
|
async run(args) {
|
|
271
|
+
const flagReject = rejectUnknownFlags(args);
|
|
272
|
+
if (flagReject !== undefined)
|
|
273
|
+
return flagReject;
|
|
144
274
|
const prep = await prepare(args);
|
|
145
275
|
if (!prep.ok)
|
|
146
276
|
return prep.code;
|
|
147
277
|
const planes = parsePlanes(args);
|
|
148
278
|
if (!planes.ok)
|
|
149
|
-
return ExitCode.
|
|
279
|
+
return ExitCode.UsageError;
|
|
150
280
|
const json = flagBool(args, 'json');
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (rules.length === 0)
|
|
281
|
+
const filtered = filterRules(prep.value.rules, planes.planes, flagString(args, 'only'));
|
|
282
|
+
if (!filtered.ok)
|
|
283
|
+
return ExitCode.UsageError;
|
|
284
|
+
if (filtered.rules.length === 0)
|
|
155
285
|
return writeNoRules(json);
|
|
156
|
-
|
|
286
|
+
// Scoping the STALE-SELECTOR check to the diff is what moves it from a
|
|
287
|
+
// CI-only report to something you can afford on every save — a stale glob
|
|
288
|
+
// is then caught the moment you cause it, not the next morning.
|
|
289
|
+
const scope = resolveScope(args, prep.value.cwd);
|
|
290
|
+
if (scope.error) {
|
|
291
|
+
process.stderr.write(`Cannot scope to the changeset: ${scope.error}\n`);
|
|
292
|
+
return ExitCode.UsageError;
|
|
293
|
+
}
|
|
294
|
+
const { selected, skippedByScope } = narrowToScope(filtered.rules, scope.files);
|
|
295
|
+
if (selected.length === 0) {
|
|
296
|
+
// Nothing in scope means nothing was PROVEN — never a green.
|
|
297
|
+
if (json) {
|
|
298
|
+
process.stdout.write(asJson({
|
|
299
|
+
schema: 'sharkcraft.gate-coverage/v1',
|
|
300
|
+
rules: [],
|
|
301
|
+
total: 0,
|
|
302
|
+
scoped: true,
|
|
303
|
+
skippedByScope,
|
|
304
|
+
exitCode: ExitCode.NotVerified,
|
|
305
|
+
}) + '\n');
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
process.stdout.write(header('Gate-rule coverage'));
|
|
309
|
+
process.stdout.write(` No rule's footprint intersects the changeset (${skippedByScope} skipped by scope).\n` +
|
|
310
|
+
' Nothing was checked — this is NOT a pass.\n');
|
|
311
|
+
}
|
|
312
|
+
return ExitCode.NotVerified;
|
|
313
|
+
}
|
|
314
|
+
const rules = selected;
|
|
315
|
+
const report = buildGateCoverage(prep.value.cwd, rules, prep.value.excludeDirs, prep.value.extractors, false, await inspectionIfNeeded(prep.value.cwd, rules));
|
|
157
316
|
// A rule that matched nothing is NOT-VERIFIED (2) by default — it neither
|
|
158
317
|
// passed nor failed, it never ran. `failOnEmpty` on the rule (or the global
|
|
159
318
|
// --strict promotion) turns that into a hard failure.
|
|
@@ -165,26 +324,76 @@ export const gatesCoverageCommand = {
|
|
|
165
324
|
? ExitCode.NotVerified
|
|
166
325
|
: ExitCode.VerifiedPass;
|
|
167
326
|
if (json) {
|
|
168
|
-
process.stdout.write(asJson({
|
|
327
|
+
process.stdout.write(asJson({
|
|
328
|
+
...report,
|
|
329
|
+
hardFailures: hardFailures.length,
|
|
330
|
+
...(scope.files ? { scoped: true, skippedByScope } : {}),
|
|
331
|
+
exitCode: exit,
|
|
332
|
+
gate: buildGateEnvelope('gates coverage', exit, report.rules.map((r) => ({
|
|
333
|
+
id: r.id,
|
|
334
|
+
type: r.plane,
|
|
335
|
+
status: r.status === 'ok'
|
|
336
|
+
? 'passed'
|
|
337
|
+
: r.status === 'empty'
|
|
338
|
+
? r.failOnEmpty
|
|
339
|
+
? 'failed'
|
|
340
|
+
: 'skipped'
|
|
341
|
+
: r.status === 'error'
|
|
342
|
+
? 'error'
|
|
343
|
+
: 'failed',
|
|
344
|
+
severity: r.failOnEmpty ? 'error' : 'warning',
|
|
345
|
+
counts: { files: r.filesMatched, units: r.unitsMatched },
|
|
346
|
+
violations: r.expectationFailures.map((f) => ({ id: r.id, message: f })),
|
|
347
|
+
...(r.status === 'empty' ? { skipReason: `matched 0 ${r.unitLabel}` } : {}),
|
|
348
|
+
...(r.error ? { error: r.error } : {}),
|
|
349
|
+
}))),
|
|
350
|
+
}) + '\n');
|
|
169
351
|
return exit;
|
|
170
352
|
}
|
|
171
353
|
process.stdout.write(header('Gate-rule coverage'));
|
|
172
|
-
process.stdout.write(kv('rules',
|
|
354
|
+
process.stdout.write(kv('rules', `${report.total}${skippedByScope > 0 ? ` (${skippedByScope} skipped by scope)` : ''}`) + '\n');
|
|
355
|
+
if (scope.files) {
|
|
356
|
+
process.stdout.write(kv('scope', `changed-only (${scope.files.length} file(s))`) + '\n');
|
|
357
|
+
}
|
|
173
358
|
process.stdout.write(kv('matched nothing', `${report.empty}${report.empty > 0 ? ' ← stale selector suspects' : ''}`) + '\n');
|
|
174
359
|
if (report.errored > 0)
|
|
175
360
|
process.stdout.write(kv('misconfigured', String(report.errored)) + '\n');
|
|
176
361
|
if (report.expectationFailures > 0) {
|
|
177
362
|
process.stdout.write(kv('broken selfTest', String(report.expectationFailures)) + '\n');
|
|
178
363
|
}
|
|
364
|
+
// The shared extractors, ONCE. Their whole value is that N consumers cannot
|
|
365
|
+
// disagree about which set they check — so one line proving the shared set
|
|
366
|
+
// is live and non-empty covers all N.
|
|
367
|
+
if (report.extractors.length > 0) {
|
|
368
|
+
process.stdout.write('\n shared extractors\n');
|
|
369
|
+
for (const e of report.extractors) {
|
|
370
|
+
const mark = e.error ? '!' : e.idsMatched === 0 ? '✗' : '✓';
|
|
371
|
+
process.stdout.write(` ${mark} $use:${e.id} — ${e.idsMatched} ids across ${e.filesMatched} file(s), ` +
|
|
372
|
+
`shared by ${e.consumers.length} rule(s): ${e.consumers.join(', ')}\n`);
|
|
373
|
+
if (e.sampleIds.length > 0)
|
|
374
|
+
process.stdout.write(` e.g. ${e.sampleIds.join(', ')}\n`);
|
|
375
|
+
if (e.error)
|
|
376
|
+
process.stdout.write(` ! ${e.error}\n`);
|
|
377
|
+
if (!e.error && e.idsMatched === 0) {
|
|
378
|
+
process.stdout.write(' matched nothing — every consumer of this extractor is checking an empty set\n');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
179
382
|
process.stdout.write('\n');
|
|
180
383
|
for (const r of report.rules) {
|
|
181
384
|
const mark = r.status === 'ok' ? '✓' : r.status === 'empty' ? (r.failOnEmpty ? '✗' : '–') : '✗';
|
|
182
|
-
process.stdout.write(` ${mark} [${r.plane}] ${r.id}
|
|
385
|
+
process.stdout.write(` ${mark} [${r.plane}] ${r.id}${r.viaExtractor ? ` (via $use:${r.viaExtractor})` : ''}` +
|
|
386
|
+
` — ${r.unitsMatched} ${r.unitLabel} across ${r.filesMatched} file(s)\n`);
|
|
183
387
|
if (r.sampleIds.length > 0) {
|
|
184
388
|
process.stdout.write(` e.g. ${r.sampleIds.join(', ')}\n`);
|
|
185
389
|
}
|
|
186
390
|
if (r.status === 'empty') {
|
|
187
391
|
process.stdout.write(` ${r.failOnEmpty ? 'FAILED' : 'SKIPPED'} — matched nothing; the selector is probably stale\n`);
|
|
392
|
+
// When the engine knows WHY a correct zero-match is probably not what
|
|
393
|
+
// the author meant, say so here rather than leaving them to rediscover
|
|
394
|
+
// it — this is the one dead end `import-edges` reliably produces.
|
|
395
|
+
if (r.hint)
|
|
396
|
+
process.stdout.write(` → ${r.hint}\n`);
|
|
188
397
|
}
|
|
189
398
|
if (r.error)
|
|
190
399
|
process.stdout.write(` ! ${r.error}\n`);
|
|
@@ -201,10 +410,432 @@ export const gatesCoverageCommand = {
|
|
|
201
410
|
return exit;
|
|
202
411
|
},
|
|
203
412
|
};
|
|
413
|
+
export const gatesCheckCommand = {
|
|
414
|
+
name: 'check',
|
|
415
|
+
description: 'Run EVERY data-defined rule plane\'s violation check in one pass — one exit code, one JSON envelope. The CI / pre-commit primitive. Distinct from `gates coverage` (are the rules still connected) and `shrk quality` (the whole pre-PR bundle).',
|
|
416
|
+
usage: 'shrk gates check [--plane <p>] [--only <ids>] [--changed-only | --since <ref>] [--no-spawn] [--strict] [--json]',
|
|
417
|
+
booleanFlags: new Set(['json', 'strict', 'changed-only', 'no-spawn', 'no-cache']),
|
|
418
|
+
async run(args) {
|
|
419
|
+
const flagReject = rejectUnknownFlags(args);
|
|
420
|
+
if (flagReject !== undefined)
|
|
421
|
+
return flagReject;
|
|
422
|
+
const prep = await prepare(args);
|
|
423
|
+
if (!prep.ok)
|
|
424
|
+
return prep.code;
|
|
425
|
+
const planes = parsePlanes(args);
|
|
426
|
+
if (!planes.ok)
|
|
427
|
+
return ExitCode.UsageError;
|
|
428
|
+
const json = flagBool(args, 'json');
|
|
429
|
+
const filtered = filterRules(prep.value.rules, planes.planes, flagString(args, 'only'));
|
|
430
|
+
if (!filtered.ok)
|
|
431
|
+
return ExitCode.UsageError;
|
|
432
|
+
if (filtered.rules.length === 0)
|
|
433
|
+
return writeNoRules(json);
|
|
434
|
+
const scope = resolveScope(args, prep.value.cwd);
|
|
435
|
+
if (scope.error) {
|
|
436
|
+
process.stderr.write(`Cannot scope to the changeset: ${scope.error}\n`);
|
|
437
|
+
return ExitCode.UsageError;
|
|
438
|
+
}
|
|
439
|
+
const { selected, skippedByScope } = narrowToScope(filtered.rules, scope.files);
|
|
440
|
+
const noSpawn = flagBool(args, 'no-spawn');
|
|
441
|
+
const run = selected.length === 0
|
|
442
|
+
? { results: [], diagnostics: [] }
|
|
443
|
+
: runGatePlanes(selected, {
|
|
444
|
+
cwd: prep.value.cwd,
|
|
445
|
+
excludeDirs: prep.value.excludeDirs,
|
|
446
|
+
...(scope.files ? { changedFiles: scope.files } : {}),
|
|
447
|
+
...(noSpawn ? { noSpawn: true } : {}),
|
|
448
|
+
...(await inspectionIfNeeded(prep.value.cwd, selected).then((i) => i ? { inspection: i } : {})),
|
|
449
|
+
});
|
|
450
|
+
// Only an ERROR-severity failure blocks. A warning-severity rule reports
|
|
451
|
+
// without failing, exactly as its own plane's verb does.
|
|
452
|
+
//
|
|
453
|
+
// `--strict` promotes those warnings to blocking — the documented switch for
|
|
454
|
+
// a zero-warning CI. It reuses the established local meaning of `--strict`
|
|
455
|
+
// (`shrk check --strict` already does exactly this) rather than inventing a
|
|
456
|
+
// second severity model; the GLOBAL `--strict` promotion of not-verified
|
|
457
|
+
// (`2` → `1`) is applied once in `runCli` and composes with this.
|
|
458
|
+
const strict = flagBool(args, 'strict');
|
|
459
|
+
const failedRules = run.results.filter((r) => r.status === 'failed' || r.status === 'error');
|
|
460
|
+
const blocking = failedRules.filter((r) => strict || r.severity === 'error');
|
|
461
|
+
const failedWarnings = failedRules.filter((r) => r.severity !== 'error');
|
|
462
|
+
// An ERRORED rule proved nothing — it never reached a subject. It is
|
|
463
|
+
// therefore not `evaluated`, whatever its severity; otherwise a
|
|
464
|
+
// warning-severity rule that could not run exits 0 with a green banner,
|
|
465
|
+
// which is the silent-green this engine exists to prevent.
|
|
466
|
+
const evaluated = run.results.filter((r) => r.status !== 'skipped' && r.status !== 'error').length;
|
|
467
|
+
const skipped = run.results.length - evaluated;
|
|
468
|
+
// Exit must match the banner. Two kinds of "didn't run" are NOT the same
|
|
469
|
+
// thing, and conflating them breaks the surface either way:
|
|
470
|
+
// • skipped BY SCOPE — the user asked for the narrowing (`--changed-only`),
|
|
471
|
+
// so the rules outside it are deliberately out of the question. Failing
|
|
472
|
+
// here would make a pre-commit hook exit non-zero on every commit.
|
|
473
|
+
// • skipped BY ACCIDENT — a selector matched nothing, or --no-spawn
|
|
474
|
+
// dropped the drift half. Nobody asked for that, and it is exactly the
|
|
475
|
+
// silent-green this engine exists to prevent, so it is `2`.
|
|
476
|
+
// Matches `check wiring --changed-only`, which draws the same line.
|
|
477
|
+
const exit = blocking.length > 0
|
|
478
|
+
? ExitCode.Failure
|
|
479
|
+
: evaluated === 0 || skipped > 0
|
|
480
|
+
? ExitCode.NotVerified
|
|
481
|
+
: ExitCode.VerifiedPass;
|
|
482
|
+
const diagnostics = [...run.diagnostics, ...prep.value.planeDiagnostics];
|
|
483
|
+
if (json) {
|
|
484
|
+
process.stdout.write(asJson({
|
|
485
|
+
schema: SCHEMA,
|
|
486
|
+
configured: filtered.rules.length,
|
|
487
|
+
selected: selected.length,
|
|
488
|
+
evaluated,
|
|
489
|
+
skipped,
|
|
490
|
+
skippedByScope,
|
|
491
|
+
...(scope.files ? { scoped: true, changedFiles: scope.files.length } : {}),
|
|
492
|
+
noSpawn,
|
|
493
|
+
strict,
|
|
494
|
+
failed: blocking.length,
|
|
495
|
+
failedWarnings: failedWarnings.length,
|
|
496
|
+
verdict: blocking.length > 0 ? 'errors' : evaluated === 0 ? 'not-verified' : 'pass',
|
|
497
|
+
diagnostics,
|
|
498
|
+
exitCode: exit,
|
|
499
|
+
gate: buildGateEnvelope('gates check', exit, run.results),
|
|
500
|
+
}) + '\n');
|
|
501
|
+
return exit;
|
|
502
|
+
}
|
|
503
|
+
process.stdout.write(header('Gate check — every rule plane'));
|
|
504
|
+
process.stdout.write(kv('evaluated', `${evaluated} of ${filtered.rules.length}` +
|
|
505
|
+
(skippedByScope > 0 ? ` (${skippedByScope} skipped by scope)` : '')) + '\n');
|
|
506
|
+
if (scope.files) {
|
|
507
|
+
process.stdout.write(kv('scope', `changed-only (${scope.files.length} file(s))`) + '\n');
|
|
508
|
+
}
|
|
509
|
+
if (noSpawn)
|
|
510
|
+
process.stdout.write(kv('mode', '--no-spawn — shell-executing checks skipped') + '\n');
|
|
511
|
+
process.stdout.write(kv('violations', `${blocking.length} blocking rule(s), ${failedWarnings.length} warning rule(s)` +
|
|
512
|
+
(strict && failedWarnings.length > 0 ? ' — --strict: warnings block' : '')) + '\n\n');
|
|
513
|
+
for (const plane of GATE_PLANES) {
|
|
514
|
+
const inPlane = run.results.filter((r) => r.type === plane);
|
|
515
|
+
if (inPlane.length === 0)
|
|
516
|
+
continue;
|
|
517
|
+
for (const r of inPlane) {
|
|
518
|
+
const mark = r.status === 'passed' ? '✓' : r.status === 'skipped' ? '–' : r.severity === 'error' ? '✗' : '!';
|
|
519
|
+
const counts = Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
520
|
+
process.stdout.write(` ${mark} [${plane}] ${r.id}${counts ? ` (${counts})` : ''}\n`);
|
|
521
|
+
if (r.status === 'skipped' && r.skipReason) {
|
|
522
|
+
process.stdout.write(` SKIPPED — ${r.skipReason}\n`);
|
|
523
|
+
}
|
|
524
|
+
if (r.error)
|
|
525
|
+
process.stdout.write(` ! ${r.error}\n`);
|
|
526
|
+
for (const v of r.violations.slice(0, 10)) {
|
|
527
|
+
const at = v.file ? ` (${v.file}${v.line !== undefined ? `:${v.line}` : ''})` : '';
|
|
528
|
+
process.stdout.write(` • ${v.id}${at}${v.message ? ` — ${v.message}` : ''}\n`);
|
|
529
|
+
}
|
|
530
|
+
if (r.violations.length > 10) {
|
|
531
|
+
process.stdout.write(` … (${r.violations.length - 10} more)\n`);
|
|
532
|
+
}
|
|
533
|
+
const hint = r.violations.find((v) => v.hint)?.hint;
|
|
534
|
+
if (hint && r.violations.length > 0)
|
|
535
|
+
process.stdout.write(` → ${hint}\n`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const d of diagnostics)
|
|
539
|
+
process.stdout.write(` ! ${d}\n`);
|
|
540
|
+
if (strict && failedWarnings.length > 0 && exit === ExitCode.Failure) {
|
|
541
|
+
process.stdout.write(`\n${failedWarnings.length} warning rule(s) reported findings and --strict promoted them to failures.\n`);
|
|
542
|
+
}
|
|
543
|
+
else if (exit === ExitCode.VerifiedPass && failedWarnings.length > 0) {
|
|
544
|
+
// A warning-severity rule reports without blocking, but the banner must
|
|
545
|
+
// still say it FIRED. "Everything passed" next to a printed violation is
|
|
546
|
+
// the kind of half-truth that trains people to stop reading the output.
|
|
547
|
+
process.stdout.write(`\nNo blocking violations, but ${failedWarnings.length} warning rule(s) reported findings.\n` +
|
|
548
|
+
(skippedByScope > 0 ? `(${skippedByScope} rule(s) outside the changeset were not run)\n` : ''));
|
|
549
|
+
}
|
|
550
|
+
else if (exit === ExitCode.VerifiedPass && skippedByScope === 0) {
|
|
551
|
+
process.stdout.write('\nEvery declared rule ran and passed. ✓\n');
|
|
552
|
+
}
|
|
553
|
+
else if (exit === ExitCode.NotVerified) {
|
|
554
|
+
process.stdout.write(`\n${skipped} rule(s) in scope checked NOTHING — this is not a pass.\n` +
|
|
555
|
+
'Run `shrk gates coverage` to see which selectors are stale.\n');
|
|
556
|
+
}
|
|
557
|
+
else if (skippedByScope > 0) {
|
|
558
|
+
process.stdout.write(`\nEvery rule in scope passed. ✓ (${skippedByScope} outside the changeset were not run)\n`);
|
|
559
|
+
}
|
|
560
|
+
return exit;
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
/** Parse `--wiring 'declared=<glob>:<pattern> registered=<glob>:<pattern>'`. */
|
|
564
|
+
function parseInlineWiring(spec) {
|
|
565
|
+
const sides = new Map();
|
|
566
|
+
// Split on whitespace that precedes a `<side>=`, so a glob may contain none.
|
|
567
|
+
for (const part of spec.trim().split(/\s+(?=(?:declared|registered)=)/)) {
|
|
568
|
+
const eq = part.indexOf('=');
|
|
569
|
+
if (eq === -1)
|
|
570
|
+
return { error: `"${part}" is not <side>=<glob>:<pattern>` };
|
|
571
|
+
const side = part.slice(0, eq).trim();
|
|
572
|
+
if (side !== 'declared' && side !== 'registered') {
|
|
573
|
+
return { error: `unknown side "${side}" — use declared= or registered=` };
|
|
574
|
+
}
|
|
575
|
+
const rest = part.slice(eq + 1);
|
|
576
|
+
const colon = rest.lastIndexOf(':');
|
|
577
|
+
if (colon <= 0)
|
|
578
|
+
return { error: `"${side}=" needs <glob>:<pattern>` };
|
|
579
|
+
sides.set(side, { files: [rest.slice(0, colon)], match: rest.slice(colon + 1) });
|
|
580
|
+
}
|
|
581
|
+
const declared = sides.get('declared');
|
|
582
|
+
const registered = sides.get('registered');
|
|
583
|
+
if (!declared || !registered) {
|
|
584
|
+
return { error: 'both declared=<glob>:<pattern> and registered=<glob>:<pattern> are required' };
|
|
585
|
+
}
|
|
586
|
+
const toSource = (side) => ({
|
|
587
|
+
files: side.files,
|
|
588
|
+
extract: 'regex-capture',
|
|
589
|
+
pattern: side.match,
|
|
590
|
+
});
|
|
591
|
+
return {
|
|
592
|
+
rule: { id: '(try)', declared: toSource(declared), registered: toSource(registered) },
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
export const gatesTryCommand = {
|
|
596
|
+
name: 'try',
|
|
597
|
+
description: 'Dry-run a rule spec against the live tree WITHOUT adding it to config — the rule-authoring REPL. Prints the resolved sets and the diff, so a selector is tightened before it is committed.',
|
|
598
|
+
usage: "shrk gates try --rule-file <rule.json> [--plane <p>] [--full | --limit N] | --wiring 'declared=<glob>:<pat> registered=<glob>:<pat>' [--json]",
|
|
599
|
+
booleanFlags: new Set(['json', 'full']),
|
|
600
|
+
async run(args) {
|
|
601
|
+
const flagReject = rejectUnknownFlags(args);
|
|
602
|
+
if (flagReject !== undefined)
|
|
603
|
+
return flagReject;
|
|
604
|
+
const ruleFile = flagString(args, 'rule-file') ?? args.positional[0];
|
|
605
|
+
const inline = flagString(args, 'wiring');
|
|
606
|
+
if (!ruleFile && !inline) {
|
|
607
|
+
process.stderr.write('Usage: shrk gates try --rule-file <rule.json> [--plane <p>] [--full | --limit N]\n' +
|
|
608
|
+
" shrk gates try --wiring 'declared=<glob>:<pat> registered=<glob>:<pat>'\n" +
|
|
609
|
+
' Runs the extraction and prints the resolved sets — config is never touched.\n' +
|
|
610
|
+
' --full dump the WHOLE extracted set (default: first 5)\n' +
|
|
611
|
+
' --limit N dump the first N\n');
|
|
612
|
+
return ExitCode.UsageError;
|
|
613
|
+
}
|
|
614
|
+
const cwd = resolveCwd(args);
|
|
615
|
+
const json = flagBool(args, 'json');
|
|
616
|
+
// A candidate may `$use` the project's shared extractors, so the real
|
|
617
|
+
// config is loaded for its `extractors` map (and for nothing else — no
|
|
618
|
+
// declared rule is read, and nothing is written).
|
|
619
|
+
const loaded = await resolveProjectConfig(cwd);
|
|
620
|
+
const extractors = loaded.ok ? (loaded.value.config.extractors ?? {}) : {};
|
|
621
|
+
const excludeDirs = loaded.ok
|
|
622
|
+
? (() => {
|
|
623
|
+
const rel = nodePath
|
|
624
|
+
.relative(cwd, loaded.value.sharkcraftDir)
|
|
625
|
+
.split(nodePath.sep)
|
|
626
|
+
.join('/');
|
|
627
|
+
return rel && !rel.startsWith('..') ? [rel] : [];
|
|
628
|
+
})()
|
|
629
|
+
: [];
|
|
630
|
+
let raw;
|
|
631
|
+
let plane = 'wiring';
|
|
632
|
+
if (inline) {
|
|
633
|
+
const parsed = parseInlineWiring(inline);
|
|
634
|
+
if (parsed.error) {
|
|
635
|
+
process.stderr.write(`Invalid --wiring spec: ${parsed.error}\n`);
|
|
636
|
+
return ExitCode.UsageError;
|
|
637
|
+
}
|
|
638
|
+
raw = parsed.rule;
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
if (!existsSync(ruleFile)) {
|
|
642
|
+
process.stderr.write(`Rule file not found: ${ruleFile}\n`);
|
|
643
|
+
return ExitCode.UsageError;
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
raw = JSON.parse(readFileSync(ruleFile, 'utf8'));
|
|
647
|
+
}
|
|
648
|
+
catch (e) {
|
|
649
|
+
process.stderr.write(`${ruleFile} is not valid JSON: ${e.message}\n`);
|
|
650
|
+
return ExitCode.UsageError;
|
|
651
|
+
}
|
|
652
|
+
const planes = parsePlanes(args);
|
|
653
|
+
if (!planes.ok)
|
|
654
|
+
return ExitCode.UsageError;
|
|
655
|
+
const explicit = planes.planes ? [...planes.planes][0] : undefined;
|
|
656
|
+
const inferred = explicit ?? inferPlane(raw);
|
|
657
|
+
if (!inferred) {
|
|
658
|
+
process.stderr.write('Could not infer the plane from the rule shape. Pass --plane wiring|policy|registry|registration|baseline|generated.\n');
|
|
659
|
+
return ExitCode.UsageError;
|
|
660
|
+
}
|
|
661
|
+
plane = inferred;
|
|
662
|
+
}
|
|
663
|
+
// Validate with the SAME schema the loader uses, so a spec that passes here
|
|
664
|
+
// is a spec that will load — the point of the REPL is that what you see is
|
|
665
|
+
// what you get once you paste it in.
|
|
666
|
+
const schema = PLANE_SCHEMAS[plane];
|
|
667
|
+
const parsed = schema.safeParse(raw);
|
|
668
|
+
if (!parsed.success) {
|
|
669
|
+
const summary = (parsed.error?.issues ?? [])
|
|
670
|
+
.map((iss) => `${iss.path.join('.') || '<root>'}: ${iss.message}`)
|
|
671
|
+
.join('; ');
|
|
672
|
+
if (json)
|
|
673
|
+
process.stdout.write(asJson({ schema: SCHEMA, plane, valid: false, error: summary }) + '\n');
|
|
674
|
+
else
|
|
675
|
+
process.stderr.write(`Invalid ${plane} rule: ${summary}\n`);
|
|
676
|
+
return ExitCode.UsageError;
|
|
677
|
+
}
|
|
678
|
+
// Resolve `$use` exactly as the loader would, so a candidate referencing a
|
|
679
|
+
// shared extractor is tried against the real shared definition.
|
|
680
|
+
const planeKey = PLANE_CONFIG_KEY[plane];
|
|
681
|
+
const resolved = resolvePlaneExtractors({ [planeKey]: [parsed.data] }, extractors);
|
|
682
|
+
if (resolved.errors.length > 0) {
|
|
683
|
+
const summary = resolved.errors.map((e) => e.message).join('; ');
|
|
684
|
+
if (json)
|
|
685
|
+
process.stdout.write(asJson({ schema: SCHEMA, plane, valid: false, error: summary }) + '\n');
|
|
686
|
+
else
|
|
687
|
+
process.stderr.write(`Unresolvable extractor reference: ${summary}\n`);
|
|
688
|
+
return ExitCode.UsageError;
|
|
689
|
+
}
|
|
690
|
+
const resolvedByKey = resolved;
|
|
691
|
+
const rule = resolvedByKey[planeKey]?.[0] ?? parsed.data;
|
|
692
|
+
const view = {
|
|
693
|
+
id: rule.id ?? rule.name ?? '(try)',
|
|
694
|
+
plane,
|
|
695
|
+
severity: 'error',
|
|
696
|
+
failOnEmpty: false,
|
|
697
|
+
raw: rule,
|
|
698
|
+
};
|
|
699
|
+
// The wiring plane already has a BOTH-SIDES explainer (resolved declared
|
|
700
|
+
// set, resolved registered set, and the set-difference between them) — the
|
|
701
|
+
// exact view an author tightening a selector needs. Reuse it rather than
|
|
702
|
+
// printing a second, thinner one that could disagree with `gates explain`.
|
|
703
|
+
if (plane === 'wiring') {
|
|
704
|
+
const explain = explainWiring(cwd, rule, { excludeDirs });
|
|
705
|
+
const code = renderWiringExplain(explain, json);
|
|
706
|
+
if (!json) {
|
|
707
|
+
process.stdout.write('\n Nothing was written. Paste the rule into `wiringRules[]` to keep it.\n');
|
|
708
|
+
}
|
|
709
|
+
// An empty side means the selector proved nothing — not a pass.
|
|
710
|
+
return explain.declared.distinctCount === 0 || explain.registered.distinctCount === 0
|
|
711
|
+
? ExitCode.NotVerified
|
|
712
|
+
: code;
|
|
713
|
+
}
|
|
714
|
+
// The candidate's COVERAGE (what it matched) is the answer the author
|
|
715
|
+
// needs, and the violation run tells them whether the rule would be green.
|
|
716
|
+
const coverage = buildGateCoverage(cwd, [view], excludeDirs, extractors, true, await inspectionIfNeeded(cwd, [view]));
|
|
717
|
+
// `noSpawn` is not a performance choice here: a `--rule-file` is arbitrary
|
|
718
|
+
// JSON, and honouring a `regen` / `compute.run` from it would turn a
|
|
719
|
+
// read-only preview into shell execution from an untrusted file.
|
|
720
|
+
const run = runGatePlanes([view], {
|
|
721
|
+
cwd,
|
|
722
|
+
excludeDirs,
|
|
723
|
+
noSpawn: true,
|
|
724
|
+
...(await inspectionIfNeeded(cwd, [view]).then((i) => (i ? { inspection: i } : {}))),
|
|
725
|
+
});
|
|
726
|
+
const result = run.results[0];
|
|
727
|
+
const cov = coverage.rules[0];
|
|
728
|
+
const allIds = cov.allIds ?? cov.sampleIds;
|
|
729
|
+
if (json) {
|
|
730
|
+
process.stdout.write(asJson({
|
|
731
|
+
schema: 'sharkcraft.gates-try/v1',
|
|
732
|
+
plane,
|
|
733
|
+
valid: true,
|
|
734
|
+
coverage: cov,
|
|
735
|
+
...(cov.hint ? { hint: cov.hint } : {}),
|
|
736
|
+
result: result ?? null,
|
|
737
|
+
note: 'nothing was written — paste the rule into sharkcraft.config.ts to keep it',
|
|
738
|
+
}) + '\n');
|
|
739
|
+
return cov.status === 'empty' ? ExitCode.NotVerified : ExitCode.VerifiedPass;
|
|
740
|
+
}
|
|
741
|
+
process.stdout.write(header(`gates try — candidate ${plane} rule "${view.id}"`));
|
|
742
|
+
process.stdout.write(kv('files matched', String(cov.filesMatched)) + '\n');
|
|
743
|
+
process.stdout.write(kv(cov.unitLabel, String(cov.unitsMatched)) + '\n');
|
|
744
|
+
if (cov.viaExtractor)
|
|
745
|
+
process.stdout.write(kv('via extractor', `$use:${cov.viaExtractor}`) + '\n');
|
|
746
|
+
// Tuning a selector against a large tree needs the WHOLE candidate set —
|
|
747
|
+
// a five-item sample cannot tell you whether the tail is right.
|
|
748
|
+
const full = flagBool(args, 'full');
|
|
749
|
+
const limitRaw = flagString(args, 'limit');
|
|
750
|
+
const limit = limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined;
|
|
751
|
+
if (limitRaw !== undefined && (!Number.isFinite(limit) || (limit ?? 0) < 1)) {
|
|
752
|
+
process.stderr.write(`--limit must be a positive integer, got "${limitRaw}".\n`);
|
|
753
|
+
return ExitCode.UsageError;
|
|
754
|
+
}
|
|
755
|
+
const shown = full ? allIds : allIds.slice(0, limit ?? 5);
|
|
756
|
+
if (shown.length > 0) {
|
|
757
|
+
const label = shown.length === allIds.length ? `all ${shown.length}` : `first ${shown.length} of ${allIds.length}`;
|
|
758
|
+
process.stdout.write(`\n extracted (${label}):\n`);
|
|
759
|
+
for (const id of shown)
|
|
760
|
+
process.stdout.write(` ${id}\n`);
|
|
761
|
+
if (shown.length < allIds.length) {
|
|
762
|
+
process.stdout.write(` … (${allIds.length - shown.length} more — re-run with --full)\n`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (cov.error)
|
|
766
|
+
process.stdout.write(`\n ! ${cov.error}\n`);
|
|
767
|
+
if (result) {
|
|
768
|
+
process.stdout.write(`\n would be: ${result.status}${result.violations.length > 0 ? ` (${result.violations.length} violation(s))` : ''}\n`);
|
|
769
|
+
for (const v of result.violations.slice(0, 20)) {
|
|
770
|
+
const at = v.file ? ` (${v.file}${v.line !== undefined ? `:${v.line}` : ''})` : '';
|
|
771
|
+
process.stdout.write(` • ${v.id}${at}\n`);
|
|
772
|
+
}
|
|
773
|
+
if (result.violations.length > 20) {
|
|
774
|
+
process.stdout.write(` … (${result.violations.length - 20} more)\n`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (cov.status === 'empty') {
|
|
778
|
+
process.stdout.write('\n This selector matched NOTHING — tighten it before adding it to config.\n');
|
|
779
|
+
if (cov.hint)
|
|
780
|
+
process.stdout.write(` → ${cov.hint}\n`);
|
|
781
|
+
}
|
|
782
|
+
process.stdout.write('\n Nothing was written. Paste the rule into sharkcraft.config.ts to keep it.\n');
|
|
783
|
+
return cov.status === 'empty' ? ExitCode.NotVerified : ExitCode.VerifiedPass;
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
/** Which config key each plane's rules live under, for `$use` resolution. */
|
|
787
|
+
const PLANE_CONFIG_KEY = {
|
|
788
|
+
wiring: 'wiringRules',
|
|
789
|
+
policy: 'policyRules',
|
|
790
|
+
registry: 'registries',
|
|
791
|
+
registration: 'registrationGraph',
|
|
792
|
+
baseline: 'baselines',
|
|
793
|
+
generated: 'generatedArtifacts',
|
|
794
|
+
'doc-reference': 'docReferences',
|
|
795
|
+
};
|
|
796
|
+
/** The loader's own schema for each plane — one validation, not a second one. */
|
|
797
|
+
const PLANE_SCHEMAS = {
|
|
798
|
+
wiring: WiringRuleSchema,
|
|
799
|
+
policy: PolicyRuleSchema,
|
|
800
|
+
registry: RegistryDeclarationSchema,
|
|
801
|
+
registration: RegistrationIdiomSchema,
|
|
802
|
+
baseline: BaselineRuleSchema,
|
|
803
|
+
generated: GeneratedArtifactRuleSchema,
|
|
804
|
+
'doc-reference': DocReferenceRuleSchema,
|
|
805
|
+
};
|
|
806
|
+
/**
|
|
807
|
+
* Infer a candidate's plane from its shape.
|
|
808
|
+
*
|
|
809
|
+
* Each plane has a field no other plane uses, so inference is exact rather than
|
|
810
|
+
* a guess — and an ambiguous shape returns `undefined` so the author is asked
|
|
811
|
+
* with `--plane` instead of being silently run on the wrong engine.
|
|
812
|
+
*/
|
|
813
|
+
function inferPlane(raw) {
|
|
814
|
+
if (typeof raw !== 'object' || raw === null)
|
|
815
|
+
return undefined;
|
|
816
|
+
const o = raw;
|
|
817
|
+
if ('generatedGlob' in o)
|
|
818
|
+
return 'generated';
|
|
819
|
+
if ('tokenPattern' in o && 'resolvesAs' in o)
|
|
820
|
+
return 'doc-reference';
|
|
821
|
+
if ('compute' in o)
|
|
822
|
+
return 'baseline';
|
|
823
|
+
if ('declared' in o && 'provided' in o && 'consumed' in o)
|
|
824
|
+
return 'registration';
|
|
825
|
+
if ('declared' in o || 'registered' in o || 'chain' in o)
|
|
826
|
+
return 'wiring';
|
|
827
|
+
if ('surface' in o && 'pattern' in o)
|
|
828
|
+
return 'policy';
|
|
829
|
+
if ('source' in o && 'name' in o)
|
|
830
|
+
return 'registry';
|
|
831
|
+
return undefined;
|
|
832
|
+
}
|
|
204
833
|
/** Render a registry inventory as the trust-layer explain view. */
|
|
205
834
|
function explainRegistry(cwd, decl, excludeDirs) {
|
|
206
835
|
const inventory = scanRegistry(cwd, decl, { excludeDirs });
|
|
207
836
|
const insp = inspectSource(cwd, decl.source, excludeDirs);
|
|
837
|
+
if (decl.source.$use)
|
|
838
|
+
process.stdout.write(kv('via extractor', `$use:${decl.source.$use}`) + '\n');
|
|
208
839
|
process.stdout.write(kv('files scanned', String(insp.filesScanned)) + '\n');
|
|
209
840
|
process.stdout.write(kv('ids', String(inventory.entries.length)) + '\n');
|
|
210
841
|
for (const e of inventory.entries.slice(0, 60)) {
|
|
@@ -224,7 +855,8 @@ function explainRegistration(cwd, idiom, excludeDirs) {
|
|
|
224
855
|
['consumed', idiom.consumed],
|
|
225
856
|
]) {
|
|
226
857
|
const insp = inspectSource(cwd, source, excludeDirs);
|
|
227
|
-
process.stdout.write(kv(label, `${insp.ids.length} token(s) across ${insp.filesScanned} file(s)`
|
|
858
|
+
process.stdout.write(kv(label, `${insp.ids.length} token(s) across ${insp.filesScanned} file(s)` +
|
|
859
|
+
(source.$use ? ` (via $use:${source.$use})` : '')) + '\n');
|
|
228
860
|
if (insp.error)
|
|
229
861
|
process.stdout.write(` ! ${insp.error}\n`);
|
|
230
862
|
for (const s of insp.sites.slice(0, 20)) {
|
|
@@ -244,7 +876,7 @@ export const gatesExplainCommand = {
|
|
|
244
876
|
const id = args.positional[0] ?? flagString(args, 'id');
|
|
245
877
|
if (!id) {
|
|
246
878
|
process.stderr.write('Usage: shrk gates explain <id> [--json]\n');
|
|
247
|
-
return ExitCode.
|
|
879
|
+
return ExitCode.UsageError;
|
|
248
880
|
}
|
|
249
881
|
const prep = await prepare(args);
|
|
250
882
|
if (!prep.ok)
|
|
@@ -252,25 +884,25 @@ export const gatesExplainCommand = {
|
|
|
252
884
|
const matches = prep.value.rules.filter((r) => r.id === id);
|
|
253
885
|
if (matches.length === 0) {
|
|
254
886
|
process.stderr.write(`No gate rule "${id}". Run \`shrk gates list\` to see the ${prep.value.rules.length} declared rule(s).\n`);
|
|
255
|
-
return ExitCode.
|
|
887
|
+
return ExitCode.UsageError;
|
|
256
888
|
}
|
|
257
889
|
// An id may legitimately exist on two planes (a wiring rule and a registry
|
|
258
890
|
// can share a name); `--plane` disambiguates instead of guessing.
|
|
259
891
|
const planes = parsePlanes(args);
|
|
260
892
|
if (!planes.ok)
|
|
261
|
-
return ExitCode.
|
|
893
|
+
return ExitCode.UsageError;
|
|
262
894
|
const candidates = planes.planes
|
|
263
895
|
? matches.filter((r) => planes.planes.has(r.plane))
|
|
264
896
|
: matches;
|
|
265
897
|
if (candidates.length > 1) {
|
|
266
898
|
process.stderr.write(`"${id}" exists on ${candidates.length} planes (${candidates.map((c) => c.plane).join(', ')}). ` +
|
|
267
899
|
'Disambiguate with --plane <p>.\n');
|
|
268
|
-
return ExitCode.
|
|
900
|
+
return ExitCode.UsageError;
|
|
269
901
|
}
|
|
270
902
|
const view = candidates[0];
|
|
271
903
|
if (!view) {
|
|
272
904
|
process.stderr.write(`No gate rule "${id}" on the requested plane.\n`);
|
|
273
|
-
return ExitCode.
|
|
905
|
+
return ExitCode.UsageError;
|
|
274
906
|
}
|
|
275
907
|
const json = flagBool(args, 'json');
|
|
276
908
|
// The two shell-executing planes own their explain output (and their trust
|
|
@@ -283,6 +915,13 @@ export const gatesExplainCommand = {
|
|
|
283
915
|
args.flags.set('id', view.id);
|
|
284
916
|
return generatedExplainCommand.run(args);
|
|
285
917
|
}
|
|
918
|
+
// The doc-reference plane owns its explain view too — it resolves against
|
|
919
|
+
// registries, not file globs, so the generic source-based renderer below
|
|
920
|
+
// has nothing to show for it.
|
|
921
|
+
if (view.plane === 'doc-reference') {
|
|
922
|
+
args.flags.set('id', view.id);
|
|
923
|
+
return docsReferencesExplainCommand.run(args);
|
|
924
|
+
}
|
|
286
925
|
if (view.plane === 'wiring') {
|
|
287
926
|
const explain = explainWiring(prep.value.cwd, view.raw, {
|
|
288
927
|
excludeDirs: prep.value.excludeDirs,
|
|
@@ -319,16 +958,44 @@ export const gatesExplainCommand = {
|
|
|
319
958
|
return ExitCode.VerifiedPass;
|
|
320
959
|
},
|
|
321
960
|
};
|
|
961
|
+
/**
|
|
962
|
+
* Try to explain `id` as a data-defined rule on ANY plane.
|
|
963
|
+
*
|
|
964
|
+
* Returns the exit code when the id resolves to exactly one declared rule, or
|
|
965
|
+
* `undefined` when it is not a rule id at all — which lets `shrk explain` keep
|
|
966
|
+
* its original topic-search behaviour for everything else. This is the D2
|
|
967
|
+
* unification: a user holding a rule id no longer has to know which plane owns
|
|
968
|
+
* it, and no existing invocation changes meaning.
|
|
969
|
+
*/
|
|
970
|
+
export async function tryExplainGateRule(args, id) {
|
|
971
|
+
const cwd = resolveCwd(args);
|
|
972
|
+
const loaded = await resolveProjectConfig(cwd);
|
|
973
|
+
if (!loaded.ok)
|
|
974
|
+
return undefined;
|
|
975
|
+
const rules = collectGateRules(loaded.value.config);
|
|
976
|
+
if (!rules.some((r) => r.id === id))
|
|
977
|
+
return undefined;
|
|
978
|
+
const forwarded = { ...args, positional: [id] };
|
|
979
|
+
return gatesExplainCommand.run(forwarded);
|
|
980
|
+
}
|
|
322
981
|
export const gatesCommand = {
|
|
323
982
|
name: 'gates',
|
|
324
|
-
description: 'Rule-authoring trust layer: list every data-defined rule, show what each one MATCHED (the stale-selector detector),
|
|
325
|
-
usage: 'shrk gates
|
|
326
|
-
booleanFlags: new Set(['json', 'strict']),
|
|
983
|
+
description: 'Rule-authoring trust layer: run every plane\'s violation check in one pass (`check`), list every data-defined rule, show what each one MATCHED (`coverage` — the stale-selector detector), explain any one of them, and dry-run a candidate rule before adding it (`try`). Never writes config. Not `shrk gate`, singular, which runs the quality-gate pipeline.',
|
|
984
|
+
usage: 'shrk gates check | coverage | list | explain <id> | try --rule-file <f>',
|
|
985
|
+
booleanFlags: new Set(['json', 'strict', 'changed-only', 'no-spawn']),
|
|
327
986
|
async run(args) {
|
|
328
987
|
const sub = args.positional[0];
|
|
329
988
|
process.stderr.write((sub ? `Unknown subcommand "${sub}". ` : '') +
|
|
330
|
-
'Usage: shrk gates
|
|
989
|
+
'Usage: shrk gates <subcommand>\n' +
|
|
990
|
+
' check [--plane <p>] [--only <ids>] [--changed-only|--since <ref>] [--no-spawn]\n' +
|
|
991
|
+
' run EVERY plane\'s violation check — one exit code (the CI / pre-commit primitive)\n' +
|
|
992
|
+
' coverage [--plane <p>] [--changed-only|--since <ref>] [--strict]\n' +
|
|
993
|
+
' what each rule MATCHED — the stale-selector detector\n' +
|
|
994
|
+
' list [--plane <p>] every declared rule, with severity + empty-match policy\n' +
|
|
995
|
+
' explain <id> the concrete inputs one rule resolved\n' +
|
|
996
|
+
" try --rule-file <f> | --wiring 'declared=<g>:<p> registered=<g>:<p>'\n" +
|
|
997
|
+
' dry-run a candidate rule WITHOUT touching config\n' +
|
|
331
998
|
'(`shrk gate`, singular, runs the quality-gate pipeline — a different verb.)\n');
|
|
332
|
-
return ExitCode.
|
|
999
|
+
return ExitCode.UsageError;
|
|
333
1000
|
},
|
|
334
1001
|
};
|