arkgate 4.8.24 → 4.8.26

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 (166) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +17 -14
  4. package/bin/ark-layer-match.mjs +115 -13
  5. package/bin/ark-mcp-runtime.mjs +36 -31
  6. package/bin/ark-shared.mjs +4 -23
  7. package/bin/lib/adoption-stance.mjs +2 -2
  8. package/bin/lib/adr-path.mjs +3 -3
  9. package/bin/lib/agent-gates.mjs +0 -61
  10. package/bin/lib/agent-homes.mjs +12 -21
  11. package/bin/lib/agent-projection-command.mjs +4 -4
  12. package/bin/lib/ambient-state.mjs +9 -30
  13. package/bin/lib/analysis-engine.mjs +14 -14
  14. package/bin/lib/architecture-scan.mjs +11 -9
  15. package/bin/lib/ark-order-doctor.mjs +3 -28
  16. package/bin/lib/ark-order-facts.mjs +6 -99
  17. package/bin/lib/ark-order-types.mjs +1 -2
  18. package/bin/lib/ark-run-doctor.mjs +3 -28
  19. package/bin/lib/ark-run-facts.mjs +30 -40
  20. package/bin/lib/ark-run-sensors.mjs +1 -1
  21. package/bin/lib/arkrule-file-hints.mjs +2 -47
  22. package/bin/lib/arkrules-contract.mjs +124 -133
  23. package/bin/lib/arkrules-sensors.mjs +7 -5
  24. package/bin/lib/check-args.mjs +63 -1
  25. package/bin/lib/ci-and-commands.mjs +8 -72
  26. package/bin/lib/ci-merge-boundary.mjs +1 -1
  27. package/bin/lib/class-source-scan.mjs +5 -5
  28. package/bin/lib/clone-detection.mjs +474 -0
  29. package/bin/lib/codex-home.mjs +1 -1
  30. package/bin/lib/config-contract-slices.mjs +44 -6
  31. package/bin/lib/config-contract.mjs +40 -117
  32. package/bin/lib/config-extras.mjs +16 -22
  33. package/bin/lib/contract-smells.mjs +33 -15
  34. package/bin/lib/core-ratchet.mjs +0 -2
  35. package/bin/lib/dashboard-cli.mjs +2 -2
  36. package/bin/lib/deep-module-coach.mjs +2 -4
  37. package/bin/lib/deploy-path.mjs +13 -38
  38. package/bin/lib/design-smells.mjs +2 -2
  39. package/bin/lib/diagnostic-catalog.mjs +8 -1
  40. package/bin/lib/doctor-advisories.mjs +55 -9
  41. package/bin/lib/doctor-green-cite.mjs +1 -1
  42. package/bin/lib/doctor-human.mjs +1 -1
  43. package/bin/lib/doctor-next-actions.mjs +2 -2
  44. package/bin/lib/doctor-plan.mjs +4 -6
  45. package/bin/lib/duplication-io.mjs +572 -0
  46. package/bin/lib/effective-contract-load.mjs +181 -44
  47. package/bin/lib/entry-points-io.mjs +424 -0
  48. package/bin/lib/extra-merge-teeth.mjs +36 -0
  49. package/bin/lib/field-install.mjs +38 -14
  50. package/bin/lib/first-run-help.mjs +7 -3
  51. package/bin/lib/flat-parent-importers.mjs +5 -3
  52. package/bin/lib/gate-files.mjs +30 -89
  53. package/bin/lib/github-enforcement.mjs +165 -28
  54. package/bin/lib/golden-pattern.mjs +1 -1
  55. package/bin/lib/hook-templates.mjs +16 -16
  56. package/bin/lib/html-report-advisories.mjs +4 -0
  57. package/bin/lib/html-report-depth.mjs +2 -2
  58. package/bin/lib/html-report.mjs +2 -2
  59. package/bin/lib/import-graph-projection.mjs +112 -0
  60. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  61. package/bin/lib/invariant-coverage-io.mjs +4 -3
  62. package/bin/lib/invariant-coverage.mjs +26 -0
  63. package/bin/lib/invariant-probe-cli.mjs +347 -0
  64. package/bin/lib/invariant-probe-io.mjs +165 -0
  65. package/bin/lib/invariant-probe-runner.mjs +268 -0
  66. package/bin/lib/invariant-probe-sites.mjs +174 -0
  67. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  68. package/bin/lib/invariant-probe.mjs +461 -0
  69. package/bin/lib/invariant-tests-path.mjs +16 -22
  70. package/bin/lib/literal-path-drift.mjs +4 -2
  71. package/bin/lib/managed-upgrade.mjs +2 -8
  72. package/bin/lib/mcp-adoption.mjs +4 -5
  73. package/bin/lib/mcp-hook-payload.mjs +4 -4
  74. package/bin/lib/orphan-modules-io.mjs +458 -0
  75. package/bin/lib/orphan-modules.mjs +251 -0
  76. package/bin/lib/outside-importers.mjs +197 -0
  77. package/bin/lib/package-manager.mjs +3 -3
  78. package/bin/lib/parse-health.mjs +1 -1
  79. package/bin/lib/persistence-write-hint.mjs +33 -8
  80. package/bin/lib/pin-dependent-gates.mjs +2 -2
  81. package/bin/lib/policy-delta-io.mjs +60 -12
  82. package/bin/lib/post-green-path.mjs +1 -1
  83. package/bin/lib/prepare-change.mjs +3 -9
  84. package/bin/lib/prepare-write.mjs +1 -2
  85. package/bin/lib/presets.mjs +56 -68
  86. package/bin/lib/product-copy.mjs +1 -6
  87. package/bin/lib/project-root.mjs +43 -85
  88. package/bin/lib/resolved-candidate-facts.mjs +90 -62
  89. package/bin/lib/rules-under-contract.mjs +72 -8
  90. package/bin/lib/scan-files.mjs +4 -4
  91. package/bin/lib/schema-validation.mjs +130 -0
  92. package/bin/lib/sensor-promote-cli.mjs +2 -11
  93. package/bin/lib/sensor-promote-io.mjs +33 -22
  94. package/bin/lib/sensor-promotion.mjs +1 -1
  95. package/bin/lib/skill-catalog-apply.mjs +1 -1
  96. package/bin/lib/skill-install.mjs +42 -38
  97. package/bin/lib/snippet-analysis.mjs +1 -1
  98. package/bin/lib/start-install-recovery.mjs +1 -1
  99. package/bin/lib/start-preview.mjs +1 -1
  100. package/bin/lib/states-transitions-presence.mjs +3 -3
  101. package/bin/lib/status-command.mjs +2 -18
  102. package/bin/lib/status-manifest.mjs +18 -26
  103. package/bin/lib/status-transition-catalog.mjs +0 -6
  104. package/bin/lib/suggestions.mjs +3 -3
  105. package/bin/lib/team-parliament-io.mjs +3 -17
  106. package/bin/lib/team-parliament.mjs +15 -9
  107. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  108. package/bin/lib/upgrade-command.mjs +2 -24
  109. package/bin/lib/upgrade-outcome.mjs +1 -1
  110. package/bin/lib/upstream-report.mjs +21 -10
  111. package/bin/lib/violations.mjs +10 -9
  112. package/bin/lib/weakest-link.mjs +13 -0
  113. package/bin/lib/write-path-capabilities.mjs +2 -2
  114. package/dist/chunk-G3JRBJGH.js +16 -0
  115. package/dist/chunk-R3AJ5RSZ.cjs +16 -0
  116. package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
  117. package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
  118. package/dist/eslint/index.cjs +11 -11
  119. package/dist/eslint/index.d.cts +10 -1
  120. package/dist/eslint/index.d.ts +10 -1
  121. package/dist/eslint/index.js +11 -11
  122. package/dist/index.cjs +34 -34
  123. package/dist/index.d.cts +587 -515
  124. package/dist/index.d.ts +587 -515
  125. package/dist/index.js +34 -34
  126. package/dist/nestjs/index.cjs +1 -1
  127. package/dist/nestjs/index.d.cts +1 -1
  128. package/dist/nestjs/index.d.ts +1 -1
  129. package/dist/nestjs/index.js +1 -1
  130. package/dist/order/index.cjs +1 -1
  131. package/dist/order/index.js +1 -1
  132. package/dist/runtime/index.cjs +12 -12
  133. package/dist/runtime/index.d.cts +174 -136
  134. package/dist/runtime/index.d.ts +174 -136
  135. package/dist/runtime/index.js +12 -12
  136. package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
  137. package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
  138. package/docs/README.md +3 -1
  139. package/docs/agent-guide.md +140 -6
  140. package/docs/ai-gates.md +15 -1
  141. package/docs/configuration.md +40 -11
  142. package/docs/diagnostics.md +119 -3
  143. package/docs/package-surface.md +4 -1
  144. package/docs/product-voice.md +7 -1
  145. package/package.json +11 -2
  146. package/schemas/ark.config.schema.json +31 -3
  147. package/schemas/ark.invariant-probe.schema.json +368 -0
  148. package/server.json +2 -2
  149. package/templates/agent-skills/README.md +1 -1
  150. package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
  151. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  152. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  153. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  154. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  155. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  156. package/templates/skills/ark-adopt.md +1 -0
  157. package/templates/skills/ark-contract.md +17 -0
  158. package/templates/skills/ark-coverage.md +2 -0
  159. package/templates/skills/ark-explore.md +7 -0
  160. package/templates/skills/ark-fix.md +20 -0
  161. package/templates/skills/ark-place.md +8 -0
  162. package/bin/lib/ark-order-error.mjs +0 -18
  163. package/bin/lib/ark-order-invariants.mjs +0 -388
  164. package/bin/lib/stable-hash.mjs +0 -90
  165. package/dist/chunk-MBXCFLO4.js +0 -16
  166. package/dist/chunk-RNQHRDCQ.cjs +0 -16
