arkgate 2.8.3 → 2.9.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 (53) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +2 -1
  3. package/bin/ark-check.mjs +2 -2
  4. package/bin/ark-layer-match.mjs +88 -5
  5. package/bin/ark-mcp.mjs +7 -70
  6. package/bin/ark-shared.mjs +88 -8
  7. package/bin/ark.mjs +3 -2
  8. package/bin/lib/architecture-scan.mjs +16 -4
  9. package/bin/lib/config-warnings.mjs +11 -2
  10. package/bin/lib/doctor-plan.mjs +20 -0
  11. package/bin/lib/import-resolve.mjs +133 -0
  12. package/bin/lib/presets.mjs +207 -11
  13. package/bin/lib/remediation.mjs +15 -0
  14. package/bin/lib/suggestions.mjs +8 -3
  15. package/dist/eslint/index.cjs +63 -5
  16. package/dist/eslint/index.cjs.map +1 -1
  17. package/dist/eslint/index.d.cts +33 -1
  18. package/dist/eslint/index.d.ts +33 -1
  19. package/dist/eslint/index.js +63 -5
  20. package/dist/eslint/index.js.map +1 -1
  21. package/dist/index.cjs +103 -14
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +21 -9
  24. package/dist/index.d.ts +21 -9
  25. package/dist/index.js +103 -14
  26. package/dist/index.js.map +1 -1
  27. package/dist/nestjs/index.cjs +78 -4
  28. package/dist/nestjs/index.cjs.map +1 -1
  29. package/dist/nestjs/index.d.cts +1 -1
  30. package/dist/nestjs/index.d.ts +1 -1
  31. package/dist/nestjs/index.js +78 -4
  32. package/dist/nestjs/index.js.map +1 -1
  33. package/dist/runtime/index.cjs +103 -14
  34. package/dist/runtime/index.cjs.map +1 -1
  35. package/dist/runtime/index.d.cts +1 -1
  36. package/dist/runtime/index.d.ts +1 -1
  37. package/dist/runtime/index.js +103 -14
  38. package/dist/runtime/index.js.map +1 -1
  39. package/dist/{types-CSJhEOk2.d.cts → types-D6Q8WHes.d.cts} +7 -0
  40. package/dist/{types-CSJhEOk2.d.ts → types-D6Q8WHes.d.ts} +7 -0
  41. package/docs/agent-guide.md +55 -4
  42. package/package.json +3 -1
  43. package/server.json +2 -2
  44. package/templates/architecture-playbook.json +65 -1
  45. package/templates/policy-packs/enthusiast-ddd-bounded-contexts.json +19 -0
  46. package/templates/policy-packs/enthusiast-ui-surface.json +18 -0
  47. package/templates/policy-packs/enthusiast-vertical-slice.json +18 -0
  48. package/templates/skills/ark-adopt.md +4 -0
  49. package/templates/skills/ark-architect.md +5 -1
  50. package/templates/skills/ark-autopilot.md +3 -1
  51. package/templates/skills/ark-fix.md +3 -0
  52. package/templates/skills/ark-place.md +7 -0
  53. package/templates/skills/ark-think.md +43 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Shared import path → repo-relative + layer resolution for ark-mcp write-gate.
