arkgate 4.8.21 → 4.8.23

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 (42) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +2 -2
  3. package/bin/ark-check-runtime.mjs +4 -2
  4. package/bin/ark-layer-match.mjs +606 -11
  5. package/bin/lib/analysis-engine.mjs +11 -11
  6. package/bin/lib/config-contract.mjs +492 -1
  7. package/bin/lib/diagnostic-catalog.mjs +32 -0
  8. package/bin/lib/doctor-advisories.mjs +60 -1
  9. package/bin/lib/doctor-plan.mjs +2 -2
  10. package/bin/lib/enforcement-honesty.mjs +22 -0
  11. package/bin/lib/flat-parent-pilot.mjs +252 -0
  12. package/bin/lib/html-report-advisories.mjs +2 -0
  13. package/bin/lib/html-report-depth.mjs +3 -1
  14. package/bin/lib/invariant-coverage.mjs +27 -3
  15. package/bin/lib/physical-cohesion.mjs +16 -18
  16. package/bin/lib/pilot-loop.mjs +21 -8
  17. package/bin/lib/team-parliament-io.mjs +4 -2
  18. package/dist/{configTypes-CTELo87-.d.ts → configTypes-Bbf_MFGe.d.ts} +36 -2
  19. package/dist/{diagnosticCatalog-B7BXlet6.d.ts → diagnosticCatalog-35mrwAHe.d.ts} +20 -5
  20. package/dist/eslint/index.cjs +4 -4
  21. package/dist/eslint/index.d.ts +51 -5
  22. package/dist/eslint/index.js +4 -4
  23. package/dist/index.cjs +36 -36
  24. package/dist/index.d.ts +104 -5
  25. package/dist/index.js +36 -36
  26. package/dist/nestjs/index.cjs +9 -9
  27. package/dist/nestjs/index.d.ts +3 -3
  28. package/dist/nestjs/index.js +9 -9
  29. package/dist/runtime/index.cjs +19 -19
  30. package/dist/runtime/index.d.ts +6 -6
  31. package/dist/runtime/index.js +19 -19
  32. package/dist/{types-yDNEQ603.d.ts → types-Bdj32QwA.d.ts} +1 -1
  33. package/dist/{types-fQ9-KrBj.d.ts → types-Ik_OakcI.d.ts} +1 -1
  34. package/docs/README.md +3 -1
  35. package/docs/agent-guide.md +9 -1
  36. package/docs/configuration.md +92 -1
  37. package/docs/diagnostics.md +32 -0
  38. package/docs/product-voice.md +1 -1
  39. package/package.json +2 -1
  40. package/schemas/ark.config.schema.json +124 -2
  41. package/server.json +2 -2
  42. package/templates/agent-skills/README.md +1 -1
@@ -21,10 +21,10 @@ import { collectStatesTransitionsResidual } from './states-transitions-presence.
21
21
  import { collectStatusTransitionCatalogResidual } from './status-transition-catalog.mjs';
22
22
  import { collectNoDomainFrontendResidual } from './no-domain-frontend.mjs';
23
23
  import { collectInvariantCoverageResiduals } from './invariant-tests-path.mjs';
24
+ import { anyChildWallAdvisory, anySliceAlias } from '../ark-layer-match.mjs';
24
25
  export { printAdrPresenceHint };
25
26
  export { printDoctorCompactHuman, printDoctorDetailsHuman };
26
27
  export { summarizeRulesUnderContract };
27
-
28
28
  /** Optional S3 dual-match classifier when ark-shared exports it (soft dep for S5 landing). */
29
29
  const matchingLayersForRelativePath =
30
30
  typeof arkShared.matchingLayersForRelativePath === 'function'
@@ -694,7 +694,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
694
694
  github: githubForBoundary,
695
695
  adoptionStance: stanceFile,
696
696
  stewardNudge: doctorAdvisories.stewardNudge,
697
- physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion),
697
+ physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion), childWallAdvisory: anyChildWallAdvisory(config?.rules), sliceAliasDebt: anySliceAlias(config?.rules),
698
698
  });
699
699
 
700
700
  // Improvement compass: projection only — never feeds ok/valid/goal.met.
@@ -289,6 +289,8 @@ export function buildWritePathHonesty(activeHost, hardWriteActive = false, extra
289
289
  * operatingMode?: string | null,
290
290
  * activeBlockingViolations?: number | null,
291
291
  * physicalCohesionResidual?: boolean,
292
+ * childWallAdvisory?: boolean,
293
+ * sliceAliasDebt?: boolean,
292
294
  * }} input