@@ -7,6 +7,7 @@ import {
7
7
  directoryArkRulesReader,
8
8
  loadEffectiveArkRules,
9
9
  loadEffectiveArkRulesFromDisk,
10
+ normalizeProjectRelativePath,
10
11
  objectArkRulesReader,
11
12
  } from './effective-contract-load.mjs';
12
13
  import {
@@ -15,7 +16,9 @@ import {
15
16
  loadInvariantCoverageInputs,
16
17
  } from './invariant-coverage-io.mjs';
17
18
  import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
19
+ import { attachProbeEvidence } from './invariant-probe-io.mjs';
18
20
  import { attachPolicyAdrNote } from './adr-path.mjs';
21
+ import { collectGovernedFiles } from './scan-files.mjs';
19
22
 
20
23
  function readJsonFile(filePath, label) {
21
24
  if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
@@ -95,6 +98,20 @@ function configPathInRepository(root, configPath, top) {
95
98
  * ArkRules files at the base ref, relative to the project root inside the repository.
96
99
  * A path absent at the ref (added by the candidate) reads as `missing`.
97
100
  */
101
+ function baseRefSourceFiles(top, ref, prefix) {
102
+ const shown = runGit(top, ['ls-tree', '-r', '--name-only', ref]);
103
+ if (shown.status !== 0) return [];
104
+ const rootPrefix = prefix ? `${prefix}/` : '';
105
+ const files = [];
106
+ for (const line of shown.stdout.split('\n')) {
107
+ if (!line) continue;
108
+ if (rootPrefix && !line.startsWith(rootPrefix)) continue;
109
+ const rel = rootPrefix ? line.slice(rootPrefix.length) : line;
110
+ if (/\.[cm]?[tj]sx?$/.test(rel) && !rel.endsWith('.d.ts')) files.push(rel);
111
+ }
112
+ return files;
113
+ }
114
+
98
115
  function baseRefArkRulesReader(root, top, ref) {
99
116
  const prefix = path
100
117
  .relative(fs.realpathSync(top), fs.realpathSync(root))
@@ -102,6 +119,7 @@ function baseRefArkRulesReader(root, top, ref) {
102
119
  .join('/');
103
120
  const spec = (rel) => `${ref}:${prefix ? `${prefix}/` : ''}${rel}`;
104
121
  return {
122
+ files: baseRefSourceFiles(top, ref, prefix),
105
123
  readArkRulesFile: (rel) => {
106
124
  const shown = runGit(top, ['show', spec(rel)]);
107
125
  if (shown.status === 0) return { ok: true, content: shown.stdout };
@@ -131,11 +149,16 @@ export function resolvePolicyBaseConfig({
131
149
  // A base config FILE carries its ArkRules next to it (same relative paths as the
132
150
  // project), never from the candidate working tree: that would compare the
133
151
  // candidate catalog with itself and hide every demotion/deletion.
152
+ const baseRoot = fs.realpathSync(path.dirname(absolute));
153
+ const include = Array.isArray(config.include) && config.include.length > 0 ? config.include : ['src'];
134
154
  return {
135
155
  config,
136
156
  source: absolute,
137
157
  ref: null,
138
- readArkRulesFile: directoryArkRulesReader(fs.realpathSync(path.dirname(absolute))),
158
+ files: collectGovernedFiles(baseRoot, { ...config, include })
159
+ .map((file) => path.relative(baseRoot, file).split(path.sep).join('/'))
160
+ .filter((rel) => rel && !rel.startsWith('..')),
161
+ readArkRulesFile: directoryArkRulesReader(baseRoot),
139
162
  };
140
163
  }
141
164
 
@@ -198,9 +221,10 @@ export function readPolicyAcknowledgement(root, acknowledgementPath) {
198
221
  * failure is fail-closed: silently dropping the base catalog would make every
199
222
  * ArkRule demotion or deletion classify as neutral.
200
223
  */
201
- export function loadBaseArkRules(base) {
224
+ function loadBaseArkRules(base) {
202
225
  const loaded = loadEffectiveArkRules(base.config, base.readArkRulesFile, {
203
226
  missingAsEmpty: base.ref != null,
227
+ ...(Array.isArray(base.files) ? { files: base.files } : {}),
204
228
  });
205
229
  if (loaded.errors.length > 0) {
206
230
  const message = loaded.errors.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n');
@@ -220,6 +244,17 @@ function hasArkRulesMap(config) {
220
244
  return Boolean(refs && typeof refs === 'object' && Object.keys(refs).length > 0);
221
245
  }
222
246
 
247
+ /** Paths in a supplied catalog. A slice rule file is enough for the child wall to name it. */
248
+ function catalogPaths(files) {
249
+ if (!files || typeof files !== 'object') return [];
250
+ const out = [];
251
+ for (const key of Object.keys(files)) {
252
+ const rel = normalizeProjectRelativePath(String(key));
253
+ if (rel) out.push(rel);
254
+ }
255
+ return out;
256
+ }
257
+
223
258
  function loadedOrThrow(loaded, label) {
224
259
  if (loaded.errors.length === 0) return loaded.arkRules;
225
260
  const message = loaded.errors.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n');
@@ -275,7 +310,9 @@ export function resolvePolicyDeltaArkRules({
275
310
  );
276
311
  }
277
312
  baseArkRules = loadedOrThrow(
278
- loadEffectiveArkRules(baseConfig, objectArkRulesReader(baseArkRuleFiles)),
313
+ loadEffectiveArkRules(baseConfig, objectArkRulesReader(baseArkRuleFiles), {
314
+ files: catalogPaths(baseArkRuleFiles),
315
+ }),
279
316
  'Base'
280
317
  );
281
318
  } else {
@@ -285,7 +322,9 @@ export function resolvePolicyDeltaArkRules({
285
322
  let candidateArkRules;
286
323
  if (candidateArkRuleFiles && typeof candidateArkRuleFiles === 'object') {
287
324
  candidateArkRules = loadedOrThrow(
288
- loadEffectiveArkRules(candidateConfig, objectArkRulesReader(candidateArkRuleFiles)),
325
+ loadEffectiveArkRules(candidateConfig, objectArkRulesReader(candidateArkRuleFiles), {
326
+ files: catalogPaths(candidateArkRuleFiles),
327
+ }),
289
328
  'Candidate'
290
329
  );
291
330
  } else if (candidateIsProjectConfig) {
@@ -316,16 +355,17 @@ export function analyzePolicyTransition({
316
355
  acknowledgementPath,
317
356
  }) {
318
357
  if (!strictMerge && !basePath && !baseRef && !acknowledgementPath) return undefined;
358
+ if (strictMerge && !basePath) {
359
+ const top = repositoryRoot(root);
360
+ if (top) configPathInRepository(root, configPath, top);
361
+ }
319
362
  const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef, adoptMissingBase });
320
363
  if (!base && (basePath || (baseRef && !adoptMissingBase) || acknowledgementPath)) {
321
364
  throw new Error('Policy delta was requested but no policy base could be resolved.');
322
365
  }
323
366
  if (!base) return undefined;
324
367
 
325
- // AR02/AR11: load Effective ArkRules so mode edits inside arkrules/*.json classify,
326
- // and attach candidate coverage so covered advisory→enforced can auto-allow.
327
- // Base ArkRules come from the SAME source as the base config (git ref or the
328
- // base file's directory), never from the candidate working tree.
368
+ // Base ArkRules come from the base ref or base file, never the candidate tree.
329
369
  const baseArkRules = loadBaseArkRules(base);
330
370
  const candidateLoad = loadEffectiveArkRulesFromDisk(root, candidateConfig);
331
371
  if (candidateLoad.errors.length > 0) {
@@ -347,12 +387,20 @@ export function analyzePolicyTransition({
347
387
  fileContents: coverageInputs.fileContents,
348
388
  testFiles: coverageInputs.testFiles,
349
389
  testGlobsMissing: coverageInputs.testGlobsMissing,
350
- // No coverageStats / coverageRoots: this caller reads coverage ROWS and
351
- // drops the violations, and both only shape violation messages. Passing
352
- // them would look like wiring while changing nothing observable here.
353
390
  coverageBudgetExhausted: coverageInputs.coverageBudgetExhausted === true,
391
+ ...(coverageInputs.stats ? { coverageStats: coverageInputs.stats } : {}),
392
+ // The declared roots set `coverageRootsDeclared` / `outsideDeclaredRoots`
393
+ // on each row, and canPromoteInvariant refuses without them. Same inputs
394
+ // as `--promote` (sensor-promote-io), so both surfaces judge alike.
395
+ ...(coverageInputs.coverageRoots ? { coverageRoots: coverageInputs.coverageRoots } : {}),
354
396
  });
355
- candidateInvariantCoverage = evaluated.coverage;
397
+ // ADR 0039: the same committed probe evidence --promote reads, so both
398
+ // surfaces refuse alike. Read-only; nothing here runs a test.
399
+ candidateInvariantCoverage = attachProbeEvidence(
400
+ root,
401
+ evaluated.coverage,
402
+ candidateArkRules.invariants
403
+ ).rows;
356
404
  }
357
405
 
358
406
  const acknowledgement = readPolicyAcknowledgement(root, acknowledgementPath);
@@ -21,7 +21,7 @@ export const POST_GREEN_PRIMARY_SKILL = '/ark-explore';
21
21
  export const POST_GREEN_PRIMARY_ACTION = POST_GREEN_HUMAN;
22
22
 
23
23
  /** Short label for tables / metrics. */
24
- export const POST_GREEN_PRIMARY_SHORT =
24
+ const POST_GREEN_PRIMARY_SHORT =
25
25
  '/ark-explore shape-focus → /ark-autopilot (apply one B pilot) # Shape residual';
26
26
 
27
27
  /** Placement coaching while design residual remains (new code only). */
@@ -15,7 +15,7 @@ import {
15
15
  import { effectiveAnalysisConfig } from './analysis-policy.mjs';
16
16
  import { isScanExcludedRelative } from '../ark-shared.mjs';
17
17
  import { classifyChangeSet, evaluateTeamGate } from './team-parliament.mjs';
18
- import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
18
+ import { arkRulesLoadFailed, loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
19
19
  import {
20
20
  coverageOptionsFromConfig,
21
21
  invariantIdsFromCatalog,
@@ -83,7 +83,7 @@ function assertInsideProject(root, relativePath) {
83
83
  }
84
84
  }
85
85
 
86
- export function normalizeChangeSet(input) {
86
+ function normalizeChangeSet(input) {
87
87
  if (!Array.isArray(input)) throw new Error('changes must be an array.');
88
88
  return input.map((change, index) => {
89
89
  if (!change || typeof change !== 'object' || Array.isArray(change)) {
@@ -201,13 +201,7 @@ export function prepareChangeFromRoot({
201
201
  // and a broken reference fails closed instead of silently dropping the catalog.
202
202
  const arkRulesLoad = loadEffectiveArkRulesFromDisk(root, effectiveConfig);
203
203
  if (arkRulesLoad.errors.length > 0) {
204
- const message = arkRulesLoad.errors
205
- .map((issue) => (typeof issue === 'string' ? issue : `- ${issue.path}: ${issue.message}`))
206
- .join('\n');
207
- const error = new Error(`Invalid Effective Contract (${contractSource}):\n${message}`);
208
- error.code = 'ARKRULES_LOAD_FAILED';
209
- error.issues = arkRulesLoad.errors;
210
- throw error;
204
+ throw arkRulesLoadFailed(contractSource, arkRulesLoad.errors);
211
205
  }
212
206
  const contract = loadContract(effectiveConfig, contractSource, {
213
207
  arkRules: arkRulesLoad.arkRules,
@@ -8,11 +8,10 @@ import crypto from 'node:crypto';
8
8
  import { validateWithAutoPatch } from './auto-patch.mjs';
9
9
  import { classifyRemediation, enrichViolationWithFixClass } from './remediation.mjs';
10
10
  import {
11
- layerDescriptionCaption,
12
11
  placementDescriptionFields,
13
12
  } from './layer-description.mjs';
14
13
 
15
- export { layerDescriptionCaption, placementDescriptionFields };
14
+ export { placementDescriptionFields };
16
15
 
17
16
  /**
18
17
  * Stable content identity for host commit / cache keys.
@@ -19,7 +19,7 @@ import { withArkConfigMetadata } from './config-contract.mjs';
19
19
  const PRESETS_DIR = path.dirname(fileURLToPath(import.meta.url));
20
20
  const ARKRULES_TEMPLATES_DIR = path.join(PRESETS_DIR, '../../templates/arkrules');
21
21
 
22
- export function denyUpward(names) {
22
+ function denyUpward(names) {
23
23
  const rules = [];
24
24
  for (let i = 0; i < names.length; i += 1) {
25
25
  for (let j = i + 1; j < names.length; j += 1) {
@@ -33,7 +33,7 @@ export function denyUpward(names) {
33
33
  * peerIsolation matrix: deny only when importer/importee sit under different slices.
34
34
  * Covers same-layer and cross-layer pairs (honest DDD / vertical-slice isolation).
35
35
  */
36
- export function peerIsolationEdges(layerNames, sliceFolders, message) {
36
+ function peerIsolationEdges(layerNames, sliceFolders, message) {
37
37
  const rules = [];
38
38
  for (const from of layerNames) {
39
39
  for (const to of layerNames) {
@@ -57,7 +57,7 @@ export function peerIsolationEdges(layerNames, sliceFolders, message) {
57
57
  // Framework internals under `src/kernel/**` are NOT application architecture — a broad
58
58
  // `src/**/domain/**` would otherwise swallow `src/kernel/domain`. Do NOT use `**/kernel/**`
59
59
  // (that carves out legitimate `src/shared/kernel/**` SharedKernel paths).
60
- export const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
60
+ const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
61
61
 
62
62
  /**
63
63
  * Shared high-spec domain globs. Prefer these over Application/Presentation scatter
@@ -217,18 +217,6 @@ export const APPLICATION_LIB_ORCHESTRATION_PATTERNS = Object.freeze([
217
217
  '**/lib/api-handlers/**',
218
218
  ]);
219
219
 
220
- /**
221
- * AR08 — attach lean arkRules map. Keys are always exact project layer names.
222
- * Sensor roles (domain-structure / orchestration / adapter-thin / generic) are
223
- * independent of display names so renamed layers still get the right starter.
224
- */
225
- export const DEFAULT_ARKRULES_REFS = {
226
- DomainModel: 'arkrules/DomainModel.json',
227
- ApplicationOrchestration: 'arkrules/ApplicationOrchestration.json',
228
- PresentationAdapters: 'arkrules/PresentationAdapters.json',
229
- PersistenceAdapters: 'arkrules/PersistenceAdapters.json',
230
- };
231
-
232
220
  /** Sensor roles used when selecting or synthesizing per-layer templates. */
233
221
  export const ARKRULES_SENSOR_ROLES = Object.freeze({
234
222
  DOMAIN_STRUCTURE: 'domain-structure',
@@ -241,7 +229,7 @@ export const ARKRULES_SENSOR_ROLES = Object.freeze({
241
229
  * Exact-name aliases → sensor role. Prefer this table over heuristics when the
242
230
  * project uses a known vocabulary (hexagonal, monorepo field renames, etc.).
243
231
  */
244
- export const LAYER_SENSOR_ROLE_ALIASES = Object.freeze({
232
+ const LAYER_SENSOR_ROLE_ALIASES = Object.freeze({
245
233
  // Domain / pure model
246
234
  DomainModel: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
247
235
  Domain: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
@@ -448,7 +436,46 @@ export function writeArkRulesTemplates(root, config, { force = false } = {}) {
448
436
  return written;
449
437
  }
450
438
 
451
- export function presetWithOverlays(baseConfig, root) {
439
+ /**
440
+ * Deny edges shared by the ports-and-adapters presets after their DomainModel
441
+ * rules: orchestration never reaches adapters, adapters never reach each other,
442
+ * and presentation never reaches the domain directly. Fresh objects per call.
443
+ */
444
+ function portsAndAdaptersRules() {
445
+ return [
446
+ { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
447
+ { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
448
+ { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
449
+ { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
450
+ { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
451
+ { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
452
+ ];
453
+ }
454
+
455
+ /**
456
+ * UI-surface preset rules: the domain and orchestration stay out of the UI, while
457
+ * presentation may reach data clients on day one (soft, `message` explains why).
458
+ * Persistence → Presentation stays denied. Fresh objects per call.
459
+ * @param {string} presentationToPersistenceMessage
460
+ */
461
+ function uiSurfaceRules(presentationToPersistenceMessage) {
462
+ return [
463
+ { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
464
+ { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
465
+ { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
466
+ { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
467
+ {
468
+ from: 'PresentationAdapters',
469
+ to: 'PersistenceAdapters',
470
+ allowed: true,
471
+ message: presentationToPersistenceMessage,
472
+ },
473
+ { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
474
+ { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
475
+ ];
476
+ }
477
+
478
+ function presetWithOverlays(baseConfig, root) {
452
479
  const config = root ? applyFrameworkLayoutOverlays(baseConfig, root) : baseConfig;
453
480
  return withArkConfigMetadata(withDefaultArkRules(config));
454
481
  }
@@ -511,12 +538,7 @@ export const ARCHITECTURE_PRESETS = {
511
538
  { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
512
539
  { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
513
540
  { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
514
- { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
515
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
516
- { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
517
- { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
518
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
519
- { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
541
+ ...portsAndAdaptersRules(),
520
542
  ],
521
543
  },
522
544
  root
@@ -705,12 +727,7 @@ export const ARCHITECTURE_PRESETS = {
705
727
  { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
706
728
  { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
707
729
  { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
708
- { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
709
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
710
- { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
711
- { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
712
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
713
- { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
730
+ ...portsAndAdaptersRules(),
714
731
  ],
715
732
  },
716
733
  root
@@ -810,24 +827,12 @@ export const ARCHITECTURE_PRESETS = {
810
827
  optional: true,
811
828
  },
812
829
  ],
813
- rules: [
814
- { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
815
- { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
816
- { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
817
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
818
- // Next RSC often imports data clients from routes; deny is ideal but day-one
819
- // ui-surface keeps this as a soft guidance edge (allowed) until ports exist —
820
- // Persistence → Presentation stays denied when that edge appears.
821
- {
822
- from: 'PresentationAdapters',
823
- to: 'PersistenceAdapters',
824
- allowed: true,
825
- message:
826
- 'UI/routes may reach data clients on day one (RSC); prefer application ports as the product grows.',
827
- },
828
- { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
829
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
830
- ],
830
+ // Next RSC often imports data clients from routes; deny is ideal but day-one
831
+ // ui-surface keeps this as a soft guidance edge (allowed) until ports exist —
832
+ // Persistence → Presentation stays denied when that edge appears.
833
+ rules: uiSurfaceRules(
834
+ 'UI/routes may reach data clients on day one (RSC); prefer application ports as the product grows.'
835
+ ),
831
836
  },
832
837
  root
833
838
  ),
@@ -978,12 +983,7 @@ export const ARCHITECTURE_PRESETS = {
978
983
  { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
979
984
  { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
980
985
  { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
981
- { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
982
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
983
- { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
984
- { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
985
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
986
- { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
986
+ ...portsAndAdaptersRules(),
987
987
  { from: 'SharedKernel', to: 'DomainModel', allowed: false },
988
988
  { from: 'SharedKernel', to: 'ApplicationOrchestration', allowed: false },
989
989
  { from: 'SharedKernel', to: 'PresentationAdapters', allowed: false },
@@ -1078,21 +1078,9 @@ export const ARCHITECTURE_PRESETS = {
1078
1078
  optional: true,
1079
1079
  },
1080
1080
  ],
1081
- rules: [
1082
- { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
1083
- { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
1084
- { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
1085
- { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
1086
- {
1087
- from: 'PresentationAdapters',
1088
- to: 'PersistenceAdapters',
1089
- allowed: true,
1090
- message:
1091
- 'SPA day-one UI may reach data clients; prefer ports as the product grows.',
1092
- },
1093
- { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
1094
- { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
1095
- ],
1081
+ rules: uiSurfaceRules(
1082
+ 'SPA day-one UI may reach data clients; prefer ports as the product grows.'
1083
+ ),
1096
1084
  },
1097
1085
  root
1098
1086
  ),
@@ -25,7 +25,7 @@ export const ARKRULES_FIRST_CONTACT_NEXT =
25
25
  export const ARKRULES_EMPTY_CATALOG_NEXT =
26
26
  'Next: add 1–2 short phrases to invariants[] in the Domain rules file, then ark-check --doctor.';
27
27
 
28
- /** Status-light leftover-design qualifier (was “design-weak”). */
28
+ /** Status-light leftover-design qualifier (was “design-weak”); also the HTML/doctor badge text. */
29
29
  export const LEFTOVER_DESIGN_LABEL = 'leftover design work';
30
30
 
31
31
  /**
@@ -41,14 +41,9 @@ export function operatingModeTitle(mode, leftoverDesign, stewardsUnset) {
41
41
  return light;
42
42
  }
43
43
 
44
- /** Short HTML/doctor badge text. */
45
- export const LEFTOVER_DESIGN_BADGE = LEFTOVER_DESIGN_LABEL;
46
-
47
44
  export const POST_GREEN_HUMAN =
48
45
  'Imports check out, but the design is still messy. Next: /ark-explore, then one small refactor with /ark-autopilot and your OK.';
49
46
 
50
47
  export const POST_GREEN_LEDE =
51
48
  'Import rules are clean, but leftover design work remains. That does not fail the check — it only means “done” is still wrong until you tidy shape.';
52
49
 
53
- export const HARD_WRITE_HUMAN = 'pre-write block';
54
- export const ADVISORY_WRITE_HUMAN = 'warning only (not blocked)';
@@ -4,6 +4,12 @@
4
4
  * Monorepo honesty (NEW-MONOREPO-CWD-WALKUP): when cwd (or --root) has no
5
5
  * ark.config.json, walk parent directories until one is found. Never invent a
6
6
  * silent 11-layer / empty ADAPT world while a parent monorepo contract exists.
7
+ * That ancestor contains the directory the caller named, so the requested tree
8
+ * stays inside the analysis.
9
+ *
10
+ * An explicit --config path (absolute, or any name with a separator) is a file.
11
+ * Layer patterns resolve against the caller's --root. The file's directory is
12
+ * never a fallback analysis root (#352).
7
13
  *
8
14
  * Security (S0 walk-up review):
9
15
  * - Split **config discovery root** from **write root**.
@@ -16,14 +22,14 @@ import fs from 'node:fs';
16
22
  import path from 'node:path';
17
23
 
18
24
  /** Safety cap so a pathological tree cannot walk forever. */
19
- export const MAX_CONFIG_WALK_DEPTH = 32;
25
+ const MAX_CONFIG_WALK_DEPTH = 32;
20
26
 
21
27
  /**
22
28
  * True when dir looks like a package-manager workspaces / monorepo root.
23
29
  * Used as an upper bound for walk-up (config at this root is accepted; parents are not).
24
30
  * @param {string} dir
25
31
  */
26
- export function isWorkspacesPackageRoot(dir) {
32
+ function isWorkspacesPackageRoot(dir) {
27
33
  const pkgPath = path.join(dir, 'package.json');
28
34
  try {
29
35
  if (!fs.statSync(pkgPath, { throwIfNoEntry: false })?.isFile()) {
@@ -55,7 +61,7 @@ export function isWorkspacesPackageRoot(dir) {
55
61
  * True when dir is a git worktree root (.git file or directory).
56
62
  * @param {string} dir
57
63
  */
58
- export function isGitRoot(dir) {
64
+ function isGitRoot(dir) {
59
65
  try {
60
66
  const git = path.join(dir, '.git');
61
67
  const st = fs.statSync(git, { throwIfNoEntry: false });
@@ -97,18 +103,13 @@ function isFile(absPath) {
97
103
  }
98
104
 
99
105
  /** True when --config is a path (nested or absolute), not a basename to walk. */
100
- export function configNameIsPath(configName) {
106
+ function configNameIsPath(configName) {
101
107
  return (
102
108
  typeof configName === 'string' &&
103
109
  (path.isAbsolute(configName) || /[\\/]/.test(configName))
104
110
  );
105
111
  }
106
112
 
107
- function isInsideRoot(root, target) {
108
- const rel = path.relative(root, target);
109
- return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
110
- }
111
-
112
113
  /**
113
114
  * Resolve a nested/absolute --config path without walking parents.
114
115
  * `--root examples/app --config examples/app/ark.config.json` must load the nested
@@ -118,7 +119,7 @@ function isInsideRoot(root, target) {
118
119
  * @param {string} configName
119
120
  * @returns {string | null} absolute config file path
120
121
  */
121
- export function resolveConfigPathCandidate(startDir, configName) {
122
+ function resolveConfigPathCandidate(startDir, configName) {
122
123
  if (typeof configName !== 'string' || configName.trim() === '') return null;
123
124
  const start = path.resolve(startDir || process.cwd());
124
125
  if (path.isAbsolute(configName)) return isFile(configName) ? path.resolve(configName) : null;
@@ -145,7 +146,8 @@ export function resolveConfigPathCandidate(startDir, configName) {
145
146
  * Walk parents from startDir looking for configName (default ark.config.json).
146
147
  * Bounds: filesystem root, max depth, git root, workspaces package root.
147
148
  * Config found at a bound root is accepted; walking above a bound is refused.
148
- * Nested relative --config (`examples/app/ark.config.json`) is a file path, never a walk-up name.
149
+ * An explicit --config path is a file. The returned root stays `startDir`.
150
+ * Basename walk-up (`ark.config.json`) may still adopt an ancestor.
149
151
  *
150
152
  * @param {string} startDir
151
153
  * @param {string} [configName='ark.config.json']
@@ -157,26 +159,12 @@ export function findNearestArkConfig(startDir, configName = 'ark.config.json', o
157
159
  const boundAtGit = opts.boundAtGitRoot !== false;
158
160
  const boundAtWorkspaces = opts.boundAtWorkspacesRoot !== false;
159
161
 
160
- if (typeof configName === 'string' && path.isAbsolute(configName)) {
161
- if (isFile(configName)) {
162
- const root = path.dirname(configName);
163
- const start = path.resolve(startDir || process.cwd());
164
- return { root, configPath: path.resolve(configName), walkedUp: path.resolve(root) !== start };
165
- }
166
- return null;
167
- }
168
-
169
162
  const name = configName || 'ark.config.json';
170
163
  if (configNameIsPath(name)) {
171
164
  const resolved = resolveConfigPathCandidate(startDir, name);
172
165
  if (!resolved) return null;
173
166
  const start = path.resolve(startDir || process.cwd());
174
- const inside = isInsideRoot(start, resolved);
175
- return {
176
- root: inside ? start : path.dirname(resolved),
177
- configPath: resolved,
178
- walkedUp: !inside,
179
- };
167
+ return { root: start, configPath: resolved, walkedUp: false };
180
168
  }
181
169
 
182
170
  let dir = path.resolve(startDir || process.cwd());
@@ -213,9 +201,10 @@ export function findNearestArkConfig(startDir, configName = 'ark.config.json', o
213
201
  * Resolve config discovery vs write roots for CLI invocation.
214
202
  *
215
203
  * - If config exists at startRoot → use startRoot for both.
216
- * - Else walk parents for config (bounded) → configRoot may differ from writeRoot.
204
+ * - An explicit --config path keeps `root` at startRoot. The file's directory is not a fallback.
205
+ * - Else walk parents for a basename config (bounded) → configRoot may differ from writeRoot.
217
206
  * - writeMode without followConfigRoot: keep writeRoot/start as `root` (do not rewrite parent).
218
- * - writeMode + followConfigRoot (or read mode): adopt walked config root as `root`.
207
+ * - writeMode + followConfigRoot (or read mode): adopt a basename walk-up root as `root`.
219
208
  *
220
209
  * @param {string} startRoot
221
210
  * @param {{
@@ -243,76 +232,45 @@ export function resolveEffectiveProjectRoot(startRoot, opts = {}) {
243
232
  // Writes adopt walked config root only with explicit opt-in.
244
233
  const adoptWalkedRoot = !writeMode || followConfigRoot;
245
234
 
235
+ /** Config found by walk-up (or at start); root follows it unless a plain write. */
236
+ const walkedResult = (found) => ({
237
+ root: adoptWalkedRoot ? found.root : start,
238
+ writeRoot: start,
239
+ config: configName,
240
+ configPath: found.configPath,
241
+ configRoot: found.root,
242
+ walkedUp: found.walkedUp,
243
+ configFound: true,
244
+ writeRootFollowedConfig: adoptWalkedRoot && found.walkedUp,
245
+ });
246
+ /** Everything stays at the start directory. */
247
+ const startResult = (configPath, configFound) => ({
248
+ root: start,
249
+ writeRoot: start,
250
+ config: configName,
251
+ configPath,
252
+ configRoot: start,
253
+ walkedUp: false,
254
+ configFound,
255
+ writeRootFollowedConfig: false,
256
+ });
257
+
246
258
  if (typeof configName === 'string' && path.isAbsolute(configName)) {
247
259
  const found = findNearestArkConfig(start, configName, opts);
248
- if (found) {
249
- const root = adoptWalkedRoot ? found.root : start;
250
- return {
251
- root,
252
- writeRoot: start,
253
- config: configName,
254
- configPath: found.configPath,
255
- configRoot: found.root,
256
- walkedUp: found.walkedUp,
257
- configFound: true,
258
- writeRootFollowedConfig: adoptWalkedRoot && found.walkedUp,
259
- };
260
- }
261
- return {
262
- root: start,
263
- writeRoot: start,
264
- config: configName,
265
- configPath: configName,
266
- configRoot: start,
267
- walkedUp: false,
268
- configFound: false,
269
- writeRootFollowedConfig: false,
270
- };
260
+ return found ? walkedResult(found) : startResult(configName, false);
271
261
  }
272
262
 
273
263
  const localPath = path.join(start, configName);
274
264
  try {
275
265
  if (fs.statSync(localPath, { throwIfNoEntry: false })?.isFile()) {
276
- return {
277
- root: start,
278
- writeRoot: start,
279
- config: configName,
280
- configPath: localPath,
281
- configRoot: start,
282
- walkedUp: false,
283
- configFound: true,
284
- writeRootFollowedConfig: false,
285
- };
266
+ return startResult(localPath, true);
286
267
  }
287
268
  } catch {
288
269
  // fall through to walk-up
289
270
  }
290
271
 
291
272
  const found = findNearestArkConfig(start, configName, opts);
292
- if (found) {
293
- const root = adoptWalkedRoot ? found.root : start;
294
- return {
295
- root,
296
- writeRoot: start,
297
- config: configName,
298
- configPath: found.configPath,
299
- configRoot: found.root,
300
- walkedUp: found.walkedUp,
301
- configFound: true,
302
- writeRootFollowedConfig: adoptWalkedRoot && found.walkedUp,
303
- };
304
- }
305
-
306
- return {
307
- root: start,
308
- writeRoot: start,
309
- config: configName,
310
- configPath: localPath,
311
- configRoot: start,
312
- walkedUp: false,
313
- configFound: false,
314
- writeRootFollowedConfig: false,
315
- };
273
+ return found ? walkedResult(found) : startResult(localPath, false);
316
274
  }
317
275
 
318
276
  /**