arkgate 3.9.1 → 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.
- package/CHANGELOG.md +111 -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/ambient-state.mjs +64 -8
- 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 +22 -5
- package/bin/lib/doctor-plan.mjs +68 -13
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/enforcement-honesty.mjs +225 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +254 -0
- package/bin/lib/html-report-advisories.mjs +29 -3
- 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/pilot-loop.mjs +19 -0
- package/bin/lib/policy-delta-io.mjs +33 -0
- package/bin/lib/post-green-path.mjs +22 -1
- 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 +66 -0
- package/bin/lib/start-preview.mjs +24 -7
- package/bin/lib/upgrade-command.mjs +48 -2
- 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 +4 -3
- package/docs/agent-guide.md +7 -3
- package/docs/ai-gates.md +6 -1
- package/docs/brownfield-adoption.md +20 -0
- package/docs/configuration.md +37 -4
- package/docs/develop.md +8 -2
- package/docs/enthusiast/README.md +11 -0
- package/docs/package-surface.md +13 -11
- package/docs/product-voice.md +9 -2
- package/docs/use.md +9 -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 +3 -3
- 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 +30 -0
- package/templates/skills/ark-explain.md +23 -0
- package/templates/skills/ark-explore.md +30 -2
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +30 -0
- package/templates/skills/ark-runtime.md +4 -0
- package/templates/skills/ark-think.md +24 -1
- package/templates/skills/ark-upgrade.md +23 -0
- 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);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Doctor's advisory sensors, aggregated (W01 contract health, U05 ambient
|
|
3
|
-
* state, X04 physical cohesion, Y03 parse health
|
|
4
|
-
* architecture violations or designFitness findings;
|
|
5
|
-
* parse-health evidence to analysis completeness and
|
|
6
|
-
* seam keeps doctor-plan.mjs inside its module budget
|
|
3
|
+
* state, X04 physical cohesion, Y03 parse health, graph-blind template edges).
|
|
4
|
+
* These sensors do not create architecture violations or designFitness findings;
|
|
5
|
+
* Z02 separately maps parse-health evidence to analysis completeness and
|
|
6
|
+
* fail-closed exits. One seam keeps doctor-plan.mjs inside its module budget.
|
|
7
7
|
*/
|
|
8
8
|
import { computeAmbientState, printAmbientStateSection } from './ambient-state.mjs';
|
|
9
9
|
import { computeContractHealth, printContractHealthSection } from './contract-smells.mjs';
|
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
printReshapeDecisionsSection,
|
|
18
18
|
} from './reshape-decisions.mjs';
|
|
19
19
|
import { printParseHealthSection, summarizeParseHealth } from './parse-health.mjs';
|
|
20
|
+
import { detectGraphBlindSpots, printGraphBlindSection } from './graph-blind.mjs';
|
|
21
|
+
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
20
22
|
|
|
21
|
-
export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth) {
|
|
23
|
+
export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth, facts) {
|
|
22
24
|
const physicalCohesion = computePhysicalCohesion(root, files);
|
|
23
25
|
const decisionMemory = computeReshapeDecisionMemory(root, files);
|
|
24
26
|
physicalCohesion.reshapeDecisions = decisionMemory.summary;
|
|
@@ -28,11 +30,25 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
28
30
|
root,
|
|
29
31
|
decisionMemory
|
|
30
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);
|
|
31
43
|
return {
|
|
32
44
|
contractHealth: computeContractHealth(root, config, cov, rules),
|
|
33
45
|
ambientState: computeAmbientState(ts, root, config, files),
|
|
34
46
|
physicalCohesion,
|
|
35
47
|
parseHealth: parseHealth ?? summarizeParseHealth(),
|
|
48
|
+
// Y09 direction: advisory graph-blind spots (template-interpolation); never hard verdict.
|
|
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),
|
|
36
52
|
};
|
|
37
53
|
}
|
|
38
54
|
|
|
@@ -46,4 +62,5 @@ export function printDoctorAdvisories(advisories, io) {
|
|
|
46
62
|
);
|
|
47
63
|
printReshapeDecisionsSection(advisories.physicalCohesion?.reshapeDecisions, io);
|
|
48
64
|
printParseHealthSection(advisories.parseHealth, io);
|
|
65
|
+
printGraphBlindSection(advisories.graphBlindSpots, io);
|
|
49
66
|
}
|