arkgate 2.8.2 → 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 +75 -2
- package/README.md +6 -2
- package/bin/ark-check.mjs +10 -2
- package/bin/ark-layer-match.mjs +88 -5
- package/bin/ark-mcp.mjs +7 -70
- package/bin/ark-shared.mjs +89 -9
- package/bin/ark.mjs +8 -2
- package/bin/lib/agent-gates.mjs +104 -20
- package/bin/lib/architecture-scan.mjs +16 -4
- package/bin/lib/config-warnings.mjs +12 -3
- package/bin/lib/core-ratchet.mjs +152 -0
- 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/docs/brownfield-adoption.md +1 -1
- package/docs/package-surface.md +3 -0
- package/package.json +4 -2
- 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 +6 -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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,79 @@ All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are do
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.9.0 — 2026-07-09
|
|
8
|
+
|
|
9
|
+
Track P: slice isolation, vertical-slice + DDD presets, skill surface, and adoption depth.
|
|
10
|
+
**No intentional CLI flag or JSON shape breaks** for existing presets; new rules/presets are opt-in.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`peerIsolation` edge rules (P0):** opt-in cross-slice bans
|
|
15
|
+
(e.g. `features/auth` ↛ `features/payments`). Optional `sliceFolders`.
|
|
16
|
+
Wired in `ark-check`, ESLint, write-gate, remediation (`cross-slice-boundary`, judgment).
|
|
17
|
+
- **`vertical-slice` preset (P2):** Features / Shared / Lib / App with peerIsolation on
|
|
18
|
+
Features. `ark init --preset vertical-slice`. CLI help and fit scoring include all
|
|
19
|
+
public presets (`ui-surface` documented).
|
|
20
|
+
- **P3 vertical-slice adoption surface:** playbook archetype `vertical-slice-product`,
|
|
21
|
+
signal `verticalSliceLayout`, policy pack `enthusiast-vertical-slice`, gallery
|
|
22
|
+
`examples/vertical-slice-starter/` (strict-config green).
|
|
23
|
+
- **P4 `ddd-bounded-contexts` preset:** contexts/*/domain|application|presentation|infra +
|
|
24
|
+
SharedKernel; peerIsolation matrix blocks **any** cross-context import (same or
|
|
25
|
+
cross technical layer). Archetype, pack, gallery starter.
|
|
26
|
+
- **Skills (S1/S3):** architect/place/fix/adopt/autopilot know vertical-slice + DDD;
|
|
27
|
+
new host-only `/ark-think` skill (no package LLM). Refresh installs with
|
|
28
|
+
`ark-check --install-agent-gates --skills-only --force`.
|
|
29
|
+
- **Eval corpus (S5):** `eval/cases/vertical-slice-cross-feature` labeled peerIsolation case
|
|
30
|
+
(`cross-slice-boundary` fixClass, judgment).
|
|
31
|
+
- **S2 recommend/doctor:** JSON/human output includes `galleryStarter` + `policyPack`; wizard
|
|
32
|
+
choices for vertical-slice and DDD; doctor new-here lines for Nest modular and monorepo tooling.
|
|
33
|
+
- **P5 monorepo depth:** default include falls back to `packages`/`apps`/`libs`; detect
|
|
34
|
+
`turbo.json` / `nx.json`; playbook boosts multi-app-workspace on monorepo tooling.
|
|
35
|
+
- **P6 FSD patterns:** feature-sliced accepts `src/<layer>/**` and root `<layer>/**` (app/pages).
|
|
36
|
+
- **P7 aliases:** `clean-architecture` and `onion-architecture` → hexagonal factory.
|
|
37
|
+
- **P8 Nest guidance:** agent-guide + doctor tip (hexagonal vs ddd-bounded-contexts).
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Same-layer deny semantics (locked):** classic `{ allowed: false }` without
|
|
42
|
+
`peerIsolation` never blocks same-layer edges (historical short-circuit restored /
|
|
43
|
+
confirmed). Only `peerIsolation: true` may deny, and only when slice ids differ.
|
|
44
|
+
- **`peerIsolation` applies cross-layer too:** when set, deny only if slices differ
|
|
45
|
+
(enables honest DDD inter-context isolation for e.g. application→domain across contexts).
|
|
46
|
+
- **`FRAMEWORK_INTERNAL_EXCLUDE`:** `src/kernel/**` + `**/src/kernel/**` only — no longer
|
|
47
|
+
`**/kernel/**` (which carved out `src/shared/kernel/**`).
|
|
48
|
+
- **Write-gate import resolve:** single `resolveImportTarget` primitive in
|
|
49
|
+
`bin/lib/import-resolve.mjs`; `ark-mcp` entry stays under 1000 LOC.
|
|
50
|
+
- **Gallery starters:** `npm run check:gallery-starters` fails on factory drift;
|
|
51
|
+
`generate:gallery-starters` rewrites configs from presets.
|
|
52
|
+
|
|
53
|
+
## 2.8.3 — 2026-07-09
|
|
54
|
+
|
|
55
|
+
Field residuals + official site: core ratchet to honest ENFORCE, typecheck bootstrap,
|
|
56
|
+
host-token scrub, arkgate.online homepage. **No intentional CLI flag or JSON shape breaks.**
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **`--ratchet-cores`:** when architecture is green (0 active violations, governed ≥ 50%),
|
|
61
|
+
set `optional: false` on **populated** core layers only so doctor can report **ENFORCE**
|
|
62
|
+
honestly. Empty cores stay optional (no false-ENFORCE theatre). Doctor core-optional gaps
|
|
63
|
+
point at this command; `/ark-autopilot` documents the step after goal.met.
|
|
64
|
+
- **Typecheck bootstrap:** `ark start` / `--install-agent-gates` add `"typecheck": "tsc --noEmit"`
|
|
65
|
+
when `tsconfig.json`/`jsconfig.json` exists and no typecheck-like script is present; generated
|
|
66
|
+
CI includes the typecheck step. Existing scripts are never overwritten.
|
|
67
|
+
|
|
68
|
+
### Changed
|
|
69
|
+
|
|
70
|
+
- **Official website:** product homepage is [arkgate.online](https://www.arkgate.online/)
|
|
71
|
+
(`package.json` `homepage`, README badges/footer, ROADMAP identity). npm package page
|
|
72
|
+
and GitHub remain source + distribution links.
|
|
73
|
+
- **Repo hygiene:** scrub named field-probe host identities from docs, comments, tests, and
|
|
74
|
+
historical changelog wording; fixtures stay framework-generic (Nest/Next only).
|
|
75
|
+
- **Maintainability:** `--ratchet-cores` lives in `bin/lib/core-ratchet.mjs` (not the ark-check
|
|
76
|
+
entry); typecheck detection uses shared `packageScriptsHaveTypecheck` (not full deploy-path
|
|
77
|
+
scan); typecheck bootstrap is skipped under `--skills-only`.
|
|
78
|
+
|
|
79
|
+
|
|
7
80
|
## 2.8.2 — 2026-07-09
|
|
8
81
|
|
|
9
82
|
Field-honesty patch (Next/UI host probe): no Nest false positives, no false ENFORCE on
|
|
@@ -112,7 +185,7 @@ shape breaks** for the gate/co-pilot path.
|
|
|
112
185
|
|
|
113
186
|
## 2.6.1 — 2026-07-09
|
|
114
187
|
|
|
115
|
-
Field-test release: Next/monorepo honesty (
|
|
188
|
+
Field-test release: Next/monorepo honesty (frontend monorepo hosts), simplified **one-flow** UX for
|
|
116
189
|
humans and autonomous agents, and skills that require real source remediation—not CLI paraphrase.
|
|
117
190
|
|
|
118
191
|
### Fixed — false greens & strict CI noise (Next / monorepo)
|
|
@@ -160,7 +233,7 @@ Templates under `templates/skills/` (and project `.grok/skills` copies) for at l
|
|
|
160
233
|
|
|
161
234
|
### Tests
|
|
162
235
|
|
|
163
|
-
- Fixture-style unit tests drive real `bin/ark-check.mjs`: Next core governance;
|
|
236
|
+
- Fixture-style unit tests drive real `bin/ark-check.mjs`: Next core governance; frontend monorepo-like
|
|
164
237
|
monorepo (`frontend` + `core/**/types.ts` not Domain); strict-config with dead globs;
|
|
165
238
|
empty baseline removal.
|
|
166
239
|
|
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
Your AI writes most of the code. ArkGate keeps that code inside an architecture you can trust —
|
|
8
8
|
and makes sure a “green” check means something real.
|
|
9
9
|
|
|
10
|
+
[](https://www.arkgate.online/)
|
|
10
11
|
[](https://github.com/pedroknigge/arkgate/actions/workflows/ci.yml)
|
|
11
12
|
[](https://www.npmjs.com/package/arkgate)
|
|
12
13
|
[](LICENSE)
|
|
@@ -152,6 +153,7 @@ Install once: `npx arkgate-check --install-agent-gates`
|
|
|
152
153
|
| Deep brownfield / manifest mining alone | `/ark-adopt` |
|
|
153
154
|
| New file placement | `/ark-place` |
|
|
154
155
|
| Gate violation on a change | `/ark-fix` |
|
|
156
|
+
| Design trade-offs within the contract (no package LLM) | `/ark-think` |
|
|
155
157
|
| Edit `ark.config.json` safely | `/ark-contract` |
|
|
156
158
|
| Plain-language tour of the report | `/ark-explain` |
|
|
157
159
|
| Deep “what am I not using?” audit | `/ark-coverage` |
|
|
@@ -172,7 +174,7 @@ ark.config.json
|
|
|
172
174
|
└─► Runtime kernel (opt-in) — only if you call it
|
|
173
175
|
```
|
|
174
176
|
|
|
175
|
-
- **Presets:** hexagonal, layered, feature-sliced, monorepo (
|
|
177
|
+
- **Presets:** hexagonal, layered, feature-sliced, monorepo, ui-surface, vertical-slice, ddd-bounded-contexts (+ aliases clean-architecture / onion-architecture). Layers start optional; doctor suggests tightening populated cores. Cross-slice / cross-context bans use optional `peerIsolation` rules.
|
|
176
178
|
- **Frameworks:** Nest / Next / express / library layouts get sensible globs on init so day-one coverage is real.
|
|
177
179
|
- **Brownfield:** baseline ratchet, refuse to freeze a wrong contract, `/ark-adopt` for mature trees.
|
|
178
180
|
- **Agents:** skills install into Claude / Cursor / Codex / Grok; `ark start` freezes an origin report under `.ark/reports/`.
|
|
@@ -267,10 +269,12 @@ npm run typecheck
|
|
|
267
269
|
npm run check:architecture # Ark gates itself
|
|
268
270
|
```
|
|
269
271
|
|
|
272
|
+
**Website:** [arkgate.online](https://www.arkgate.online/)
|
|
270
273
|
**npm:** [`arkgate`](https://www.npmjs.com/package/arkgate) · formerly `ark-runtime-kernel`
|
|
271
274
|
**Product:** **ArkGate** — architecture co-pilot / gate for AI TypeScript (not a runtime kernel).
|
|
272
275
|
CLI: `arkgate` · `arkgate-check` · `arkgate-mcp` (aliases `ark` / `ark-check` / `ark-mcp` still work for one major).
|
|
273
|
-
MCP registry: `io.github.pedroknigge/arkgate`.
|
|
276
|
+
MCP registry: `io.github.pedroknigge/arkgate`.
|
|
277
|
+
**Source:** [github.com/pedroknigge/arkgate](https://github.com/pedroknigge/arkgate)
|
|
274
278
|
|
|
275
279
|
Node ≥ 18 · **MIT**.
|
|
276
280
|
|
package/bin/ark-check.mjs
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
runPlan,
|
|
61
61
|
runDoctor,
|
|
62
62
|
} from './lib/doctor-plan.mjs';
|
|
63
|
+
import { runRatchetCores } from './lib/core-ratchet.mjs';
|
|
63
64
|
import {
|
|
64
65
|
baselineKey,
|
|
65
66
|
readBaseline,
|
|
@@ -153,6 +154,7 @@ function parseArgs(argv) {
|
|
|
153
154
|
else if (arg === '--apply-policy-pack') args.applyPolicyPack = argv[++i];
|
|
154
155
|
else if (arg === '--suggest-include') args.suggestInclude = true;
|
|
155
156
|
else if (arg === '--adopt-contract') args.adoptContract = true;
|
|
157
|
+
else if (arg === '--ratchet-cores') args.ratchetCores = true;
|
|
156
158
|
else if (arg === '--write') args.write = true;
|
|
157
159
|
else if (arg === '--watch') args.watch = true;
|
|
158
160
|
else if (arg === '--beginner') args.beginner = true;
|
|
@@ -199,14 +201,15 @@ function usage() {
|
|
|
199
201
|
' ark-check --coverage [--json] per-layer file counts + full unclassified list (report only, exit 0)',
|
|
200
202
|
' ark-check --plan [--json] classified remediation plan (mechanical-safe / judgment / deferred) + goal; report only',
|
|
201
203
|
' ark-check --recommend [--json] [--write-plan] application-shape plan; --write-plan emits ark-adoption-plan.json',
|
|
202
|
-
' ark-check --list-policy-packs enthusiast
|
|
204
|
+
' ark-check --list-policy-packs enthusiast packs (hexagonal, layered, feature-sliced, monorepo, ui-surface, vertical-slice, ddd-bounded-contexts)',
|
|
203
205
|
' ark-check --apply-policy-pack <id> [--force] write ark.config.json from templates/policy-packs/ (uses preset factory)',
|
|
204
206
|
' ark-check --suggest-include [--json] propose include roots (TS packages / workspaces)',
|
|
205
207
|
' ark-check --adopt-contract [--write] expand include + UI patterns from ungoverned dirs (contract adopt)',
|
|
208
|
+
' ark-check --ratchet-cores when raw graph is green (0 violations; baseline ignored), set optional:false on populated cores only (writes ark.config.json)',
|
|
206
209
|
' ark-check --watch re-run the check when governed files change (debounced)',
|
|
207
210
|
' ark-check --report [file.html] [--beginner] [--reset-origin] [--no-archive]',
|
|
208
211
|
' HTML report + snapshots under .ark/reports/ (origin once, latest each run, history JSON)',
|
|
209
|
-
' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo] [--force]',
|
|
212
|
+
' ark-check --init [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture] [--force]',
|
|
210
213
|
' ark-check --install-agent-gates [--tools claude,cursor,codex,grok] [--skills-only] [--codex-home] [--force]',
|
|
211
214
|
' ark-check --update-baseline [file] freeze current violations (default .ark-baseline.json)',
|
|
212
215
|
' ark-check --print-config eleven-layer',
|
|
@@ -1046,6 +1049,11 @@ async function main() {
|
|
|
1046
1049
|
return;
|
|
1047
1050
|
}
|
|
1048
1051
|
|
|
1052
|
+
if (args.ratchetCores) {
|
|
1053
|
+
runRatchetCores(root, config, files, rules, violations, args, { displayPathFromRoot });
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1049
1057
|
if (args.updateBaseline) {
|
|
1050
1058
|
const summary = summarizeViolations(violations);
|
|
1051
1059
|
// Bloquear y avisar: a lopsided freeze buries a likely contract bug as "debt". Refuse it
|
package/bin/ark-layer-match.mjs
CHANGED
|
@@ -135,11 +135,94 @@ export function layerForRelativePath(relPath, layers) {
|
|
|
135
135
|
}
|
|
136
136
|
return bestName;
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Extract the slice id under a known folder name.
|
|
140
|
+
* Includes the parent folder so `features/auth` ≠ `modules/auth`.
|
|
141
|
+
* `src/features/auth/api.ts` + folders `["features"]` → `"features/auth"`.
|
|
142
|
+
*/
|
|
143
|
+
export function sliceIdForPath(relPath, sliceFolders) {
|
|
144
|
+
if (!sliceFolders?.length)
|
|
145
|
+
return undefined;
|
|
146
|
+
const parts = String(relPath)
|
|
147
|
+
.split(/[/\\]/)
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
const folders = new Set(sliceFolders.map((s) => String(s).toLowerCase()));
|
|
150
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
151
|
+
if (folders.has(parts[i].toLowerCase())) {
|
|
152
|
+
return `${parts[i]}/${parts[i + 1]}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Infer slice parent folders from layer globs: the path segment immediately
|
|
159
|
+
* before a `*` or `**` wildcard (e.g. `src/features/**` → `features`).
|
|
160
|
+
*/
|
|
161
|
+
export function inferSliceFoldersFromPatterns(patterns) {
|
|
162
|
+
const out = new Set();
|
|
163
|
+
for (const pattern of patterns ?? []) {
|
|
164
|
+
const glob = normalizeGlobSeparators(String(pattern));
|
|
165
|
+
const parts = glob.split('/').filter(Boolean);
|
|
166
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
167
|
+
const part = parts[i];
|
|
168
|
+
if ((part === '**' || part === '*') && i > 0) {
|
|
169
|
+
const prev = parts[i - 1];
|
|
170
|
+
if (prev && !prev.includes('*') && !prev.includes('{') && !prev.includes('}')) {
|
|
171
|
+
out.add(prev);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return [...out];
|
|
177
|
+
}
|
|
178
|
+
function resolveSliceFolders(rule, layerName, layers) {
|
|
179
|
+
if (Array.isArray(rule.sliceFolders) && rule.sliceFolders.length > 0) {
|
|
180
|
+
return rule.sliceFolders.filter((s) => typeof s === 'string' && s.length > 0);
|
|
181
|
+
}
|
|
182
|
+
const layer = (layers ?? []).find((l) => l.name === layerName);
|
|
183
|
+
return inferSliceFoldersFromPatterns(layer?.patterns);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Find the first denying rule for a layer edge.
|
|
187
|
+
*
|
|
188
|
+
* Semantics (locked):
|
|
189
|
+
* - Classic (`allowed: false`, no peerIsolation): deny cross-layer edges only.
|
|
190
|
+
* Same-layer is always allowed (historical short-circuit).
|
|
191
|
+
* - `peerIsolation: true` + `allowed: false`: deny only when importer and importee
|
|
192
|
+
* resolve to **different** slice ids (same or cross layer). Same-slice → allow.
|
|
193
|
+
* Missing paths or unclassifiable slices → fail-open (do not deny).
|
|
194
|
+
*/
|
|
195
|
+
export function findDeniedEdgeRule(rules, from, to, options) {
|
|
196
|
+
for (const rule of rules ?? []) {
|
|
197
|
+
if (rule.from !== from || rule.to !== to)
|
|
198
|
+
continue;
|
|
199
|
+
if (rule.allowed !== false)
|
|
200
|
+
continue;
|
|
201
|
+
if (rule.peerIsolation) {
|
|
202
|
+
const fromPath = options?.fromPath;
|
|
203
|
+
const toPath = options?.toPath;
|
|
204
|
+
if (!fromPath || !toPath)
|
|
205
|
+
continue;
|
|
206
|
+
const folders = resolveSliceFolders(rule, from, options?.layers);
|
|
207
|
+
if (folders.length === 0)
|
|
208
|
+
continue;
|
|
209
|
+
const fromSlice = sliceIdForPath(fromPath, folders);
|
|
210
|
+
const toSlice = sliceIdForPath(toPath, folders);
|
|
211
|
+
if (!fromSlice || !toSlice)
|
|
212
|
+
continue;
|
|
213
|
+
if (fromSlice !== toSlice)
|
|
214
|
+
return rule;
|
|
215
|
+
continue; // same slice: this peerIsolation rule does not deny
|
|
216
|
+
}
|
|
217
|
+
// Classic deny — same-layer always allowed without peerIsolation
|
|
218
|
+
if (from === to)
|
|
219
|
+
continue;
|
|
220
|
+
return rule;
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
export function isEdgeDenied(rules, from, to, options) {
|
|
225
|
+
return findDeniedEdgeRule(rules, from, to, options) !== undefined;
|
|
143
226
|
}
|
|
144
227
|
/** Codegen globs skipped by default scan (emitted into the CLI derived matcher). */
|
|
145
228
|
export const DEFAULT_GENERATED_FILE_GLOBS = [
|
package/bin/ark-mcp.mjs
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
detectTsPackageRoots,
|
|
46
46
|
resolveIncludeRoots,
|
|
47
47
|
} from './ark-shared.mjs';
|
|
48
|
+
import { createImportTargetResolver } from './lib/import-resolve.mjs';
|
|
48
49
|
|
|
49
50
|
const arkCheckBin = fileURLToPath(new URL('./ark-check.mjs', import.meta.url));
|
|
50
51
|
|
|
@@ -97,73 +98,6 @@ function inferLayer(filePath, config, root) {
|
|
|
97
98
|
return layerForFile(root, filePath, config.layers);
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
// Read tsconfig path aliases via the TypeScript config parser (so JSONC + `extends` work).
|
|
101
|
-
// Returns { baseUrl (absolute), aliases: [{ from, to }] } — `from` a prefix like "@/", `to`
|
|
102
|
-
// its base like "./src/". Empty when typescript/tsconfig is unavailable; callers then resolve
|
|
103
|
-
// only relative imports (aliased ones fall through to the infra heuristic — no worse than before).
|
|
104
|
-
function readTsconfigAliases(ts, root) {
|
|
105
|
-
if (!ts) return { baseUrl: root, aliases: [] };
|
|
106
|
-
try {
|
|
107
|
-
const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json');
|
|
108
|
-
if (!configPath) return { baseUrl: root, aliases: [] };
|
|
109
|
-
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
110
|
-
if (read.error) return { baseUrl: root, aliases: [] };
|
|
111
|
-
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath));
|
|
112
|
-
const opts = parsed.options || {};
|
|
113
|
-
const baseUrl = opts.baseUrl || path.dirname(configPath);
|
|
114
|
-
const aliases = [];
|
|
115
|
-
for (const [pattern, targets] of Object.entries(opts.paths || {})) {
|
|
116
|
-
if (!Array.isArray(targets) || targets.length === 0) continue;
|
|
117
|
-
aliases.push({ from: pattern.replace(/\*$/, ''), to: String(targets[0]).replace(/\*$/, '') });
|
|
118
|
-
}
|
|
119
|
-
aliases.sort((a, b) => b.from.length - a.from.length); // longest prefix wins
|
|
120
|
-
return { baseUrl, aliases };
|
|
121
|
-
} catch {
|
|
122
|
-
return { baseUrl: root, aliases: [] };
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Resolve an import specifier to a repo-relative target path (directory-level — enough to
|
|
127
|
-
// classify the layer). Relative and tsconfig-aliased imports resolve; bare packages return
|
|
128
|
-
// undefined (ungoverned → the gate's infra heuristic decides those).
|
|
129
|
-
function resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases) {
|
|
130
|
-
let abs;
|
|
131
|
-
if (specifier.startsWith('./') || specifier.startsWith('../')) {
|
|
132
|
-
if (!fromFilePath) return undefined;
|
|
133
|
-
abs = path.resolve(path.dirname(path.resolve(root, fromFilePath)), specifier);
|
|
134
|
-
} else {
|
|
135
|
-
const alias = tsAliases.aliases.find((a) => specifier.startsWith(a.from));
|
|
136
|
-
if (!alias) return undefined; // bare package specifier
|
|
137
|
-
abs = path.resolve(tsAliases.baseUrl, `${alias.to}${specifier.slice(alias.from.length)}`);
|
|
138
|
-
}
|
|
139
|
-
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
140
|
-
return rel.startsWith('..') ? undefined : rel; // outside the project root
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Build the resolveImportLayer callback for the AI write gate: specifier → target layer, so
|
|
144
|
-
// the gate lets ark.config.json layer RULES govern a resolvable edge instead of the infra
|
|
145
|
-
// path-heuristic. A barrel/dir import (`@/lib/db`) is classified like a file under it.
|
|
146
|
-
function createImportLayerResolver(ts, root, config) {
|
|
147
|
-
const layers = config.layers ?? [];
|
|
148
|
-
if (layers.length === 0) return undefined;
|
|
149
|
-
const tsAliases = readTsconfigAliases(ts, root);
|
|
150
|
-
return (specifier, fromFilePath) => {
|
|
151
|
-
const rel = resolveSpecifierToRel(specifier, fromFilePath, root, tsAliases);
|
|
152
|
-
if (!rel) return undefined;
|
|
153
|
-
// When the target is a real directory (a barrel import like `@/lib/db`), classify a file
|
|
154
|
-
// INSIDE it so it resolves to the `src/lib/db/**` layer, not a broader `src/lib/**` that
|
|
155
|
-
// the bare path would also match. Falls back to the same probe when the target isn't on
|
|
156
|
-
// disk (e.g. a file referenced without its extension).
|
|
157
|
-
let probe = rel;
|
|
158
|
-
try {
|
|
159
|
-
if (fs.statSync(path.join(root, rel)).isDirectory()) probe = `${rel}/index.ts`;
|
|
160
|
-
} catch {
|
|
161
|
-
/* not on disk — classify the path as-is, with the dir-style fallback below */
|
|
162
|
-
}
|
|
163
|
-
return layerForFile(root, probe, layers) || layerForFile(root, `${rel}/index.ts`, layers);
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
101
|
async function loadArk() {
|
|
168
102
|
const url = new URL('../dist/index.js', import.meta.url);
|
|
169
103
|
if (!fs.existsSync(url)) {
|
|
@@ -501,9 +435,12 @@ async function main() {
|
|
|
501
435
|
typescript: ts,
|
|
502
436
|
forbiddenGlobals,
|
|
503
437
|
infrastructureLayers,
|
|
504
|
-
// Contract-first:
|
|
505
|
-
|
|
506
|
-
|
|
438
|
+
// Contract-first: one resolve step yields layer + relPath for rules + peerIsolation.
|
|
439
|
+
resolveImportTarget: createImportTargetResolver(ts, args.root, config),
|
|
440
|
+
architectureLayers: configLayers.map((layer) => ({
|
|
441
|
+
name: layer.name,
|
|
442
|
+
patterns: layer.patterns,
|
|
443
|
+
})),
|
|
507
444
|
});
|
|
508
445
|
|
|
509
446
|
if (args.hook) {
|
package/bin/ark-shared.mjs
CHANGED
|
@@ -446,6 +446,9 @@ export {
|
|
|
446
446
|
layerForFile,
|
|
447
447
|
layerForRelativePath,
|
|
448
448
|
isEdgeDenied,
|
|
449
|
+
findDeniedEdgeRule,
|
|
450
|
+
sliceIdForPath,
|
|
451
|
+
inferSliceFoldersFromPatterns,
|
|
449
452
|
DEFAULT_GENERATED_FILE_GLOBS,
|
|
450
453
|
scanExcludePatterns,
|
|
451
454
|
isScanExcludedRelative,
|
|
@@ -666,6 +669,8 @@ export const ARCHETYPE_IDS = [
|
|
|
666
669
|
'integration-bridge',
|
|
667
670
|
'multi-app-workspace',
|
|
668
671
|
'prototype-spike',
|
|
672
|
+
'vertical-slice-product',
|
|
673
|
+
'ddd-bounded-contexts',
|
|
669
674
|
];
|
|
670
675
|
|
|
671
676
|
const UI_DIR_NAMES = new Set([
|
|
@@ -1053,7 +1058,7 @@ export function collectRepoShapeSignals(root) {
|
|
|
1053
1058
|
const pkg = readPackageJson(root);
|
|
1054
1059
|
const workspaceDirs = detectWorkspaces(root);
|
|
1055
1060
|
const workspaces = workspaceDirs.length > 0;
|
|
1056
|
-
// Include frontend/web/client — common Next monorepo app folders
|
|
1061
|
+
// Include frontend/web/client — common Next monorepo app folders.
|
|
1057
1062
|
const candidateScanDirs = [
|
|
1058
1063
|
'src',
|
|
1059
1064
|
'lib',
|
|
@@ -1142,6 +1147,18 @@ export function collectRepoShapeSignals(root) {
|
|
|
1142
1147
|
['app', 'pages', 'features', 'entities', 'shared'].some((name) =>
|
|
1143
1148
|
fs.existsSync(path.join(root, 'src', name))
|
|
1144
1149
|
);
|
|
1150
|
+
// Vertical slice: features/* co-located slices + shared/lib escape, without full FSD ladder
|
|
1151
|
+
// (entities/widgets). Distinct from featureSlicedLayout which matches any FSD folder.
|
|
1152
|
+
const verticalSliceLayout =
|
|
1153
|
+
fs.existsSync(path.join(root, 'src', 'features')) &&
|
|
1154
|
+
(fs.existsSync(path.join(root, 'src', 'shared')) ||
|
|
1155
|
+
fs.existsSync(path.join(root, 'src', 'lib'))) &&
|
|
1156
|
+
!fs.existsSync(path.join(root, 'src', 'entities')) &&
|
|
1157
|
+
!fs.existsSync(path.join(root, 'src', 'widgets'));
|
|
1158
|
+
// DDD multi-context tree (contexts/*/domain|application|infrastructure).
|
|
1159
|
+
const dddBoundedContextsLayout =
|
|
1160
|
+
fs.existsSync(path.join(root, 'src', 'contexts')) ||
|
|
1161
|
+
fs.existsSync(path.join(root, 'src', 'bounded-contexts'));
|
|
1145
1162
|
|
|
1146
1163
|
const hasBin = Boolean(pkg?.bin);
|
|
1147
1164
|
const hasExports = Boolean(pkg?.exports);
|
|
@@ -1180,6 +1197,22 @@ export function collectRepoShapeSignals(root) {
|
|
|
1180
1197
|
}
|
|
1181
1198
|
if (nextFramework && !toolHints.includes('next')) toolHints.push('next');
|
|
1182
1199
|
|
|
1200
|
+
// Turborepo / Nx markers (monorepo tooling — maps to monorepo preset, not separate engines).
|
|
1201
|
+
const monorepoTooling = [];
|
|
1202
|
+
if (fs.existsSync(path.join(root, 'turbo.json'))) monorepoTooling.push('turborepo');
|
|
1203
|
+
if (
|
|
1204
|
+
fs.existsSync(path.join(root, 'nx.json')) ||
|
|
1205
|
+
fs.existsSync(path.join(root, 'workspace.json'))
|
|
1206
|
+
) {
|
|
1207
|
+
monorepoTooling.push('nx');
|
|
1208
|
+
}
|
|
1209
|
+
if (monorepoTooling.includes('turborepo') && !toolHints.includes('turborepo')) {
|
|
1210
|
+
toolHints.push('turborepo');
|
|
1211
|
+
}
|
|
1212
|
+
if (monorepoTooling.includes('nx') && !toolHints.includes('nx')) {
|
|
1213
|
+
toolHints.push('nx');
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1183
1216
|
return {
|
|
1184
1217
|
workspaces,
|
|
1185
1218
|
workspaceDirs,
|
|
@@ -1203,6 +1236,10 @@ export function collectRepoShapeSignals(root) {
|
|
|
1203
1236
|
domainHeavy,
|
|
1204
1237
|
uiOnly,
|
|
1205
1238
|
featureSlicedLayout,
|
|
1239
|
+
verticalSliceLayout,
|
|
1240
|
+
dddBoundedContextsLayout,
|
|
1241
|
+
// null when absent so scoreArchetypes `if (signals.x)` is false for empty tooling
|
|
1242
|
+
monorepoTooling: monorepoTooling.length > 0 ? monorepoTooling : null,
|
|
1206
1243
|
fullStackProduct,
|
|
1207
1244
|
persistenceFromDeps,
|
|
1208
1245
|
nestFramework,
|
|
@@ -1229,6 +1266,14 @@ const SIGNAL_WHY = {
|
|
|
1229
1266
|
library: () => 'publishable package shape (exports/main, no CLI bin)',
|
|
1230
1267
|
libraryOnly: () => 'library package without a CLI entry',
|
|
1231
1268
|
featureSlicedLayout: () => 'feature-sliced directory layout under src/',
|
|
1269
|
+
verticalSliceLayout: () =>
|
|
1270
|
+
'vertical-slice layout (src/features + shared/lib, without FSD entities/widgets)',
|
|
1271
|
+
dddBoundedContextsLayout: () =>
|
|
1272
|
+
'DDD bounded contexts under src/contexts or src/bounded-contexts',
|
|
1273
|
+
monorepoTooling: (signals) =>
|
|
1274
|
+
signals.monorepoTooling?.length
|
|
1275
|
+
? `monorepo tooling detected (${signals.monorepoTooling.join(', ')})`
|
|
1276
|
+
: 'monorepo tooling markers present',
|
|
1232
1277
|
domain: () => 'domain directory present',
|
|
1233
1278
|
application: () => 'application or services directory present',
|
|
1234
1279
|
domainHeavy: () => 'both domain and application directories present',
|
|
@@ -1418,6 +1463,9 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1418
1463
|
phase3: result.phases?.['3'] ?? [],
|
|
1419
1464
|
};
|
|
1420
1465
|
|
|
1466
|
+
const galleryStarter = GALLERY_STARTER_BY_ARCHETYPE[result.archetype] ?? null;
|
|
1467
|
+
const policyPackId = policyPackIdForPreset(result.preset);
|
|
1468
|
+
|
|
1421
1469
|
return {
|
|
1422
1470
|
ok: true,
|
|
1423
1471
|
playbookVersion: playbook.version,
|
|
@@ -1435,6 +1483,8 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1435
1483
|
matchedSignals: result.matched,
|
|
1436
1484
|
runnerUp: result.runnerUp,
|
|
1437
1485
|
toolHints: signals.toolHints,
|
|
1486
|
+
galleryStarter,
|
|
1487
|
+
policyPack: policyPackId,
|
|
1438
1488
|
signals: {
|
|
1439
1489
|
sourceFileCount: signals.sourceFileCount,
|
|
1440
1490
|
workspaces: signals.workspaces,
|
|
@@ -1452,6 +1502,9 @@ export function buildArchitectureRecommendation(root, options = {}) {
|
|
|
1452
1502
|
nestFramework: signals.nestFramework,
|
|
1453
1503
|
nextFramework: signals.nextFramework,
|
|
1454
1504
|
expressLike: signals.expressLike,
|
|
1505
|
+
verticalSliceLayout: signals.verticalSliceLayout,
|
|
1506
|
+
dddBoundedContextsLayout: signals.dddBoundedContextsLayout,
|
|
1507
|
+
monorepoTooling: signals.monorepoTooling,
|
|
1455
1508
|
},
|
|
1456
1509
|
// A repo past this size is not greenfield: `ark init` would scaffold a starter that governs
|
|
1457
1510
|
// a thin slice and can mis-flag framework internals, so steer these to the adoption flow.
|
|
@@ -1473,7 +1526,9 @@ export const INIT_WIZARD_CHOICES = [
|
|
|
1473
1526
|
{ key: '5', archetype: 'worker-pipeline', label: 'Background jobs or workers' },
|
|
1474
1527
|
{ key: '6', archetype: 'multi-app-workspace', label: 'Several apps in one repository' },
|
|
1475
1528
|
{ key: '7', archetype: 'prototype-spike', label: 'A quick experiment or learning project' },
|
|
1476
|
-
{ key: '8', archetype: '
|
|
1529
|
+
{ key: '8', archetype: 'vertical-slice-product', label: 'Feature-first slices (vertical slice)' },
|
|
1530
|
+
{ key: '9', archetype: 'ddd-bounded-contexts', label: 'Multiple business domains (bounded contexts)' },
|
|
1531
|
+
{ key: 'a', archetype: 'auto', label: 'Analyze my repo and suggest (recommended if unsure)' },
|
|
1477
1532
|
];
|
|
1478
1533
|
|
|
1479
1534
|
export function isValidArchetypeId(id) {
|
|
@@ -1528,6 +1583,12 @@ export function formatArchitectureRecommendationHuman(recommendation) {
|
|
|
1528
1583
|
lines.push('');
|
|
1529
1584
|
lines.push(`Archetype: ${recommendation.archetype} — ${recommendation.label}`);
|
|
1530
1585
|
lines.push(`Preset: ${recommendation.preset} (confidence ${recommendation.confidence})`);
|
|
1586
|
+
if (recommendation.galleryStarter) {
|
|
1587
|
+
lines.push(`Gallery starter: ${recommendation.galleryStarter}`);
|
|
1588
|
+
}
|
|
1589
|
+
if (recommendation.policyPack) {
|
|
1590
|
+
lines.push(`Policy pack: ark-check --apply-policy-pack ${recommendation.policyPack}`);
|
|
1591
|
+
}
|
|
1531
1592
|
if (recommendation.thinTsSurface) {
|
|
1532
1593
|
lines.push('');
|
|
1533
1594
|
lines.push(
|
|
@@ -1591,18 +1652,37 @@ const GALLERY_STARTER_BY_ARCHETYPE = {
|
|
|
1591
1652
|
'api-backend': 'examples/api-backend-starter/',
|
|
1592
1653
|
'worker-pipeline': 'examples/worker-pipeline-starter/',
|
|
1593
1654
|
'multi-app-workspace': 'examples/multi-app-workspace-starter/',
|
|
1655
|
+
'vertical-slice-product': 'examples/vertical-slice-starter/',
|
|
1656
|
+
'ddd-bounded-contexts': 'examples/ddd-context-starter/',
|
|
1594
1657
|
};
|
|
1595
1658
|
|
|
1596
|
-
/**
|
|
1597
|
-
export function
|
|
1598
|
-
|
|
1599
|
-
const policyPackId =
|
|
1659
|
+
/** Enthusiast pack id for a named preset, or null when none ships. */
|
|
1660
|
+
export function policyPackIdForPreset(preset) {
|
|
1661
|
+
if (
|
|
1600
1662
|
preset === 'hexagonal' ||
|
|
1601
1663
|
preset === 'layered' ||
|
|
1602
1664
|
preset === 'feature-sliced' ||
|
|
1603
|
-
preset === 'monorepo'
|
|
1604
|
-
|
|
1605
|
-
|
|
1665
|
+
preset === 'monorepo' ||
|
|
1666
|
+
preset === 'vertical-slice' ||
|
|
1667
|
+
preset === 'ddd-bounded-contexts' ||
|
|
1668
|
+
preset === 'ui-surface' ||
|
|
1669
|
+
preset === 'clean-architecture' ||
|
|
1670
|
+
preset === 'onion-architecture'
|
|
1671
|
+
) {
|
|
1672
|
+
// clean/onion alias packs → hexagonal enthusiast pack
|
|
1673
|
+
const packPreset =
|
|
1674
|
+
preset === 'clean-architecture' || preset === 'onion-architecture'
|
|
1675
|
+
? 'hexagonal'
|
|
1676
|
+
: preset;
|
|
1677
|
+
return `enthusiast-${packPreset}`;
|
|
1678
|
+
}
|
|
1679
|
+
return null;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** Machine-readable adoption record for optional commit (Phase E). */
|
|
1683
|
+
export function buildAdoptionPlanDocument(recommendation) {
|
|
1684
|
+
const preset = recommendation.preset;
|
|
1685
|
+
const policyPackId = recommendation.policyPack ?? policyPackIdForPreset(preset);
|
|
1606
1686
|
|
|
1607
1687
|
return {
|
|
1608
1688
|
version: '1',
|
package/bin/ark.mjs
CHANGED
|
@@ -55,7 +55,7 @@ function parseArgs(argv) {
|
|
|
55
55
|
function usage() {
|
|
56
56
|
return `Usage:
|
|
57
57
|
ark start [--root <project>] [--yes]
|
|
58
|
-
ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo]
|
|
58
|
+
ark init [--root <project>] [--preset hexagonal|layered|feature-sliced|monorepo|ui-surface|vertical-slice|ddd-bounded-contexts|clean-architecture|onion-architecture]
|
|
59
59
|
[--archetype <playbook-id>] [--tools <list>] [--yes] [--force] [--no-strict]
|
|
60
60
|
ark upgrade [--root <project>] [--no-install] [--no-strict]
|
|
61
61
|
|
|
@@ -76,7 +76,8 @@ Options:
|
|
|
76
76
|
--preset Start from a named architecture preset instead of detection.
|
|
77
77
|
--archetype Application shape from templates/architecture-playbook.json (maps to the matching preset).
|
|
78
78
|
Valid ids: crud-product, api-backend, frontend-surface, library-sdk, cli-utility,
|
|
79
|
-
worker-pipeline, event-coordinator, integration-bridge, multi-app-workspace, prototype-spike
|
|
79
|
+
worker-pipeline, event-coordinator, integration-bridge, multi-app-workspace, prototype-spike,
|
|
80
|
+
vertical-slice-product, ddd-bounded-contexts.
|
|
80
81
|
--tools Comma-separated agents to gate (claude,cursor,codex,windsurf,cline,copilot,kiro,roo,continue,gemini).
|
|
81
82
|
Omit to auto-detect from each tool's config dir, falling back to claude+cursor+codex.
|
|
82
83
|
|
|
@@ -498,6 +499,11 @@ async function start(args) {
|
|
|
498
499
|
console.log(' → origin report, adoption, plan, safe fixes, leave gates on.');
|
|
499
500
|
console.log(` 2. Status anytime: ${arkCommand(root, 'ark-check', '--doctor')}`);
|
|
500
501
|
console.log(` 3. After edits: ${arkCommand(root, 'ark-check', '--root . --config ark.config.json --strict-config')}`);
|
|
502
|
+
if (mode === 'adapt' && planOk) {
|
|
503
|
+
console.log(
|
|
504
|
+
` 4. When green but cores still optional: ${arkCommand(root, 'ark-check', '--ratchet-cores')} → honest ENFORCE`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
501
507
|
console.log('');
|
|
502
508
|
console.log('Optional later: --plan · --coverage · /ark-fix · /ark-place · ark upgrade');
|
|
503
509
|
|