arkgate 2.13.0 → 3.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 +39 -0
- package/README.md +37 -22
- package/bin/ark-check.mjs +62 -4
- package/bin/ark-mcp.mjs +108 -1
- package/bin/ark-shared.mjs +204 -149
- package/bin/ark.mjs +90 -25
- package/bin/lib/adapter-contract.mjs +93 -0
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/analysis-engine.mjs +1171 -0
- package/bin/lib/architecture-scan.mjs +84 -135
- package/bin/lib/ci-and-commands.mjs +31 -0
- package/bin/lib/config-warnings.mjs +7 -205
- package/bin/lib/field-install.mjs +67 -10
- package/bin/lib/gate-files.mjs +42 -3
- package/bin/lib/graph-cycles.mjs +4 -54
- package/bin/lib/hook-templates.mjs +33 -1
- package/bin/lib/host-support-matrix.mjs +7 -1
- package/bin/lib/install-migrate.mjs +54 -16
- package/bin/lib/presets.mjs +42 -2
- package/bin/lib/safety-diagnostics.mjs +18 -17
- package/bin/lib/scan-files.mjs +12 -1
- package/bin/lib/skill-install.mjs +8 -1
- package/bin/lib/source-policy.mjs +36 -0
- package/bin/lib/start-preview.mjs +271 -0
- package/bin/lib/ts-resolve.mjs +11 -2
- package/bin/lib/write-path-capabilities.mjs +4 -0
- package/compat/nestjs.cjs +2 -0
- package/compat/nestjs.d.ts +2 -0
- package/compat/nestjs.js +1 -0
- package/compat/runtime.cjs +2 -0
- package/compat/runtime.d.ts +2 -0
- package/compat/runtime.js +1 -0
- package/dist/configContract-BxSIwVRo.d.cts +259 -0
- package/dist/configContract-BxSIwVRo.d.ts +259 -0
- package/dist/eslint/index.cjs +125 -48
- package/dist/eslint/index.d.cts +7 -1
- package/dist/eslint/index.d.ts +7 -1
- package/dist/eslint/index.js +125 -48
- package/dist/index.cjs +1248 -3302
- package/dist/index.d.cts +359 -483
- package/dist/index.d.ts +359 -483
- package/dist/index.js +1231 -3248
- package/docs/agent-guide.md +28 -16
- package/docs/ai-gates.md +30 -7
- package/docs/migrate-from-ark-runtime-kernel.md +2 -3
- package/docs/package-surface.md +8 -13
- package/docs/production-hardening.md +17 -4
- package/docs/typescript-support.md +27 -0
- package/package.json +33 -11
- package/schemas/ark.analysis-result.schema.json +91 -0
- package/server.json +2 -2
- package/templates/skills/ark-architect.md +3 -2
- package/dist/configContract-iBLxx5Tz.d.cts +0 -53
- package/dist/configContract-iBLxx5Tz.d.ts +0 -53
- package/dist/eslint/index.cjs.map +0 -1
- package/dist/eslint/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nestjs/index.cjs +0 -2606
- package/dist/nestjs/index.cjs.map +0 -1
- package/dist/nestjs/index.d.cts +0 -23
- package/dist/nestjs/index.d.ts +0 -23
- package/dist/nestjs/index.js +0 -2582
- package/dist/nestjs/index.js.map +0 -1
- package/dist/runtime/index.cjs +0 -4014
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/runtime/index.d.cts +0 -3
- package/dist/runtime/index.d.ts +0 -3
- package/dist/runtime/index.js +0 -3925
- package/dist/runtime/index.js.map +0 -1
- package/dist/types-BxBwnBpC.d.cts +0 -1041
- package/dist/types-Wcs_l1_J.d.ts +0 -1041
|
@@ -5,16 +5,13 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
|
-
collectForbiddenGlobalUses,
|
|
9
8
|
layerForFile,
|
|
10
9
|
looksLikeIntent,
|
|
11
10
|
} from '../ark-shared.mjs';
|
|
12
11
|
import {
|
|
13
|
-
isTypeOnlyModuleReference,
|
|
14
12
|
isArkPublishCandidate,
|
|
15
13
|
isPublishCall,
|
|
16
14
|
lineOf,
|
|
17
|
-
moduleSpecifierFromCall,
|
|
18
15
|
namedModuleBindings,
|
|
19
16
|
objectHasProperty,
|
|
20
17
|
publishHasSource,
|
|
@@ -22,7 +19,6 @@ import {
|
|
|
22
19
|
sourceFileExportsOnlyTypes,
|
|
23
20
|
sourceFileHasTopLevelSideEffects,
|
|
24
21
|
stringLiteralText,
|
|
25
|
-
textOfModuleSpecifier,
|
|
26
22
|
typeOnlyExportNames,
|
|
27
23
|
} from './ast-scan.mjs';
|
|
28
24
|
import { provePortProofInject } from './port-proof.mjs';
|
|
@@ -32,9 +28,14 @@ import {
|
|
|
32
28
|
isBlocked,
|
|
33
29
|
collectConfigWarnings,
|
|
34
30
|
} from './config-warnings.mjs';
|
|
35
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
collectForbiddenCapabilityUses,
|
|
33
|
+
evaluateArchitectureGraph,
|
|
34
|
+
extractSemanticDependencies,
|
|
35
|
+
} from './analysis-engine.mjs';
|
|
36
36
|
import { normalize } from './scan-files.mjs';
|
|
37
37
|
import { collectSafetyDiagnostics } from './safety-diagnostics.mjs';
|
|
38
|
+
import { classifyPublishFacts } from './source-policy.mjs';
|
|
38
39
|
import {
|
|
39
40
|
createCompilerOptionsLookup,
|
|
40
41
|
createModuleResolutionHost,
|
|
@@ -57,11 +58,11 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
|
|
|
57
58
|
const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
|
|
58
59
|
? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
|
|
59
60
|
: [];
|
|
60
|
-
for (const use of
|
|
61
|
+
for (const use of collectForbiddenCapabilityUses(ts, sourceFile, forbiddenGlobals)) {
|
|
61
62
|
violations.push({
|
|
62
63
|
ruleId: 'FORBIDDEN_GLOBAL',
|
|
63
64
|
file: normalize(path.relative(root, file)),
|
|
64
|
-
line:
|
|
65
|
+
line: use.line,
|
|
65
66
|
fromLayer: sourceLayer,
|
|
66
67
|
target: use.name,
|
|
67
68
|
message: `${sourceLayer} must not use the ambient global "${use.name}".`,
|
|
@@ -79,56 +80,37 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
|
|
|
79
80
|
});
|
|
80
81
|
};
|
|
81
82
|
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
ts.isImportEqualsDeclaration(node) &&
|
|
97
|
-
ts.isExternalModuleReference(node.moduleReference)
|
|
98
|
-
) {
|
|
99
|
-
const specifier = stringLiteralText(ts, node.moduleReference.expression);
|
|
100
|
-
if (specifier) checkModuleEdge(specifier, node, 'require');
|
|
101
|
-
}
|
|
83
|
+
for (const dependency of extractSemanticDependencies(ts, sourceFile)) {
|
|
84
|
+
if (!dependency.specifier) continue;
|
|
85
|
+
checkModuleEdge(
|
|
86
|
+
dependency.specifier,
|
|
87
|
+
dependency.node,
|
|
88
|
+
dependency.kind,
|
|
89
|
+
dependency.typeOnly
|
|
90
|
+
);
|
|
91
|
+
}
|
|
102
92
|
|
|
93
|
+
const needsPolicyWalk =
|
|
94
|
+
/\bpublish\s*\(|\bintent\b/.test(source) ||
|
|
95
|
+
/['"`]\s*[A-Z][A-Za-z0-9_]*\./.test(source);
|
|
96
|
+
const visit = (node) => {
|
|
103
97
|
if (ts.isCallExpression(node)) {
|
|
104
|
-
const moduleCall = moduleSpecifierFromCall(ts, node);
|
|
105
|
-
if (moduleCall) {
|
|
106
|
-
checkModuleEdge(moduleCall.value, node, moduleCall.kind);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
98
|
if (isPublishCall(ts, node)) {
|
|
110
99
|
const firstArg = node.arguments[0];
|
|
111
100
|
const rawIntent = stringLiteralText(ts, firstArg);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
120
|
-
message:
|
|
121
|
-
'Publish through a registered intent creator; raw event objects or intent strings bypass Ark contracts and tooling.',
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (isArkPublishCandidate(ts, node) && !publishHasSource(ts, node)) {
|
|
101
|
+
for (const finding of classifyPublishFacts({
|
|
102
|
+
publishCall: true,
|
|
103
|
+
rawIntentName: rawIntent,
|
|
104
|
+
objectHasIntent: objectHasProperty(ts, firstArg, 'intent'),
|
|
105
|
+
arkPublishCandidate: isArkPublishCandidate(ts, node),
|
|
106
|
+
hasSource: publishHasSource(ts, node),
|
|
107
|
+
})) {
|
|
126
108
|
violations.push({
|
|
127
|
-
ruleId:
|
|
109
|
+
ruleId: finding.ruleId,
|
|
128
110
|
file: normalize(path.relative(root, file)),
|
|
129
111
|
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
130
|
-
fromLayer: sourceLayer,
|
|
131
|
-
message:
|
|
112
|
+
...(finding.ruleId === 'PUBLISH_MISSING_SOURCE' ? { fromLayer: sourceLayer } : {}),
|
|
113
|
+
message: finding.message,
|
|
132
114
|
});
|
|
133
115
|
}
|
|
134
116
|
|
|
@@ -175,7 +157,7 @@ export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, fi
|
|
|
175
157
|
|
|
176
158
|
ts.forEachChild(node, visit);
|
|
177
159
|
};
|
|
178
|
-
visit(sourceFile);
|
|
160
|
+
if (needsPolicyWalk) visit(sourceFile);
|
|
179
161
|
return {
|
|
180
162
|
contentViolations: violations,
|
|
181
163
|
edges,
|
|
@@ -194,7 +176,6 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
|
|
|
194
176
|
const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
|
|
195
177
|
const moduleHost = createModuleResolutionHost(ts);
|
|
196
178
|
|
|
197
|
-
const violations = [];
|
|
198
179
|
const warnings = collectConfigWarnings(root, config, files, rules, manifest);
|
|
199
180
|
const safety = collectSafetyDiagnostics(ts, root, config, files);
|
|
200
181
|
warnings.push(...safety.warnings);
|
|
@@ -202,13 +183,11 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
|
|
|
202
183
|
const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
|
|
203
184
|
const nextCacheFiles = {};
|
|
204
185
|
|
|
205
|
-
const importGraph = new Map();
|
|
206
186
|
const scanned = [];
|
|
207
187
|
for (const file of files) {
|
|
208
188
|
const sourceLayer = layerForFile(root, file, config.layers);
|
|
209
189
|
if (!sourceLayer) continue;
|
|
210
190
|
const relFile = normalize(path.relative(root, file));
|
|
211
|
-
if (!importGraph.has(relFile)) importGraph.set(relFile, new Set());
|
|
212
191
|
const stat = fs.statSync(file);
|
|
213
192
|
const fileKey = `${stat.mtimeMs}:${stat.size}`;
|
|
214
193
|
const cached = cachedFiles?.[relFile];
|
|
@@ -231,8 +210,8 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
|
|
|
231
210
|
scanned.push({ file, sourceLayer, relFile, entry });
|
|
232
211
|
}
|
|
233
212
|
|
|
213
|
+
const engineEdges = [];
|
|
234
214
|
for (const { file, sourceLayer, relFile, entry } of scanned) {
|
|
235
|
-
violations.push(...entry.contentViolations);
|
|
236
215
|
for (const edge of entry.edges) {
|
|
237
216
|
const target = resolveImport(
|
|
238
217
|
ts,
|
|
@@ -243,99 +222,69 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
|
|
|
243
222
|
root
|
|
244
223
|
);
|
|
245
224
|
const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
|
|
246
|
-
if (target && targetLayer) {
|
|
247
|
-
const relTarget = normalize(path.relative(root, target));
|
|
248
|
-
if (relTarget !== relFile && !edge.typeOnly) {
|
|
249
|
-
importGraph.get(relFile).add(relTarget);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
225
|
const relTarget = target ? normalize(path.relative(root, target)) : undefined;
|
|
253
|
-
const
|
|
226
|
+
const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
|
|
227
|
+
const staticEdge = edge.kind === 'import' || edge.kind === 'export';
|
|
228
|
+
const targetTypeOnlyExports =
|
|
229
|
+
staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
|
|
230
|
+
const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
|
|
231
|
+
const targetTypeNames = new Set(targetCached?.typeOnlyExportNames || []);
|
|
232
|
+
const named = edge.namedBindings;
|
|
233
|
+
const namedBindingsTypeOnly =
|
|
234
|
+
staticEdge &&
|
|
235
|
+
Array.isArray(named) &&
|
|
236
|
+
named.length > 0 &&
|
|
237
|
+
targetTypeNames.size > 0 &&
|
|
238
|
+
!targetCached?.hasTopLevelSideEffects &&
|
|
239
|
+
named.every((name) => targetTypeNames.has(name));
|
|
240
|
+
const deniedRule = targetLayer
|
|
254
241
|
? isBlocked(rules, sourceLayer, targetLayer, {
|
|
255
242
|
fromPath: relFile,
|
|
256
243
|
toPath: relTarget,
|
|
257
244
|
layers: config.layers,
|
|
258
245
|
})
|
|
259
246
|
: undefined;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
named.length > 0 &&
|
|
275
|
-
targetTypeNames.size > 0 &&
|
|
276
|
-
!targetCached?.hasTopLevelSideEffects &&
|
|
277
|
-
named.every((n) => targetTypeNames.has(n));
|
|
278
|
-
const peerIsolation = Boolean(rule.peerIsolation);
|
|
279
|
-
// W6: port-proof eligibility (value import only; fail-closed static proof).
|
|
280
|
-
let portProofEligible = false;
|
|
281
|
-
if (
|
|
282
|
-
!edge.typeOnly &&
|
|
283
|
-
!peerIsolation &&
|
|
284
|
-
edge.kind === 'import' &&
|
|
285
|
-
!targetTypeOnlyExports &&
|
|
286
|
-
!namedBindingsTypeOnly
|
|
287
|
-
) {
|
|
288
|
-
try {
|
|
289
|
-
const srcText = fs.readFileSync(file, 'utf8');
|
|
290
|
-
const proof = provePortProofInject(ts, srcText, { filePath: file });
|
|
291
|
-
portProofEligible = Boolean(proof.eligible);
|
|
292
|
-
} catch {
|
|
293
|
-
portProofEligible = false;
|
|
294
|
-
}
|
|
247
|
+
let portProofEligible = false;
|
|
248
|
+
if (
|
|
249
|
+
deniedRule &&
|
|
250
|
+
!deniedRule.peerIsolation &&
|
|
251
|
+
!edge.typeOnly &&
|
|
252
|
+
edge.kind === 'import' &&
|
|
253
|
+
!targetTypeOnlyExports &&
|
|
254
|
+
!namedBindingsTypeOnly
|
|
255
|
+
) {
|
|
256
|
+
try {
|
|
257
|
+
const source = fs.readFileSync(file, 'utf8');
|
|
258
|
+
portProofEligible = Boolean(provePortProofInject(ts, source, { filePath: file }).eligible);
|
|
259
|
+
} catch {
|
|
260
|
+
portProofEligible = false;
|
|
295
261
|
}
|
|
296
|
-
violations.push({
|
|
297
|
-
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
298
|
-
file: relFile,
|
|
299
|
-
line: edge.line,
|
|
300
|
-
fromLayer: sourceLayer,
|
|
301
|
-
toLayer: targetLayer,
|
|
302
|
-
target: relTarget,
|
|
303
|
-
...(edge.typeOnly ? { typeOnly: true } : {}),
|
|
304
|
-
...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
|
|
305
|
-
...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
|
|
306
|
-
...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
|
|
307
|
-
...(portProofEligible ? { portProofEligible: true } : {}),
|
|
308
|
-
...(edge.kind ? { edgeKind: edge.kind } : {}),
|
|
309
|
-
...(peerIsolation ? { peerIsolation: true } : {}),
|
|
310
|
-
message:
|
|
311
|
-
rule.message ??
|
|
312
|
-
(peerIsolation
|
|
313
|
-
? `${sourceLayer} must not ${edge.kind} another slice of ${targetLayer} (${relFile} → ${relTarget}). Extract shared code or use events/ports across slices.`
|
|
314
|
-
: `${sourceLayer} must not ${edge.kind} ${targetLayer}.`),
|
|
315
|
-
});
|
|
316
262
|
}
|
|
263
|
+
engineEdges.push({
|
|
264
|
+
from: relFile,
|
|
265
|
+
fromLayer: sourceLayer,
|
|
266
|
+
to: relTarget,
|
|
267
|
+
toLayer: targetLayer,
|
|
268
|
+
line: edge.line,
|
|
269
|
+
kind: edge.kind,
|
|
270
|
+
typeOnly: edge.typeOnly,
|
|
271
|
+
targetTypeOnlyExports,
|
|
272
|
+
sourcePureTypeModule,
|
|
273
|
+
namedBindingsTypeOnly,
|
|
274
|
+
portProofEligible,
|
|
275
|
+
});
|
|
317
276
|
}
|
|
318
277
|
}
|
|
319
278
|
|
|
320
279
|
if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
|
|
321
280
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
target: c.target,
|
|
332
|
-
failsStrict: false,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
} else {
|
|
336
|
-
violations.push(...cycles);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return { violations, warnings, safety: safety.report };
|
|
281
|
+
return evaluateArchitectureGraph({
|
|
282
|
+
config,
|
|
283
|
+
rules,
|
|
284
|
+
files: scanned.map(({ relFile }) => relFile),
|
|
285
|
+
contentViolations: scanned.flatMap(({ entry }) => entry.contentViolations),
|
|
286
|
+
edges: engineEdges,
|
|
287
|
+
warnings,
|
|
288
|
+
safety: safety.report,
|
|
289
|
+
});
|
|
341
290
|
}
|
|
@@ -188,6 +188,37 @@ advisory MCP plus CI. The experimental runtime is not required.
|
|
|
188
188
|
`;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Compact onboarding uses one project router instead of copied slash-command
|
|
193
|
+
* skills. The package and ark MCP resources remain the canonical capability
|
|
194
|
+
* source; the marker makes the selected host verifiable by the strict gate.
|
|
195
|
+
*/
|
|
196
|
+
export function compactAgentInstructions(root, host = null) {
|
|
197
|
+
const selectedHost = host || 'none';
|
|
198
|
+
const checkCmd = arkCheckCommand(root);
|
|
199
|
+
const doctorCmd = arkCommand(root, 'ark-check', '--doctor');
|
|
200
|
+
const installSkills = arkCommand(
|
|
201
|
+
root,
|
|
202
|
+
'ark-check',
|
|
203
|
+
`--install-agent-gates --skills-only --tools ${selectedHost === 'none' ? '<host>' : selectedHost}`
|
|
204
|
+
);
|
|
205
|
+
return `# Ark Enforcement
|
|
206
|
+
|
|
207
|
+
<!-- arkgate:compact-router host=${selectedHost} -->
|
|
208
|
+
## Compact router
|
|
209
|
+
|
|
210
|
+
This project uses the ArkGate package and its \`ark\` MCP resources as its one
|
|
211
|
+
agent router. Before editing TypeScript or JavaScript, read \`ark://manifest\`
|
|
212
|
+
when available; use \`ark_place\` for new files and \`validate_code\` after edits.
|
|
213
|
+
If MCP is unavailable, inspect \`ark.config.json\` and run \`${checkCmd}\`.
|
|
214
|
+
|
|
215
|
+
For architecture status, run \`${doctorCmd}\`. The selected host is
|
|
216
|
+
\`${selectedHost}\`; its host registration and CI gate are installed alongside
|
|
217
|
+
this file. Full \`/ark-*\` guided workflows are optional and can be added later
|
|
218
|
+
with \`${installSkills}\`.
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
|
|
191
222
|
export function mcpJson(root) {
|
|
192
223
|
return `${JSON.stringify({
|
|
193
224
|
mcpServers: {
|
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
DEFAULT_INTENT_PREFIXES,
|
|
8
|
-
globToRegExp,
|
|
9
|
-
layerForFile,
|
|
10
|
-
patternSpecificity,
|
|
11
8
|
resolveIntentLayer,
|
|
12
9
|
} from '../ark-shared.mjs';
|
|
13
10
|
import { findDeniedEdgeRule } from '../ark-layer-match.mjs';
|
|
11
|
+
import { collectAnalysisConfigWarnings } from './analysis-engine.mjs';
|
|
14
12
|
import { normalize } from './scan-files.mjs';
|
|
15
13
|
|
|
16
14
|
export function intentLayersFromManifest(manifest) {
|
|
@@ -54,206 +52,10 @@ export function configWarning(ruleId, message, extra = {}) {
|
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
export function collectConfigWarnings(root, config, files, rules, manifest) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
warnings.push(
|
|
64
|
-
configWarning(
|
|
65
|
-
'CONFIG_INVALID_DYNAMIC_IMPORT_ALLOWLIST',
|
|
66
|
-
'dynamicImportAllowlist must be an array of file globs.'
|
|
67
|
-
)
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
if (config.safety !== undefined && (config.safety === null || typeof config.safety !== 'object' || Array.isArray(config.safety))) {
|
|
71
|
-
warnings.push(configWarning('CONFIG_INVALID_SAFETY', 'safety must be an object.'));
|
|
72
|
-
} else if (config.safety) {
|
|
73
|
-
for (const key of ['maxTsSuppressions', 'maxAnyCasts']) {
|
|
74
|
-
const value = config.safety[key];
|
|
75
|
-
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
|
|
76
|
-
warnings.push(configWarning('CONFIG_INVALID_SAFETY_THRESHOLD', `safety.${key} must be a non-negative integer.`));
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const layers = Array.isArray(config.layers) ? config.layers : [];
|
|
81
|
-
const manifestLayers = Array.isArray(manifest?.architecture?.layers)
|
|
82
|
-
? manifest.architecture.layers
|
|
83
|
-
: [];
|
|
84
|
-
const knownLayers = new Set([
|
|
85
|
-
...layers.map((layer) => layer.name).filter(Boolean),
|
|
86
|
-
...manifestLayers.map((layer) => layer.name).filter(Boolean),
|
|
87
|
-
]);
|
|
88
|
-
|
|
89
|
-
if (layers.length === 0) {
|
|
90
|
-
warnings.push(
|
|
91
|
-
configWarning(
|
|
92
|
-
'CONFIG_NO_LAYERS',
|
|
93
|
-
'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
|
|
94
|
-
)
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const seenLayers = new Set();
|
|
99
|
-
const duplicateLayers = new Set();
|
|
100
|
-
for (const layer of layers) {
|
|
101
|
-
if (!layer.name) {
|
|
102
|
-
warnings.push(
|
|
103
|
-
configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
|
|
104
|
-
);
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
|
|
108
|
-
seenLayers.add(layer.name);
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
layer.forbiddenGlobals !== undefined &&
|
|
112
|
-
(!Array.isArray(layer.forbiddenGlobals) ||
|
|
113
|
-
layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
|
|
114
|
-
) {
|
|
115
|
-
warnings.push(
|
|
116
|
-
configWarning(
|
|
117
|
-
'CONFIG_INVALID_FORBIDDEN_GLOBALS',
|
|
118
|
-
`Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
|
|
119
|
-
{ layer: layer.name }
|
|
120
|
-
)
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
|
|
125
|
-
if (patterns.length === 0) {
|
|
126
|
-
warnings.push(
|
|
127
|
-
configWarning(
|
|
128
|
-
'CONFIG_LAYER_WITHOUT_PATTERNS',
|
|
129
|
-
`Layer "${layer.name}" has no file patterns and will never classify files.`,
|
|
130
|
-
{ layer: layer.name }
|
|
131
|
-
)
|
|
132
|
-
);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
for (const pattern of patterns) {
|
|
137
|
-
let re;
|
|
138
|
-
try {
|
|
139
|
-
re = globToRegExp(pattern);
|
|
140
|
-
} catch (err) {
|
|
141
|
-
warnings.push(
|
|
142
|
-
configWarning(
|
|
143
|
-
'CONFIG_INVALID_LAYER_PATTERN',
|
|
144
|
-
`Layer "${layer.name}" has an invalid pattern "${pattern}": ${
|
|
145
|
-
err instanceof Error ? err.message : String(err)
|
|
146
|
-
}`,
|
|
147
|
-
{ layer: layer.name, pattern }
|
|
148
|
-
)
|
|
149
|
-
);
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const matched = files.some((file) => {
|
|
154
|
-
const rel = normalize(path.relative(root, file));
|
|
155
|
-
return re.test(rel);
|
|
156
|
-
});
|
|
157
|
-
if (!matched && !layer.optional) {
|
|
158
|
-
// Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
|
|
159
|
-
// globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
|
|
160
|
-
// Failing the release gate on dead preset globs caused false CI red while architecture
|
|
161
|
-
// edges were clean on multi-package hosts. Real safety is import violations +
|
|
162
|
-
// CONFIG_UNCLASSIFIED_FILES / invalid patterns.
|
|
163
|
-
warnings.push(
|
|
164
|
-
configWarning(
|
|
165
|
-
'CONFIG_LAYER_PATTERN_NO_MATCHES',
|
|
166
|
-
`Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
|
|
167
|
-
{ layer: layer.name, pattern, failsStrict: false }
|
|
168
|
-
)
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
for (const name of duplicateLayers) {
|
|
175
|
-
warnings.push(
|
|
176
|
-
configWarning(
|
|
177
|
-
'CONFIG_DUPLICATE_LAYER',
|
|
178
|
-
`Layer "${name}" is configured more than once.`,
|
|
179
|
-
{ layer: name }
|
|
180
|
-
)
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (knownLayers.size > 0) {
|
|
185
|
-
for (const rule of rules ?? []) {
|
|
186
|
-
if (rule.from && !knownLayers.has(rule.from)) {
|
|
187
|
-
warnings.push(
|
|
188
|
-
configWarning(
|
|
189
|
-
'CONFIG_RULE_UNKNOWN_FROM_LAYER',
|
|
190
|
-
`Rule references unknown source layer "${rule.from}".`,
|
|
191
|
-
{ fromLayer: rule.from, toLayer: rule.to }
|
|
192
|
-
)
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
if (rule.to && !knownLayers.has(rule.to)) {
|
|
196
|
-
warnings.push(
|
|
197
|
-
configWarning(
|
|
198
|
-
'CONFIG_RULE_UNKNOWN_TO_LAYER',
|
|
199
|
-
`Rule references unknown target layer "${rule.to}".`,
|
|
200
|
-
{ fromLayer: rule.from, toLayer: rule.to }
|
|
201
|
-
)
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
|
|
208
|
-
// layerForFile breaks the tie by declaration order, but the config is genuinely undecided
|
|
209
|
-
// (unlike a facade split, where the surface pattern is strictly more specific and wins
|
|
210
|
-
// cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
|
|
211
|
-
const ambiguousPairs = new Set();
|
|
212
|
-
if (layers.length > 1) {
|
|
213
|
-
for (const file of files) {
|
|
214
|
-
const rel = normalize(path.relative(root, file));
|
|
215
|
-
let topScore = -1;
|
|
216
|
-
let topLayers = [];
|
|
217
|
-
for (const layer of layers) {
|
|
218
|
-
for (const pattern of layer.patterns ?? []) {
|
|
219
|
-
if (!globToRegExp(pattern).test(rel)) continue;
|
|
220
|
-
const score = patternSpecificity(pattern);
|
|
221
|
-
if (score > topScore) {
|
|
222
|
-
topScore = score;
|
|
223
|
-
topLayers = [layer.name];
|
|
224
|
-
} else if (score === topScore && !topLayers.includes(layer.name)) {
|
|
225
|
-
topLayers.push(layer.name);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (topLayers.length > 1) {
|
|
230
|
-
ambiguousPairs.add([...topLayers].sort().join(' + '));
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (ambiguousPairs.size > 0) {
|
|
235
|
-
warnings.push(
|
|
236
|
-
configWarning(
|
|
237
|
-
'CONFIG_AMBIGUOUS_LAYERS',
|
|
238
|
-
`Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
|
|
239
|
-
{ pairs: [...ambiguousPairs] }
|
|
240
|
-
)
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const unclassified = files.filter((file) => !layerForFile(root, file, layers));
|
|
245
|
-
if (unclassified.length > 0) {
|
|
246
|
-
warnings.push(
|
|
247
|
-
configWarning(
|
|
248
|
-
'CONFIG_UNCLASSIFIED_FILES',
|
|
249
|
-
`${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
|
|
250
|
-
{
|
|
251
|
-
count: unclassified.length,
|
|
252
|
-
samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
|
|
253
|
-
}
|
|
254
|
-
)
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return warnings;
|
|
55
|
+
return collectAnalysisConfigWarnings({
|
|
56
|
+
config,
|
|
57
|
+
rules,
|
|
58
|
+
manifest,
|
|
59
|
+
files: files.map((file) => normalize(path.relative(root, file))),
|
|
60
|
+
});
|
|
259
61
|
}
|