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.
Files changed (57) hide show
  1. package/CHANGELOG.md +75 -2
  2. package/README.md +6 -2
  3. package/bin/ark-check.mjs +10 -2
  4. package/bin/ark-layer-match.mjs +88 -5
  5. package/bin/ark-mcp.mjs +7 -70
  6. package/bin/ark-shared.mjs +89 -9
  7. package/bin/ark.mjs +8 -2
  8. package/bin/lib/agent-gates.mjs +104 -20
  9. package/bin/lib/architecture-scan.mjs +16 -4
  10. package/bin/lib/config-warnings.mjs +12 -3
  11. package/bin/lib/core-ratchet.mjs +152 -0
  12. package/bin/lib/doctor-plan.mjs +20 -0
  13. package/bin/lib/import-resolve.mjs +133 -0
  14. package/bin/lib/presets.mjs +207 -11
  15. package/bin/lib/remediation.mjs +15 -0
  16. package/bin/lib/suggestions.mjs +8 -3
  17. package/dist/eslint/index.cjs +63 -5
  18. package/dist/eslint/index.cjs.map +1 -1
  19. package/dist/eslint/index.d.cts +33 -1
  20. package/dist/eslint/index.d.ts +33 -1
  21. package/dist/eslint/index.js +63 -5
  22. package/dist/eslint/index.js.map +1 -1
  23. package/dist/index.cjs +103 -14
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +21 -9
  26. package/dist/index.d.ts +21 -9
  27. package/dist/index.js +103 -14
  28. package/dist/index.js.map +1 -1
  29. package/dist/nestjs/index.cjs +78 -4
  30. package/dist/nestjs/index.cjs.map +1 -1
  31. package/dist/nestjs/index.d.cts +1 -1
  32. package/dist/nestjs/index.d.ts +1 -1
  33. package/dist/nestjs/index.js +78 -4
  34. package/dist/nestjs/index.js.map +1 -1
  35. package/dist/runtime/index.cjs +103 -14
  36. package/dist/runtime/index.cjs.map +1 -1
  37. package/dist/runtime/index.d.cts +1 -1
  38. package/dist/runtime/index.d.ts +1 -1
  39. package/dist/runtime/index.js +103 -14
  40. package/dist/runtime/index.js.map +1 -1
  41. package/dist/{types-CSJhEOk2.d.cts → types-D6Q8WHes.d.cts} +7 -0
  42. package/dist/{types-CSJhEOk2.d.ts → types-D6Q8WHes.d.ts} +7 -0
  43. package/docs/agent-guide.md +55 -4
  44. package/docs/brownfield-adoption.md +1 -1
  45. package/docs/package-surface.md +3 -0
  46. package/package.json +4 -2
  47. package/server.json +2 -2
  48. package/templates/architecture-playbook.json +65 -1
  49. package/templates/policy-packs/enthusiast-ddd-bounded-contexts.json +19 -0
  50. package/templates/policy-packs/enthusiast-ui-surface.json +18 -0
  51. package/templates/policy-packs/enthusiast-vertical-slice.json +18 -0
  52. package/templates/skills/ark-adopt.md +4 -0
  53. package/templates/skills/ark-architect.md +5 -1
  54. package/templates/skills/ark-autopilot.md +6 -1
  55. package/templates/skills/ark-fix.md +3 -0
  56. package/templates/skills/ark-place.md +7 -0
  57. package/templates/skills/ark-think.md +43 -0
@@ -20,8 +20,9 @@ import {
20
20
  DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
21
21
  DEFAULT_RULES,
22
22
  createElevenLayerConfig,
23
- applyFrameworkLayoutOverlays
23
+ applyFrameworkLayoutOverlays,
24
24
  } from '../ark-shared.mjs';
