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

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 (52) hide show
  1. package/dist/command-registry.d.ts +10 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +16 -0
  4. package/dist/commands/changelog-data.d.ts +25 -0
  5. package/dist/commands/changelog-data.d.ts.map +1 -0
  6. package/dist/commands/changelog-data.js +94 -0
  7. package/dist/commands/changelog.command.d.ts +3 -0
  8. package/dist/commands/changelog.command.d.ts.map +1 -0
  9. package/dist/commands/changelog.command.js +100 -0
  10. package/dist/commands/changes.command.d.ts.map +1 -1
  11. package/dist/commands/changes.command.js +4 -0
  12. package/dist/commands/check.command.d.ts.map +1 -1
  13. package/dist/commands/check.command.js +134 -19
  14. package/dist/commands/command-catalog.d.ts.map +1 -1
  15. package/dist/commands/command-catalog.js +8 -0
  16. package/dist/commands/compress.command.d.ts.map +1 -1
  17. package/dist/commands/compress.command.js +46 -2
  18. package/dist/commands/constructs.command.d.ts.map +1 -1
  19. package/dist/commands/constructs.command.js +49 -14
  20. package/dist/commands/context.command.d.ts.map +1 -1
  21. package/dist/commands/context.command.js +31 -19
  22. package/dist/commands/gate.command.d.ts.map +1 -1
  23. package/dist/commands/gate.command.js +63 -3
  24. package/dist/commands/gen.command.d.ts.map +1 -1
  25. package/dist/commands/gen.command.js +65 -9
  26. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  27. package/dist/commands/graph-code-subverbs.js +14 -2
  28. package/dist/commands/graph.command.d.ts.map +1 -1
  29. package/dist/commands/graph.command.js +68 -1
  30. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  31. package/dist/commands/policy-lint.command.js +46 -8
  32. package/dist/commands/registry-resolve.d.ts +41 -0
  33. package/dist/commands/registry-resolve.d.ts.map +1 -0
  34. package/dist/commands/registry-resolve.js +89 -0
  35. package/dist/commands/registry.command.d.ts.map +1 -1
  36. package/dist/commands/registry.command.js +86 -13
  37. package/dist/commands/reuse.command.d.ts +20 -0
  38. package/dist/commands/reuse.command.d.ts.map +1 -1
  39. package/dist/commands/reuse.command.js +156 -20
  40. package/dist/commands/smart-context.command.d.ts.map +1 -1
  41. package/dist/commands/smart-context.command.js +20 -5
  42. package/dist/commands/task.command.d.ts.map +1 -1
  43. package/dist/commands/task.command.js +33 -16
  44. package/dist/exit-codes.d.ts +49 -0
  45. package/dist/exit-codes.d.ts.map +1 -0
  46. package/dist/exit-codes.js +61 -0
  47. package/dist/main.d.ts.map +1 -1
  48. package/dist/main.js +26 -1
  49. package/dist/validation/typecheck-emitted.d.ts +36 -0
  50. package/dist/validation/typecheck-emitted.d.ts.map +1 -0
  51. package/dist/validation/typecheck-emitted.js +109 -0
  52. package/package.json +33 -33
@@ -1,8 +1,63 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import * as nodePath from 'node:path';
3
3
  import { analyzeImportGraph, buildKnowledgeGraph, findGraphPath, getGraphNode, inspectSharkcraft, } from '@shrkcrft/inspector';
4
- import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
4
+ import { firstUnknownFlag, flagBool, flagString, resolveCwd, } from "../command-registry.js";
5
+ import { ExitCode } from "../exit-codes.js";
5
6
  import { asJson, header, kv } from "../output/format-output.js";
