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
@@ -1,197 +1,174 @@
1
1
  /**
2
- * Pure layer-glob matching for ark.config.json.
3
- * Single source of truth for CLI (ark-shared / ark-check) and ESLint (bundled via import).
4
- * No Node I/O beyond path.sep normalization — pure string/path math only.
2
+ * GENERATED FILE do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/layerMatch.ts
5
+ * Regenerate: node scripts/generate-layer-match.mjs
6
+ * Drift check: node scripts/generate-layer-match.mjs --check
7
+ *
8
+ * Pure layer-glob matching for ark.config.json (CLI load path).
9
+ * CLI-only layerForFile (Node path resolution) is appended below the pure core.
5
10
  */
6
- import path from 'node:path';
7
-
8
- const _regexpCache = new Map();
9
11
 
12
+ const regexpCache = new Map();
10
13
  function escapeLiteral(ch) {
11
- return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
14
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
12
15
  }
13
-
14
- /** True only when every `{` has a matching `}` (ignoring backslash-escaped braces). */
15
- function bracesBalanced(glob) {
16
- let depth = 0;
17
- for (let i = 0; i < glob.length; i += 1) {
18
- const c = glob[i];
19
- if (c === '\\') {
20
- i += 1; // skip the escaped character
21
- continue;
22
- }
23
- if (c === '{') depth += 1;
24
- else if (c === '}') {
25
- depth -= 1;
26
- if (depth < 0) return false;
27
- }
28
- }
29
- return depth === 0;
30
- }
31
-
32
16
  /**
33
- * Convert an ark.config.json layer glob pattern to an anchored RegExp (compiled once per
34
- * pattern, then cached).
35
- *
36
- * IMPORTANT: the double-star is expanded in a SINGLE pass. A chained two-step replace
37
- * (double-star to dot-star, then single-star to a no-slash class) corrupts the double-star,
38
- * because the second step re-matches the star inside the substitution the first step just
39
- * inserted. That made "src/kernel/**" stop matching nested paths, silently unclassifying
40
- * every file in a subdirectory. Scanning one character at a time also lets us support
41
- * brace alternation ("*.{ts,tsx}") and backslash escapes ("\\{" → literal brace).
42
- *
43
- * Brace alternation is only enabled when braces are balanced; an unbalanced brace (a config
44
- * typo) is treated as a literal so the gate never crashes on `new RegExp`.
17
+ * Normalize path separators to `/` without destroying glob escape sequences.
18
+ * `src\domain\x` `src/domain/x` (Windows paths); `src/\{legacy\}/**` keeps `\{` / `\}`.
19
+ * A plain `pattern.split('\\').join('/')` would eat those escapes.
45
20
  */