293
295
  */
294
296
  export function physicalCohesionResidualRemains(cohesion) {
@@ -489,6 +491,22 @@ export function buildProductHonesty(input = {}) {
489
491
  // Informational only when no enforced arkrule plane — does not alone make unfinished.
490
492
  }
491
493
 
494
+ if (input.childWallAdvisory === true) {
495
+ reasons.push({
496
+ id: 'child-wall-advisory',
497
+ message:
498
+ 'A child wall lists siblings as advisory. Recorded crossings stay non-blocking, and a new sibling crossing past that baseline still fails. An enforce list makes those subtrees errors and does not finish the house. This house stays unfinished until siblings is deny.',
499
+ });
500
+ }
501
+
502
+ if (input.sliceAliasDebt === true) {
503
+ reasons.push({
504
+ id: 'slice-alias-debt',
505
+ message:
506
+ 'Slice aliases are an owed move, not a destination. Aliased files are not finished until they move into the target slice folder.',
507
+ });
508
+ }
509
+
492
510
  // EH05: environment residual deny-list (future reason ids stay architecture debt by default).
493
511
  const ENVIRONMENT_REASON_IDS = new Set(['soft-write-host', 'native-fail-open']);
494
512
 
@@ -683,6 +701,8 @@ export function computeDoctorEnforcementHonesty({
683
701
  nativeFailClosed,
684
702
  nativeFailClosedPolicy,
685
703
  physicalCohesionResidual,
704
+ childWallAdvisory,
705
+ sliceAliasDebt,
686
706
  } = {}) {
687
707
  const coverageHonesty = buildCoverageHonesty({
688
708
  percent: governedPercent,
@@ -741,6 +761,8 @@ export function computeDoctorEnforcementHonesty({
741
761
  emptyStewards,
742
762
  stewardNudge,
743
763
  physicalCohesionResidual,
764
+ childWallAdvisory,
765
+ sliceAliasDebt,
744
766
  });
745
767
  return {
746
768
  coverageHonesty,
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Doctor suggestion: a universe-level flat file that one child slice imports.
3
+ * Advisory only. Not a finding, not a verdict, not a config field.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ layerForRelativePath,
9
+ resolveGovernedSlice,
10
+ sliceAliasDestination,
11
+ sliceIdForPath,
12
+ } from '../ark-layer-match.mjs';
13
+ import { destinationKeepsLayerAndSlice } from './physical-cohesion.mjs';
14
+
15
+ const SPEC_MARKS = [' from "', " from '", ' from `', 'require("', "require('", 'import("', "import('"];
16
+ const EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'];
17
+
18
+ function ruleKey(rule) {
19
+ const child = rule?.childSlices;
20
+ if (!child?.sliceFolders?.length) return '';
21
+ return [
22
+ (rule.sliceFolders ?? []).join('\0'),
23
+ rule.sliceIdentity ?? '',
24
+ child.sliceFolders.join('\0'),
25
+ child.sliceIdentity ?? '',
26
+ (child.commonFolders ?? []).join('\0'),
27
+ ].join('\n');
28
+ }
29
+
30
+ function distinctChildRules(rules) {
31
+ const out = [];
32
+ const seen = new Set();
33
+ for (const rule of Array.isArray(rules) ? rules : []) {
34
+ const key = ruleKey(rule);
35
+ if (!key || seen.has(key)) continue;
36
+ seen.add(key);
37
+ out.push(rule);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function isFlatParent(rel, rule) {
43
+ const universe = sliceIdForPath(rel, rule.sliceFolders, rule.sliceIdentity);
44
+ const raw = sliceIdForPath(rel, rule.childSlices.sliceFolders, rule.childSlices.sliceIdentity);
45
+ return Boolean(universe && raw && raw.toLowerCase() === universe.toLowerCase());
46
+ }
47
+
48
+ function withoutComments(text) {
49
+ let out = '';
50
+ let i = 0;
51
+ while (i < text.length) {
52
+ const c = text[i];
53
+ const n = text[i + 1];
54
+ if (c === '/' && n === '/') {
55
+ while (i < text.length && text[i] !== '\n') i += 1;
56
+ continue;
57
+ }
58
+ if (c === '/' && n === '*') {
59
+ i += 2;
60
+ while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
61
+ i = Math.min(text.length, i + 2);
62
+ continue;
63
+ }
64
+ out += c;
65
+ i += 1;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ function relativeSpecifiers(text) {
71
+ const specs = [];
72
+ const source = withoutComments(String(text));
73
+ let i = 0;
74
+ while (i < source.length) {
75
+ let at = -1;
76
+ let mark = '';
77
+ for (const candidate of SPEC_MARKS) {
78
+ const found = source.indexOf(candidate, i);
79
+ if (found !== -1 && (at === -1 || found < at)) {
80
+ at = found;
81
+ mark = candidate;
82
+ }
83
+ }
84
+ if (at === -1) break;
85
+ const quote = mark[mark.length - 1];
86
+ const start = at + mark.length;
87
+ const end = source.indexOf(quote, start);
88
+ if (end === -1) break;
89
+ const spec = source.slice(start, end);
90
+ if (spec.startsWith('.')) specs.push(spec);
91
+ i = end + 1;
92
+ }
93
+ return specs;
94
+ }
95
+
96
+ function resolveRelative(fromFile, spec, fileSet) {
97
+ const base = fromFile.split('/');
98
+ base.pop();
99
+ for (const part of spec.split('/')) {
100
+ if (part === '' || part === '.') continue;
101
+ if (part === '..') {
102
+ if (base.length === 0) return null;
103
+ base.pop();
104
+ continue;
105
+ }
106
+ base.push(part);
107
+ }
108
+ const joined = base.join('/');
109
+ const names = [joined];
110
+ for (const ext of EXT) names.push(`${joined}${ext}`, `${joined}/index${ext}`);
111
+ for (const name of names) {
112
+ if (fileSet.has(name)) return name;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function readText(root, rel) {
118
+ try {
119
+ return fs.readFileSync(path.join(root, rel), 'utf8');
120
+ } catch {
121
+ return '';
122
+ }
123
+ }
124
+
125
+ function childImporters(flatFiles, files, rule, root) {
126
+ const flat = new Set(flatFiles);
127
+ const known = new Set(files);
128
+ const importers = new Map();
129
+ for (const file of files) {
130
+ const place = resolveGovernedSlice(file, rule);
131
+ if (!place.childId) continue;
132
+ for (const spec of relativeSpecifiers(readText(root, file))) {
133
+ const target = resolveRelative(file, spec, known);
134
+ if (!target || !flat.has(target) || target === file) continue;
135
+ let ids = importers.get(target);
136
+ if (!ids) {
137
+ ids = new Set();
138
+ importers.set(target, ids);
139
+ }
140
+ ids.add(place.childId);
141
+ }
142
+ }
143
+ return importers;
144
+ }
145
+
146
+ function basename(rel) {
147
+ const parts = rel.split('/');
148
+ return parts[parts.length - 1] ?? rel;
149
+ }
150
+
151
+ /**
152
+ * @param {{ root: string, files: string[], rules: object[] | undefined, layers: object[] | undefined }} input
153
+ * @returns {object | null}
154
+ */
155
+ export function collectFlatParentPilot(input) {
156
+ const rules = distinctChildRules(input?.rules);
157
+ if (rules.length === 0) return null;
158
+ const files = (Array.isArray(input.files) ? input.files : [])
159
+ .map((file) => String(file).replace(/\\/g, '/'))
160
+ .filter((file) => file.length > 0);
161
+ const layers = input.layers;
162
+ const moves = [];
163
+ const seen = new Set();
164
+ for (const rule of rules) {
165
+ const flat = files.filter((file) => isFlatParent(file, rule));
166
+ if (flat.length === 0) continue;
167
+ const importers = childImporters(flat, files, rule, input.root);
168
+ for (const file of flat) {
169
+ if (seen.has(file)) continue;
170
+ const ids = importers.get(file);
171
+ if (!ids || ids.size !== 1) continue;
172
+ const importer = [...ids][0];
173
+ const destination = sliceAliasDestination(file, importer, rule.childSlices);
174
+ if (!destination) continue;
175
+ const destFile = `${destination}/${basename(file)}`;
176
+ const layer = layerForRelativePath(file, layers);
177
+ if (!layer || layer !== layerForRelativePath(destFile, layers)) continue;
178
+ const toPlace = resolveGovernedSlice(destFile, rule);
179
+ if ((toPlace.childId ?? '') !== importer) continue;
180
+ const fromPlace = resolveGovernedSlice(file, rule);
181
+ const keepsSlice = destinationKeepsLayerAndSlice(file, destFile, layers, [rule]);
182
+ const wallSame =
183
+ (fromPlace.universeId ?? '') === (toPlace.universeId ?? '') &&
184
+ (fromPlace.childId ?? '') === (toPlace.childId ?? '');
185
+ seen.add(file);
186
+ moves.push({
187
+ file,
188
+ importer,
189
+ destination,
190
+ layer,
191
+ keepsLayer: true,
192
+ keepsSlice,
193
+ destinationChild: toPlace.childId,
194
+ agreesWithWall: keepsSlice === wallSame,
195
+ evidence: `One child imports ${file} (${importer}). Move it into ${destination}. The ${layer} layer stays. This is a suggestion with that importer as its evidence.`,
196
+ });
197
+ }
198
+ }
199
+ if (moves.length === 0) return null;
200
+ moves.sort((left, right) => (left.file < right.file ? -1 : left.file > right.file ? 1 : 0));
201
+ return {
202
+ advisory: true,
203
+ notAScore: true,
204
+ proposed: true,
205
+ applied: false,
206
+ neverMechanicalSafe: true,
207
+ moves,
208
+ };
209
+ }
210
+
211
+ /** One pilot candidate per move. Appended after pattern bets and the reshape card. */
212
+ export function flatParentCandidates(section) {
213
+ if (!section || !Array.isArray(section.moves)) return [];
214
+ return section.moves
215
+ .filter((move) => move && typeof move.file === 'string' && move.file.length > 0)
216
+ .map((move) => ({
217
+ source: 'flat-parent',
218
+ target: move.file,
219
+ move: typeof move.evidence === 'string' ? move.evidence : `Move ${move.file} into its one importing child. This is a suggestion.`,
220
+ moveSample: [{ from: move.file, to: `${move.destination}/${basename(move.file)}` }],
221
+ successSignal: 're-run doctor: this file is no longer a single-importer flat parent',
222
+ killSwitch: 'leave the file where it is',
223
+ doNot: [
224
+ 'a suggestion with one importer as its evidence',
225
+ 'leave a file in place when two children import it',
226
+ 'one pilot at a time; re-doctor before the next card',
227
+ 'never weaken the contract to make a move pass',
228
+ ],
229
+ }));
230
+ }
231
+
232
+ export function printFlatParentPilot(section, io) {
233
+ const moves = Array.isArray(section?.moves) ? section.moves : [];
234
+ if (moves.length === 0) return;
235
+ console.log('');
236
+ console.log(io.color.bold('Flat parent files (suggestion)'));
237
+ for (const move of moves.slice(0, 5)) io.line(io.warn, move.evidence);
238
+ if (moves.length > 5) io.line(' ', io.color.dim(`…(+${moves.length - 5} more in doctor JSON)`));
239
+ io.line(' ', io.color.dim('suggestion only — one importer is the evidence; the gate verdict is unchanged'));
240
+ }
241
+
242
+ export function flatParentPilotHtml(section, escape) {
243
+ const moves = Array.isArray(section?.moves) ? section.moves : [];
244
+ if (moves.length === 0) return '';
245
+ const esc = typeof escape === 'function' ? escape : (value) => String(value);
246
+ const items = moves
247
+ .slice(0, 5)
248
+ .map((move) => `<li>${esc(move.evidence ?? '')}</li>`)
249
+ .join('');
250
+ const more = moves.length > 5 ? `<p class="muted">…(+${moves.length - 5} more in doctor JSON)</p>` : '';
251
+ return `<section class="section card" data-advisory="flatParentPilot"><h2>Flat parent files <span class="muted">(suggestion — one importer is the evidence; not a score; the verdict is unchanged)</span></h2><ul>${items}</ul>${more}</section>`;
252
+ }
@@ -13,6 +13,7 @@ import { formatRulesUnderContractHtml } from './rules-under-contract.mjs';
13
13
  import { formatArkRunHtml } from './ark-run-report.mjs';
14
14
  import { formatArkOrderHtml } from './ark-order-report.mjs';
15
15
  import { primaryImprovementCompassNextAction } from './improvement-compass.mjs';
16
+ import { flatParentPilotHtml } from './flat-parent-pilot.mjs';
16
17
 
17
18
  // htmlEscape is injected by the caller (html-report.mjs) — importing it back
18
19
  // would be a dependency cycle, and the repo's own gate blocks that. The
@@ -355,6 +356,7 @@ export function renderAdvisorySections(advisories, escape) {
355
356
  deepModuleCoachHtml(advisories.deepModuleCoach),
356
357
  stewardNudgeHtml(advisories.stewardNudge),
357
358
  sliceIdentityHtml(advisories.sliceIdentity),
359
+ flatParentPilotHtml(advisories.flatParentPilot, esc),
358
360
  contractHealthHtml(advisories.contractHealth),
359
361
  ambientStateHtml(advisories.ambientState),
360
362
  physicalCohesionHtml(advisories.physicalCohesion),
@@ -28,6 +28,7 @@ import { buildDeepModuleCoachAdvisory } from './deep-module-coach.mjs';
28
28
  import { computePhysicalCohesion } from './physical-cohesion.mjs';
29
29
  import { computeReshapeDecisionMemory } from './reshape-decisions.mjs';
30
30
  import { POST_GREEN_LEDE, operatingModeTitle } from './product-copy.mjs';
31
+ import { anyChildWallAdvisory, anySliceAlias } from '../ark-layer-match.mjs';
31
32
 
32
33
  function esc(value) {
33
34
  return String(value)
@@ -174,7 +175,8 @@ export function buildReportDepthPayload(
174
175
  : null,
175
176
  primaryNextAction: postGreenPath?.action ?? dualTruthNext,
176
177
  activeBlockingViolations: activeBlockingCount,
177
- physicalCohesionResidual: physicalCohesionResidualRemains(physicalCohesion),
178
+ physicalCohesionResidual: physicalCohesionResidualRemains(physicalCohesion), childWallAdvisory: anyChildWallAdvisory(config?.rules),
179
+ sliceAliasDebt: anySliceAlias(config?.rules),
178
180
  });
179
181
  // Doctor parity: same physical-cohesion + baseline stale facts as runDoctor.
180
182
  const baselineStale =
@@ -133,7 +133,7 @@ function matchTestTitle(content, id) {
133
133
  if (!id)
134
134
  return undefined;
135
135
  const escaped = escapeRegExp(id);
136
- const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*(['"\`])([^'"\`]*${escaped}[^'"\`]*)\\1`, 'i');
136
+ const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*(['"\`])((?:(?!\\1)[^\\\\]|\\\\.)*?${escaped}(?:(?!\\1)[^\\\\]|\\\\.)*?)\\1`, 'i');
137
137
  const match = re.exec(maskNonCode(content, false));
138
138
  const title = match?.[2];
139
139
  return title === undefined ? undefined : title;
@@ -160,7 +160,7 @@ function declarationShape(content, name) {
160
160
  },
161
161
  {
162
162
  shape: 'const',
163
- source: `(?:^|[\\s;{}])export\\s+(?:const|let|var)\\s+${n}\\s*=`,
163
+ source: `(?:^|[\\s;{}])export\\s+(?:const|let|var)\\s+${n}(?:\\s*:(?:[^=]|=>)+?)?\\s*=(?!>)`,
164
164
  },
165
165
  {
166
166
  shape: 'type',
@@ -382,15 +382,36 @@ function rankCoverage(invariant, files, scanTests = true) {
382
382
  }
383
383
  }
384
384
  }
385
- const best = testTitle
385
+ let best = testTitle
386
386
  ? { kind: 'test-title', file: testTitle.file, title: testTitle.title }
387
387
  : declaration
388
388
  ? { kind: 'declaration', file: declaration.file, shape: declaration.shape }
389
389
  : mention
390
390
  ? { kind: 'mention-only', file: mention.file, context: mention.context }
391
391
  : { kind: 'none' };
392
+ if (best.kind === 'mention-only' &&
393
+ best.context === 'comment' &&
394
+ needle &&
395
+ symbolInCodeElsewhere(files.fileContents, tests, needle.name, best.file)) {
396
+ best = { kind: 'declaration-miss', symbol: needle.name };
397
+ }
392
398
  return { best, ...(testTitle ? { testTitle } : {}), ...(declaration ? { declaration } : {}) };
393
399
  }
400
+ /** True when `name` survives comment/string masking in some non-test file other than `mentionFile`. */
401
+ function symbolInCodeElsewhere(fileContents, tests, name, mentionFile) {
402
+ const needle = new RegExp(`(?:^|[^A-Za-z0-9_$])${escapeRegExp(name)}(?=$|[^A-Za-z0-9_$])`);
403
+ const mention = normalizePath(mentionFile);
404
+ for (const file of Object.keys(fileContents)) {
405
+ if (tests.has(normalizePath(file)) || normalizePath(file) === mention)
406
+ continue;
407
+ const content = fileContents[file];
408
+ if (!content)
409
+ continue;
410
+ if (needle.test(maskNonCode(content, true)))
411
+ return true;
412
+ }
413
+ return false;
414
+ }
394
415
  /**
395
416
  * Best coverage evidence for `invariant` in `files`.
396
417
  * test-title beats a declaration; a declaration beats a bare mention; a bare
@@ -409,6 +430,7 @@ export function countsAsCoverage(ev) {
409
430
  case 'declaration':
410
431
  return true;
411
432
  case 'mention-only':
433
+ case 'declaration-miss':
412
434
  case 'none':
413
435
  return false;
414
436
  }
@@ -434,6 +456,8 @@ export function describeCoverage(invariant, ev) {
434
456
  return `found \`${ev.shape} ${declaredLabel(invariant)}\` in ${ev.file}`;
435
457
  case 'mention-only':
436
458
  return `${invariant.id} appears only in ${mentionWhere(ev.context)} in ${ev.file}; put it in a describe/it title`;
459
+ case 'declaration-miss':
460
+ return `no declaration matched \`${ev.symbol}\``;
437
461
  case 'none':
438
462
  return 'no scanned test names it in a describe/it title and no declared symbol was found';
439
463
  }
@@ -15,7 +15,7 @@
15
15
  * as W01 layer roles: a miss costs a warning line, never a verdict.
16
16
  */
17
17
  import path from 'node:path';
18
- import { layerForRelativePath, sliceIdForPath } from '../ark-layer-match.mjs';
18
+ import { layerForRelativePath, resolveGovernedSlice } from '../ark-layer-match.mjs';
19
19
 
20
20
  /** ADR 0010 D3 — corpus-calibrated, fixed. */
21
21
  const CLUSTER_MIN = 40;
@@ -135,23 +135,21 @@ function anchorIsConsolidationSubtree(anchor, concept) {
135
135
  return String(anchor).split('/').some((part) => part === concept);
136
136
  }
137
137
 
138
- function declaredSliceFolders(rules) {
139
- const out = [];
140
- const seen = new Set();
138
+ /**
139
+ * Layer must match. Universe id and child id must match on every rule, using
140
+ * the same resolver as the walls (`sliceIdentity` included). A missing child
141
+ * id is universe common. Two children of one universe do not keep the slice.
142
+ */
143
+ export function destinationKeepsLayerAndSlice(fromRel, toRel, layers, rules) {
144
+ if (layerForRelativePath(fromRel, layers) !== layerForRelativePath(toRel, layers)) return false;
141
145
  for (const rule of Array.isArray(rules) ? rules : []) {
142
- const folders = Array.isArray(rule?.sliceFolders) ? rule.sliceFolders : [];
143
- for (const folder of folders) {
144
- if (typeof folder !== 'string' || folder.length === 0 || seen.has(folder.toLowerCase())) continue;
145
- seen.add(folder.toLowerCase());
146
- out.push(folder);
147
- }
146
+ if (!rule || typeof rule !== 'object') continue;
147
+ const from = resolveGovernedSlice(fromRel, rule);
148
+ const to = resolveGovernedSlice(toRel, rule);
149
+ if ((from.universeId ?? '') !== (to.universeId ?? '')) return false;
150
+ if ((from.childId ?? '') !== (to.childId ?? '')) return false;
148
151
  }
149
- return out;
150
- }
151
-
152
- function destinationKeepsLayerAndSlice(fromRel, toRel, layers, sliceFolders) {
153
- return layerForRelativePath(fromRel, layers) === layerForRelativePath(toRel, layers)
154
- && sliceIdForPath(fromRel, sliceFolders) === sliceIdForPath(toRel, sliceFolders);
152
+ return true;
155
153
  }
156
154
 
157
155
  function withheldPilot(concept, note) {
@@ -168,7 +166,7 @@ export function computeReshapePilot(cohesion, files, root, contract) {
168
166
  const top = cohesion?.findings?.[0];
169
167
  if (!top) return null;
170
168
  const layers = contract?.layers;
171
- const sliceFolders = declaredSliceFolders(contract?.rules);
169
+ const rules = Array.isArray(contract?.rules) ? contract.rules : [];
172
170
  // Full anchor map: the finding's anchors are display-filtered (>= MIRROR_MIN).
173
171
  const byAnchor = new Map();
174
172
  const relOf = (abs) => path.relative(root, abs).split(path.sep).join('/');
@@ -193,7 +191,7 @@ export function computeReshapePilot(cohesion, files, root, contract) {
193
191
  const ranked = classifyPhysical(rel);
194
192
  if (!ranked || ranked.concept !== top.concept || ranked.anchor !== anchor.path) return false;
195
193
  const to = `${targetDir}/${rel.split('/').at(-1)}`;
196
- return destinationKeepsLayerAndSlice(rel, to, layers, sliceFolders);
194
+ return destinationKeepsLayerAndSlice(rel, to, layers, rules);
197
195
  }).sort().map((rel) => ({ from: rel, to: `${targetDir}/${rel.split('/').at(-1)}` }));
198
196
  if (legal.length > 0) legalAnchors.push({ ...anchor, targetDir, legal });
199
197
  }
@@ -9,6 +9,7 @@ import {
9
9
  buildPatternBetsFromSmells,
10
10
  isNonProductionPilotPath,
11
11
  } from './design-smells.mjs';
12
+ import { flatParentCandidates } from './flat-parent-pilot.mjs';
12
13
 
13
14
  /** Stable product id for JSON / tests. */
14
15
  export const PILOT_LOOP_ID = 'one-pilot-redoctor';
@@ -181,7 +182,7 @@ export function formatExtractionCard(card) {
181
182
  * One proposed pilot. The loop does not know designWeak or which sensor
182
183
  * produced the card — only this list.
183
184
  *
184
- * @typedef {'pattern-bet' | 'reshape'} PilotSource
185
+ * @typedef {'pattern-bet' | 'reshape' | 'flat-parent'} PilotSource
185
186
  * @typedef {{
186
187
  * source: PilotSource,
187
188
  * target: string,
@@ -243,10 +244,15 @@ function extractionCardFromCandidate(candidate) {
243
244
  .filter((entry) => typeof entry === 'string' && entry.length > 0)
244
245
  .slice(0, 8);
245
246
  const reshape = candidate.source === 'reshape';
247
+ const flatParent = candidate.source === 'flat-parent';
246
248
  return {
247
249
  id: PILOT_LOOP_ID,
248
- patternBetId: reshape ? `reshape:${candidate.target}` : `pattern-b:${candidate.target}`,
249
- smellId: reshape ? 'physical-cohesion' : 'unknown',
250
+ patternBetId: flatParent
251
+ ? `flat-parent:${candidate.target}`
252
+ : reshape
253
+ ? `reshape:${candidate.target}`
254
+ : `pattern-b:${candidate.target}`,
255
+ smellId: flatParent ? 'flat-parent' : reshape ? 'physical-cohesion' : 'unknown',
250
256
  pilot: candidate.target,
251
257
  pilotTarget: candidate.target,
252
258
  evidence,
@@ -262,9 +268,11 @@ function extractionCardFromCandidate(candidate) {
262
268
  loopStep: 'one-pilot',
263
269
  reDoctor: 'ark-check --doctor --json',
264
270
  rePlan: 'ark-check --plan --json',
265
- next: reshape
266
- ? '/ark-loop (one reshape pilot) | re-doctor after pilot'
267
- : '/ark-autopilot (one cluster / one B pilot) | re-doctor after pilot',
271
+ next: flatParent
272
+ ? '/ark-loop (one flat-parent suggestion) | re-doctor after the move'
273
+ : reshape
274
+ ? '/ark-loop (one reshape pilot) | re-doctor after pilot'
275
+ : '/ark-autopilot (one cluster / one B pilot) | re-doctor after pilot',
268
276
  };
269
277
  }
270
278
 
@@ -272,8 +280,10 @@ function extractionCardFromCandidate(candidate) {
272
280
  * Normalize design-weak pattern bets and a proposed reshape pilot into one list.
273
281
  * Pattern bets stay in selector rank (best first) and are omitted unless
274
282
  * design fitness is design-weak, so that path keeps today's card. A reshape
275
- * card is appended when physical cohesion proposes one. Doctor builds this
276
- * list after advisories, so the loop never depends on call order.
283
+ * card is appended when physical cohesion proposes one. A flat-parent move
284
+ * is appended after that: one file with one importer waits behind a card the
285
+ * loop already promised. Doctor builds this list after advisories, so the
286
+ * loop never depends on call order.
277
287
  *
278
288
  * @param {{
279
289
  * designWeak?: boolean,
@@ -317,6 +327,9 @@ export function collectPilotCandidates(designFitness = {}, advisories = {}) {
317
327
  ...(Array.isArray(card.doNot) ? { doNot: card.doNot } : {}),
318
328
  });
319
329
  }
330
+ for (const candidate of flatParentCandidates(advisories?.flatParentPilot)) {
331
+ candidates.push(candidate);
332
+ }
320
333
  return candidates;
321
334
  }
322
335
 
@@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process';
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
+ import { applyAdvisorySiblingRatchet } from '../ark-layer-match.mjs';
9
10
  import {
10
11
  baselineKeysFromDocument,
11
12
  classifyBaselineKeyDelta,
@@ -321,7 +322,8 @@ export function applyAgainstRatchet({
321
322
  }) {
322
323
  const baseRaw = readJsonMaybe(gitShowText(root, againstRef, '.ark-baseline.json'));
323
324
  const baseKeys = new Set(baselineKeysFromDocument(baseRaw));
324
- const vsBaseActive = violations.filter((_, index) => !baseKeys.has(occurrenceKeys[index]));
325
+ const judged = baseRaw ? applyAdvisorySiblingRatchet(violations, occurrenceKeys, baseKeys) : violations;
326
+ const vsBaseActive = judged.filter((_, index) => !baseKeys.has(occurrenceKeys[index]));
325
327
  const changedSet = new Set(changedPaths ?? []);
326
328
  const activeViolations = changed
327
329
  ? vsBaseActive.filter((violation) =>
@@ -330,7 +332,7 @@ export function applyAgainstRatchet({
330
332
  : vsBaseActive;
331
333
  return {
332
334
  activeViolations,
333
- suppressed: violations.filter((violation) => !activeViolations.includes(violation)),
335
+ suppressed: judged.filter((violation) => !activeViolations.includes(violation)),
334
336
  };
335
337
  }
336
338
 
@@ -67,8 +67,42 @@ type ArkConfigRule = {
67
67
  sharedRoots?: string[];
68
68
  /** Directed slice→slice edges the repo declares on purpose. */
69
69
  allowedCrossSlice?: ArkConfigCrossSliceEdge[];
70
- /** When `"deny"`, a declared shared root may not import a slice. */
71
- sharedImportsSlice?: 'deny';
70
+ /**
71
+ * `"deny"` blocks a shared root from importing a slice.
72
+ * `"deny-cross-parent"` leaves that hop allowed and asks the whole-graph
73
+ * check to report a slice that reaches another universe through shared.
74
+ */
75
+ sharedImportsSlice?: 'deny' | 'deny-cross-parent';
76
+ /**
77
+ * Optional inner wall. Absent keeps today's universe-wall output.
78
+ * `siblings` defaults to deny. An object keeps that default and lists enforced subtrees.
79
+ * `parentMayImportChild` defaults to false.
80
+ */
81
+ childSlices?: ArkConfigChildSlices;
82
+ };
83
+ type ArkConfigChildSlices = {
84
+ sliceFolders: string[];
85
+ sliceIdentity?: ArkConfigSliceIdentity;
86
+ commonFolders?: string[];
87
+ siblings?: 'deny' | 'advisory' | {
88
+ default: 'deny' | 'advisory';
89
+ enforce?: string[];
90
+ };
91
+ parentMayImportChild?: boolean;
92
+ /**
93
+ * Directed child-slice allowances. `*` is one whole path segment.
94
+ * Clears a sibling crossing only. The universe `allowedCrossSlice` does not read this list.
95
+ */
96
+ allowedCrossSlice?: ArkConfigCrossSliceEdge[];
97
+ /**
98
+ * Files outside the slice trees, borrowed onto a child id until they move.
99
+ * `to` is the universe id plus one child segment.
100
+ */
101
+ sliceAliases?: ArkConfigSliceAlias[];
102
+ };
103
+ type ArkConfigSliceAlias = {
104
+ from: string;
105
+ to: string;
72
106
  };
73
107
  type ArkConfigCrossSliceEdge = {
74
108
  from: string;