arkgate 2.13.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +53 -36
  3. package/bin/ark-check.mjs +72 -6
  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 +51 -15
  12. package/bin/lib/config-warnings.mjs +7 -205
  13. package/bin/lib/design-smells.mjs +434 -0
  14. package/bin/lib/doctor-plan.mjs +149 -16
  15. package/bin/lib/field-install.mjs +67 -10
  16. package/bin/lib/gate-files.mjs +42 -3
  17. package/bin/lib/graph-cycles.mjs +4 -54
  18. package/bin/lib/hook-templates.mjs +33 -1
  19. package/bin/lib/host-support-matrix.mjs +7 -1
  20. package/bin/lib/install-migrate.mjs +54 -16
  21. package/bin/lib/presets.mjs +42 -2
  22. package/bin/lib/safety-diagnostics.mjs +18 -17
  23. package/bin/lib/scan-files.mjs +12 -1
  24. package/bin/lib/skill-install.mjs +8 -1
  25. package/bin/lib/source-policy.mjs +36 -0
  26. package/bin/lib/start-preview.mjs +271 -0
  27. package/bin/lib/ts-resolve.mjs +11 -2
  28. package/bin/lib/write-path-capabilities.mjs +4 -0
  29. package/compat/nestjs.cjs +2 -0
  30. package/compat/nestjs.d.ts +2 -0
  31. package/compat/nestjs.js +1 -0
  32. package/compat/runtime.cjs +2 -0
  33. package/compat/runtime.d.ts +2 -0
  34. package/compat/runtime.js +1 -0
  35. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  36. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  37. package/dist/eslint/index.cjs +125 -48
  38. package/dist/eslint/index.d.cts +7 -1
  39. package/dist/eslint/index.d.ts +7 -1
  40. package/dist/eslint/index.js +125 -48
  41. package/dist/index.cjs +1248 -3302
  42. package/dist/index.d.cts +359 -483
  43. package/dist/index.d.ts +359 -483
  44. package/dist/index.js +1231 -3248
  45. package/docs/agent-guide.md +34 -16
  46. package/docs/ai-gates.md +30 -7
  47. package/docs/brownfield-adoption.md +52 -1
  48. package/docs/migrate-from-ark-runtime-kernel.md +2 -3
  49. package/docs/package-surface.md +10 -13
  50. package/docs/production-hardening.md +17 -4
  51. package/docs/typescript-support.md +27 -0
  52. package/package.json +33 -11
  53. package/schemas/ark.analysis-result.schema.json +91 -0
  54. package/server.json +2 -2
  55. package/templates/skills/ark-adopt.md +26 -3
  56. package/templates/skills/ark-architect.md +10 -2
  57. package/templates/skills/ark-autopilot.md +37 -20
  58. package/templates/skills/ark-contract.md +7 -0
  59. package/templates/skills/ark-coverage.md +44 -45
  60. package/templates/skills/ark-explain.md +8 -0
  61. package/templates/skills/ark-explore.md +117 -47
  62. package/templates/skills/ark-fix.md +22 -0
  63. package/templates/skills/ark-loop.md +15 -1
  64. package/templates/skills/ark-place.md +7 -0
  65. package/templates/skills/ark-think.md +24 -20
  66. package/dist/configContract-iBLxx5Tz.d.cts +0 -53
  67. package/dist/configContract-iBLxx5Tz.d.ts +0 -53
  68. package/dist/eslint/index.cjs.map +0 -1
  69. package/dist/eslint/index.js.map +0 -1
  70. package/dist/index.cjs.map +0 -1
  71. package/dist/index.js.map +0 -1
  72. package/dist/nestjs/index.cjs +0 -2606
  73. package/dist/nestjs/index.cjs.map +0 -1
  74. package/dist/nestjs/index.d.cts +0 -23
  75. package/dist/nestjs/index.d.ts +0 -23
  76. package/dist/nestjs/index.js +0 -2582
  77. package/dist/nestjs/index.js.map +0 -1
  78. package/dist/runtime/index.cjs +0 -4014
  79. package/dist/runtime/index.cjs.map +0 -1
  80. package/dist/runtime/index.d.cts +0 -3
  81. package/dist/runtime/index.d.ts +0 -3
  82. package/dist/runtime/index.js +0 -3925
  83. package/dist/runtime/index.js.map +0 -1
  84. package/dist/types-BxBwnBpC.d.cts +0 -1041
  85. 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
  }
@@ -153,21 +153,26 @@ files; never weaken the gate via subagents.
153
153
 
154
154
  ## Skill routing (triggers → skill)
155
155
 
