arkgate 4.6.6 → 4.7.0

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 (66) hide show
  1. package/CHANGELOG.md +139 -2
  2. package/README.md +22 -10
  3. package/SECURITY.md +1 -1
  4. package/bin/ark-check-runtime.mjs +21 -341
  5. package/bin/ark-mcp-runtime.mjs +71 -325
  6. package/bin/ark-shared.mjs +24 -158
  7. package/bin/lib/adapter-contract.mjs +17 -36
  8. package/bin/lib/analysis-engine.mjs +6 -6
  9. package/bin/lib/ark-run-doctor.mjs +144 -0
  10. package/bin/lib/ark-run-facts.mjs +472 -0
  11. package/bin/lib/ark-run-report.mjs +57 -0
  12. package/bin/lib/ark-run-sensors.mjs +309 -0
  13. package/bin/lib/check-args.mjs +173 -0
  14. package/bin/lib/check-config-detect.mjs +101 -0
  15. package/bin/lib/check-watch.mjs +80 -0
  16. package/bin/lib/config-contract.mjs +86 -11
  17. package/bin/lib/deep-module-coach.mjs +3 -0
  18. package/bin/lib/diagnostic-catalog.mjs +8 -0
  19. package/bin/lib/doctor-advisories.mjs +45 -8
  20. package/bin/lib/doctor-human.mjs +519 -0
  21. package/bin/lib/doctor-plan.mjs +62 -445
  22. package/bin/lib/extra-merge-teeth.mjs +187 -0
  23. package/bin/lib/github-enforcement.mjs +22 -9
  24. package/bin/lib/html-report-advisories.mjs +2 -0
  25. package/bin/lib/html-report-depth.mjs +22 -2
  26. package/bin/lib/html-report.mjs +40 -7
  27. package/bin/lib/mcp-hook-payload.mjs +328 -0
  28. package/bin/lib/package-manager.mjs +174 -0
  29. package/bin/lib/policy-delta-io.mjs +4 -0
  30. package/bin/lib/remediation.mjs +132 -0
  31. package/bin/lib/resolved-candidate-facts.mjs +67 -2
  32. package/bin/lib/rules-under-contract.mjs +37 -89
  33. package/bin/lib/snippet-analysis.mjs +43 -2
  34. package/bin/lib/status-command.mjs +28 -0
  35. package/bin/lib/status-manifest.mjs +23 -0
  36. package/bin/lib/team-parliament-io.mjs +4 -0
  37. package/dist/{configTypes-l6XiwiC1.d.ts → configTypes-CgJimx9o.d.ts} +17 -3
  38. package/dist/eslint/index.cjs +6 -2
  39. package/dist/eslint/index.d.ts +70 -2
  40. package/dist/eslint/index.js +6 -2
  41. package/dist/index.cjs +35 -35
  42. package/dist/index.d.ts +787 -272
  43. package/dist/index.js +35 -35
  44. package/docs/README.md +4 -3
  45. package/docs/agent-guide.md +21 -15
  46. package/docs/ai-gates.md +13 -0
  47. package/docs/configuration.md +24 -11
  48. package/docs/develop.md +12 -3
  49. package/docs/diagnostics.md +75 -0
  50. package/docs/enthusiast/README.md +4 -3
  51. package/docs/package-surface.md +17 -13
  52. package/docs/product-voice.md +6 -3
  53. package/docs/threat-model.md +1 -1
  54. package/docs/use.md +5 -4
  55. package/package.json +1 -1
  56. package/schemas/ark.config.schema.json +41 -2
  57. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  58. package/schemas/ark.status-manifest.schema.json +47 -0
  59. package/server.json +2 -2
  60. package/templates/agent-skills/README.md +1 -1
  61. package/templates/agent-skills/ark-adopt/SKILL.md +23 -2
  62. package/templates/agent-skills/ark-place/SKILL.md +26 -2
  63. package/templates/agent-skills/ark-runtime/SKILL.md +66 -24
  64. package/templates/skills/ark-adopt.md +23 -2
  65. package/templates/skills/ark-place.md +26 -2
  66. package/templates/skills/ark-runtime.md +66 -24
@@ -19,6 +19,7 @@ export const MECHANICAL_SAFE_KINDS = [
19
19
  'type-only-import-move',
20
20
  'import-type-from-pure-type-module',
21
21
  'import-type-of-type-exports',
22
+ 'arkrun-declaration-list',
22
23
  // port-proof-inject-binding is intentionally NOT mechanical-safe (signature change).
23
24
  ];
