arkgate 4.8.23 → 4.8.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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -10,6 +10,7 @@ import {
10
10
  execRunner,
11
11
  presentLockfiles,
12
12
  } from '../ark-shared.mjs';
13
+ import { npxArkgatePrefixLength } from './package-manager.mjs';
13
14
  import {
14
15
  codexPromptsDir,
15
16
  codexSkillsDir,
@@ -29,6 +30,7 @@ import {
29
30
  grokHooks,
30
31
  grokProjectConfig,
31
32
  mergeAntigravityArkHook,
33
+ mergeClaudeStyleArkHooks,
32
34
  mergeCursorArkHook,
33
35
  mergeOpencodeArkMcp,
34
36
  opencodeProjectConfig,
@@ -39,6 +41,7 @@ import {
39
41
  compactRouterHost,
40
42
  mergeArkMcpJson,
41
43
  MCP_JSON_GATE_FILES,
44
+ CLAUDE_STYLE_HOOK_FILES,
42
45
  writeTemplate,
43
46
  } from './gate-files.mjs';
44
47
  import {
@@ -119,7 +122,10 @@ export function staleRunnerGateFiles(root) {
119
122
  continue;
120
123
  }
121
124
  const ark = json?.mcpServers?.ark;
122
- if (ark && ark.command && ark.command !== want.split(' ')[0]) stale.push(rel);
125
+ if (!ark || !ark.command) continue;
126
+ // Unpinned project: a bare `npx <bin>` MCP entry 404s the same way a text command does.
127
+ const barePinnedNpx = want.startsWith('npx ') && npxArkgatePrefixLength(ark.args) === 0;
128
+ if (ark.command !== want.split(' ')[0] || barePinnedNpx) stale.push(rel);
123
129
  }
124
130
  return stale;
125
131
  }
@@ -341,6 +347,18 @@ export function runMigrateCommands(root) {
341
347
  warnLockfileConflict(root);
342
348
  }
343
349
 
350
+ /** Shared host files Ark merges into (keeps sibling servers / hooks / keys). */
351
+ function mergeableGateFile(relativePath) {
352
+ if (relativePath === 'opencode.json') return { fn: mergeOpencodeArkMcp, forceReplaces: false };
353
+ if (relativePath === '.cursor/hooks.json') return { fn: mergeCursorArkHook, forceReplaces: false };
354
+ if (relativePath === '.agents/hooks.json') return { fn: mergeAntigravityArkHook, forceReplaces: false };
355
+ if (CLAUDE_STYLE_HOOK_FILES.includes(relativePath)) {
356
+ return { fn: mergeClaudeStyleArkHooks, forceReplaces: true };
357
+ }
358
+ if (MCP_JSON_GATE_FILES.includes(relativePath)) return { fn: mergeArkMcpJson, forceReplaces: true };
359
+ return null;
360
+ }
361
+
344
362
  export function runInstallAgentGates(args) {
345
363
  const root = args.root;
346
364
  if (args.migrateCommands) {
@@ -454,6 +472,11 @@ export function runInstallAgentGates(args) {
454
472
  `Added package.json script "typecheck": "${typecheckBootstrap.script}" (tsconfig present; local/CI parity).`
455
473
  );
456
474
  }
475
+ if (typecheckBootstrap.reason === 'no-typescript' && !args.compact && !args.json) {
476
+ console.log(
477
+ 'tsconfig/jsconfig found but typescript is not a dependency — skipped the "typecheck" script and CI step (add typescript as a devDependency, then "typecheck": "tsc --noEmit").'
478
+ );
479
+ }
457
480
  }
458
481
  const catalog = buildManagedAssetCatalog({
459
482
  root,
@@ -518,81 +541,24 @@ export function runInstallAgentGates(args) {
518
541
  if (merged === existing) return { relativePath, status: 'skipped' };
519
542
  return writeTemplate(root, relativePath, merged, true);
520
543
  }
521
- if (relativePath === 'opencode.json') {
522
- const fullPath = path.join(root, relativePath);
523
- let existing = '';
524
- try {
525
- existing = fs.readFileSync(fullPath, 'utf8');
526
- } catch {
527
- // Missing project config → write the generated Ark MCP block.
528
- }
529
- if (!existing) {
530
- return writeTemplate(root, relativePath, content, true);
531
- }
532
- const merged = mergeOpencodeArkMcp(existing, content);
533
- if (merged == null) {
534
- return { relativePath, status: 'skipped-non-ark' };
535
- }
536
- if (merged === existing) return { relativePath, status: 'skipped' };
537
- return writeTemplate(root, relativePath, merged, true);
538
- }
539
- if (relativePath === '.cursor/hooks.json') {
540
- const fullPath = path.join(root, relativePath);
541
- let existing = '';
542
- try {
543
- existing = fs.readFileSync(fullPath, 'utf8');
544
- } catch {
545
- // Missing hooks file → write generated Cursor preToolUse gate.
546
- }
547
- if (!existing) {
548
- return writeTemplate(root, relativePath, content, true);
549
- }
550
- const merged = mergeCursorArkHook(existing, content);
551
- if (merged == null) {
552
- return { relativePath, status: 'skipped-non-ark' };
553
- }
554
- if (merged === existing) return { relativePath, status: 'skipped' };
555
- // Upsert Ark preToolUse without requiring --force; never wipe sibling hooks.
556
- return writeTemplate(root, relativePath, merged, true);
557
- }
558
- if (relativePath === '.agents/hooks.json') {
559
- const fullPath = path.join(root, relativePath);
560
- let existing = '';
561
- try {
562
- existing = fs.readFileSync(fullPath, 'utf8');
563
- } catch {
564
- // Missing hooks file → write generated ark-write-gate map.
565
- }
566
- if (!existing) {
567
- return writeTemplate(root, relativePath, content, true);
568
- }
569
- const merged = mergeAntigravityArkHook(existing, content);
570
- if (merged == null) {
571
- return { relativePath, status: 'skipped-non-ark' };
572
- }
573
- if (merged === existing) return { relativePath, status: 'skipped' };
574
- // Upsert ark-write-gate without requiring --force; never wipe sibling named hooks.
575
- return writeTemplate(root, relativePath, merged, true);
576
- }
577
- if (MCP_JSON_GATE_FILES.includes(relativePath)) {
578
- const fullPath = path.join(root, relativePath);
544
+ const merge = mergeableGateFile(relativePath);
545
+ if (merge) {
546
+ // Upsert Ark's entry into a shared host file without --force; never wipe sibling keys.
579
547
  let existing = '';
580
548
  try {
581
- existing = fs.readFileSync(fullPath, 'utf8');
549
+ existing = fs.readFileSync(path.join(root, relativePath), 'utf8');
582
550
  } catch {
583
- // Missing MCP JSON → write generated ark server.
584
- }
585
- if (!existing) {
586
- return writeTemplate(root, relativePath, content, true);
551
+ // Missing file → write the generated Ark template.
587
552
  }
588
- const merged = mergeArkMcpJson(existing, content);
553
+ if (!existing) return writeTemplate(root, relativePath, content, true);
554
+ const merged = merge.fn(existing, content);
589
555
  if (merged == null) {
590
- return args.force
556
+ // Unmergeable file: never guess. --force replaces it only where that is explicit consent.
557
+ return merge.forceReplaces && args.force
591
558
  ? writeTemplate(root, relativePath, content, true)
592
559
  : { relativePath, status: 'skipped-non-ark' };
593
560
  }
594
561
  if (merged === existing) return { relativePath, status: 'skipped' };
595
- // Upsert mcpServers.ark without requiring --force; never wipe sibling servers.
596
562
  return writeTemplate(root, relativePath, merged, true);
597
563
  }
598
564
  return writeTemplate(
@@ -794,14 +760,25 @@ export function runInstallAgentGates(args) {
794
760
  }
795
761
  if (tools.has('codex') && !args.compact) {
796
762
  console.log(' Codex write path (honest):');
797
- console.log(' - Local: advisory MCP + best-effort .codex/hooks.json (not a hard boundary).');
798
- console.log(' - Hard merge backstop: CI --strict-merge + required status check.');
799
- console.log(' - Not equivalent to Claude/Grok PreToolUse hard-write + repair.');
763
+ console.log(' - Local: .codex/hooks.json PreToolUse hard-blocks a complete local apply_patch (Codex CLI / local Desktop) once the project is trusted and a hook run is runtime-observed; on disk it is configured, not verified.');
764
+ console.log(' - Outside that claim (hosted tools, hook opt-out, shell writes, incomplete patch reconstruction): CI --strict-merge + required status check.');
765
+ console.log(' - Repair envelopes may emit, but Codex reinjection is not guaranteed (unlike Claude/Grok hard-write + repair).');
800
766
  }
801
767
  if (args.codexHome) {
802
- console.log(
803
- ` Codex: refreshed home skills under ${codexSkillsDir()}/<name>/SKILL.md (Codex skill catalog).`
804
- );
768
+ // Report what the home write actually did (it is skipped when the project catalog exists).
769
+ if (homeResults.some((r) => r.status === 'written')) {
770
+ console.log(
771
+ ` Codex: refreshed home skills under ${codexSkillsDir()}/<name>/SKILL.md (Codex skill catalog).`
772
+ );
773
+ } else if (homeResults.some((r) => r.status === 'failed')) {
774
+ console.log(` Codex: home skills NOT written under ${codexSkillsDir()} (see failures above).`);
775
+ } else if (homeResults.length === 0) {
776
+ console.log(
777
+ ' Codex: home skills not written — project .agents/skills is the catalog (Codex reads repo scope).'
778
+ );
779
+ } else {
780
+ console.log(` Codex: home skills under ${codexSkillsDir()} already current (no changes).`);
781
+ }
805
782
  } else if (tools.has('codex') && skills.length > 0 && !args.compact) {
806
783
  console.log(
807
784
  ' Codex: project skills at `.agents/skills/<name>/SKILL.md` (Agent Skills REPO scope).'
@@ -10,7 +10,7 @@ const DEFAULT_TEST_NAME_RE =
10
10
  /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$|\/__tests__\/|\/tests?\//i;
11
11
 
12
12
  /** Default max files to load for coverage evidence (budget). Config: `coverage.maxFiles`. */
13
- export const DEFAULT_MAX_COVERAGE_FILES = 400;
13
+ const DEFAULT_MAX_COVERAGE_FILES = 400;
14
14
  /**
15
15
  * Hard ceiling on `coverage.maxFiles`. The config validator implements no
16
16
  * `maximum` keyword for integers, so a schema bound would be accepted and then
@@ -38,11 +38,12 @@ function isPathInsideRoot(root, absolute) {
38
38
  }
39
39
 
40
40
  /**
41
- * Minimal glob match for testGlobs (double-star slash = zero path segments).
41
+ * Minimal glob match for testGlobs and ArkRules `appliesTo` fileHints
42
+ * (double-star slash = zero path segments).
42
43
  * @param {string} glob
43
44
  * @param {string} file
44
45
  */
45
- function matchSimpleGlob(glob, file) {
46
+ export function matchSimpleGlob(glob, file) {
46
47
  const pattern = String(glob || '').replace(/\\/g, '/');
47
48
  const target = String(file || '').replace(/\\/g, '/');
48
49
  if (!pattern) return false;
@@ -8,6 +8,8 @@
8
8
  * Pure CLI helper (bin/lib/invariant-coverage.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ import { findClassDeclarations, findMemberContainers, objectLiteralMemberNames, scanClassMembers, } from './class-source-scan.mjs';
12
+ import { probeRefusalReason, verdictRefusesPromotion } from './invariant-probe.mjs';
11
13
  /** Adopted + catalogued invariants, but no declared tests path (P2 §10). */
12
14
  export const INVARIANT_TESTS_PATH_RULE_ID = 'INVARIANT_TESTS_PATH_MISSING';
13
15
  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.';
@@ -185,6 +187,38 @@ function declarationShape(content, name) {
185
187
  }
186
188
  return undefined;
187
189
  }
190
+ /**
191
+ * `Name.member` coverage: the member must be declared inside the body of
192
+ * `class Name` (exported, default-exported, abstract, or local), a
193
+ * `const Name = class { … }` expression, a `namespace Name { … }` (function or
194
+ * const member), or a `const Name = { … }` object literal, in this file. A
195
+ * member of another container, a free function, or a file that merely mentions
196
+ * the name does not count.
197
+ */
198
+ function classMemberShape(content, className, member) {
199
+ const hasClassMethod = (body) => scanClassMembers(body).members.some((m) => m.kind === 'method' && m.name === member);
200
+ for (const decl of findClassDeclarations(content)) {
201
+ if (decl.name !== className || decl.bodyStart == null)
202
+ continue;
203
+ if (hasClassMethod(content.slice(decl.bodyStart, decl.bodyEnd ?? content.length))) {
204
+ return 'method';
205
+ }
206
+ }
207
+ for (const container of findMemberContainers(content, className)) {
208
+ const body = content.slice(container.bodyStart, container.bodyEnd);
209
+ if (container.kind === 'class-expression' && hasClassMethod(body))
210
+ return 'method';
211
+ if (container.kind === 'object' && objectLiteralMemberNames(body).includes(member)) {
212
+ return 'method';
213
+ }
214
+ if (container.kind === 'namespace') {
215
+ const shape = declarationShape(body, member);
216
+ if (shape === 'function' || shape === 'const')
217
+ return shape;
218
+ }
219
+ }
220
+ return undefined;
221
+ }
188
222
  function lineAt(content, index) {
189
223
  const start = content.lastIndexOf('\n', index - 1) + 1;
190
224
  const end = content.indexOf('\n', index);
@@ -298,7 +332,8 @@ function symbolNeedle(symbol) {
298
332
  const name = parts[parts.length - 1] ?? '';
299
333
  if (!name)
300
334
  return undefined;
301
- const className = parts.length > 1 ? (parts[0] ?? null) : null;
335
+ // `Aggregate.method` (or `ns.Aggregate.method`): the segment before the member names the class.
336
+ const className = parts.length > 1 ? (parts[parts.length - 2] || null) : null;
302
337
  return { className, name };
303
338
  }
304
339
  function declaredLabel(invariant) {
@@ -344,9 +379,9 @@ function rankCoverage(invariant, files, scanTests = true) {
344
379
  const content = files.fileContents[file];
345
380
  if (!content)
346
381
  continue;
347
- if (needle.className && !content.includes(needle.className))
348
- continue;
349
- const shape = declarationShape(content, needle.name);
382
+ const shape = needle.className
383
+ ? classMemberShape(content, needle.className, needle.name)
384
+ : declarationShape(content, needle.name);
350
385
  if (!shape)
351
386
  continue;
352
387
  declaration = { file, shape };
@@ -513,10 +548,14 @@ export function evaluateInvariantCoverage(input) {
513
548
  // Covered if the policy says this verdict counts.
514
549
  // When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
515
550
  const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
551
+ // `coverage.test: false` without a symbol declares zero evidence. That opt-out is
552
+ // advisory-only: on an enforced invariant it is reported uncovered (never a false
553
+ // green), not refused at load, so the rest of the contract still evaluates.
554
+ const zeroEvidenceOptOut = inv.coverage?.test === false && !symbol;
516
555
  const covered = requiresEvidence && counts
517
556
  ? true
518
- : inv.coverage?.test === false && !symbol
519
- ? true // explicitly no coverage requirements
557
+ : zeroEvidenceOptOut && inv.mode !== 'enforced'
558
+ ? true // explicitly no coverage requirements (advisory-only opt-out)
520
559
  : counts;
521
560
  // Partial only when tests are missing *and* no other evidence (e.g. symbol) completed coverage.
522
561
  const partial = testGlobsMissing && wantsTest && !counts;
@@ -562,11 +601,13 @@ export function evaluateInvariantCoverage(input) {
562
601
  ? coverageBudgetExhausted
563
602
  ? `Invariant ${inv.id} coverage cannot be proven (${budgetDetail}); reporting partial, not covered.`
564
603
  : `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
565
- : // The sentence is the verdict. It names a title, a declaration
566
- // shape, a bare mention, or silence — it does not re-scan.
567
- `Invariant ${inv.id}: ${describeCoverage(inv, ev)} (${kind === 'tests-disappeared'
568
- ? 'tests-disappeared — a suite exists'
569
- : 'never-had-tests — the scan found no tests at all'}). ArkGate matches declared text; it never executes tests.`) +
604
+ : zeroEvidenceOptOut
605
+ ? `Invariant ${inv.id} is enforced but declares no evidence (coverage.test is false and no coverage.symbol); set coverage.symbol, drop test:false, or keep mode "advisory".`
606
+ : // The sentence is the verdict. It names a title, a declaration
607
+ // shape, a bare mention, or silence — it does not re-scan.
608
+ `Invariant ${inv.id}: ${describeCoverage(inv, ev)} (${kind === 'tests-disappeared'
609
+ ? 'tests-disappeared — a suite exists'
610
+ : 'never-had-tests — the scan found no tests at all'}). ArkGate matches declared text; it never executes tests.`) +
570
611
  (partial && coverageBudgetExhausted ? budgetExhaustedTail : discardTail),
571
612
  file: inv.provenance.sourceFile,
572
613
  line: 1,
@@ -608,6 +649,14 @@ export function canPromoteInvariant(coverage) {
608
649
  reason: `Invariant ${coverage.invariantId} is uncovered; add a test title or symbol before promoting to enforced.`,
609
650
  };
610
651
  }
652
+ // A `coverage.test: false` opt-out without a symbol reports covered with no
653
+ // evidence at all. That is fine while advisory; it is not a basis for enforcing.
654
+ if (Array.isArray(coverage.evidence) && coverage.evidence.length === 0) {
655
+ return {
656
+ ok: false,
657
+ reason: `Invariant ${coverage.invariantId} declares no evidence (coverage.test:false and no symbol); add coverage.symbol or a test title before promoting to enforced.`,
658
+ };
659
+ }
611
660
  // Promotion is the moment coverage stops being advice, so an evidence file
612
661
  // the project itself says its runner does not walk cannot carry it.
613
662
  if (coverage.outsideDeclaredRoots === true) {
@@ -624,6 +673,15 @@ export function canPromoteInvariant(coverage) {
624
673
  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.`,
625
674
  };
626
675
  }
676
+ // ADR 0039 D2: probe evidence can only subtract. A fresh survived or
677
+ // not-reached row refuses; stale, killed, inconclusive or absent changes nothing.
678
+ if (coverage.probe?.fresh === true && verdictRefusesPromotion(coverage.probe.verdict)) {
679
+ return {
680
+ ok: false,
681
+ reason: probeRefusalReason(coverage.invariantId, coverage.probe),
682
+ blocker: 'probe-survived',
683
+ };
684
+ }
627
685
  return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
628
686
  }
629
687
  function nonEmptyPathStrings(value) {
@@ -732,3 +790,19 @@ export function collectMissingCoverageRootsFindings(input) {
732
790
  },
733
791
  ];
734
792
  }
793
+ /**
794
+ * Test files whose describe/it/test/context title names `invariant.id` — the
795
+ * same title matcher coverage uses. Sorted, deduplicated. The probe (ADR 0039)
796
+ * runs these as covering tests; coverage itself never runs anything.
797
+ */
798
+ export function testFilesNamingInvariant(invariant, files) {
799
+ const out = new Set();
800
+ for (const file of files.testFiles ?? []) {
801
+ const content = files.fileContents[file];
802
+ if (!content)
803
+ continue;
804
+ if (matchTestTitle(content, invariant.id) !== undefined)
805
+ out.add(normalizePath(file));
806
+ }
807
+ return [...out].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
808
+ }