arkgate 4.8.15 → 4.8.16

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 (63) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +7 -5
  3. package/bin/ark-shared.mjs +2 -0
  4. package/bin/ark.mjs +17 -8
  5. package/bin/lib/analysis-engine.mjs +5 -5
  6. package/bin/lib/architecture-scan.mjs +17 -0
  7. package/bin/lib/baseline-key.mjs +2 -0
  8. package/bin/lib/config-contract.mjs +1 -1
  9. package/bin/lib/diagnostic-catalog.mjs +2 -0
  10. package/bin/lib/doctor-advisories.mjs +26 -0
  11. package/bin/lib/doctor-green-cite.mjs +139 -0
  12. package/bin/lib/doctor-human.mjs +98 -63
  13. package/bin/lib/doctor-next-actions.mjs +9 -0
  14. package/bin/lib/doctor-plan.mjs +6 -3
  15. package/bin/lib/field-install.mjs +47 -7
  16. package/bin/lib/first-run-help.mjs +1 -0
  17. package/bin/lib/improvement-compass-doctor.mjs +3 -1
  18. package/bin/lib/improvement-compass-map.mjs +3 -1
  19. package/bin/lib/invariant-coverage.mjs +121 -0
  20. package/bin/lib/invariant-tests-path.mjs +212 -0
  21. package/bin/lib/package-manager.mjs +8 -0
  22. package/bin/lib/prototype-shortcuts.mjs +224 -0
  23. package/bin/lib/remediation.mjs +11 -0
  24. package/bin/lib/start-preview.mjs +20 -1
  25. package/dist/{configTypes-Dt3DpVbd.d.ts → configTypes-VD0qcubY.d.ts} +2 -1
  26. package/dist/{diagnosticCatalog-BEg85XlE.d.ts → diagnosticCatalog-KWvGLI1U.d.ts} +24 -3
  27. package/dist/eslint/index.cjs +4 -4
  28. package/dist/eslint/index.d.ts +1 -1
  29. package/dist/eslint/index.js +4 -4
  30. package/dist/index.cjs +31 -31
  31. package/dist/index.d.ts +5 -5
  32. package/dist/index.js +31 -31
  33. package/dist/nestjs/index.cjs +1 -1
  34. package/dist/nestjs/index.d.ts +3 -3
  35. package/dist/nestjs/index.js +1 -1
  36. package/dist/runtime/index.cjs +10 -10
  37. package/dist/runtime/index.d.ts +6 -6
  38. package/dist/runtime/index.js +10 -10
  39. package/dist/{types-TBiv0WHL.d.ts → types-BSzRy2X1.d.ts} +1 -1
  40. package/dist/{types-CN9tVMPz.d.ts → types-D5GT5ZT8.d.ts} +1 -1
  41. package/docs/README.md +1 -1
  42. package/docs/agent-guide.md +2 -0
  43. package/docs/configuration.md +11 -3
  44. package/docs/develop.md +3 -1
  45. package/docs/diagnostics.md +25 -1
  46. package/docs/package-surface.md +1 -1
  47. package/docs/use.md +5 -3
  48. package/package.json +1 -1
  49. package/schemas/ark.config.schema.json +1 -1
  50. package/server.json +2 -2
  51. package/templates/agent-skills/README.md +1 -1
  52. package/templates/agent-skills/ark-adopt/SKILL.md +15 -0
  53. package/templates/agent-skills/ark-autopilot/SKILL.md +6 -1
  54. package/templates/agent-skills/ark-coverage/SKILL.md +1 -1
  55. package/templates/agent-skills/ark-explain/SKILL.md +2 -1
  56. package/templates/agent-skills/ark-explore/SKILL.md +19 -0
  57. package/templates/agent-skills/ark-place/SKILL.md +4 -0
  58. package/templates/skills/ark-adopt.md +15 -0
  59. package/templates/skills/ark-autopilot.md +6 -1
  60. package/templates/skills/ark-coverage.md +1 -1
  61. package/templates/skills/ark-explain.md +2 -1
  62. package/templates/skills/ark-explore.md +19 -0
  63. package/templates/skills/ark-place.md +4 -0
