arkgate 3.9.2 → 4.0.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 (74) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +16 -5
  3. package/bin/ark-check-runtime.mjs +75 -3
  4. package/bin/ark-mcp-runtime.mjs +94 -0
  5. package/bin/lib/adapter-contract.mjs +14 -1
  6. package/bin/lib/analysis-engine.mjs +8 -8
  7. package/bin/lib/architecture-scan.mjs +35 -2
  8. package/bin/lib/arkrule-file-hints.mjs +71 -0
  9. package/bin/lib/arkrules-contract.mjs +382 -0
  10. package/bin/lib/arkrules-sensors.mjs +411 -0
  11. package/bin/lib/config-contract.mjs +85 -6
  12. package/bin/lib/doctor-advisories.mjs +14 -1
  13. package/bin/lib/doctor-plan.mjs +21 -0
  14. package/bin/lib/effective-contract-load.mjs +116 -0
  15. package/bin/lib/field-install.mjs +104 -0
  16. package/bin/lib/graph-blind.mjs +19 -0
  17. package/bin/lib/html-report-advisories.mjs +24 -0
  18. package/bin/lib/install-migrate.mjs +20 -2
  19. package/bin/lib/invariant-coverage-io.mjs +157 -0
  20. package/bin/lib/invariant-coverage.mjs +127 -0
  21. package/bin/lib/policy-delta-io.mjs +33 -0
  22. package/bin/lib/presets.mjs +241 -1
  23. package/bin/lib/remediation.mjs +28 -0
  24. package/bin/lib/resolved-candidate-facts.mjs +14 -1
  25. package/bin/lib/rules-inventory.mjs +144 -0
  26. package/bin/lib/rules-under-contract.mjs +66 -0
  27. package/bin/lib/start-preview.mjs +24 -7
  28. package/bin/lib/upgrade-command.mjs +48 -2
  29. package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
  30. package/dist/eslint/index.cjs +2 -2
  31. package/dist/eslint/index.d.ts +1 -1
  32. package/dist/eslint/index.js +2 -2
  33. package/dist/index.cjs +14 -7
  34. package/dist/index.d.ts +615 -20
  35. package/dist/index.js +13 -6
  36. package/docs/README.md +4 -3
  37. package/docs/agent-guide.md +7 -3
  38. package/docs/ai-gates.md +6 -1
  39. package/docs/brownfield-adoption.md +20 -0
  40. package/docs/configuration.md +37 -4
  41. package/docs/develop.md +8 -2
  42. package/docs/enthusiast/README.md +11 -0
  43. package/docs/package-surface.md +13 -11
  44. package/docs/product-voice.md +9 -2
  45. package/docs/use.md +9 -0
  46. package/package.json +4 -17
  47. package/schemas/ark.analysis-result.schema.json +9 -1
  48. package/schemas/ark.arkrules.schema.json +141 -0
  49. package/schemas/ark.config.schema.json +10 -2
  50. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  51. package/server.json +2 -2
  52. package/templates/arkrules/ApplicationOrchestration.json +14 -0
  53. package/templates/arkrules/DomainModel.json +32 -0
  54. package/templates/arkrules/PersistenceAdapters.json +14 -0
  55. package/templates/arkrules/PresentationAdapters.json +14 -0
  56. package/templates/skills/ark-adopt.md +28 -1
  57. package/templates/skills/ark-architect.md +23 -0
  58. package/templates/skills/ark-autopilot.md +27 -1
  59. package/templates/skills/ark-contract.md +27 -1
  60. package/templates/skills/ark-coverage.md +23 -0
  61. package/templates/skills/ark-explain.md +23 -0
  62. package/templates/skills/ark-explore.md +26 -1
  63. package/templates/skills/ark-fix.md +23 -0
  64. package/templates/skills/ark-loop.md +23 -0
  65. package/templates/skills/ark-place.md +26 -0
  66. package/templates/skills/ark-runtime.md +4 -0
  67. package/templates/skills/ark-think.md +24 -1
  68. package/templates/skills/ark-upgrade.md +23 -0
  69. package/compat/nestjs.cjs +0 -2
  70. package/compat/nestjs.d.ts +0 -2
  71. package/compat/nestjs.js +0 -1
  72. package/compat/runtime.cjs +0 -2
  73. package/compat/runtime.d.ts +0 -2
  74. package/compat/runtime.js +0 -1
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Architecture starter presets for ark-check init/coverage suggestions.
3
3
  */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
