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.
Files changed (72) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +37 -22
  3. package/bin/ark-check.mjs +62 -4
  4. package/bin/ark-mcp.mjs +108 -1
  5. package/bin/ark-shared.mjs +204 -149
  6. package/bin/ark.mjs +90 -25
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +1 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -135
  11. package/bin/lib/ci-and-commands.mjs +31 -0
  12. package/bin/lib/config-warnings.mjs +7 -205
  13. package/bin/lib/field-install.mjs +67 -10
  14. package/bin/lib/gate-files.mjs +42 -3
  15. package/bin/lib/graph-cycles.mjs +4 -54
  16. package/bin/lib/hook-templates.mjs +33 -1
  17. package/bin/lib/host-support-matrix.mjs +7 -1
  18. package/bin/lib/install-migrate.mjs +54 -16
  19. package/bin/lib/presets.mjs +42 -2
  20. package/bin/lib/safety-diagnostics.mjs +18 -17
  21. package/bin/lib/scan-files.mjs +12 -1
  22. package/bin/lib/skill-install.mjs +8 -1
  23. package/bin/lib/source-policy.mjs +36 -0
  24. package/bin/lib/start-preview.mjs +271 -0
  25. package/bin/lib/ts-resolve.mjs +11 -2
  26. package/bin/lib/write-path-capabilities.mjs +4 -0
  27. package/compat/nestjs.cjs +2 -0
  28. package/compat/nestjs.d.ts +2 -0
  29. package/compat/nestjs.js +1 -0
  30. package/compat/runtime.cjs +2 -0
  31. package/compat/runtime.d.ts +2 -0
  32. package/compat/runtime.js +1 -0
  33. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  34. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  35. package/dist/eslint/index.cjs +125 -48
  36. package/dist/eslint/index.d.cts +7 -1
  37. package/dist/eslint/index.d.ts +7 -1
  38. package/dist/eslint/index.js +125 -48
  39. package/dist/index.cjs +1248 -3302
  40. package/dist/index.d.cts +359 -483
  41. package/dist/index.d.ts +359 -483
  42. package/dist/index.js +1231 -3248
  43. package/docs/agent-guide.md +28 -16
  44. package/docs/ai-gates.md +30 -7
  45. package/docs/migrate-from-ark-runtime-kernel.md +2 -3
  46. package/docs/package-surface.md +8 -13
  47. package/docs/production-hardening.md +17 -4
  48. package/docs/typescript-support.md +27 -0
  49. package/package.json +33 -11
  50. package/schemas/ark.analysis-result.schema.json +91 -0
  51. package/server.json +2 -2
  52. package/templates/skills/ark-architect.md +3 -2
  53. package/dist/configContract-iBLxx5Tz.d.cts +0 -53
  54. package/dist/configContract-iBLxx5Tz.d.ts +0 -53
  55. package/dist/eslint/index.cjs.map +0 -1
  56. package/dist/eslint/index.js.map +0 -1
  57. package/dist/index.cjs.map +0 -1
  58. package/dist/index.js.map +0 -1
  59. package/dist/nestjs/index.cjs +0 -2606
  60. package/dist/nestjs/index.cjs.map +0 -1
  61. package/dist/nestjs/index.d.cts +0 -23
  62. package/dist/nestjs/index.d.ts +0 -23
  63. package/dist/nestjs/index.js +0 -2582
  64. package/dist/nestjs/index.js.map +0 -1
  65. package/dist/runtime/index.cjs +0 -4014
  66. package/dist/runtime/index.cjs.map +0 -1
  67. package/dist/runtime/index.d.cts +0 -3
  68. package/dist/runtime/index.d.ts +0 -3
  69. package/dist/runtime/index.js +0 -3925
  70. package/dist/runtime/index.js.map +0 -1
  71. package/dist/types-BxBwnBpC.d.cts +0 -1041
  72. 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 { detectCycles } from './graph-cycles.mjs';
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 collectForbiddenGlobalUses(ts, sourceFile, forbiddenGlobals)) {
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: lineOf(sourceFile, use.node.getStart(sourceFile)),
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 visit = (node) => {
83
- if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
84
- const specifier = textOfModuleSpecifier(node);
85
- if (specifier) {
86
- checkModuleEdge(
87
- specifier,
88
- node,
89
- ts.isImportDeclaration(node) ? 'import' : 'export',
90
- isTypeOnlyModuleReference(ts, node)
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
- if (
113
- (rawIntent && looksLikeIntent(rawIntent)) ||
114
- objectHasProperty(ts, firstArg, 'intent')
115
- ) {
116
- violations.push({
117
- ruleId: 'RAW_EVENT_PUBLISH',
118
- file: normalize(path.relative(root, file)),
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: 'PUBLISH_MISSING_SOURCE',
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: 'Strict Ark publish calls must include metadata.source.',
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 rule = targetLayer
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
- if (rule) {
261
- const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
262
- const staticEdge = edge.kind === 'import' || edge.kind === 'export';
263
- const targetTypeOnlyExports =
264
- staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
265
- const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
266
- // R6: every named binding is a type-only export of the target (mixed modules OK).
267
- // Conservative: no dual-space value names, no top-level side effects on target
268
- // (import type would skip evaluation), no default/namespace/side-effect/export*.
269
- const targetTypeNames = new Set(targetCached?.typeOnlyExportNames || []);
270
- const named = edge.namedBindings;
271
- const namedBindingsTypeOnly =
272
- staticEdge &&
273
- Array.isArray(named) &&
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
- const cyclePolicy = String(config.cyclePolicy || 'strict').toLowerCase();
323
- if (cyclePolicy !== 'off') {
324
- const cycles = detectCycles(importGraph);
325
- if (cyclePolicy === 'soft' || cyclePolicy === 'framework-soft') {
326
- for (const c of cycles) {
327
- warnings.push({
328
- ruleId: 'CIRCULAR_DEPENDENCY',
329
- message: `${c.message} (soft cycle policy — advisory only; set cyclePolicy: "strict" to fail the check)`,
330
- file: c.file,
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
- const warnings = [];
58
- if (
59
- config.dynamicImportAllowlist !== undefined &&
60
- (!Array.isArray(config.dynamicImportAllowlist) ||
61
- config.dynamicImportAllowlist.some((entry) => typeof entry !== 'string'))
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
  }