@shrkcrft/cli 0.1.0-alpha.26 → 0.1.0-alpha.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/command-registry.d.ts +12 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +25 -0
  4. package/dist/commands/baseline.command.d.ts +8 -0
  5. package/dist/commands/baseline.command.d.ts.map +1 -0
  6. package/dist/commands/baseline.command.js +511 -0
  7. package/dist/commands/changelog-data.d.ts.map +1 -1
  8. package/dist/commands/changelog-data.js +43 -0
  9. package/dist/commands/check.command.d.ts.map +1 -1
  10. package/dist/commands/check.command.js +28 -1
  11. package/dist/commands/command-catalog.d.ts.map +1 -1
  12. package/dist/commands/command-catalog.js +112 -0
  13. package/dist/commands/delegate.command.d.ts +76 -1
  14. package/dist/commands/delegate.command.d.ts.map +1 -1
  15. package/dist/commands/delegate.command.js +585 -25
  16. package/dist/commands/finish.command.js +4 -4
  17. package/dist/commands/gates.command.d.ts +6 -0
  18. package/dist/commands/gates.command.d.ts.map +1 -0
  19. package/dist/commands/gates.command.js +334 -0
  20. package/dist/commands/generated.command.d.ts +6 -0
  21. package/dist/commands/generated.command.d.ts.map +1 -0
  22. package/dist/commands/generated.command.js +514 -0
  23. package/dist/commands/help.command.d.ts.map +1 -1
  24. package/dist/commands/help.command.js +73 -0
  25. package/dist/commands/ingest.command.d.ts +11 -0
  26. package/dist/commands/ingest.command.d.ts.map +1 -1
  27. package/dist/commands/ingest.command.js +49 -23
  28. package/dist/commands/policy-lint.command.d.ts +37 -0
  29. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  30. package/dist/commands/policy-lint.command.js +119 -2
  31. package/dist/commands/registry-resolve.d.ts +11 -4
  32. package/dist/commands/registry-resolve.d.ts.map +1 -1
  33. package/dist/commands/registry-resolve.js +50 -24
  34. package/dist/commands/registry.command.d.ts.map +1 -1
  35. package/dist/commands/registry.command.js +36 -4
  36. package/dist/commands/trace.command.d.ts.map +1 -1
  37. package/dist/commands/trace.command.js +7 -1
  38. package/dist/commands/wiring.command.d.ts.map +1 -1
  39. package/dist/commands/wiring.command.js +113 -14
  40. package/dist/exit-codes.d.ts +41 -0
  41. package/dist/exit-codes.d.ts.map +1 -1
  42. package/dist/exit-codes.js +85 -0
  43. package/dist/finish/run-finish.d.ts +22 -3
  44. package/dist/finish/run-finish.d.ts.map +1 -1
  45. package/dist/finish/run-finish.js +194 -18
  46. package/dist/gates/gate-rule-view.d.ts +35 -0
  47. package/dist/gates/gate-rule-view.d.ts.map +1 -0
  48. package/dist/gates/gate-rule-view.js +80 -0
  49. package/dist/gates/rule-coverage.d.ts +53 -0
  50. package/dist/gates/rule-coverage.d.ts.map +1 -0
  51. package/dist/gates/rule-coverage.js +165 -0
  52. package/dist/main.d.ts.map +1 -1
  53. package/dist/main.js +46 -6
  54. package/dist/output/output-compression.d.ts.map +1 -1
  55. package/dist/output/output-compression.js +4 -1
  56. package/package.json +33 -33
