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.
- package/CHANGELOG.md +46 -0
- package/README.md +2 -1
- package/bin/ark-check.mjs +2 -2
- package/bin/ark-layer-match.mjs +88 -5
- package/bin/ark-mcp.mjs +7 -70
- package/bin/ark-shared.mjs +88 -8
- package/bin/ark.mjs +3 -2
- package/bin/lib/architecture-scan.mjs +16 -4
- package/bin/lib/config-warnings.mjs +11 -2
- package/bin/lib/doctor-plan.mjs +20 -0
- package/bin/lib/import-resolve.mjs +133 -0
- package/bin/lib/presets.mjs +207 -11
- package/bin/lib/remediation.mjs +15 -0
- package/bin/lib/suggestions.mjs +8 -3
- package/dist/eslint/index.cjs +63 -5
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +33 -1
- package/dist/eslint/index.d.ts +33 -1
- package/dist/eslint/index.js +63 -5
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +103 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -9
- package/dist/index.d.ts +21 -9
- package/dist/index.js +103 -14
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +78 -4
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +78 -4
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +103 -14
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +103 -14
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-CSJhEOk2.d.cts → types-D6Q8WHes.d.cts} +7 -0
- package/dist/{types-CSJhEOk2.d.ts → types-D6Q8WHes.d.ts} +7 -0
- package/docs/agent-guide.md +55 -4
- package/package.json +3 -1
- package/server.json +2 -2
- package/templates/architecture-playbook.json +65 -1
- package/templates/policy-packs/enthusiast-ddd-bounded-contexts.json +19 -0
- package/templates/policy-packs/enthusiast-ui-surface.json +18 -0
- package/templates/policy-packs/enthusiast-vertical-slice.json +18 -0
- package/templates/skills/ark-adopt.md +4 -0
- package/templates/skills/ark-architect.md +5 -1
- package/templates/skills/ark-autopilot.md +3 -1
- package/templates/skills/ark-fix.md +3 -0
- package/templates/skills/ark-place.md +7 -0
- 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
|
+
|
package/bin/lib/presets.mjs
CHANGED
|
@@ -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
|
|
27
|
-
//
|
|
28
|
-
// (
|
|
29
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -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';
|
package/bin/lib/suggestions.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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 ?? []) {
|
package/dist/eslint/index.cjs
CHANGED
|
@@ -156,10 +156,64 @@ function layerForRelativePath(relPath, layers) {
|
|
|
156
156
|
}
|
|
157
157
|
return bestName;
|
|
158
158
|
}
|
|
159
|
-
function
|
|
160
|
-
if (
|
|
161
|
-
const
|
|
162
|
-
|
|
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",
|