7
+ /**
8
+ * The code-intelligence subverbs that share one arg parser. Each is guarded so
9
+ * an unrecognized/misspelled flag is rejected loudly instead of parsing as a
10
+ * silent `true` that reads as a confident opt-in (a25 §2.1). The allow-set is
11
+ * the UNION of every flag any code-graph subverb reads, plus the global flags,
12
+ * so a real flag is never false-rejected — only a genuinely unknown token is.
13
+ */
14
+ const CODE_GRAPH_SUBVERBS = new Set([
15
+ 'index',
16
+ 'status',
17
+ 'search',
18
+ 'context',
19
+ 'impact',
20
+ 'path',
21
+ 'hubs',
22
+ 'callers',
23
+ 'cycles',
24
+ 'unresolved',
25
+ 'deps',
26
+ ]);
27
+ const CODE_GRAPH_ALLOWED_FLAGS = new Set([
28
+ // code-subverb flags (union across index/status/search/context/impact/path/
29
+ // hubs/callers/cycles/unresolved/deps)
30
+ 'changed',
31
+ 'compact',
32
+ 'depth',
33
+ 'full',
34
+ 'has-unresolved-imports',
35
+ 'include-type-edges',
36
+ 'json',
37
+ 'kind',
38
+ 'limit',
39
+ 'max-depth',
40
+ 'min-size',
41
+ 'mode',
42
+ 'no-bridge',
43
+ 'no-framework',
44
+ 'no-refresh',
45
+ 'path',
46
+ 'since',
47
+ 'table',
48
+ // --watch loop (graph index --watch)
49
+ 'watch',
50
+ 'paths',
51
+ 'debounce',
52
+ 'once',
53
+ // global / meta flags that reach any verb
54
+ 'cwd',
55
+ 'strict',
56
+ 'help',
57
+ 'h',
58
+ 'no-color',
59
+ 'color',
60
+ ]);
6
61
  import { runGraphCallers, runGraphContext, runGraphCycles, runGraphDeps, runGraphHubs, runGraphImpact, runGraphIndex, runGraphPath, runGraphSearch, runGraphStatus, runGraphUnresolved, } from "./graph-code-subverbs.js";
