arkgate 4.8.14 → 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 (103) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +17 -7
  3. package/bin/ark-check-runtime.mjs +17 -49
  4. package/bin/ark-mcp-runtime.mjs +111 -2
  5. package/bin/ark-shared.mjs +142 -11
  6. package/bin/ark.mjs +24 -33
  7. package/bin/lib/adr-presence.mjs +97 -0
  8. package/bin/lib/agent-skills-package.mjs +179 -1
  9. package/bin/lib/analysis-completeness.mjs +38 -2
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/architecture-scan.mjs +43 -4
  12. package/bin/lib/ark-run-doctor.mjs +6 -0
  13. package/bin/lib/ark-run-report.mjs +6 -2
  14. package/bin/lib/arkrules-sensors.mjs +81 -0
  15. package/bin/lib/baseline-key.mjs +6 -1
  16. package/bin/lib/check-args.mjs +52 -2
  17. package/bin/lib/config-contract.mjs +65 -2
  18. package/bin/lib/diagnostic-catalog.mjs +5 -1
  19. package/bin/lib/doctor-advisories.mjs +120 -10
  20. package/bin/lib/doctor-green-cite.mjs +139 -0
  21. package/bin/lib/doctor-human.mjs +115 -67
  22. package/bin/lib/doctor-next-actions.mjs +23 -0
  23. package/bin/lib/doctor-plan.mjs +35 -2
  24. package/bin/lib/enforcement-honesty.mjs +47 -2
  25. package/bin/lib/field-install.mjs +47 -7
  26. package/bin/lib/first-run-help.mjs +9 -1
  27. package/bin/lib/host-support-matrix.mjs +9 -1
  28. package/bin/lib/html-report-depth.mjs +2 -0
  29. package/bin/lib/html-report.mjs +13 -2
  30. package/bin/lib/import-resolve.mjs +74 -13
  31. package/bin/lib/improvement-compass-doctor.mjs +8 -1
  32. package/bin/lib/improvement-compass-map.mjs +5 -1
  33. package/bin/lib/invariant-coverage.mjs +121 -0
  34. package/bin/lib/invariant-tests-path.mjs +212 -0
  35. package/bin/lib/layer-description.mjs +90 -8
  36. package/bin/lib/mcp-hook-payload.mjs +56 -0
  37. package/bin/lib/no-domain-frontend.mjs +91 -0
  38. package/bin/lib/package-manager.mjs +8 -0
  39. package/bin/lib/presets.mjs +3 -4
  40. package/bin/lib/product-copy.mjs +15 -0
  41. package/bin/lib/projected-governed-coverage.mjs +114 -0
  42. package/bin/lib/prototype-shortcuts.mjs +224 -0
  43. package/bin/lib/recommend-cli.mjs +54 -0
  44. package/bin/lib/remediation.mjs +15 -0
  45. package/bin/lib/resolved-candidate-facts.mjs +83 -66
  46. package/bin/lib/rules-under-contract.mjs +117 -2
  47. package/bin/lib/snippet-analysis.mjs +14 -8
  48. package/bin/lib/start-preview.mjs +24 -2
  49. package/bin/lib/states-transitions-presence.mjs +212 -0
  50. package/bin/lib/status-command.mjs +2 -0
  51. package/bin/lib/status-transition-catalog.mjs +410 -0
  52. package/bin/lib/team-parliament-io.mjs +10 -0
  53. package/bin/lib/violations.mjs +13 -1
  54. package/bin/lib/write-path-capabilities.mjs +20 -5
  55. package/bin/lib/write-path-detect.mjs +27 -2
  56. package/dist/{configTypes-j7so8B4O.d.ts → configTypes-VD0qcubY.d.ts} +21 -1
  57. package/dist/{diagnosticCatalog-DVx_2RmF.d.ts → diagnosticCatalog-KWvGLI1U.d.ts} +24 -3
  58. package/dist/eslint/index.cjs +4 -4
  59. package/dist/eslint/index.d.ts +1 -1
  60. package/dist/eslint/index.js +4 -4
  61. package/dist/index.cjs +34 -34
  62. package/dist/index.d.ts +71 -10
  63. package/dist/index.js +34 -34
  64. package/dist/nestjs/index.cjs +5 -5
  65. package/dist/nestjs/index.d.ts +3 -3
  66. package/dist/nestjs/index.js +5 -5
  67. package/dist/runtime/index.cjs +15 -15
  68. package/dist/runtime/index.d.ts +6 -6
  69. package/dist/runtime/index.js +15 -15
  70. package/dist/{types-tGhZUiGX.d.ts → types-BSzRy2X1.d.ts} +1 -1
  71. package/dist/{types-Djbs3KjE.d.ts → types-D5GT5ZT8.d.ts} +3 -1
  72. package/docs/README.md +1 -1
  73. package/docs/agent-guide.md +8 -4
  74. package/docs/ai-gates.md +10 -3
  75. package/docs/brownfield-adoption.md +4 -1
  76. package/docs/configuration.md +66 -10
  77. package/docs/develop.md +4 -1
  78. package/docs/diagnostics.md +48 -4
  79. package/docs/enthusiast/how-to-agent-gates.md +5 -0
  80. package/docs/enthusiast/how-to-pick-shape.md +1 -1
  81. package/docs/package-surface.md +7 -5
  82. package/docs/use.md +10 -3
  83. package/package.json +1 -1
  84. package/schemas/ark.config.schema.json +24 -3
  85. package/server.json +2 -2
  86. package/templates/agent-skills/README.md +1 -1
  87. package/templates/agent-skills/ark-adopt/SKILL.md +97 -1
  88. package/templates/agent-skills/ark-autopilot/SKILL.md +12 -3
  89. package/templates/agent-skills/ark-coverage/SKILL.md +2 -2
  90. package/templates/agent-skills/ark-explain/SKILL.md +6 -3
  91. package/templates/agent-skills/ark-explore/SKILL.md +33 -1
  92. package/templates/agent-skills/ark-place/SKILL.md +42 -3
  93. package/templates/agent-skills/ark-runtime/SKILL.md +4 -2
  94. package/templates/agent-skills/ark-upgrade/SKILL.md +1 -1
  95. package/templates/arkrules/DomainModel.json +14 -1
  96. package/templates/skills/ark-adopt.md +97 -1
  97. package/templates/skills/ark-autopilot.md +12 -3
  98. package/templates/skills/ark-coverage.md +2 -2
  99. package/templates/skills/ark-explain.md +6 -3
  100. package/templates/skills/ark-explore.md +33 -1
  101. package/templates/skills/ark-place.md +42 -3
  102. package/templates/skills/ark-runtime.md +4 -2
  103. package/templates/skills/ark-upgrade.md +1 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Soft prototype-shortcut residual (P2 §3 / Guiar).