24
25
  /**
@@ -40,6 +41,7 @@ export const KNOWN_FIX_CLASSES = [
40
41
  'intent-relocation',
41
42
  'break-cycle',
42
43
  'review-contract',
44
+ 'arkrun-usage',
43
45
  ];
44
46
  const PURE_SHARED_RE = /(^|\/)(constants|types|enums|shared-types|shared\/(?:types|constants)|test-projects)(\/|\.|$)|(?:^|\/)[^/]*(?:constants|types)(?:\.[cm]?[jt]sx?)?$/i;
45
47
  const KERNEL_EMIT_RE = /(^|\/)(?:kernel(?:\/|$)|events?(?:\/|\.|$)|bootstrap(?:\.[cm]?[jt]sx?)?$|emitter(?:\.[cm]?[jt]sx?)?$)|(?:^|\/)(?:intents?|publish)(?:\/|\.|$)/i;
@@ -87,6 +89,101 @@ export function layerImportNextAction(violation) {
87
89
  }
88
90
  return 'Classify the import: if it is constants/types/pure, adopt into DomainModel or SharedKernel; define a port only if the target is a real use-case. Then preflight again.';
89
91
  }
92
+ const ARKRUN_UNDECLARED_RULE_IDS = new Set([
93
+ 'ARKRUN_UNDECLARED_EMIT',
94
+ 'ARKRUN_UNDECLARED_HANDLE',
95
+ 'ARKRUN_UNDECLARED_DEPEND',
96
+ ]);
97
+ const ARKRUN_JUDGMENT_RULE_IDS = new Set([
98
+ 'ARKRUN_MISSING_ROOT',
99
+ 'ARKRUN_KERNEL_IN_DOMAIN',
100
+ 'ARKRUN_DIRECT_NEW',
101
+ 'ARKRUN_TRANSPORT_BYPASS',
102
+ ]);
103
+ function arkRunCallSiteName(violation) {
104
+ return typeof violation.target === 'string' && violation.target.trim().length > 0
105
+ ? violation.target.trim()
106
+ : undefined;
107
+ }
108
+ function isArkRunDeclarationListSafe(violation) {
109
+ return (typeof violation.ruleId === 'string' &&
110
+ ARKRUN_UNDECLARED_RULE_IDS.has(violation.ruleId) &&
111
+ arkRunCallSiteName(violation) !== undefined);
112
+ }
113
+ /** Catalog `fix` is the no-target form; a present `target` specializes it. */
114
+ function arkRunNextAction(violation) {
115
+ const target = arkRunCallSiteName(violation);
116
+ const fromLayer = typeof violation.fromLayer === 'string' && violation.fromLayer.length > 0
117
+ ? violation.fromLayer
118
+ : undefined;
119
+ switch (violation.ruleId) {
120
+ case 'ARKRUN_MISSING_ROOT':
121
+ return target
122
+ ? `Import createStrictArkKernel from @arkgate/runtime and call it in composition root ${target} listed in arkRun.compositionRoots, then preflight again.`
123
+ : 'Import createStrictArkKernel from @arkgate/runtime (never a removed arkgate/runtime shim) and call it in a composition root listed in arkRun.compositionRoots, then preflight again. Never mechanical-safe — factory placement is a design decision.';
124
+ case 'ARKRUN_KERNEL_IN_DOMAIN':
125
+ return target
126
+ ? `Move the kernel import of ${target} out of ${fromLayer ?? 'the Domain-role layer'} into a composition root or adapter. Import from @arkgate/runtime, never a removed arkgate/runtime shim, then preflight again.`
127
+ : 'Move the kernel import out of the Domain-role layer into a composition root or adapter. Import from @arkgate/runtime, never a removed arkgate/runtime shim, then preflight again. Never mechanical-safe.';
128
+ case 'ARKRUN_DIRECT_NEW':
129
+ return target
130
+ ? `Resolve ${target} from the kernel instead of constructing it with new, then preflight again.`
131
+ : 'Resolve the type from the kernel instead of constructing it with new, then preflight again. Never mechanical-safe — rewiring construction is a design decision.';
132
+ case 'ARKRUN_UNDECLARED_EMIT':
133
+ return target
134
+ ? `Add ${target} to raises or sends on the managed component, then preflight again.`
135
+ : 'Add the existing call-site name to raises or sends on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new emit stays judgment.';
136
+ case 'ARKRUN_UNDECLARED_HANDLE':
137
+ return target
138
+ ? `Add ${target} to reactsTo on the managed component, then preflight again.`
139
+ : 'Add the existing call-site name to reactsTo on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new handle stays judgment.';
140
+ case 'ARKRUN_UNDECLARED_DEPEND':
141
+ return target
142
+ ? `Add ${target} to uses on the managed component, then preflight again.`
143
+ : 'Add the existing call-site name to uses on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new depend stays judgment.';
144
+ case 'ARKRUN_TRANSPORT_BYPASS':
145
+ return target
146
+ ? `Send through the ArkRun kernel transport instead of importing ${target}, then preflight again.`
147
+ : 'Send through the ArkRun kernel transport instead of importing that broker or emitter, then preflight again. Never mechanical-safe — homemade buses stay judgment.';
148
+ default:
149
+ return `Resolve ${typeof violation.ruleId === 'string' && violation.ruleId.length > 0 ? violation.ruleId : 'ARK_UNKNOWN'} without weakening ark.config.json, then run Ark again.`;
150
+ }
151
+ }
152
+ function arkRunEnthusiastHint(violation) {
153
+ const target = arkRunCallSiteName(violation);
154
+ switch (violation.ruleId) {
155
+ case 'ARKRUN_MISSING_ROOT':
156
+ return target
157
+ ? `Call createStrictArkKernel from @arkgate/runtime in ${target} so the app actually uses the kernel.`
158
+ : 'Call createStrictArkKernel from @arkgate/runtime in a listed composition root so the app actually uses the kernel.';
159
+ case 'ARKRUN_KERNEL_IN_DOMAIN':
160
+ return target
161
+ ? `Domain stays kernel-free. Move the ${target} import to a composition root or adapter — never a removed arkgate/runtime shim.`
162
+ : 'Domain stays kernel-free. Move that @arkgate/runtime import to a composition root or adapter — never a removed arkgate/runtime shim.';
163
+ case 'ARKRUN_DIRECT_NEW':
164
+ return target
165
+ ? `Do not construct ${target} with new. Resolve it from the kernel instead.`
166
+ : 'Do not construct that managed type with new. Resolve it from the kernel instead.';
167
+ case 'ARKRUN_UNDECLARED_EMIT':
168
+ return target
169
+ ? `Add "${target}" to raises or sends. Do not invent a new emit.`
170
+ : 'Add the name you already publish to raises or sends. Do not invent a new emit.';
171
+ case 'ARKRUN_UNDECLARED_HANDLE':
172
+ return target
173
+ ? `Add "${target}" to reactsTo. Do not invent a new handle.`
174
+ : 'Add the name you already subscribe to reactsTo.';
175
+ case 'ARKRUN_UNDECLARED_DEPEND':
176
+ return target
177
+ ? `Add "${target}" to uses. Do not invent a new depend.`
178
+ : 'Add the name you already resolve to uses.';
179
+ case 'ARKRUN_TRANSPORT_BYPASS':
180
+ return target
181
+ ? `Do not import ${target} here. Send through the ArkRun kernel transport.`
182
+ : 'Do not import that broker or EventEmitter here. Send through the ArkRun kernel transport.';
183
+ default:
184
+ return 'Read the ArkRun finding and use the kernel instead of skipping it.';
185
+ }
186
+ }
90
187
  /** One deterministic re-entry action shared by human and machine denial surfaces. */