7
62
  const KNOWN_KINDS = [
8
63
  'knowledge',
@@ -26,6 +81,18 @@ export const graphCommand = {
26
81
  // Code-intelligence subverbs (R65) don't need the knowledge graph —
27
82
  // dispatch them before the expensive inspection so they stay fast.
28
83
  const earlySub = args.positional[0];
84
+ // Reject unknown/misspelled flags on the code-graph family BEFORE dispatch:
85
+ // an unrecognized flag must never parse as a silent success (a25 §2.1). Any
86
+ // real flag is in the union allow-set, so this only fires on a genuine typo.
87
+ if (typeof earlySub === 'string' && CODE_GRAPH_SUBVERBS.has(earlySub)) {
88
+ const unknown = firstUnknownFlag(args, CODE_GRAPH_ALLOWED_FLAGS);
89
+ if (unknown) {
90
+ const dash = unknown.length === 1 ? '-' : '--';
91
+ process.stderr.write(`unknown option '${dash}${unknown}' for 'shrk graph ${earlySub}'. ` +
92
+ `Run 'shrk graph --help' for valid flags.\n`);
93
+ return ExitCode.NotVerified;
94
+ }
95
+ }
29
96
  if (earlySub === 'index')
30
97
  return runGraphIndex(args);
31
98
  if (earlySub === 'status')
@@ -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,eAkJ/B,CAAC"}
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,17 +1,21 @@
1
1
  import * as nodePath from 'node:path';
2
2
  import { runPolicyLint } from '@shrkcrft/boundaries';
3
- import { resolveChangedFiles, resolveProjectConfig } from '@shrkcrft/inspector';
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
7
  export const policyLintCommand = {
8
8
  name: 'policy-lint',
9
9
  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
- usage: 'shrk [--cwd <dir>] policy-lint [--surface template|style|ts] [--changed-only] [--since <ref>] [--only <ids>] [--json]',
10
+ 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)',
11
11
  async run(args) {
12
12
  const cwd = resolveCwd(args);
13
13
  const wantJson = flagBool(args, 'json');
14
14
  const changedOnly = flagBool(args, 'changed-only');
15
+ // --new-only: scan the WHOLE tree, then show only findings the current change
16
+ // introduced (baseline debt is bucketed as hidden, not printed) — the
17
+ // finding-level complement to --changed-only's file-level scoping.
18
+ const newOnly = flagBool(args, 'new-only');
15
19
  const since = flagString(args, 'since');
16
20
  const only = flagString(args, 'only');
17
21
  const surfaceRaw = flagString(args, 'surface');
@@ -60,11 +64,11 @@ export const policyLintCommand = {
60
64
  }
61
65
  }
62
66
  let changedFiles;
63
- if (changedOnly || since) {
67
+ if (changedOnly || newOnly || since) {
64
68
  changedFiles = resolveChangedFiles({
65
69
  projectRoot: cwd,
66
70
  ...(since ? { since } : {}),
67
- ...(changedOnly && !since ? { includeWorktree: true } : {}),
71
+ ...((changedOnly || newOnly) && !since ? { includeWorktree: true } : {}),
68
72
  }).files;
69
73
  }
70
74
  // Don't lint SharkCraft's own asset/config dir by default (its .ts files
@@ -73,17 +77,46 @@ export const policyLintCommand = {
73
77
  const excludeDirs = sharkcraftRel && !sharkcraftRel.startsWith('..') ? [sharkcraftRel] : [];
74
78
  const reportRaw = runPolicyLint(cwd, rules, {
75
79
  ...(surfaces ? { surfaces } : {}),
76
- ...(changedOnly || since ? { changedOnly: true, changedFiles: changedFiles ?? [] } : {}),
80
+ // --changed-only narrows the SCAN; --new-only scans the full tree so it can
81
+ // still diff findings (it filters after, below).
82
+ ...((changedOnly || since) && !newOnly ? { changedOnly: true, changedFiles: changedFiles ?? [] } : {}),
77
83
  ...(only ? { only: only.split(',').map((s) => s.trim()).filter(Boolean) } : {}),
78
84
  ...(excludeDirs.length > 0 ? { excludeDirs } : {}),
79
85
  });
80
86
  // Surface pack-plane merge notes (missing/invalid pack policy files, dropped
81
87
  // collisions) in the same diagnostics array the engine already emits.
82
- const report = planeDiagnostics.length > 0
88
+ let report = planeDiagnostics.length > 0
83
89
  ? { ...reportRaw, diagnostics: [...reportRaw.diagnostics, ...planeDiagnostics] }
84
90
  : reportRaw;
91
+ // --new-only: partition the full-tree findings against the changed set and
92
+ // keep only the ones the current change introduced; pre-existing baseline
93
+ // debt is bucketed as hidden (reported as a count, never printed as green).
94
+ let hiddenBaseline = 0;
95
+ if (newOnly) {
96
+ const keyOf = (f) => `${f.ruleId}|${f.file}:${f.line}|${f.match}`;
97
+ const classification = classifyChangedScope({
98
+ projectRoot: cwd,
99
+ current: report.findings.map((f) => ({
100
+ key: keyOf(f),
101
+ file: f.file,
102
+ code: f.ruleId,
103
+ severity: f.severity,
104
+ message: f.message,
105
+ })),
106
+ changedFiles: changedFiles ?? [],
107
+ });
108
+ const newKeys = new Set(classification.newIssues.map((n) => n.key));
109
+ const newFindings = report.findings.filter((f) => newKeys.has(keyOf(f)));
110
+ hiddenBaseline = report.findings.length - newFindings.length;
111
+ const verdict = newFindings.some((f) => f.severity === 'error')
112
+ ? 'errors'
113
+ : newFindings.length > 0
114
+ ? report.verdict
115
+ : 'pass';
116
+ report = { ...report, findings: newFindings, verdict };
117
+ }
85
118
  if (wantJson) {
86
- process.stdout.write(asJson(report) + '\n');
119
+ process.stdout.write(asJson({ ...report, ...(newOnly ? { newOnly: true, hiddenBaseline } : {}) }) + '\n');
87
120
  return report.verdict === 'errors' ? 1 : 0;
88
121
  }
89
122
  process.stdout.write(header('Policy lint'));
@@ -99,13 +132,18 @@ export const policyLintCommand = {
99
132
  const errors = report.findings.filter((f) => f.severity === 'error').length;
100
133
  const warnings = report.findings.filter((f) => f.severity === 'warning').length;
101
134
  process.stdout.write(kv('findings', `${errors} error(s), ${warnings} warning(s)`) + '\n');
135
+ if (newOnly) {
136
+ process.stdout.write(kv('scope', `new-only (${hiddenBaseline} pre-existing finding(s) hidden — run without --new-only to see all)`) + '\n');
137
+ }
102
138
  if (report.diagnostics.length > 0) {
103
139
  process.stdout.write('\nMisconfigured rules:\n');
104
140
  for (const d of report.diagnostics)
105
141
  process.stdout.write(` ! ${d}\n`);
106
142
  }
107
143
  if (report.findings.length === 0 && report.diagnostics.length === 0) {
108
- process.stdout.write('\nNo policy violations on the scanned surfaces. ✓\n');
144
+ process.stdout.write(newOnly
145
+ ? `\nNo NEW policy violations from this change${hiddenBaseline > 0 ? ` (${hiddenBaseline} pre-existing hidden)` : ''}. ✓\n`
146
+ : '\nNo policy violations on the scanned surfaces. ✓\n');
109
147
  return 0;
110
148
  }
111
149
  // Group findings by rule.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `--resolve` support for `shrk registry <name> exists <id>`.
3
+ *
4
+ * The noun an author types is not always the exact registered slug. `--resolve`
5
+ * bridges that vocabulary gap before the existence check so a duplicate guard
6
+ * can't return a false "not declared" for a construct that DOES exist under its
7
+ * canonical id (a25 §2.4). Resolution is deterministic and layered, most
8
+ * author-controlled first:
9
+ *
10
+ * 1. the registry's declared `aliases` map (explicit synonym → canonical),
11
+ * 2. a case-insensitive exact match against the declared ids,
12
+ * 3. singular/plural normalization (`commands` ↔ `command`),
13
+ * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
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.
18
+ */
19
+ /** How a noun resolved to its canonical registered id (for a truthful report). */
20
+ export declare enum ERegistryResolveVia {
21
+ Identity = "identity",
22
+ Alias = "alias",
23
+ Case = "case-fold",
24
+ SingularPlural = "singular/plural",
25
+ Suffix = "suffix"
26
+ }
27
+ export interface IRegistryResolution {
28
+ /** The canonical id to test for existence (may equal the input noun). */
29
+ readonly canonical: string;
30
+ /** True when `canonical` is actually a declared id in the registry. */
31
+ readonly matched: boolean;
32
+ /** Which normalization layer produced `canonical`. */
33
+ readonly via: ERegistryResolveVia;
34
+ }
35
+ /**
36
+ * Resolve a human/synonym `noun` to a canonical registered id. `declaredIds` is
37
+ * the registry's actual declared id list (so no layer can resolve to something
38
+ * that isn't declared); `aliases` is the registry's optional declared map.
39
+ */
40
+ export declare function resolveRegistryNoun(declaredIds: readonly string[], aliases: Readonly<Record<string, string>> | undefined, noun: string): IRegistryResolution;
41
+ //# sourceMappingURL=registry-resolve.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `--resolve` support for `shrk registry <name> exists <id>`.
3
+ *
4
+ * The noun an author types is not always the exact registered slug. `--resolve`
5
+ * bridges that vocabulary gap before the existence check so a duplicate guard
6
+ * can't return a false "not declared" for a construct that DOES exist under its
7
+ * canonical id (a25 §2.4). Resolution is deterministic and layered, most
8
+ * author-controlled first:
9
+ *
10
+ * 1. the registry's declared `aliases` map (explicit synonym → canonical),
11
+ * 2. a case-insensitive exact match against the declared ids,
12
+ * 3. singular/plural normalization (`commands` ↔ `command`),
13
+ * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
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.
18
+ */
19
+ /** How a noun resolved to its canonical registered id (for a truthful report). */
20
+ export var ERegistryResolveVia;
21
+ (function (ERegistryResolveVia) {
22
+ ERegistryResolveVia["Identity"] = "identity";
23
+ ERegistryResolveVia["Alias"] = "alias";
24
+ ERegistryResolveVia["Case"] = "case-fold";
25
+ ERegistryResolveVia["SingularPlural"] = "singular/plural";
26
+ ERegistryResolveVia["Suffix"] = "suffix";
27
+ })(ERegistryResolveVia || (ERegistryResolveVia = {}));
28
+ function singularPluralVariants(noun) {
29
+ const out = [];
30
+ const lower = noun.toLowerCase();
31
+ if (lower.endsWith('ies'))
32
+ out.push(noun.slice(0, -3) + 'y');
33
+ if (lower.endsWith('s'))
34
+ out.push(noun.slice(0, -1));
35
+ if (lower.endsWith('y'))
36
+ out.push(noun.slice(0, -1) + 'ies');
37
+ out.push(noun + 's');
38
+ return out;
39
+ }
40
+ /**
41
+ * Resolve a human/synonym `noun` to a canonical registered id. `declaredIds` is
42
+ * the registry's actual declared id list (so no layer can resolve to something
43
+ * that isn't declared); `aliases` is the registry's optional declared map.
44
+ */
45
+ export function resolveRegistryNoun(declaredIds, aliases, noun) {
46
+ const idSet = new Set(declaredIds);
47
+ // 1. Author-declared alias map — highest priority, honored even if it points
48
+ // at an id the current scan didn't find (the author asserted the mapping).
49
+ const aliased = aliases?.[noun];
50
+ if (aliased !== undefined) {
51
+ return { canonical: aliased, matched: idSet.has(aliased), via: ERegistryResolveVia.Alias };
52
+ }
53
+ // Exact identity.
54
+ if (idSet.has(noun)) {
55
+ return { canonical: noun, matched: true, via: ERegistryResolveVia.Identity };
56
+ }
57
+ const lower = noun.toLowerCase();
58
+ // 2. Case-insensitive exact.
59
+ const ciHit = declaredIds.find((d) => d.toLowerCase() === lower);
60
+ if (ciHit !== undefined) {
61
+ return { canonical: ciHit, matched: true, via: ERegistryResolveVia.Case };
62
+ }
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 };
69
+ }
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 };
85
+ }
86
+ }
87
+ // Nothing resolved — the honest unmatched identity.
88
+ return { canonical: noun, matched: false, via: ERegistryResolveVia.Identity };
89
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"registry.command.d.ts","sourceRoot":"","sources":["../../src/commands/registry.command.ts"],"names":[],"mappings":"AAwBA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAGhC,eAAO,MAAM,wBAAwB,EAAE,eAetC,CAAC;AAsGF,eAAO,MAAM,eAAe,EAAE,eAuB7B,CAAC"}
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"}
@@ -4,30 +4,66 @@
4
4
  * shrk registry lifecycle [--json] # register/remove symmetry