3
+ * Single primitive so peerIsolation and layer rules share one resolver.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { layerForFile } from '../ark-layer-match.mjs';
8
+
9
+ /**
10
+ * Read tsconfig path aliases via the TypeScript config parser (JSONC + extends).
11
+ * @returns {{ baseUrl: string, aliases: Array<{ from: string, to: string }> }}
12
+ */
13
+ export function readTsconfigAliases(ts, root) {
14
+ if (!ts) return { baseUrl: root, aliases: [] };
15
+ try {
16
+ const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
17
+ if (!configPath) return { baseUrl: root, aliases: [] };
18
+ const read = ts.readConfigFile(configPath, ts.sys.readFile);
19
+ if (read.error) return { baseUrl: root, aliases: [] };
20
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
21
+ const opts = parsed.options || {};
22
+ const baseUrl = opts.baseUrl || path.dirname(configPath);
23
+ const aliases = [];
24
+ for (const [pattern, targets] of Object.entries(opts.paths || {})) {
25
+ if (!Array.isArray(targets) || targets.length === 0) continue;
26
+ // Catch-all `*` → empty prefix would match every specifier; skip it.
27
+ const from = pattern.replace(/\*$/, '');
28
+ if (!from) continue;
29
+ aliases.push({ from, to: String(targets[0]).replace(/\*$/, '') });
30
+ }
31
+ aliases.sort((a, b) => b.from.length - a.from.length);
32
+ return { baseUrl, aliases };
33
+ } catch {
34
+ return { baseUrl: root, aliases: [] };
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Resolve an import specifier to a repo-relative path.
40
+ * Relative + tsconfig-aliased only; bare packages → undefined.
41
+ */
42
+ export function resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases) {
43
+ let abs;
44
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
45
+ if (!fromFilePath) return undefined;
46
+ const fromAbs = path.isAbsolute(fromFilePath)
47
+ ? fromFilePath
48
+ : path.resolve(root, fromFilePath);
49
+ abs = path.resolve(path.dirname(fromAbs), specifier);
50
+ } else {
51
+ const alias = tsAliases.aliases.find((a) => specifier.startsWith(a.from));
52
+ if (!alias) return undefined;
53
+ abs = path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`);
54
+ }
55
+ const rel = path.relative(root, abs).split(path.sep).join('/');
56
+ return rel.startsWith('..') ? undefined : rel;
57
+ }
58
+
59
+ function filePathToRel(filePath, root) {
60
+ if (!filePath || typeof filePath !== 'string') return undefined;
61
+ const abs = path.isAbsolute(filePath) ? filePath : path.resolve(root, filePath);
62
+ const rel = path.relative(root, abs).split(path.sep).join('/');
63
+ return rel.startsWith('..') ? undefined : rel;
64
+ }
65
+
66
+ function classifyProbe(root, rel, layers) {
67
+ let probe = rel;
68
+ try {
69
+ if (fs.statSync(path.join(root, rel)).isDirectory()) probe = `${rel}/index.ts`;
70
+ } catch {
71
+ /* not on disk */
72
+ }
73
+ return (
74
+ layerForFile(root, probe, layers) || layerForFile(root, `${rel}/index.ts`, layers)
75
+ );
76
+ }
77
+
78
+ /**
79
+ * One resolver for write-gate: specifier or absolute/repo-relative source file →
80
+ * `{ relPath, layer }`.
81
+ */
82
+ export function createImportTargetResolver(ts, root, config) {
83
+ const layers = config?.layers ?? [];
84
+ if (layers.length === 0) return undefined;
85
+ const tsAliases = readTsconfigAliases(ts, root);
86
+
87
+ return (specifierOrFilePath, fromFilePath) => {
88
+ if (!specifierOrFilePath || typeof specifierOrFilePath !== 'string') return undefined;
89
+
90
+ // Absolute filesystem path (file being written)
91
+ if (path.isAbsolute(specifierOrFilePath)) {
92
+ const relPath = filePathToRel(specifierOrFilePath, root);
93
+ if (!relPath) return undefined;
94
+ return { relPath, layer: classifyProbe(root, relPath, layers) };
95
+ }
96
+
97
+ // Relative or path-alias import
98
+ if (
99
+ specifierOrFilePath.startsWith('./') ||
100
+ specifierOrFilePath.startsWith('../') ||
101
+ specifierOrFilePath.startsWith('@')
102
+ ) {
103
+ const rel = resolveSpecifierToRel(
104
+ specifierOrFilePath,
105
+ fromFilePath,
106
+ root,
107
+ tsAliases
108
+ );
109
+ if (!rel) return undefined;
110
+ return { relPath: rel, layer: classifyProbe(root, rel, layers) };
111
+ }
112
+
113
+ // Try as import alias / bare package first
114
+ const asImport = resolveSpecifierToRel(
115
+ specifierOrFilePath,
116
+ fromFilePath,
117
+ root,
118
+ tsAliases
119
+ );
120
+ if (asImport) {
121
+ return { relPath: asImport, layer: classifyProbe(root, asImport, layers) };
122
+ }
123
+
124
+ // Repo-relative source file path (not an import specifier)
125
+ const asFile = filePathToRel(specifierOrFilePath, root);
126
+ if (asFile) {
127
+ return { relPath: asFile, layer: classifyProbe(root, asFile, layers) };
128
+ }
129
+
130
+ return undefined;
131
+ };
132
+ }
133
+
@@ -19,16 +19,35 @@ export function denyUpward(names) {
19
19
  return rules;
20
20
  }
21
21
 
22
+ /**
23
+ * peerIsolation matrix: deny only when importer/importee sit under different slices.
24
+ * Covers same-layer and cross-layer pairs (honest DDD / vertical-slice isolation).
25
+ */
26
+ export function peerIsolationEdges(layerNames, sliceFolders, message) {
27
+ const rules = [];
28
+ for (const from of layerNames) {
29
+ for (const to of layerNames) {
30
+ rules.push({
31
+ from,
32
+ to,
33
+ allowed: false,
34
+ peerIsolation: true,
35
+ sliceFolders,
36
+ ...(message ? { message } : {}),
37
+ });
38
+ }
39
+ }
40
+ return rules;
41
+ }
42
+
22
43
  // Named starter configs. Globs use `**` so they fit both flat (src/domain/**) and
23
44
  // modular (src/modules/x/domain/**) layouts. Every layer is optional, so the strict
24
45
  // check passes on a greenfield repo and each layer switches on as its dir gains files.
25
46
  //
26
- // Framework internals live under conventional names like `kernel/` and are NOT application
27
- // architecture — a broad `src/**/domain/**` would otherwise swallow `src/kernel/domain`
28
- // (DI/runtime wiring) and fire domain-purity rules on it, the false-positive class that
29
- // motivated `exclude`. Carve those out of every wildcard preset layer by default; a config
30
- // author who really does keep app code under kernel/ can drop the exclude.
31
- export const FRAMEWORK_INTERNAL_EXCLUDE = ['**/kernel/**'];
47
+ // Framework internals under `src/kernel/**` are NOT application architecture — a broad
48
+ // `src/**/domain/**` would otherwise swallow `src/kernel/domain`. Do NOT use `**/kernel/**`
49
+ // (that carves out legitimate `src/shared/kernel/**` SharedKernel paths).
50
+ export const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
32
51
  export function presetWithOverlays(baseConfig, root) {
33
52
  if (!root) return baseConfig;
34
53
  return applyFrameworkLayoutOverlays(baseConfig, root);
@@ -153,20 +172,22 @@ export const ARCHITECTURE_PRESETS = {
153
172
  'feature-sliced': (_workspaces, root) => {
154
173
  const order = ['App', 'Pages', 'Widgets', 'Features', 'Entities', 'Shared'];
155
174
  const purpose = {
156
- App: 'App-wide setup, providers, and routing.',
157
- Pages: 'Route-level compositions.',
175
+ App: 'App-wide setup, providers, and routing (FSD app/ + Next app router when co-located under src/app).',
176
+ Pages: 'Route-level compositions (FSD pages/ and Next pages router).',
158
177
  Widgets: 'Self-contained UI blocks composed from features and entities.',
159
178
  Features: 'User-facing feature units.',
160
179
  Entities: 'Business entities with their UI and logic.',
161
180
  Shared: 'Reusable primitives with no business knowledge.',
162
181
  };
182
+ // Canonical FSD under src/<layer>/**; also root <layer>/** for packages that hoist segments.
183
+ const fsdPatterns = (dir) => [`src/${dir}/**`, `${dir}/**`];
163
184
  return presetWithOverlays(
164
185
  {
165
- include: ['src'],
186
+ include: ['src', 'app', 'pages'],
166
187
  layers: order.map((name) => ({
167
188
  name,
168
189
  description: purpose[name],
169
- patterns: [`src/${name.toLowerCase()}/**`],
190
+ patterns: fsdPatterns(name.toLowerCase()),
170
191
  optional: true,
171
192
  })),
172
193
  rules: denyUpward(order),
@@ -185,7 +206,8 @@ export const ARCHITECTURE_PRESETS = {
185
206
  const resolved = resolveIncludeRoots(root);
186
207
  if (resolved.length > 0) include = resolved;
187
208
  }
188
- if (include.length === 0) include = ['packages', 'apps'];
209
+ // Turborepo: apps/ + packages/; Nx enterprise: apps/ + libs/ (+ packages/).
210
+ if (include.length === 0) include = ['packages', 'apps', 'libs'];
189
211
  return presetWithOverlays(
190
212
  {
191
213
  include,
@@ -377,8 +399,182 @@ export const ARCHITECTURE_PRESETS = {
377
399
  },
378
400
  root
379
401
  ),
402
+
403
+ /**
404
+ * Vertical Slice: feature folders own UI+logic+api; no cross-feature imports
405
+ * (peerIsolation). Shared primitives and pure lib/infra are the only escape hatches.
406
+ */
407
+ 'vertical-slice': (_workspaces, root) =>
408
+ presetWithOverlays(
409
+ {
410
+ include: ['src'],
411
+ layers: [
412
+ {
413
+ name: 'Features',
414
+ description:
415
+ 'Feature / use-case slices (co-located API, UI, hooks, types). No import across sibling slices.',
416
+ patterns: ['src/features/**', 'src/modules/**'],
417
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
418
+ optional: true,
419
+ },
420
+ {
421
+ name: 'Shared',
422
+ description: 'Reusable UI primitives, utils, and types with no feature knowledge.',
423
+ patterns: ['src/shared/**'],
424
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
425
+ optional: true,
426
+ },
427
+ {
428
+ name: 'Lib',
429
+ description: 'Infrastructure clients (db, HTTP, env) shared across features.',
430
+ patterns: ['src/lib/**', 'src/infra/**', 'src/infrastructure/**'],
431
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
432
+ optional: true,
433
+ },
434
+ {
435
+ name: 'App',
436
+ description: 'App shell, routing, providers, composition root.',
437
+ patterns: ['src/app/**', 'app/**', 'src/pages/**', 'pages/**'],
438
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
439
+ optional: true,
440
+ },
441
+ ],
442
+ rules: [
443
+ {
444
+ from: 'Features',
445
+ to: 'Features',
446
+ allowed: false,
447
+ peerIsolation: true,
448
+ sliceFolders: ['features', 'modules'],
449
+ message:
450
+ 'Features must not import other feature slices. Extract shared code to Shared/Lib or coordinate via events.',
451
+ },
452
+ // Features must not pull in the composition root (re-coupling via App).
453
+ { from: 'Features', to: 'App', allowed: false },
454
+ { from: 'Shared', to: 'Features', allowed: false },
455
+ { from: 'Shared', to: 'App', allowed: false },
456
+ { from: 'Lib', to: 'Features', allowed: false },
457
+ { from: 'Lib', to: 'Shared', allowed: false },
458
+ { from: 'Lib', to: 'App', allowed: false },
459
+ // App may compose Features + Shared + Lib (no deny).
460
+ // Features may import Shared + Lib (no deny).
461
+ ],
462
+ },
463
+ root
464
+ ),
465
+
466
+ /**
467
+ * DDD bounded contexts: per-context domain/application/infra/presentation + shared kernel.
468
+ * peerIsolation on every pair of context-local layers blocks cross-context imports
469
+ * (same or cross technical layer). SharedKernel is exempt (not in the peer matrix).
470
+ * Classic hexagonal denies still block e.g. Domain → Persistence within a context.
471
+ */
472
+ 'ddd-bounded-contexts': (_workspaces, root) => {
473
+ const contextLayers = [
474
+ 'DomainModel',
475
+ 'ApplicationOrchestration',
476
+ 'PresentationAdapters',
477
+ 'PersistenceAdapters',
478
+ ];
479
+ const sliceFolders = ['contexts', 'bounded-contexts'];
480
+ return presetWithOverlays(
481
+ {
482
+ include: ['src'],
483
+ layers: [
484
+ {
485
+ name: 'DomainModel',
486
+ description:
487
+ 'Per-context pure domain (entities, VOs, domain events). No I/O, no framework.',
488
+ patterns: [
489
+ 'src/contexts/**/domain/**',
490
+ 'src/bounded-contexts/**/domain/**',
491
+ 'src/**/domain/**',
492
+ ],
493
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
494
+ forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
495
+ optional: true,
496
+ },
497
+ {
498
+ name: 'ApplicationOrchestration',
499
+ description: 'Per-context use cases / application services.',
500
+ patterns: [
501
+ 'src/contexts/**/application/**',
502
+ 'src/bounded-contexts/**/application/**',
503
+ 'src/**/application/**',
504
+ ],
505
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
506
+ optional: true,
507
+ },
508
+ {
509
+ name: 'PresentationAdapters',
510
+ description: 'Per-context controllers, HTTP, UI adapters.',
511
+ patterns: [
512
+ 'src/contexts/**/presentation/**',
513
+ 'src/contexts/**/controllers/**',
514
+ 'src/bounded-contexts/**/presentation/**',
515
+ 'src/**/presentation/**',
516
+ 'src/**/controllers/**',
517
+ 'src/**/http/**',
518
+ ],
519
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
520
+ optional: true,
521
+ },
522
+ {
523
+ name: 'PersistenceAdapters',
524
+ description: 'Per-context infrastructure: repositories, DB, external APIs.',
525
+ patterns: [
526
+ 'src/contexts/**/infrastructure/**',
527
+ 'src/contexts/**/adapters/**',
528
+ 'src/bounded-contexts/**/infrastructure/**',
529
+ 'src/**/infrastructure/**',
530
+ 'src/**/adapters/**',
531
+ 'src/**/persistence/**',
532
+ 'src/**/repositories/**',
533
+ ],
534
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
535
+ optional: true,
536
+ },
537
+ {
538
+ name: 'SharedKernel',
539
+ description: 'Truly shared kernel types and primitives across contexts.',
540
+ patterns: ['src/shared/kernel/**', 'src/shared/**'],
541
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
542
+ optional: true,
543
+ },
544
+ ],
545
+ rules: [
546
+ { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
547
+ { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
548
+ { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
549
+ { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
550
+ { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
551
+ { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
552
+ { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
553
+ { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
554
+ { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
555
+ { from: 'SharedKernel', to: 'DomainModel', allowed: false },
556
+ { from: 'SharedKernel', to: 'ApplicationOrchestration', allowed: false },
557
+ { from: 'SharedKernel', to: 'PresentationAdapters', allowed: false },
558
+ { from: 'SharedKernel', to: 'PersistenceAdapters', allowed: false },
559
+ ...peerIsolationEdges(
560
+ contextLayers,
561
+ sliceFolders,
562
+ 'Bounded contexts must not import each other. Use shared kernel or integration events.'
563
+ ),
564
+ ],
565
+ },
566
+ root
567
+ );
568
+ },
380
569
  };
381
570
 
571
+ // Aliases: Clean / Onion map to the hexagonal factory (same matrix + globs). Avoid dual maintenance.
572
+ ARCHITECTURE_PRESETS['clean-architecture'] = ARCHITECTURE_PRESETS.hexagonal;
573
+ ARCHITECTURE_PRESETS['onion-architecture'] = ARCHITECTURE_PRESETS.hexagonal;
574
+
575
+ /** Stable public preset keys (CLI help, score fit, docs). Order is display order. */
576
+ export const ARCHITECTURE_PRESET_NAMES = Object.keys(ARCHITECTURE_PRESETS);
577
+
382
578
  // ── Layer suggestion engine ──────────────────────────────────────────────────
383
579
  // Everything here is HARVESTED from Ark's own canonical sources — the 11-layer defaults
384
580
  // (DEFAULT_LAYER_DIRECTORIES) and the named presets — so a suggestion can never drift from
@@ -24,6 +24,7 @@ export const MECHANICAL_SAFE_KINDS = [
24
24
  export const KNOWN_FIX_CLASSES = [
25
25
  'file-move',
26
26
  'port-inversion',
27
+ 'cross-slice-boundary',
27
28
  'inject-port',
28
29
  'registered-intent',
29
30
  'add-source-metadata',
@@ -39,6 +40,14 @@ export const KNOWN_FIX_CLASSES = [
39
40
  export function classifyRemediation(violation) {
40
41
  const ruleId = violation?.ruleId;
41
42
  if (ruleId === 'LAYER_IMPORT_VIOLATION') {
43
+ // Cross-slice peer isolation is always judgment (extract shared / events — not mechanical).
44
+ if (violation?.peerIsolation) {
45
+ return {
46
+ class: 'judgment',
47
+ confidence: 0.9,
48
+ rationale: 'peerIsolation blocks cross-slice imports: extract to shared, use events/ports, or redesign ownership — not a mechanical auto-fix.',
49
+ };
50
+ }
42
51
  // Single invariant: runtime module loads are never mechanical-safe.
43
52
  const edgeKind = violation?.edgeKind;
44
53
  if (edgeKind === 'require' || edgeKind === 'dynamic-import') {
@@ -131,6 +140,12 @@ export function enrichViolationWithFixClass(violation) {
131
140
  ? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
132
141
  : 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
133
142
  }
143
+ else if (violation.peerIsolation) {
144
+ enriched.fixClass = 'cross-slice-boundary';
145
+ enriched.effort = 'medium';
146
+ enriched.enthusiastHint =
147
+ 'Cross-slice import blocked (peerIsolation). Do not import another feature/context directly — extract shared code to a shared layer, or coordinate via events/ports. Moving code across slices is a judgment call, not a mechanical auto-fix.';
148
+ }
134
149
  else {
135
150
  enriched.fixClass = 'port-inversion';
136
151
  enriched.effort = 'medium';
@@ -3,7 +3,11 @@
3
3
  */
4
4
  import path from 'node:path';
5
5
  import { DEFAULT_LAYER_DIRECTORIES } from '../ark-shared.mjs';
6
- import { ARCHITECTURE_PRESETS, CANONICAL_LAYER_NAMES } from './presets.mjs';
6
+ import {
7
+ ARCHITECTURE_PRESETS,
8
+ ARCHITECTURE_PRESET_NAMES,
9
+ CANONICAL_LAYER_NAMES,
10
+ } from './presets.mjs';
7
11
 
8
12
  export function dirSegmentsFromGlob(pattern) {
9
13
  return String(pattern)
@@ -31,7 +35,8 @@ export function layerByDir() {
31
35
  // (services→Application, components/pages→Presentation, data/infrastructure→Persistence…)
32
36
  // map cleanly onto the same taxonomy. feature-sliced uses a different vocabulary
33
37
  // (Widgets/Entities/…) that doesn't reduce to the 11, so it's covered by model-fit, not here.
34
- for (const preset of ['hexagonal', 'layered', 'monorepo']) {
38
+ for (const preset of ARCHITECTURE_PRESET_NAMES) {
39
+ // feature-sliced uses a different vocabulary (App/Pages/…) — still harvest dirs.
35
40
  for (const layer of ARCHITECTURE_PRESETS[preset]([]).layers) {
36
41
  if (!CANONICAL_LAYER_NAMES.has(layer.name)) continue;
37
42
  for (const pattern of layer.patterns ?? []) {
@@ -67,7 +72,7 @@ export function suggestLayerForPath(relDir) {
67
72
  // `ark init --preset <name>`. null when nothing lines up.
68
73
  export function detectBestFitModel(dirBasenames) {
69
74
  const present = new Set(dirBasenames);
70
- const scored = ['hexagonal', 'layered', 'feature-sliced', 'monorepo'].map((name) => {
75
+ const scored = ARCHITECTURE_PRESET_NAMES.map((name) => {
71
76
  const segments = new Set();
72
77
  for (const layer of ARCHITECTURE_PRESETS[name]([]).layers) {
73
78
  for (const pattern of layer.patterns ?? []) {
@@ -156,10 +156,64 @@ function layerForRelativePath(relPath, layers) {
156
156
  }
157
157
  return bestName;
158
158
  }
159
- function isEdgeDenied(rules2, from, to) {
160
- if (from === to) return false;
161
- const hit = (rules2 ?? []).find((r) => r.from === from && r.to === to);
162
- return hit?.allowed === false;
159
+ function sliceIdForPath(relPath, sliceFolders) {
160
+ if (!sliceFolders?.length) return void 0;
161
+ const parts = String(relPath).split(/[/\\]/).filter(Boolean);
162
+ const folders = new Set(sliceFolders.map((s) => String(s).toLowerCase()));
163
+ for (let i = 0; i < parts.length - 1; i += 1) {
164
+ if (folders.has(parts[i].toLowerCase())) {
165
+ return `${parts[i]}/${parts[i + 1]}`;
166
+ }
167
+ }
168
+ return void 0;
169
+ }
170
+ function inferSliceFoldersFromPatterns(patterns) {
171
+ const out = /* @__PURE__ */ new Set();
172
+ for (const pattern of patterns ?? []) {
173
+ const glob = normalizeGlobSeparators(String(pattern));
174
+ const parts = glob.split("/").filter(Boolean);
175
+ for (let i = 0; i < parts.length; i += 1) {
176
+ const part = parts[i];
177
+ if ((part === "**" || part === "*") && i > 0) {
178
+ const prev = parts[i - 1];
179
+ if (prev && !prev.includes("*") && !prev.includes("{") && !prev.includes("}")) {
180
+ out.add(prev);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ return [...out];
186
+ }
187
+ function resolveSliceFolders(rule, layerName, layers) {
188
+ if (Array.isArray(rule.sliceFolders) && rule.sliceFolders.length > 0) {
189
+ return rule.sliceFolders.filter((s) => typeof s === "string" && s.length > 0);
190
+ }
191
+ const layer = (layers ?? []).find((l) => l.name === layerName);
192
+ return inferSliceFoldersFromPatterns(layer?.patterns);
193
+ }
194
+ function findDeniedEdgeRule(rules2, from, to, options) {
195
+ for (const rule of rules2 ?? []) {
196
+ if (rule.from !== from || rule.to !== to) continue;
197
+ if (rule.allowed !== false) continue;
198
+ if (rule.peerIsolation) {
199
+ const fromPath = options?.fromPath;
200
+ const toPath = options?.toPath;
201
+ if (!fromPath || !toPath) continue;
202
+ const folders = resolveSliceFolders(rule, from, options?.layers);
203
+ if (folders.length === 0) continue;
204
+ const fromSlice = sliceIdForPath(fromPath, folders);
205
+ const toSlice = sliceIdForPath(toPath, folders);
206
+ if (!fromSlice || !toSlice) continue;
207
+ if (fromSlice !== toSlice) return rule;
208
+ continue;
209
+ }
210
+ if (from === to) continue;
211
+ return rule;
212
+ }
213
+ return void 0;
214
+ }
215
+ function isEdgeDenied(rules2, from, to, options) {
216
+ return findDeniedEdgeRule(rules2, from, to, options) !== void 0;
163
217
  }
164
218
 
165
219
  // src/eslint/index.ts
@@ -302,7 +356,11 @@ var noDomainInfraImports = {
302
356
  if (relTarget.startsWith("..")) return;
303
357
  const toLayer = layerForRelativePath(relTarget, config.layers);
304
358
  if (!toLayer) return;
305
- if (isEdgeDenied(config.rules, fromLayer, toLayer)) {
359
+ if (isEdgeDenied(config.rules, fromLayer, toLayer, {
360
+ fromPath: relFile,
361
+ toPath: relTarget,
362
+ layers: config.layers
363
+ })) {
306
364
  context.report({
307
365
  node,
308
366
  messageId: "forbiddenImport",