4
7
  import {
5
8
  applyFrameworkLayoutOverlays,
6
9
  createElevenLayerConfig,
@@ -11,6 +14,9 @@ import {
11
14
  } from '../ark-shared.mjs';
12
15
  import { withArkConfigMetadata } from './config-contract.mjs';
13
16
 
17
+ const PRESETS_DIR = path.dirname(fileURLToPath(import.meta.url));
18
+ const ARKRULES_TEMPLATES_DIR = path.join(PRESETS_DIR, '../../templates/arkrules');
19
+
14
20
  export function denyUpward(names) {
15
21
  const rules = [];
16
22
  for (let i = 0; i < names.length; i += 1) {
@@ -50,9 +56,243 @@ export function peerIsolationEdges(layerNames, sliceFolders, message) {
50
56
  // `src/**/domain/**` would otherwise swallow `src/kernel/domain`. Do NOT use `**/kernel/**`
51
57
  // (that carves out legitimate `src/shared/kernel/**` SharedKernel paths).
52
58
  export const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
59
+ /**
60
+ * AR08 — attach lean arkRules map. Keys are always exact project layer names.
61
+ * Sensor roles (domain-structure / orchestration / adapter-thin / generic) are
62
+ * independent of display names so renamed layers still get the right starter.
63
+ */
64
+ export const DEFAULT_ARKRULES_REFS = {
65
+ DomainModel: 'arkrules/DomainModel.json',
66
+ ApplicationOrchestration: 'arkrules/ApplicationOrchestration.json',
67
+ PresentationAdapters: 'arkrules/PresentationAdapters.json',
68
+ PersistenceAdapters: 'arkrules/PersistenceAdapters.json',
69
+ };
70
+
71
+ /** Sensor roles used when selecting or synthesizing per-layer templates. */
72
+ export const ARKRULES_SENSOR_ROLES = Object.freeze({
73
+ DOMAIN_STRUCTURE: 'domain-structure',
74
+ ORCHESTRATION: 'orchestration',
75
+ ADAPTER_THIN: 'adapter-thin',
76
+ GENERIC: 'generic',
77
+ });
78
+
79
+ /**
80
+ * Exact-name aliases → sensor role. Prefer this table over heuristics when the
81
+ * project uses a known vocabulary (hexagonal, monorepo field renames, etc.).
82
+ */
83
+ export const LAYER_SENSOR_ROLE_ALIASES = Object.freeze({
84
+ // Domain / pure model
85
+ DomainModel: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
86
+ Domain: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
87
+ Entities: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
88
+ // Application / use cases
89
+ ApplicationOrchestration: ARKRULES_SENSOR_ROLES.ORCHESTRATION,
90
+ Application: ARKRULES_SENSOR_ROLES.ORCHESTRATION,
91
+ UseCases: ARKRULES_SENSOR_ROLES.ORCHESTRATION,
92
+ // Presentation / UI adapters
93
+ PresentationAdapters: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
94
+ Presentation: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
95
+ UI: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
96
+ WebPresentation: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
97
+ ApiComposition: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
98
+ // Persistence / infrastructure adapters
99
+ PersistenceAdapters: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
100
+ Infrastructure: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
101
+ Persistence: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
102
+ Data: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
103
+ });
104
+
105
+ /**
106
+ * Resolve a stable sensor role for a project layer name.
107
+ * Exact alias first, then specific adapter/domain cues, then broad application
108
+ * heuristics (so ApplicationAdapters → thin-adapter, not orchestration).
109
+ */
110
+ export function resolveLayerSensorRole(layerName) {
111
+ if (typeof layerName !== 'string' || layerName.length === 0) {
112
+ return ARKRULES_SENSOR_ROLES.GENERIC;
113
+ }
114
+ if (LAYER_SENSOR_ROLE_ALIASES[layerName]) return LAYER_SENSOR_ROLE_ALIASES[layerName];
115
+ const n = layerName.toLowerCase();
116
+ if (/(^|[^a-z])(domain|entit)/.test(n) || n.includes('domainmodel')) {
117
+ return ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE;
118
+ }
119
+ // Adapter / I/O cues before broad "application" — ApplicationAdapters must not
120
+ // inherit orchestration-only sensors.
121
+ if (
122
+ n.includes('present') ||
123
+ n.includes('persist') ||
124
+ n.includes('infra') ||
125
+ n.includes('adapter') ||
126
+ n.includes('repository') ||
127
+ /(^|[^a-z])(ui|web|data)([^a-z]|$)/.test(n)
128
+ ) {
129
+ return ARKRULES_SENSOR_ROLES.ADAPTER_THIN;
130
+ }
131
+ if (
132
+ n.includes('application') ||
133
+ n.includes('orchestr') ||
134
+ n.includes('usecase') ||
135
+ n.includes('use-case') ||
136
+ n.includes('use_case')
137
+ ) {
138
+ return ARKRULES_SENSOR_ROLES.ORCHESTRATION;
139
+ }
140
+ return ARKRULES_SENSOR_ROLES.GENERIC;
141
+ }
142
+
143
+ /**
144
+ * Pick the shipped archetype filename for a layer (role + presentation vs persistence cue).
145
+ * Returns null for generic molds.
146
+ */
147
+ export function archetypeTemplateFileForLayer(layerName, role = resolveLayerSensorRole(layerName)) {
148
+ if (role === ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE) return 'DomainModel.json';
149
+ if (role === ARKRULES_SENSOR_ROLES.ORCHESTRATION) return 'ApplicationOrchestration.json';
150
+ if (role === ARKRULES_SENSOR_ROLES.ADAPTER_THIN) {
151
+ const n = String(layerName).toLowerCase();
152
+ if (
153
+ n.includes('present') ||
154
+ n.includes('ui') ||
155
+ n.includes('web') ||
156
+ n.includes('page') ||
157
+ n.includes('widget') ||
158
+ n.includes('api')
159
+ ) {
160
+ return 'PresentationAdapters.json';
161
+ }
162
+ return 'PersistenceAdapters.json';
163
+ }
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Layer names used as single path segments under arkrules/ must not escape the dir
169
+ * (reject `/`, `\`, `..`, absolute, empty). Mirrors managed-upgrade isSafeRelativePath.
170
+ */
171
+ export function isSafeArkRulesLayerName(layerName) {
172
+ if (typeof layerName !== 'string' || layerName.length === 0) return false;
173
+ if (layerName.includes('\0') || layerName.includes('/') || layerName.includes('\\')) return false;
174
+ if (layerName === '.' || layerName === '..' || layerName.includes('..')) return false;
175
+ // Single path segment: letters/digits/underscore/hyphen; optional leading letter preferred
176
+ // but allow common PascalCase layer vocabularies.
177
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(layerName)) return false;
178
+ return true;
179
+ }
180
+
181
+ /** Relative path for a layer's arkrules file (always arkrules/<exactLayerName>.json). */
182
+ export function arkRulesPathForLayer(layerName) {
183
+ if (!isSafeArkRulesLayerName(layerName)) {
184
+ throw new Error(
185
+ `unsafe arkRules layer name ${JSON.stringify(layerName)}: must be a single path segment (A-Za-z0-9_-) under arkrules/`
186
+ );
187
+ }
188
+ return `arkrules/${layerName}.json`;
189
+ }
190
+
191
+ /** True when rel is a safe project-relative arkrules/*.json path (no escape). */
192
+ export function isSafeArkRulesRelativePath(rel) {
193
+ if (typeof rel !== 'string' || rel.length === 0) return false;
194
+ if (rel.includes('\0') || rel.includes('\\') || path.isAbsolute(rel)) return false;
195
+ const normalized = path.posix.normalize(rel);
196
+ if (normalized !== rel || normalized === '.' || normalized.startsWith('../')) return false;
197
+ if (!normalized.startsWith('arkrules/') || !normalized.endsWith('.json')) return false;
198
+ // Exactly one segment under arkrules/ (no nested dirs).
199
+ const rest = normalized.slice('arkrules/'.length);
200
+ if (!rest || rest.includes('/') || rest === '..' || rest.includes('..')) return false;
201
+ return true;
202
+ }
203
+
204
+ /**
205
+ * Build starter ArkRules JSON for a project layer: archetype clone with rewritten
206
+ * `layer` field, or an empty generic mold the agent can refine.
207
+ */
208
+ export function buildArkRulesTemplateForLayer(layerName, role = resolveLayerSensorRole(layerName)) {
209
+ const archetype = archetypeTemplateFileForLayer(layerName, role);
210
+ if (archetype) {
211
+ const sourcePath = path.join(ARKRULES_TEMPLATES_DIR, archetype);
212
+ if (fs.existsSync(sourcePath)) {
213
+ try {
214
+ const parsed = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
215
+ return {
216
+ ...parsed,
217
+ layer: layerName,
218
+ };
219
+ } catch {
220
+ // Fall through to generic mold.
221
+ }
222
+ }
223
+ }
224
+ // Generic mold: valid empty contract keyed to the exact project layer name.
225
+ // Structure is empty so agents refine without inheriting the wrong sensors.
226
+ return {
227
+ $schema: 'https://unpkg.com/arkgate/schemas/ark.arkrules.schema.json',
228
+ schemaVersion: '1.0',
229
+ layer: layerName,
230
+ structure: [],
231
+ invariants: [],
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Attach arkRules map for every declared layer. Keys = exact layer names.
237
+ * Existing map entries are preserved (never overwritten).
238
+ */
239
+ export function withDefaultArkRules(config) {
240
+ const layers = config.layers ?? [];
241
+ if (layers.length === 0) return config;
242
+ const arkRules = { ...(config.arkRules ?? {}) };
243
+ let changed = false;
244
+ for (const layer of layers) {
245
+ const name = layer?.name;
246
+ if (typeof name !== 'string' || name.length === 0) continue;
247
+ if (arkRules[name]) continue;
248
+ if (!isSafeArkRulesLayerName(name)) continue;
249
+ arkRules[name] = arkRulesPathForLayer(name);
250
+ changed = true;
251
+ }
252
+ if (!changed) return config;
253
+ return { ...config, arkRules };
254
+ }
255
+
256
+ /**
257
+ * Write starter arkrules/*.json for each arkRules map entry (AR08).
258
+ * Uses sensor-role mapping so renamed layers get the right archetype;
259
+ * unknown layers get a generic empty mold. Skips existing files unless force=true.
260
+ * Rejects paths that escape arkrules/ (mirror managed-upgrade path safety).
261
+ */
262
+ export function writeArkRulesTemplates(root, config, { force = false } = {}) {
263
+ const refs = config?.arkRules;
264
+ if (!refs || typeof refs !== 'object') return [];
265
+ const written = [];
266
+ const resolvedRoot = path.resolve(root);
267
+ for (const [layerName, rel] of Object.entries(refs)) {
268
+ if (typeof rel !== 'string' || !rel.endsWith('.json')) continue;
269
+ if (!isSafeArkRulesRelativePath(rel)) {
270
+ throw new Error(
271
+ `refusing arkRules write for unsafe path ${JSON.stringify(rel)} (layer ${JSON.stringify(layerName)})`
272
+ );
273
+ }
274
+ const target = path.resolve(resolvedRoot, rel);
275
+ const relToRoot = path.relative(resolvedRoot, target);
276
+ if (!relToRoot || relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
277
+ throw new Error(`arkRules path escapes project root: ${rel}`);
278
+ }
279
+ const underArkRules = path.relative(path.join(resolvedRoot, 'arkrules'), target);
280
+ if (!underArkRules || underArkRules.startsWith('..') || path.isAbsolute(underArkRules)) {
281
+ throw new Error(`arkRules path escapes arkrules/: ${rel}`);
282
+ }
283
+ if (fs.existsSync(target) && !force) continue;
284
+ const role = resolveLayerSensorRole(layerName);
285
+ const body = buildArkRulesTemplateForLayer(layerName, role);
286
+ fs.mkdirSync(path.dirname(target), { recursive: true });
287
+ fs.writeFileSync(target, `${JSON.stringify(body, null, 2)}\n`);
288
+ written.push(rel);
289
+ }
290
+ return written;
291
+ }
292
+
53
293
  export function presetWithOverlays(baseConfig, root) {
54
294
  const config = root ? applyFrameworkLayoutOverlays(baseConfig, root) : baseConfig;
55
- return withArkConfigMetadata(config);
295
+ return withArkConfigMetadata(withDefaultArkRules(config));
56
296
  }
57
297
 
58
298
  export const ARCHITECTURE_PRESETS = {
@@ -62,7 +62,18 @@ export function deterministicNextAction(violation) {
62
62
  return 'Publish through a registered intent creator, then run Ark again.';
63
63
  case 'PUBLISH_MISSING_SOURCE':
64
64
  return 'Add metadata.source to the publish call, then run Ark again.';
65
+ case 'ARKRULE_STRUCTURE':
66
+ case 'ARKRULE_INVARIANT':
67
+ case 'INVARIANT_UNCOVERED':
68
+ return `Fix the structure or invariant for ${typeof violation.arkruleId === 'string' && violation.arkruleId.length > 0
69
+ ? violation.arkruleId
70
+ : 'the ArkRule'} (declared in ${typeof violation.arkruleSource === 'string' && violation.arkruleSource.length > 0
71
+ ? violation.arkruleSource
72
+ : 'arkrules/<Layer>.json'}), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.`;
65
73
  default:
74
+ if (typeof violation.ruleId === 'string' && violation.ruleId.startsWith('ARKRULE_')) {
75
+ return `Fix the ArkRule ${typeof violation.arkruleId === 'string' ? violation.arkruleId : violation.ruleId}, then preflight again.`;
76
+ }
66
77
  return `Resolve ${typeof violation.ruleId === 'string' && violation.ruleId.length > 0 ? violation.ruleId : 'ARK_UNKNOWN'} without weakening ark.config.json, then run Ark again.`;
67
78
  }
68
79
  }
@@ -164,6 +175,16 @@ export function classifyRemediation(violation) {
164
175
  rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
165
176
  };
166
177
  }
178
+ if (ruleId === 'ARKRULE_STRUCTURE' ||
179
+ ruleId === 'ARKRULE_INVARIANT' ||
180
+ ruleId === 'INVARIANT_UNCOVERED' ||
181
+ (typeof ruleId === 'string' && ruleId.startsWith('ARKRULE_'))) {
182
+ return {
183
+ class: 'judgment',
184
+ confidence: 0.85,
185
+ rationale: 'ArkRule structure/invariant findings are never mechanical-safe — restore private state, factory shape, event publish, coverage, or redesign the aggregate with judgment.',
186
+ };
187
+ }
167
188
  if (typeof ruleId === 'string' && ruleId.length > 0) {
168
189
  return {
169
190
  class: 'judgment',
@@ -239,6 +260,13 @@ export function enrichViolationWithFixClass(violation) {
239
260
  enriched.enthusiastHint =
240
261
  'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
241
262
  break;
263
+ case 'ARKRULE_STRUCTURE':
264
+ case 'ARKRULE_INVARIANT':
265
+ case 'INVARIANT_UNCOVERED':
266
+ enriched.fixClass = 'review-contract';
267
+ enriched.effort = 'medium';
268
+ enriched.enthusiastHint = `ArkRule ${typeof violation.arkruleId === 'string' ? violation.arkruleId : 'rule'} failed${typeof violation.arkruleSource === 'string' ? ` (from ${violation.arkruleSource})` : ''}. Restore the declared structure or add a covering test/symbol — do not demote without acknowledgement.`;
269
+ break;
242
270
  case 'CIRCULAR_DEPENDENCY':
243
271
  enriched.fixClass = 'break-cycle';
244
272
  enriched.effort = 'medium';
@@ -34,6 +34,7 @@ import {
34
34
  typeOnlyExportNames,
35
35
  } from './ast-scan.mjs';
36
36
  import { provePortProofInject } from './port-proof.mjs';
37
+ import { extractClassShapesFromSource } from './arkrules-sensors.mjs';
37
38
  import {
38
39
  collectGovernedFiles,
39
40
  isGovernableSourceFile,
@@ -995,6 +996,8 @@ export function resolveCandidateFacts({
995
996
  const publishCalls = [];
996
997
  const intentReferences = [];
997
998
  const safetyUses = [];
999
+ /** ADR 0013 class-shape facts for ArkRules structure sensors. */
1000
+ const classShapes = [];
998
1001
 
999
1002
  for (const candidate of candidateFiles) {
1000
1003
  const sourceFile = ts.createSourceFile(
@@ -1076,6 +1079,15 @@ export function resolveCandidateFacts({
1076
1079
  safetyUses.push(
1077
1080
  ...collectSafetyUses(ts, sourceFile, candidate.path, candidate.content, semanticDependencies)
1078
1081
  );
1082
+ // Class-shape extraction is text-conservative (false negatives over false positives).
1083
+ // Only TS/TSX candidates; sensors consume the same shape via facts.classShapes.
1084
+ if (/\.(tsx?|mts|cts)$/i.test(candidate.path)) {
1085
+ try {
1086
+ classShapes.push(...extractClassShapesFromSource(candidate.path, candidate.content));
1087
+ } catch {
1088
+ // Never fail the resolver for shape extraction; sensors stay silent on this file.
1089
+ }
1090
+ }
1079
1091
  }
1080
1092
 
1081
1093
  const dependencies = [];
@@ -1140,7 +1152,7 @@ export function resolveCandidateFacts({
1140
1152
 
1141
1153
  const projectPackageName = readPackageName(canonicalRoot, observeInput);
1142
1154
  return createTrustedResolvedCandidateFacts({
1143
- schemaVersion: '1.0',
1155
+ schemaVersion: '1.1',
1144
1156
  completeness: completenessReasons.length === 0 ? 'complete' : 'partial',
1145
1157
  completenessReasons,
1146
1158
  resolverIdentity: RESOLVED_FACTS_RESOLVER_IDENTITY,
@@ -1156,5 +1168,6 @@ export function resolveCandidateFacts({
1156
1168
  publishCalls,
1157
1169
  intentReferences,
1158
1170
  safetyUses,
1171
+ classShapes,
1159
1172
  });
1160
1173
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/rulesInventory.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/rules-inventory.mjs). Zero Node I/O.
9
+ */
10
+
11
+ function lineOf(content, index) {
12
+ return content.slice(0, index).split('\n').length;
13
+ }
14
+ export function buildRulesInventory(input) {
15
+ const candidates = [];
16
+ let seq = 0;
17
+ for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
18
+ const isController = /controller|route|handler|resolver/i.test(file) ||
19
+ /@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content);
20
+ const isDomain = /domain|entity|aggregate|model/i.test(file);
21
+ // validation-in-controller
22
+ if (isController) {
23
+ const valRe = /\b(if\s*\([^)]{0,80}(amount|total|price|qty|quantity|balance)[^)]{0,40}\)|throw new (Error|BadRequest|ValidationError)|z\.object\(|yup\.|class-validator|@Is[A-Z])/g;
24
+ let m;
25
+ while ((m = valRe.exec(content)) !== null) {
26
+ seq += 1;
27
+ candidates.push({
28
+ id: `inv-val-${seq}`,
29
+ kind: 'validation-in-controller',
30
+ file,
31
+ line: lineOf(content, m.index),
32
+ message: 'Business validation appears in a controller/handler — extract an invariant or Domain rule.',
33
+ confidence: 'direct-evidence',
34
+ suggestedArkRule: {
35
+ layer: 'DomainModel',
36
+ invariantId: `INV-EXTRACT-${seq}`,
37
+ sensor: 'invariant-coverage',
38
+ },
39
+ neverMechanicalSafe: true,
40
+ });
41
+ }
42
+ }
43
+ // magic business constants (heuristic)
44
+ const magicRe = /\b(const|let)\s+([A-Z][A-Z0-9_]{2,})\s*=\s*(\d{2,}|['"][^'"]{8,}['"])/g;
45
+ let magic;
46
+ while ((magic = magicRe.exec(content)) !== null) {
47
+ if (/TEST|SPEC|TIMEOUT|PORT|VERSION|MAX_RETRY/i.test(magic[2]))
48
+ continue;
49
+ seq += 1;
50
+ candidates.push({
51
+ id: `inv-magic-${seq}`,
52
+ kind: 'magic-business-constant',
53
+ file,
54
+ line: lineOf(content, magic.index),
55
+ message: `Magic business constant ${magic[2]} may belong in a Domain policy or invariant catalog.`,
56
+ confidence: 'heuristic',
57
+ suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${magic[2]}` },
58
+ neverMechanicalSafe: true,
59
+ });
60
+ }
61
+ // anemic entity
62
+ if (isDomain) {
63
+ const classRe = /export\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([^}]{0,800})\}/g;
64
+ let c;
65
+ while ((c = classRe.exec(content)) !== null) {
66
+ const body = c[2] ?? '';
67
+ const methods = (body.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g) ?? []).length;
68
+ const fields = (body.match(/:\s*[A-Za-z]/g) ?? []).length;
69
+ if (fields >= 2 && methods <= 1) {
70
+ seq += 1;
71
+ candidates.push({
72
+ id: `inv-anemic-${seq}`,
73
+ kind: 'anemic-entity',
74
+ file,
75
+ line: lineOf(content, c.index),
76
+ message: `Class ${c[1]} looks anemic (data-heavy, few behaviors).`,
77
+ confidence: 'heuristic',
78
+ suggestedArkRule: {
79
+ layer: 'DomainModel',
80
+ structureId: 'no-anemic-model',
81
+ sensor: 'no-anemic-model',
82
+ },
83
+ neverMechanicalSafe: true,
84
+ });
85
+ }
86
+ }
87
+ }
88
+ // mutation without guard in domain
89
+ if (isDomain) {
90
+ const mutRe = /this\.\w+\s*=/g;
91
+ let mut;
92
+ while ((mut = mutRe.exec(content)) !== null) {
93
+ const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
94
+ if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
95
+ seq += 1;
96
+ candidates.push({
97
+ id: `inv-mut-${seq}`,
98
+ kind: 'mutation-without-guard',
99
+ file,
100
+ line: lineOf(content, mut.index),
101
+ message: 'Domain field mutation without nearby guard/publish call.',
102
+ confidence: 'heuristic',
103
+ suggestedArkRule: {
104
+ layer: 'DomainModel',
105
+ structureId: 'events-on-mutation',
106
+ sensor: 'domain-event-on-mutation',
107
+ },
108
+ neverMechanicalSafe: true,
109
+ });
110
+ break; // one per file is enough for inventory ranking
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const contracted = new Set(input.contractedRuleIds ?? []);
116
+ const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||
117
+ (c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;
118
+ return {
119
+ candidates,
120
+ inventoried: candidates.length,
121
+ underContract,
122
+ frozen: (input.frozenKeys ?? []).length,
123
+ notAScore: true,
124
+ };
125
+ }
126
+ /** Build a pilotLoop extraction card for the top inventory candidate (AR14). */
127
+ export function inventoryToExtractionCard(candidate) {
128
+ return {
129
+ pilot: `Extract rule candidate ${candidate.id} (${candidate.kind})`,
130
+ pilotTarget: candidate.file,
131
+ smellId: candidate.kind,
132
+ move: `Declare in arkrules/${candidate.suggestedArkRule?.layer ?? 'DomainModel'}.json, implement pure Domain logic, add covering test.`,
133
+ doNot: [
134
+ 'Do not auto-apply codemods',
135
+ 'Do not promote to enforced without coverage evidence',
136
+ 'Do not batch multiple extractions',
137
+ ],
138
+ successSignal: 'Doctor reports candidate under contract; gate green with residual honest.',
139
+ killSwitch: 'Stop if extraction requires multi-module redesign without a clear aggregate owner.',
140
+ neverMechanicalSafe: true,
141
+ class: 'judgment',
142
+ next: 'Run ark_prepare_change / preflight, then re-doctor.',
143
+ };
144
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * AR12 — doctor/HTML "Rules under contract" counts (not a score).
3
+ * Uses real file I/O for coverage evidence (never empty-fileContents stub).
4
+ */
5
+ import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
6
+ import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
7
+ import { loadInvariantCoverageInputs } from './invariant-coverage-io.mjs';
8
+
9
+ /**
10
+ * @param {string} root
11
+ * @param {Record<string, unknown>} config
12
+ * @param {{ files?: Array<{ path: string }> }} [facts] optional facts for path set
13
+ */
14
+ export function summarizeRulesUnderContract(root, config, facts) {
15
+ if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
16
+ return {
17
+ active: false,
18
+ structureRules: 0,
19
+ invariants: 0,
20
+ coveredInvariants: 0,
21
+ uncoveredInvariants: 0,
22
+ notAScore: true,
23
+ note: 'No arkRules map — intra-layer ArkRules are opt-in.',
24
+ };
25
+ }
26
+ try {
27
+ const loaded = loadEffectiveArkRulesFromDisk(root, config);
28
+ if (loaded.errors?.length) {
29
+ return {
30
+ active: true,
31
+ loadErrors: loaded.errors,
32
+ notAScore: true,
33
+ note: 'ArkRules references failed to load (fail closed on full check).',
34
+ };
35
+ }
36
+ const structureRules = loaded.arkRules.structure?.length ?? 0;
37
+ const invariants = loaded.arkRules.invariants?.length ?? 0;
38
+ const coverageInputs =
39
+ invariants > 0
40
+ ? loadInvariantCoverageInputs(root, facts ?? { files: [] })
41
+ : { fileContents: {}, testFiles: [], testGlobsMissing: false };
42
+ const coverage = evaluateInvariantCoverage({
43
+ arkRules: loaded.arkRules,
44
+ fileContents: coverageInputs.fileContents,
45
+ testFiles: coverageInputs.testFiles,
46
+ testGlobsMissing: coverageInputs.testGlobsMissing,
47
+ });
48
+ return {
49
+ active: true,
50
+ structureRules,
51
+ invariants,
52
+ coveredInvariants: coverage.coverage.filter((c) => c.covered).length,
53
+ uncoveredInvariants: coverage.coverage.filter((c) => !c.covered).length,
54
+ partialCoverage: coverage.partial,
55
+ testFilesScanned: coverageInputs.testFiles.length,
56
+ notAScore: true,
57
+ note: 'Counts only — never a score. Green with uncovered residual must say so.',
58
+ };
59
+ } catch (error) {
60
+ return {
61
+ active: true,
62
+ notAScore: true,
63
+ note: error instanceof Error ? error.message : String(error),
64
+ };
65
+ }
66
+ }
@@ -76,20 +76,31 @@ function change(pathname, before, after) {
76
76
  };
77
77
  }
78
78
 
79
- function setupBudget(changes) {
79
+ /**
80
+ * Compact start onboarding budget. Exported for pure unit tests.
81
+ * Gate surface (non-arkrules) ≤ maxFiles; arkrules count toward bytes only.
82
+ */
83
+ export function setupBudget(changes) {
80
84
  const generatedChanges = changes.filter((item) => item.path !== 'package.json');
85
+ // Compact gate surface stays ≤8 files (MCP + one host + CI + AGENTS + config).
86
+ // ArkRules starters (arkrules/*.json) are opt-in contract content and count against
87
+ // the byte budget only — AR08 4.0 emit was failing field start at 10/8 otherwise.
88
+ const gateChanges = generatedChanges.filter((item) => !item.path.startsWith('arkrules/'));
89
+ const arkrulesFiles = generatedChanges.length - gateChanges.length;
81
90
  const bytes = generatedChanges.reduce(
82
91
  (total, item) => total + (item.afterBase64 ? Buffer.from(item.afterBase64, 'base64').length : 0),
83
92
  0
84
93
  );
85
- // Compact start includes shared MCP + one host registration + CI + AGENTS + config.
86
- // Budget raised from 5→8 so .mcp.json always fits (field: grok compact hit the old ceiling).
94
+ const maxFiles = 8;
95
+ const maxBytes = 32 * 1024;
87
96
  return {
88
97
  files: generatedChanges.length,
98
+ gateFiles: gateChanges.length,
99
+ arkrulesFiles,
89
100
  bytes,
90
- maxFiles: 8,
91
- maxBytes: 32 * 1024,
92
- ok: generatedChanges.length <= 8 && bytes < 32 * 1024,
101
+ maxFiles,
102
+ maxBytes,
103
+ ok: gateChanges.length <= maxFiles && bytes < maxBytes,
93
104
  };
94
105
  }
95
106
 
@@ -117,7 +128,13 @@ export function renderStartPreview(preview) {
117
128
  console.log(`Your project looks like: ${preview.analysis.label} (${preview.analysis.archetype}, confidence ${preview.analysis.confidence}).`);
118
129
  }
119
130
  console.log(`Projected governed coverage: ${preview.projectedCoverage.percent ?? 'unknown'}% (${preview.projectedCoverage.classifiedFiles}/${preview.projectedCoverage.totalFiles} files)`);
120
- console.log(`Compact setup budget: ${preview.setupBudget.files}/${preview.setupBudget.maxFiles} files, ${preview.setupBudget.bytes}/${preview.setupBudget.maxBytes} bytes${preview.setupBudget.ok ? '' : ' (exceeded)'}.`);
131
+ const budget = preview.setupBudget;
132
+ const arkrulesNote =
133
+ budget.arkrulesFiles > 0 ? ` (+${budget.arkrulesFiles} arkrules)` : '';
134
+ const gateCount = budget.gateFiles ?? budget.files;
135
+ console.log(
136
+ `Compact setup budget: ${gateCount}/${budget.maxFiles} gate files${arkrulesNote}, ${budget.bytes}/${budget.maxBytes} bytes${budget.ok ? '' : ' (exceeded)'}.`
137
+ );
121
138
  console.log('Files to create/edit/delete:');
122
139
  if (preview.changes.length === 0) console.log(' (none)');
123
140
  for (const change of preview.changes) {