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

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 (40) hide show
  1. package/dist/commands/changelog-data.d.ts +25 -0
  2. package/dist/commands/changelog-data.d.ts.map +1 -0
  3. package/dist/commands/changelog-data.js +70 -0
  4. package/dist/commands/changelog.command.d.ts +3 -0
  5. package/dist/commands/changelog.command.d.ts.map +1 -0
  6. package/dist/commands/changelog.command.js +100 -0
  7. package/dist/commands/changes.command.d.ts.map +1 -1
  8. package/dist/commands/changes.command.js +4 -0
  9. package/dist/commands/check.command.d.ts.map +1 -1
  10. package/dist/commands/check.command.js +73 -15
  11. package/dist/commands/command-catalog.d.ts.map +1 -1
  12. package/dist/commands/command-catalog.js +8 -0
  13. package/dist/commands/compress.command.d.ts.map +1 -1
  14. package/dist/commands/compress.command.js +15 -1
  15. package/dist/commands/constructs.command.d.ts.map +1 -1
  16. package/dist/commands/constructs.command.js +49 -14
  17. package/dist/commands/context.command.d.ts.map +1 -1
  18. package/dist/commands/context.command.js +31 -19
  19. package/dist/commands/gate.command.d.ts.map +1 -1
  20. package/dist/commands/gate.command.js +6 -1
  21. package/dist/commands/gen.command.d.ts.map +1 -1
  22. package/dist/commands/gen.command.js +65 -9
  23. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  24. package/dist/commands/graph-code-subverbs.js +14 -2
  25. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  26. package/dist/commands/policy-lint.command.js +46 -8
  27. package/dist/commands/registry.command.d.ts.map +1 -1
  28. package/dist/commands/registry.command.js +71 -13
  29. package/dist/commands/reuse.command.d.ts.map +1 -1
  30. package/dist/commands/reuse.command.js +80 -14
  31. package/dist/commands/smart-context.command.d.ts.map +1 -1
  32. package/dist/commands/smart-context.command.js +20 -5
  33. package/dist/commands/task.command.d.ts.map +1 -1
  34. package/dist/commands/task.command.js +33 -16
  35. package/dist/main.d.ts.map +1 -1
  36. package/dist/main.js +2 -0
  37. package/dist/validation/typecheck-emitted.d.ts +36 -0
  38. package/dist/validation/typecheck-emitted.d.ts.map +1 -0
  39. package/dist/validation/typecheck-emitted.js +109 -0
  40. package/package.json +33 -33
@@ -6,6 +6,15 @@ import * as nodePath from 'node:path';
6
6
  import { buildUniversalSearch, explainTaskRouting, recommendCommands, renderOverviewText, buildProjectOverview, } from '@shrkcrft/inspector';
7
7
  import { flagBool, flagNumber, flagString, flagList, resolveCwd, } from "../command-registry.js";
8
8
  import { asJson, header } from "../output/format-output.js";