5
5
  * shrk registry <name> list [--json] # every declared id
6
6
  * shrk registry <name> exists <id> [--json] # is the id taken? (exit 1 if not)
7
+ * [--resolve] # map a synonym → canonical id first
8
+ * [--fail-if-taken] # guard: non-zero when taken (free → 0)
9
+ * [--fail-if-missing] # guard: non-zero when NOT registered
7
10
  * shrk registry <name> where <id> [--json] # declaration (+ consumer) sites
8
11
  *
9
12
  * `<name>` resolves a `registries[]` declaration in sharkcraft.config.ts — one
10
13
  * deterministic multi-root scan that answers "is this id taken / where is it"
11
14
  * without an agent re-running a fragile grep.
12
15
  */
13
- import { buildRegistryLifecycleReport, renderRegistryLifecycleReportText, resolveProjectConfig, } from '@shrkcrft/inspector';
16
+ import { buildRegistryLifecycleReport, renderRegistryLifecycleReportText, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
14
17
  import { scanRegistry, registryExists, registryWhere, } from '@shrkcrft/boundaries';
15
18
  import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
19
+ import { ExitCode } from "../exit-codes.js";
16
20
  import { asJson } from "../output/format-output.js";
21
+ import { resolveRegistryNoun } from "./registry-resolve.js";
17
22
  export const registryLifecycleCommand = {
18
23
  name: 'lifecycle',
19
24
  description: 'Scan the workspace for register/remove symmetry. Read-only.',
20
- usage: 'shrk registry lifecycle [--scope <dir>] [--json]',
25
+ usage: 'shrk registry lifecycle [--scope <dir>] [--changed-only] [--since <ref>] [--json]',
21
26
  async run(args) {
22
27
  const cwd = resolveCwd(args);
23
28
  const scope = flagString(args, 'scope');
24
- const report = buildRegistryLifecycleReport({ projectRoot: cwd, ...(scope ? { scope } : {}) });
29
+ const changedOnly = flagBool(args, 'changed-only');
30
+ const since = flagString(args, 'since');
31
+ let files;
32
+ if (changedOnly || since) {
33
+ const changed = resolveChangedFiles({
34
+ projectRoot: cwd,
35
+ ...(since ? { since } : {}),
36
+ ...(changedOnly && !since ? { includeWorktree: true } : {}),
37
+ });
38
+ files = changed.files;
39
+ }
40
+ // Full-tree walk honors the project's skipDirs override.
41
+ let skipDirs;
42
+ if (files === undefined) {
43
+ const loaded = await resolveProjectConfig(cwd);
44
+ if (loaded.ok)
45
+ skipDirs = loaded.value.config.registryLifecycle?.skipDirs;
46
+ }
47
+ const report = buildRegistryLifecycleReport({
48
+ projectRoot: cwd,
49
+ ...(files !== undefined ? { files } : {}),
50
+ ...(scope ? { scope } : {}),
51
+ ...(skipDirs ? { skipDirs } : {}),
52
+ });
53
+ // Honest exit-code contract (a25 §1 / §2.5): a wedged/timed-out scan OR a
54
+ // scope with zero registrations to check is NOT verified (`2`), never a
55
+ // green `0` an agent's `&& next` would march past.
56
+ const exit = report.timedOut || report.registersFound === 0
57
+ ? ExitCode.NotVerified
58
+ : report.missingRemovers.length === 0
59
+ ? ExitCode.VerifiedPass
60
+ : ExitCode.Failure;
25
61
  if (flagBool(args, 'json')) {
26
62
  process.stdout.write(asJson(report) + '\n');
27
- return report.missingRemovers.length === 0 ? 0 : 1;
63
+ return exit;
28
64
  }
29
65
  process.stdout.write(renderRegistryLifecycleReportText(report));
30
- return report.missingRemovers.length === 0 ? 0 : 1;
66
+ return exit;
31
67
  },
32
68
  };
33
69
  async function loadRegistries(cwd) {
@@ -87,15 +123,48 @@ async function runRegistryInventory(args, name) {
87
123
  }
88
124
  if (action === 'exists') {
89
125
  if (!id) {
90
- process.stderr.write(`Usage: shrk registry ${name} exists <id>\n`);
126
+ process.stderr.write(`Usage: shrk registry ${name} exists <id> [--resolve] [--fail-if-taken|--fail-if-missing]\n`);
91
127
  return 2;
92
128
  }
93
- const exists = registryExists(inventory, id);
94
- if (json)
95
- process.stdout.write(asJson({ name: inventory.name, id, exists }) + '\n');
96
- else
97
- process.stdout.write(`${exists ? 'yes' : 'no'} — "${id}" is ${exists ? 'declared' : 'NOT declared'} in registry "${inventory.name}".\n`);
98
- return exists ? 0 : 1;
129
+ const failIfTaken = flagBool(args, 'fail-if-taken');
130
+ const failIfMissing = flagBool(args, 'fail-if-missing');
131
+ if (failIfTaken && failIfMissing) {
132
+ process.stderr.write('Pass at most one of --fail-if-taken / --fail-if-missing.\n');
133
+ return 2;
134
+ }
135
+ // `--resolve` maps a human noun to the canonical registered id before the
136
+ // existence test — via the registry's declared `aliases` map AND generic
137
+ // normalization (case-fold, singular/plural, suffix strip/append) — so a
138
+ // duplicate guard can't return a false "free" on a synonym of an
139
+ // already-taken slug (a25 §2.4).
140
+ const doResolve = flagBool(args, 'resolve');
141
+ const resolution = doResolve
142
+ ? resolveRegistryNoun(inventory.entries.map((e) => e.id), decl.aliases, id)
143
+ : undefined;
144
+ const canonical = resolution ? resolution.canonical : id;
145
+ const resolved = canonical !== id;
146
+ const exists = registryExists(inventory, canonical);
147
+ // Exit-code convention:
148
+ // --fail-if-taken → non-zero when the id is already registered (free → 0),
149
+ // so `exists <id> --fail-if-taken && <author>` is a natural guard.
150
+ // --fail-if-missing → non-zero when the id is NOT registered (the consume-side check).
151
+ // neither → the historical query convention (taken → 0, free → 1).
152
+ const code = failIfTaken ? (exists ? 1 : 0) : exists ? 0 : 1;
153
+ if (json) {
154
+ process.stdout.write(asJson({
155
+ name: inventory.name,
156
+ id,
157
+ ...(resolved ? { resolvedId: canonical, resolvedVia: resolution?.via } : {}),
158
+ exists,
159
+ exitCode: code,
160
+ }) + '\n');
161
+ return code;
162
+ }
163
+ if (resolved) {
164
+ process.stdout.write(`resolved "${id}" → "${canonical}" (${resolution?.via})\n`);
165
+ }
166
+ process.stdout.write(`${exists ? 'yes' : 'no'} — "${canonical}" is ${exists ? 'declared' : 'NOT declared'} in registry "${inventory.name}".\n`);
167
+ return code;
99
168
  }
100
169
  if (action === 'where') {
101
170
  if (!id) {
@@ -124,7 +193,11 @@ async function runRegistryInventory(args, name) {
124
193
  export const registryCommand = {
125
194
  name: 'registry',
126
195
  description: 'Registry inspections: lifecycle symmetry + declared-registry inventory. Read-only.',
127
- usage: 'shrk registry lifecycle | <name> list | <name> exists <id> | <name> where <id>',
196
+ usage: 'shrk registry lifecycle | <name> list | <name> exists <id> [--resolve] [--fail-if-taken|--fail-if-missing] | <name> where <id>',
197
+ // Guard-mode + query flags take no value — declare them so `exists <id>
198
+ // --fail-if-taken` (flag last) and `exists --resolve <id>` (flag first) both
199
+ // keep the id as a positional instead of swallowing it.
200
+ booleanFlags: new Set(['json', 'resolve', 'fail-if-taken', 'fail-if-missing']),
128
201
  async run(args) {
129
202
  const sub = args.positional[0];
130
203
  if (sub === 'lifecycle') {
@@ -1,3 +1,23 @@
1
+ import type { IReusePrimitive } from '@shrkcrft/core';
1
2
  import { type ICommandHandler } from '../command-registry.js';
3
+ /** A weak (below-confidence) candidate offered as a did-you-mean, never as an answer. */
4
+ interface IReuseSuggestion {
5
+ symbol: string;
6
+ score: number;
7
+ confidence: number;
8
+ matched: readonly string[];
9
+ roles: readonly string[];
10
+ }
11
+ /**
12
+ * Rank ALL primitives by the matcher's own score (descending; ties broken by
13
+ * symbol name so the order is deterministic), then return the top-`k` as scored
14
+ * did-you-mean suggestions. Pure — no graph, no IO — so it is directly
15
+ * unit-testable. When every candidate scores 0 (a nonsense intent that shares no
16
+ * term) the result is the alphabetically-first `k` primitives, each with
17
+ * `score: 0`; the caller states "no candidate shares any term" in that case
18
+ * rather than dumping the whole catalog.
19
+ */
20
+ export declare function rankReuseSuggestions(primitives: readonly IReusePrimitive[], tokens: readonly string[], k: number): IReuseSuggestion[];
2
21
  export declare const reuseCommand: ICommandHandler;
22
+ export {};
3
23
  //# sourceMappingURL=reuse.command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reuse.command.d.ts","sourceRoot":"","sources":["../../src/commands/reuse.command.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA4DhC,eAAO,MAAM,YAAY,EAAE,eAqK1B,CAAC"}
1
+ {"version":3,"file":"reuse.command.d.ts","sourceRoot":"","sources":["../../src/commands/reuse.command.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAkGhC,yFAAyF;AACzF,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,SAAS,eAAe,EAAE,EACtC,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,CAAC,EAAE,MAAM,GACR,gBAAgB,EAAE,CAapB;AAED,eAAO,MAAM,YAAY,EAAE,eA6Q1B,CAAC"}