@shrkcrft/cli 0.1.0-alpha.29 → 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 +23 -7
- package/dist/commands/changelog-data.d.ts.map +1 -1
- package/dist/commands/changelog-data.js +26 -0
- package/dist/commands/check.command.d.ts.map +1 -1
- package/dist/commands/check.command.js +19 -2
- package/dist/commands/code-intel.command.d.ts.map +1 -1
- package/dist/commands/code-intel.command.js +5 -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 +2 -0
- package/dist/commands/gates.command.d.ts.map +1 -1
- package/dist/commands/gates.command.js +640 -16
- 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 +180 -49
- package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
- package/dist/commands/graph-code-subverbs.js +5 -1
- package/dist/commands/wiring.command.d.ts.map +1 -1
- package/dist/commands/wiring.command.js +4 -3
- package/dist/gates/gate-envelope.d.ts +1 -1
- package/dist/gates/gate-envelope.d.ts.map +1 -1
- 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 +13 -0
- 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 +5 -1
- package/package.json +33 -33
|
@@ -14,22 +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";
|
|
25
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";
|
|
26
32
|
import { baselineExplainCommand } from "./baseline.command.js";
|
|
27
33
|
import { generatedExplainCommand } from "./generated.command.js";
|
|
34
|
+
import { docsReferencesExplainCommand } from "./docs-references.command.js";
|
|
28
35
|
import { renderPolicyExplain, runPolicyExplain } from "./policy-lint.command.js";
|
|
29
36
|
import { renderWiringExplain } from "./wiring.command.js";
|
|
30
37
|
const SCHEMA = 'sharkcraft.gates/v1';
|
|
31
38
|
async function prepare(args) {
|
|
32
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();
|
|
33
45
|
const json = flagBool(args, 'json');
|
|
34
46
|
const loaded = await resolveProjectConfig(cwd);
|
|
35
47
|
if (!loaded.ok) {
|
|
@@ -48,6 +60,7 @@ async function prepare(args) {
|
|
|
48
60
|
rules: collectGateRules(loaded.value.config),
|
|
49
61
|
excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
|
|
50
62
|
planeDiagnostics: loaded.value.planeDiagnostics,
|
|
63
|
+
extractors: loaded.value.config.extractors ?? {},
|
|
51
64
|
},
|
|
52
65
|
};
|
|
53
66
|
}
|
|
@@ -79,6 +92,119 @@ function writeNoRules(json) {
|
|
|
79
92
|
' generatedArtifacts[] generated files that must not be hand-edited\n');
|
|
80
93
|
return ExitCode.NotVerified;
|
|
81
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
|
+
}
|
|
82
208
|
export const gatesListCommand = {
|
|
83
209
|
name: 'list',
|
|
84
210
|
description: 'Every data-defined rule across every plane, with its severity and empty-match policy.',
|
|
@@ -139,9 +265,12 @@ export const gatesListCommand = {
|
|
|
139
265
|
export const gatesCoverageCommand = {
|
|
140
266
|
name: 'coverage',
|
|
141
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.',
|
|
142
|
-
usage: 'shrk gates coverage [--plane <p>] [--strict] [--json]',
|
|
143
|
-
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']),
|
|
144
270
|
async run(args) {
|
|
271
|
+
const flagReject = rejectUnknownFlags(args);
|
|
272
|
+
if (flagReject !== undefined)
|
|
273
|
+
return flagReject;
|
|
145
274
|
const prep = await prepare(args);
|
|
146
275
|
if (!prep.ok)
|
|
147
276
|
return prep.code;
|
|
@@ -149,12 +278,41 @@ export const gatesCoverageCommand = {
|
|
|
149
278
|
if (!planes.ok)
|
|
150
279
|
return ExitCode.UsageError;
|
|
151
280
|
const json = flagBool(args, 'json');
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
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)
|
|
156
285
|
return writeNoRules(json);
|
|
157
|
-
|
|
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));
|
|
158
316
|
// A rule that matched nothing is NOT-VERIFIED (2) by default — it neither
|
|
159
317
|
// passed nor failed, it never ran. `failOnEmpty` on the rule (or the global
|
|
160
318
|
// --strict promotion) turns that into a hard failure.
|
|
@@ -169,6 +327,7 @@ export const gatesCoverageCommand = {
|
|
|
169
327
|
process.stdout.write(asJson({
|
|
170
328
|
...report,
|
|
171
329
|
hardFailures: hardFailures.length,
|
|
330
|
+
...(scope.files ? { scoped: true, skippedByScope } : {}),
|
|
172
331
|
exitCode: exit,
|
|
173
332
|
gate: buildGateEnvelope('gates coverage', exit, report.rules.map((r) => ({
|
|
174
333
|
id: r.id,
|
|
@@ -192,22 +351,49 @@ export const gatesCoverageCommand = {
|
|
|
192
351
|
return exit;
|
|
193
352
|
}
|
|
194
353
|
process.stdout.write(header('Gate-rule coverage'));
|
|
195
|
-
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
|
+
}
|
|
196
358
|
process.stdout.write(kv('matched nothing', `${report.empty}${report.empty > 0 ? ' ← stale selector suspects' : ''}`) + '\n');
|
|
197
359
|
if (report.errored > 0)
|
|
198
360
|
process.stdout.write(kv('misconfigured', String(report.errored)) + '\n');
|
|
199
361
|
if (report.expectationFailures > 0) {
|
|
200
362
|
process.stdout.write(kv('broken selfTest', String(report.expectationFailures)) + '\n');
|
|
201
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
|
+
}
|
|
202
382
|
process.stdout.write('\n');
|
|
203
383
|
for (const r of report.rules) {
|
|
204
384
|
const mark = r.status === 'ok' ? '✓' : r.status === 'empty' ? (r.failOnEmpty ? '✗' : '–') : '✗';
|
|
205
|
-
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`);
|
|
206
387
|
if (r.sampleIds.length > 0) {
|
|
207
388
|
process.stdout.write(` e.g. ${r.sampleIds.join(', ')}\n`);
|
|
208
389
|
}
|
|
209
390
|
if (r.status === 'empty') {
|
|
210
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`);
|
|
211
397
|
}
|
|
212
398
|
if (r.error)
|
|
213
399
|
process.stdout.write(` ! ${r.error}\n`);
|
|
@@ -224,10 +410,432 @@ export const gatesCoverageCommand = {
|
|
|
224
410
|
return exit;
|
|
225
411
|
},
|
|
226
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
|
+
}
|
|
227
833
|
/** Render a registry inventory as the trust-layer explain view. */
|
|
228
834
|
function explainRegistry(cwd, decl, excludeDirs) {
|
|
229
835
|
const inventory = scanRegistry(cwd, decl, { excludeDirs });
|
|
230
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');
|
|
231
839
|
process.stdout.write(kv('files scanned', String(insp.filesScanned)) + '\n');
|
|
232
840
|
process.stdout.write(kv('ids', String(inventory.entries.length)) + '\n');
|
|
233
841
|
for (const e of inventory.entries.slice(0, 60)) {
|
|
@@ -247,7 +855,8 @@ function explainRegistration(cwd, idiom, excludeDirs) {
|
|
|
247
855
|
['consumed', idiom.consumed],
|
|
248
856
|
]) {
|
|
249
857
|
const insp = inspectSource(cwd, source, excludeDirs);
|
|
250
|
-
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');
|
|
251
860
|
if (insp.error)
|
|
252
861
|
process.stdout.write(` ! ${insp.error}\n`);
|
|
253
862
|
for (const s of insp.sites.slice(0, 20)) {
|
|
@@ -306,6 +915,13 @@ export const gatesExplainCommand = {
|
|
|
306
915
|
args.flags.set('id', view.id);
|
|
307
916
|
return generatedExplainCommand.run(args);
|
|
308
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
|
+
}
|
|
309
925
|
if (view.plane === 'wiring') {
|
|
310
926
|
const explain = explainWiring(prep.value.cwd, view.raw, {
|
|
311
927
|
excludeDirs: prep.value.excludeDirs,
|
|
@@ -364,13 +980,21 @@ export async function tryExplainGateRule(args, id) {
|
|
|
364
980
|
}
|
|
365
981
|
export const gatesCommand = {
|
|
366
982
|
name: 'gates',
|
|
367
|
-
description: 'Rule-authoring trust layer: list every data-defined rule, show what each one MATCHED (the stale-selector detector),
|
|
368
|
-
usage: 'shrk gates
|
|
369
|
-
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']),
|
|
370
986
|
async run(args) {
|
|
371
987
|
const sub = args.positional[0];
|
|
372
988
|
process.stderr.write((sub ? `Unknown subcommand "${sub}". ` : '') +
|
|
373
|
-
'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' +
|
|
374
998
|
'(`shrk gate`, singular, runs the quality-gate pipeline — a different verb.)\n');
|
|
375
999
|
return ExitCode.UsageError;
|
|
376
1000
|
},
|