@@ -83,6 +83,37 @@ function addDevDependencyPreservingFormat(source, version) {
83
83
  return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
84
84
  }
85
85
 
86
+ function replaceArkgateDependencyPreservingFormat(source, version) {
87
+ const encoded = JSON.stringify(version);
88
+ const next = source.replace(/("arkgate"\s*:\s*)(?:"(?:\\.|[^"\\])*")/, `$1${encoded}`);
89
+ if (next === source) {
90
+ throw new Error('arkgate pin not found in package.json');
91
+ }
92
+ return next;
93
+ }
94
+
95
+ /** True when a caret/tilde/exact pin is an older semver than the CLI. Non-semver stays. */
96
+ function pinCoreIsBehind(declaredPin, cliVersion) {
97
+ const pinCore = String(declaredPin).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0];
98
+ const pinParts = pinCore.split('.').map((p) => Number.parseInt(p, 10));
99
+ const cliParts = String(cliVersion).split('.').map((p) => Number.parseInt(p, 10));
100
+ if (
101
+ pinParts.length < 1 ||
102
+ cliParts.length < 1 ||
103
+ !pinParts.every((n) => Number.isFinite(n)) ||
104
+ !cliParts.every((n) => Number.isFinite(n))
105
+ ) {
106
+ return false;
107
+ }
108
+ for (let i = 0; i < 3; i += 1) {
109
+ const p = pinParts[i] ?? 0;
110
+ const c = cliParts[i] ?? 0;
111
+ if (p < c) return true;
112
+ if (p > c) return false;
113
+ }
114
+ return false;
115
+ }
116
+
86
117
  const ARK_CHECK_BIN_RE = /\b(?:ark-check|arkgate-check)(?:\.mjs|\.js)?\b/;
87
118
  const ARK_CHECK_RUNNER_RE =
