@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
@@ -458,32 +458,16 @@ export const contradictionsCommand = {
458
458
  return 0;
459
459
  },
460
460
  };
461
- export const generatedCommand = {
462
- name: 'generated',
463
- description: 'Generated-code classifier. Subcommands: `report` (default), `protect --write-drafts`. Read-only by default.',
464
- usage: 'shrk [--cwd <dir>] generated [report|protect] [--format text|markdown|json] [--write-drafts]',
461
+ /** Classify the tree's generated code (the `report` half of `shrk generated`). */
462
+ export const generatedReportCommand = {
463
+ name: 'report',
464
+ description: 'Classify which parts of the tree are generated code. Read-only.',
465
+ usage: 'shrk [--cwd <dir>] generated report [--format text|markdown|json]',
465
466
  async run(args) {
466
- const sub = args.positional[0] === 'protect' ? 'protect' : 'report';
467
- const rest = { ...args, positional: args.positional.slice(args.positional[0] === 'report' || args.positional[0] === 'protect' ? 1 : 0) };
468
- const cwd = resolveCwd(rest);
469
- const format = parseFormat(flagString(rest, 'format'));
467
+ const cwd = resolveCwd(args);
468
+ const format = parseFormat(flagString(args, 'format'));
470
469
  const inspection = await inspectSharkcraft({ cwd });
471
470
  const report = buildGeneratedCodeReport({ inspection });
472
- if (sub === 'protect') {
473
- const writeDrafts = flagBool(rest, 'write-drafts');
474
- const outDir = nodePath.join(cwd, INGEST_BASE);
475
- if (writeDrafts) {
476
- if (!existsSync(outDir))
477
- mkdirSync(outDir, { recursive: true });
478
- const target = nodePath.join(outDir, 'GENERATED_PROTECT.md');
479
- writeFileSync(target, renderGeneratedCodeReportMarkdown(report), 'utf8');
480
- process.stdout.write(`Wrote ${target}\n`);
481
- return 0;
482
- }
483
- process.stdout.write(renderGeneratedCodeReportMarkdown(report) + '\n');
484
- process.stdout.write('\nRe-run with --write-drafts to save the recommended protect rules under sharkcraft/ingestion/.\n');
485
- return 0;
486
- }
487
471
  if (format === 'json')
488
472
  process.stdout.write(renderGeneratedCodeReportJson(report) + '\n');
489
473
  else if (format === 'markdown')
@@ -493,6 +477,48 @@ export const generatedCommand = {
493
477
  return 0;
494
478
  },
495
479
  };
480
+ /** Recommend protect rules for the classified generated roots. */
481
+ export const generatedProtectCommand = {
482
+ name: 'protect',
483
+ description: 'Recommend protect rules for the detected generated roots. Read-only unless --write-drafts (writes under sharkcraft/ingestion/).',
484
+ usage: 'shrk [--cwd <dir>] generated protect [--write-drafts]',
485
+ booleanFlags: new Set(['write-drafts']),
486
+ async run(args) {
487
+ const cwd = resolveCwd(args);
488
+ const inspection = await inspectSharkcraft({ cwd });
489
+ const report = buildGeneratedCodeReport({ inspection });
490
+ if (flagBool(args, 'write-drafts')) {
491
+ const outDir = nodePath.join(cwd, INGEST_BASE);
492
+ if (!existsSync(outDir))
493
+ mkdirSync(outDir, { recursive: true });
494
+ const target = nodePath.join(outDir, 'GENERATED_PROTECT.md');
495
+ writeFileSync(target, renderGeneratedCodeReportMarkdown(report), 'utf8');
496
+ process.stdout.write(`Wrote ${target}\n`);
497
+ return 0;
498
+ }
499
+ process.stdout.write(renderGeneratedCodeReportMarkdown(report) + '\n');
500
+ process.stdout.write('\nRe-run with --write-drafts to save the recommended protect rules under sharkcraft/ingestion/.\n');
501
+ return 0;
502
+ },
503
+ };
504
+ /**
505
+ * The `generated` group. The bare verb keeps its historical behaviour (the
506
+ * classifier report); `report` / `protect` are the classifier subverbs, and
507
+ * `list` / `check` / `update` / `explain` are the drift GATE (registered from
508
+ * `generated.command.ts`). One noun: that half FINDS what is generated, this
509
+ * half proves it has not drifted.
510
+ */
511
+ export const generatedCommand = {
512
+ name: 'generated',
513
+ description: 'Generated-code classifier (`report` default, `protect --write-drafts`) + the generated-artifact drift GATE (`list | check | update | explain`, see docs/generated-drift.md). Read-only by default.',
514
+ usage: 'shrk [--cwd <dir>] generated [report|protect] [--format text|markdown|json] [--write-drafts]\n (drift gate: shrk generated list | check [--headers-only] | update | explain --id <id>)',
515
+ booleanFlags: new Set(['write-drafts']),
516
+ async run(args) {
517
+ // Bare `shrk generated` (and any stray positional) → the classifier report,
518
+ // exactly as before the gate verbs joined the group.
519
+ return generatedReportCommand.run({ ...args, positional: [] });
520
+ },
521
+ };
496
522
  export const stabilityCommand = {
497
523
  name: 'stability',
498
524
  description: 'Stability classification (stable/experimental/deprecated/legacy/generated/internal/public-api/high-risk). Read-only.',
@@ -1,3 +1,40 @@
1
+ import type { IPolicyRule, PolicySurface } from '@shrkcrft/core';
2
+ import { type IPolicyFinding, type IPolicySuppression } from '@shrkcrft/boundaries';
1
3
  import { type ICommandHandler } from '../command-registry.js';
4
+ export declare const POLICY_EXPLAIN_SCHEMA: "sharkcraft.policy-explain/v1";
5
+ /** What ONE policy rule resolved to against the live tree. */
6
+ export interface IPolicyExplain {
7
+ readonly schema: typeof POLICY_EXPLAIN_SCHEMA;
8
+ readonly ruleId: string;
9
+ readonly description?: string;
10
+ readonly surface: PolicySurface;
11
+ readonly severity: 'error' | 'warning';
12
+ readonly pattern: string;
13
+ readonly scan: string;
14
+ /** Content units the rule scanned (files, or inline-template bodies). */
15
+ readonly unitsScanned: number;
16
+ readonly status: string;
17
+ /** Every hit that COUNTED, with file:line. */
18
+ readonly findings: readonly IPolicyFinding[];
19
+ /** Every hit an exemption or the scan zone dropped, and which one applied. */
20
+ readonly suppressed: readonly IPolicySuppression[];
21
+ readonly exemptFiles: readonly string[];
22
+ readonly exemptLines?: string;
23
+ readonly diagnostics: readonly string[];
24
+ /** Why the rule scanned nothing, when it did. */
25
+ readonly skipReason?: string;
26
+ }
27
+ /**
28
+ * Dry-run ONE policy rule and return everything it saw — including the hits an
29
+ * exemption swallowed.
30
+ *
31
+ * Showing suppressed hits is the point: an exemption that silently deletes a
32
+ * finding is indistinguishable from a stale glob, so both the kept and the
33
+ * dropped hits are reported, each labelled with the exemption that applied.
34
+ */
35
+ export declare function runPolicyExplain(cwd: string, rule: IPolicyRule, excludeDirs: readonly string[]): IPolicyExplain;
36
+ /** Render an {@link IPolicyExplain}. Always returns 0 — explain is informational. */
37
+ export declare function renderPolicyExplain(explain: IPolicyExplain, wantJson: boolean): number;
38
+ export declare const policyLintExplainCommand: ICommandHandler;
2
39
  export declare const policyLintCommand: ICommandHandler;
3
40
  //# sourceMappingURL=policy-lint.command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"policy-lint.command.d.ts","sourceRoot":"","sources":["../../src/commands/policy-lint.command.ts"],"names":[],"mappings":"AAIA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAKhC,eAAO,MAAM,iBAAiB,EAAE,eA6L/B,CAAC"}
1
+ {"version":3,"file":"policy-lint.command.d.ts","sourceRoot":"","sources":["../../src/commands/policy-lint.command.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAEL,KAAK,cAAc,EAEnB,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAMhC,eAAO,MAAM,qBAAqB,EAAG,8BAAuC,CAAC;AAE7E,8DAA8D;AAC9D,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,qBAAqB,CAAC;IAC9C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAC;IAC7C,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACnD,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,iDAAiD;IACjD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,WAAW,EACjB,WAAW,EAAE,SAAS,MAAM,EAAE,GAC7B,cAAc,CAqBhB;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,CA0CtF;AAGD,eAAO,MAAM,wBAAwB,EAAE,eA8BtC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,eAmN/B,CAAC"}
@@ -1,13 +1,115 @@
1
1
  import * as nodePath from 'node:path';
2
- import { runPolicyLint } from '@shrkcrft/boundaries';
2
+ import { runPolicyLint, } from '@shrkcrft/boundaries';
3
3
  import { classifyChangedScope, resolveChangedFiles, resolveProjectConfig } from '@shrkcrft/inspector';
4
4
  import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
5
5
  import { asJson, header, kv } from "../output/format-output.js";
6
6
  const VALID_SURFACES = new Set(['template', 'style', 'ts']);
7
+ export const POLICY_EXPLAIN_SCHEMA = 'sharkcraft.policy-explain/v1';
8
+ /**
9
+ * Dry-run ONE policy rule and return everything it saw — including the hits an
10
+ * exemption swallowed.
11
+ *
12
+ * Showing suppressed hits is the point: an exemption that silently deletes a
13
+ * finding is indistinguishable from a stale glob, so both the kept and the
14
+ * dropped hits are reported, each labelled with the exemption that applied.
15
+ */
16
+ export function runPolicyExplain(cwd, rule, excludeDirs) {
17
+ const report = runPolicyLint(cwd, [rule], { excludeDirs });
18
+ const result = report.rules[0];
19
+ const skip = report.skipped[0];
20
+ return {
21
+ schema: POLICY_EXPLAIN_SCHEMA,
22
+ ruleId: rule.id,
23
+ ...(rule.description ? { description: rule.description } : {}),
24
+ surface: rule.surface,
25
+ severity: rule.severity ?? 'error',
26
+ pattern: rule.pattern,
27
+ scan: rule.scan ?? 'all',
28
+ unitsScanned: result?.unitsScanned ?? 0,
29
+ status: result?.status ?? 'error',
30
+ findings: report.findings,
31
+ suppressed: report.suppressed,
32
+ exemptFiles: rule.exemptFiles ?? [],
33
+ ...(rule.exemptLines ? { exemptLines: rule.exemptLines } : {}),
34
+ diagnostics: report.diagnostics,
35
+ ...(skip ? { skipReason: skip.reason } : {}),
36
+ };
37
+ }
38
+ /** Render an {@link IPolicyExplain}. Always returns 0 — explain is informational. */
39
+ export function renderPolicyExplain(explain, wantJson) {
40
+ if (wantJson) {
41
+ process.stdout.write(asJson(explain) + '\n');
42
+ return 0;
43
+ }
44
+ process.stdout.write(header(`Policy explain: ${explain.ruleId} (${explain.surface})`));
45
+ if (explain.description)
46
+ process.stdout.write(` ${explain.description}\n`);
47
+ process.stdout.write(kv('pattern', `/${explain.pattern}/`) + '\n');
48
+ process.stdout.write(kv('scan zone', explain.scan) + '\n');
49
+ process.stdout.write(kv('units scanned', String(explain.unitsScanned)) + '\n');
50
+ process.stdout.write(kv('status', explain.status) + '\n');
51
+ if (explain.exemptFiles.length > 0) {
52
+ process.stdout.write(kv('exemptFiles', explain.exemptFiles.join(', ')) + '\n');
53
+ }
54
+ if (explain.exemptLines)
55
+ process.stdout.write(kv('exemptLines', explain.exemptLines) + '\n');
56
+ process.stdout.write(`\nHits that COUNT (${explain.findings.length}):\n`);
57
+ for (const f of explain.findings.slice(0, 60)) {
58
+ process.stdout.write(` ✗ ${f.match} (${f.file}:${f.line})${f.inlineTemplate ? ' [inline template]' : ''}\n`);
59
+ }
60
+ if (explain.findings.length > 60) {
61
+ process.stdout.write(` … (${explain.findings.length - 60} more)\n`);
62
+ }
63
+ if (explain.suppressed.length > 0) {
64
+ process.stdout.write(`\nHits an exemption DROPPED (${explain.suppressed.length}):\n`);
65
+ for (const s of explain.suppressed.slice(0, 60)) {
66
+ process.stdout.write(` – ${s.match} (${s.file}:${s.line}) via ${s.via}\n`);
67
+ }
68
+ if (explain.suppressed.length > 60) {
69
+ process.stdout.write(` … (${explain.suppressed.length - 60} more)\n`);
70
+ }
71
+ }
72
+ if (explain.skipReason) {
73
+ process.stdout.write(`\n! SKIPPED — ${explain.skipReason}. A rule that scans nothing is a bug in the rule,\n` +
74
+ ' not a pass. Fix the glob, or set `failOnEmpty: true` to make this a hard failure.\n');
75
+ }
76
+ for (const d of explain.diagnostics)
77
+ process.stdout.write(` ! ${d}\n`);
78
+ return 0;
79
+ }
80
+ export const policyLintExplainCommand = {
81
+ name: 'explain',
82
+ description: 'Dry-run ONE policyRule and print every hit with file:line — INCLUDING the hits an exemption or the scan zone dropped, each labelled with which one applied.',
83
+ usage: 'shrk policy-lint explain <ruleId> [--json]',
84
+ booleanFlags: new Set(['json']),
85
+ async run(args) {
86
+ const id = args.positional[0] ?? flagString(args, 'id');
87
+ if (!id) {
88
+ process.stderr.write('Usage: shrk policy-lint explain <ruleId> [--json]\n');
89
+ return 2;
90
+ }
91
+ const cwd = resolveCwd(args);
92
+ const loaded = await resolveProjectConfig(cwd);
93
+ if (!loaded.ok) {
94
+ process.stderr.write(`Could not load config: ${loaded.error.message}\n`);
95
+ return 2;
96
+ }
97
+ const rules = loaded.value.config.policyRules ?? [];
98
+ const rule = rules.find((r) => r.id === id);
99
+ if (!rule) {
100
+ process.stderr.write(`No policy rule "${id}". Configured: ${rules.map((r) => r.id).join(', ') || '(none)'}\n`);
101
+ return 2;
102
+ }
103
+ const rel = nodePath.relative(cwd, loaded.value.sharkcraftDir).split(nodePath.sep).join('/');
104
+ const excludeDirs = rel && !rel.startsWith('..') ? [rel] : [];
105
+ return renderPolicyExplain(runPolicyExplain(cwd, rule, excludeDirs), flagBool(args, 'json'));
106
+ },
107
+ };
7
108
  export const policyLintCommand = {
8
109
  name: 'policy-lint',
9
110
  description: 'Lint template/markup, stylesheet, and AOT-invisible TS surfaces against data-defined policyRules[] (e.g. flag raw markup when a primitive exists). Sees `.html` files AND inline `template:` strings — surfaces tsc/AOT cannot. Deterministic; no AI.',
10
111
  usage: 'shrk [--cwd <dir>] policy-lint [--surface template|style|ts] [--changed-only] [--new-only] [--since <ref>] [--only <ids>] [--json]\n (--changed-only SCANS just the changed files; --new-only scans the whole tree but shows only findings the change introduced, hiding pre-existing baseline debt)',
112
+ booleanFlags: new Set(['json', 'changed-only', 'new-only']),
11
113
  async run(args) {
12
114
  const cwd = resolveCwd(args);
13
115
  const wantJson = flagBool(args, 'json');
@@ -126,12 +228,23 @@ export const policyLintCommand = {
126
228
  if (report.evaluated === 0) {
127
229
  process.stdout.write(` ! Nothing evaluated — ${report.rules.length} rule(s) configured but none matched files in scope` +
128
230
  (changedOnly || since ? ' (changed-only).\n' : '.\n'));
129
- return 0;
231
+ for (const sk of report.skipped) {
232
+ process.stdout.write(` – ${sk.ruleId}: ${sk.reason}${sk.failed ? ' (failOnEmpty → FAILED)' : ''}\n`);
233
+ }
234
+ // Scanning nothing is not a pass. `2` = not verified (the repo-wide
235
+ // contract); a `failOnEmpty` rule promotes it to a real failure.
236
+ return report.skipped.some((sk) => sk.failed) ? 1 : 2;
130
237
  }
131
238
  process.stdout.write(kv('rules evaluated', `${report.evaluated} of ${report.rules.length}`) + '\n');
132
239
  const errors = report.findings.filter((f) => f.severity === 'error').length;
133
240
  const warnings = report.findings.filter((f) => f.severity === 'warning').length;
134
241
  process.stdout.write(kv('findings', `${errors} error(s), ${warnings} warning(s)`) + '\n');
242
+ if (report.suppressed.length > 0) {
243
+ process.stdout.write(kv('suppressed', `${report.suppressed.length} hit(s) dropped by an exemption — see \`policy-lint explain <id>\``) + '\n');
244
+ }
245
+ for (const sk of report.skipped) {
246
+ process.stdout.write(` ${sk.failed ? '✗' : '–'} ${sk.ruleId} ${sk.failed ? 'FAILED' : 'SKIPPED'} — ${sk.reason}\n`);
247
+ }
135
248
  if (newOnly) {
136
249
  process.stdout.write(kv('scope', `new-only (${hiddenBaseline} pre-existing finding(s) hidden — run without --new-only to see all)`) + '\n');
137
250
  }
@@ -140,6 +253,10 @@ export const policyLintCommand = {
140
253
  for (const d of report.diagnostics)
141
254
  process.stdout.write(` ! ${d}\n`);
142
255
  }
256
+ if (report.skipped.some((sk) => sk.failed)) {
257
+ process.stdout.write('\nA rule with `failOnEmpty: true` matched nothing — that is a bug in the rule, not a pass.\n');
258
+ return 1;
259
+ }
143
260
  if (report.findings.length === 0 && report.diagnostics.length === 0) {
144
261
  process.stdout.write(newOnly
145
262
  ? `\nNo NEW policy violations from this change${hiddenBaseline > 0 ? ` (${hiddenBaseline} pre-existing hidden)` : ''}. ✓\n`
@@ -12,9 +12,14 @@
12
12
  * 3. singular/plural normalization (`commands` ↔ `command`),
13
13
  * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
14
14
  *
15
- * The first layer that lands on a DECLARED id wins; when nothing resolves, the
16
- * noun is returned unchanged and unmatched (the honest "genuinely not declared"
17
- * answer). No layer invents an id that isn't in the registry.
15
+ * Layers 3 and 4 CHAIN: the singular/plural variants and the suffix transform
16
+ * compose, so a doubly-off noun still lands (`buttons` →(plural)→ `button`
17
+ * →(suffix) `button-command`) where a single layer would no-op. When both
18
+ * transforms fire, the resolution reports `SingularPluralSuffix`.
19
+ *
20
+ * The first candidate that lands on a DECLARED id wins; when nothing resolves,
21
+ * the noun is returned unchanged and unmatched (the honest "genuinely not
22
+ * declared" answer). No layer invents an id that isn't in the registry.
18
23
  */
19
24
  /** How a noun resolved to its canonical registered id (for a truthful report). */
20
25
  export declare enum ERegistryResolveVia {
@@ -22,7 +27,9 @@ export declare enum ERegistryResolveVia {
22
27
  Alias = "alias",
23
28
  Case = "case-fold",
24
29
  SingularPlural = "singular/plural",
25
- Suffix = "suffix"
30
+ Suffix = "suffix",
31
+ /** Both a singular/plural AND a suffix transform were applied (chained). */
32
+ SingularPluralSuffix = "singular/plural+suffix"
26
33
  }
27
34
  export interface IRegistryResolution {
28
35
  /** The canonical id to test for existence (may equal the input noun). */
@@ -1 +1 @@
1
- {"version":3,"file":"registry-resolve.d.ts","sourceRoot":"","sources":["../../src/commands/registry-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,kFAAkF;AAClF,oBAAY,mBAAmB;IAC7B,QAAQ,aAAa;IACrB,KAAK,UAAU;IACf,IAAI,cAAc;IAClB,cAAc,oBAAoB;IAClC,MAAM,WAAW;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,mBAAmB,CAAC;CACnC;AAYD;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,EACrD,IAAI,EAAE,MAAM,GACX,mBAAmB,CAmDrB"}
1
+ {"version":3,"file":"registry-resolve.d.ts","sourceRoot":"","sources":["../../src/commands/registry-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,kFAAkF;AAClF,oBAAY,mBAAmB;IAC7B,QAAQ,aAAa;IACrB,KAAK,UAAU;IACf,IAAI,cAAc;IAClB,cAAc,oBAAoB;IAClC,MAAM,WAAW;IACjB,4EAA4E;IAC5E,oBAAoB,2BAA2B;CAChD;AAED,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,mBAAmB,CAAC;CACnC;AAYD;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,EACrD,IAAI,EAAE,MAAM,GACX,mBAAmB,CAwErB"}
@@ -12,9 +12,14 @@
12
12
  * 3. singular/plural normalization (`commands` ↔ `command`),
13
13
  * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
14
14
  *
15
- * The first layer that lands on a DECLARED id wins; when nothing resolves, the
16
- * noun is returned unchanged and unmatched (the honest "genuinely not declared"
17
- * answer). No layer invents an id that isn't in the registry.
15
+ * Layers 3 and 4 CHAIN: the singular/plural variants and the suffix transform
16
+ * compose, so a doubly-off noun still lands (`buttons` →(plural)→ `button`
17
+ * →(suffix) `button-command`) where a single layer would no-op. When both
18
+ * transforms fire, the resolution reports `SingularPluralSuffix`.
19
+ *
20
+ * The first candidate that lands on a DECLARED id wins; when nothing resolves,
21
+ * the noun is returned unchanged and unmatched (the honest "genuinely not
22
+ * declared" answer). No layer invents an id that isn't in the registry.
18
23
  */
19
24
  /** How a noun resolved to its canonical registered id (for a truthful report). */
20
25
  export var ERegistryResolveVia;
@@ -24,6 +29,8 @@ export var ERegistryResolveVia;
24
29
  ERegistryResolveVia["Case"] = "case-fold";
25
30
  ERegistryResolveVia["SingularPlural"] = "singular/plural";
26
31
  ERegistryResolveVia["Suffix"] = "suffix";
32
+ /** Both a singular/plural AND a suffix transform were applied (chained). */
33
+ ERegistryResolveVia["SingularPluralSuffix"] = "singular/plural+suffix";
27
34
  })(ERegistryResolveVia || (ERegistryResolveVia = {}));
28
35
  function singularPluralVariants(noun) {
29
36
  const out = [];
@@ -60,28 +67,47 @@ export function resolveRegistryNoun(declaredIds, aliases, noun) {
60
67
  if (ciHit !== undefined) {
61
68
  return { canonical: ciHit, matched: true, via: ERegistryResolveVia.Case };
62
69
  }
63
- // 3. Singular/plural.
64
- for (const variant of singularPluralVariants(noun)) {
65
- const v = variant.toLowerCase();
66
- const hit = declaredIds.find((d) => d.toLowerCase() === v);
67
- if (hit !== undefined) {
68
- return { canonical: hit, matched: true, via: ERegistryResolveVia.SingularPlural };
70
+ // 3 + 4. Chained singular/plural × suffix strip/append.
71
+ //
72
+ // Build an ordered candidate list — the noun itself first, then its
73
+ // singular/plural variants (plural variants BEFORE the suffix test) and for
74
+ // EACH candidate test an exact (case-insensitive) match against the declared
75
+ // ids, then a suffix strip, then a suffix append. The first candidate that
76
+ // lands on a DECLARED id wins. Because the transforms compose, a doubly-off
77
+ // noun (`buttons` →(plural)→ `button` →(suffix)→ `button-command`) resolves
78
+ // where either layer alone would no-op. `via` records which transforms fired:
79
+ // a variant matching exactly is `SingularPlural`, the original noun matching
80
+ // by suffix is `Suffix`, and a variant matching by suffix is the chained
81
+ // `SingularPluralSuffix`. The original noun can never match here EXACTLY (the
82
+ // identity / case-fold short-circuits above already handled that).
83
+ const candidates = [noun, ...singularPluralVariants(noun)];
84
+ for (const candidate of candidates) {
85
+ const isVariant = candidate !== noun;
86
+ const cLower = candidate.toLowerCase();
87
+ const exact = declaredIds.find((d) => d.toLowerCase() === cLower);
88
+ if (exact !== undefined && isVariant) {
89
+ return { canonical: exact, matched: true, via: ERegistryResolveVia.SingularPlural };
69
90
  }
70
- }
71
- // 4. Suffix strip/append. A declared id whose trailing `-`/`.`/`_` segment,
72
- // once stripped, equals the noun (`button` `button-command`); or the noun
73
- // plus a suffix another declared id carries yields a declared id.
74
- const suffixStripped = declaredIds.find((d) => {
75
- const stripped = d.replace(/[-_.][a-z0-9]+$/i, '');
76
- return stripped.toLowerCase() === lower && stripped.toLowerCase() !== d.toLowerCase();
77
- });
78
- if (suffixStripped !== undefined) {
79
- return { canonical: suffixStripped, matched: true, via: ERegistryResolveVia.Suffix };
80
- }
81
- for (const d of declaredIds) {
82
- const m = d.match(/([-_.][a-z0-9]+)$/i);
83
- if (m && `${lower}${m[1].toLowerCase()}` === d.toLowerCase()) {
84
- return { canonical: d, matched: true, via: ERegistryResolveVia.Suffix };
91
+ const suffixStripped = declaredIds.find((d) => {
92
+ const stripped = d.replace(/[-_.][a-z0-9]+$/i, '');
93
+ return stripped.toLowerCase() === cLower && stripped.toLowerCase() !== d.toLowerCase();
94
+ });
95
+ if (suffixStripped !== undefined) {
96
+ return {
97
+ canonical: suffixStripped,
98
+ matched: true,
99
+ via: isVariant ? ERegistryResolveVia.SingularPluralSuffix : ERegistryResolveVia.Suffix,
100
+ };
101
+ }
102
+ for (const d of declaredIds) {
103
+ const m = d.match(/([-_.][a-z0-9]+)$/i);
104
+ if (m && `${cLower}${m[1].toLowerCase()}` === d.toLowerCase()) {
105
+ return {
106
+ canonical: d,
107
+ matched: true,
108
+ via: isVariant ? ERegistryResolveVia.SingularPluralSuffix : ERegistryResolveVia.Suffix,
109
+ };
110
+ }
85
111
  }
86
112
  }
87
113
  // Nothing resolved — the honest unmatched identity.
@@ -1 +1 @@
1
- {"version":3,"file":"registry.command.d.ts","sourceRoot":"","sources":["../../src/commands/registry.command.ts"],"names":[],"mappings":"AA4BA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAKhC,eAAO,MAAM,wBAAwB,EAAE,eA8CtC,CAAC;AA6IF,eAAO,MAAM,eAAe,EAAE,eA4B7B,CAAC"}
1
+ {"version":3,"file":"registry.command.d.ts","sourceRoot":"","sources":["../../src/commands/registry.command.ts"],"names":[],"mappings":"AA8BA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAKhC,eAAO,MAAM,wBAAwB,EAAE,eA8CtC,CAAC;AAoLF,eAAO,MAAM,eAAe,EAAE,eA4B7B,CAAC"}
@@ -8,13 +8,14 @@
8
8
  * [--fail-if-taken] # guard: non-zero when taken (free → 0)
9
9
  * [--fail-if-missing] # guard: non-zero when NOT registered
10
10
  * shrk registry <name> where <id> [--json] # declaration (+ consumer) sites
11
+ * shrk registry <name> duplicates [--json] # ids declared in more than one place
11
12
  *
12
13
  * `<name>` resolves a `registries[]` declaration in sharkcraft.config.ts — one
13
14
  * deterministic multi-root scan that answers "is this id taken / where is it"
14
15
  * without an agent re-running a fragile grep.
15
16
  */
16
17
  import { buildRegistryLifecycleReport, renderRegistryLifecycleReportText, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
17
- import { scanRegistry, registryExists, registryWhere, } from '@shrkcrft/boundaries';
18
+ import { scanRegistry, registryDuplicates, registryExists, registryWhere, } from '@shrkcrft/boundaries';
18
19
  import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
19
20
  import { ExitCode } from "../exit-codes.js";
20
21
  import { asJson } from "../output/format-output.js";
@@ -166,6 +167,37 @@ async function runRegistryInventory(args, name) {
166
167
  process.stdout.write(`${exists ? 'yes' : 'no'} — "${canonical}" is ${exists ? 'declared' : 'NOT declared'} in registry "${inventory.name}".\n`);
167
168
  return code;
168
169
  }
170
+ if (action === 'duplicates') {
171
+ // Two roots claiming the same id compile fine; whichever registration wins
172
+ // at runtime is an accident of load order. Every site is printed so the
173
+ // duplicate can be resolved, not merely detected.
174
+ const dupes = registryDuplicates(inventory);
175
+ if (json) {
176
+ process.stdout.write(asJson({
177
+ name: inventory.name,
178
+ scanned: inventory.entries.length,
179
+ duplicates: dupes,
180
+ diagnostics: [...inventory.diagnostics, ...loaded.planeDiagnostics],
181
+ }) + '\n');
182
+ return inventory.entries.length === 0 ? 2 : dupes.length > 0 ? 1 : 0;
183
+ }
184
+ if (inventory.entries.length === 0) {
185
+ process.stdout.write(`Registry "${inventory.name}" matched 0 ids — nothing was checked. This is NOT a pass;\n` +
186
+ ' the source selector is probably stale (see `shrk gates coverage`).\n');
187
+ return 2;
188
+ }
189
+ if (dupes.length === 0) {
190
+ process.stdout.write(`No duplicate ids in registry "${inventory.name}" (${inventory.entries.length} scanned). ✓\n`);
191
+ return 0;
192
+ }
193
+ process.stdout.write(`Duplicate ids in registry "${inventory.name}" (${dupes.length}):\n`);
194
+ for (const e of dupes) {
195
+ process.stdout.write(` ✗ ${e.id} (${e.sites.length} declarations)\n`);
196
+ for (const s of e.sites)
197
+ process.stdout.write(` ${s.file}:${s.line}\n`);
198
+ }
199
+ return 1;
200
+ }
169
201
  if (action === 'where') {
170
202
  if (!id) {
171
203
  process.stderr.write(`Usage: shrk registry ${name} where <id>\n`);
@@ -187,13 +219,13 @@ async function runRegistryInventory(args, name) {
187
219
  process.stdout.write(` consumed ${s.file}:${s.line}\n`);
188
220
  return 0;
189
221
  }
190
- process.stderr.write(`Unknown action "${action}". Usage: shrk registry ${name} list | exists <id> | where <id>\n`);
222
+ process.stderr.write(`Unknown action "${action}". Usage: shrk registry ${name} list | exists <id> | where <id> | duplicates\n`);
191
223
  return 2;
192
224
  }
193
225
  export const registryCommand = {
194
226
  name: 'registry',
195
227
  description: 'Registry inspections: lifecycle symmetry + declared-registry inventory. Read-only.',
196
- usage: 'shrk registry lifecycle | <name> list | <name> exists <id> [--resolve] [--fail-if-taken|--fail-if-missing] | <name> where <id>',
228
+ usage: 'shrk registry lifecycle | <name> list | <name> exists <id> [--resolve] [--fail-if-taken|--fail-if-missing] | <name> where <id> | <name> duplicates',
197
229
  // Guard-mode + query flags take no value — declare them so `exists <id>
198
230
  // --fail-if-taken` (flag last) and `exists --resolve <id>` (flag first) both
199
231
  // keep the id as a positional instead of swallowing it.
@@ -211,7 +243,7 @@ export const registryCommand = {
211
243
  const cwd = resolveCwd(args);
212
244
  const loaded = await loadRegistries(cwd);
213
245
  const names = loaded.ok ? loaded.registries.map((r) => r.name) : [];
214
- process.stderr.write('Usage: shrk registry lifecycle | <name> list | <name> exists <id> | <name> where <id>\n' +
246
+ process.stderr.write('Usage: shrk registry lifecycle | <name> list | <name> exists <id> | <name> where <id> | <name> duplicates\n' +
215
247
  (names.length > 0 ? `Declared registries: ${names.join(', ')}.\n` : 'No registries declared (sharkcraft.config.ts `registries[]`).\n'));
216
248
  return 2;
217
249
  },
@@ -1 +1 @@
1
- {"version":3,"file":"trace.command.d.ts","sourceRoot":"","sources":["../../src/commands/trace.command.ts"],"names":[],"mappings":"AAmBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA6EhC,eAAO,MAAM,YAAY,EAAE,eAwH1B,CAAC"}
1
+ {"version":3,"file":"trace.command.d.ts","sourceRoot":"","sources":["../../src/commands/trace.command.ts"],"names":[],"mappings":"AAmBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA8EhC,eAAO,MAAM,YAAY,EAAE,eA+H1B,CAAC"}
@@ -17,6 +17,7 @@ const TRACE_ROLE_ORDER = [
17
17
  TraceRole.Declare,
18
18
  TraceRole.Register,
19
19
  TraceRole.Consume,
20
+ TraceRole.Render,
20
21
  TraceRole.Reference,
21
22
  ];
22
23
  function renderTraceLiteral(report, limit) {
@@ -31,7 +32,7 @@ function renderTraceLiteral(report, limit) {
31
32
  }
32
33
  // Mirror `shrk registry <name> where`'s `<role> file:line` line idiom so the
33
34
  // two surfaces read the same — `trace literal` is the same scanner + classifier
34
- // without a pre-declared registry, just with two extra roles (`registered`,
35
+ // without a pre-declared registry, just with extra roles (`register`, `render`,
35
36
  // `reference`). The raw source line stays in `--json` (`text`); the human view
36
37
  // is classification-first (direction), not a grep-style text dump.
37
38
  process.stdout.write('\n');
@@ -154,6 +155,11 @@ export const traceCommand = {
154
155
  process.stdout.write(header(`Trace: ${query}`));
155
156
  if (!resolution.bestMatch) {
156
157
  process.stdout.write(' no matches found.\n');
158
+ // The bare-`trace` path resolves a FUZZY registry query, not an exact
159
+ // string literal — quotes are stripped by the shell, so we can't tell a
160
+ // literal from a query and must not auto-route. Point the agent at the
161
+ // exact-literal tracer (`trace literal`) so a no-match here isn't a dead end.
162
+ process.stderr.write(`hint: to trace an exact string literal across files, use: shrk trace literal "${query}"\n`);
157
163
  return 1;
158
164
  }
159
165
  process.stdout.write(`Confidence: ${resolution.confidence}\n`);
@@ -1 +1 @@
1
- {"version":3,"file":"wiring.command.d.ts","sourceRoot":"","sources":["../../src/commands/wiring.command.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAAwB,KAAK,eAAe,EAAmB,MAAM,wBAAwB,CAAC;AAKrG;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,CAsDrF;AA+TD,eAAO,MAAM,aAAa,EAAE,eAgB3B,CAAC"}
1
+ {"version":3,"file":"wiring.command.d.ts","sourceRoot":"","sources":["../../src/commands/wiring.command.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAMhC;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,CAoFrF;AAyZD,eAAO,MAAM,aAAa,EAAE,eAgB3B,CAAC"}