91
188
  export function deterministicNextAction(violation) {
92
189
  switch (violation.ruleId) {
@@ -110,6 +207,14 @@ export function deterministicNextAction(violation) {
110
207
  : 'the ArkRule'} (declared in ${typeof violation.arkruleSource === 'string' && violation.arkruleSource.length > 0
111
208
  ? violation.arkruleSource
112
209
  : 'arkrules/<Layer>.json'}), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.`;
210
+ case 'ARKRUN_MISSING_ROOT':
211
+ case 'ARKRUN_KERNEL_IN_DOMAIN':
212
+ case 'ARKRUN_DIRECT_NEW':
213
+ case 'ARKRUN_UNDECLARED_EMIT':
214
+ case 'ARKRUN_UNDECLARED_HANDLE':
215
+ case 'ARKRUN_UNDECLARED_DEPEND':
216
+ case 'ARKRUN_TRANSPORT_BYPASS':
217
+ return arkRunNextAction(violation);
113
218
  default:
114
219
  if (typeof violation.ruleId === 'string' && violation.ruleId.startsWith('ARKRULE_')) {
115
220
  return `Fix the ArkRule ${typeof violation.arkruleId === 'string' ? violation.arkruleId : violation.ruleId}, then preflight again.`;
@@ -226,6 +331,22 @@ export function classifyRemediation(violation) {
226
331
  rationale: 'ArkRule structure/invariant findings are never mechanical-safe — restore private state, factory shape, event publish, coverage, or redesign the aggregate with judgment.',
227
332
  };
228
333
  }
334
+ if (violation && isArkRunDeclarationListSafe(violation)) {
335
+ return {
336
+ class: 'mechanical-safe',
337
+ confidence: 0.9,
338
+ remediationKind: 'arkrun-declaration-list',
339
+ rationale: 'Call-site literal already exists; adding it to the declaration list is behavior-preserving. Inventing a new emit/handle/depend stays judgment.',
340
+ };
341
+ }
342
+ if (typeof ruleId === 'string' &&
343
+ (ARKRUN_JUDGMENT_RULE_IDS.has(ruleId) || ARKRUN_UNDECLARED_RULE_IDS.has(ruleId))) {
344
+ return {
345
+ class: 'judgment',
346
+ confidence: 0.85,
347
+ rationale: 'ArkRun usage, construction, and homemade-transport findings are never mechanical-safe. Declaration-list edits are mechanical-safe only when the call-site literal is already present as target.',
348
+ };
349
+ }
229
350
  if (typeof ruleId === 'string' && ruleId.length > 0) {
230
351
  return {
231
352
  class: 'judgment',
@@ -338,6 +459,17 @@ export function enrichViolationWithFixClass(violation) {
338
459
  enriched.enthusiastHint =
339
460
  'Two modules import each other in a loop. Extract shared code, invert one dependency behind a port, or merge them if they are really one unit.';
340
461
  break;
462
+ case 'ARKRUN_MISSING_ROOT':
463
+ case 'ARKRUN_KERNEL_IN_DOMAIN':
464
+ case 'ARKRUN_DIRECT_NEW':
465
+ case 'ARKRUN_UNDECLARED_EMIT':
466
+ case 'ARKRUN_UNDECLARED_HANDLE':
467
+ case 'ARKRUN_UNDECLARED_DEPEND':
468
+ case 'ARKRUN_TRANSPORT_BYPASS':
469
+ enriched.fixClass = 'arkrun-usage';
470
+ enriched.effort = ARKRUN_UNDECLARED_RULE_IDS.has(violation.ruleId ?? '') ? 'small' : 'medium';
471
+ enriched.enthusiastHint = arkRunEnthusiastHint(violation);
472
+ break;
341
473
  default:
342
474
  enriched.fixClass = 'review-contract';
343
475
  enriched.effort = 'small';
@@ -8,7 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
 
11
- import { isScanExcludedRelative } from '../ark-shared.mjs';
11
+ import { globToRegExp, isScanExcludedRelative } from '../ark-shared.mjs';
12
12
  import {
13
13
  AMBIENT_CAPABILITY_ENTRIES,
14
14
  collectCapabilityUses,
@@ -35,6 +35,11 @@ import {
35
35
  } from './ast-scan.mjs';
36
36
  import { provePortProofInject } from './port-proof.mjs';
37
37
  import { extractClassShapesFromSource } from './arkrules-sensors.mjs';
38
+ import {
39
+ extractArkRunDeclarationsFromSource,
40
+ extractArkRunKernelCallsFromSource,
41
+ extractArkRunManagedNewsFromSource,
42
+ } from './ark-run-facts.mjs';
38
43
  import {
39
44
  collectGovernedFiles,
40
45
  isGovernableSourceFile,
@@ -998,6 +1003,12 @@ export function resolveCandidateFacts({
998
1003
  const safetyUses = [];
999
1004
  /** ADR 0013 class-shape facts for ArkRules structure sensors. */
1000
1005
  const classShapes = [];
1006
+ /** ADR 0022 / RN03 — syntax evidence only; sensors emit in RN04. */
1007
+ const arkRunKernelCalls = [];
1008
+ const arkRunManagedNews = [];
1009
+ const arkRunCompositionRootHits = [];
1010
+ const arkRunDeclarations = [];
1011
+ const compositionRootPatterns = [...(config.arkRun?.compositionRoots ?? [])];
1001
1012
 
1002
1013
  for (const candidate of candidateFiles) {
1003
1014
  const sourceFile = ts.createSourceFile(
@@ -1087,6 +1098,56 @@ export function resolveCandidateFacts({
1087
1098
  } catch {
1088
1099
  // Never fail the resolver for shape extraction; sensors stay silent on this file.
1089
1100
  }
1101
+ try {
1102
+ arkRunKernelCalls.push(
1103
+ ...extractArkRunKernelCallsFromSource(candidate.path, candidate.content)
1104
+ );
1105
+ } catch {
1106
+ // Never fail the resolver for ArkRun call extraction.
1107
+ }
1108
+ try {
1109
+ arkRunDeclarations.push(
1110
+ ...extractArkRunDeclarationsFromSource(candidate.path, candidate.content)
1111
+ );
1112
+ } catch {
1113
+ // Never fail the resolver for ArkRun declaration extraction.
1114
+ }
1115
+ }
1116
+ }
1117
+
1118
+ const admittedTypeNames = new Set(classShapes.map((shape) => shape.className));
1119
+ for (const candidate of candidateFiles) {
1120
+ if (!/\.(tsx?|mts|cts)$/i.test(candidate.path)) continue;
1121
+ try {
1122
+ arkRunManagedNews.push(
1123
+ ...extractArkRunManagedNewsFromSource(
1124
+ candidate.path,
1125
+ candidate.content,
1126
+ admittedTypeNames
1127
+ )
1128
+ );
1129
+ } catch {
1130
+ // Never fail the resolver for managed-new extraction.
1131
+ }
1132
+ }
1133
+
1134
+ if (compositionRootPatterns.length > 0) {
1135
+ const factoryFiles = new Set(
1136
+ arkRunKernelCalls.filter((call) => call.kind === 'factory').map((call) => call.file)
1137
+ );
1138
+ for (const candidate of candidateFiles) {
1139
+ for (const pattern of compositionRootPatterns) {
1140
+ try {
1141
+ if (!globToRegExp(pattern).test(candidate.path)) continue;
1142
+ } catch {
1143
+ continue;
1144
+ }
1145
+ arkRunCompositionRootHits.push({
1146
+ file: candidate.path,
1147
+ matchedRoot: pattern,
1148
+ hasKernelFactory: factoryFiles.has(candidate.path),
1149
+ });
1150
+ }
1090
1151
  }
1091
1152
  }
1092
1153
 
@@ -1152,7 +1213,7 @@ export function resolveCandidateFacts({
1152
1213
 
1153
1214
  const projectPackageName = readPackageName(canonicalRoot, observeInput);
1154
1215
  return createTrustedResolvedCandidateFacts({
1155
- schemaVersion: '1.1',
1216
+ schemaVersion: '1.2',
1156
1217
  completeness: completenessReasons.length === 0 ? 'complete' : 'partial',
1157
1218
  completenessReasons,
1158
1219
  resolverIdentity: RESOLVED_FACTS_RESOLVER_IDENTITY,
@@ -1169,5 +1230,9 @@ export function resolveCandidateFacts({
1169
1230
  intentReferences,
1170
1231
  safetyUses,
1171
1232
  classShapes,
1233
+ arkRunKernelCalls,
1234
+ arkRunManagedNews,
1235
+ arkRunCompositionRootHits,
1236
+ arkRunDeclarations,
1172
1237
  });
1173
1238
  }
@@ -7,6 +7,11 @@
7
7
  import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
8
8
  import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
9
9
  import { loadInvariantCoverageInputs } from './invariant-coverage-io.mjs';
10
+ import {
11
+ EXTRA_MERGE_TEETH_GOVERNED_FLOOR,
12
+ composeMergePlanesHonesty,
13
+ demoteExtraPlaneTeethUnderClassificationFloor,
14
+ } from './extra-merge-teeth.mjs';
10
15
 
11
16
  /**
12
17
  * Cap long catalogs in doctor JSON (and HTML, which consumes the same summary).
@@ -16,12 +21,11 @@ const COVERED_SAMPLE_MAX = 24;
16
21
  const STRUCTURE_CATALOG_MAX = 40;
17
22
  const UNCOVERED_CATALOG_MAX = 30;
18
23
 
19
- /** Minimum governed % before enforced ArkRules may arm extra merge teeth (P1M / FG-EXTRATEETH). */
20
- export const EXTRA_MERGE_TEETH_GOVERNED_FLOOR = 50;
24
+ export { EXTRA_MERGE_TEETH_GOVERNED_FLOOR };
21
25
 
22
26
  /**
23
27
  * P1M / extraMergeTeeth: under the classification floor, demote enforced ArkRules
24
- * structure/invariant findings so merge matches doctor stamp (layer graph only).
28
+ * and ArkRun findings so merge matches doctor stamp (layer graph only).
25
29
  * Unknown classification (null/null) → do not demote (contract-only callers).
26
30
  *
27
31
  * @param {object[]} violations
@@ -29,28 +33,7 @@ export const EXTRA_MERGE_TEETH_GOVERNED_FLOOR = 50;
29
33
  * @returns {object[]}
30
34
  */
31
35
  export function demoteArkRuleTeethUnderClassificationFloor(violations, classification = {}) {
32
- if (!Array.isArray(violations)) return violations;
33
- const governed =
34
- typeof classification.governedPercent === 'number' ? classification.governedPercent : null;
35
- const populated =
36
- typeof classification.populatedLayerCount === 'number'
37
- ? classification.populatedLayerCount
38
- : null;
39
- if (governed == null && populated == null) return violations;
40
- const allowsTeeth =
41
- (governed ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR && (populated ?? 0) >= 1;
42
- if (allowsTeeth) return violations;
43
- for (const v of violations) {
44
- const isArkRule =
45
- v?.arkruleId != null ||
46
- (typeof v?.ruleId === 'string' &&
47
- (v.ruleId.startsWith('ARKRULE') || v.ruleId.startsWith('arkrule')));
48
- if (isArkRule && v.failsStrict !== false) {
49
- v.failsStrict = false;
50
- if (v.severity === 'error') v.severity = 'warning';
51
- }
52
- }
53
- return violations;
36
+ return demoteExtraPlaneTeethUnderClassificationFloor(violations, classification);
54
37
  }
55
38
 
56
39
  /**
@@ -63,6 +46,18 @@ export function demoteArkRuleTeethUnderClassificationFloor(violations, classific
63
46
  * classifiedFiles?: number | null,
64
47
  * }} [classification] layer-plane coverage (when known)
65
48
  */
49
+ function arkRunMergeInput(config, residualCount = 0) {
50
+ const extra = config?.arkRun;
51
+ if (!extra || typeof extra !== 'object') {
52
+ return { present: false, mode: null, residualCount: 0 };
53
+ }
54
+ return {
55
+ present: true,
56
+ mode: extra.mode === 'enforced' || extra.mode === 'advisory' ? extra.mode : null,
57
+ residualCount: Number(residualCount) || 0,
58
+ };
59
+ }
60
+
66
61
  export function summarizeRulesUnderContract(root, config, facts, classification) {
67
62
  if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
68
63
  return {
@@ -71,6 +66,11 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
71
66
  invariants: 0,
72
67
  coveredInvariants: 0,
73
68
  uncoveredInvariants: 0,
69
+ mergePlanes: composeMergePlanesHonesty({
70
+ classification,
71
+ arkRules: { active: false },
72
+ arkRun: arkRunMergeInput(config),
73
+ }),
74
74
  notAScore: true,
75
75
  note: 'No arkRules map — intra-layer ArkRules are opt-in.',
76
76
  };
@@ -162,73 +162,21 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
162
162
  const invariantAdvisory = invariants - invariantEnforced;
163
163
  const coveredInvariants = coverage.coverage.filter((c) => c.covered).length;
164
164
  const uncoveredInvariants = coverage.coverage.filter((c) => !c.covered).length;
165
- const hasEnforcedTeeth = structureEnforced > 0 || invariantEnforced > 0;
166
- // P1M-EXTRATEETH-EMPTY-GRAPH / FG-EXTRATEETH-EMPTY-CLASSIFICATION:
167
- // Do not arm structure/invariant merge teeth when the layer plane is empty or
168
- // barely classified (e.g. 0% governed). Classification unknown → allow teeth
169
- // (contract-only callers / unit tests without coverage).
170
- const governedPercent =
171
- classification && typeof classification.governedPercent === 'number'
172
- ? classification.governedPercent
173
- : null;
174
- const populatedLayerCount =
175
- classification && typeof classification.populatedLayerCount === 'number'
176
- ? classification.populatedLayerCount
177
- : classification && typeof classification.classifiedFiles === 'number'
178
- ? classification.classifiedFiles > 0
179
- ? 1
180
- : 0
181
- : null;
182
- const classificationKnown = governedPercent != null || populatedLayerCount != null;
183
- const classificationAllowsTeeth = !classificationKnown
184
- ? true
185
- : (governedPercent ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR &&
186
- (populatedLayerCount ?? 0) >= 1;
187
- const extraMergeTeeth = hasEnforcedTeeth && classificationAllowsTeeth;
188
- const teethDeferredForClassification =
189
- hasEnforcedTeeth && classificationKnown && !classificationAllowsTeeth;
190
- // P1-M — which plane can fail merge (layers vs enforced structure vs invariants).
191
- const mergePlanes = {
192
- layers: {
193
- role: 'inter-layer-edges',
194
- alwaysOnGate: true,
195
- note: 'Import/export layer graph — the default merge plane. Absent arkRules changes nothing here.',
196
- },
197
- structureSensors: {
198
- role: 'intra-layer-heuristics',
199
- total: structureRules,
200
- enforced: structureEnforced,
201
- advisory: structureAdvisory,
202
- note: 'Structure sensors are heuristics (prefer false negatives). Only mode:enforced fails merge; noisy sensors stay advisory by default. Advisory-only packs never add merge teeth (FG-ARKRULES-ADVISORY-ONLY).',
203
- },
204
- invariants: {
205
- role: 'catalog-plus-coverage',
206
- total: invariants,
207
- enforced: invariantEnforced,
208
- advisory: invariantAdvisory,
165
+ const mergePlanes = composeMergePlanesHonesty({
166
+ classification,
167
+ arkRules: {
168
+ active: true,
169
+ structureEnforced,
170
+ structureTotal: structureRules,
171
+ structureAdvisory,
172
+ invariantEnforced,
173
+ invariantTotal: invariants,
174
+ invariantAdvisory,
209
175
  covered: coveredInvariants,
210
176
  uncovered: uncoveredInvariants,
211
- note: 'Invariants are catalog + coverage evidence, not a business runtime. Enforced + proven-uncovered fails merge; absence of enforced rules adds no extra teeth.',
212
177
  },
213
- dualPlaneStamp:
214
- 'Structure = heuristics; invariants = catalog+coverage evidence (not business runtime). The two planes never merge into one architecture score. Advisory ArkRules ≠ merge teeth.',
215
- extraMergeTeeth,
216
- ...(classificationKnown
217
- ? {
218
- classificationGate: {
219
- governedPercent: governedPercent ?? null,
220
- populatedLayerCount: populatedLayerCount ?? null,
221
- floorPercent: EXTRA_MERGE_TEETH_GOVERNED_FLOOR,
222
- allowsTeeth: classificationAllowsTeeth,
223
- },
224
- }
225
- : {}),
226
- failMergeWhen: extraMergeTeeth
227
- ? 'Layer graph failures plus enforced structure/invariant findings (advisory sensors never fail merge alone).'
228
- : teethDeferredForClassification
229
- ? `Layer graph only — enforced ArkRules structure/invariant findings are demoted under the teeth floor (need ≥${EXTRA_MERGE_TEETH_GOVERNED_FLOOR}% governed and ≥1 populated layer); they do not merge-block until classification is honest.`
230
- : 'Layer graph only — no enforced ArkRules structure/invariant teeth on this tree. Advisory packs do not arm merge teeth.',
231
- };
178
+ arkRun: arkRunMergeInput(config),
179
+ });
232
180
 
233
181
  return {
234
182
  active: true,
@@ -1,5 +1,7 @@
1
1
  /** Fail-closed completeness evidence for one proposed source snippet. */
2
+ import { layerForRelativePath } from '../ark-layer-match.mjs';
2
3
  import { ANALYSIS_COMPLETENESS } from './analysis-completeness.mjs';
4
+ import { evaluateArkRunEditorSensorsFromSource } from './ark-run-sensors.mjs';
3
5
 
4
6
  export function flattenTsParseDiagnostics(ts, diagnostics, sourceFile) {
5
7
  if (!Array.isArray(diagnostics) || !ts) return [];
@@ -31,11 +33,50 @@ function finding(ruleId, message, file, nextAction) {
31
33
  };
32
34
  }
33
35
 
36
+ function arkRunSnippetViolations(source, context = {}) {
37
+ const extra = context.arkRun;
38
+ const layers = context.layers;
39
+ const file = context.relFile || context.filePath;
40
+ if (!extra || !Array.isArray(layers) || typeof file !== 'string' || file.length === 0) {
41
+ return [];
42
+ }
43
+ const layerForFile = (pathValue) => {
44
+ if (pathValue === file && typeof context.layer === 'string') return context.layer;
45
+ return layerForRelativePath(pathValue, layers);
46
+ };
47
+ const { findings } = evaluateArkRunEditorSensorsFromSource({
48
+ arkRun: extra,
49
+ layers,
50
+ file,
51
+ source,
52
+ layerForFile,
53
+ classification: context.classification,
54
+ });
55
+ return findings
56
+ .filter((finding) => finding.failsStrict)
57
+ .map((finding) => ({
58
+ ruleId: finding.ruleId,
59
+ code: finding.ruleId,
60
+ message: finding.message,
61
+ file: finding.file,
62
+ line: finding.line,
63
+ fromLayer: finding.fromLayer,
64
+ target: finding.target,
65
+ nextAction: finding.nextAction,
66
+ failsStrict: true,
67
+ severity: 'error',
68
+ }));
69
+ }
70
+
34
71
  export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
35
72
  const observed = gate.validate(source, context);
73
+ const arkRunViolations = arkRunSnippetViolations(source, context);
36
74
  const base = {
37
- valid: Boolean(observed.lexicalValid ?? observed.valid),
38
- violations: Array.isArray(observed.violations) ? observed.violations : [],
75
+ valid: Boolean(observed.lexicalValid ?? observed.valid) && arkRunViolations.length === 0,
76
+ violations: [
77
+ ...(Array.isArray(observed.violations) ? observed.violations : []),
78
+ ...arkRunViolations,
79
+ ],
39
80
  };
40
81
  const file = context.filePath;
41
82
 
@@ -25,6 +25,7 @@ import { detectActiveAgentHost } from './skill-install.mjs';
25
25
  import { readBaseline } from './violations.mjs';
26
26
  import { reportsDir, readJsonSafe } from './html-report.mjs';
27
27
  import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
28
+ import { projectStatusArkRun } from './ark-run-doctor.mjs';
28
29
  import { collectVsBaseFacts, discoverTeamBaseRef } from './team-parliament-io.mjs';
29
30
  import { classifyAdopted, readAdoptionStance } from './adoption-stance.mjs';
30
31
 
@@ -340,6 +341,19 @@ export function collectStatusFacts(options = {}) {
340
341
 
341
342
  const arkruleFrozenFallback = countArkruleFrozenKeys(baseline);
342
343
 
344
+ const arkRun = (() => {
345
+ const extra = config?.arkRun;
346
+ if (!extra || typeof extra !== 'object') {
347
+ return projectStatusArkRun({ present: false, residual: 0 });
348
+ }
349
+ const snap = latest?.arkRun && typeof latest.arkRun === 'object' ? latest.arkRun : null;
350
+ const mode = extra.mode === 'enforced' || extra.mode === 'advisory' ? extra.mode : null;
351
+ const extraMergeTeeth =
352
+ snap && typeof snap.extraMergeTeeth === 'boolean' ? snap.extraMergeTeeth === true : false;
353
+ const residual = typeof snap?.residual === 'number' ? snap.residual : null;
354
+ return projectStatusArkRun({ present: true, mode, extraMergeTeeth, residual });
355
+ })();
356
+
343
357
  // DF02 — always project compass with honesty mode (never invent green residual).
344
358
  // Prefer explicit override (tests/MCP inject doctor-facts); else report snapshot.
345
359
  let improvementCompass = null;
@@ -388,6 +402,7 @@ export function collectStatusFacts(options = {}) {
388
402
  latest?.leftoverDesignWork === true ||
389
403
  latest?.designFitness?.designWeak === true ||
390
404
  latest?.doctor?.designFitness?.designWeak === true,
405
+ arkRun: options.arkRun ?? arkRun,
391
406
  adopted:
392
407
  options.adopted ??
393
408
  classifyAdopted({
@@ -506,6 +521,19 @@ export function runStatusCommand(args = {}) {
506
521
  );
507
522
  }
508
523
  if (manifest.vsBase?.line) write(` ${manifest.vsBase.line}`);
524
+ const arkRunLine = manifest.arkRun;
525
+ if (arkRunLine && arkRunLine.notAScore === true) {
526
+ const residual =
527
+ arkRunLine.residual == null ? 'unknown' : String(arkRunLine.residual);
528
+ write(
529
+ ` arkRun: ${arkRunLine.present ? arkRunLine.mode || 'on' : 'absent'}` +
530
+ ` · residual=${residual}` +
531
+ (arkRunLine.present
532
+ ? ` · extraMergeTeeth=${arkRunLine.extraMergeTeeth === true}`
533
+ : '') +
534
+ ' · not a score'
535
+ );
536
+ }
509
537
  write(` next: [${manifest.nextAction.id}] ${manifest.nextAction.summary}`);
510
538
  }
511
539
 
@@ -8,6 +8,7 @@
8
8
  * Pure CLI helper (bin/lib/status-manifest.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
+ import { projectStatusArkRun } from './ark-run-doctor.mjs';
11
12
  export const ARK_STATUS_MANIFEST_SCHEMA_VERSION = '1.0';
12
13
  export const ARK_STATUS_MANIFEST_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.status-manifest.schema.json';
13
14
  /**
@@ -242,6 +243,12 @@ export function resolveStatusNextAction(facts, binding, activation, lastCheck, r
242
243
  summary: 'ArkRules residual remains frozen — review inventory debt without claiming a score.',
243
244
  };
244
245
  }
246
+ if (facts.arkRun?.present === true && (facts.arkRun.residual ?? 0) > 0) {
247
+ return {
248
+ id: 'review-arkrun-residual',
249
+ summary: 'ArkRun residual remains — wire kernel usage or declarations through @arkgate/runtime. Not a score.',
250
+ };
251
+ }
245
252
  if (facts.adopted === 'required-merge' || facts.adopted === 'advisory-only-acked') {
246
253
  return {
247
254
  id: 'stay-enforced',
@@ -326,6 +333,9 @@ export function buildStatusManifest(facts) {
326
333
  if (facts.vsBase && typeof facts.vsBase.baseRef === 'string' && facts.vsBase.baseRef.length > 0) {
327
334
  status.vsBase = facts.vsBase;
328
335
  }
336
+ if (facts.arkRun && typeof facts.arkRun === 'object') {
337
+ status.arkRun = projectStatusArkRun(facts.arkRun);
338
+ }
329
339
  return status;
330
340
  }
331
341
  const STATUS_COMPASS_MODE_SET = new Set(STATUS_COMPASS_MODES);
@@ -605,5 +615,18 @@ export const ARK_STATUS_MANIFEST_SCHEMA = {
605
615
  baselineGrew: { type: 'boolean' },
606
616
  },
607
617
  },
618
+ arkRun: {
619
+ type: 'object',
620
+ description: 'ArkRun extra residual (notAScore). present/mode from config; residual is a finding-id count (null = unknown, not green). extraMergeTeeth is honesty, never a score.',
621
+ additionalProperties: false,
622
+ required: ['notAScore', 'present', 'mode', 'extraMergeTeeth', 'residual'],
623
+ properties: {
624
+ notAScore: { const: true },
625
+ present: { type: 'boolean' },
626
+ mode: { anyOf: [{ enum: ['advisory', 'enforced'] }, { type: 'null' }] },
627
+ extraMergeTeeth: { type: 'boolean' },
628
+ residual: { anyOf: [{ type: 'integer', minimum: 0 }, { type: 'null' }] },
629
+ },
630
+ },
608
631
  },
609
632
  };
@@ -34,10 +34,14 @@ export {
34
34
  suggestStewards,
35
35
  };
36
36
 
37
+ /** Kill hung git instead of stalling CI. */
38
+ export const SPAWN_TIMEOUT_MS = 8000;
39
+
37
40
  function runGit(cwd, args) {
38
41
  return spawnSync('git', ['-C', cwd, ...args], {
39
42
  encoding: 'utf8',
40
43
  stdio: ['ignore', 'pipe', 'pipe'],
44
+ timeout: SPAWN_TIMEOUT_MS,
41
45
  });
42
46
  }
43
47