3
+ * Tooling I/O. Never a gate fail.
4
+ *
5
+ * SQLite / JSON-file stores standing in for a declared Persistence home,
6
+ * and admin / god-mode literals sitting outside Domain or an auth tag.
7
+ * Silent when the contract does not imply those houses, the shortcut is
8
+ * already in the right house, or the tree has no such markers.
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { layerForFile } from '../ark-shared.mjs';
14
+ import { isDomainRoleLayerName } from './arkrules-sensors.mjs';
15
+ import { isNonProductionPilotPath } from './design-smells.mjs';
16
+
17
+ export const PROTOTYPE_KIND = Object.freeze({
18
+ SQLITE: 'sqlite',
19
+ JSON_FILE: 'json-file',
20
+ ADMIN: 'admin-literals',
21
+ });
22
+
23
+ export const PROTOTYPE_SQLITE_ASK =
24
+ 'SQLite is the standing store, and it is not in Persistence.';
25
+
26
+ export const PROTOTYPE_JSON_ASK =
27
+ 'A JSON file is standing in for Persistence.';
28
+
29
+ export const PROTOTYPE_ADMIN_ASK =
30
+ 'Admin / god-mode literals sit outside Domain (or an auth tag).';
31
+
32
+ export const PROTOTYPE_PACK_ASK =
33
+ 'Prototype shortcuts are still standing in for Persistence or Domain policy.';
34
+
35
+ export const PROTOTYPE_PERSISTENCE_NEXT =
36
+ 'Create a Persistence adapter (/ark-place), then move the file store behind it (/ark-autopilot). Do not harden the shortcut.';
37
+
38
+ export const PROTOTYPE_ADMIN_NEXT =
39
+ 'Move admin / god-mode literals behind Domain or an auth tag (/ark-place). Then one small refactor with /ark-autopilot.';
40
+
41
+ export const PROTOTYPE_PACK_NEXT =
42
+ 'Create a Persistence adapter (/ark-place) for the file store, and move admin literals behind Domain or an auth tag. Then one small refactor with /ark-autopilot.';
43
+
44
+ const SOURCE_EXT = /\.(?:ts|tsx|js|jsx|mts|cts)$/;
45
+ const MAX_FILE_BYTES = 256 * 1024;
46
+ const MAX_SCAN_FILES = 800;
47
+
48
+ const SQLITE_IMPORT_RE =
49
+ /\b(?:from|require\()\s*['"](?:better-sqlite3|sqlite3|sql\.js|bun:sqlite|node:sqlite)['"]/;
50
+
51
+ const JSON_DB_IMPORT_RE = /\b(?:from|require\()\s*['"](?:lowdb|node-json-db)['"]/;
52
+
53
+ const JSON_DB_PATH_RE =
54
+ /['"`](?:[^'"`]*[\\/])?(?:db|database|store|data-store|dump)\.json['"`]/i;
55
+
56
+ const FS_OR_JSON_RE = /\b(?:readFile|writeFile|readFileSync|writeFileSync|JSON\.parse|JSON\.stringify)\b/;
57
+
58
+ const PRIVILEGE_LITERAL_RE =
59
+ /['"](?:admin|superadmin|super-admin|superuser|god|god-mode|godmode)['"]/i;
60
+
61
+ const PRIVILEGE_CONTEXT_RE =
62
+ /\b(?:role|roles|privilege|permission|permissions|acl|isAdmin|isSuperuser|godMode|GOD_MODE)\b/;
63
+
64
+ const HARDCODED_ADMIN_RE = /\bisAdmin\s*=\s*true\b/;
65
+
66
+ const GOD_IDENT_RE = /\b(?:isGodMode|godMode|GOD_MODE|isSuperuser)\b/;
67
+
68
+ /** Persistence-role house by name. Align with design-smells persistence heuristic. */
69
+ export function isPersistenceRoleLayerName(name) {
70
+ return typeof name === 'string' && /persist|repository|data.?access/i.test(name);
71
+ }
72
+
73
+ function configLayers(input) {
74
+ return Array.isArray(input.config?.layers) ? input.config.layers : [];
75
+ }
76
+
77
+ function stripComments(source) {
78
+ return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
79
+ }
80
+
81
+ function readTextLimited(absPath) {
82
+ try {
83
+ const st = fs.statSync(absPath);
84
+ if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) return null;
85
+ return fs.readFileSync(absPath, 'utf8');
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function normalizeRel(root, filePath) {
92
+ const abs = path.isAbsolute(filePath) ? filePath : path.join(root, filePath);
93
+ let rel = path.relative(root, abs).split(path.sep).join('/');
94
+ if (rel.startsWith('./')) rel = rel.slice(2);
95
+ return { abs, rel };
96
+ }
97
+
98
+ function hasSqliteShortcut(source) {
99
+ return SQLITE_IMPORT_RE.test(source);
100
+ }
101
+
102
+ function hasJsonFileShortcut(source) {
103
+ if (JSON_DB_IMPORT_RE.test(source)) return true;
104
+ return JSON_DB_PATH_RE.test(source) && FS_OR_JSON_RE.test(source);
105
+ }
106
+
107
+ function hasAdminLiteral(source) {
108
+ if (HARDCODED_ADMIN_RE.test(source) || GOD_IDENT_RE.test(source)) return true;
109
+ return PRIVILEGE_LITERAL_RE.test(source) && PRIVILEGE_CONTEXT_RE.test(source);
110
+ }
111
+
112
+ function layerNameFor(root, rel, config) {
113
+ try {
114
+ return layerForFile(root, rel, config?.layers ?? []) ?? null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ function layerByName(config, name) {
121
+ return configLayers({ config }).find((layer) => layer.name === name) ?? null;
122
+ }
123
+
124
+ function isTrustPolicyLayer(layer) {
125
+ const tag = layer?.trustBoundary;
126
+ return tag === 'auth' || tag === 'admin';
127
+ }
128
+
129
+ function composeAsk(kinds) {
130
+ if (kinds.length === 1) {
131
+ if (kinds[0] === PROTOTYPE_KIND.SQLITE) return PROTOTYPE_SQLITE_ASK;
132
+ if (kinds[0] === PROTOTYPE_KIND.JSON_FILE) return PROTOTYPE_JSON_ASK;
133
+ return PROTOTYPE_ADMIN_ASK;
134
+ }
135
+ return PROTOTYPE_PACK_ASK;
136
+ }
137
+
138
+ function composeNext(kinds) {
139
+ const persist = kinds.includes(PROTOTYPE_KIND.SQLITE) || kinds.includes(PROTOTYPE_KIND.JSON_FILE);
140
+ const admin = kinds.includes(PROTOTYPE_KIND.ADMIN);
141
+ if (persist && admin) return PROTOTYPE_PACK_NEXT;
142
+ if (admin) return PROTOTYPE_ADMIN_NEXT;
143
+ return PROTOTYPE_PERSISTENCE_NEXT;
144
+ }
145
+
146
+ /**
147
+ * Soft residual when prototype stores or privilege literals are standing in
148
+ * for a declared Persistence / Domain (or auth) house. Never flips valid.
149
+ *
150
+ * @param {{ root?: string, config?: object, coverage?: object, files?: readonly string[] }} [input]
151
+ * @returns {{ kinds: string[], ask: string, nextAction: string, evidence: string[], persistenceLayers: string[], domainLayers: string[] } | null}
152
+ */
153
+ export function collectPrototypeShortcutsResidual(input = {}) {
154
+ const declared = configLayers(input);
155
+ const persistenceLayers = declared.filter((layer) => isPersistenceRoleLayerName(layer.name));
156
+ const domainLayers = declared.filter((layer) =>
157
+ isDomainRoleLayerName(layer.name, layer.intentPrefixes ?? [])
158
+ );
159
+ const trustPolicyLayers = declared.filter((layer) => isTrustPolicyLayer(layer));
160
+ const persistEligible = persistenceLayers.length > 0;
161
+ const adminEligible = domainLayers.length > 0 || trustPolicyLayers.length > 0;
162
+ if (!persistEligible && !adminEligible) return null;
163
+
164
+ const root = typeof input.root === 'string' ? input.root : '';
165
+ const files = Array.isArray(input.files) ? input.files : [];
166
+ if (files.length === 0) return null;
167
+
168
+ const persistNames = new Set(persistenceLayers.map((layer) => layer.name));
169
+ const domainNames = new Set(domainLayers.map((layer) => layer.name));
170
+
171
+ const sqliteHits = [];
172
+ const jsonHits = [];
173
+ const adminHits = [];
174
+ let scanned = 0;
175
+
176
+ for (const file of files) {
177
+ if (scanned >= MAX_SCAN_FILES) break;
178
+ const { abs, rel } = normalizeRel(root, file);
179
+ if (!rel || rel.startsWith('..')) continue;
180
+ if (!SOURCE_EXT.test(rel)) continue;
181
+ if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) continue;
182
+ if (isNonProductionPilotPath(rel)) continue;
183
+ const source = readTextLimited(abs);
184
+ if (source == null) continue;
185
+ scanned += 1;
186
+ const code = stripComments(source);
187
+ const layer = layerNameFor(root, rel, input.config);
188
+ const inPersistence = Boolean(layer && persistNames.has(layer));
189
+ const policyHome =
190
+ Boolean(layer && domainNames.has(layer)) || isTrustPolicyLayer(layerByName(input.config, layer));
191
+
192
+ if (persistEligible && !inPersistence) {
193
+ if (hasSqliteShortcut(code)) sqliteHits.push(rel);
194
+ if (hasJsonFileShortcut(code)) jsonHits.push(rel);
195
+ }
196
+ if (adminEligible && !policyHome && hasAdminLiteral(code)) adminHits.push(rel);
197
+ }
198
+
199
+ const kinds = [];
200
+ const evidence = [];
201
+ if (persistEligible && sqliteHits.length > 0) {
202
+ kinds.push(PROTOTYPE_KIND.SQLITE);
203
+ evidence.push(...sqliteHits);
204
+ }
205
+ if (persistEligible && jsonHits.length > 0) {
206
+ kinds.push(PROTOTYPE_KIND.JSON_FILE);
207
+ evidence.push(...jsonHits);
208
+ }
209
+ if (adminEligible && adminHits.length > 0) {
210
+ kinds.push(PROTOTYPE_KIND.ADMIN);
211
+ evidence.push(...adminHits);
212
+ }
213
+ if (kinds.length === 0) return null;
214
+
215
+ const uniqueEvidence = [...new Set(evidence)].slice(0, 12);
216
+ return {
217
+ kinds,
218
+ ask: composeAsk(kinds),
219
+ nextAction: composeNext(kinds),
220
+ evidence: uniqueEvidence,
221
+ persistenceLayers: persistenceLayers.map((layer) => layer.name),
222
+ domainLayers: domainLayers.map((layer) => layer.name),
223
+ };
224
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `--recommend` presentation. Lives here so ark-check-runtime stays under the
3
+ * orchestration LOC budget and doctor does not parse this path.
4
+ */
5
+ import path from 'node:path';
6
+ import {
7
+ ADOPTION_PLAN_FILENAME,
8
+ buildArchitectureRecommendation,
9
+ formatArchitectureRecommendationHuman,
10
+ writeAdoptionPlan,
11
+ } from '../ark-shared.mjs';
12
+ import { withProjectedGovernedCoverage } from './projected-governed-coverage.mjs';
13
+
14
+ export function runRecommend(args) {
15
+ try {
16
+ const recommendation = withProjectedGovernedCoverage(
17
+ buildArchitectureRecommendation(args.root),
18
+ args.root
19
+ );
20
+ let planWritten;
21
+ if (args.writePlan) {
22
+ const result = writeAdoptionPlan(args.root, recommendation);
23
+ planWritten = result.path;
24
+ }
25
+ if (args.json) {
26
+ console.log(
27
+ JSON.stringify(
28
+ {
29
+ ...recommendation,
30
+ ...(planWritten
31
+ ? { adoptionPlanPath: path.relative(args.root, planWritten) || ADOPTION_PLAN_FILENAME }
32
+ : {}),
33
+ },
34
+ null,
35
+ 2
36
+ )
37
+ );
38
+ } else {
39
+ console.log(formatArchitectureRecommendationHuman(recommendation));
40
+ if (planWritten) {
41
+ console.log('');
42
+ console.log(`Wrote ${path.relative(args.root, planWritten) || ADOPTION_PLAN_FILENAME}`);
43
+ }
44
+ }
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ if (args.json) {
48
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
49
+ } else {
50
+ console.error(`ark-check --recommend failed: ${message}`);
51
+ }
52
+ process.exitCode = 2;
53
+ }
54
+ }
@@ -216,6 +216,14 @@ export function deterministicNextAction(violation) {
216
216
  return 'Add metadata.source to the publish call, then run Ark again.';
217
217
  case 'INVARIANT_COVERAGE_OUTSIDE_ROOTS':
218
218
  return `Move the covering test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json, then run Ark again.`;
219
+ case 'INVARIANT_CATALOG_EMPTY':
220
+ return typeof violation.file === 'string' && violation.file.length > 0
221
+ ? `Add 1–2 short phrases to invariants[] in ${violation.file}, then run ark-check --doctor. Starters show the shape.`
222
+ : 'Add 1–2 short phrases to invariants[] in arkrules/<Domain>.json, then run ark-check --doctor. Starters show the shape.';
223
+ case 'INVARIANT_TESTS_PATH_MISSING':
224
+ return 'Add coverage.testGlobs or coverage.coverageRoots in ark.config.json pointing at a real tests folder, then re-run. Adopted mode fails closed until that path is present.';
225
+ case 'INVARIANT_COVERAGE_ROOTS_MISSING':
226
+ return 'Add coverage.coverageRoots in ark.config.json pointing at the folder the test runner uses, then re-run. An enforced invariant fails closed until that path is present.';
219
227
  case 'ARKRULE_STRUCTURE':
220
228
  case 'ARKRULE_INVARIANT':
221
229
  case 'INVARIANT_UNCOVERED':
@@ -360,6 +368,7 @@ export function classifyRemediation(violation) {
360
368
  ruleId === 'ARKRULE_INVARIANT' ||
361
369
  ruleId === 'INVARIANT_UNCOVERED' ||
362
370
  ruleId === 'INVARIANT_COVERAGE_OUTSIDE_ROOTS' ||
371
+ ruleId === 'INVARIANT_COVERAGE_ROOTS_MISSING' ||
363
372
  (typeof ruleId === 'string' && ruleId.startsWith('ARKRULE_'))) {
364
373
  return {
365
374
  class: 'judgment',
@@ -490,6 +499,12 @@ export function enrichViolationWithFixClass(violation) {
490
499
  enriched.enthusiastHint =
491
500
  'The covering test lives where the project says its runner does not go. Move it, or declare that root in coverage.coverageRoots.';
492
501
  break;
502
+ case 'INVARIANT_COVERAGE_ROOTS_MISSING':
503
+ enriched.fixClass = 'review-contract';
504
+ enriched.effort = 'small';
505
+ enriched.enthusiastHint =
506
+ 'An invariant is enforced, but the project never said where tests run. Add coverage.coverageRoots so coverage cannot tick a test no runner uses.';
507
+ break;
493
508
  case 'ARKRULE_STRUCTURE':
494
509
  case 'ARKRULE_INVARIANT':
495
510
  case 'INVARIANT_UNCOVERED':
@@ -1168,9 +1168,17 @@ export function resolveCandidateFacts({
1168
1168
  const arkOrderXiFieldWrites = [];
1169
1169
  const arkOrderIngestWritesXi = [];
1170
1170
  const arkOrderReleaseKeyCounts = [];
1171
- const xiKeys = [...(config.arkOrder?.xiKeys ?? [])];
1172
- const compositionRootPatterns = [...(config.arkRun?.compositionRoots ?? [])];
1173
- const planeRootPatterns = [...(config.arkOrder?.planeRoots ?? [])];
1171
+ const arkRunActive = config.arkRun != null && typeof config.arkRun === 'object';
1172
+ const arkOrderActive = config.arkOrder != null && typeof config.arkOrder === 'object';
1173
+ const arkRulesActive =
1174
+ config.arkRules != null &&
1175
+ typeof config.arkRules === 'object' &&
1176
+ Object.keys(config.arkRules).length > 0;
1177
+ const xiKeys = arkOrderActive ? [...(config.arkOrder?.xiKeys ?? [])] : [];
1178
+ const compositionRootPatterns = arkRunActive
1179
+ ? [...(config.arkRun?.compositionRoots ?? [])]
1180
+ : [];
1181
+ const planeRootPatterns = arkOrderActive ? [...(config.arkOrder?.planeRoots ?? [])] : [];
1174
1182
 
1175
1183
  const seedPathSet = new Set(candidateFiles.map((file) => file.path));
1176
1184
  const ingest = (candidate, fullExtract) => {
@@ -1256,60 +1264,67 @@ export function resolveCandidateFacts({
1256
1264
  );
1257
1265
  // Class-shape extraction is text-conservative (false negatives over false positives).
1258
1266
  // Only TS/TSX candidates; sensors consume the same shape via facts.classShapes.
1267
+ // Extra-plane and ArkRules extractors stay silent when that plane is off (#212).
1259
1268
  if (/\.(tsx?|mts|cts)$/i.test(candidate.path)) {
1260
- try {
1261
- classShapes.push(...extractClassShapesFromSource(candidate.path, candidate.content));
1262
- } catch {
1263
- // Never fail the resolver for shape extraction; sensors stay silent on this file.
1264
- }
1265
- try {
1266
- arkRunKernelCalls.push(
1267
- ...extractArkRunKernelCallsFromSource(candidate.path, candidate.content)
1268
- );
1269
- } catch {
1270
- // Never fail the resolver for ArkRun call extraction.
1271
- }
1272
- try {
1273
- arkRunDeclarations.push(
1274
- ...extractArkRunDeclarationsFromSource(candidate.path, candidate.content)
1275
- );
1276
- } catch {
1277
- // Never fail the resolver for ArkRun declaration extraction.
1278
- }
1279
- try {
1280
- arkOrderPlaneCalls.push(
1281
- ...extractArkOrderPlaneCallsFromSource(candidate.path, candidate.content)
1282
- );
1283
- } catch {
1284
- // Never fail the resolver for ArkOrder factory extraction.
1285
- }
1286
- try {
1287
- arkOrderGenericUpdates.push(
1288
- ...extractArkOrderGenericUpdatesFromSource(candidate.path, candidate.content)
1289
- );
1290
- } catch {
1291
- // Never fail the resolver for ArkOrder generic-update extraction.
1292
- }
1293
- try {
1294
- arkOrderXiFieldWrites.push(
1295
- ...extractArkOrderXiFieldWritesFromSource(candidate.path, candidate.content, xiKeys)
1296
- );
1297
- } catch {
1298
- // Never fail the resolver for ArkOrder xi-field-write extraction.
1269
+ if (arkRulesActive || arkRunActive) {
1270
+ try {
1271
+ classShapes.push(...extractClassShapesFromSource(candidate.path, candidate.content));
1272
+ } catch {
1273
+ // Never fail the resolver for shape extraction; sensors stay silent on this file.
1274
+ }
1299
1275
  }
1300
- try {
1301
- arkOrderIngestWritesXi.push(
1302
- ...extractArkOrderIngestWritesXiFromSource(candidate.path, candidate.content)
1303
- );
1304
- } catch {
1305
- // Never fail the resolver for ArkOrder ingest-writes-ξ extraction.
1276
+ if (arkRunActive) {
1277
+ try {
1278
+ arkRunKernelCalls.push(
1279
+ ...extractArkRunKernelCallsFromSource(candidate.path, candidate.content)
1280
+ );
1281
+ } catch {
1282
+ // Never fail the resolver for ArkRun call extraction.
1283
+ }
1284
+ try {
1285
+ arkRunDeclarations.push(
1286
+ ...extractArkRunDeclarationsFromSource(candidate.path, candidate.content)
1287
+ );
1288
+ } catch {
1289
+ // Never fail the resolver for ArkRun declaration extraction.
1290
+ }
1306
1291
  }
1307
- try {
1308
- arkOrderReleaseKeyCounts.push(
1309
- ...extractArkOrderReleaseKeyCountsFromSource(candidate.path, candidate.content)
1310
- );
1311
- } catch {
1312
- // Never fail the resolver for ArkOrder release key-count extraction.
1292
+ if (arkOrderActive) {
1293
+ try {
1294
+ arkOrderPlaneCalls.push(
1295
+ ...extractArkOrderPlaneCallsFromSource(candidate.path, candidate.content)
1296
+ );
1297
+ } catch {
1298
+ // Never fail the resolver for ArkOrder factory extraction.
1299
+ }
1300
+ try {
1301
+ arkOrderGenericUpdates.push(
1302
+ ...extractArkOrderGenericUpdatesFromSource(candidate.path, candidate.content)
1303
+ );
1304
+ } catch {
1305
+ // Never fail the resolver for ArkOrder generic-update extraction.
1306
+ }
1307
+ try {
1308
+ arkOrderXiFieldWrites.push(
1309
+ ...extractArkOrderXiFieldWritesFromSource(candidate.path, candidate.content, xiKeys)
1310
+ );
1311
+ } catch {
1312
+ // Never fail the resolver for ArkOrder xi-field-write extraction.
1313
+ }
1314
+ try {
1315
+ arkOrderIngestWritesXi.push(
1316
+ ...extractArkOrderIngestWritesXiFromSource(candidate.path, candidate.content)
1317
+ );
1318
+ } catch {
1319
+ // Never fail the resolver for ArkOrder ingest-writes-ξ extraction.
1320
+ }
1321
+ try {
1322
+ arkOrderReleaseKeyCounts.push(
1323
+ ...extractArkOrderReleaseKeyCountsFromSource(candidate.path, candidate.content)
1324
+ );
1325
+ } catch {
1326
+ // Never fail the resolver for ArkOrder release key-count extraction.
1327
+ }
1313
1328
  }
1314
1329
  }
1315
1330
  };
@@ -1355,18 +1370,20 @@ export function resolveCandidateFacts({
1355
1370
  ? candidateFiles.filter((file) => seedPathSet.has(file.path))
1356
1371
  : candidateFiles;
1357
1372
  const admittedTypeNames = new Set(classShapes.map((shape) => shape.className));
1358
- for (const candidate of extractCandidates) {
1359
- if (!/\.(tsx?|mts|cts)$/i.test(candidate.path)) continue;
1360
- try {
1361
- arkRunManagedNews.push(
1362
- ...extractArkRunManagedNewsFromSource(
1363
- candidate.path,
1364
- candidate.content,
1365
- admittedTypeNames
1366
- )
1367
- );
1368
- } catch {
1369
- // Never fail the resolver for managed-new extraction.
1373
+ if (arkRunActive) {
1374
+ for (const candidate of extractCandidates) {
1375
+ if (!/\.(tsx?|mts|cts)$/i.test(candidate.path)) continue;
1376
+ try {
1377
+ arkRunManagedNews.push(
1378
+ ...extractArkRunManagedNewsFromSource(
1379
+ candidate.path,
1380
+ candidate.content,
1381
+ admittedTypeNames
1382
+ )
1383
+ );
1384
+ } catch {
1385
+ // Never fail the resolver for managed-new extraction.
1386
+ }
1370
1387
  }
1371
1388
  }
1372
1389
 
@@ -4,6 +4,7 @@
4
4
  * Summary includes per-layer + structure/invariant detail so showcase HTML /ark-explain
5
5
  * can teach what is under contract, not only aggregate numbers.
6
6
  */
7
+ import path from 'node:path';
7
8
  import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
8
9
  import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
9
10
  import {
@@ -16,6 +17,15 @@ import {
16
17
  composeMergePlanesHonesty,
17
18
  demoteExtraPlaneTeethUnderClassificationFloor,
18
19
  } from './extra-merge-teeth.mjs';
20
+ import { collectEmptyInvariantCatalogFindings } from './arkrules-sensors.mjs';
21
+ import { layerForRelativePath } from '../ark-layer-match.mjs';
22
+ import {
23
+ ARKRULES_EMPTY_CATALOG_NEXT,
24
+ ARKRULES_FIRST_CONTACT_NEXT,
25
+ ARKRULES_ONE_BREATH,
26
+ } from './product-copy.mjs';
27
+
28
+ export { ARKRULES_EMPTY_CATALOG_NEXT, ARKRULES_FIRST_CONTACT_NEXT, ARKRULES_ONE_BREATH };
19
29
 
20
30
  /**
21
31
  * Cap long catalogs in doctor JSON (and HTML, which consumes the same summary).
@@ -27,6 +37,18 @@ const UNCOVERED_CATALOG_MAX = 30;
27
37
 
28
38
  export { EXTRA_MERGE_TEETH_GOVERNED_FLOOR };
29
39
 
40
+ /** Doctor walk often hands absolute paths; layer globs are project-relative. */
41
+ function projectRelativePath(root, filePath) {
42
+ const posix = String(filePath).replace(/\\/g, '/');
43
+ if (path.isAbsolute(posix) && typeof root === 'string' && root.length > 0) {
44
+ const relative = path.relative(root, posix).replace(/\\/g, '/');
45
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
46
+ return relative;
47
+ }
48
+ }
49
+ return posix.replace(/^\.\//, '');
50
+ }
51
+
30
52
  /**
31
53
  * P1M / extraMergeTeeth: under the classification floor, demote enforced ArkRules
32
54
  * and ArkRun findings so merge matches doctor stamp (layer graph only).
@@ -186,6 +208,25 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
186
208
  const invariantAdvisory = invariants - invariantEnforced;
187
209
  const coveredInvariants = coverage.coverage.filter((c) => c.covered).length;
188
210
  const uncoveredInvariants = coverage.coverage.filter((c) => !c.covered).length;
211
+ const catalogFiles = Array.isArray(facts?.files)
212
+ ? facts.files
213
+ .map((entry) => {
214
+ const raw = typeof entry?.path === 'string' ? entry.path : '';
215
+ if (!raw) return null;
216
+ const relative = projectRelativePath(root, raw);
217
+ return {
218
+ path: relative,
219
+ layer: layerForRelativePath(relative, config.layers ?? []),
220
+ };
221
+ })
222
+ .filter(Boolean)
223
+ : [];
224
+ const emptyCatalogFinding = collectEmptyInvariantCatalogFindings({
225
+ arkRulesActive: true,
226
+ arkRules: loaded.arkRules,
227
+ layers: config.layers ?? [],
228
+ files: catalogFiles,
229
+ })[0];
189
230
  const mergePlanes = composeMergePlanesHonesty({
190
231
  classification,
191
232
  arkRules: {
@@ -221,6 +262,14 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
221
262
  mergePlanes,
222
263
  notAScore: true,
223
264
  note: 'ArkRules plane (intra-layer) — counts and catalog, never a score. Green with uncovered residual must say so. Structure sensors are heuristics; invariants are catalog+coverage evidence, not a business runtime.',
265
+ ...(emptyCatalogFinding
266
+ ? {
267
+ emptyInvariantCatalog: true,
268
+ catalogFillPath: emptyCatalogFinding.arkruleSource,
269
+ catalogFillLayer: emptyCatalogFinding.fromLayer,
270
+ catalogFailsStrict: emptyCatalogFinding.failsStrict === true,
271
+ }
272
+ : { emptyInvariantCatalog: false }),
224
273
  };
225
274
  } catch (error) {
226
275
  return {
@@ -231,6 +280,61 @@ export function summarizeRulesUnderContract(root, config, facts, classification)
231
280
  }
232
281
  }
233
282
 
283
+ /**
284
+ * Compact / details doctor lines. Empty when the map is off — absence is silent.
285
+ * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
286
+ * @returns {string[]}
287
+ */
288
+ export function formatArkRulesDoctorLines(section) {
289
+ if (!section || typeof section !== 'object') return [];
290
+ if (section.active !== true) return [];
291
+
292
+ if (Array.isArray(section.loadErrors) && section.loadErrors.length > 0) {
293
+ const first = section.loadErrors[0];
294
+ const detail =
295
+ typeof first?.message === 'string' && first.message.length > 0
296
+ ? first.message
297
+ : 'Fix the path in arkRules.';
298
+ return [
299
+ ARKRULES_ONE_BREATH,
300
+ `ArkRules: the rules file failed to load — a full check will refuse. ${detail}`,
301
+ ];
302
+ }
303
+
304
+ const structure = Number(section.structureRules) || 0;
305
+ const invariants = Number(section.invariants) || 0;
306
+ const uncovered = Number(section.uncoveredInvariants) || 0;
307
+ const enforced =
308
+ (Number(section.mergePlanes?.structureSensors?.enforced) || 0) +
309
+ (Number(section.mergePlanes?.invariants?.enforced) || 0);
310
+ const teeth =
311
+ enforced > 0
312
+ ? 'some enforced'
313
+ : 'advisory only — does not fail the merge';
314
+ const lines = [
315
+ ARKRULES_ONE_BREATH,
316
+ `ArkRules: on · structure=${structure} · invariants=${invariants} · uncovered=${uncovered} · ${teeth} · not a score`,
317
+ ];
318
+ if (section.emptyInvariantCatalog === true) {
319
+ const fill =
320
+ typeof section.catalogFillPath === 'string' && section.catalogFillPath.length > 0
321
+ ? section.catalogFillPath
322
+ : 'arkrules/<Domain>.json';
323
+ const layer =
324
+ typeof section.catalogFillLayer === 'string' && section.catalogFillLayer.length > 0
325
+ ? section.catalogFillLayer
326
+ : 'Domain';
327
+ lines.push(
328
+ `ArkRules: ${layer} has code, but invariants[] is empty — add 1–2 short phrases in ${fill}.`
329
+ );
330
+ lines.push(ARKRULES_EMPTY_CATALOG_NEXT);
331
+ } else if (uncovered > 0) lines.push(ARKRULES_FIRST_CONTACT_NEXT);
332
+ else if (typeof section.note === 'string' && /failed/i.test(section.note)) {
333
+ lines.push(`ArkRules: ${section.note}`);
334
+ }
335
+ return lines;
336
+ }
337
+
234
338
  /**
235
339
  * Showcase HTML for the ArkRules plane (used by html-report-advisories).
236
340
  * @param {ReturnType<typeof summarizeRulesUnderContract>|null|undefined} section
@@ -347,12 +451,23 @@ export function formatRulesUnderContractHtml(section, esc) {
347
451
  uncoveredOverflow > 0
348
452
  ? `<p class="muted">…(+${uncoveredOverflow} more uncovered)</p>`
349
453
  : '';
454
+ const emptyCatalogBlock =
455
+ section.emptyInvariantCatalog === true
456
+ ? `<p class="tag warn" style="margin-top:.55rem">Domain has code, but <code>invariants[]</code> is empty — that is not done.
457
+ Add 1–2 short phrases in <code>${escape(section.catalogFillPath || 'arkrules/<Domain>.json')}</code>.
458
+ ${
459
+ section.catalogFailsStrict === true
460
+ ? 'A domain structure rule is already enforced, so <code>--strict-merge</code> can refuse.'
461
+ : 'Advisory — does not fail the merge until a domain structure rule is enforced.'
462
+ }</p>`
463
+ : '';
350
464
  const uncoveredBlock =
465
+ emptyCatalogBlock ||
351
466
  // Aggregate total (not the truncated array length) decides "all covered".
352
- Number(section.uncoveredInvariants) === 0 && uncovered.length === 0
467
+ (Number(section.uncoveredInvariants) === 0 && uncovered.length === 0
353
468
  ? `<p class="clean-body" style="margin-top:.55rem">All catalogued invariants have coverage evidence (test/symbol scan) — residual inventory may still suggest new candidates via <code>--rules-inventory</code>.</p>`
354
469
  : `<h3 style="margin-top:.9rem;font-size:.95rem">Uncovered invariants</h3>
355
- <ul class="senior-list">${uncoveredItems}</ul>${uncoveredMore}`;
470
+ <ul class="senior-list">${uncoveredItems}</ul>${uncoveredMore}`);
356
471
 
357
472
  const coveredItems = coveredSample
358
473
  .map(