25
+ import { CORE_LAYER_NAMES } from './core-ratchet.mjs';
25
26
  import {
26
27
  assessCodexHomeMcp,
27
28
  codexArkBlockHasPreferredBin,
@@ -76,6 +77,97 @@ export function hasCheckArchitectureScript(root) {
76
77
  return Boolean(pkg?.scripts?.['check:architecture']);
77
78
  }
78
79
 
80
+ /**
81
+ * Whether package.json scripts already expose a typecheck-like command.
82
+ * Shared by deploy-path quality + typecheck bootstrap (single definition).
83
+ * @param {Record<string, unknown>|null|undefined} scripts
84
+ */
85
+ export function packageScriptsHaveTypecheck(scripts) {
86
+ if (!scripts || typeof scripts !== 'object') return false;
87
+ return Boolean(
88
+ (typeof scripts.typecheck === 'string' && scripts.typecheck.trim()) ||
89
+ (typeof scripts['type-check'] === 'string' && scripts['type-check'].trim()) ||
90
+ (typeof scripts['check:types'] === 'string' && scripts['check:types'].trim()) ||
91
+ (typeof scripts.tsc === 'string' && /\btsc\b/.test(scripts.tsc))
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Root package (and shallow nested packages) already have a typecheck script.
97
+ * Does not scan CI or framework configs — only package.json scripts.
98
+ * @param {string} root
99
+ */
100
+ export function treeHasTypecheckScript(root) {
101
+ const pkg = readPackageJson(root);
102
+ if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
103
+ try {
104
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
105
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
106
+ const candidates = [path.join(root, entry.name)];
107
+ try {
108
+ for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
109
+ if (child.isDirectory() && !child.name.startsWith('.')) {
110
+ candidates.push(path.join(root, entry.name, child.name));
111
+ }
112
+ }
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ for (const dir of candidates) {
117
+ const pj = path.join(dir, 'package.json');
118
+ if (!fs.existsSync(pj)) continue;
119
+ try {
120
+ const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
121
+ if (packageScriptsHaveTypecheck(nested.scripts)) return true;
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ }
126
+ }
127
+ } catch {
128
+ /* ignore */
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * Add a conservative `typecheck` script when the host has a TS/JS project config
135
+ * but no typecheck-like script yet. Never overwrites an existing script.
136
+ *
137
+ * @param {string} root
138
+ * @param {{ write?: boolean }} [opts]
139
+ * @returns {{
140
+ * changed: boolean,
141
+ * reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json',
142
+ * script?: string,
143
+ * }}
144
+ */
145
+ export function ensureTypecheckScript(root, opts = {}) {
146
+ const write = opts.write !== false;
147
+ const hasTsconfig =
148
+ fs.existsSync(path.join(root, 'tsconfig.json')) ||
149
+ fs.existsSync(path.join(root, 'jsconfig.json'));
150
+ if (!hasTsconfig) return { changed: false, reason: 'no-tsconfig' };
151
+
152
+ const pkgPath = path.join(root, 'package.json');
153
+ if (!fs.existsSync(pkgPath)) return { changed: false, reason: 'no-package-json' };
154
+
155
+ if (treeHasTypecheckScript(root)) {
156
+ return { changed: false, reason: 'already' };
157
+ }
158
+
159
+ const pkg = readPackageJson(root) || {};
160
+ const scripts =
161
+ pkg.scripts && typeof pkg.scripts === 'object' ? { ...pkg.scripts } : {};
162
+ const script = 'tsc --noEmit';
163
+ scripts.typecheck = script;
164
+ if (write) {
165
+ const next = { ...pkg, scripts };
166
+ fs.writeFileSync(pkgPath, `${JSON.stringify(next, null, 2)}\n`);
167
+ }
168
+ return { changed: true, reason: 'added', script };
169
+ }
170
+
79
171
  export const REQUIRED_GATE_FILES = [
80
172
  'AGENTS.md',
81
173
  '.mcp.json',
@@ -945,14 +1037,6 @@ export function brokenMcpGateFiles(root) {
945
1037
  return bad;
946
1038
  }
947
1039
 
948
- /** Core layers whose optionality matters once they match files (presets share these names). */
949
- const CORE_LAYER_NAMES = new Set([
950
- 'DomainModel',
951
- 'ApplicationOrchestration',
952
- 'PresentationAdapters',
953
- 'PersistenceAdapters',
954
- ]);
955
-
956
1040
  /**
957
1041
  * Production deploy path quality (universal — any consumer repo).
958
1042
  * Detects when the production build host runs ESLint / typecheck as part of
@@ -1005,17 +1089,9 @@ export function detectDeployPathQuality(root) {
1005
1089
  (typeof s['lint:ci'] === 'string' && s['lint:ci'].trim()) ||
1006
1090
  (typeof s['check:lint'] === 'string' && s['check:lint'].trim()))
1007
1091
  );
1008
- const scriptHasTypecheck = (s) =>
1009
- Boolean(
1010
- s &&
1011
- ((typeof s.typecheck === 'string' && s.typecheck.trim()) ||
1012
- (typeof s['type-check'] === 'string' && s['type-check'].trim()) ||
1013
- (typeof s['check:types'] === 'string' && s['check:types'].trim()) ||
1014
- (typeof s.tsc === 'string' && /\btsc\b/.test(s.tsc)))
1015
- );
1016
1092
 
1017
1093
  let hasLintScript = scriptHasLint(scripts);
1018
- let hasTypecheckScript = scriptHasTypecheck(scripts);
1094
+ let hasTypecheckScript = packageScriptsHaveTypecheck(scripts);
1019
1095
  const packageLintScripts = [];
1020
1096
  // Monorepo: package-level scripts count (apps/web, packages/ui, …).
1021
1097
  try {
@@ -1042,7 +1118,7 @@ export function detectDeployPathQuality(root) {
1042
1118
  hasLintScript = true;
1043
1119
  packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
1044
1120
  }
1045
- if (scriptHasTypecheck(ns)) hasTypecheckScript = true;
1121
+ if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
1046
1122
  const nd = {
1047
1123
  ...(nested.dependencies || {}),
1048
1124
  ...(nested.devDependencies || {}),
@@ -1289,7 +1365,7 @@ export function collectAdoptionGaps(root, config, coverage) {
1289
1365
  id: `core-optional-${layer.name}`,
1290
1366
  severity: 'info',
1291
1367
  message: `Core layer ${layer.name} has ${files} file(s) but is still optional: true — contract is weaker than the tree`,
1292
- fix: `Edit ark.config.json: remove optional on ${layer.name} (or set false), then ${arkCommand(root, 'ark-check', '--strict-config')}`,
1368
+ fix: `${arkCommand(root, 'ark-check', '--ratchet-cores')} (when architecture is green: 0 active violations)`,
1293
1369
  });
1294
1370
  }
1295
1371
  }
@@ -1625,7 +1701,15 @@ export function runInstallAgentGates(args) {
1625
1701
  // overwrite (they track the package). The gate/instruction files (AGENTS.md,
1626
1702
  // settings.json, CI workflow, rules) are the ones users customize, so a plain
1627
1703
  // `--force` clobbers them — this is the safe way to pick up new skill versions.
1704
+ // Do not mutate package.json under --skills-only (typecheck bootstrap is gates/CI).
1628
1705
  if (!args.skillsOnly) {
1706
+ // Bootstrap typecheck before CI template so generated workflow includes the step.
1707
+ const typecheckBootstrap = ensureTypecheckScript(root, { write: true });
1708
+ if (typecheckBootstrap.changed && !args.json) {
1709
+ console.log(
1710
+ `Added package.json script "typecheck": "${typecheckBootstrap.script}" (tsconfig present; local/CI parity).`
1711
+ );
1712
+ }
1629
1713
  // Base gates: tool-agnostic contract + CI backstop, always written.
1630
1714
  templates.push(['AGENTS.md', agentInstructions(root)]);
1631
1715
  templates.push(['.mcp.json', mcpJson(root)]);
@@ -237,10 +237,16 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
237
237
  importGraph.get(relFile).add(relTarget);
238
238
  }
239
239
  }
240
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
240
+ const relTarget = target ? normalize(path.relative(root, target)) : undefined;
241
+ const rule = targetLayer
242
+ ? isBlocked(rules, sourceLayer, targetLayer, {
243
+ fromPath: relFile,
244
+ toPath: relTarget,
245
+ layers: config.layers,
246
+ })
247
+ : undefined;
241
248
  if (rule) {
242
- const relTarget = normalize(path.relative(root, target));
243
- const targetCached = nextCacheFiles[relTarget];
249
+ const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
244
250
  const staticEdge = edge.kind === 'import' || edge.kind === 'export';
245
251
  const targetTypeOnlyExports =
246
252
  staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
@@ -257,6 +263,7 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
257
263
  targetTypeNames.size > 0 &&
258
264
  !targetCached?.hasTopLevelSideEffects &&
259
265
  named.every((n) => targetTypeNames.has(n));
266
+ const peerIsolation = Boolean(rule.peerIsolation);
260
267
  violations.push({
261
268
  ruleId: 'LAYER_IMPORT_VIOLATION',
262
269
  file: relFile,
@@ -269,7 +276,12 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
269
276
  ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
270
277
  ...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
271
278
  ...(edge.kind ? { edgeKind: edge.kind } : {}),
272
- message: rule.message ?? `${sourceLayer} must not ${edge.kind} ${targetLayer}.`,
279
+ ...(peerIsolation ? { peerIsolation: true } : {}),
280
+ message:
281
+ rule.message ??
282
+ (peerIsolation
283
+ ? `${sourceLayer} must not ${edge.kind} another slice of ${targetLayer} (${relFile} → ${relTarget}). Extract shared code or use events/ports across slices.`
284
+ : `${sourceLayer} must not ${edge.kind} ${targetLayer}.`),
273
285
  });
274
286
  }
275
287
  }
@@ -10,6 +10,7 @@ import {
10
10
  patternSpecificity,
11
11
  resolveIntentLayer,
12
12
  } from '../ark-shared.mjs';
13
+ import { findDeniedEdgeRule } from '../ark-layer-match.mjs';
13
14
  import { normalize } from './scan-files.mjs';
14
15
 
15
16
  export function intentLayersFromManifest(manifest) {
@@ -36,8 +37,16 @@ export function layerForIntent(intent, layers, manifestIntentLayers) {
36
37
  return resolveIntentLayer(intent, source);
37
38
  }
38
39
 
39
- export function isBlocked(rules, from, to) {
40
- return rules.find((rule) => !rule.allowed && rule.from === from && rule.to === to);
40
+ /**
41
+ * First denying edge rule for from→to, or undefined.
42
+ * Path-aware when options.fromPath / options.toPath are set (peerIsolation).
43
+ * @param {object[]} rules
44
+ * @param {string} from
45
+ * @param {string} to
46
+ * @param {{ fromPath?: string, toPath?: string, layers?: object[] }} [options]
47
+ */
48
+ export function isBlocked(rules, from, to, options) {
49
+ return findDeniedEdgeRule(rules, from, to, options);
41
50
  }
42
51
 
43
52
  export function configWarning(ruleId, message, extra = {}) {
@@ -127,7 +136,7 @@ export function collectConfigWarnings(root, config, files, rules, manifest) {
127
136
  // Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
128
137
  // globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
129
138
  // Failing the release gate on dead preset globs caused false CI red while architecture
130
- // edges were clean (deer-flow host validation). Real safety is import violations +
139
+ // edges were clean on multi-package hosts. Real safety is import violations +
131
140
  // CONFIG_UNCLASSIFIED_FILES / invalid patterns.
132
141
  warnings.push(
133
142
  configWarning(
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Core-layer optionality ratchet — pure plan + CLI runner.
3
+ * Keeps ark-check.mjs orchestration-only (dispatch only).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { arkCommand } from '../ark-shared.mjs';
8
+ import { computeCoverage } from './doctor-plan.mjs';
9
+
10
+ /**
11
+ * Core layers whose optionality matters once they match files (presets share these names).
12
+ * Used by doctor adoption gaps and `--ratchet-cores`.
13
+ */
14
+ export const CORE_LAYER_NAMES = new Set([
15
+ 'DomainModel',
16
+ 'ApplicationOrchestration',
17
+ 'PresentationAdapters',
18
+ 'PersistenceAdapters',
19
+ ]);
20
+
21
+ /**
22
+ * Plan a ratchet of optional→required for core layers that already match files.
23
+ * Empty cores stay optional (avoids false ENFORCE theatre). Pure — does not write disk.
24
+ *
25
+ * @param {object} config ark.config.json shape
26
+ * @param {{ name: string, files: number }[]} layerRows coverage layer rows
27
+ */
28
+ export function planPopulatedCoreRatchet(config, layerRows = []) {
29
+ const countByName = new Map(
30
+ (Array.isArray(layerRows) ? layerRows : []).map((row) => [row.name, Number(row.files) || 0])
31
+ );
32
+ const ratcheted = [];
33
+ const alreadyStrict = [];
34
+ const stillOptionalEmpty = [];
35
+ const nextLayers = (config?.layers ?? []).map((layer) => {
36
+ if (!CORE_LAYER_NAMES.has(layer.name)) return layer;
37
+ const files = countByName.get(layer.name) ?? 0;
38
+ if (layer.optional !== true) {
39
+ if (files > 0) alreadyStrict.push({ layer: layer.name, files });
40
+ return layer;
41
+ }
42
+ if (files <= 0) {
43
+ stillOptionalEmpty.push(layer.name);
44
+ return layer;
45
+ }
46
+ ratcheted.push({ layer: layer.name, files });
47
+ return { ...layer, optional: false };
48
+ });
49
+ return {
50
+ ratcheted,
51
+ alreadyStrict,
52
+ stillOptionalEmpty,
53
+ config: { ...(config ?? {}), layers: nextLayers },
54
+ changed: ratcheted.length > 0,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * When the architecture is green (raw violations = 0, not baselined), ratchet populated
60
+ * core layers from optional→required so doctor can honestly report ENFORCE.
61
+ * Empty cores stay optional. Always writes when changes apply (like --update-baseline).
62
+ *
63
+ * @param {string} root
64
+ * @param {object} config
65
+ * @param {string[]} files
66
+ * @param {object[]} rules
67
+ * @param {object[]} violations raw scan violations (baseline ignored — must be truly clean)
68
+ * @param {{ json?: boolean, config?: string }} args
69
+ * @param {{ displayPathFromRoot: (root: string, abs: string) => string }} deps
70
+ */
71
+ export function runRatchetCores(root, config, files, rules, violations, args, deps) {
72
+ const displayPathFromRoot = deps.displayPathFromRoot;
73
+ const cov = computeCoverage(root, config, files, rules);
74
+ const activeCount = Array.isArray(violations) ? violations.length : 0;
75
+ const configPath = path.isAbsolute(args.config)
76
+ ? args.config
77
+ : path.join(root, args.config || 'ark.config.json');
78
+
79
+ const refuse = (code, message, extra = {}) => {
80
+ if (args.json) {
81
+ console.log(JSON.stringify({ ok: false, error: message, ...extra }, null, 2));
82
+ } else {
83
+ console.error(message);
84
+ }
85
+ process.exitCode = code;
86
+ };
87
+
88
+ if (activeCount > 0) {
89
+ refuse(
90
+ 2,
91
+ `Refusing --ratchet-cores: ${activeCount} active architecture violation(s) (raw graph; baseline does not count). Resolve them first (ark-check --plan), then re-run.`,
92
+ { activeViolations: activeCount, governed: cov.governed }
93
+ );
94
+ return;
95
+ }
96
+ if (cov.totalFiles === 0 || (cov.governed?.percent ?? 0) < 50) {
97
+ refuse(
98
+ 2,
99
+ `Refusing --ratchet-cores: governed coverage is too low (${cov.governed?.percent ?? 0}% of ${cov.totalFiles} files). Classify ungoverned code first.`,
100
+ { governed: cov.governed }
101
+ );
102
+ return;
103
+ }
104
+
105
+ const plan = planPopulatedCoreRatchet(config, cov.layers);
106
+ if (!plan.changed) {
107
+ const payload = {
108
+ ok: true,
109
+ changed: false,
110
+ message: 'No optional core layers with files — nothing to ratchet.',
111
+ alreadyStrict: plan.alreadyStrict,
112
+ stillOptionalEmpty: plan.stillOptionalEmpty,
113
+ governed: cov.governed,
114
+ };
115
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
116
+ else {
117
+ console.log(payload.message);
118
+ if (plan.stillOptionalEmpty.length > 0) {
119
+ console.log(`Still optional (empty patterns): ${plan.stillOptionalEmpty.join(', ')}`);
120
+ }
121
+ }
122
+ return;
123
+ }
124
+
125
+ fs.writeFileSync(configPath, `${JSON.stringify(plan.config, null, 2)}\n`);
126
+ const payload = {
127
+ ok: true,
128
+ changed: true,
129
+ configPath: displayPathFromRoot(root, configPath),
130
+ ratcheted: plan.ratcheted,
131
+ stillOptionalEmpty: plan.stillOptionalEmpty,
132
+ governed: cov.governed,
133
+ next: arkCommand(root, 'ark-check', '--doctor'),
134
+ };
135
+ if (args.json) {
136
+ console.log(JSON.stringify(payload, null, 2));
137
+ } else {
138
+ console.log(
139
+ `Ratcheted ${plan.ratcheted.length} core layer(s) to optional: false (populated only):`
140
+ );
141
+ for (const row of plan.ratcheted) {
142
+ console.log(` ${row.layer} (${row.files} file(s))`);
143
+ }
144
+ if (plan.stillOptionalEmpty.length > 0) {
145
+ console.log(
146
+ `Left optional (empty patterns — avoid false ENFORCE): ${plan.stillOptionalEmpty.join(', ')}`
147
+ );
148
+ }
149
+ console.log(`Wrote ${displayPathFromRoot(root, configPath)}`);
150
+ console.log(`Confirm: ${payload.next}`);
151
+ }
152
+ }
@@ -330,6 +330,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
330
330
  archetype: recommendation?.archetype,
331
331
  label: recommendation?.label,
332
332
  preset: recommendation?.preset,
333
+ galleryStarter: recommendation?.galleryStarter,
334
+ policyPack: recommendation?.policyPack,
333
335
  recommendCommand: arkCommand(root, 'ark-check', '--recommend'),
334
336
  initCommand: recommendation?.archetype
335
337
  ? arkCommand(root, 'ark', `init --archetype ${recommendation.archetype} --yes`)
@@ -409,6 +411,24 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
409
411
  console.log(color.bold('New here?'));
410
412
  if (recommendation) {
411
413
  line(warn, `Suggested application shape: ${recommendation.archetype} — ${recommendation.label} (preset ${recommendation.preset})`);
414
+ if (recommendation.galleryStarter) {
415
+ line(ok, `Gallery starter: ${recommendation.galleryStarter}`);
416
+ }
417
+ if (recommendation.policyPack) {
418
+ line(ok, `Policy pack: ${arkCommand(root, 'ark-check', `--apply-policy-pack ${recommendation.policyPack}`)}`);
419
+ }
420
+ if (recommendation.signals?.nestFramework) {
421
+ line(
422
+ ok,
423
+ 'Nest modular monolith → prefer hexagonal (or ddd-bounded-contexts if you have src/contexts/*)'
424
+ );
425
+ }
426
+ if (recommendation.signals?.monorepoTooling?.length) {
427
+ line(
428
+ ok,
429
+ `Monorepo tooling (${recommendation.signals.monorepoTooling.join(', ')}) → preset monorepo (apps/packages/libs)`
430
+ );
431
+ }
412
432
  } else {
413
433
  line(warn, 'Low governed coverage or fresh config — pick an application shape before adding code.');
414
434
  }
@@ -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
+