88
119
  /(?:^|[\s"'`;|&])(?:npx|pnpm|yarn|npm|bunx?|node)(?:\s|$)/;
@@ -379,13 +410,6 @@ export function pinArkgateDevDependency(root, opts = {}) {
379
410
  }
380
411
  const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
381
412
  const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
382
- if (typeof deps.arkgate === 'string' || typeof dev.arkgate === 'string') {
383
- return {
384
- changed: false,
385
- reason: 'already-present',
386
- version: deps.arkgate || dev.arkgate,
387
- };
388
- }
389
413
  const shipped = arkPackageVersion();
390
414
  const version =
391
415
  typeof opts.version === 'string' && opts.version
@@ -393,6 +417,22 @@ export function pinArkgateDevDependency(root, opts = {}) {
393
417
  : shipped
394
418
  ? `^${shipped}`
395
419
  : 'latest';
420
+ if (typeof deps.arkgate === 'string' || typeof dev.arkgate === 'string') {
421
+ const current = deps.arkgate || dev.arkgate;
422
+ const shouldBump =
423
+ opts.force === true || (Boolean(shipped) && pinCoreIsBehind(current, shipped));
424
+ if (!shouldBump || current === version) {
425
+ return {
426
+ changed: false,
427
+ reason: 'already-present',
428
+ version: current,
429
+ };
430
+ }
431
+ if (opts.write !== false) {
432
+ fs.writeFileSync(pkgPath, replaceArkgateDependencyPreservingFormat(source, version));
433
+ }
434
+ return { changed: true, reason: 'bumped', version };
435
+ }
396
436
  if (opts.write !== false) {
397
437
  fs.writeFileSync(pkgPath, addDevDependencyPreservingFormat(source, version));
398
438
  }
@@ -13,6 +13,7 @@ ${NORTH_STAR_ONE_LINE}
13
13
  arkgate start --apply write host + CI setup
14
14
  (refuses weak coverage/shape; lock with --archetype/--preset/--force)
15
15
  arkgate-check --doctor status — one next step
16
+ (if missing: npx --package=arkgate arkgate-check --doctor)
16
17
 
17
18
  Stuck? Run status (--doctor). Do #1.
18
19
  `;
@@ -45,7 +45,9 @@ export function buildDoctorImprovementCompass(input = {}) {
45
45
  if (
46
46
  id.startsWith('ARKRULE_') ||
47
47
  id === 'INVARIANT_UNCOVERED' ||
48
- id === 'INVARIANT_CATALOG_EMPTY'
48
+ id === 'INVARIANT_CATALOG_EMPTY' ||
49
+ id === 'INVARIANT_TESTS_PATH_MISSING' ||
50
+ id === 'INVARIANT_COVERAGE_ROOTS_MISSING'
49
51
  )
50
52
  arkRulesStructureResidual += 1;
51
53
  }
@@ -262,7 +262,9 @@ function mapViolations(byId, violations) {
262
262
  }
263
263
  if (upper.startsWith('ARKRULE_') ||
264
264
  upper === 'INVARIANT_UNCOVERED' ||
265
- upper === 'INVARIANT_CATALOG_EMPTY') {
265
+ upper === 'INVARIANT_CATALOG_EMPTY' ||
266
+ upper === 'INVARIANT_TESTS_PATH_MISSING' ||
267
+ upper === 'INVARIANT_COVERAGE_ROOTS_MISSING') {
266
268
  attach('encapsulation', 'ArkRules structure / invariant residual inside a layer.', {
267
269
  kind: 'skill',
268
270
  ref: '/ark-autopilot',
@@ -8,6 +8,12 @@
8
8
  * Pure CLI helper (bin/lib/invariant-coverage.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ /** Adopted + catalogued invariants, but no declared tests path (P2 §10). */
12
+ export const INVARIANT_TESTS_PATH_RULE_ID = 'INVARIANT_TESTS_PATH_MISSING';
13
+ export const INVARIANT_TESTS_PATH_MESSAGE = 'This project is adopted and has domain invariants, but ark.config.json does not name a real tests path. Add coverage.testGlobs or coverage.coverageRoots pointing at the folder where those tests live, then re-run. Without that path, coverage is an empty checkbox.';
14
+ /** Enforced invariant, but no declared runner roots (P2 §10 residual). */
15
+ export const INVARIANT_COVERAGE_ROOTS_RULE_ID = 'INVARIANT_COVERAGE_ROOTS_MISSING';
16
+ export const INVARIANT_COVERAGE_ROOTS_MESSAGE = 'A domain invariant is enforced, but ark.config.json does not name coverage.coverageRoots — the folders where this project\'s test runner actually goes. Add coverage.coverageRoots pointing at that folder, then re-run. Without it, coverage can certify a test no runner runs.';
11
17
  /**
12
18
  * Human-readable discard tail. Empty when the scan discarded nothing.
13
19
  * `omitBudget` drops the budget clause and the load totals for messages whose
@@ -149,6 +155,7 @@ export function evaluateInvariantCoverage(input) {
149
155
  description: inv.description,
150
156
  ...(testEvidenceFile !== undefined ? { testEvidenceFile } : {}),
151
157
  ...(outsideDeclaredRoots !== undefined ? { outsideDeclaredRoots } : {}),
158
+ coverageRootsDeclared: rootsDeclared,
152
159
  });
153
160
  // The covering test exists but sits outside the roots the project declared
154
161
  // its runner walks. ArkGate does not execute tests, so it cannot tell the
@@ -233,5 +240,119 @@ export function canPromoteInvariant(coverage) {
233
240
  reason: `Invariant ${coverage.invariantId} is covered only by ${coverage.testEvidenceFile ?? 'a test'}, outside the declared coverage roots; ArkGate cannot tell whether that test runs, so it will not promote on it.`,
234
241
  };
235
242
  }
243
+ // Explicit false only: hand-built evidence may omit the field. The evaluator
244
+ // always sets it. Promoting without roots would make OUTSIDE_ROOTS silent.
245
+ if (coverage.coverageRootsDeclared === false) {
246
+ return {
247
+ ok: false,
248
+ reason: `Declare coverage.coverageRoots in ark.config.json before promoting ${coverage.invariantId} to enforced. Without that, ArkGate cannot tell whether a covering test is one the runner executes.`,
249
+ };
250
+ }
236
251
  return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
237
252
  }
253
+ function nonEmptyPathStrings(value) {
254
+ if (!Array.isArray(value))
255
+ return [];
256
+ const out = [];
257
+ for (const item of value) {
258
+ if (typeof item !== 'string')
259
+ continue;
260
+ const trimmed = item.trim();
261
+ if (trimmed.length > 0)
262
+ out.push(trimmed);
263
+ }
264
+ return out;
265
+ }
266
+ /**
267
+ * Declared tests homes: `coverage.testGlobs` and/or `coverage.coverageRoots`.
268
+ * Either is a configured path. Empty strings do not count.
269
+ */
270
+ export function configuredInvariantTestsPaths(coverage) {
271
+ if (!coverage || typeof coverage !== 'object')
272
+ return [];
273
+ return [...nonEmptyPathStrings(coverage.testGlobs), ...nonEmptyPathStrings(coverage.coverageRoots)];
274
+ }
275
+ export function hasConfiguredInvariantTestsPath(coverage) {
276
+ return configuredInvariantTestsPaths(coverage).length > 0;
277
+ }
278
+ /**
279
+ * True when at least one catalogued invariant wants test evidence.
280
+ * Same default as AR10: `coverage.test !== false`. `test: false` is an explicit
281
+ * opt-out (starter Domain phrases use it) and does not demand a tests path.
282
+ */
283
+ export function catalogDemandsInvariantTestsPath(invariants) {
284
+ if (!Array.isArray(invariants) || invariants.length === 0)
285
+ return false;
286
+ return invariants.some((inv) => inv != null && inv.coverage?.test !== false);
287
+ }
288
+ /**
289
+ * §10 — adopted + invariants that want tests require a real tests path.
290
+ * Fail-closed. Not freezable. Silent when not adopted, the catalog is empty,
291
+ * or every entry sets `coverage.test: false`.
292
+ */
293
+ export function collectMissingInvariantTestsPathFindings(input) {
294
+ const demanded = input.hasDomainInvariants === true ||
295
+ (input.hasDomainInvariants !== false && catalogDemandsInvariantTestsPath(input.invariants));
296
+ if (input.adopted !== true || !demanded)
297
+ return [];
298
+ const configured = hasConfiguredInvariantTestsPath(input.coverage);
299
+ if (configured && input.declaredPathPresent !== false)
300
+ return [];
301
+ return [
302
+ {
303
+ ruleId: INVARIANT_TESTS_PATH_RULE_ID,
304
+ message: INVARIANT_TESTS_PATH_MESSAGE,
305
+ file: 'ark.config.json',
306
+ line: 1,
307
+ severity: 'error',
308
+ failsStrict: true,
309
+ freezable: false,
310
+ },
311
+ ];
312
+ }
313
+ /**
314
+ * Declared runner homes: `coverage.coverageRoots` only.
315
+ * Empty strings do not count. testGlobs is not a runner root.
316
+ */
317
+ export function configuredCoverageRoots(coverage) {
318
+ if (!coverage || typeof coverage !== 'object')
319
+ return [];
320
+ return nonEmptyPathStrings(coverage.coverageRoots);
321
+ }
322
+ export function hasConfiguredCoverageRoots(coverage) {
323
+ return configuredCoverageRoots(coverage).length > 0;
324
+ }
325
+ /**
326
+ * True when at least one catalogued invariant is `mode: "enforced"`.
327
+ * Structure-sensor enforced is not this — only `invariants[]`.
328
+ */
329
+ export function catalogHasEnforcedInvariant(invariants) {
330
+ if (!Array.isArray(invariants) || invariants.length === 0)
331
+ return false;
332
+ return invariants.some((inv) => inv != null && inv.mode === 'enforced');
333
+ }
334
+ /**
335
+ * P2 §10 residual — any enforced invariant requires `coverage.coverageRoots`.
336
+ * Fail-closed. Not freezable. Silent when no invariant is enforced.
337
+ * testGlobs alone does not satisfy this: without roots, OUTSIDE_ROOTS cannot fire.
338
+ */
339
+ export function collectMissingCoverageRootsFindings(input) {
340
+ const enforced = input.hasEnforcedInvariant === true ||
341
+ (input.hasEnforcedInvariant !== false && catalogHasEnforcedInvariant(input.invariants));
342
+ if (!enforced)
343
+ return [];
344
+ const configured = hasConfiguredCoverageRoots(input.coverage);
345
+ if (configured && input.declaredPathPresent !== false)
346
+ return [];
347
+ return [
348
+ {
349
+ ruleId: INVARIANT_COVERAGE_ROOTS_RULE_ID,
350
+ message: INVARIANT_COVERAGE_ROOTS_MESSAGE,
351
+ file: 'ark.config.json',
352
+ line: 1,
353
+ severity: 'error',
354
+ failsStrict: true,
355
+ freezable: false,
356
+ },
357
+ ];
358
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Adopted-mode domain-invariant tests path (P2 §10 / Contener).
3
+ * Tooling I/O over Domain collectMissingInvariantTestsPathFindings.
4
+ * Fail-closed when adopted. Silent when not adopted or the catalog is empty.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import {
10
+ classifyAdopted,
11
+ isAdopted,
12
+ readAdoptionStance,
13
+ } from './adoption-stance.mjs';
14
+ import {
15
+ catalogDemandsInvariantTestsPath,
16
+ catalogHasEnforcedInvariant,
17
+ configuredCoverageRoots,
18
+ configuredInvariantTestsPaths,
19
+ collectMissingCoverageRootsFindings,
20
+ collectMissingInvariantTestsPathFindings,
21
+ hasConfiguredCoverageRoots,
22
+ hasConfiguredInvariantTestsPath,
23
+ INVARIANT_COVERAGE_ROOTS_MESSAGE,
24
+ INVARIANT_TESTS_PATH_MESSAGE,
25
+ } from './invariant-coverage.mjs';
26
+ import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
27
+
28
+ export const INVARIANT_TESTS_PATH_ASK = INVARIANT_TESTS_PATH_MESSAGE;
29
+
30
+ export const INVARIANT_TESTS_PATH_NEXT =
31
+ 'Add coverage.testGlobs or coverage.coverageRoots in ark.config.json pointing at a real tests folder, then re-run.';
32
+
33
+ export const INVARIANT_COVERAGE_ROOTS_ASK = INVARIANT_COVERAGE_ROOTS_MESSAGE;
34
+
35
+ export const INVARIANT_COVERAGE_ROOTS_NEXT =
36
+ 'Add coverage.coverageRoots in ark.config.json pointing at the folder the test runner uses, then re-run.';
37
+
38
+ /**
39
+ * --require-gates / --strict-merge, or an explicit advisory-only ack.
40
+ * GitHub required-merge is known later in doctor; the scan uses this cheap side.
41
+ *
42
+ * @param {string} root
43
+ * @param {{ requireGates?: boolean }} [args]
44
+ */
45
+ export function scanDemandsInvariantTestsPath(root, args = {}) {
46
+ if (args.requireGates === true) return true;
47
+ return isAdopted(classifyAdopted({ stance: readAdoptionStance(root) }));
48
+ }
49
+
50
+ /**
51
+ * First concrete path segment exists, or every declared glob is wildcard-only.
52
+ *
53
+ * @param {string} root
54
+ * @param {{ testGlobs?: unknown, coverageRoots?: unknown } | null | undefined} coverage
55
+ */
56
+ export function declaredInvariantTestsPathPresent(root, coverage) {
57
+ const declared = configuredInvariantTestsPaths(coverage);
58
+ if (declared.length === 0) return false;
59
+ if (typeof root !== 'string' || root.length === 0) return true;
60
+ let sawConcrete = false;
61
+ for (const entry of declared) {
62
+ const prefix = entry.split(/[*?]/)[0].replace(/\/$/, '');
63
+ if (!prefix) continue;
64
+ sawConcrete = true;
65
+ const abs = path.resolve(root, prefix);
66
+ const rel = path.relative(path.resolve(root), abs).replace(/\\/g, '/');
67
+ if (!rel || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) continue;
68
+ try {
69
+ if (fs.existsSync(abs)) return true;
70
+ } catch {
71
+ continue;
72
+ }
73
+ }
74
+ return !sawConcrete;
75
+ }
76
+
77
+ /**
78
+ * Doctor residual when adopted + invariants + missing/empty tests path.
79
+ *
80
+ * @param {{
81
+ * adopted?: boolean,
82
+ * coverage?: { testGlobs?: unknown, coverageRoots?: unknown } | null,
83
+ * config?: object,
84
+ * invariants?: unknown[],
85
+ * hasDomainInvariants?: boolean,
86
+ * root?: string,
87
+ * }} [input]
88
+ * @returns {{ missing: true, ask: string, nextAction: string } | null}
89
+ */
90
+ function residualDemandsInvariantTests(input) {
91
+ if (input.hasDomainInvariants === true) return true;
92
+ if (input.hasDomainInvariants === false) return false;
93
+ if (Array.isArray(input.invariants)) return catalogDemandsInvariantTestsPath(input.invariants);
94
+ if (typeof input.root === 'string' && input.config) {
95
+ try {
96
+ const loaded = loadEffectiveArkRulesFromDisk(input.root, input.config);
97
+ if (loaded.errors?.length) return false;
98
+ return catalogDemandsInvariantTestsPath(loaded.arkRules?.invariants);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+
106
+ export function collectInvariantTestsPathResidual(input = {}) {
107
+ if (input.adopted !== true) return null;
108
+ const coverage = input.coverage ?? input.config?.coverage;
109
+ const present =
110
+ typeof input.root === 'string' && input.root.length > 0
111
+ ? declaredInvariantTestsPathPresent(input.root, coverage)
112
+ : hasConfiguredInvariantTestsPath(coverage);
113
+ const findings = collectMissingInvariantTestsPathFindings({
114
+ adopted: true,
115
+ hasDomainInvariants: residualDemandsInvariantTests(input),
116
+ coverage,
117
+ declaredPathPresent: present,
118
+ });
119
+ if (findings.length === 0) return null;
120
+ return {
121
+ missing: true,
122
+ ask: findings[0]?.message ?? INVARIANT_TESTS_PATH_ASK,
123
+ nextAction: INVARIANT_TESTS_PATH_NEXT,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * First concrete coverageRoots segment exists, or every declared glob is wildcard-only.
129
+ *
130
+ * @param {string} root
131
+ * @param {{ coverageRoots?: unknown } | null | undefined} coverage
132
+ */
133
+ export function declaredCoverageRootsPresent(root, coverage) {
134
+ const declared = configuredCoverageRoots(coverage);
135
+ if (declared.length === 0) return false;
136
+ if (typeof root !== 'string' || root.length === 0) return true;
137
+ let sawConcrete = false;
138
+ for (const entry of declared) {
139
+ const prefix = entry.split(/[*?]/)[0].replace(/\/$/, '');
140
+ if (!prefix) continue;
141
+ sawConcrete = true;
142
+ const abs = path.resolve(root, prefix);
143
+ const rel = path.relative(path.resolve(root), abs).replace(/\\/g, '/');
144
+ if (!rel || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) continue;
145
+ try {
146
+ if (fs.existsSync(abs)) return true;
147
+ } catch {
148
+ continue;
149
+ }
150
+ }
151
+ return !sawConcrete;
152
+ }
153
+
154
+ function residualHasEnforcedInvariant(input) {
155
+ if (input.hasEnforcedInvariant === true) return true;
156
+ if (input.hasEnforcedInvariant === false) return false;
157
+ if (Array.isArray(input.invariants)) return catalogHasEnforcedInvariant(input.invariants);
158
+ const refs = input.config?.arkRules;
159
+ if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) return false;
160
+ if (typeof input.root === 'string' && input.config) {
161
+ try {
162
+ const loaded = loadEffectiveArkRulesFromDisk(input.root, input.config);
163
+ if (loaded.errors?.length) return false;
164
+ return catalogHasEnforcedInvariant(loaded.arkRules?.invariants);
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /** One doctor light: roots residual wins when both would fire. */
173
+ export function collectInvariantCoverageResiduals(input = {}) {
174
+ const invariantCoverageRoots = collectCoverageRootsResidual(input);
175
+ return {
176
+ invariantCoverageRoots,
177
+ invariantTestsPath: invariantCoverageRoots
178
+ ? null
179
+ : collectInvariantTestsPathResidual(input),
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Doctor residual when any invariant is enforced and coverageRoots is missing/empty.
185
+ *
186
+ * @param {{
187
+ * coverage?: { coverageRoots?: unknown } | null,
188
+ * config?: object,
189
+ * invariants?: unknown[],
190
+ * hasEnforcedInvariant?: boolean,
191
+ * root?: string,
192
+ * }} [input]
193
+ * @returns {{ missing: true, ask: string, nextAction: string } | null}
194
+ */
195
+ export function collectCoverageRootsResidual(input = {}) {
196
+ const coverage = input.coverage ?? input.config?.coverage;
197
+ const present =
198
+ typeof input.root === 'string' && input.root.length > 0
199
+ ? declaredCoverageRootsPresent(input.root, coverage)
200
+ : hasConfiguredCoverageRoots(coverage);
201
+ const findings = collectMissingCoverageRootsFindings({
202
+ hasEnforcedInvariant: residualHasEnforcedInvariant(input),
203
+ coverage,
204
+ declaredPathPresent: present,
205
+ });
206
+ if (findings.length === 0) return null;
207
+ return {
208
+ missing: true,
209
+ ask: findings[0]?.message ?? INVARIANT_COVERAGE_ROOTS_ASK,
210
+ nextAction: INVARIANT_COVERAGE_ROOTS_NEXT,
211
+ };
212
+ }
@@ -91,6 +91,14 @@ export function arkCommand(root, bin, argsStr = '') {
91
91
  return `${execRunner(root)} ${bin}${argsStr ? ` ${argsStr}` : ''}`;
92
92
  }
93
93
 
94
+ /**
95
+ * Run an Ark binary from package `arkgate` when local bins may be missing.
96
+ * Bare `npx arkgate-check` 404s — npx treats that bin name as its own package.
97
+ */
98
+ export function arkPackageRecoveryCommand(bin, argsStr = '') {
99
+ return `npx --package=arkgate ${bin}${argsStr ? ` ${argsStr}` : ''}`;
100
+ }
101
+
94
102
  /**
95
103
  * Split { command, args } form for JSON/TOML configs (.mcp.json, config.toml) that spawn
96
104
  * the binary directly. `pnpm exec ark-mcp` becomes command "pnpm" + args ["exec","ark-mcp",…]