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

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.
@@ -1 +1 @@
1
- {"version":3,"file":"run-finish.d.ts","sourceRoot":"","sources":["../../src/finish/run-finish.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAa7B,eAAO,MAAM,aAAa,EAAG,sBAA+B,CAAC;AAE7D,2FAA2F;AAC3F,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC1E,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;CACxC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,OAAO,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;QACzD,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;QAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;KAC5B,CAAC;IACF,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAClC,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,oBAAoB,CAAC;CACtC;AAYD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,CAkHnF"}
1
+ {"version":3,"file":"run-finish.d.ts","sourceRoot":"","sources":["../../src/finish/run-finish.ts"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAiB7B,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE5C,eAAO,MAAM,aAAa,EAAG,sBAA+B,CAAC;AAQ7D,2FAA2F;AAC3F,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,SAAS,GAAG,QAAQ,GAAG,YAAY,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;IAClG,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC;;;;;;OAMG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,OAAO,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;QACzD,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;QAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;KAC5B,CAAC;IACF,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,cAAc,CAAC;IACnD,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,oBAAoB,CAAC;CACtC;AAmBD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,CAmJnF"}
@@ -1,9 +1,23 @@
1
- import { buildImportHygieneReport, filterViolationsToChangedScope, inspectSharkcraft, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
1
+ import { buildImportHygieneReport, filterViolationsToChangedScope, gitShowFile, inspectSharkcraft, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
2
2
  import { existsSync } from 'node:fs';
3
3
  import * as nodePath from 'node:path';
4
- import { evaluateBoundaries, loadTsconfigPaths, runPolicyLint, runWiring, scanImports, } from '@shrkcrft/boundaries';
4
+ import { buildRegistrationGraph, evaluateBoundaries, loadTsconfigPaths, providedTokensFromEntries, registrationTouchesChanged, registrationUnprovided, runPolicyLint, runWiring, scanImports, } from '@shrkcrft/boundaries';
5
5
  import { computeDeletedOrphans } from "../diff/deleted-orphans.js";
6
+ import { ExitCode } from "../exit-codes.js";
6
7
  export const FINISH_SCHEMA = 'sharkcraft.finish/v1';
8
+ /** Files in the boundary/import/wiring domain — a change outside it evaluates nothing there. */
9
+ const CODE_FILE = /\.(?:m|c)?[jt]sx?$/;
10
+ function codeFilesOf(files) {
11
+ return files.filter((f) => CODE_FILE.test(f));
12
+ }
13
+ /** The base ref the unprovided gate diffs against to spot removed providers. */
14
+ function baseRefFor(input) {
15
+ if (input.mode === 'files')
16
+ return undefined; // an explicit file list has no diff base
17
+ if (input.mode === 'since' && input.scope.since)
18
+ return input.scope.since;
19
+ return 'HEAD'; // worktree + staged both diff vs HEAD
20
+ }
7
21
  /** Map the changed-scope onto the orphan check's diff inputs. */
8
22
  function orphanOptsFor(input) {
9
23
  if (input.mode === 'files')
@@ -29,6 +43,11 @@ export async function runFinishGates(input) {
29
43
  const { cwd } = input;
30
44
  const changed = resolveChangedFiles(input.scope);
31
45
  const changedFiles = changed.files;
46
+ // The boundary/import/wiring engines only reason about code files. A change to
47
+ // non-code files (docs, JSON, config) evaluates NOTHING in those gates, so they
48
+ // must SKIP, not trivially pass — otherwise a markdown-only change paints green
49
+ // ("evaluated nothing" must read as not-verified, never a `0`).
50
+ const codeChanged = codeFilesOf(changedFiles);
32
51
  const gates = [];
33
52
  // ── boundaries (changed-only) ────────────────────────────────────────
34
53
  const inspection = await inspectSharkcraft({ cwd });
@@ -36,8 +55,8 @@ export async function runFinishGates(input) {
36
55
  if (boundaryRules.length === 0) {
37
56
  gates.push(skip('boundaries', 'no boundary rules configured'));
38
57
  }
39
- else if (changedFiles.length === 0) {
40
- gates.push(skip('boundaries', 'no files in changed scope'));
58
+ else if (codeChanged.length === 0) {
59
+ gates.push(skip('boundaries', 'no code files in changed scope'));
41
60
  }
42
61
  else {
43
62
  const scan = scanImports({ projectRoot: cwd });
@@ -62,11 +81,11 @@ export async function runFinishGates(input) {
62
81
  });
63
82
  }
64
83
  // ── import hygiene (changed-only) ────────────────────────────────────
65
- if (changedFiles.length === 0) {
66
- gates.push(skip('imports', 'no files in changed scope'));
84
+ if (codeChanged.length === 0) {
85
+ gates.push(skip('imports', 'no code files in changed scope'));
67
86
  }
68
87
  else {
69
- const report = buildImportHygieneReport(cwd, { files: changedFiles });
88
+ const report = buildImportHygieneReport(cwd, { files: codeChanged });
70
89
  const errors = report.counts?.['error'] ?? (report.verdict === 'errors' ? report.findings.length : 0);
71
90
  const warnings = report.counts?.['warning'] ?? (report.verdict === 'warnings' ? report.findings.length : 0);
72
91
  gates.push({
@@ -96,34 +115,61 @@ export async function runFinishGates(input) {
96
115
  ? `config did not load: ${loaded.error.message}`
97
116
  : 'no sharkcraft config (gate not applicable)';
98
117
  gates.push(skip('wiring', detail));
118
+ gates.push(skip('unprovided', detail));
99
119
  gates.push(skip('policy', detail));
100
120
  }
101
121
  else {
102
- gates.push(wiringGate(cwd, loaded.value.config.wiringRules ?? [], changedFiles), policyGate(cwd, loaded.value.config.policyRules ?? [], changedFiles));
122
+ gates.push(wiringGate(cwd, loaded.value.config.wiringRules ?? [], changedFiles), unprovidedGate(cwd, loaded.value.config.registrationGraph ?? [], changedFiles, baseRefFor(input)), policyGate(cwd, loaded.value.config.policyRules ?? [], changedFiles));
103
123
  }
104
124
  // ── deleted-orphans (write-safety) ───────────────────────────────────
105
125
  gates.push(await orphansGate(input));
126
+ // ── architecture (advisory): cycles the change participates in ───────
127
+ gates.push(await archGate(cwd, changedFiles));
106
128
  // ── impact summary (informational, best-effort) ──────────────────────
107
129
  const impact = await impactSummary(cwd, changedFiles);
108
- // ── fold into one verdict ────────────────────────────────────────────
109
- const failed = gates.filter((g) => g.status === 'fail');
130
+ // ── fold into one honest 0/1/2 verdict ───────────────────────────────
131
+ // Only NON-advisory ("deciding") gates decide the verdict. A deciding fail (or
132
+ // a config that could not load) is `1`. Otherwise, if NO deciding gate actually
133
+ // evaluated anything (all skipped), the run verified nothing → `2` (never a
134
+ // green `0`). Only when at least one deciding gate ran over a real scope and
135
+ // none failed is it a true `0`.
136
+ const deciding = gates.filter((g) => !g.advisory);
137
+ const failed = deciding.filter((g) => g.status === 'fail');
138
+ const anyEvaluated = deciding.some((g) => g.status === 'pass');
110
139
  const warnings = gates.reduce((n, g) => n + g.warnings, 0);
111
- const verdict = failed.length > 0 || configError ? 'fail' : 'pass';
140
+ let verdict;
141
+ let exit;
142
+ if (failed.length > 0 || configError) {
143
+ verdict = 'fail';
144
+ exit = ExitCode.Failure;
145
+ }
146
+ else if (!anyEvaluated) {
147
+ verdict = 'not-verified';
148
+ exit = ExitCode.NotVerified;
149
+ }
150
+ else {
151
+ verdict = 'pass';
152
+ exit = ExitCode.VerifiedPass;
153
+ }
112
154
  const skipped = gates.filter((g) => g.status === 'skipped').map((g) => g.name);
155
+ const advisoryWarned = gates.filter((g) => g.advisory && g.warnings > 0).map((g) => g.name);
113
156
  const summary = verdict === 'fail'
114
157
  ? `Not safe to finish: ${configError ? 'config failed to load; ' : ''}${failed.map((g) => `${g.name} (${g.errors} error(s))`).join(', ') || 'see gates'}.`
115
- : changedFiles.length === 0 && input.mode !== 'files'
116
- ? 'No files in the changed scope nothing to gate.'
117
- : `Safe to finish: every applicable gate passed${warnings > 0 ? ` (${warnings} non-blocking warning(s))` : ''}${skipped.length > 0 ? `; skipped: ${skipped.join(', ')}` : ''}.`;
158
+ : verdict === 'not-verified'
159
+ ? `Not verified: no gate evaluated the changed scope${changedFiles.length === 0 ? ' (nothing changed)' : ' (nothing in it is gate-relevant)'} — this is NOT a pass. Re-run over a scope with code changes, or gate explicitly.`
160
+ : `Safe to finish: every applicable gate passed${warnings > 0 ? ` (${warnings} non-blocking warning(s)${advisoryWarned.length > 0 ? ` — see ${advisoryWarned.join(', ')}` : ''})` : ''}${skipped.length > 0 ? `; skipped: ${skipped.join(', ')}` : ''}.`;
118
161
  const nextAction = verdict === 'fail'
119
162
  ? 'Fix every failing gate item (each carries file:line), then re-run `shrk finish`.'
120
- : 'Safe to declare done.';
163
+ : verdict === 'not-verified'
164
+ ? 'Nothing was verified — do not treat this as done.'
165
+ : 'Safe to declare done.';
121
166
  return {
122
167
  schema: FINISH_SCHEMA,
123
168
  scope: { mode: input.mode, files: changedFiles, fileCount: changedFiles.length },
124
169
  gates,
125
170
  impact,
126
171
  verdict,
172
+ exit,
127
173
  warnings,
128
174
  ...(configError ? { configError } : {}),
129
175
  summary,
@@ -133,6 +179,10 @@ export async function runFinishGates(input) {
133
179
  function skip(name, detail) {
134
180
  return { name, status: 'skipped', detail, errors: 0, warnings: 0, items: [] };
135
181
  }
182
+ /** A skipped ADVISORY gate — reports its detail but never decides the verdict. */
183
+ function advisorySkip(name, detail) {
184
+ return { name, status: 'skipped', detail, errors: 0, warnings: 0, items: [], advisory: true };
185
+ }
136
186
  function wiringGate(cwd, rules, changedFiles) {
137
187
  if (rules.length === 0)
138
188
  return skip('wiring', 'no wiring rules configured');
@@ -188,6 +238,126 @@ function policyGate(cwd, rules, changedFiles) {
188
238
  ],
189
239
  };
190
240
  }
241
+ /**
242
+ * The runtime-wiring `unprovided` gate — the silent-at-runtime class imports
243
+ * can't see: a token DECLARED or INJECTED but never PROVIDED (typecheck-green,
244
+ * absent at runtime). Backed by the registration/DI graph, scoped to the
245
+ * changeset so only tokens THIS change touches decide the verdict. Skips (never
246
+ * fails) when no idioms are configured or the change touched no wiring — so a
247
+ * repo that never modeled its DI is not penalized.
248
+ */
249
+ function unprovidedGate(cwd, idioms, changedFiles, baseRef) {
250
+ if (idioms.length === 0)
251
+ return skip('unprovided', 'no registration idioms configured');
252
+ const graph = buildRegistrationGraph(cwd, idioms);
253
+ const diag = graph.diagnostics;
254
+ // Two ways THIS change can leave a token unprovided:
255
+ // (1) a declared/injected site added in a changed file with no provider —
256
+ // caught by scoping the graph query to the changed files;
257
+ // (2) the last PROVIDER removed from a changed file — which leaves NO site in
258
+ // the changed file, so (1) structurally can't see it. Recover it by
259
+ // diffing the base content of the changed files (providerRegressions).
260
+ const scoped = registrationUnprovided(graph, changedFiles);
261
+ const regressions = baseRef ? providerRegressions(cwd, idioms, graph, changedFiles, baseRef) : [];
262
+ const byToken = new Map();
263
+ for (const u of [...scoped, ...regressions])
264
+ if (!byToken.has(u.token))
265
+ byToken.set(u.token, u);
266
+ const unprovided = [...byToken.values()].sort((a, b) => a.token.localeCompare(b.token));
267
+ // Skip (evaluated nothing) only when the change touched no registration site
268
+ // AND removed no provider AND has no misconfigured idiom — never a silent pass.
269
+ if (!registrationTouchesChanged(graph, changedFiles) && unprovided.length === 0 && diag.length === 0) {
270
+ return skip('unprovided', 'no registration sites in the changed scope');
271
+ }
272
+ return {
273
+ name: 'unprovided',
274
+ status: unprovided.length > 0 || diag.length > 0 ? 'fail' : 'pass',
275
+ detail: `${unprovided.length} unprovided token(s) attributable to the change${diag.length > 0 ? `, ${diag.length} misconfigured idiom(s)` : ''}`,
276
+ errors: unprovided.length + diag.length,
277
+ warnings: 0,
278
+ items: [
279
+ ...unprovided.map((u) => {
280
+ const site = u.declared[0] ?? u.consumed[0];
281
+ return {
282
+ ...(site ? { file: site.file, line: site.line } : {}),
283
+ message: `"${u.token}" declared/injected but never provided (silent at runtime)`,
284
+ };
285
+ }),
286
+ ...diag.map((d) => ({ message: `misconfigured idiom: ${d}` })),
287
+ ],
288
+ };
289
+ }
290
+ /**
291
+ * Tokens whose LAST provider this change removed. Reads the base content of each
292
+ * changed code file, extracts what it USED to provide, and keeps any token now
293
+ * provided nowhere in the worktree but still declared/consumed — the runtime
294
+ * break (`inject(T)` → undefined) that deleting a provider registration causes,
295
+ * which leaves no site in the changed file for post-change scoping to catch.
296
+ */
297
+ function providerRegressions(cwd, idioms, graph, changedFiles, baseRef) {
298
+ const codeChanged = codeFilesOf(changedFiles);
299
+ if (codeChanged.length === 0)
300
+ return [];
301
+ const baseEntries = [];
302
+ for (const f of codeChanged) {
303
+ const content = gitShowFile(cwd, baseRef, f);
304
+ if (content !== null)
305
+ baseEntries.push({ path: f, content });
306
+ }
307
+ if (baseEntries.length === 0)
308
+ return [];
309
+ const wasProvided = providedTokensFromEntries(idioms, baseEntries);
310
+ const out = [];
311
+ for (const token of wasProvided) {
312
+ const node = graph.tokens.find((t) => t.token === token);
313
+ if (node && node.provided.length === 0 && (node.declared.length > 0 || node.consumed.length > 0)) {
314
+ out.push({ token, declared: node.declared, consumed: node.consumed });
315
+ }
316
+ }
317
+ return out;
318
+ }
319
+ /**
320
+ * Advisory architecture gate: does any changed file participate in a runtime
321
+ * import cycle? Distinct from the `boundaries` gate (which sees layer violations,
322
+ * not cycles). Deliberately ADVISORY — a pre-existing cycle a change merely
323
+ * touches must not be attributed to this changeset, so it reports as a
324
+ * non-blocking warning and never fails the composite. Type-only import edges are
325
+ * excluded (they erase at emit and can't cause a runtime cycle). Best-effort:
326
+ * a missing graph index degrades to skip, never fail.
327
+ */
328
+ async function archGate(cwd, changedFiles) {
329
+ const codeChanged = codeFilesOf(changedFiles);
330
+ if (codeChanged.length === 0)
331
+ return advisorySkip('arch', 'no code files in changed scope');
332
+ try {
333
+ const { GraphStore, GraphQueryApi } = await import('@shrkcrft/graph');
334
+ if (!new GraphStore(cwd).exists()) {
335
+ return advisorySkip('arch', 'code-graph index missing — run `shrk graph index`');
336
+ }
337
+ const api = GraphQueryApi.fromStore(cwd);
338
+ const cycles = api.cycles(); // runtime cycles only (type-only edges excluded)
339
+ const scope = new Set(codeChanged.map((f) => f.replace(/\\/g, '/').replace(/^\.\//, '')));
340
+ const touching = cycles.filter((c) => (c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, ''))).some((p) => scope.has(p)));
341
+ if (touching.length === 0) {
342
+ return advisorySkip('arch', `no changed file participates in an import cycle (${cycles.length} runtime cycle(s) in repo)`);
343
+ }
344
+ return {
345
+ name: 'arch',
346
+ status: 'pass', // advisory: reports cycles as warnings, never fails the composite
347
+ advisory: true,
348
+ detail: `${touching.length} changed file(s) participate in a runtime import cycle (advisory — not attributed to this change)`,
349
+ errors: 0,
350
+ warnings: touching.length,
351
+ items: touching.slice(0, 15).map((c) => {
352
+ const paths = c.paths ?? c.nodeIds.map((id) => id.replace(/^file:/, ''));
353
+ return { message: `import cycle (size ${c.size}): ${paths.join(' → ')}` };
354
+ }),
355
+ };
356
+ }
357
+ catch (e) {
358
+ return advisorySkip('arch', `arch check unavailable: ${e instanceof Error ? e.message : String(e)}`);
359
+ }
360
+ }
191
361
  async function orphansGate(input) {
192
362
  const opts = orphanOptsFor(input);
193
363
  if (!opts)
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,EACL,eAAe,EAKhB,MAAM,uBAAuB,CAAC;AA8X/B,wBAAgB,aAAa,IAAI,eAAe,CAiY/C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAiCrE;AAqID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAoDxE"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,EACL,eAAe,EAMhB,MAAM,uBAAuB,CAAC;AA8X/B,wBAAgB,aAAa,IAAI,eAAe,CAiY/C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA+CrE;AAwID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAoDxE"}
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadDotenv } from "./env/load-dotenv.js";
3
- import { CommandRegistry, extractGlobalCompress, extractGlobalCwd, parseArgs, } from "./command-registry.js";
4
- import { argvHasStrict, promoteForStrict } from "./exit-codes.js";
3
+ import { CommandRegistry, extractGlobalCompress, extractGlobalCwd, extractGlobalExitTrailer, parseArgs, } from "./command-registry.js";
4
+ import { argvHasExitTrailer, argvHasStrict, emitPipeExitSignal, promoteForStrict } from "./exit-codes.js";
5
5
  import { runCommandWithCompression } from "./output/output-compression.js";
6
6
  import { initCommand } from "./commands/init.command.js";
7
7
  import { inspectCommand } from "./commands/inspect.command.js";
@@ -516,15 +516,29 @@ export async function runCli(argv) {
516
516
  // and each command's own `--strict` semantics (e.g. `check --strict`) still
517
517
  // apply beneath this (a25 §1.1).
518
518
  const strict = argvHasStrict(argv);
519
+ // Derive the command path from an argv with the global --exit-trailer stripped:
520
+ // otherwise a LEADING `--exit-trailer` makes extractCommandPath break on the '-'
521
+ // and return "" — zeroing out both the pipe-exit signal and the usage record.
522
+ const command = extractCommandPath(extractGlobalExitTrailer(probeArgv).rest);
519
523
  let exitCode = 0;
520
524
  try {
521
525
  exitCode = promoteForStrict(await runCliInner(argv), strict);
526
+ // Keep the honest 0/1/2 verdict readable through a trailing pipe (the shape
527
+ // an agent reaches for first). A no-op for non-gate verbs. Skipped inside the
528
+ // smart-context worker child and the --compress re-run child, whose stdout is
529
+ // a pipe by construction — a spurious "stdout is piped" note there (the user
530
+ // never piped) would be noise, not signal.
531
+ if (!process.env.SHRK_WORKER_EXITCODE_FILE && !process.env.SHRK_COMPRESS_CHILD) {
532
+ emitPipeExitSignal(command, exitCode, {
533
+ piped: !process.stdout.isTTY,
534
+ trailer: argvHasExitTrailer(argv),
535
+ });
536
+ }
522
537
  return exitCode;
523
538
  }
524
539
  finally {
525
540
  // append one local usage entry. Failure is silent.
526
541
  try {
527
- const command = extractCommandPath(probeArgv);
528
542
  if (command.length > 0) {
529
543
  const flags = sanitizeFlagNames(probeArgv);
530
544
  const enabled = await isUsageEnabled(probeCwd ?? process.cwd());
@@ -566,8 +580,11 @@ async function runCliInner(argv) {
566
580
  const registry = buildRegistry();
567
581
  // Pre-parse the global --cwd so it can appear anywhere (incl. before the command).
568
582
  const { cwd: globalCwd, rest: cwdCleanArgv } = extractGlobalCwd(argv);
583
+ // Strip the global --exit-trailer here so no per-command flag-guard sees it;
584
+ // runCli reads it off the raw argv to decide whether to print the trailer.
585
+ const { rest: trailerCleanArgv } = extractGlobalExitTrailer(cwdCleanArgv);
569
586
  // Pre-parse the global --compress / --ccr output-compression flags.
570
- const { directive: compressDirective, rest: cleanArgv } = extractGlobalCompress(cwdCleanArgv);
587
+ const { directive: compressDirective, rest: cleanArgv } = extractGlobalCompress(trailerCleanArgv);
571
588
  const [first] = cleanArgv;
572
589
  // `--compress` / `--ccr` on a real command: re-run it and compress its stdout.
573
590
  // (Meta verbs like --help/--version and bare invocations are left untouched.)
@@ -1 +1 @@
1
- {"version":3,"file":"output-compression.d.ts","sourceRoot":"","sources":["../../src/output/output-compression.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAQvE;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,SAAS,EAAE,wBAAwB,EACnC,GAAG,EAAE,MAAM,GACV,MAAM,CAgDR"}
1
+ {"version":3,"file":"output-compression.d.ts","sourceRoot":"","sources":["../../src/output/output-compression.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAC;AAQvE;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,SAAS,MAAM,EAAE,EAC5B,SAAS,EAAE,wBAAwB,EACnC,GAAG,EAAE,MAAM,GACV,MAAM,CAmDR"}
@@ -32,7 +32,10 @@ export function runCommandWithCompression(childArgv, directive, cwd) {
32
32
  const res = spawnSync(runtime, [entry, ...childArgv], {
33
33
  encoding: 'utf8',
34
34
  maxBuffer: MAX_BUFFER,
35
- env: process.env,
35
+ // Mark the child so it suppresses the piped-stdout exit note: its stdout is
36
+ // captured here (always "piped") but the USER may not have piped anything —
37
+ // the parent decides the note from the real user-facing stdout instead.
38
+ env: { ...process.env, SHRK_COMPRESS_CHILD: '1' },
36
39
  });
37
40
  if (res.error) {
38
41
  process.stderr.write(`--compress: failed to run the command (${res.error.message}); emitting nothing.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shrkcrft/cli",
3
- "version": "0.1.0-alpha.26",
3
+ "version": "0.1.0-alpha.27",
4
4
  "description": "SharkCraft CLI (`shrk`): structured project intelligence for AI coding agents.",
5
5
  "license": "MIT",
6
6
  "author": "SharkCraft contributors",
@@ -47,38 +47,38 @@
47
47
  "typecheck": "tsc --noEmit -p tsconfig.json"
48
48
  },
49
49
  "dependencies": {
50
- "@shrkcrft/core": "^0.1.0-alpha.26",
51
- "@shrkcrft/compress": "^0.1.0-alpha.26",
52
- "@shrkcrft/config": "^0.1.0-alpha.26",
53
- "@shrkcrft/workspace": "^0.1.0-alpha.26",
54
- "@shrkcrft/knowledge": "^0.1.0-alpha.26",
55
- "@shrkcrft/context": "^0.1.0-alpha.26",
56
- "@shrkcrft/rules": "^0.1.0-alpha.26",
57
- "@shrkcrft/paths": "^0.1.0-alpha.26",
58
- "@shrkcrft/templates": "^0.1.0-alpha.26",
59
- "@shrkcrft/plugin-api": "^0.1.0-alpha.26",
60
- "@shrkcrft/dashboard": "^0.1.0-alpha.26",
61
- "@shrkcrft/dashboard-api": "^0.1.0-alpha.26",
62
- "@shrkcrft/pipelines": "^0.1.0-alpha.26",
63
- "@shrkcrft/presets": "^0.1.0-alpha.26",
64
- "@shrkcrft/boundaries": "^0.1.0-alpha.26",
65
- "@shrkcrft/graph": "^0.1.0-alpha.26",
66
- "@shrkcrft/rule-graph": "^0.1.0-alpha.26",
67
- "@shrkcrft/structural-search": "^0.1.0-alpha.26",
68
- "@shrkcrft/impact-engine": "^0.1.0-alpha.26",
69
- "@shrkcrft/context-planner": "^0.1.0-alpha.26",
70
- "@shrkcrft/architecture-guard": "^0.1.0-alpha.26",
71
- "@shrkcrft/framework-scanners": "^0.1.0-alpha.26",
72
- "@shrkcrft/api-surface-diff": "^0.1.0-alpha.26",
73
- "@shrkcrft/quality-gates": "^0.1.0-alpha.26",
74
- "@shrkcrft/migrate": "^0.1.0-alpha.26",
75
- "@shrkcrft/generator": "^0.1.0-alpha.26",
76
- "@shrkcrft/importer": "^0.1.0-alpha.26",
77
- "@shrkcrft/inspector": "^0.1.0-alpha.26",
78
- "@shrkcrft/ai": "^0.1.0-alpha.26",
79
- "@shrkcrft/embeddings": "^0.1.0-alpha.26",
80
- "@shrkcrft/shared": "^0.1.0-alpha.26",
81
- "@shrkcrft/mcp-server": "^0.1.0-alpha.26",
50
+ "@shrkcrft/core": "^0.1.0-alpha.27",
51
+ "@shrkcrft/compress": "^0.1.0-alpha.27",
52
+ "@shrkcrft/config": "^0.1.0-alpha.27",
53
+ "@shrkcrft/workspace": "^0.1.0-alpha.27",
54
+ "@shrkcrft/knowledge": "^0.1.0-alpha.27",
55
+ "@shrkcrft/context": "^0.1.0-alpha.27",
56
+ "@shrkcrft/rules": "^0.1.0-alpha.27",
57
+ "@shrkcrft/paths": "^0.1.0-alpha.27",
58
+ "@shrkcrft/templates": "^0.1.0-alpha.27",
59
+ "@shrkcrft/plugin-api": "^0.1.0-alpha.27",
60
+ "@shrkcrft/dashboard": "^0.1.0-alpha.27",
61
+ "@shrkcrft/dashboard-api": "^0.1.0-alpha.27",
62
+ "@shrkcrft/pipelines": "^0.1.0-alpha.27",
63
+ "@shrkcrft/presets": "^0.1.0-alpha.27",
64
+ "@shrkcrft/boundaries": "^0.1.0-alpha.27",
65
+ "@shrkcrft/graph": "^0.1.0-alpha.27",
66
+ "@shrkcrft/rule-graph": "^0.1.0-alpha.27",
67
+ "@shrkcrft/structural-search": "^0.1.0-alpha.27",
68
+ "@shrkcrft/impact-engine": "^0.1.0-alpha.27",
69
+ "@shrkcrft/context-planner": "^0.1.0-alpha.27",
70
+ "@shrkcrft/architecture-guard": "^0.1.0-alpha.27",
71
+ "@shrkcrft/framework-scanners": "^0.1.0-alpha.27",
72
+ "@shrkcrft/api-surface-diff": "^0.1.0-alpha.27",
73
+ "@shrkcrft/quality-gates": "^0.1.0-alpha.27",
74
+ "@shrkcrft/migrate": "^0.1.0-alpha.27",
75
+ "@shrkcrft/generator": "^0.1.0-alpha.27",
76
+ "@shrkcrft/importer": "^0.1.0-alpha.27",
77
+ "@shrkcrft/inspector": "^0.1.0-alpha.27",
78
+ "@shrkcrft/ai": "^0.1.0-alpha.27",
79
+ "@shrkcrft/embeddings": "^0.1.0-alpha.27",
80
+ "@shrkcrft/shared": "^0.1.0-alpha.27",
81
+ "@shrkcrft/mcp-server": "^0.1.0-alpha.27",
82
82
  "@huggingface/transformers": "^3.7.5"
83
83
  },
84
84
  "publishConfig": {