156
- | When | Invoke |
157
- |------|--------|
158
- | Unsure / make architecture sound | **/ark-autopilot** (default) |
159
- | Need map / opportunities only (no apply) | \`/ark-explore\` |
160
- | Greenfield shape / empty tree | \`/ark-architect\` |
161
- | Brownfield / wrong contract / false-green | \`/ark-adopt\` then \`/ark-contract\` if globs wrong |
162
- | Edit \`ark.config.json\` layers/rules/intents | \`/ark-contract\` |
163
- | New file “where does this go?” | \`/ark-place\` |
164
- | Gate violation on a change | \`/ark-fix\` |
165
- | Drive plan to goal.met | \`/ark-loop\` |
166
- | Deep coverage + ranked audit | \`/ark-coverage\` |
167
- | Design trade-offs (no package LLM) | \`/ark-think\` |
168
- | Explain / HTML report tour | \`/ark-explain\` |
169
- | Bump arkgate + refresh hosts | \`/ark-upgrade\` |
170
- | Optional runtime kernel migrate | \`/ark-runtime\` |
156
+ Do **not** run overlapping skills for the same job. Pick **one** primary skill from the table.
157
+
158
+ | When | Invoke | Not this |
159
+ |------|--------|----------|
160
+ | Unsure / make architecture sound (apply path) | **/ark-autopilot** (default) | explore-only, coverage-only |
161
+ | Map / residual / dual-plan **seed** / spaghetti Shape plan (no apply) | \`/ark-explore\` | coverage (fitness only), think (one decision) |
162
+ | ENFORCE but design-weak / concurrent patterns / god modules | \`/ark-explore\` shape-focus or dual-plan seed → then autopilot for apply | loop (empty A is not done) |
163
+ | Greenfield shape / empty tree | \`/ark-architect\` | adopt |
164
+ | Brownfield / wrong contract / false-green | \`/ark-adopt\` then \`/ark-contract\` if globs wrong | architect |
165
+ | Edit \`ark.config.json\` layers/rules/intents | \`/ark-contract\` | fix/loop for config |
166
+ | New file “where does this go?” | \`/ark-place\` | architect (unless greenfield shape missing) |
167
+ | Gate violation on a change (small cluster) | \`/ark-fix\` | loop/autopilot unless bulk |
168
+ | Drive plan **A** to goal.met | \`/ark-loop\` | explore (unless A empty + design residual) |
169
+ | Ark **fitness** only (governed%, gates, baseline, install gaps) | \`/ark-coverage\` | explore (full recon) |
170
+ | One design decision, 2–3 options | \`/ark-think\` | explore (full dual-plan) |
171
+ | Explain / HTML report tour | \`/ark-explain\` | explore |
172
+ | Bump arkgate + refresh hosts | \`/ark-upgrade\` | — |
173
+ | Optional runtime kernel evaluate | \`/ark-runtime\` | — |
174
+
175
+ **Phases (brownfield honesty):** Align (contract truth) → Stabilize (real baseline) → Shape (golden pattern + pilot). Empty plan A after Stabilize still leaves Shape work — that is explore/autopilot **B**, not “healthy finished.”
171
176
 
172
177
  ## Before editing TypeScript or JavaScript source files
173
178
 
@@ -188,6 +193,37 @@ advisory MCP plus CI. The experimental runtime is not required.
188
193
  `;
189
194
  }
190
195
 
196
+ /**
197
+ * Compact onboarding uses one project router instead of copied slash-command
198
+ * skills. The package and ark MCP resources remain the canonical capability
199
+ * source; the marker makes the selected host verifiable by the strict gate.
200
+ */
201
+ export function compactAgentInstructions(root, host = null) {
202
+ const selectedHost = host || 'none';
203
+ const checkCmd = arkCheckCommand(root);
204
+ const doctorCmd = arkCommand(root, 'ark-check', '--doctor');
205
+ const installSkills = arkCommand(
206
+ root,
207
+ 'ark-check',
208
+ `--install-agent-gates --skills-only --tools ${selectedHost === 'none' ? '<host>' : selectedHost}`
209
+ );
210
+ return `# Ark Enforcement
211
+
212
+ <!-- arkgate:compact-router host=${selectedHost} -->
213
+ ## Compact router
214
+
215
+ This project uses the ArkGate package and its \`ark\` MCP resources as its one
216
+ agent router. Before editing TypeScript or JavaScript, read \`ark://manifest\`
217
+ when available; use \`ark_place\` for new files and \`validate_code\` after edits.
218
+ If MCP is unavailable, inspect \`ark.config.json\` and run \`${checkCmd}\`.
219
+
220
+ For architecture status, run \`${doctorCmd}\`. The selected host is
221
+ \`${selectedHost}\`; its host registration and CI gate are installed alongside
222
+ this file. Full \`/ark-*\` guided workflows are optional and can be added later
223
+ with \`${installSkills}\`.
224
+ `;
225
+ }
226
+
191
227
  export function mcpJson(root) {
192
228
  return `${JSON.stringify({
193
229
  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
  }