arkgate 3.9.2 → 4.0.1

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 (75) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +16 -4
  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 +20 -1
  17. package/bin/lib/html-report-advisories.mjs +12 -5
  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/managed-upgrade.mjs +1 -1
  22. package/bin/lib/policy-delta-io.mjs +33 -0
  23. package/bin/lib/presets.mjs +241 -1
  24. package/bin/lib/remediation.mjs +28 -0
  25. package/bin/lib/resolved-candidate-facts.mjs +14 -1
  26. package/bin/lib/rules-inventory.mjs +144 -0
  27. package/bin/lib/rules-under-contract.mjs +320 -0
  28. package/bin/lib/start-preview.mjs +24 -7
  29. package/bin/lib/upgrade-command.mjs +373 -16
  30. package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
  31. package/dist/eslint/index.cjs +2 -2
  32. package/dist/eslint/index.d.ts +1 -1
  33. package/dist/eslint/index.js +2 -2
  34. package/dist/index.cjs +14 -7
  35. package/dist/index.d.ts +615 -20
  36. package/dist/index.js +13 -6
  37. package/docs/README.md +5 -3
  38. package/docs/agent-guide.md +7 -3
  39. package/docs/ai-gates.md +6 -1
  40. package/docs/brownfield-adoption.md +22 -0
  41. package/docs/configuration.md +53 -4
  42. package/docs/develop.md +8 -2
  43. package/docs/enthusiast/README.md +11 -0
  44. package/docs/package-surface.md +13 -10
  45. package/docs/product-voice.md +11 -2
  46. package/docs/use.md +11 -0
  47. package/package.json +4 -17
  48. package/schemas/ark.analysis-result.schema.json +9 -1
  49. package/schemas/ark.arkrules.schema.json +141 -0
  50. package/schemas/ark.config.schema.json +10 -2
  51. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  52. package/server.json +2 -2
  53. package/templates/arkrules/ApplicationOrchestration.json +14 -0
  54. package/templates/arkrules/DomainModel.json +32 -0
  55. package/templates/arkrules/PersistenceAdapters.json +14 -0
  56. package/templates/arkrules/PresentationAdapters.json +14 -0
  57. package/templates/skills/ark-adopt.md +28 -1
  58. package/templates/skills/ark-architect.md +23 -0
  59. package/templates/skills/ark-autopilot.md +27 -1
  60. package/templates/skills/ark-contract.md +27 -1
  61. package/templates/skills/ark-coverage.md +23 -0
  62. package/templates/skills/ark-explain.md +39 -3
  63. package/templates/skills/ark-explore.md +26 -1
  64. package/templates/skills/ark-fix.md +23 -0
  65. package/templates/skills/ark-loop.md +23 -0
  66. package/templates/skills/ark-place.md +26 -0
  67. package/templates/skills/ark-runtime.md +4 -0
  68. package/templates/skills/ark-think.md +24 -1
  69. package/templates/skills/ark-upgrade.md +80 -11
  70. package/compat/nestjs.cjs +0 -2
  71. package/compat/nestjs.d.ts +0 -2
  72. package/compat/nestjs.js +0 -1
  73. package/compat/runtime.cjs +0 -2
  74. package/compat/runtime.d.ts +0 -2
  75. package/compat/runtime.js +0 -1
