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.
@@ -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).