arkgate 2.4.0 → 2.6.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 +194 -3867
- package/bin/ark-layer-match.mjs +168 -0
- package/bin/ark-shared.mjs +8 -131
- package/bin/lib/agent-gates.mjs +1550 -0
- package/bin/lib/doctor-plan.mjs +503 -0
- package/bin/lib/html-report.mjs +1301 -0
- package/bin/lib/presets.mjs +244 -0
- package/bin/lib/suggestions.mjs +109 -0
- package/bin/lib/violations.mjs +170 -0
- package/dist/eslint/index.cjs +263 -23
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +54 -1
- package/dist/eslint/index.d.ts +54 -1
- package/dist/eslint/index.js +245 -22
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/docs/agent-guide.md +4 -3
- package/docs/ai-gates.md +15 -29
- package/package.json +2 -1
- package/server.json +2 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Architecture starter presets for ark-check init/coverage suggestions.
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
applyFrameworkLayoutOverlays,
|
|
6
|
+
createElevenLayerConfig,
|
|
7
|
+
DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
8
|
+
DEFAULT_INTENT_PREFIXES,
|
|
9
|
+
} from '../ark-shared.mjs';
|
|
10
|
+
|
|
11
|
+
export function denyUpward(names) {
|
|
12
|
+
const rules = [];
|
|
13
|
+
for (let i = 0; i < names.length; i += 1) {
|
|
14
|
+
for (let j = i + 1; j < names.length; j += 1) {
|
|
15
|
+
rules.push({ from: names[j], to: names[i], allowed: false });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return rules;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Named starter configs. Globs use `**` so they fit both flat (src/domain/**) and
|
|
22
|
+
// modular (src/modules/x/domain/**) layouts. Every layer is optional, so the strict
|
|
23
|
+
// check passes on a greenfield repo and each layer switches on as its dir gains files.
|
|
24
|
+
//
|
|
25
|
+
// Framework internals live under conventional names like `kernel/` and are NOT application
|
|
26
|
+
// architecture — a broad `src/**/domain/**` would otherwise swallow `src/kernel/domain`
|
|
27
|
+
// (DI/runtime wiring) and fire domain-purity rules on it, the false-positive class that
|
|
28
|
+
// motivated `exclude`. Carve those out of every wildcard preset layer by default; a config
|
|
29
|
+
// author who really does keep app code under kernel/ can drop the exclude.
|
|
30
|
+
export const FRAMEWORK_INTERNAL_EXCLUDE = ['**/kernel/**'];
|
|
31
|
+
export function presetWithOverlays(baseConfig, root) {
|
|
32
|
+
if (!root) return baseConfig;
|
|
33
|
+
return applyFrameworkLayoutOverlays(baseConfig, root);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const ARCHITECTURE_PRESETS = {
|
|
37
|
+
// Second arg `root` is optional — when provided (init/start on a real repo), framework
|
|
38
|
+
// filename conventions (Nest/Next/express) are overlaid so starters get real governed%.
|
|
39
|
+
hexagonal: (_workspaces, root) =>
|
|
40
|
+
presetWithOverlays(
|
|
41
|
+
{
|
|
42
|
+
include: ['src'],
|
|
43
|
+
layers: [
|
|
44
|
+
{
|
|
45
|
+
name: 'DomainModel',
|
|
46
|
+
description: 'Pure business rules and entities. No I/O, no framework, no ambient globals.',
|
|
47
|
+
patterns: ['src/**/domain/**'],
|
|
48
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
49
|
+
forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
50
|
+
optional: true,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'ApplicationOrchestration',
|
|
54
|
+
description: 'Use cases that coordinate the domain through ports. No I/O of its own.',
|
|
55
|
+
patterns: ['src/**/application/**'],
|
|
56
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
57
|
+
optional: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'PresentationAdapters',
|
|
61
|
+
description: 'Entrypoints — HTTP routes, controllers, UI. Drives use cases.',
|
|
62
|
+
patterns: [
|
|
63
|
+
'src/**/presentation/**',
|
|
64
|
+
'src/**/controllers/**',
|
|
65
|
+
'src/**/interface-adapters/**',
|
|
66
|
+
'src/**/http/**',
|
|
67
|
+
],
|
|
68
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
69
|
+
optional: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'PersistenceAdapters',
|
|
73
|
+
description: 'Implements ports with real infrastructure: DB, external APIs, filesystem.',
|
|
74
|
+
patterns: [
|
|
75
|
+
'src/**/infrastructure/**',
|
|
76
|
+
'src/**/adapters/**',
|
|
77
|
+
'src/**/persistence/**',
|
|
78
|
+
'src/**/repositories/**',
|
|
79
|
+
],
|
|
80
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
81
|
+
optional: true,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
rules: [
|
|
85
|
+
{ from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
|
|
86
|
+
{ from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
|
|
87
|
+
{ from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
|
|
88
|
+
{ from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
|
|
89
|
+
{ from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
|
|
90
|
+
{ from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
|
|
91
|
+
{ from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
|
|
92
|
+
{ from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
|
|
93
|
+
{ from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
root
|
|
97
|
+
),
|
|
98
|
+
layered: (_workspaces, root) =>
|
|
99
|
+
presetWithOverlays(
|
|
100
|
+
{
|
|
101
|
+
include: ['src'],
|
|
102
|
+
layers: [
|
|
103
|
+
{
|
|
104
|
+
name: 'PresentationAdapters',
|
|
105
|
+
description: 'UI and API entrypoints.',
|
|
106
|
+
patterns: [
|
|
107
|
+
'src/**/presentation/**',
|
|
108
|
+
'src/**/controllers/**',
|
|
109
|
+
'src/**/ui/**',
|
|
110
|
+
'src/**/http/**',
|
|
111
|
+
],
|
|
112
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
113
|
+
optional: true,
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'ApplicationOrchestration',
|
|
117
|
+
description: 'Business services and use-case coordination.',
|
|
118
|
+
patterns: ['src/**/application/**', 'src/**/services/**'],
|
|
119
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
120
|
+
optional: true,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'DomainModel',
|
|
124
|
+
description: 'Pure business rules and entities. No I/O, no framework, no ambient globals.',
|
|
125
|
+
patterns: ['src/**/domain/**'],
|
|
126
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
127
|
+
forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
128
|
+
optional: true,
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'PersistenceAdapters',
|
|
132
|
+
description: 'Data access and infrastructure.',
|
|
133
|
+
patterns: [
|
|
134
|
+
'src/**/persistence/**',
|
|
135
|
+
'src/**/data/**',
|
|
136
|
+
'src/**/repositories/**',
|
|
137
|
+
'src/**/infrastructure/**',
|
|
138
|
+
],
|
|
139
|
+
exclude: FRAMEWORK_INTERNAL_EXCLUDE,
|
|
140
|
+
optional: true,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
rules: denyUpward([
|
|
144
|
+
'PresentationAdapters',
|
|
145
|
+
'ApplicationOrchestration',
|
|
146
|
+
'DomainModel',
|
|
147
|
+
'PersistenceAdapters',
|
|
148
|
+
]),
|
|
149
|
+
},
|
|
150
|
+
root
|
|
151
|
+
),
|
|
152
|
+
'feature-sliced': (_workspaces, root) => {
|
|
153
|
+
const order = ['App', 'Pages', 'Widgets', 'Features', 'Entities', 'Shared'];
|
|
154
|
+
const purpose = {
|
|
155
|
+
App: 'App-wide setup, providers, and routing.',
|
|
156
|
+
Pages: 'Route-level compositions.',
|
|
157
|
+
Widgets: 'Self-contained UI blocks composed from features and entities.',
|
|
158
|
+
Features: 'User-facing feature units.',
|
|
159
|
+
Entities: 'Business entities with their UI and logic.',
|
|
160
|
+
Shared: 'Reusable primitives with no business knowledge.',
|
|
161
|
+
};
|
|
162
|
+
return presetWithOverlays(
|
|
163
|
+
{
|
|
164
|
+
include: ['src'],
|
|
165
|
+
layers: order.map((name) => ({
|
|
166
|
+
name,
|
|
167
|
+
description: purpose[name],
|
|
168
|
+
patterns: [`src/${name.toLowerCase()}/**`],
|
|
169
|
+
optional: true,
|
|
170
|
+
})),
|
|
171
|
+
rules: denyUpward(order),
|
|
172
|
+
},
|
|
173
|
+
root
|
|
174
|
+
);
|
|
175
|
+
},
|
|
176
|
+
// Cross-package profile for workspace monorepos. Patterns match by directory NAME
|
|
177
|
+
// anywhere in the tree (`**/domain/**` hits packages/x/domain AND apps/y/src/domain),
|
|
178
|
+
// so one profile governs every package. include defaults to the detected workspace
|
|
179
|
+
// roots (falls back to packages+apps). Naming varies by repo — adjust and re-check.
|
|
180
|
+
monorepo: (includeDirs, root) =>
|
|
181
|
+
presetWithOverlays(
|
|
182
|
+
{
|
|
183
|
+
include: includeDirs && includeDirs.length > 0 ? includeDirs : ['packages', 'apps'],
|
|
184
|
+
layers: [
|
|
185
|
+
{
|
|
186
|
+
name: 'DomainModel',
|
|
187
|
+
description:
|
|
188
|
+
'Pure business rules and entities, in any package. No I/O, no framework, no ambient globals.',
|
|
189
|
+
patterns: ['**/domain/**', '**/entities/**'],
|
|
190
|
+
forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
|
|
191
|
+
optional: true,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'ApplicationOrchestration',
|
|
195
|
+
description: 'Use cases and services that coordinate the domain through ports.',
|
|
196
|
+
patterns: ['**/application/**', '**/use-cases/**', '**/services/**'],
|
|
197
|
+
optional: true,
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'PresentationAdapters',
|
|
201
|
+
description: 'Entrypoints — HTTP routes, controllers, UI, framework app/pages dirs.',
|
|
202
|
+
patterns: [
|
|
203
|
+
'**/app/**',
|
|
204
|
+
'**/pages/**',
|
|
205
|
+
'**/components/**',
|
|
206
|
+
'**/controllers/**',
|
|
207
|
+
'**/http/**',
|
|
208
|
+
'**/routes/**',
|
|
209
|
+
],
|
|
210
|
+
optional: true,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'PersistenceAdapters',
|
|
214
|
+
description: 'Implements ports with real infrastructure: DB, external APIs, filesystem.',
|
|
215
|
+
patterns: [
|
|
216
|
+
'**/infrastructure/**',
|
|
217
|
+
'**/adapters/**',
|
|
218
|
+
'**/persistence/**',
|
|
219
|
+
'**/repositories/**',
|
|
220
|
+
],
|
|
221
|
+
optional: true,
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
rules: [
|
|
225
|
+
{ from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
|
|
226
|
+
{ from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
|
|
227
|
+
{ from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
|
|
228
|
+
{ from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
|
|
229
|
+
{ from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
|
|
230
|
+
{ from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
root
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// ── Layer suggestion engine ──────────────────────────────────────────────────
|
|
238
|
+
// Everything here is HARVESTED from Ark's own canonical sources — the 11-layer defaults
|
|
239
|
+
// (DEFAULT_LAYER_DIRECTORIES) and the named presets — so a suggestion can never drift from
|
|
240
|
+
// what the gate actually enforces. No ad-hoc directory heuristics: a directory Ark doesn't
|
|
241
|
+
// already know about is reported as "unrecognized — you classify", never guessed. This is
|
|
242
|
+
// what lets `init`/`--coverage` PROPOSE where ungoverned code belongs instead of silently
|
|
243
|
+
// leaving the majority of a repo ungoverned behind a false-green check.
|
|
244
|
+
export const CANONICAL_LAYER_NAMES = new Set(DEFAULT_INTENT_PREFIXES.map((entry) => entry.layer));
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unclassified-path layer suggestions for coverage/doctor.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DEFAULT_LAYER_DIRECTORIES } from '../ark-shared.mjs';
|
|
6
|
+
import { ARCHITECTURE_PRESETS, CANONICAL_LAYER_NAMES } from './presets.mjs';
|
|
7
|
+
|
|
8
|
+
export function dirSegmentsFromGlob(pattern) {
|
|
9
|
+
return String(pattern)
|
|
10
|
+
.split('/')
|
|
11
|
+
.filter((segment) => segment && !segment.includes('*'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let _layerByDir;
|
|
15
|
+
// Map<dirBasename, string[] layers>. A basename mapping to >1 layer (e.g. `app` — Application
|
|
16
|
+
// orchestration in the 11-layer defaults, but Presentation in the monorepo/Next preset) is
|
|
17
|
+
// genuinely ambiguous; every candidate is surfaced rather than silently picked.
|
|
18
|
+
export function layerByDir() {
|
|
19
|
+
if (_layerByDir) return _layerByDir;
|
|
20
|
+
const map = new Map();
|
|
21
|
+
const add = (segment, layer) => {
|
|
22
|
+
if (!segment) return;
|
|
23
|
+
const existing = map.get(segment) ?? [];
|
|
24
|
+
if (!existing.includes(layer)) existing.push(layer);
|
|
25
|
+
map.set(segment, existing);
|
|
26
|
+
};
|
|
27
|
+
for (const [layer, dirs] of Object.entries(DEFAULT_LAYER_DIRECTORIES)) {
|
|
28
|
+
for (const dir of dirs) add(dirSegmentsFromGlob(dir).pop(), layer);
|
|
29
|
+
}
|
|
30
|
+
// The canonical-named presets reuse the 11 layer names, so their directory synonyms
|
|
31
|
+
// (services→Application, components/pages→Presentation, data/infrastructure→Persistence…)
|
|
32
|
+
// map cleanly onto the same taxonomy. feature-sliced uses a different vocabulary
|
|
33
|
+
// (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']) {
|
|
35
|
+
for (const layer of ARCHITECTURE_PRESETS[preset]([]).layers) {
|
|
36
|
+
if (!CANONICAL_LAYER_NAMES.has(layer.name)) continue;
|
|
37
|
+
for (const pattern of layer.patterns ?? []) {
|
|
38
|
+
add(dirSegmentsFromGlob(pattern).pop(), layer.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
_layerByDir = map;
|
|
43
|
+
return map;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Suggest a canonical layer for a directory by its basename. null when Ark doesn't recognize
|
|
47
|
+
// it (the honest "you classify this" case), else { layer, alternatives }.
|
|
48
|
+
export function suggestLayerForDir(name) {
|
|
49
|
+
const layers = layerByDir().get(name);
|
|
50
|
+
if (!layers || layers.length === 0) return null;
|
|
51
|
+
return { layer: layers[0], alternatives: layers.slice(1) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Suggest a layer for a directory PATH by finding the deepest segment Ark recognizes, so
|
|
55
|
+
// `src/lib/repositories` proposes PersistenceAdapters even though `lib` itself is unknown.
|
|
56
|
+
export function suggestLayerForPath(relDir) {
|
|
57
|
+
const segments = relDir.split('/').filter(Boolean);
|
|
58
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
59
|
+
const hit = suggestLayerForDir(segments[i]);
|
|
60
|
+
if (hit) return { ...hit, matchedDir: segments[i] };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Which starter model does this set of directory basenames most resemble? Scored purely by
|
|
66
|
+
// how many of the repo's directories each preset's patterns recognize — a hint toward
|
|
67
|
+
// `ark init --preset <name>`. null when nothing lines up.
|
|
68
|
+
export function detectBestFitModel(dirBasenames) {
|
|
69
|
+
const present = new Set(dirBasenames);
|
|
70
|
+
const scored = ['hexagonal', 'layered', 'feature-sliced', 'monorepo'].map((name) => {
|
|
71
|
+
const segments = new Set();
|
|
72
|
+
for (const layer of ARCHITECTURE_PRESETS[name]([]).layers) {
|
|
73
|
+
for (const pattern of layer.patterns ?? []) {
|
|
74
|
+
const seg = dirSegmentsFromGlob(pattern).pop();
|
|
75
|
+
if (seg) segments.add(seg);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let hits = 0;
|
|
79
|
+
for (const dir of present) if (segments.has(dir)) hits += 1;
|
|
80
|
+
return { name, hits };
|
|
81
|
+
});
|
|
82
|
+
scored.sort((a, b) => b.hits - a.hits);
|
|
83
|
+
return scored[0].hits > 0 ? scored[0] : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Group ungoverned files by their parent directory and attach a proposed layer (or the
|
|
87
|
+
// honest "unrecognized"). The single source the coverage report and init both format.
|
|
88
|
+
export function buildUnclassifiedSuggestions(unclassifiedRelFiles) {
|
|
89
|
+
const byDir = new Map();
|
|
90
|
+
for (const rel of unclassifiedRelFiles) {
|
|
91
|
+
const dir = rel.split('/').slice(0, -1).join('/') || '.';
|
|
92
|
+
byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
return [...byDir.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([dir, files]) => {
|
|
95
|
+
const hit = suggestLayerForPath(dir);
|
|
96
|
+
return hit
|
|
97
|
+
? {
|
|
98
|
+
dir,
|
|
99
|
+
files,
|
|
100
|
+
layer: hit.layer,
|
|
101
|
+
...(hit.alternatives.length > 0 ? { alternatives: hit.alternatives } : {}),
|
|
102
|
+
}
|
|
103
|
+
: { dir, files, unrecognized: true };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// For `init`: propose a layer for every ungoverned top-level directory, descending one level
|
|
108
|
+
// into unrecognized ones so `lib/repositories`, `lib/db` etc. still get a concrete proposal
|
|
109
|
+
// instead of a blanket "lib is ungoverned".
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
|
|
5
|
+
const color = {
|
|
6
|
+
red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
|
|
7
|
+
yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
|
|
8
|
+
green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
|
|
9
|
+
dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
|
10
|
+
bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function baselineKey(violation) {
|
|
14
|
+
return [
|
|
15
|
+
violation.ruleId,
|
|
16
|
+
violation.file,
|
|
17
|
+
violation.fromLayer ?? '',
|
|
18
|
+
violation.toLayer ?? '',
|
|
19
|
+
violation.target ?? '',
|
|
20
|
+
].join('|');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readBaseline(root, baselinePath) {
|
|
24
|
+
const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
|
|
25
|
+
if (!fs.existsSync(fullPath)) return { keys: new Set(), fullPath, exists: false };
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
|
|
27
|
+
return { keys: new Set(raw.violations ?? []), fullPath, exists: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function writeBaseline(root, baselinePath, violations) {
|
|
31
|
+
const fullPath = path.isAbsolute(baselinePath) ? baselinePath : path.join(root, baselinePath);
|
|
32
|
+
const keys = [...new Set(violations.map(baselineKey))].sort();
|
|
33
|
+
fs.writeFileSync(
|
|
34
|
+
fullPath,
|
|
35
|
+
`${JSON.stringify({ version: 1, note: 'Frozen ark-check violations. Only NEW violations fail --baseline runs. Regenerate with: ark-check --update-baseline', violations: keys }, null, 2)}\n`
|
|
36
|
+
);
|
|
37
|
+
return { fullPath, count: keys.length };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const FIX_HINTS = {
|
|
41
|
+
LAYER_IMPORT_VIOLATION:
|
|
42
|
+
'Depend on a port/interface owned by an inner layer instead, or move this code to a layer allowed to make this import.',
|
|
43
|
+
LAYER_INTENT_REFERENCE_VIOLATION:
|
|
44
|
+
'Reference intents through a layer that owns them (e.g. subscribe from an adapter, not from the domain).',
|
|
45
|
+
RAW_EVENT_PUBLISH:
|
|
46
|
+
'Define the intent with ark.registry.define(...) and publish through the returned creator.',
|
|
47
|
+
PUBLISH_MISSING_SOURCE:
|
|
48
|
+
'Add metadata.source (the publishing intent name) to the publish call.',
|
|
49
|
+
PUBLISH_SOURCE_LAYER_MISMATCH:
|
|
50
|
+
'Use a source intent that belongs to the same layer as the publishing file, or move the file.',
|
|
51
|
+
FORBIDDEN_GLOBAL:
|
|
52
|
+
'Inject the capability through a port (e.g. a Clock, IdGenerator, or HttpPort) instead of reaching for the ambient global.',
|
|
53
|
+
CIRCULAR_DEPENDENCY:
|
|
54
|
+
'Break the cycle: extract the shared code into a module both sides import, invert one edge behind a port/interface, or merge the files if they are really one unit.',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function printViolation(violation) {
|
|
58
|
+
const location = `${violation.file}:${violation.line}`;
|
|
59
|
+
console.error(`${color.red('✖')} ${color.bold(violation.ruleId)} ${location}`);
|
|
60
|
+
if (violation.fromLayer && violation.toLayer) {
|
|
61
|
+
const target = violation.target ? ` ${color.dim(`(${violation.target})`)}` : '';
|
|
62
|
+
console.error(` ${violation.fromLayer} → ${violation.toLayer}${target}`);
|
|
63
|
+
}
|
|
64
|
+
console.error(` ${violation.message}`);
|
|
65
|
+
const hint = FIX_HINTS[violation.ruleId];
|
|
66
|
+
if (hint) console.error(` ${color.dim(`fix: ${hint}`)}`);
|
|
67
|
+
console.error('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Violation diagnosis ──────────────────────────────────────────────────────
|
|
71
|
+
// Groups violations by their layer EDGE (and target subtree) so a wall of N violations reads
|
|
72
|
+
// as "M distinct problems, ranked by size" — the burn-down order. The killer signal: when
|
|
73
|
+
// one edge dominates, the CONTRACT is usually wrong, not the code (e.g. every API route
|
|
74
|
+
// importing the kernel through a sanctioned entrypoint). Freezing that as "debt" buries a
|
|
75
|
+
// config fix behind a baseline, so --update-baseline refuses a lopsided freeze (see guard).
|
|
76
|
+
export const CONCENTRATION_MIN_VIOLATIONS = 10;
|
|
77
|
+
export const CONCENTRATION_SHARE = 0.9;
|
|
78
|
+
|
|
79
|
+
export function violationEdge(violation) {
|
|
80
|
+
if (violation.ruleId === 'CIRCULAR_DEPENDENCY') return 'circular dependency';
|
|
81
|
+
if (violation.ruleId === 'FORBIDDEN_GLOBAL') return `${violation.fromLayer ?? '?'} → ambient global`;
|
|
82
|
+
if (violation.fromLayer && violation.toLayer) return `${violation.fromLayer} → ${violation.toLayer}`;
|
|
83
|
+
return violation.ruleId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The directory the offending import lands in — the signal for "where does this edge go?".
|
|
87
|
+
// For a LAYER_IMPORT_VIOLATION the target is a resolved file path; cluster by its dir prefix
|
|
88
|
+
// so `kernel/internal/x` and `kernel/internal/y` collapse to one "into kernel/internal/".
|
|
89
|
+
export function violationTargetSubtree(violation) {
|
|
90
|
+
if (!violation.target || typeof violation.target !== 'string' || !violation.target.includes('/')) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const segments = violation.target.split('/');
|
|
94
|
+
return segments.slice(0, Math.min(3, segments.length - 1)).join('/');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function summarizeViolations(violations) {
|
|
98
|
+
const byEdge = new Map();
|
|
99
|
+
let typeOnly = 0;
|
|
100
|
+
for (const violation of violations) {
|
|
101
|
+
if (violation.typeOnly) typeOnly += 1;
|
|
102
|
+
const key = violationEdge(violation);
|
|
103
|
+
const entry = byEdge.get(key) ?? { edge: key, count: 0, typeOnly: 0, targets: new Map() };
|
|
104
|
+
entry.count += 1;
|
|
105
|
+
if (violation.typeOnly) entry.typeOnly += 1;
|
|
106
|
+
const subtree = violationTargetSubtree(violation);
|
|
107
|
+
if (subtree) entry.targets.set(subtree, (entry.targets.get(subtree) ?? 0) + 1);
|
|
108
|
+
byEdge.set(key, entry);
|
|
109
|
+
}
|
|
110
|
+
const edges = [...byEdge.values()]
|
|
111
|
+
.map((entry) => ({
|
|
112
|
+
edge: entry.edge,
|
|
113
|
+
count: entry.count,
|
|
114
|
+
typeOnly: entry.typeOnly,
|
|
115
|
+
topTargets: [...entry.targets.entries()]
|
|
116
|
+
.sort((a, b) => b[1] - a[1])
|
|
117
|
+
.slice(0, 4)
|
|
118
|
+
.map(([dir, count]) => ({ dir, count })),
|
|
119
|
+
}))
|
|
120
|
+
.sort((a, b) => b.count - a.count);
|
|
121
|
+
const total = violations.length;
|
|
122
|
+
const dominant = edges[0];
|
|
123
|
+
const dominantShare = total > 0 && dominant ? dominant.count / total : 0;
|
|
124
|
+
return {
|
|
125
|
+
total,
|
|
126
|
+
// Value edges are real runtime coupling; type-only edges (erased at compile time) are
|
|
127
|
+
// just type placement — fix the value ones first, the type-only ones move with the type.
|
|
128
|
+
valueCount: total - typeOnly,
|
|
129
|
+
typeOnlyCount: typeOnly,
|
|
130
|
+
edges,
|
|
131
|
+
dominant: dominant ? dominant.edge : undefined,
|
|
132
|
+
dominantShare,
|
|
133
|
+
concentrated: total >= CONCENTRATION_MIN_VIOLATIONS && dominantShare >= CONCENTRATION_SHARE,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function printViolationBreakdown(summary, { toStderr = false } = {}) {
|
|
138
|
+
const out = toStderr ? (line) => console.error(line) : (line) => console.log(line);
|
|
139
|
+
out('');
|
|
140
|
+
out(`Violation breakdown — ${summary.total} across ${summary.edges.length} edge(s), largest first:`);
|
|
141
|
+
if (summary.typeOnlyCount > 0) {
|
|
142
|
+
out(
|
|
143
|
+
` ${summary.valueCount} value (runtime coupling — fix first) · ${summary.typeOnlyCount} type-only (type placement — moves with the type)`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
for (const edge of summary.edges) {
|
|
147
|
+
const pct = Math.round((edge.count / summary.total) * 100);
|
|
148
|
+
const typeNote = edge.typeOnly > 0 ? `, ${edge.typeOnly} type-only` : '';
|
|
149
|
+
out(` ${String(edge.count).padStart(5)} ${edge.edge} (${pct}%${typeNote})`);
|
|
150
|
+
for (const target of edge.topTargets) {
|
|
151
|
+
out(` ↳ ${target.count}× into ${target.dir}/`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (summary.concentrated) {
|
|
155
|
+
out('');
|
|
156
|
+
out(`⚠ ${Math.round(summary.dominantShare * 100)}% of violations are a SINGLE edge: ${summary.dominant}.`);
|
|
157
|
+
out(' That usually means the CONTRACT is wrong, not the code — e.g. app-land reaching a');
|
|
158
|
+
out(' framework/kernel through a sanctioned entrypoint. Before treating it as debt:');
|
|
159
|
+
out(' • If the edge is intended, allow it — or split the target layer into a public');
|
|
160
|
+
out(' surface app-land may import + internals it may not (see the target dirs above');
|
|
161
|
+
out(' to find the surface). Do it via /ark-contract.');
|
|
162
|
+
out(' • Only the minority hitting real internals is genuine debt for /ark-fix.');
|
|
163
|
+
out(` Fixing the contract clears ~${summary.edges[0].count} of ${summary.total} at once.`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Finds strongly-connected components in the resolved import graph. Any component
|
|
168
|
+
// with more than one file is a set of files that transitively import each other —
|
|
169
|
+
// a circular dependency. One violation per component keeps the output minimal and
|
|
170
|
+
// the baseline key stable (anchored at the alphabetically-first member).
|