arkgate 2.6.1 → 2.8.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 (55) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +8 -3
  3. package/bin/ark-check.mjs +19 -993
  4. package/bin/ark-layer-match.mjs +148 -171
  5. package/bin/ark-shared.mjs +9 -159
  6. package/bin/lib/agent-gates.mjs +48 -228
  7. package/bin/lib/architecture-scan.mjs +299 -0
  8. package/bin/lib/ast-scan.mjs +427 -0
  9. package/bin/lib/baseline-key.mjs +23 -0
  10. package/bin/lib/codex-home.mjs +320 -0
  11. package/bin/lib/config-warnings.mjs +228 -0
  12. package/bin/lib/doctor-plan.mjs +2 -0
  13. package/bin/lib/graph-cycles.mjs +56 -0
  14. package/bin/lib/remediation.mjs +182 -0
  15. package/bin/lib/scan-files.mjs +69 -0
  16. package/bin/lib/ts-resolve.mjs +216 -0
  17. package/bin/lib/violations.mjs +3 -9
  18. package/dist/eslint/index.cjs +21 -3
  19. package/dist/eslint/index.cjs.map +1 -1
  20. package/dist/eslint/index.d.cts +5 -3
  21. package/dist/eslint/index.d.ts +5 -3
  22. package/dist/eslint/index.js +21 -3
  23. package/dist/eslint/index.js.map +1 -1
  24. package/dist/index.cjs +1 -1
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +3 -3
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/nestjs/index.cjs +1 -1
  31. package/dist/nestjs/index.cjs.map +1 -1
  32. package/dist/nestjs/index.d.cts +1 -1
  33. package/dist/nestjs/index.d.ts +1 -1
  34. package/dist/nestjs/index.js +1 -1
  35. package/dist/nestjs/index.js.map +1 -1
  36. package/dist/runtime/index.cjs +3080 -0
  37. package/dist/runtime/index.cjs.map +1 -0
  38. package/dist/runtime/index.d.cts +2 -0
  39. package/dist/runtime/index.d.ts +2 -0
  40. package/dist/runtime/index.js +2998 -0
  41. package/dist/runtime/index.js.map +1 -0
  42. package/dist/{types-DpdVN7Lm.d.cts → types-CP3KkwZt.d.cts} +1 -1
  43. package/dist/{types-DpdVN7Lm.d.ts → types-CP3KkwZt.d.ts} +1 -1
  44. package/docs/agent-guide.md +5 -2
  45. package/docs/ai-gates.md +41 -7
  46. package/docs/brownfield-adoption.md +7 -0
  47. package/docs/demos/03-copilot-autopilot.md +3 -2
  48. package/docs/enthusiast/reference-commands.md +2 -2
  49. package/docs/migrate-from-ark-runtime-kernel.md +4 -2
  50. package/docs/package-surface.md +72 -0
  51. package/docs/production-hardening.md +3 -0
  52. package/package.json +12 -1
  53. package/server.json +2 -2
  54. package/templates/skills/ark-explain.md +3 -2
  55. package/templates/skills/ark-loop.md +2 -1
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Codex home config ($CODEX_HOME/config.toml) — paths, multi-project wire, adoption assess.
3
+ * Extracted from agent-gates so install/doctor stay orchestration-only (R7 review).
4
+ */
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { execCommandParts } from '../ark-shared.mjs';
10
+
11
+ export const PREFERRED_CODEX_MCP_BIN = 'arkgate-mcp';
12
+
13
+ /** Where Codex loads slash-command prompts ($CODEX_HOME/prompts). */
14
+ export function codexPromptsDir() {
15
+ const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
16
+ return path.join(base, 'prompts');
17
+ }
18
+
19
+ /** Where Codex loads MCP servers ($CODEX_HOME/config.toml) — global, not project-local. */
20
+ export function codexConfigPath() {
21
+ const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
22
+ return path.join(base, 'config.toml');
23
+ }
24
+
25
+ /** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
26
+ export function isTempOrUpgradeRoot(p) {
27
+ if (!p || typeof p !== 'string') return false;
28
+ const n = p.replace(/\\/g, '/');
29
+ return (
30
+ /\/var\/folders\//i.test(n) ||
31
+ /\/tmp\//i.test(n) ||
32
+ /\/Temp\//i.test(n) ||
33
+ /ark-upgrade/i.test(n) ||
34
+ /\/T\/(?:ark-|grok-)/i.test(n) ||
35
+ /[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Stable secondary table name: basename + short path hash so two projects named
41
+ * `app` do not collide on `mcp_servers.ark_app`.
42
+ */
43
+ export function codexProjectSlug(absRoot) {
44
+ const abs = path.resolve(absRoot);
45
+ const base =
46
+ path
47
+ .basename(abs)
48
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
49
+ .slice(0, 40) || 'project';
50
+ const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
51
+ return `${base}_${hash}`;
52
+ }
53
+
54
+ /** Extract `--root` from one TOML mcp_servers table body. */
55
+ export function extractCodexRootFromBlock(block) {
56
+ if (!block || typeof block !== 'string') return null;
57
+ const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
58
+ return m ? m[1] : null;
59
+ }
60
+
61
+ /**
62
+ * All Ark MCP server tables in a Codex config.toml.
63
+ * @returns {Array<{ table: string, root: string|null, block: string, start: number, end: number }>}
64
+ */
65
+ export function listCodexArkServerTables(tomlText) {
66
+ if (!tomlText || typeof tomlText !== 'string') return [];
67
+ const out = [];
68
+ const headerRe = /\[mcp_servers\.(ark(?:_[a-zA-Z0-9_-]*)?)\]/g;
69
+ const headers = [];
70
+ let hm;
71
+ while ((hm = headerRe.exec(tomlText)) !== null) {
72
+ headers.push({ table: hm[1], index: hm.index });
73
+ }
74
+ for (let i = 0; i < headers.length; i++) {
75
+ const start = headers[i].index;
76
+ let end = i + 1 < headers.length ? headers[i + 1].index : tomlText.length;
77
+ if (i + 1 >= headers.length) {
78
+ const rest = tomlText.slice(start + 1);
79
+ const other = rest.search(/\n\[/);
80
+ if (other >= 0) end = start + 1 + other;
81
+ }
82
+ const block = tomlText.slice(start, end).replace(/\s+$/, '\n');
83
+ out.push({
84
+ table: headers[i].table,
85
+ root: extractCodexRootFromBlock(block),
86
+ block,
87
+ start,
88
+ end,
89
+ });
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Replace an existing `[mcp_servers.<table>]` block or append a new one.
96
+ * `block` should include the header line; trailing whitespace is normalized.
97
+ */
98
+ export function upsertCodexMcpTable(tomlText, tableName, block) {
99
+ const existing = tomlText || '';
100
+ const normalized = `${String(block).replace(/\s+$/, '')}\n`;
101
+ const tables = listCodexArkServerTables(existing);
102
+ const hit = tables.find((t) => t.table === tableName);
103
+ if (hit) {
104
+ return `${existing.slice(0, hit.start)}${normalized}${existing.slice(hit.end).replace(/^\n+/, '\n')}`;
105
+ }
106
+ if (existing.length === 0) return normalized;
107
+ const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
108
+ return `${existing}${sep}${normalized}`;
109
+ }
110
+
111
+ /** Primary table entry or null. */
112
+ export function codexPrimaryTable(tomlText) {
113
+ return listCodexArkServerTables(tomlText).find((t) => t.table === 'ark') ?? null;
114
+ }
115
+
116
+ /** Secondary (non-primary) table whose --root is this project, if any. */
117
+ export function codexScopedTableForRoot(tomlText, absRoot) {
118
+ const abs = path.resolve(absRoot);
119
+ for (const entry of listCodexArkServerTables(tomlText)) {
120
+ if (entry.table === 'ark') continue;
121
+ if (!entry.root) continue;
122
+ try {
123
+ if (path.resolve(entry.root) === abs) return entry.table;
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** Extract --root from primary [mcp_servers.ark]. */
132
+ export function extractCodexArkRootFromToml(tomlText) {
133
+ return codexPrimaryTable(tomlText)?.root ?? null;
134
+ }
135
+
136
+ export function codexArkBlockHasPreferredBin(tomlText) {
137
+ const primary = codexPrimaryTable(tomlText);
138
+ if (!primary) return false;
139
+ const bins = [...primary.block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
140
+ if (bins.length > 1) return false;
141
+ return bins.length === 1 && bins[0] === PREFERRED_CODEX_MCP_BIN;
142
+ }
143
+
144
+ /**
145
+ * True when primary is broken (temp root / dual bin) and should rewrite fail-closed.
146
+ * Permanent different project roots are NOT broken — multi-project uses a secondary table.
147
+ */
148
+ export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
149
+ if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
150
+ const rootArg = extractCodexArkRootFromToml(tomlText);
151
+ if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
152
+ try {
153
+ if (path.resolve(rootArg) !== path.resolve(absRoot)) {
154
+ if (!isTempOrUpgradeRoot(rootArg)) return false;
155
+ return true;
156
+ }
157
+ } catch {
158
+ return true;
159
+ }
160
+ if (!codexArkBlockHasPreferredBin(tomlText)) return true;
161
+ return false;
162
+ }
163
+
164
+ /**
165
+ * Assess Codex home MCP vs this project. Pure (no I/O).
166
+ * @returns {{
167
+ * root: string|null,
168
+ * tempPath: boolean,
169
+ * wrongRoot: boolean,
170
+ * preferredBin: boolean,
171
+ * needsRewrite: boolean,
172
+ * multiProject: boolean,
173
+ * scopedTable: string|null,
174
+ * gap: null | { id: string, severity: string, message: string, fixArgs: string }
175
+ * }}
176
+ */
177
+ export function assessCodexHomeMcp(tomlText, absRoot) {
178
+ const resolvedRoot = path.resolve(absRoot);
179
+ if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) {
180
+ return {
181
+ root: null,
182
+ tempPath: false,
183
+ wrongRoot: false,
184
+ preferredBin: false,
185
+ needsRewrite: false,
186
+ multiProject: false,
187
+ scopedTable: null,
188
+ gap: null,
189
+ };
190
+ }
191
+ const rootArg = extractCodexArkRootFromToml(tomlText);
192
+ const temp = isTempOrUpgradeRoot(rootArg);
193
+ let wrongRoot = false;
194
+ try {
195
+ wrongRoot = rootArg ? path.resolve(rootArg) !== resolvedRoot : true;
196
+ } catch {
197
+ wrongRoot = true;
198
+ }
199
+ const preferredBin = codexArkBlockHasPreferredBin(tomlText);
200
+ const needsRewrite = codexArkBlockNeedsRewrite(tomlText, resolvedRoot);
201
+ const scopedTable = wrongRoot && !temp ? codexScopedTableForRoot(tomlText, resolvedRoot) : null;
202
+ const multiProject = Boolean(wrongRoot && !temp && !needsRewrite);
203
+
204
+ let gap = null;
205
+ if (needsRewrite) {
206
+ gap = {
207
+ id: 'codex-home-mcp',
208
+ severity: temp || wrongRoot ? 'warn' : 'info',
209
+ message: temp
210
+ ? `Codex home MCP --root points at a temp/upgrade path (${rootArg})`
211
+ : wrongRoot
212
+ ? `Codex home MCP --root is not this project (${rootArg || 'missing'} ≠ ${resolvedRoot})`
213
+ : `Codex home MCP should use a single ${PREFERRED_CODEX_MCP_BIN} bin with absolute project paths`,
214
+ fixArgs: '--install-agent-gates --codex-home --force',
215
+ };
216
+ } else if (multiProject) {
217
+ gap = {
218
+ id: 'codex-home-multi-project',
219
+ severity: scopedTable ? 'info' : 'warn',
220
+ message: scopedTable
221
+ ? `Codex primary [mcp_servers.ark] is bound to another project (${rootArg}); ` +
222
+ `this project is registered as [mcp_servers.${scopedTable}]. ` +
223
+ `Codex may still prefer the primary binding for ark://manifest — rebind if this repo should own it.`
224
+ : `Codex home primary MCP --root is another permanent project ` +
225
+ `(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +
226
+ `Install without --force adds a scoped [mcp_servers.ark_<slug>] table and leaves primary unchanged; ` +
227
+ `--force rebinds primary to this project.`,
228
+ fixArgs: scopedTable
229
+ ? '--install-agent-gates --tools codex --force'
230
+ : '--install-agent-gates --tools codex',
231
+ };
232
+ }
233
+
234
+ return {
235
+ root: rootArg,
236
+ tempPath: temp,
237
+ wrongRoot,
238
+ preferredBin,
239
+ needsRewrite,
240
+ multiProject,
241
+ scopedTable,
242
+ gap,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Merge [mcp_servers.ark] (or scoped secondary) into Codex home config.toml.
248
+ * Without --force, permanent other-project primary is left alone; this project gets
249
+ * [mcp_servers.ark_<slug>]. Temp/stale roots rewrite fail-closed.
250
+ *
251
+ * All mutations go through upsertCodexMcpTable (single table model).
252
+ */
253
+ export function wireCodexMcp(root, force) {
254
+ const file = codexConfigPath();
255
+ const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
256
+ const absRoot = path.resolve(root);
257
+ const absConfig = path.join(absRoot, 'ark.config.json');
258
+ const { command, args } = execCommandParts(root, PREFERRED_CODEX_MCP_BIN, [
259
+ '--root',
260
+ esc(absRoot),
261
+ '--config',
262
+ esc(absConfig),
263
+ ]);
264
+ const argsToml = args.map((value) => `"${value}"`).join(', ');
265
+ const makeBlock = (table) =>
266
+ `[mcp_servers.${table}]
267
+ command = "${command}"
268
+ args = [${argsToml}]`;
269
+
270
+ let existing = '';
271
+ try {
272
+ if (fs.existsSync(file)) existing = fs.readFileSync(file, 'utf8');
273
+ } catch (error) {
274
+ return { status: 'failed', file, message: error.message };
275
+ }
276
+
277
+ const primary = codexPrimaryTable(existing);
278
+ const hasPrimary = Boolean(primary);
279
+ const existingRoot = primary?.root ?? null;
280
+ let differentProject = false;
281
+ try {
282
+ differentProject = Boolean(existingRoot && path.resolve(existingRoot) !== absRoot);
283
+ } catch {
284
+ differentProject = Boolean(existingRoot);
285
+ }
286
+ const mustRewrite = hasPrimary && codexArkBlockNeedsRewrite(existing, absRoot);
287
+
288
+ const writeToml = (next) => {
289
+ try {
290
+ fs.mkdirSync(path.dirname(file), { recursive: true });
291
+ fs.writeFileSync(file, next);
292
+ return null;
293
+ } catch (error) {
294
+ return { status: 'failed', file, message: error.message };
295
+ }
296
+ };
297
+
298
+ // Multi-project: leave primary alone; upsert scoped secondary for this root.
299
+ if (hasPrimary && differentProject && !force && !mustRewrite) {
300
+ const existingScoped = codexScopedTableForRoot(existing, absRoot);
301
+ const table = existingScoped || `ark_${codexProjectSlug(absRoot)}`;
302
+ const next = upsertCodexMcpTable(existing, table, makeBlock(table));
303
+ const err = writeToml(next);
304
+ if (err) return err;
305
+ return { status: 'written-multi', file, table, primaryUnchanged: true };
306
+ }
307
+
308
+ if (hasPrimary && !force && !mustRewrite) {
309
+ return { status: 'skipped', file };
310
+ }
311
+
312
+ const next = upsertCodexMcpTable(existing, 'ark', makeBlock('ark'));
313
+ const err = writeToml(next);
314
+ if (err) return err;
315
+ return {
316
+ status: hasPrimary ? 'updated' : 'written',
317
+ file,
318
+ ...(mustRewrite && !force ? { reason: 'temp-or-stale-root' } : {}),
319
+ };
320
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Config validation warnings + intent layer helpers for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ import path from 'node:path';
6
+ import {
7
+ DEFAULT_INTENT_PREFIXES,
8
+ globToRegExp,
9
+ layerForFile,
10
+ patternSpecificity,
11
+ resolveIntentLayer,
12
+ } from '../ark-shared.mjs';
13
+ import { normalize } from './scan-files.mjs';
14
+
15
+ export function intentLayersFromManifest(manifest) {
16
+ const layers = manifest?.architecture?.layers;
17
+ if (!Array.isArray(layers)) return undefined;
18
+ return layers
19
+ .filter((layer) => Array.isArray(layer.prefixes) && layer.prefixes.length > 0)
20
+ .map((layer) => ({ name: layer.name, prefixes: layer.prefixes }));
21
+ }
22
+
23
+ export function layerForIntent(intent, layers, manifestIntentLayers) {
24
+ // Use only layers that declare intent prefixes; fall back to the built-in defaults when
25
+ // none do (mirrors the write-gate). resolveIntentLayer applies the library's exact
26
+ // longest-prefix + trailing-dot semantics so CI and the MCP gate classify identically.
27
+ const configured =
28
+ manifestIntentLayers ??
29
+ layers
30
+ .filter((layer) => (layer.intentPrefixes ?? []).length > 0)
31
+ .map((layer) => ({ name: layer.name, prefixes: layer.intentPrefixes }));
32
+ const source =
33
+ configured.length > 0
34
+ ? configured
35
+ : DEFAULT_INTENT_PREFIXES.map((entry) => ({ name: entry.layer, prefixes: entry.prefixes }));
36
+ return resolveIntentLayer(intent, source);
37
+ }
38
+
39
+ export function isBlocked(rules, from, to) {
40
+ return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
41
+ }
42
+
43
+ export function configWarning(ruleId, message, extra = {}) {
44
+ return { ruleId, message, ...extra };
45
+ }
46
+
47
+ export function collectConfigWarnings(root, config, files, rules, manifest) {
48
+ const warnings = [];
49
+ const layers = Array.isArray(config.layers) ? config.layers : [];
50
+ const manifestLayers = Array.isArray(manifest?.architecture?.layers)
51
+ ? manifest.architecture.layers
52
+ : [];
53
+ const knownLayers = new Set([
54
+ ...layers.map((layer) => layer.name).filter(Boolean),
55
+ ...manifestLayers.map((layer) => layer.name).filter(Boolean),
56
+ ]);
57
+
58
+ if (layers.length === 0) {
59
+ warnings.push(
60
+ configWarning(
61
+ 'CONFIG_NO_LAYERS',
62
+ 'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
63
+ )
64
+ );
65
+ }
66
+
67
+ const seenLayers = new Set();
68
+ const duplicateLayers = new Set();
69
+ for (const layer of layers) {
70
+ if (!layer.name) {
71
+ warnings.push(
72
+ configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
73
+ );
74
+ continue;
75
+ }
76
+ if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
77
+ seenLayers.add(layer.name);
78
+
79
+ if (
80
+ layer.forbiddenGlobals !== undefined &&
81
+ (!Array.isArray(layer.forbiddenGlobals) ||
82
+ layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
83
+ ) {
84
+ warnings.push(
85
+ configWarning(
86
+ 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
87
+ `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
88
+ { layer: layer.name }
89
+ )
90
+ );
91
+ }
92
+
93
+ const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
94
+ if (patterns.length === 0) {
95
+ warnings.push(
96
+ configWarning(
97
+ 'CONFIG_LAYER_WITHOUT_PATTERNS',
98
+ `Layer "${layer.name}" has no file patterns and will never classify files.`,
99
+ { layer: layer.name }
100
+ )
101
+ );
102
+ continue;
103
+ }
104
+
105
+ for (const pattern of patterns) {
106
+ let re;
107
+ try {
108
+ re = globToRegExp(pattern);
109
+ } catch (err) {
110
+ warnings.push(
111
+ configWarning(
112
+ 'CONFIG_INVALID_LAYER_PATTERN',
113
+ `Layer "${layer.name}" has an invalid pattern "${pattern}": ${
114
+ err instanceof Error ? err.message : String(err)
115
+ }`,
116
+ { layer: layer.name, pattern }
117
+ )
118
+ );
119
+ continue;
120
+ }
121
+
122
+ const matched = files.some((file) => {
123
+ const rel = normalize(path.relative(root, file));
124
+ return re.test(rel);
125
+ });
126
+ if (!matched && !layer.optional) {
127
+ // Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
128
+ // globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
129
+ // Failing the release gate on dead preset globs caused false CI red while architecture
130
+ // edges were clean (deer-flow host validation). Real safety is import violations +
131
+ // CONFIG_UNCLASSIFIED_FILES / invalid patterns.
132
+ warnings.push(
133
+ configWarning(
134
+ 'CONFIG_LAYER_PATTERN_NO_MATCHES',
135
+ `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
136
+ { layer: layer.name, pattern, failsStrict: false }
137
+ )
138
+ );
139
+ }
140
+ }
141
+ }
142
+
143
+ for (const name of duplicateLayers) {
144
+ warnings.push(
145
+ configWarning(
146
+ 'CONFIG_DUPLICATE_LAYER',
147
+ `Layer "${name}" is configured more than once.`,
148
+ { layer: name }
149
+ )
150
+ );
151
+ }
152
+
153
+ if (knownLayers.size > 0) {
154
+ for (const rule of rules ?? []) {
155
+ if (rule.from && !knownLayers.has(rule.from)) {
156
+ warnings.push(
157
+ configWarning(
158
+ 'CONFIG_RULE_UNKNOWN_FROM_LAYER',
159
+ `Rule references unknown source layer "${rule.from}".`,
160
+ { fromLayer: rule.from, toLayer: rule.to }
161
+ )
162
+ );
163
+ }
164
+ if (rule.to && !knownLayers.has(rule.to)) {
165
+ warnings.push(
166
+ configWarning(
167
+ 'CONFIG_RULE_UNKNOWN_TO_LAYER',
168
+ `Rule references unknown target layer "${rule.to}".`,
169
+ { fromLayer: rule.from, toLayer: rule.to }
170
+ )
171
+ );
172
+ }
173
+ }
174
+ }
175
+
176
+ // Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
177
+ // layerForFile breaks the tie by declaration order, but the config is genuinely undecided
178
+ // (unlike a facade split, where the surface pattern is strictly more specific and wins
179
+ // cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
180
+ const ambiguousPairs = new Set();
181
+ if (layers.length > 1) {
182
+ for (const file of files) {
183
+ const rel = normalize(path.relative(root, file));
184
+ let topScore = -1;
185
+ let topLayers = [];
186
+ for (const layer of layers) {
187
+ for (const pattern of layer.patterns ?? []) {
188
+ if (!globToRegExp(pattern).test(rel)) continue;
189
+ const score = patternSpecificity(pattern);
190
+ if (score > topScore) {
191
+ topScore = score;
192
+ topLayers = [layer.name];
193
+ } else if (score === topScore && !topLayers.includes(layer.name)) {
194
+ topLayers.push(layer.name);
195
+ }
196
+ }
197
+ }
198
+ if (topLayers.length > 1) {
199
+ ambiguousPairs.add([...topLayers].sort().join(' + '));
200
+ }
201
+ }
202
+ }
203
+ if (ambiguousPairs.size > 0) {
204
+ warnings.push(
205
+ configWarning(
206
+ 'CONFIG_AMBIGUOUS_LAYERS',
207
+ `Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
208
+ { pairs: [...ambiguousPairs] }
209
+ )
210
+ );
211
+ }
212
+
213
+ const unclassified = files.filter((file) => !layerForFile(root, file, layers));
214
+ if (unclassified.length > 0) {
215
+ warnings.push(
216
+ configWarning(
217
+ 'CONFIG_UNCLASSIFIED_FILES',
218
+ `${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
219
+ {
220
+ count: unclassified.length,
221
+ samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
222
+ }
223
+ )
224
+ );
225
+ }
226
+
227
+ return warnings;
228
+ }
@@ -162,6 +162,8 @@ export function buildRemediationPlan(root, activeViolations, governedPercent = n
162
162
  ...(v.typeOnly ? { typeOnly: true } : {}),
163
163
  ...(v.targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
164
164
  ...(v.sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
165
+ ...(v.namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
166
+ ...(v.edgeKind ? { edgeKind: v.edgeKind } : {}),
165
167
  ...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
166
168
  };
167
169
  });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Import-graph cycle detection (Tarjan) for ark-check.
3
+ * Extracted from ark-check entry (R3).
4
+ */
5
+ export function detectCycles(graph) {
6
+ let index = 0;
7
+ const indices = new Map();
8
+ const low = new Map();
9
+ const onStack = new Set();
10
+ const stack = [];
11
+ const components = [];
12
+
13
+ // ponytail: recursive Tarjan; make it iterative only if a real repo blows the stack.
14
+ const strongconnect = (v) => {
15
+ indices.set(v, index);
16
+ low.set(v, index);
17
+ index += 1;
18
+ stack.push(v);
19
+ onStack.add(v);
20
+ for (const w of [...(graph.get(v) ?? [])].sort()) {
21
+ if (!graph.has(w)) continue;
22
+ if (!indices.has(w)) {
23
+ strongconnect(w);
24
+ low.set(v, Math.min(low.get(v), low.get(w)));
25
+ } else if (onStack.has(w)) {
26
+ low.set(v, Math.min(low.get(v), indices.get(w)));
27
+ }
28
+ }
29
+ if (low.get(v) === indices.get(v)) {
30
+ const comp = [];
31
+ let w;
32
+ do {
33
+ w = stack.pop();
34
+ onStack.delete(w);
35
+ comp.push(w);
36
+ } while (w !== v);
37
+ if (comp.length > 1) components.push(comp.sort());
38
+ }
39
+ };
40
+
41
+ for (const v of [...graph.keys()].sort()) {
42
+ if (!indices.has(v)) strongconnect(v);
43
+ }
44
+
45
+ return components
46
+ .sort((a, b) => a[0].localeCompare(b[0]))
47
+ .map((members) => ({
48
+ ruleId: 'CIRCULAR_DEPENDENCY',
49
+ file: members[0],
50
+ line: 1,
51
+ target: members.join(' → '),
52
+ message: `Circular dependency among ${members.length} files: ${members.join(' → ')} → ${members[0]}.`,
53
+ // Graph is value/runtime edges only (type-only imports omitted).
54
+ cycleKind: 'value',
55
+ }));
56
+ }