9
+ /**
10
+ * Budget used for the DEFAULT full human-text orientation view when the caller
11
+ * set no explicit `--max-tokens`. Orientation is the cheap-first step an agent
12
+ * uses to form its initial model of the project — it must not silently drop the
13
+ * body of the richest sections (architecture, conventions, paths, workflows) the
14
+ * way a tight budget would. Large enough to hold every planned section; explicit
15
+ * `--max-tokens` and JSON/`--summary` callers still get the configured budget.
16
+ */
17
+ const WIDE_CONTEXT_BUDGET = 100_000;
9
18
  /**
10
19
  * Minimal JSON shape for agent / skill consumption — the context-side mirror
11
20
  * of `shrk task --compact`. Drops the heavy `body` and `request` echo and
@@ -65,8 +74,19 @@ export const contextCommand = {
65
74
  const noPaths = flagBool(args, 'no-paths');
66
75
  const includeDocs = flagBool(args, 'include-docs');
67
76
  const includeCommands = flagBool(args, 'include-commands');
77
+ // Orientation renders the FULL body by default now (parity with why / reuse
78
+ // / knowledge get). `--summary`/`--brief` opts back into the terse view.
79
+ const wantsSummary = flagBool(args, 'summary') || flagBool(args, 'brief');
80
+ const wantsJsonOut = flagBool(args, 'json') || flagBool(args, 'machine-json');
68
81
  const inspection = await inspectSharkcraft({ cwd: resolveCwd(args) });
69
82
  const overview = buildProjectOverview(inspection.workspace, inspection.config?.projectName);
83
+ // Auto-widen the budget for the default full human view so no requested
84
+ // section is dropped purely to fit a tight budget. Explicit `--max-tokens`,
85
+ // JSON, and summary callers keep the configured budget.
86
+ const effectiveMaxTokens = maxTokens ??
87
+ (!wantsJsonOut && !wantsSummary
88
+ ? WIDE_CONTEXT_BUDGET
89
+ : (inspection.config?.defaultMaxTokens ?? 3000));
70
90
  const contextBoost = contextTuningBoostFor(inspection, task);
71
91
  const result = buildContext(inspection.knowledgeEntries, {
72
92
  task,
@@ -74,7 +94,7 @@ export const contextCommand = {
74
94
  area,
75
95
  tags,
76
96
  scope,
77
- maxTokens: maxTokens ?? inspection.config?.defaultMaxTokens ?? 3000,
97
+ maxTokens: effectiveMaxTokens,
78
98
  includeExamples: !noExamples,
79
99
  includeTemplates: !noTemplates,
80
100
  includeRules: !noRules,
@@ -84,14 +104,10 @@ export const contextCommand = {
84
104
  projectOverview: renderOverviewText(overview),
85
105
  ...(contextBoost ? { boostFor: contextBoost } : {}),
86
106
  });
87
- // Surface top commands prominently before the long context body.
88
- // Auto-promote commands-first for action-like tasks (rename / add /
89
- // fix / refactor / remove / migrate / explore / wire). Pass --full to
90
- // see the long context body anyway.
91
- const actionVerbRe = /^(rename|add|fix|refactor|remove|delete|migrate|wire|explore|create|implement|update|introduce|build|extract|move|inline|generate|scaffold)\b/i;
92
- const isActionLike = actionVerbRe.test(task.trim());
93
- const wantsFull = flagBool(args, 'full');
94
- const commandsFirst = flagBool(args, 'commands-first') || (isActionLike && !wantsFull);
107
+ // Surface top commands prominently before the long context body. The body
108
+ // itself now prints by default (parity with sibling orientation verbs);
109
+ // `--commands-first` keeps the terse commands-only view for action tasks.
110
+ const commandsOnly = flagBool(args, 'commands-first');
95
111
  let commandRecommendations = null;
96
112
  let routingMatches = [];
97
113
  let searchReport = null;
@@ -141,16 +157,12 @@ export const contextCommand = {
141
157
  process.stdout.write(` • ${m.hint.id} ${m.hint.title}\n`);
142
158
  }
143
159
  }
144
- // Default human text mode keeps the output short. The long
145
- // context body is one flag away via `--full`. JSON / commands-first /
146
- // markdown paths are unchanged.
147
- if (commandsFirst || !wantsFull) {
148
- if (!wantsFull) {
149
- process.stdout.write('\n(text mode is summary-only pass --full for the long context body, --json for machine output.)\n');
150
- }
151
- else if (isActionLike && !flagBool(args, 'commands-first')) {
152
- process.stdout.write('\n(action-like task → commands-first; pass --full to see the long context body.)\n');
153
- }
160
+ // The full context body prints by default now parity with why / reuse /
161
+ // knowledge get, so the agent's first read of the project isn't thinner than
162
+ // every adjacent command. `--summary`/`--brief` (or `--commands-first`) opts
163
+ // back into the terse view.
164
+ if (wantsSummary || commandsOnly) {
165
+ process.stdout.write('\n(summary mode — omit --summary for the full context body, --json for machine output.)\n');
154
166
  return 0;
155
167
  }
156
168
  process.stdout.write('\n');
@@ -1 +1 @@
1
- {"version":3,"file":"gate.command.d.ts","sourceRoot":"","sources":["../../src/commands/gate.command.ts"],"names":[],"mappings":"AAaA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAGhC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,EAAE,eAiMzB,CAAC"}
1
+ {"version":3,"file":"gate.command.d.ts","sourceRoot":"","sources":["../../src/commands/gate.command.ts"],"names":[],"mappings":"AAaA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAGhC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,EAAE,eAsMzB,CAAC"}
@@ -143,7 +143,12 @@ export const gateCommand = {
143
143
  : {}),
144
144
  impact: {
145
145
  ...(sinceRef ? { sinceRef } : {}),
146
- ...(failOn ? { failOn } : {}),
146
+ // Blast-radius risk is inherently PRE-EXISTING structure (touching a hub
147
+ // is risky but not a new failure this change introduced), so the composite
148
+ // gate treats it as ADVISORY by default — `failOn: []` warns instead of
149
+ // redding, keeping the verdict change-attributable. `--fail-on critical`
150
+ // opts into a hard fail; `--strict` escalates the advisory warn.
151
+ failOn: failOn ?? [],
147
152
  // Scope the impact gate to the changeset too: with `--since` we keep the
148
153
  // gitref diff; with `--changed-only` / `--staged` / `--files` (and no
149
154
  // `--since`) we analyze the resolved changed-file set directly.
@@ -1 +1 @@
1
- {"version":3,"file":"gen.command.d.ts","sourceRoot":"","sources":["../../src/commands/gen.command.ts"],"names":[],"mappings":"AASA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAkBhC,eAAO,MAAM,UAAU,EAAE,eA+JxB,CAAC"}
1
+ {"version":3,"file":"gen.command.d.ts","sourceRoot":"","sources":["../../src/commands/gen.command.ts"],"names":[],"mappings":"AASA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAuBhC,eAAO,MAAM,UAAU,EAAE,eAsNxB,CAAC"}
@@ -1,8 +1,10 @@
1
1
  import { inspectSharkcraft } from '@shrkcrft/inspector';
2
2
  import { buildSavedPlan, FileChangeType, generate, OverwriteStrategy, savePlanToFile, signPlan, } from '@shrkcrft/generator';
3
3
  import { flagBool, flagString, flagVars, resolveCwd, } from "../command-registry.js";
4
+ import * as nodePath from 'node:path';
4
5
  import { asJson, header } from "../output/format-output.js";
5
6
  import { printError } from "../output/print-error.js";
7
+ import { typecheckEmittedFiles, } from "../validation/typecheck-emitted.js";
6
8
  const CHANGE_LABEL = {
7
9
  [FileChangeType.Create]: 'CREATE',
8
10
  [FileChangeType.Update]: 'UPDATE',
@@ -19,7 +21,7 @@ const CHANGE_LABEL = {
19
21
  export const genCommand = {
20
22
  name: 'gen',
21
23
  description: 'Generate code from a template. Defaults to dry-run.',
22
- usage: 'shrk gen <templateId> [<name>] [--var key=value ...] [--dry-run] [--write] [--force] [--save-plan <file>] [--show-content] [--json]',
24
+ usage: 'shrk gen <templateId> [<name>] [--var key=value ...] [--dry-run] [--write] [--force] [--save-plan <file>] [--print|--show-content] [--typecheck] [--json]\n (--typecheck compiles the emitted files against the detected tsconfig BEFORE apply — a template bug fails here, not at the human next build; --print shows the rendered bodies)',
23
25
  async run(args) {
24
26
  const templateId = args.positional[0];
25
27
  const name = args.positional[1];
@@ -40,19 +42,50 @@ export const genCommand = {
40
42
  : flagString(args, 'overwrite') ?? OverwriteStrategy.Never;
41
43
  const variables = flagVars(args);
42
44
  const savePlanPath = flagString(args, 'save-plan');
43
- const result = generate(template, {
45
+ const wantTypecheck = flagBool(args, 'typecheck');
46
+ const genOpts = {
44
47
  templateId: template.id,
45
48
  name,
46
49
  variables,
47
50
  projectRoot: inspection.projectRoot,
48
51
  overwriteStrategy: overwrite,
49
- write,
50
- });
52
+ };
53
+ // --typecheck is a PRE-WRITE gate. Render a dry-run FIRST, compile the emitted
54
+ // (full-file create) TS/TSX against the detected tsconfig in memory, and only
55
+ // proceed to write if they compile — so an agent driving gen→apply never lands
56
+ // non-compiling code. On failure the write is refused (nothing touches disk).
57
+ // Only whole-file creates are checkable standalone (update ops are fragments).
58
+ let typecheckResult;
59
+ let effectiveWrite = write;
60
+ let result;
61
+ if (wantTypecheck) {
62
+ const dry = generate(template, { ...genOpts, write: false });
63
+ if (!dry.ok) {
64
+ printError(dry.error);
65
+ return 1;
66
+ }
67
+ const emitted = dry.value.plan.changes
68
+ .filter((c) => c.type === FileChangeType.Create)
69
+ .map((c) => ({
70
+ absPath: nodePath.resolve(inspection.projectRoot, c.relativePath),
71
+ contents: c.contents ?? '',
72
+ }))
73
+ .filter((f) => f.contents.length > 0 && /\.tsx?$/.test(f.absPath));
74
+ typecheckResult = typecheckEmittedFiles(inspection.projectRoot, emitted);
75
+ // Refuse to write non-compiling output — the whole point of the gate.
76
+ effectiveWrite = write && typecheckResult.errors.length === 0;
77
+ result = effectiveWrite ? generate(template, { ...genOpts, write: true }) : dry;
78
+ }
79
+ else {
80
+ result = generate(template, { ...genOpts, write });
81
+ }
51
82
  if (!result.ok) {
52
83
  printError(result.error);
53
84
  return 1;
54
85
  }
55
86
  const { plan, summary, written } = result.value;
87
+ const typecheckFailed = (typecheckResult?.errors.length ?? 0) > 0;
88
+ const writeRefused = write && typecheckFailed;
56
89
  // --save-plan is allowed regardless of write/dry-run, but we refuse to
57
90
  // save plans that have conflicts (since `shrk apply` would refuse them too).
58
91
  if (savePlanPath) {
@@ -89,10 +122,12 @@ export const genCommand = {
89
122
  summary,
90
123
  written: written.map((w) => w.relativePath),
91
124
  savedPlanPath: savePlanPath ?? null,
125
+ ...(typecheckResult ? { typecheck: typecheckResult } : {}),
126
+ ...(writeRefused ? { writeRefused: true } : {}),
92
127
  }) + '\n');
93
- return plan.hasConflicts ? 1 : 0;
128
+ return plan.hasConflicts || typecheckFailed ? 1 : 0;
94
129
  }
95
- process.stdout.write(header(write ? `Generation: ${template.id}` : `Dry-run: ${template.id}`));
130
+ process.stdout.write(header(effectiveWrite ? `Generation: ${template.id}` : `Dry-run: ${template.id}`));
96
131
  if (plan.warnings.length) {
97
132
  process.stdout.write('Warnings:\n');
98
133
  for (const w of plan.warnings)
@@ -118,7 +153,7 @@ export const genCommand = {
118
153
  // agent can review what a template would generate WITHOUT writing to disk
119
154
  // (the saved plan stays content-free). The bytes are identical in dry-run
120
155
  // and --write; this is purely additive.
121
- if (flagBool(args, 'show-content')) {
156
+ if (flagBool(args, 'show-content') || flagBool(args, 'print')) {
122
157
  process.stdout.write('\nVirtual content (not written to disk):\n');
123
158
  for (const change of plan.changes) {
124
159
  const body = change.contents ?? '';
@@ -139,10 +174,31 @@ export const genCommand = {
139
174
  if (updateLike.length > 0) {
140
175
  process.stdout.write(`\nHUMAN REVIEW REQUIRED — ${updateLike.length} update entry/entries modify existing files.\n`);
141
176
  }
177
+ if (typecheckResult) {
178
+ if (!typecheckResult.ran) {
179
+ process.stdout.write(`\nTypecheck: skipped — ${typecheckResult.note ?? 'nothing to check'}.\n`);
180
+ }
181
+ else if (typecheckResult.errors.length === 0) {
182
+ process.stdout.write('\nTypecheck: ✓ emitted files compile against the detected tsconfig.\n');
183
+ }
184
+ else {
185
+ process.stdout.write(`\nTypecheck: ✗ ${typecheckResult.errors.length} error(s) in the emitted files — a template bug (NOT applied):\n`);
186
+ for (const e of typecheckResult.errors.slice(0, 30)) {
187
+ const rel = nodePath.relative(inspection.projectRoot, e.file) || e.file;
188
+ process.stdout.write(` ${rel}:${e.line}:${e.column} ${e.message}\n`);
189
+ }
190
+ if (typecheckResult.errors.length > 30) {
191
+ process.stdout.write(` … (${typecheckResult.errors.length - 30} more)\n`);
192
+ }
193
+ if (writeRefused) {
194
+ process.stdout.write('\nWrite REFUSED — the emitted files do not typecheck (nothing written). Fix the template, then re-run.\n');
195
+ }
196
+ }
197
+ }
142
198
  process.stdout.write(`\nSummary: written=${summary.written}, skipped=${summary.skipped}, conflicts=${summary.conflicts}\n`);
143
- if (!write && !savePlanPath) {
199
+ if (!effectiveWrite && !savePlanPath && !writeRefused) {
144
200
  process.stdout.write('\nRe-run with --write to apply, or --save-plan <file> + `shrk apply`.\n');
145
201
  }
146
- return plan.hasConflicts ? 1 : 0;
202
+ return plan.hasConflicts || typecheckFailed ? 1 : 0;
147
203
  },
148
204
  };
@@ -1 +1 @@
1
- {"version":3,"file":"graph-code-subverbs.d.ts","sourceRoot":"","sources":["../../src/commands/graph-code-subverbs.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAiE,KAAK,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAgLxH,wBAAsB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBrE;AA4FD,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkEtE;AAiBD,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA2F1E;AAID,wBAAsB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA+EpE;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA+FtE;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAwEtE;AAID,wBAAsB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA2MvE;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA+HtE;AAID;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAgEpE;AAID,wBAAsB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAiGvE;AAyBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAgHpE"}
1
+ {"version":3,"file":"graph-code-subverbs.d.ts","sourceRoot":"","sources":["../../src/commands/graph-code-subverbs.ts"],"names":[],"mappings":"AA2BA,OAAO,EAAiE,KAAK,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAgLxH,wBAAsB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBrE;AA4FD,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkFtE;AAiBD,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA2F1E;AAID,wBAAsB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA+EpE;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA+FtE;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAwEtE;AAID,wBAAsB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA2MvE;AAID,wBAAsB,cAAc,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA+HtE;AAID;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAgEpE;AAID,wBAAsB,eAAe,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAiGvE;AAyBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAgHpE"}
@@ -285,7 +285,14 @@ export async function runGraphCycles(args) {
285
285
  const api = loadGuarded(() => GraphQueryApi.fromStore(cwd), wantJson);
286
286
  if (!api)
287
287
  return 1;
288
- const allCycles = api.cycles();
288
+ // Type-only import edges (`import type`, `export type … from`) are erased at
289
+ // emit time, so they cannot cause a runtime cycle — excluded by default.
290
+ // `--include-type-edges` opts back in for auditing. The type-only-loop delta
291
+ // is reported as a non-blocking bucket.
292
+ const includeTypeEdges = flagBool(args, 'include-type-edges');
293
+ const allCycles = api.cycles({ includeTypeEdges });
294
+ const runtimeCycles = includeTypeEdges ? api.cycles() : allCycles;
295
+ const typeOnlyLoopCount = Math.max(0, api.cycles({ includeTypeEdges: true }).length - runtimeCycles.length);
289
296
  const filtered = allCycles.filter((c) => c.size >= minSize);
290
297
  const limited = filtered.slice(0, limit);
291
298
  if (wantJson) {
@@ -293,6 +300,8 @@ export async function runGraphCycles(args) {
293
300
  ok: true,
294
301
  total: filtered.length,
295
302
  truncated: filtered.length > limit,
303
+ includeTypeEdges,
304
+ typeOnlyLoopCount,
296
305
  cycles: limited.map((c) => ({
297
306
  size: c.size,
298
307
  paths: c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, '')),
@@ -302,8 +311,11 @@ export async function runGraphCycles(args) {
302
311
  }
303
312
  process.stdout.write(header('Graph cycles'));
304
313
  process.stdout.write(kv('total', String(filtered.length)) + '\n');
314
+ if (!includeTypeEdges && typeOnlyLoopCount > 0) {
315
+ process.stdout.write(kv('type-only loops', `${typeOnlyLoopCount} (excluded — compile-time only; --include-type-edges to audit)`) + '\n');
316
+ }
305
317
  if (filtered.length === 0) {
306
- process.stdout.write('\nNo cycles in the file-import graph. ✓\n');
318
+ process.stdout.write(`\nNo ${includeTypeEdges ? '' : 'runtime '}cycles in the file-import graph. ✓\n`);
307
319
  return 0;
308
320
  }
309
321
  process.stdout.write(kv('shown', `${limited.length}/${filtered.length}`) + '\n');
@@ -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.
@@ -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;AAGhC,eAAO,MAAM,wBAAwB,EAAE,eAsCtC,CAAC;AAsIF,eAAO,MAAM,eAAe,EAAE,eA4B7B,CAAC"}
@@ -4,30 +4,57 @@
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";
16
19
  import { asJson } from "../output/format-output.js";
17
20
  export const registryLifecycleCommand = {
18
21
  name: 'lifecycle',
19
22
  description: 'Scan the workspace for register/remove symmetry. Read-only.',
20
- usage: 'shrk registry lifecycle [--scope <dir>] [--json]',
23
+ usage: 'shrk registry lifecycle [--scope <dir>] [--changed-only] [--since <ref>] [--json]',
21
24
  async run(args) {
22
25
  const cwd = resolveCwd(args);
23
26
  const scope = flagString(args, 'scope');
24
- const report = buildRegistryLifecycleReport({ projectRoot: cwd, ...(scope ? { scope } : {}) });
27
+ const changedOnly = flagBool(args, 'changed-only');
28
+ const since = flagString(args, 'since');
29
+ let files;
30
+ if (changedOnly || since) {
31
+ const changed = resolveChangedFiles({
32
+ projectRoot: cwd,
33
+ ...(since ? { since } : {}),
34
+ ...(changedOnly && !since ? { includeWorktree: true } : {}),
35
+ });
36
+ files = changed.files;
37
+ }
38
+ // Full-tree walk honors the project's skipDirs override.
39
+ let skipDirs;
40
+ if (files === undefined) {
41
+ const loaded = await resolveProjectConfig(cwd);
42
+ if (loaded.ok)
43
+ skipDirs = loaded.value.config.registryLifecycle?.skipDirs;
44
+ }
45
+ const report = buildRegistryLifecycleReport({
46
+ projectRoot: cwd,
47
+ ...(files !== undefined ? { files } : {}),
48
+ ...(scope ? { scope } : {}),
49
+ ...(skipDirs ? { skipDirs } : {}),
50
+ });
51
+ const exit = report.timedOut ? 2 : report.missingRemovers.length === 0 ? 0 : 1;
25
52
  if (flagBool(args, 'json')) {
26
53
  process.stdout.write(asJson(report) + '\n');
27
- return report.missingRemovers.length === 0 ? 0 : 1;
54
+ return exit;
28
55
  }
29
56
  process.stdout.write(renderRegistryLifecycleReportText(report));
30
- return report.missingRemovers.length === 0 ? 0 : 1;
57
+ return exit;
31
58
  },
32
59
  };
33
60
  async function loadRegistries(cwd) {
@@ -87,15 +114,42 @@ async function runRegistryInventory(args, name) {
87
114
  }
88
115
  if (action === 'exists') {
89
116
  if (!id) {
90
- process.stderr.write(`Usage: shrk registry ${name} exists <id>\n`);
117
+ process.stderr.write(`Usage: shrk registry ${name} exists <id> [--resolve] [--fail-if-taken|--fail-if-missing]\n`);
91
118
  return 2;
92
119
  }
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;
120
+ const failIfTaken = flagBool(args, 'fail-if-taken');
121
+ const failIfMissing = flagBool(args, 'fail-if-missing');
122
+ if (failIfTaken && failIfMissing) {
123
+ process.stderr.write('Pass at most one of --fail-if-taken / --fail-if-missing.\n');
124
+ return 2;
125
+ }
126
+ // `--resolve` maps a human noun to the canonical registered id via the
127
+ // registry's `aliases` map before the existence test — so a duplicate guard
128
+ // can't return a false "free" on a synonym of an already-taken slug.
129
+ const doResolve = flagBool(args, 'resolve');
130
+ const canonical = doResolve ? (decl.aliases?.[id] ?? id) : id;
131
+ const resolved = canonical !== id;
132
+ const exists = registryExists(inventory, canonical);
133
+ // Exit-code convention:
134
+ // --fail-if-taken → non-zero when the id is already registered (free → 0),
135
+ // so `exists <id> --fail-if-taken && <author>` is a natural guard.
136
+ // --fail-if-missing → non-zero when the id is NOT registered (the consume-side check).
137
+ // neither → the historical query convention (taken → 0, free → 1).
138
+ const code = failIfTaken ? (exists ? 1 : 0) : exists ? 0 : 1;
139
+ if (json) {
140
+ process.stdout.write(asJson({
141
+ name: inventory.name,
142
+ id,
143
+ ...(resolved ? { resolvedId: canonical } : {}),
144
+ exists,
145
+ exitCode: code,
146
+ }) + '\n');
147
+ return code;
148
+ }
149
+ if (resolved)
150
+ process.stdout.write(`resolved "${id}" → "${canonical}" (alias)\n`);
151
+ process.stdout.write(`${exists ? 'yes' : 'no'} — "${canonical}" is ${exists ? 'declared' : 'NOT declared'} in registry "${inventory.name}".\n`);
152
+ return code;
99
153
  }
100
154
  if (action === 'where') {
101
155
  if (!id) {
@@ -124,7 +178,11 @@ async function runRegistryInventory(args, name) {
124
178
  export const registryCommand = {
125
179
  name: 'registry',
126
180
  description: 'Registry inspections: lifecycle symmetry + declared-registry inventory. Read-only.',
127
- usage: 'shrk registry lifecycle | <name> list | <name> exists <id> | <name> where <id>',
181
+ usage: 'shrk registry lifecycle | <name> list | <name> exists <id> [--resolve] [--fail-if-taken|--fail-if-missing] | <name> where <id>',
182
+ // Guard-mode + query flags take no value — declare them so `exists <id>
183
+ // --fail-if-taken` (flag last) and `exists --resolve <id>` (flag first) both
184
+ // keep the id as a positional instead of swallowing it.
185
+ booleanFlags: new Set(['json', 'resolve', 'fail-if-taken', 'fail-if-missing']),
128
186
  async run(args) {
129
187
  const sub = args.positional[0];
130
188
  if (sub === 'lifecycle') {
@@ -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":"AAGA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA2GhC,eAAO,MAAM,YAAY,EAAE,eA0N1B,CAAC"}