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.
- package/CHANGELOG.md +125 -0
- package/README.md +16 -4
- package/bin/ark-check-runtime.mjs +75 -3
- package/bin/ark-mcp-runtime.mjs +94 -0
- package/bin/lib/adapter-contract.mjs +14 -1
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/architecture-scan.mjs +35 -2
- package/bin/lib/arkrule-file-hints.mjs +71 -0
- package/bin/lib/arkrules-contract.mjs +382 -0
- package/bin/lib/arkrules-sensors.mjs +411 -0
- package/bin/lib/config-contract.mjs +85 -6
- package/bin/lib/doctor-advisories.mjs +14 -1
- package/bin/lib/doctor-plan.mjs +21 -0
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +20 -1
- package/bin/lib/html-report-advisories.mjs +12 -5
- package/bin/lib/install-migrate.mjs +20 -2
- package/bin/lib/invariant-coverage-io.mjs +157 -0
- package/bin/lib/invariant-coverage.mjs +127 -0
- package/bin/lib/managed-upgrade.mjs +1 -1
- package/bin/lib/policy-delta-io.mjs +33 -0
- package/bin/lib/presets.mjs +241 -1
- package/bin/lib/remediation.mjs +28 -0
- package/bin/lib/resolved-candidate-facts.mjs +14 -1
- package/bin/lib/rules-inventory.mjs +144 -0
- package/bin/lib/rules-under-contract.mjs +320 -0
- package/bin/lib/start-preview.mjs +24 -7
- package/bin/lib/upgrade-command.mjs +373 -16
- package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +14 -7
- package/dist/index.d.ts +615 -20
- package/dist/index.js +13 -6
- package/docs/README.md +5 -3
- package/docs/agent-guide.md +7 -3
- package/docs/ai-gates.md +6 -1
- package/docs/brownfield-adoption.md +22 -0
- package/docs/configuration.md +53 -4
- package/docs/develop.md +8 -2
- package/docs/enthusiast/README.md +11 -0
- package/docs/package-surface.md +13 -10
- package/docs/product-voice.md +11 -2
- package/docs/use.md +11 -0
- package/package.json +4 -17
- package/schemas/ark.analysis-result.schema.json +9 -1
- package/schemas/ark.arkrules.schema.json +141 -0
- package/schemas/ark.config.schema.json +10 -2
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +2 -2
- package/templates/arkrules/ApplicationOrchestration.json +14 -0
- package/templates/arkrules/DomainModel.json +32 -0
- package/templates/arkrules/PersistenceAdapters.json +14 -0
- package/templates/arkrules/PresentationAdapters.json +14 -0
- package/templates/skills/ark-adopt.md +28 -1
- package/templates/skills/ark-architect.md +23 -0
- package/templates/skills/ark-autopilot.md +27 -1
- package/templates/skills/ark-contract.md +27 -1
- package/templates/skills/ark-coverage.md +23 -0
- package/templates/skills/ark-explain.md +39 -3
- package/templates/skills/ark-explore.md +26 -1
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +26 -0
- package/templates/skills/ark-runtime.md +4 -0
- package/templates/skills/ark-think.md +24 -1
- package/templates/skills/ark-upgrade.md +80 -11
- package/compat/nestjs.cjs +0 -2
- package/compat/nestjs.d.ts +0 -2
- package/compat/nestjs.js +0 -1
- package/compat/runtime.cjs +0 -2
- package/compat/runtime.d.ts +0 -2
- 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
|
-
|
|
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:
|
|
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
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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?'));
|