46
- export function globToRegExp(pattern) {
47
- const cached = _regexpCache.get(pattern);
48
- if (cached) return cached;
49
-
50
- const glob = pattern.split(path.sep).join('/');
51
- const useBraces = bracesBalanced(glob);
52
- let out = '';
53
- let braceDepth = 0;
54
- for (let i = 0; i < glob.length; i += 1) {
55
- const c = glob[i];
56
- if (c === '\\' && i + 1 < glob.length) {
57
- out += escapeLiteral(glob[i + 1]); // backslash escapes the next char to a literal
58
- i += 1;
59
- } else if (c === '*') {
60
- if (glob[i + 1] === '*') {
61
- if (glob[i + 2] === '/') {
62
- out += '(?:.*/)?'; // `**/` matches zero or more path segments
63
- i += 2;
64
- } else {
65
- out += '.*'; // `**` matches across `/`
66
- i += 1;
21
+ function normalizeGlobSeparators(pattern) {
22
+ let out = '';
23
+ for (let i = 0; i < pattern.length; i += 1) {
24
+ const c = pattern[i];
25
+ if (c === '\\' && i + 1 < pattern.length) {
26
+ const next = pattern[i + 1];
27
+ // Keep escapes for glob metacharacters (and escaped backslash).
28
+ if ('*?{}[],'.includes(next) || next === '\\') {
29
+ out += '\\' + next;
30
+ i += 1;
31
+ continue;
32
+ }
33
+ // Otherwise treat `\` as a path separator (Windows).
34
+ out += '/';
35
+ continue;
67
36
  }
68
- } else {
69
- out += '[^/]*'; // `*` matches within a single segment
70
- }
71
- } else if (c === '?') {
72
- out += '[^/]';
73
- } else if (c === '{' && useBraces) {
74
- out += '(?:';
75
- braceDepth += 1;
76
- } else if (c === '}' && useBraces && braceDepth > 0) {
77
- out += ')';
78
- braceDepth -= 1;
79
- } else if (c === ',' && useBraces && braceDepth > 0) {
80
- out += '|';
81
- } else {
82
- out += escapeLiteral(c);
37
+ out += c;
83
38
  }
84
- }
85
- const re = new RegExp(`^${out}$`);
86
- _regexpCache.set(pattern, re);
87
- return re;
88
- }
89
-
90
- // Specificity score for a layer glob: more literal path segments before the first wildcard
91
- // wins, then longer literal text. So `src/kernel/app/**` (3 literal segments) beats
92
- // `src/kernel/**` (2), and an exact file like `src/kernel/events.ts` beats both. This is what
93
- // makes a facade split (a KernelApi surface layer overlapping a KernelInternal catch-all)
94
- // resolve to the surface REGARDLESS of layer declaration order — the intuitive result.
95
- export function patternSpecificity(pattern) {
96
- const glob = String(pattern).split(path.sep).join('/');
97
- const beforeWildcard = glob.split('*')[0];
98
- const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
99
- const literalLength = glob.replace(/\*/g, '').length;
100
- return literalSegments * 10000 + literalLength;
39
+ return out;
101
40
  }
102
-
103
- /**
104
- * Resolve a file's architecture layer from ark.config.json layer glob patterns. When more
105
- * than one layer matches (overlapping globs, e.g. a facade split), the MOST SPECIFIC pattern
106
- * wins; ties break by declaration order (first wins). Order-independent for non-ambiguous
107
- * overlaps, so a config author can't silently break a facade by listing the catch-all first.
108
- *
109
- * A layer may also declare `exclude` globs. A file matching ANY exclude glob is NOT a
110
- * candidate for that layer even if a `patterns` glob matches — this lets a broad pattern
111
- * (e.g. `src/**​/domain/**`) carve out subtrees it should not govern (framework internals
112
- * like `**​/kernel/**`) without enumerating every include. Excluding a file from its layer
113
- * also removes it from that layer's rule and `forbiddenGlobals` enforcement, since both key
114
- * off this classification — which is exactly how a broad domain glob stops mis-flagging
115
- * `src/kernel/domain` as impure domain code. This is the single file→layer matcher shared by
116
- * the ark-check CI gate and the ark-mcp write gate, so `exclude` behaves identically in both.
117
- */
118
- export function layerForFile(root, file, layers) {
119
- const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
120
- const rel = path.relative(root, abs).split(path.sep).join('/');
121
- let bestName;
122
- let bestScore = -1;
123
- for (const layer of layers ?? []) {
124
- if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
125
- continue;
41
+ function bracesBalanced(glob) {
42
+ let depth = 0;
43
+ for (let i = 0; i < glob.length; i += 1) {
44
+ const c = glob[i];
45
+ if (c === '\\') {
46
+ i += 1;
47
+ continue;
48
+ }
49
+ if (c === '{')
50
+ depth += 1;
51
+ else if (c === '}') {
52
+ depth -= 1;
53
+ if (depth < 0)
54
+ return false;
55
+ }
126
56
  }
127
- for (const pattern of layer.patterns ?? []) {
128
- if (globToRegExp(pattern).test(rel)) {
129
- const score = patternSpecificity(pattern);
130
- if (score > bestScore) {
131
- bestScore = score;
132
- bestName = layer.name;
57
+ return depth === 0;
58
+ }
59
+ export function globToRegExp(pattern) {
60
+ const cached = regexpCache.get(pattern);
61
+ if (cached)
62
+ return cached;
63
+ const glob = normalizeGlobSeparators(pattern);
64
+ const useBraces = bracesBalanced(glob);
65
+ let out = '';
66
+ let braceDepth = 0;
67
+ for (let i = 0; i < glob.length; i += 1) {
68
+ const c = glob[i];
69
+ if (c === '\\' && i + 1 < glob.length) {
70
+ out += escapeLiteral(glob[i + 1]);
71
+ i += 1;
72
+ }
73
+ else if (c === '*') {
74
+ if (glob[i + 1] === '*') {
75
+ if (glob[i + 2] === '/') {
76
+ out += '(?:.*/)?';
77
+ i += 2;
78
+ }
79
+ else {
80
+ out += '.*';
81
+ i += 1;
82
+ }
83
+ }
84
+ else {
85
+ out += '[^/]*';
86
+ }
87
+ }
88
+ else if (c === '?') {
89
+ out += '[^/]';
90
+ }
91
+ else if (c === '{' && useBraces) {
92
+ out += '(?:';
93
+ braceDepth += 1;
94
+ }
95
+ else if (c === '}' && useBraces && braceDepth > 0) {
96
+ out += ')';
97
+ braceDepth -= 1;
98
+ }
99
+ else if (c === ',' && useBraces && braceDepth > 0) {
100
+ out += '|';
101
+ }
102
+ else {
103
+ out += escapeLiteral(c);
133
104
  }
134
- }
135
105
  }
136
- }
137
- return bestName;
106
+ const re = new RegExp(`^${out}$`);
107
+ regexpCache.set(pattern, re);
108
+ return re;
109
+ }
110
+ export function patternSpecificity(pattern) {
111
+ const glob = normalizeGlobSeparators(String(pattern));
112
+ const beforeWildcard = glob.split('*')[0];
113
+ const literalSegments = beforeWildcard.split('/').filter(Boolean).length;
114
+ const literalLength = glob.replace(/\*/g, '').length;
115
+ return literalSegments * 10000 + literalLength;
138
116
  }
139
-
140
-
141
- /** Classify a project-relative path (posix) without needing an absolute root. */
142
117
  export function layerForRelativePath(relPath, layers) {
143
- const rel = String(relPath).split(path.sep).join('/');
144
- let bestName;
145
- let bestScore = -1;
146
- for (const layer of layers ?? []) {
147
- if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
148
- continue;
149
- }
150
- for (const pattern of layer.patterns ?? []) {
151
- if (globToRegExp(pattern).test(rel)) {
152
- const score = patternSpecificity(pattern);
153
- if (score > bestScore) {
154
- bestScore = score;
155
- bestName = layer.name;
118
+ // File paths (not globs): any OS separator → posix relative.
119
+ const rel = String(relPath).split(/[/\\]/).join('/');
120
+ let bestName;
121
+ let bestScore = -1;
122
+ for (const layer of layers ?? []) {
123
+ if ((layer.exclude ?? []).some((pattern) => globToRegExp(pattern).test(rel))) {
124
+ continue;
125
+ }
126
+ for (const pattern of layer.patterns ?? []) {
127
+ if (globToRegExp(pattern).test(rel)) {
128
+ const score = patternSpecificity(pattern);
129
+ if (score > bestScore) {
130
+ bestScore = score;
131
+ bestName = layer.name;
132
+ }
133
+ }
156
134
  }
157
- }
158
135
  }
159
- }
160
- return bestName;
136
+ return bestName;
161
137
  }
162
-
163
- /** True when rules[] explicitly deny from→to. Missing rule = allowed (implicit). */
164
138
  export function isEdgeDenied(rules, from, to) {
165
- if (from === to) return false;
166
- const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
167
- return hit?.allowed === false;
139
+ if (from === to)
140
+ return false;
141
+ const hit = (rules ?? []).find((r) => r.from === from && r.to === to);
142
+ return hit?.allowed === false;
168
143
  }
169
-
170
- /**
171
- * Codegen / generated source globs skipped by the default scan.
172
- * Universal (TanStack Router routeTree.gen, many `*.generated.ts` tools, etc.).
173
- * Opt out with `excludeGenerated: false` in ark.config.json; add more via top-level `exclude`.
174
- */
144
+ /** Codegen globs skipped by default scan (emitted into the CLI derived matcher). */
175
145
  export const DEFAULT_GENERATED_FILE_GLOBS = [
176
- '**/*.gen.ts',
177
- '**/*.gen.tsx',
178
- '**/*.generated.ts',
179
- '**/*.generated.tsx',
146
+ '**/*.gen.ts',
147
+ '**/*.gen.tsx',
148
+ '**/*.generated.ts',
149
+ '**/*.generated.tsx',
180
150
  ];
181
-
182
- /**
183
- * Globs that remove files from ark-check scan (cycles, layers, coverage).
184
- * @param {{ exclude?: string[], excludeGenerated?: boolean } | null | undefined} config
185
- */
186
151
  export function scanExcludePatterns(config) {
187
- const custom = Array.isArray(config?.exclude) ? config.exclude.filter((p) => typeof p === 'string') : [];
188
- const generated =
189
- config?.excludeGenerated === false ? [] : DEFAULT_GENERATED_FILE_GLOBS;
190
- return [...generated, ...custom];
152
+ const custom = Array.isArray(config?.exclude)
153
+ ? config.exclude.filter((p) => typeof p === 'string')
154
+ : [];
155
+ const generated = config?.excludeGenerated === false ? [] : DEFAULT_GENERATED_FILE_GLOBS;
156
+ return [...generated, ...custom];
191
157
  }
192
-
193
- /** Relative path (posix) matches any scan-exclude glob. */
194
158
  export function isScanExcludedRelative(relPath, config) {
195
- const rel = String(relPath).split(path.sep).join('/');
196
- return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
159
+ const rel = String(relPath).split(/[/\\]/).join('/');
160
+ return scanExcludePatterns(config).some((pattern) => globToRegExp(pattern).test(rel));
161
+ }
162
+
163
+
164
+ import path from 'node:path';
165
+
166
+ /**
167
+ * Resolve a file's architecture layer from ark.config.json layer glob patterns.
168
+ * Uses Node path resolution, then the pure layerForRelativePath classifier.
169
+ */
170
+ export function layerForFile(root, file, layers) {
171
+ const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
172
+ const rel = path.relative(root, abs).split(path.sep).join('/');
173
+ return layerForRelativePath(rel, layers);
197
174
  }
@@ -380,7 +380,7 @@ export function collectForbiddenGlobalUses(ts, sourceFile, forbidden) {
380
380
  return uses;
381
381
  }
382
382
 
383
- // Layer glob matching — single source of truth in ark-layer-match.mjs (also used by ESLint).
383
+ // Layer glob matching — generated from canonical src/domain/layerMatch.ts (see generate:layer-match).
384
384
  export {
385
385
  globToRegExp,
386
386
  patternSpecificity,
@@ -431,102 +431,15 @@ export function looksLikeIntent(value) {
431
431
  }
432
432
 
433
433
  /**
434
- * Co-pilot Phase F — the work classifier. Every architecture violation is remediated in one of
435
- * three ways, and this is the TRUST BOUNDARY that decides what an agent may auto-apply:
436
- *
437
- * - 'mechanical-safe' : behavior-preserving AND gate-verifiable → an agent may auto-apply it.
438
- * - 'judgment' : real coupling or a design choice → Ark PROPOSES it, a human decides.
439
- * - 'deferred' : not enough signal to place it → a human should look first.
440
- *
441
- * Deliberately biased toward 'judgment': a false 'mechanical-safe' that auto-lands a bad edit
442
- * is the failure mode that sinks trust. Only statically-provable type-surface fixes earn 'auto':
443
- * (1) whole source file is pure type-surface + type-only edge → relocate the file
444
- * (2) import/export already marked type-only → move/re-export the type
445
- * (3) static value-syntax import of a pure type-only *target* module → import type
446
- * Pure function of one violation object so CLI, MCP, and apply-loop classify identically.
447
- * Returns { class, confidence, rationale, remediationKind? }.
434
+ * Co-pilot Phase F — work classifier + fix-class enrich (R4).
435
+ * Canonical TypeScript: src/domain/remediation.ts
436
+ * Generated CLI load path: bin/lib/remediation.mjs (`npm run generate:cli-pure`).
448
437
  */
449
- export const REMEDIATION_CLASSES = ['mechanical-safe', 'judgment', 'deferred'];
450
-
451
- export function classifyRemediation(violation) {
452
- const ruleId = violation?.ruleId;
453
- if (ruleId === 'LAYER_IMPORT_VIOLATION') {
454
- // Pure type-only *source file* with a type-only edge: relocating the whole file is
455
- // behavior-preserving (no runtime body). Distinct from a single import-type move.
456
- if (violation.typeOnly && violation.sourcePureTypeModule) {
457
- return {
458
- class: 'mechanical-safe',
459
- confidence: 0.88,
460
- remediationKind: 'pure-type-file-relocate',
461
- rationale:
462
- 'Whole source file is type-only surface (no runtime statements) with a type-only cross-layer edge: relocate the file to the owning layer (or extract the type there). Behavior-preserving; gate verifies.',
463
- };
464
- }
465
- if (violation.typeOnly) {
466
- return {
467
- class: 'mechanical-safe',
468
- confidence: 0.9,
469
- remediationKind: 'type-only-import-move',
470
- rationale:
471
- 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
472
- };
473
- }
474
- // Target module is a pure type-surface file AND the edge is a static import/export
475
- // (flag only set on those edges). Value-syntax `import { T }` → convert to import type.
476
- // require()/import() never get this flag (runtime load). Mixed modules stay judgment.
477
- if (violation.targetTypeOnlyExports) {
478
- const kind = violation.edgeKind;
479
- if (kind === 'require' || kind === 'dynamic-import') {
480
- return {
481
- class: 'judgment',
482
- confidence: 0.75,
483
- rationale:
484
- 'Runtime module load (require/import()) of a type-only module still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
485
- };
486
- }
487
- return {
488
- class: 'mechanical-safe',
489
- confidence: 0.85,
490
- remediationKind: 'import-type-from-pure-type-module',
491
- rationale:
492
- 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
493
- };
494
- }
495
- return {
496
- class: 'judgment',
497
- confidence: 0.7,
498
- rationale:
499
- 'Value import — real runtime coupling. Relocating it (e.g. a route reaching the DB → a repository) is a refactor whose organization is a human choice.',
500
- };
501
- }
502
- if (ruleId === 'FORBIDDEN_GLOBAL') {
503
- return {
504
- class: 'judgment',
505
- confidence: 0.8,
506
- rationale:
507
- 'Ambient global in a pure layer: inject the capability through a port (Clock, Config, Http). Introducing the port is a design decision.',
508
- };
509
- }
510
- if (ruleId === 'CIRCULAR_DEPENDENCY') {
511
- return {
512
- class: 'judgment',
513
- confidence: 0.7,
514
- rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
515
- };
516
- }
517
- if (typeof ruleId === 'string' && ruleId.length > 0) {
518
- return {
519
- class: 'judgment',
520
- confidence: 0.6,
521
- rationale: 'Needs a human decision on how to satisfy the contract without weakening the gate.',
522
- };
523
- }
524
- return {
525
- class: 'deferred',
526
- confidence: 0.3,
527
- rationale: 'Unrecognized violation shape — a human should look before anything is changed.',
528
- };
529
- }
438
+ export {
439
+ REMEDIATION_CLASSES,
440
+ classifyRemediation,
441
+ enrichViolationWithFixClass,
442
+ } from './lib/remediation.mjs';
530
443
 
531
444
  /**
532
445
  * Normalize a required/imported TypeScript module for ark-check's host.
@@ -1547,69 +1460,6 @@ export function shouldShowNewHereNudge(root, configPath, governedPercent, config
1547
1460
  return false;
1548
1461
  }
1549
1462
 
1550
- /**
1551
- * Deterministic fix-class labels for JSON output (English, shared with future skills).
1552
- */
1553
- export function enrichViolationWithFixClass(violation) {
1554
- const enriched = { ...violation };
1555
- switch (violation.ruleId) {
1556
- case 'LAYER_IMPORT_VIOLATION':
1557
- if (violation.typeOnly || violation.targetTypeOnlyExports) {
1558
- enriched.fixClass = 'file-move';
1559
- enriched.effort = 'small';
1560
- enriched.enthusiastHint = violation.targetTypeOnlyExports
1561
- ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
1562
- : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
1563
- } else {
1564
- enriched.fixClass = 'port-inversion';
1565
- enriched.effort = 'medium';
1566
- enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
1567
- }
1568
- break;
1569
- case 'FORBIDDEN_GLOBAL':
1570
- enriched.fixClass = 'inject-port';
1571
- enriched.effort = 'small';
1572
- enriched.enthusiastHint = `Do not call "${violation.target ?? 'that global'}" here. Pass the capability in through a small interface (for example a Clock, HttpPort, or Config provider).`;
1573
- break;
1574
- case 'RAW_EVENT_PUBLISH':
1575
- enriched.fixClass = 'registered-intent';
1576
- enriched.effort = 'small';
1577
- enriched.enthusiastHint =
1578
- 'Register the event intent first, then publish through the creator returned by the registry — not a raw string or object.';
1579
- break;
1580
- case 'PUBLISH_MISSING_SOURCE':
1581
- enriched.fixClass = 'add-source-metadata';
1582
- enriched.effort = 'small';
1583
- enriched.enthusiastHint =
1584
- 'Add metadata.source to the publish call so Ark knows which layer is publishing the event.';
1585
- break;
1586
- case 'PUBLISH_SOURCE_LAYER_MISMATCH':
1587
- enriched.fixClass = 'fix-source-layer';
1588
- enriched.effort = 'small';
1589
- enriched.enthusiastHint =
1590
- 'Use a source intent that belongs to the same layer as this file, or move the publish call to the layer that owns the source.';
1591
- break;
1592
- case 'LAYER_INTENT_REFERENCE_VIOLATION':
1593
- enriched.fixClass = 'intent-relocation';
1594
- enriched.effort = 'small';
1595
- enriched.enthusiastHint =
1596
- 'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
1597
- break;
1598
- case 'CIRCULAR_DEPENDENCY':
1599
- enriched.fixClass = 'break-cycle';
1600
- enriched.effort = 'medium';
1601
- enriched.enthusiastHint =
1602
- 'Two modules import each other in a loop. Extract shared code, invert one dependency behind a port, or merge them if they are really one unit.';
1603
- break;
1604
- default:
1605
- enriched.fixClass = 'review-contract';
1606
- enriched.effort = 'small';
1607
- enriched.enthusiastHint =
1608
- 'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
1609
- }
1610
- return enriched;
1611
- }
1612
-
1613
1463
  export function formatArchitectureRecommendationHuman(recommendation) {
1614
1464
  const lines = [];
1615
1465
  lines.push('Ark architecture recommendation (application shape, not vendor stack)');