@@ -0,0 +1,411 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/arkRuleSensors.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/arkrules-sensors.mjs). Zero Node I/O.
9
+ */
10
+
11
+ /** Keep in lockstep with arkRulesTypes.ARK_RULE_TIER2_SENSOR_IDS (self-contained for CLI gen). */
12
+ const ARK_RULE_TIER2_SENSOR_IDS = ['no-anemic-model'];
13
+ /**
14
+ * Glob to RegExp for appliesTo. Keep in lockstep with layerMatch.globToRegExp
15
+ * (zero path segments for double-star-slash; self-contained for generate:cli-pure).
16
+ * Critical: double-star-slash patterns match files with no intermediate directory.
17
+ */
18
+ function escapeGlobLiteral(ch) {
19
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
20
+ }
21
+ function globToRegExp(glob) {
22
+ // Normalize Windows path separators without eating glob escapes.
23
+ let normalized = '';
24
+ for (let i = 0; i < glob.length; i += 1) {
25
+ const c = glob[i];
26
+ if (c === '\\' && i + 1 < glob.length) {
27
+ const next = glob[i + 1];
28
+ if ('*?{}[],'.includes(next) || next === '\\') {
29
+ normalized += '\\' + next;
30
+ i += 1;
31
+ continue;
32
+ }
33
+ normalized += '/';
34
+ continue;
35
+ }
36
+ normalized += c;
37
+ }
38
+ let out = '';
39
+ for (let i = 0; i < normalized.length; i += 1) {
40
+ const c = normalized[i];
41
+ if (c === '\\' && i + 1 < normalized.length) {
42
+ out += escapeGlobLiteral(normalized[i + 1]);
43
+ i += 1;
44
+ }
45
+ else if (c === '*') {
46
+ if (normalized[i + 1] === '*') {
47
+ if (normalized[i + 2] === '/') {
48
+ // Zero-or-more path segments (including zero).
49
+ out += '(?:.*/)?';
50
+ i += 2;
51
+ }
52
+ else {
53
+ out += '.*';
54
+ i += 1;
55
+ }
56
+ }
57
+ else {
58
+ out += '[^/]*';
59
+ }
60
+ }
61
+ else if (c === '?') {
62
+ out += '[^/]';
63
+ }
64
+ else {
65
+ out += escapeGlobLiteral(c);
66
+ }
67
+ }
68
+ return new RegExp(`^${out}$`);
69
+ }
70
+ function matchesAppliesTo(file, appliesTo) {
71
+ if (!appliesTo || appliesTo.length === 0)
72
+ return true;
73
+ return appliesTo.some((pattern) => globToRegExp(pattern).test(file));
74
+ }
75
+ function isTier2(sensor) {
76
+ return ARK_RULE_TIER2_SENSOR_IDS.includes(sensor);
77
+ }
78
+ function severityFor(rule) {
79
+ if (rule.mode === 'enforced' && !isTier2(rule.sensor)) {
80
+ return { severity: 'error', failsStrict: true };
81
+ }
82
+ return { severity: 'warning', failsStrict: false };
83
+ }
84
+ function baseViolation(rule, file, message, line = 1) {
85
+ const { severity, failsStrict } = severityFor(rule);
86
+ return {
87
+ ruleId: 'ARKRULE_STRUCTURE',
88
+ code: rule.sensor,
89
+ message,
90
+ file,
91
+ line,
92
+ fromLayer: rule.provenance.layer,
93
+ arkruleId: rule.id,
94
+ arkruleSource: rule.provenance.sourceFile,
95
+ severity,
96
+ sensor: rule.sensor,
97
+ failsStrict,
98
+ };
99
+ }
100
+ function shapesForRule(rule, shapes, layerForFile) {
101
+ return shapes.filter((shape) => {
102
+ if (!shape.exported)
103
+ return false;
104
+ if (!matchesAppliesTo(shape.file, rule.appliesTo))
105
+ return false;
106
+ if (layerForFile) {
107
+ const layer = layerForFile(shape.file);
108
+ if (layer && layer !== rule.provenance.layer)
109
+ return false;
110
+ }
111
+ return true;
112
+ });
113
+ }
114
+ function evaluateAggregatePrivateState(rule, shapes, layerForFile) {
115
+ const out = [];
116
+ for (const shape of shapesForRule(rule, shapes, layerForFile)) {
117
+ if (shape.hasPublicMutableFields || shape.hasPublicSetters) {
118
+ out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} exposes public mutable state (sensor aggregate-private-state).`));
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ function evaluateAlwaysValidFactory(rule, shapes, layerForFile) {
124
+ const out = [];
125
+ for (const shape of shapesForRule(rule, shapes, layerForFile)) {
126
+ if (shape.hasPublicConstructor && !shape.hasStaticFactory) {
127
+ out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} exposes a public constructor without a static factory (sensor always-valid-factory).`));
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ function evaluateDomainEventOnMutation(rule, shapes, layerForFile) {
133
+ const out = [];
134
+ for (const shape of shapesForRule(rule, shapes, layerForFile)) {
135
+ for (const method of shape.mutatingMethods) {
136
+ if (!method.referencesGuardOrPublish) {
137
+ out.push(baseViolation(rule, shape.file, `Mutating method ${shape.className}.${method.name} does not reference a guard or publish symbol (sensor domain-event-on-mutation).`));
138
+ }
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+ function evaluateOrchestrationOnly(rule, input) {
144
+ const out = [];
145
+ for (const file of input.files) {
146
+ if (!matchesAppliesTo(file, rule.appliesTo))
147
+ continue;
148
+ if (input.layerForFile) {
149
+ const layer = input.layerForFile(file);
150
+ if (layer && layer !== rule.provenance.layer)
151
+ continue;
152
+ }
153
+ if (input.fileHints?.[file]?.orchestrationHeavy) {
154
+ out.push(baseViolation(rule, file, `File appears to embed domain branching beyond guard-and-delegate orchestration (sensor orchestration-only).`));
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+ function evaluateThinAdapter(rule, input) {
160
+ const out = [];
161
+ for (const file of input.files) {
162
+ if (!matchesAppliesTo(file, rule.appliesTo))
163
+ continue;
164
+ if (input.layerForFile) {
165
+ const layer = input.layerForFile(file);
166
+ if (layer && layer !== rule.provenance.layer)
167
+ continue;
168
+ }
169
+ if (input.fileHints?.[file]?.adapterThick) {
170
+ out.push(baseViolation(rule, file, `Adapter module mixes domain branching, persistence, and mapping beyond a thin adapter (sensor thin-adapter).`));
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+ function evaluateNoAnemicModel(rule, shapes, layerForFile) {
176
+ // Tier-2: always advisory.
177
+ const out = [];
178
+ for (const shape of shapesForRule(rule, shapes, layerForFile)) {
179
+ if (shape.dataOnly === true) {
180
+ const v = baseViolation(rule, shape.file, `Exported type ${shape.className} looks data-only / anemic (sensor no-anemic-model; advisory only).`);
181
+ // Tier-2: force advisory even if misconfigured as enforced (schema also rejects enforced).
182
+ out.push({ ...v, severity: 'warning', failsStrict: false });
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ /**
188
+ * Evaluate all structure sensors. Empty Effective Contract → no findings (byte-for-byte parity).
189
+ */
190
+ export function evaluateArkRuleSensors(input) {
191
+ if (!input.arkRules.structure.length)
192
+ return [];
193
+ const violations = [];
194
+ for (const rule of input.arkRules.structure) {
195
+ switch (rule.sensor) {
196
+ case 'aggregate-private-state':
197
+ violations.push(...evaluateAggregatePrivateState(rule, input.classShapes, input.layerForFile));
198
+ break;
199
+ case 'always-valid-factory':
200
+ violations.push(...evaluateAlwaysValidFactory(rule, input.classShapes, input.layerForFile));
201
+ break;
202
+ case 'domain-event-on-mutation':
203
+ violations.push(...evaluateDomainEventOnMutation(rule, input.classShapes, input.layerForFile));
204
+ break;
205
+ case 'orchestration-only':
206
+ violations.push(...evaluateOrchestrationOnly(rule, input));
207
+ break;
208
+ case 'thin-adapter':
209
+ violations.push(...evaluateThinAdapter(rule, input));
210
+ break;
211
+ case 'no-anemic-model':
212
+ violations.push(...evaluateNoAnemicModel(rule, input.classShapes, input.layerForFile));
213
+ break;
214
+ case 'invariant-coverage':
215
+ // Owned by AR10 coverage pass.
216
+ break;
217
+ default:
218
+ break;
219
+ }
220
+ }
221
+ return violations.sort((a, b) => a.file.localeCompare(b.file) ||
222
+ a.arkruleId.localeCompare(b.arkruleId) ||
223
+ a.message.localeCompare(b.message));
224
+ }
225
+ /**
226
+ * ADR 0012 D3 — a structure rule whose appliesTo matches zero governed files is
227
+ * never silent green. Advisory → warning; enforced → failsStrict.
228
+ * Rules without appliesTo (whole-layer) never emit this signal.
229
+ */
230
+ export function collectEmptyAppliesToFindings(arkRules, files) {
231
+ const out = [];
232
+ const fileList = files.map((f) => f.replace(/\\/g, '/'));
233
+ for (const rule of arkRules.structure) {
234
+ if (!rule.appliesTo || rule.appliesTo.length === 0)
235
+ continue;
236
+ const matched = fileList.some((file) => matchesAppliesTo(file, rule.appliesTo));
237
+ if (matched)
238
+ continue;
239
+ const { severity, failsStrict } = severityFor(rule);
240
+ out.push({
241
+ ruleId: 'ARKRULE_SCOPE_EMPTY',
242
+ code: 'appliesTo-zero-match',
243
+ message: `ArkRule structure "${rule.id}" appliesTo matched zero governed files (patterns: ${rule.appliesTo.join(', ')}). A zero-match scope is almost always misconfiguration.`,
244
+ file: rule.provenance.sourceFile,
245
+ line: 1,
246
+ fromLayer: rule.provenance.layer,
247
+ arkruleId: rule.id,
248
+ arkruleSource: rule.provenance.sourceFile,
249
+ severity,
250
+ sensor: rule.sensor,
251
+ failsStrict,
252
+ });
253
+ }
254
+ for (const inv of arkRules.invariants ?? []) {
255
+ if (!inv.appliesTo || inv.appliesTo.length === 0)
256
+ continue;
257
+ const matched = fileList.some((file) => matchesAppliesTo(file, inv.appliesTo));
258
+ if (matched)
259
+ continue;
260
+ const failsStrict = inv.mode === 'enforced';
261
+ out.push({
262
+ ruleId: 'ARKRULE_SCOPE_EMPTY',
263
+ code: 'appliesTo-zero-match',
264
+ message: `ArkRule invariant "${inv.id}" appliesTo matched zero governed files (patterns: ${inv.appliesTo.join(', ')}). A zero-match scope is almost always misconfiguration.`,
265
+ file: inv.provenance.sourceFile,
266
+ line: 1,
267
+ fromLayer: inv.provenance.layer,
268
+ arkruleId: inv.id,
269
+ arkruleSource: inv.provenance.sourceFile,
270
+ severity: failsStrict ? 'error' : 'warning',
271
+ sensor: 'invariant-coverage',
272
+ failsStrict,
273
+ });
274
+ }
275
+ return out.sort((a, b) => a.file.localeCompare(b.file) ||
276
+ a.arkruleId.localeCompare(b.arkruleId) ||
277
+ a.message.localeCompare(b.message));
278
+ }
279
+ /** IO / ORM import evidence (mirrors design-smells; kept local for Domain purity). */
280
+ const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm|typeorm|knex|mongodb|pg|mysql2|better-sqlite3|ioredis|redis)['"]|require\(\s*['"](?:@?prisma\/client|pg|knex|typeorm)/;
281
+ const HANDLER_SHAPE_HINT_RE = /\b(?:@Controller|@Get|@Post|@Put|@Delete|Router\(\)|createRouter|express\.Router|fastify\.(?:get|post)|export\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b|export\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=)/;
282
+ const FRAMEWORK_HTTP_HINT_RE = /(?:^|[;\n])\s*(?:import\s+(?:type\s+)?(?:[^;]{0,512}?\s+from\s+)?|export\s+(?:type\s+)?[^;]{0,512}?\s+from\s+)['"]next\/server(?:\.js)?['"]/;
283
+ /** Business-predicate / domain branching signals (conservative). */
284
+ const DOMAIN_PREDICATE_HINT_RE = /\b(?:export\s+)?(?:async\s+)?function\s+(?:can|calculate|compute|should|ensure|validate|is|has)[A-Z]\w*|\b(?:export\s+)?const\s+(?:can|calculate|compute|should|ensure|validate|is|has)[A-Z]\w*\s*=/;
285
+ const BUSINESS_BRANCH_HINT_RE = /\bif\s*\(\s*(?:!)?(?:order|invoice|cart|user|account|policy|aggregate|entity|amount|total|balance|status|state)\b/i;
286
+ /**
287
+ * Pure Tooling/Domain heuristic for orchestration-only / thin-adapter fileHints.
288
+ * Prefers false negatives over false positives (ADR 0013 discipline).
289
+ * Returns null when neither flag is set (callers may omit the path).
290
+ */
291
+ export function deriveArkRuleFileHints(_file, content) {
292
+ if (!content || content.length < 40)
293
+ return null;
294
+ const domainPredicates = content.match(new RegExp(DOMAIN_PREDICATE_HINT_RE.source, 'g')) ?? [];
295
+ const businessBranches = content.match(new RegExp(BUSINESS_BRANCH_HINT_RE.source, 'g')) ?? [];
296
+ const ifCount = (content.match(/\bif\s*\(/g) ?? []).length;
297
+ const switchCount = (content.match(/\bswitch\s*\(/g) ?? []).length;
298
+ // Orchestration-heavy: strong multi-signal domain logic beyond guard-and-delegate.
299
+ // Require ≥2 domain-predicate defs, OR one predicate + several domain-shaped branches.
300
+ const orchestrationHeavy = domainPredicates.length >= 2 ||
301
+ (domainPredicates.length >= 1 && businessBranches.length >= 2) ||
302
+ (businessBranches.length >= 3 && ifCount + switchCount >= 6);
303
+ // Adapter-thick: multi-concern mixing — domain branching + persistence/HTTP in one module.
304
+ const hasIo = IO_IMPORT_HINT_RE.test(content);
305
+ const hasHandler = HANDLER_SHAPE_HINT_RE.test(content) || FRAMEWORK_HTTP_HINT_RE.test(content);
306
+ const hasDomainSignal = domainPredicates.length >= 1 || businessBranches.length >= 2;
307
+ const hasMapping = /\b(?:mapTo|toDomain|toDto|fromRow|toEntity|fromPrisma|serialize|deserialize)\w*\s*[(=]/.test(content);
308
+ const adapterThick = (hasIo && hasDomainSignal) ||
309
+ (hasHandler && hasDomainSignal) ||
310
+ (hasIo && hasMapping && (ifCount >= 4 || domainPredicates.length >= 1)) ||
311
+ (hasHandler && hasIo); // hollow-persistence style: HTTP + persistence together
312
+ if (!orchestrationHeavy && !adapterThick)
313
+ return null;
314
+ return {
315
+ ...(orchestrationHeavy ? { orchestrationHeavy: true } : {}),
316
+ ...(adapterThick ? { adapterThick: true } : {}),
317
+ };
318
+ }
319
+ /**
320
+ * Build fileHints map from path→content. Omits paths with no flags (sparse map).
321
+ */
322
+ export function buildArkRuleFileHints(fileContents) {
323
+ const out = {};
324
+ for (const [file, content] of Object.entries(fileContents)) {
325
+ const hint = deriveArkRuleFileHints(file, content);
326
+ if (hint)
327
+ out[file.replace(/\\/g, '/')] = hint;
328
+ }
329
+ return out;
330
+ }
331
+ /**
332
+ * Lightweight class-shape extraction from TypeScript source text (no compiler).
333
+ * Conservative: prefers false negatives over false positives for mutability.
334
+ * Tooling may replace with TypeScript-API facts; sensors consume the same shape.
335
+ *
336
+ * Limitation (AR05/AR06): only `export class` / `export abstract class` forms.
337
+ * `export default class`, re-exported classes, and non-exported aggregates are
338
+ * invisible — enforced structure sensors stay silent (false negative). Silence
339
+ * is never proof of compliance.
340
+ */
341
+ export function extractClassShapesFromSource(file, content) {
342
+ const shapes = [];
343
+ // Match exported class declarations (simple cases; see limitation above).
344
+ const classRe = /export\s+(?:abstract\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:extends\s+[^{]+)?(?:implements\s+[^{]+)?\{/g;
345
+ let match;
346
+ while ((match = classRe.exec(content)) !== null) {
347
+ const className = match[1];
348
+ const start = match.index + match[0].length;
349
+ // Brace match body
350
+ let depth = 1;
351
+ let i = start;
352
+ while (i < content.length && depth > 0) {
353
+ const ch = content[i];
354
+ if (ch === '{')
355
+ depth += 1;
356
+ else if (ch === '}')
357
+ depth -= 1;
358
+ i += 1;
359
+ }
360
+ const body = content.slice(start, i - 1);
361
+ const hasPublicMutableFields = /(?:^|\n)\s*(?:public\s+)?(?:readonly\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(body.replace(/(?:public\s+|private\s+|protected\s+|readonly\s+|static\s+|async\s+|get\s+|set\s+)/g, '')) &&
362
+ /(?:^|\n)\s*(public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(body);
363
+ // Simpler public field detection: "public foo" or unadorned "foo:" at class level
364
+ const publicField = /(?:^|\n)\s*public\s+(?!static|async|get|set|constructor)[a-zA-Z_]/.test(body) ||
365
+ /(?:^|\n)\s*[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*[^=;\n]+[;=]/m.test(body
366
+ .split('\n')
367
+ .filter((line) => !/^\s*(private|protected|static|constructor|get |set |async |\/)/.test(line))
368
+ .join('\n'));
369
+ const hasPublicSetters = /(?:^|[\n;{])\s*(?:public\s+)?set\s+[a-zA-Z_]/.test(body);
370
+ const hasPrivateConstructor = /(?:^|[\n;{])\s*private\s+constructor\s*\(/.test(body);
371
+ const hasPublicConstructor = /(?:^|[\n;{])\s*(?:public\s+)?constructor\s*\(/.test(body) && !hasPrivateConstructor;
372
+ const hasStaticFactory = /(?:^|[\n;{])\s*static\s+(?:async\s+)?(?:create|of|from|parse|build|make|new)\s*[<(]/.test(body) ||
373
+ /(?:^|[\n;{])\s*static\s+(?:async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*:\s*[A-Za-z_]/.test(body);
374
+ const mutatingMethods = [];
375
+ const methodRe = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|async\s+)*(?!constructor|get|set|static)([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{/g;
376
+ let methodMatch;
377
+ while ((methodMatch = methodRe.exec(body)) !== null) {
378
+ const name = methodMatch[1];
379
+ const mStart = methodMatch.index + methodMatch[0].length;
380
+ let mDepth = 1;
381
+ let j = mStart;
382
+ while (j < body.length && mDepth > 0) {
383
+ if (body[j] === '{')
384
+ mDepth += 1;
385
+ else if (body[j] === '}')
386
+ mDepth -= 1;
387
+ j += 1;
388
+ }
389
+ const methodBody = body.slice(mStart, j - 1);
390
+ const assignsThis = /this\.\w+\s*=/.test(methodBody);
391
+ if (!assignsThis)
392
+ continue;
393
+ const referencesGuardOrPublish = /\b(ensureInvariants|assertInvariants|validate|publish|emit|raise|record)\b/.test(methodBody);
394
+ mutatingMethods.push({ name, referencesGuardOrPublish });
395
+ }
396
+ const methodCount = (body.match(/(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g) ?? []).length;
397
+ const dataOnly = methodCount <= 1 && (publicField || hasPublicMutableFields);
398
+ shapes.push({
399
+ file,
400
+ className,
401
+ exported: true,
402
+ hasPublicMutableFields: publicField || hasPublicMutableFields,
403
+ hasPublicSetters,
404
+ hasPublicConstructor,
405
+ hasStaticFactory,
406
+ mutatingMethods: [...mutatingMethods],
407
+ dataOnly,
408
+ });
409
+ }
410
+ return shapes;
411
+ }
@@ -8,7 +8,8 @@
8
8
  * Pure CLI helper (bin/lib/config-contract.mjs). Zero Node I/O.
9
9
  */
10
10
 
11
- export const ARK_CONFIG_SCHEMA_VERSION = '1.0';
11
+ /** Current published ark.config.json schema version (ADR 0012: 1.1 adds optional arkRules). */
12
+ export const ARK_CONFIG_SCHEMA_VERSION = '1.1';
12
13
  export const ARK_CONFIG_SCHEMA_URL = 'https://unpkg.com/arkgate@2/schemas/ark.config.schema.json';
13
14
  const DEFAULT_LAYER_NAMES = [
14
15
  'DomainModel',
@@ -42,8 +43,13 @@ function createDefaultRules() {
42
43
  return rules;
43
44
  }
44
45
  export const DEFAULT_ARK_CONFIG_RULES = createDefaultRules();
46
+ /**
47
+ * Ordered migration steps. Loader walks from the input version until
48
+ * ARK_CONFIG_SCHEMA_VERSION. Additive only — never drops fields.
49
+ */
45
50
  export const ARK_CONFIG_MIGRATIONS = [
46
- { from: 'unversioned', to: ARK_CONFIG_SCHEMA_VERSION },
51
+ { from: 'unversioned', to: '1.0' },
52
+ { from: '1.0', to: '1.1' },
47
53
  ];
48
54
  const stringArraySchema = {
49
55
  type: 'array',
@@ -100,6 +106,12 @@ export const ARK_CONFIG_SCHEMA = {
100
106
  allowDisabledPeerIsolation: false,
101
107
  },
102
108
  },
109
+ /** ADR 0012 — layer name → relative path to arkrules/<Layer>.json */
110
+ arkRules: {
111
+ type: 'object',
112
+ additionalProperties: { type: 'string', minLength: 1 },
113
+ default: {},
114
+ },
103
115
  },
104
116
  $defs: {
105
117
  layer: {
@@ -236,6 +248,16 @@ function validateNode(value, schema, path, root, issues) {
236
248
  }
237
249
  }
238
250
  }
251
+ else if (schema.additionalProperties !== undefined &&
252
+ schema.additionalProperties !== true &&
253
+ typeof schema.additionalProperties === 'object') {
254
+ const additional = schema.additionalProperties;
255
+ for (const key of Object.keys(value)) {
256
+ if (!(key in properties)) {
257
+ validateNode(value[key], additional, propertyPath(path, key), root, issues);
258
+ }
259
+ }
260
+ }
239
261
  for (const [key, childSchema] of Object.entries(properties)) {
240
262
  if (value[key] !== undefined) {
241
263
  validateNode(value[key], childSchema, propertyPath(path, key), root, issues);
@@ -300,15 +322,32 @@ function defaultedConfig(input) {
300
322
  : input.rules,
301
323
  };
302
324
  }
325
+ function knownInputVersions() {
326
+ const versions = new Set([ARK_CONFIG_SCHEMA_VERSION]);
327
+ for (const step of ARK_CONFIG_MIGRATIONS) {
328
+ if (step.from !== 'unversioned')
329
+ versions.add(step.from);
330
+ versions.add(step.to);
331
+ }
332
+ return versions;
333
+ }
334
+ /**
335
+ * Rewrite schemaVersion through ARK_CONFIG_MIGRATIONS until current.
336
+ * Additive only: field defaults are applied after the chain, never removed.
337
+ */
303
338
  export function migrateArkConfig(input, source = 'ark.config.json') {
304
339
  if (!isObject(input)) {
305
340
  throw new ArkConfigValidationError(source, [
306
341
  { path: '$', message: `must be an object; received ${valueType(input)}` },
307
342
  ]);
308
343
  }
309
- const migratedFrom = input.schemaVersion === undefined ? 'unversioned' : null;
310
- if (input.schemaVersion !== undefined &&
311
- input.schemaVersion !== ARK_CONFIG_SCHEMA_VERSION) {
344
+ const known = knownInputVersions();
345
+ const originalVersion = input.schemaVersion === undefined
346
+ ? 'unversioned'
347
+ : typeof input.schemaVersion === 'string'
348
+ ? input.schemaVersion
349
+ : null;
350
+ if (originalVersion === null) {
312
351
  throw new ArkConfigValidationError(source, [
313
352
  {
314
353
  path: '$.schemaVersion',
@@ -316,7 +355,47 @@ export function migrateArkConfig(input, source = 'ark.config.json') {
316
355
  },
317
356
  ]);
318
357
  }
319
- return { candidate: defaultedConfig(input), migratedFrom };
358
+ if (originalVersion !== 'unversioned' && !known.has(originalVersion)) {
359
+ throw new ArkConfigValidationError(source, [
360
+ {
361
+ path: '$.schemaVersion',
362
+ message: `unsupported version ${JSON.stringify(originalVersion)}; expected ${ARK_CONFIG_SCHEMA_VERSION}`,
363
+ },
364
+ ]);
365
+ }
366
+ let version = originalVersion;
367
+ const working = { ...input };
368
+ // Walk the migration table. Each step is a pure version stamp for 1.0→1.1
369
+ // (arkRules is optional; absence needs no field rewrite).
370
+ let guard = 0;
371
+ while (version !== ARK_CONFIG_SCHEMA_VERSION && guard < ARK_CONFIG_MIGRATIONS.length + 1) {
372
+ guard += 1;
373
+ const step = ARK_CONFIG_MIGRATIONS.find((candidate) => candidate.from === version);
374
+ if (!step) {
375
+ throw new ArkConfigValidationError(source, [
376
+ {
377
+ path: '$.schemaVersion',
378
+ message: `unsupported version ${JSON.stringify(version)}; expected ${ARK_CONFIG_SCHEMA_VERSION}`,
379
+ },
380
+ ]);
381
+ }
382
+ version = step.to;
383
+ working.schemaVersion = version;
384
+ }
385
+ if (version !== ARK_CONFIG_SCHEMA_VERSION) {
386
+ throw new ArkConfigValidationError(source, [
387
+ {
388
+ path: '$.schemaVersion',
389
+ message: `unsupported version ${JSON.stringify(originalVersion)}; expected ${ARK_CONFIG_SCHEMA_VERSION}`,
390
+ },
391
+ ]);
392
+ }
393
+ const migratedFrom = originalVersion === 'unversioned'
394
+ ? 'unversioned'
395
+ : originalVersion === '1.0'
396
+ ? '1.0'
397
+ : null;
398
+ return { candidate: defaultedConfig(working), migratedFrom };
320
399
  }
321
400
  export function loadArkConfigContract(input, source = 'ark.config.json') {
322
401
  const { candidate, migratedFrom } = migrateArkConfig(input, source);
@@ -18,8 +18,9 @@ import {
18
18
  } from './reshape-decisions.mjs';
19
19
  import { printParseHealthSection, summarizeParseHealth } from './parse-health.mjs';
20
20
  import { detectGraphBlindSpots, printGraphBlindSection } from './graph-blind.mjs';
21
+ import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
21
22
 
22
- export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth) {
23
+ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth, facts) {
23
24
  const physicalCohesion = computePhysicalCohesion(root, files);
24
25
  const decisionMemory = computeReshapeDecisionMemory(root, files);
25
26
  physicalCohesion.reshapeDecisions = decisionMemory.summary;
@@ -29,6 +30,16 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
29
30
  root,
30
31
  decisionMemory
31
32
  );
33
+ // Prefer architecture facts paths when available; coverage I/O still walks test roots.
34
+ const factPaths =
35
+ facts ??
36
+ (Array.isArray(files)
37
+ ? {
38
+ files: files.map((f) => ({
39
+ path: typeof f === 'string' ? f.replace(/\\/g, '/').replace(/^\.\//, '') : f?.path,
40
+ })).filter((f) => f.path),
41
+ }
42
+ : undefined);
32
43
  return {
33
44
  contractHealth: computeContractHealth(root, config, cov, rules),
34
45
  ambientState: computeAmbientState(ts, root, config, files),
@@ -36,6 +47,8 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
36
47
  parseHealth: parseHealth ?? summarizeParseHealth(),
37
48
  // Y09 direction: advisory graph-blind spots (template-interpolation); never hard verdict.
38
49
  graphBlindSpots: detectGraphBlindSpots(ts, root, files),
50
+ // AR12 — Rules under contract (honest counts; real test I/O, never empty-fileContents stub).
51
+ rulesUnderContract: summarizeRulesUnderContract(root, config, factPaths),
39
52
  };
40
53
  }
41
54
 
@@ -9,6 +9,9 @@ import {
9
9
  resolveOperatingMode,
10
10
  shouldShowNewHereNudge,
11
11
  } from '../ark-shared.mjs';
12
+ import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
13
+ import { describePackageVersionDualTruth } from './field-install.mjs';
14
+ export { summarizeRulesUnderContract };
12
15
  import {
13
16
  collectAdoptionGaps,
14
17
  detectSkillGaps,
@@ -410,6 +413,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
410
413
  }
411
414
  const gatesMissing = missingGates(root);
412
415
  const skillGaps = detectSkillGaps(root);
416
+ // Dual-truth: CLI version vs package.json pin (field residual after upgrade --no-install).
417
+ const packageVersionTruth = describePackageVersionDualTruth(root);
413
418
  const staleRunners = staleRunnerGateFiles(root);
414
419
  const adoption = collectAdoptionGaps(root, config, cov);
415
420
  // Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
@@ -497,6 +502,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
497
502
  pureLayerOptIn,
498
503
  // Q04: one-pilot loop (extraction card → re-doctor).
499
504
  pilotLoop,
505
+ // AR12 — Rules under contract (honest counts, not a score).
506
+ // Pass architecture facts when available so coverage can scan real tests.
507
+ rulesUnderContract: summarizeRulesUnderContract(
508
+ root,
509
+ config,
510
+ options.facts ?? options.architectureFacts
511
+ ),
512
+ // Dual-truth: managed CLI vs package.json pin (not a gate fail).
513
+ packageVersionTruth,
500
514
  // Advisories, never a verdict: W01/U05/X04/Y03 + graph-blind spots.
501
515
  ...doctorAdvisories,
502
516
  governed: cov.governed,
@@ -726,6 +740,13 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
726
740
  if (cov.layersWithoutRules.length > 0) line(warn, `Layers with no rule edge: ${cov.layersWithoutRules.join(', ')}`);
727
741
  if (cov.suggestions.length === 0 && cov.emptyLayers.length === 0) line(ok, 'Every layer classifies files; no empty layers');
728
742
 
743
+ if (packageVersionTruth?.dualTruth) {
744
+ console.log('');
745
+ console.log(color.bold('Package pin (dual-truth)'));
746
+ line(warn, packageVersionTruth.note);
747
+ actions.push('bump package.json arkgate pin to match this CLI (or install without --no-install)');
748
+ }
749
+
729
750
  if (showNewHere) {
730
751
  console.log('');
731
752
  console.log(color.bold('New here?'));