@@ -1,9 +1,23 @@
1
- import { buildImportHygieneReport, filterViolationsToChangedScope, inspectSharkcraft, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
1
+ import { buildImportHygieneReport, filterViolationsToChangedScope, gitShowFile, inspectSharkcraft, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
2
2
  import { existsSync } from 'node:fs';
3
3
  import * as nodePath from 'node:path';
4
- import { evaluateBoundaries, loadTsconfigPaths, runPolicyLint, runWiring, scanImports, } from '@shrkcrft/boundaries';
4
+ import { buildRegistrationGraph, evaluateBoundaries, loadTsconfigPaths, providedTokensFromEntries, registrationTouchesChanged, registrationUnprovided, runPolicyLint, runWiring, scanImports, } from '@shrkcrft/boundaries';
5
5
  import { computeDeletedOrphans } from "../diff/deleted-orphans.js";
6
+ import { ExitCode } from "../exit-codes.js";
6
7
  export const FINISH_SCHEMA = 'sharkcraft.finish/v1';
8
+ /** Files in the boundary/import/wiring domain — a change outside it evaluates nothing there. */
9
+ const CODE_FILE = /\.(?:m|c)?[jt]sx?$/;
10
+ function codeFilesOf(files) {
11
+ return files.filter((f) => CODE_FILE.test(f));
12
+ }
13
+ /** The base ref the unprovided gate diffs against to spot removed providers. */
14
+ function baseRefFor(input) {
15
+ if (input.mode === 'files')
16
+ return undefined; // an explicit file list has no diff base
17
+ if (input.mode === 'since' && input.scope.since)
18
+ return input.scope.since;
19
+ return 'HEAD'; // worktree + staged both diff vs HEAD
20
+ }
7
21
  /** Map the changed-scope onto the orphan check's diff inputs. */
8
22
  function orphanOptsFor(input) {
9
23
  if (input.mode === 'files')
@@ -29,6 +43,11 @@ export async function runFinishGates(input) {
29
43
  const { cwd } = input;
30
44
  const changed = resolveChangedFiles(input.scope);
31
45
  const changedFiles = changed.files;
46
+ // The boundary/import/wiring engines only reason about code files. A change to
47
+ // non-code files (docs, JSON, config) evaluates NOTHING in those gates, so they
48
+ // must SKIP, not trivially pass — otherwise a markdown-only change paints green
49
+ // ("evaluated nothing" must read as not-verified, never a `0`).
50
+ const codeChanged = codeFilesOf(changedFiles);
32
51
  const gates = [];
33
52
  // ── boundaries (changed-only) ────────────────────────────────────────
34
53
  const inspection = await inspectSharkcraft({ cwd });
@@ -36,8 +55,8 @@ export async function runFinishGates(input) {
36
55
  if (boundaryRules.length === 0) {
37
56
  gates.push(skip('boundaries', 'no boundary rules configured'));
38
57
  }
39
- else if (changedFiles.length === 0) {
40
- gates.push(skip('boundaries', 'no files in changed scope'));
58
+ else if (codeChanged.length === 0) {
59
+ gates.push(skip('boundaries', 'no code files in changed scope'));
41
60
  }
42
61
  else {
43
62
  const scan = scanImports({ projectRoot: cwd });
@@ -62,23 +81,29 @@ export async function runFinishGates(input) {
62
81
  });
63
82
  }
64
83
  // ── import hygiene (changed-only) ────────────────────────────────────
65
- if (changedFiles.length === 0) {
66
- gates.push(skip('imports', 'no files in changed scope'));
84
+ if (codeChanged.length === 0) {
85
+ gates.push(skip('imports', 'no code files in changed scope'));
67
86
  }
68
87
  else {
69
- const report = buildImportHygieneReport(cwd, { files: changedFiles });
88
+ const report = buildImportHygieneReport(cwd, { files: codeChanged });
70
89
  const errors = report.counts?.['error'] ?? (report.verdict === 'errors' ? report.findings.length : 0);
71
90
  const warnings = report.counts?.['warning'] ?? (report.verdict === 'warnings' ? report.findings.length : 0);
91
+ // Only the findings that actually DRIVE the verdict become "failing items".
92
+ // An allowlisted import is downgraded to `info` by design and is not what
93
+ // failed — listing it anyway pads the renderer's 15-item cap and can push a
94
+ // real error out of view, while its allowlist justification reads like a
95
+ // fix instruction. A fix-list that names non-failures is not a fix-list.
96
+ const driving = report.findings.filter((f) => report.verdict === 'errors' ? f.severity === 'error' : f.severity === 'warning');
72
97
  gates.push({
73
98
  name: 'imports',
74
99
  status: report.verdict === 'errors' ? 'fail' : 'pass',
75
- detail: `verdict=${report.verdict} (${report.findings.length} finding(s))`,
100
+ detail: `verdict=${report.verdict} (${driving.length} of ${report.findings.length} finding(s) drive it)`,
76
101
  errors,
77
102
  warnings,
78
- items: report.findings.map((f) => ({
103
+ items: driving.map((f) => ({
79
104
  file: f.file,
80
105
  line: f.line,
81
- message: `${f.kind}: ${f.suggestedFix || f.reason || f.snippet}`.trim(),
106
+ message: `[${f.severity}] ${f.kind}: ${f.suggestedFix || f.reason || f.snippet}`.trim(),
82
107
  })),
83
108
  });
84
109
  }
@@ -96,34 +121,61 @@ export async function runFinishGates(input) {
96
121
  ? `config did not load: ${loaded.error.message}`
97
122
  : 'no sharkcraft config (gate not applicable)';
98
123
  gates.push(skip('wiring', detail));
124
+ gates.push(skip('unprovided', detail));
99
125
  gates.push(skip('policy', detail));
100
126
  }
101
127
  else {
102
- gates.push(wiringGate(cwd, loaded.value.config.wiringRules ?? [], changedFiles), policyGate(cwd, loaded.value.config.policyRules ?? [], changedFiles));
128
+ gates.push(wiringGate(cwd, loaded.value.config.wiringRules ?? [], changedFiles), unprovidedGate(cwd, loaded.value.config.registrationGraph ?? [], changedFiles, baseRefFor(input)), policyGate(cwd, loaded.value.config.policyRules ?? [], changedFiles));
103
129
  }
104
130
  // ── deleted-orphans (write-safety) ───────────────────────────────────
105
131
  gates.push(await orphansGate(input));
132
+ // ── architecture (advisory): cycles the change participates in ───────
133
+ gates.push(await archGate(cwd, changedFiles));
106
134
  // ── impact summary (informational, best-effort) ──────────────────────
107
135
  const impact = await impactSummary(cwd, changedFiles);
108
- // ── fold into one verdict ────────────────────────────────────────────
109
- const failed = gates.filter((g) => g.status === 'fail');
136
+ // ── fold into one honest 0/1/2 verdict ───────────────────────────────
137
+ // Only NON-advisory ("deciding") gates decide the verdict. A deciding fail (or
138
+ // a config that could not load) is `1`. Otherwise, if NO deciding gate actually
139
+ // evaluated anything (all skipped), the run verified nothing → `2` (never a
140
+ // green `0`). Only when at least one deciding gate ran over a real scope and
141
+ // none failed is it a true `0`.
142
+ const deciding = gates.filter((g) => !g.advisory);
143
+ const failed = deciding.filter((g) => g.status === 'fail');
144
+ const anyEvaluated = deciding.some((g) => g.status === 'pass');
110
145
  const warnings = gates.reduce((n, g) => n + g.warnings, 0);
111
- const verdict = failed.length > 0 || configError ? 'fail' : 'pass';
146
+ let verdict;
147
+ let exit;
148
+ if (failed.length > 0 || configError) {
149
+ verdict = 'fail';
150
+ exit = ExitCode.Failure;
151
+ }
152
+ else if (!anyEvaluated) {
153
+ verdict = 'not-verified';
154
+ exit = ExitCode.NotVerified;
155
+ }
156
+ else {
157
+ verdict = 'pass';
158
+ exit = ExitCode.VerifiedPass;
159
+ }
112
160
  const skipped = gates.filter((g) => g.status === 'skipped').map((g) => g.name);
161
+ const advisoryWarned = gates.filter((g) => g.advisory && g.warnings > 0).map((g) => g.name);
113
162
  const summary = verdict === 'fail'
114
163
  ? `Not safe to finish: ${configError ? 'config failed to load; ' : ''}${failed.map((g) => `${g.name} (${g.errors} error(s))`).join(', ') || 'see gates'}.`
115
- : changedFiles.length === 0 && input.mode !== 'files'
116
- ? 'No files in the changed scope nothing to gate.'
117
- : `Safe to finish: every applicable gate passed${warnings > 0 ? ` (${warnings} non-blocking warning(s))` : ''}${skipped.length > 0 ? `; skipped: ${skipped.join(', ')}` : ''}.`;
164
+ : verdict === 'not-verified'
165
+ ? `Not verified: no gate evaluated the changed scope${changedFiles.length === 0 ? ' (nothing changed)' : ' (nothing in it is gate-relevant)'} — this is NOT a pass. Re-run over a scope with code changes, or gate explicitly.`
166
+ : `Safe to finish: every applicable gate passed${warnings > 0 ? ` (${warnings} non-blocking warning(s)${advisoryWarned.length > 0 ? ` — see ${advisoryWarned.join(', ')}` : ''})` : ''}${skipped.length > 0 ? `; skipped: ${skipped.join(', ')}` : ''}.`;
118
167
  const nextAction = verdict === 'fail'
119
168
  ? 'Fix every failing gate item (each carries file:line), then re-run `shrk finish`.'
120
- : 'Safe to declare done.';
169
+ : verdict === 'not-verified'
170
+ ? 'Nothing was verified — do not treat this as done.'
171
+ : 'Safe to declare done.';
121
172
  return {
122
173
  schema: FINISH_SCHEMA,
123
174
  scope: { mode: input.mode, files: changedFiles, fileCount: changedFiles.length },
124
175
  gates,
125
176
  impact,
126
177
  verdict,
178
+ exit,
127
179
  warnings,
128
180
  ...(configError ? { configError } : {}),
129
181
  summary,
@@ -133,6 +185,10 @@ export async function runFinishGates(input) {
133
185
  function skip(name, detail) {
134
186
  return { name, status: 'skipped', detail, errors: 0, warnings: 0, items: [] };
135
187
  }
188
+ /** A skipped ADVISORY gate — reports its detail but never decides the verdict. */
189
+ function advisorySkip(name, detail) {
190
+ return { name, status: 'skipped', detail, errors: 0, warnings: 0, items: [], advisory: true };
191
+ }
136
192
  function wiringGate(cwd, rules, changedFiles) {
137
193
  if (rules.length === 0)
138
194
  return skip('wiring', 'no wiring rules configured');
@@ -188,6 +244,126 @@ function policyGate(cwd, rules, changedFiles) {
188
244
  ],
189
245
  };
190
246
  }
247
+ /**
248
+ * The runtime-wiring `unprovided` gate — the silent-at-runtime class imports
249
+ * can't see: a token DECLARED or INJECTED but never PROVIDED (typecheck-green,
250
+ * absent at runtime). Backed by the registration/DI graph, scoped to the
251
+ * changeset so only tokens THIS change touches decide the verdict. Skips (never
252
+ * fails) when no idioms are configured or the change touched no wiring — so a
253
+ * repo that never modeled its DI is not penalized.
254
+ */
255
+ function unprovidedGate(cwd, idioms, changedFiles, baseRef) {
256
+ if (idioms.length === 0)
257
+ return skip('unprovided', 'no registration idioms configured');
258
+ const graph = buildRegistrationGraph(cwd, idioms);
259
+ const diag = graph.diagnostics;
260
+ // Two ways THIS change can leave a token unprovided:
261
+ // (1) a declared/injected site added in a changed file with no provider —
262
+ // caught by scoping the graph query to the changed files;
263
+ // (2) the last PROVIDER removed from a changed file — which leaves NO site in
264
+ // the changed file, so (1) structurally can't see it. Recover it by
265
+ // diffing the base content of the changed files (providerRegressions).
266
+ const scoped = registrationUnprovided(graph, changedFiles);
267
+ const regressions = baseRef ? providerRegressions(cwd, idioms, graph, changedFiles, baseRef) : [];
268
+ const byToken = new Map();
269
+ for (const u of [...scoped, ...regressions])
270
+ if (!byToken.has(u.token))
271
+ byToken.set(u.token, u);
272
+ const unprovided = [...byToken.values()].sort((a, b) => a.token.localeCompare(b.token));
273
+ // Skip (evaluated nothing) only when the change touched no registration site
274
+ // AND removed no provider AND has no misconfigured idiom — never a silent pass.
275
+ if (!registrationTouchesChanged(graph, changedFiles) && unprovided.length === 0 && diag.length === 0) {
276
+ return skip('unprovided', 'no registration sites in the changed scope');
277
+ }
278
+ return {
279
+ name: 'unprovided',
280
+ status: unprovided.length > 0 || diag.length > 0 ? 'fail' : 'pass',
281
+ detail: `${unprovided.length} unprovided token(s) attributable to the change${diag.length > 0 ? `, ${diag.length} misconfigured idiom(s)` : ''}`,
282
+ errors: unprovided.length + diag.length,
283
+ warnings: 0,
284
+ items: [
285
+ ...unprovided.map((u) => {
286
+ const site = u.declared[0] ?? u.consumed[0];
287
+ return {
288
+ ...(site ? { file: site.file, line: site.line } : {}),
289
+ message: `"${u.token}" declared/injected but never provided (silent at runtime)`,
290
+ };
291
+ }),
292
+ ...diag.map((d) => ({ message: `misconfigured idiom: ${d}` })),
293
+ ],
294
+ };
295
+ }
296
+ /**
297
+ * Tokens whose LAST provider this change removed. Reads the base content of each
298
+ * changed code file, extracts what it USED to provide, and keeps any token now
299
+ * provided nowhere in the worktree but still declared/consumed — the runtime
300
+ * break (`inject(T)` → undefined) that deleting a provider registration causes,
301
+ * which leaves no site in the changed file for post-change scoping to catch.
302
+ */
303
+ function providerRegressions(cwd, idioms, graph, changedFiles, baseRef) {
304
+ const codeChanged = codeFilesOf(changedFiles);
305
+ if (codeChanged.length === 0)
306
+ return [];
307
+ const baseEntries = [];
308
+ for (const f of codeChanged) {
309
+ const content = gitShowFile(cwd, baseRef, f);
310
+ if (content !== null)
311
+ baseEntries.push({ path: f, content });
312
+ }
313
+ if (baseEntries.length === 0)
314
+ return [];
315
+ const wasProvided = providedTokensFromEntries(idioms, baseEntries);
316
+ const out = [];
317
+ for (const token of wasProvided) {
318
+ const node = graph.tokens.find((t) => t.token === token);
319
+ if (node && node.provided.length === 0 && (node.declared.length > 0 || node.consumed.length > 0)) {
320
+ out.push({ token, declared: node.declared, consumed: node.consumed });
321
+ }
322
+ }
323
+ return out;
324
+ }
325
+ /**
326
+ * Advisory architecture gate: does any changed file participate in a runtime
327
+ * import cycle? Distinct from the `boundaries` gate (which sees layer violations,
328
+ * not cycles). Deliberately ADVISORY — a pre-existing cycle a change merely
329
+ * touches must not be attributed to this changeset, so it reports as a
330
+ * non-blocking warning and never fails the composite. Type-only import edges are
331
+ * excluded (they erase at emit and can't cause a runtime cycle). Best-effort:
332
+ * a missing graph index degrades to skip, never fail.
333
+ */
334
+ async function archGate(cwd, changedFiles) {
335
+ const codeChanged = codeFilesOf(changedFiles);
336
+ if (codeChanged.length === 0)
337
+ return advisorySkip('arch', 'no code files in changed scope');
338
+ try {
339
+ const { GraphStore, GraphQueryApi } = await import('@shrkcrft/graph');
340
+ if (!new GraphStore(cwd).exists()) {
341
+ return advisorySkip('arch', 'code-graph index missing — run `shrk graph index`');
342
+ }
343
+ const api = GraphQueryApi.fromStore(cwd);
344
+ const cycles = api.cycles(); // runtime cycles only (type-only edges excluded)
345
+ const scope = new Set(codeChanged.map((f) => f.replace(/\\/g, '/').replace(/^\.\//, '')));
346
+ const touching = cycles.filter((c) => (c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, ''))).some((p) => scope.has(p)));
347
+ if (touching.length === 0) {
348
+ return advisorySkip('arch', `no changed file participates in an import cycle (${cycles.length} runtime cycle(s) in repo)`);
349
+ }
350
+ return {
351
+ name: 'arch',
352
+ status: 'pass', // advisory: reports cycles as warnings, never fails the composite
353
+ advisory: true,
354
+ detail: `${touching.length} changed file(s) participate in a runtime import cycle (advisory — not attributed to this change)`,
355
+ errors: 0,
356
+ warnings: touching.length,
357
+ items: touching.slice(0, 15).map((c) => {
358
+ const paths = c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, ''));
359
+ return { message: `import cycle (size ${c.size}): ${paths.join(' → ')}` };
360
+ }),
361
+ };
362
+ }
363
+ catch (e) {
364
+ return advisorySkip('arch', `arch check unavailable: ${e instanceof Error ? e.message : String(e)}`);
365
+ }
366
+ }
191
367
  async function orphansGate(input) {
192
368
  const opts = orphanOptsFor(input);
193
369
  if (!opts)
@@ -0,0 +1,35 @@
1
+ import type { IBaselineRule, IGeneratedArtifactRule, IPolicyRule, IRegistrationIdiom, IRegistryDeclaration, IRuleSelfTest, IWiringRule } from '@shrkcrft/core';
2
+ /** Which data-defined plane a rule belongs to. */
3
+ export type GatePlane = 'wiring' | 'policy' | 'registry' | 'registration' | 'baseline' | 'generated';
4
+ /** Every plane, in the order `shrk gates list` prints them. */
5
+ export declare const GATE_PLANES: readonly GatePlane[];
6
+ /**
7
+ * One data-defined rule, normalized across planes.
8
+ *
9
+ * The trust layer's whole job is to answer "what did this rule actually match?"
10
+ * for ANY rule, so it needs one shape to iterate. The plane-specific rule object
11
+ * rides along in `raw` for the explain dispatch.
12
+ */
13
+ export interface IGateRuleView {
14
+ readonly id: string;
15
+ readonly plane: GatePlane;
16
+ readonly description?: string;
17
+ readonly severity: 'error' | 'warning';
18
+ /** True when a zero-match is a hard failure rather than a loud skip. */
19
+ readonly failOnEmpty: boolean;
20
+ readonly selfTest?: IRuleSelfTest;
21
+ /** The underlying rule, for the plane-specific explainer. */
22
+ readonly raw: IWiringRule | IPolicyRule | IRegistryDeclaration | IRegistrationIdiom | IBaselineRule | IGeneratedArtifactRule;
23
+ }
24
+ /** The config planes this view is built from. */
25
+ export interface IGatePlanes {
26
+ readonly wiringRules?: readonly IWiringRule[];
27
+ readonly policyRules?: readonly IPolicyRule[];
28
+ readonly registries?: readonly IRegistryDeclaration[];
29
+ readonly registrationGraph?: readonly IRegistrationIdiom[];
30
+ readonly baselines?: readonly IBaselineRule[];
31
+ readonly generatedArtifacts?: readonly IGeneratedArtifactRule[];
32
+ }
33
+ /** Flatten every declared rule across every plane into one iterable list. */
34
+ export declare function collectGateRules(planes: IGatePlanes): IGateRuleView[];
35
+ //# sourceMappingURL=gate-rule-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate-rule-view.d.ts","sourceRoot":"","sources":["../../src/gates/gate-rule-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,aAAa,EACb,sBAAsB,EACtB,WAAW,EACX,kBAAkB,EAClB,oBAAoB,EACpB,aAAa,EACb,WAAW,EACZ,MAAM,gBAAgB,CAAC;AAExB,kDAAkD;AAClD,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,cAAc,GAAG,UAAU,GAAG,WAAW,CAAC;AAErG,+DAA+D;AAC/D,eAAO,MAAM,WAAW,EAAE,SAAS,SAAS,EAO3C,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,wEAAwE;IACxE,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;IAClC,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,EACR,WAAW,GACX,WAAW,GACX,oBAAoB,GACpB,kBAAkB,GAClB,aAAa,GACb,sBAAsB,CAAC;CAC5B;AAED,iDAAiD;AACjD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAC9C,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,oBAAoB,EAAE,CAAC;IACtD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAC;IAC3D,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAC9C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,SAAS,sBAAsB,EAAE,CAAC;CACjE;AAED,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,aAAa,EAAE,CAqErE"}
@@ -0,0 +1,80 @@
1
+ /** Every plane, in the order `shrk gates list` prints them. */
2
+ export const GATE_PLANES = [
3
+ 'wiring',
4
+ 'policy',
5
+ 'registry',
6
+ 'registration',
7
+ 'baseline',
8
+ 'generated',
9
+ ];
10
+ /** Flatten every declared rule across every plane into one iterable list. */
11
+ export function collectGateRules(planes) {
12
+ const out = [];
13
+ for (const r of planes.wiringRules ?? []) {
14
+ out.push({
15
+ id: r.id,
16
+ plane: 'wiring',
17
+ ...(r.description ? { description: r.description } : {}),
18
+ severity: r.severity ?? 'error',
19
+ failOnEmpty: r.failOnEmpty === true,
20
+ ...(r.selfTest ? { selfTest: r.selfTest } : {}),
21
+ raw: r,
22
+ });
23
+ }
24
+ for (const r of planes.policyRules ?? []) {
25
+ out.push({
26
+ id: r.id,
27
+ plane: 'policy',
28
+ ...(r.description ? { description: r.description } : {}),
29
+ severity: r.severity ?? 'error',
30
+ failOnEmpty: r.failOnEmpty === true,
31
+ ...(r.selfTest ? { selfTest: r.selfTest } : {}),
32
+ raw: r,
33
+ });
34
+ }
35
+ for (const r of planes.registries ?? []) {
36
+ out.push({
37
+ id: r.name,
38
+ plane: 'registry',
39
+ ...(r.description ? { description: r.description } : {}),
40
+ // A registry is an inventory, not a gate — it never fails a build on its
41
+ // own, so it carries no severity of its own.
42
+ severity: 'warning',
43
+ failOnEmpty: false,
44
+ raw: r,
45
+ });
46
+ }
47
+ for (const r of planes.registrationGraph ?? []) {
48
+ out.push({
49
+ id: r.name,
50
+ plane: 'registration',
51
+ ...(r.description ? { description: r.description } : {}),
52
+ severity: 'warning',
53
+ failOnEmpty: false,
54
+ raw: r,
55
+ });
56
+ }
57
+ for (const r of planes.baselines ?? []) {
58
+ out.push({
59
+ id: r.id,
60
+ plane: 'baseline',
61
+ ...(r.description ? { description: r.description } : {}),
62
+ severity: r.severity ?? 'error',
63
+ failOnEmpty: r.failOnEmpty === true,
64
+ ...(r.selfTest ? { selfTest: r.selfTest } : {}),
65
+ raw: r,
66
+ });
67
+ }
68
+ for (const r of planes.generatedArtifacts ?? []) {
69
+ out.push({
70
+ id: r.id,
71
+ plane: 'generated',
72
+ ...(r.description ? { description: r.description } : {}),
73
+ severity: r.severity ?? 'error',
74
+ failOnEmpty: r.failOnEmpty === true,
75
+ ...(r.selfTest ? { selfTest: r.selfTest } : {}),
76
+ raw: r,
77
+ });
78
+ }
79
+ return out;
80
+ }
@@ -0,0 +1,53 @@
1
+ import type { IGateRuleView } from './gate-rule-view.js';
2
+ export declare const GATE_COVERAGE_SCHEMA: "sharkcraft.gate-coverage/v1";
3
+ /**
4
+ * Whether a rule is connected to anything at all.
5
+ *
6
+ * `empty` is the finding this whole surface exists for: a stale selector that
7
+ * matches nothing passes every gate forever, so the ONLY way to notice is to
8
+ * report the match count itself and flag zero. `failed-expectation` is the
9
+ * stronger form — the author wrote down what the rule should match, and it
10
+ * doesn't.
11
+ */
12
+ export type GateCoverageStatus = 'ok' | 'empty' | 'error' | 'failed-expectation';
13
+ export interface IGateCoverage {
14
+ readonly id: string;
15
+ readonly plane: IGateRuleView['plane'];
16
+ readonly description?: string;
17
+ readonly status: GateCoverageStatus;
18
+ /** Files the rule's primary selector matched. */
19
+ readonly filesMatched: number;
20
+ /** Ids/units the rule extracted (findings scanned, for the policy plane). */
21
+ readonly unitsMatched: number;
22
+ /** What "units" means for this plane, for honest reporting. */
23
+ readonly unitLabel: string;
24
+ /** A few of the extracted ids, so the author can eyeball correctness. */
25
+ readonly sampleIds: readonly string[];
26
+ /** True when a zero match is a hard failure for this rule. */
27
+ readonly failOnEmpty: boolean;
28
+ readonly error?: string;
29
+ /** Unmet `selfTest` expectations, each a human-readable sentence. */
30
+ readonly expectationFailures: readonly string[];
31
+ }
32
+ export interface IGateCoverageReport {
33
+ readonly schema: typeof GATE_COVERAGE_SCHEMA;
34
+ readonly rules: readonly IGateCoverage[];
35
+ readonly total: number;
36
+ readonly empty: number;
37
+ readonly errored: number;
38
+ readonly expectationFailures: number;
39
+ /**
40
+ * `pass` — every rule matched something and met its expectations.
41
+ * `stale` — at least one rule matched nothing (or broke an expectation).
42
+ */
43
+ readonly verdict: 'pass' | 'stale';
44
+ }
45
+ /**
46
+ * Resolve every declared rule against the live tree and report what it matched.
47
+ *
48
+ * The `command`-compute baseline is the one rule kind that cannot be inspected
49
+ * without side effects; it is reported as un-inspected (never as `empty`), so
50
+ * the report never claims a fact it did not check.
51
+ */
52
+ export declare function buildGateCoverage(cwd: string, rules: readonly IGateRuleView[], excludeDirs?: readonly string[]): IGateCoverageReport;
53
+ //# sourceMappingURL=rule-coverage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rule-coverage.d.ts","sourceRoot":"","sources":["../../src/gates/rule-coverage.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,eAAO,MAAM,oBAAoB,EAAG,6BAAsC,CAAC;AAE3E;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,OAAO,GAAG,OAAO,GAAG,oBAAoB,CAAC;AAEjF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC;IACvC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,iDAAiD;IACjD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,+DAA+D;IAC/D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,yEAAyE;IACzE,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,8DAA8D;IAC9D,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,QAAQ,CAAC,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAC;CACjD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,OAAO,oBAAoB,CAAC;IAC7C,QAAQ,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,CAAC;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;CACpC;AAsHD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,SAAS,aAAa,EAAE,EAC/B,WAAW,GAAE,SAAS,MAAM,EAAO,GAClC,mBAAmB,CA4DrB"}
@@ -0,0 +1,165 @@
1
+ import { inspectSource, runPolicyLint, scanGeneratedFiles, wiringSourceSide, } from '@shrkcrft/boundaries';
2
+ export const GATE_COVERAGE_SCHEMA = 'sharkcraft.gate-coverage/v1';
3
+ /** Evaluate the author's declared expectations against what the rule extracted. */
4
+ function checkExpectations(selfTest, ids, unitsMatched) {
5
+ if (!selfTest)
6
+ return [];
7
+ const out = [];
8
+ if (selfTest.expectMatchesAtLeast !== undefined &&
9
+ unitsMatched < selfTest.expectMatchesAtLeast) {
10
+ out.push(`expected at least ${selfTest.expectMatchesAtLeast} match(es), got ${unitsMatched}`);
11
+ }
12
+ const present = new Set(ids);
13
+ for (const id of selfTest.expectIds ?? []) {
14
+ if (!present.has(id))
15
+ out.push(`expected id "${id}" was NOT extracted`);
16
+ }
17
+ for (const id of selfTest.expectNotIds ?? []) {
18
+ if (present.has(id))
19
+ out.push(`id "${id}" was extracted but is listed in expectNotIds`);
20
+ }
21
+ return out;
22
+ }
23
+ function matchWiring(cwd, rule, excludeDirs) {
24
+ const side = wiringSourceSide(rule);
25
+ if (!side)
26
+ return { filesMatched: 0, unitsMatched: 0, unitLabel: 'ids', ids: [], error: 'rule sets no source side' };
27
+ const insp = inspectSource(cwd, side, excludeDirs);
28
+ return {
29
+ filesMatched: insp.filesScanned,
30
+ unitsMatched: insp.ids.length,
31
+ unitLabel: 'ids',
32
+ ids: insp.ids,
33
+ ...(insp.error ? { error: insp.error } : {}),
34
+ };
35
+ }
36
+ function matchPolicy(cwd, rule, excludeDirs) {
37
+ const report = runPolicyLint(cwd, [rule], { excludeDirs });
38
+ const result = report.rules[0];
39
+ return {
40
+ // A policy rule's "files" and "units" differ only for inline templates.
41
+ filesMatched: result?.unitsScanned ?? 0,
42
+ unitsMatched: result?.unitsScanned ?? 0,
43
+ unitLabel: 'content units',
44
+ ids: [],
45
+ ...(result?.error ? { error: result.error } : {}),
46
+ };
47
+ }
48
+ function matchRegistry(cwd, decl, excludeDirs) {
49
+ const insp = inspectSource(cwd, decl.source, excludeDirs);
50
+ return {
51
+ filesMatched: insp.filesScanned,
52
+ unitsMatched: insp.ids.length,
53
+ unitLabel: 'ids',
54
+ ids: insp.ids,
55
+ ...(insp.error ? { error: insp.error } : {}),
56
+ };
57
+ }
58
+ function matchRegistration(cwd, idiom, excludeDirs) {
59
+ const insp = inspectSource(cwd, idiom.declared, excludeDirs);
60
+ return {
61
+ filesMatched: insp.filesScanned,
62
+ unitsMatched: insp.ids.length,
63
+ unitLabel: 'declared tokens',
64
+ ids: insp.ids,
65
+ ...(insp.error ? { error: insp.error } : {}),
66
+ };
67
+ }
68
+ function matchBaseline(cwd, rule, excludeDirs) {
69
+ // Only the EXTRACTOR half can be inspected without spawning. A command
70
+ // compute is reported honestly as un-inspectable rather than guessed at —
71
+ // `shrk baseline explain --id X` runs it on purpose.
72
+ if (rule.compute.kind !== 'extractor' || !rule.compute.source) {
73
+ return {
74
+ filesMatched: 0,
75
+ unitsMatched: 0,
76
+ unitLabel: 'entries (command compute — not inspected)',
77
+ ids: [],
78
+ };
79
+ }
80
+ const insp = inspectSource(cwd, rule.compute.source, excludeDirs);
81
+ return {
82
+ filesMatched: insp.filesScanned,
83
+ unitsMatched: insp.ids.length,
84
+ unitLabel: 'entries',
85
+ ids: insp.ids,
86
+ ...(insp.error ? { error: insp.error } : {}),
87
+ };
88
+ }
89
+ function matchGenerated(cwd, rule, excludeDirs) {
90
+ const scan = scanGeneratedFiles(cwd, rule, excludeDirs);
91
+ const files = [...scan.generated.keys()];
92
+ return {
93
+ filesMatched: files.length,
94
+ unitsMatched: files.length,
95
+ unitLabel: 'generated files',
96
+ ids: files,
97
+ };
98
+ }
99
+ /**
100
+ * Resolve every declared rule against the live tree and report what it matched.
101
+ *
102
+ * The `command`-compute baseline is the one rule kind that cannot be inspected
103
+ * without side effects; it is reported as un-inspected (never as `empty`), so
104
+ * the report never claims a fact it did not check.
105
+ */
106
+ export function buildGateCoverage(cwd, rules, excludeDirs = []) {
107
+ const out = [];
108
+ for (const view of rules) {
109
+ let match;
110
+ switch (view.plane) {
111
+ case 'wiring':
112
+ match = matchWiring(cwd, view.raw, excludeDirs);
113
+ break;
114
+ case 'policy':
115
+ match = matchPolicy(cwd, view.raw, excludeDirs);
116
+ break;
117
+ case 'registry':
118
+ match = matchRegistry(cwd, view.raw, excludeDirs);
119
+ break;
120
+ case 'registration':
121
+ match = matchRegistration(cwd, view.raw, excludeDirs);
122
+ break;
123
+ case 'baseline':
124
+ match = matchBaseline(cwd, view.raw, excludeDirs);
125
+ break;
126
+ default:
127
+ match = matchGenerated(cwd, view.raw, excludeDirs);
128
+ break;
129
+ }
130
+ const expectationFailures = checkExpectations(view.selfTest, match.ids, match.unitsMatched);
131
+ const uninspected = match.unitLabel.includes('not inspected');
132
+ const status = match.error !== undefined
133
+ ? 'error'
134
+ : expectationFailures.length > 0
135
+ ? 'failed-expectation'
136
+ : match.unitsMatched === 0 && !uninspected
137
+ ? 'empty'
138
+ : 'ok';
139
+ out.push({
140
+ id: view.id,
141
+ plane: view.plane,
142
+ ...(view.description ? { description: view.description } : {}),
143
+ status,
144
+ filesMatched: match.filesMatched,
145
+ unitsMatched: match.unitsMatched,
146
+ unitLabel: match.unitLabel,
147
+ sampleIds: match.ids.slice(0, 5),
148
+ failOnEmpty: view.failOnEmpty,
149
+ ...(match.error ? { error: match.error } : {}),
150
+ expectationFailures,
151
+ });
152
+ }
153
+ const empty = out.filter((r) => r.status === 'empty').length;
154
+ const errored = out.filter((r) => r.status === 'error').length;
155
+ const expectationFailures = out.filter((r) => r.status === 'failed-expectation').length;
156
+ return {
157
+ schema: GATE_COVERAGE_SCHEMA,
158
+ rules: out,
159
+ total: out.length,
160
+ empty,
161
+ errored,
162
+ expectationFailures,
163
+ verdict: empty + errored + expectationFailures > 0 ? 'stale' : 'pass',
164
+ };
165
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,EACL,eAAe,EAKhB,MAAM,uBAAuB,CAAC;AA8X/B,wBAAgB,aAAa,IAAI,eAAe,CAiY/C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAiCrE;AAqID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAoDxE"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,EACL,eAAe,EAMhB,MAAM,uBAAuB,CAAC;AAoZ/B,wBAAgB,aAAa,IAAI,eAAe,CAqZ/C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA+CrE;AAwID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAoDxE"}