arkgate 4.8.22 → 4.8.24

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 (136) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +27 -78
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +1117 -38
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -2
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +45 -6
  35. package/bin/lib/doctor-advisories.mjs +84 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +7 -9
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-honesty.mjs +22 -0
  40. package/bin/lib/enforcement-profiles.mjs +40 -4
  41. package/bin/lib/first-run-help.mjs +3 -2
  42. package/bin/lib/flat-parent-importers.mjs +137 -0
  43. package/bin/lib/flat-parent-pilot.mjs +192 -0
  44. package/bin/lib/gate-files.mjs +29 -2
  45. package/bin/lib/git-change-scope.mjs +109 -0
  46. package/bin/lib/github-enforcement.mjs +1 -1
  47. package/bin/lib/hook-templates.mjs +18 -16
  48. package/bin/lib/host-support-matrix.mjs +3 -1
  49. package/bin/lib/html-report-advisories.mjs +2 -0
  50. package/bin/lib/html-report-depth.mjs +3 -1
  51. package/bin/lib/import-resolve.mjs +13 -1
  52. package/bin/lib/install-migrate.mjs +50 -73
  53. package/bin/lib/invariant-coverage.mjs +59 -11
  54. package/bin/lib/mcp-adoption.mjs +9 -1
  55. package/bin/lib/mcp-hook-payload.mjs +401 -61
  56. package/bin/lib/package-manager.mjs +59 -1
  57. package/bin/lib/passthrough-commands.mjs +83 -0
  58. package/bin/lib/persistence-write-hint.mjs +81 -0
  59. package/bin/lib/physical-cohesion.mjs +16 -18
  60. package/bin/lib/pilot-loop.mjs +21 -8
  61. package/bin/lib/pin-dependent-gates.mjs +117 -0
  62. package/bin/lib/policy-delta-io.mjs +168 -10
  63. package/bin/lib/prepare-change.mjs +120 -6
  64. package/bin/lib/prepare-write.mjs +1 -0
  65. package/bin/lib/presets.mjs +3 -5
  66. package/bin/lib/remediation.mjs +23 -4
  67. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  68. package/bin/lib/rules-inventory.mjs +22 -3
  69. package/bin/lib/rules-under-contract.mjs +179 -5
  70. package/bin/lib/scan-files.mjs +32 -0
  71. package/bin/lib/snippet-analysis.mjs +140 -6
  72. package/bin/lib/source-policy.mjs +34 -0
  73. package/bin/lib/stable-hash.mjs +90 -0
  74. package/bin/lib/start-install-recovery.mjs +71 -1
  75. package/bin/lib/start-preview.mjs +30 -0
  76. package/bin/lib/status-command.mjs +33 -4
  77. package/bin/lib/status-manifest.mjs +32 -4
  78. package/bin/lib/team-parliament-io.mjs +79 -75
  79. package/bin/lib/team-parliament.mjs +6 -1
  80. package/bin/lib/write-path-capabilities.mjs +4 -2
  81. package/dist/chunk-A5EIY3HI.cjs +5 -0
  82. package/dist/chunk-MBXCFLO4.js +16 -0
  83. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  84. package/dist/chunk-SVSQVGFJ.js +5 -0
  85. package/dist/{configTypes-CTELo87-.d.ts → configTypes-BJbaeoj4.d.cts} +53 -4
  86. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  87. package/dist/eslint/index.cjs +17 -4
  88. package/dist/eslint/index.d.cts +359 -0
  89. package/dist/eslint/index.d.ts +178 -158
  90. package/dist/eslint/index.js +10 -4
  91. package/dist/index.cjs +38 -37
  92. package/dist/index.d.cts +5475 -0
  93. package/dist/index.d.ts +3408 -296
  94. package/dist/index.js +38 -37
  95. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  96. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  97. package/dist/nestjs/index.cjs +1 -18
  98. package/dist/nestjs/index.d.cts +22 -0
  99. package/dist/nestjs/index.d.ts +1 -3
  100. package/dist/nestjs/index.js +1 -18
  101. package/dist/order/index.cjs +1 -1
  102. package/dist/order/index.d.cts +155 -0
  103. package/dist/order/index.d.ts +15 -3
  104. package/dist/order/index.js +1 -1
  105. package/dist/runtime/index.cjs +13 -30
  106. package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → runtime/index.d.cts} +814 -290
  107. package/dist/runtime/index.d.ts +2678 -108
  108. package/dist/runtime/index.js +13 -30
  109. package/dist/{types-yDNEQ603.d.ts → types-DRinG-vT.d.cts} +576 -140
  110. package/dist/types-DRinG-vT.d.ts +1792 -0
  111. package/docs/README.md +3 -1
  112. package/docs/agent-guide.md +92 -15
  113. package/docs/ai-gates.md +160 -26
  114. package/docs/arkorder.md +32 -15
  115. package/docs/brownfield-adoption.md +5 -1
  116. package/docs/configuration.md +273 -12
  117. package/docs/develop.md +2 -1
  118. package/docs/diagnostics.md +122 -13
  119. package/docs/package-surface.md +31 -8
  120. package/docs/product-voice.md +1 -1
  121. package/package.json +59 -18
  122. package/schemas/ark.analysis-result.schema.json +15 -0
  123. package/schemas/ark.config.schema.json +163 -4
  124. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  125. package/schemas/ark.status-manifest.schema.json +22 -0
  126. package/server.json +3 -9
  127. package/templates/agent-skills/README.md +1 -1
  128. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  129. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  130. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  131. package/templates/arkrules/PersistenceAdapters.json +1 -1
  132. package/templates/arkrules/PresentationAdapters.json +1 -1
  133. package/templates/skills/ark-adopt.md +4 -1
  134. package/templates/skills/ark-order.md +4 -2
  135. package/templates/skills/ark-runtime.md +6 -1
  136. package/dist/types-fQ9-KrBj.d.ts +0 -183
@@ -0,0 +1,701 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/configContractSlices.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/config-contract-slices.mjs). Zero Node I/O.
9
+ */
10
+
11
+ const stringArraySchema = {
12
+ type: 'array',
13
+ items: { type: 'string', minLength: 1 },
14
+ uniqueItems: true,
15
+ };
16
+ /**
17
+ * `"deny"`, `"deny-cross-parent"`, or `{ mode: "deny-cross-parent", stopAt }`.
18
+ * The generic walk has no `oneOf`, so {@link validateSharedImportsSlice} owns it.
19
+ */
20
+ export const SHARED_IMPORTS_SLICE_SCHEMA_DEF = {
21
+ description: 'deny blocks every shared-root import of a slice. deny-cross-parent leaves that hop as a warning and, in ark-check and CI only, reports a slice that reaches another universe through a shared root. The write hook and ESLint see one edge and do not block it. The object form { mode: "deny-cross-parent", stopAt } names composition roots (bootstrap, DI registrations) the whole-graph walk never starts at or passes through; a stop file must still sit under sharedRoots, and stopAt does not silence the direct SHARED_IMPORTS_SLICE warning. arkgate 4.8.22 and older reject deny-cross-parent. arkgate 4.8.23 and older reject the object form (must be one of deny, deny-cross-parent).',
22
+ oneOf: [
23
+ { type: 'string', enum: ['deny', 'deny-cross-parent'] },
24
+ {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ required: ['mode', 'stopAt'],
28
+ properties: {
29
+ mode: { type: 'string', enum: ['deny-cross-parent'] },
30
+ stopAt: { ...stringArraySchema, minItems: 1 },
31
+ },
32
+ },
33
+ ],
34
+ };
35
+ export const CHILD_SLICES_SCHEMA_DEF = {
36
+ type: 'object',
37
+ additionalProperties: false,
38
+ required: ['sliceFolders'],
39
+ description: 'Optional inner wall under this rule. Runs only on a rule with peerIsolation: true and allowed: false; elsewhere it is inert and ark-check warns CONFIG_CHILD_SLICES_INERT (config load still accepts it, as 4.8.23 did). Absent keeps today\'s universe wall. sliceFolders names children. Flat files are universe common, and so is a commonFolders directory directly under the universe (a folder of that name inside a child belongs to that child). siblings is "deny" (default), "advisory", or { default, enforce, ratchet }. parentMayImportChild defaults to false. message is the text for inner-wall findings. allowedCrossSlice may use a whole-segment *. It clears only a sibling crossing. Cross-parent has no advisory knob. arkgate 4.8.22 and older reject this key. A build that still rejects unknown childSlices fields rejects allowedCrossSlice at config load. A build that still types siblings as a string enum rejects the object at config load.',
40
+ properties: {
41
+ sliceFolders: { ...stringArraySchema, minItems: 1 },
42
+ sliceIdentity: {
43
+ type: 'string',
44
+ enum: ['path', 'stars'],
45
+ },
46
+ commonFolders: { ...stringArraySchema, minItems: 1 },
47
+ siblings: {
48
+ description: '"deny" or "advisory", or { default, enforce, ratchet } so listed subtrees are errors while the default stays advisory. ratchet absent: a new advisory crossing fails only when the baseline in use already records an advisory crossing of this rule. ratchet true: any unrecorded advisory crossing fails when a baseline is in use. ratchet false: measure only, never fails. A string-enum build rejects the object at config load (must be one of deny, advisory). arkgate 4.8.23 and older reject ratchet (unknown field).',
49
+ oneOf: [
50
+ { type: 'string', enum: ['deny', 'advisory'] },
51
+ {
52
+ type: 'object',
53
+ additionalProperties: false,
54
+ required: ['default'],
55
+ properties: {
56
+ default: { type: 'string', enum: ['deny', 'advisory'] },
57
+ enforce: { ...stringArraySchema },
58
+ ratchet: { type: 'boolean' },
59
+ },
60
+ },
61
+ ],
62
+ },
63
+ parentMayImportChild: { type: 'boolean' },
64
+ message: {
65
+ type: 'string',
66
+ minLength: 1,
67
+ description: 'Text for inner-wall findings (CROSS_SIBLING_SLICE and universe common importing a child). The rule message stays the universe-wall text (CROSS_PARENT_SLICE and fail-closed denies). Absent: ArkGate default text, never the rule message. arkgate 4.8.23 and older reject this field (unknown field).',
68
+ },
69
+ sliceAliases: {
70
+ type: 'array',
71
+ minItems: 1,
72
+ description: 'Maps a source path glob onto a child slice id (universe id plus one child segment) so files outside the slice trees take that universe and child for both walls. A path without a wildcard also covers everything under it, as sharedRoots does. A bare name, a universe id alone, a target outside every universe shape, and a wildcard in to are rejected. Under stars identity a target written against the 4.8.23 stars id (star bindings before the last literal dropped, e.g. api/v1/x for modules/*/api/*) still loads when it maps to one universe shape, and ark-check warns CONFIG_SLICE_LEGACY_STARS_ID with the new id; it is rejected only when it maps to more than one shape. Config load checks only the shape; doctor reports a target universe no file belongs to. The glob may not overlap a slice folder, and two aliases may not match the same file. Doctor lists each alias as an owed move. A build that rejects unknown childSlices fields fails at config load (unknown field). arkgate 4.8.22 and older reject childSlices.',
73
+ items: {
74
+ type: 'object',
75
+ additionalProperties: false,
76
+ required: ['from', 'to'],
77
+ properties: {
78
+ from: { type: 'string', minLength: 1 },
79
+ to: { type: 'string', minLength: 1 },
80
+ },
81
+ },
82
+ },
83
+ allowedCrossSlice: {
84
+ type: 'array',
85
+ minItems: 1,
86
+ description: 'Directed child-slice allowances. * matches one whole path segment (features/projects/*). ** and a partial segment are rejected. A bare name is rejected. This list clears only CROSS_SIBLING_SLICE. It cannot clear CROSS_PARENT_SLICE or CROSS_PARENT_VIA_SHARED. The universe allowedCrossSlice still treats * as a literal. A build that rejects unknown childSlices fields fails at config load (unknown field).',
87
+ items: {
88
+ type: 'object',
89
+ additionalProperties: false,
90
+ required: ['from', 'to'],
91
+ properties: {
92
+ from: { type: 'string', minLength: 1 },
93
+ to: { type: 'string', minLength: 1 },
94
+ },
95
+ },
96
+ },
97
+ },
98
+ };
99
+ function isObject(value) {
100
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
101
+ }
102
+ function propertyPath(parent, key) {
103
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
104
+ ? `${parent}.${key}`
105
+ : `${parent}[${JSON.stringify(key)}]`;
106
+ }
107
+ /** Trim trailing slashes with a linear scan (no polynomial regex). */
108
+ function trimTrailingSlashes(value) {
109
+ let end = value.length;
110
+ while (end > 0 && value[end - 1] === '/')
111
+ end -= 1;
112
+ return value.slice(0, end);
113
+ }
114
+ /** Every slice-wall check the generic schema walk cannot express. */
115
+ export function validateSliceContract(candidate, issues) {
116
+ validateSharedImportsSlice(candidate, issues);
117
+ validateChildSliceSiblings(candidate, issues);
118
+ validateChildSliceAllowedCrossSlice(candidate, issues);
119
+ validateChildSliceAliases(candidate, issues);
120
+ }
121
+ // childSlices on a rule without peerIsolation: true + allowed: false is inert. 4.8.23
122
+ // loaded it, so config load keeps accepting it; ark-check warns CONFIG_CHILD_SLICES_INERT
123
+ // (layerMatch childSliceConfigFindings) instead of failing a patch-release upgrade.
124
+ const SHARED_IMPORTS_SLICE_FORM_MESSAGE = 'must be "deny", "deny-cross-parent", or { "mode": "deny-cross-parent", "stopAt": ["<composition root path or glob>"] }';
125
+ const STOP_AT_WHOLE_TREE = 'must not cover the whole tree. Name the composition root itself (bootstrap file, DI registrations folder).';
126
+ function isWildcardSegment(part) {
127
+ return part === '*' || part === '**';
128
+ }
129
+ /**
130
+ * Literal folder a path or glob covers: leading src/ or app/ dropped (the walk
131
+ * matches with or without them), trailing whole-segment wildcards dropped (a
132
+ * folder, `folder/*` and `folder/**` all stop the whole subtree). Null when a
133
+ * wildcard sits before a literal, so the entry is partial by construction.
134
+ */
135
+ function coveredFolder(value) {
136
+ let parts = value.toLowerCase().split('/').filter((part) => part.length > 0 && part !== '.');
137
+ if (parts[0] === 'src' || parts[0] === 'app')
138
+ parts = parts.slice(1);
139
+ let end = parts.length;
140
+ while (end > 0 && isWildcardSegment(parts[end - 1] ?? ''))
141
+ end -= 1;
142
+ const base = parts.slice(0, end);
143
+ return base.some((part) => part.includes('*')) ? null : base;
144
+ }
145
+ function isPrefixOf(prefix, full) {
146
+ return prefix.length <= full.length && prefix.every((part, index) => full[index] === part);
147
+ }
148
+ /** Layer roots the rule walks. A stop that covers one silences the walk for that whole layer. */
149
+ function ruleLayerFolders(candidate, rule) {
150
+ const layers = candidate.layers;
151
+ if (!Array.isArray(layers))
152
+ return [];
153
+ const names = new Set([rule.from, rule.to].filter((name) => typeof name === 'string'));
154
+ const folders = [];
155
+ for (const layer of layers) {
156
+ if (!isObject(layer) || typeof layer.name !== 'string' || !names.has(layer.name))
157
+ continue;
158
+ const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
159
+ for (const pattern of patterns) {
160
+ if (typeof pattern !== 'string')
161
+ continue;
162
+ const parts = pattern.replace(/\\/g, '/').split('/');
163
+ const firstWildcard = parts.findIndex((part) => part.includes('*'));
164
+ const literal = firstWildcard === -1 ? parts : parts.slice(0, firstWildcard);
165
+ const folder = coveredFolder(literal.join('/'));
166
+ if (folder)
167
+ folders.push(folder);
168
+ }
169
+ }
170
+ return folders;
171
+ }
172
+ function stopAtEntryIssue(raw, layerFolders) {
173
+ const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
174
+ if (trimmed.length === 0)
175
+ return 'must be a non-empty path or glob';
176
+ const bare = trimmed.replace(/^[./]+/, '');
177
+ if (bare === '' || bare === '*' || bare === '**')
178
+ return STOP_AT_WHOLE_TREE;
179
+ for (const part of trimmed.split('/')) {
180
+ if (part.length === 0 || part === '.' || part === '..') {
181
+ return 'must be a path or glob without empty, . or .. segments';
182
+ }
183
+ }
184
+ const folder = coveredFolder(trimmed);
185
+ if (folder === null)
186
+ return null;
187
+ if (folder.length === 0)
188
+ return STOP_AT_WHOLE_TREE;
189
+ if (layerFolders.some((layer) => isPrefixOf(folder, layer))) {
190
+ return 'must not cover a whole layer root of this rule. Name the composition root itself (bootstrap file, DI registrations folder).';
191
+ }
192
+ return null;
193
+ }
194
+ /** String form unchanged. Object form carries composition roots the walk stops at. */
195
+ export function validateSharedImportsSlice(candidate, issues) {
196
+ const rules = candidate.rules;
197
+ if (!Array.isArray(rules))
198
+ return;
199
+ rules.forEach((rule, index) => {
200
+ if (!isObject(rule) || rule.sharedImportsSlice === undefined)
201
+ return;
202
+ const value = rule.sharedImportsSlice;
203
+ const path = `$.rules[${index}].sharedImportsSlice`;
204
+ if (typeof value === 'string') {
205
+ if (value !== 'deny' && value !== 'deny-cross-parent') {
206
+ issues.push({ path, message: SHARED_IMPORTS_SLICE_FORM_MESSAGE });
207
+ }
208
+ return;
209
+ }
210
+ if (!isObject(value)) {
211
+ issues.push({ path, message: SHARED_IMPORTS_SLICE_FORM_MESSAGE });
212
+ return;
213
+ }
214
+ for (const key of Object.keys(value)) {
215
+ if (key !== 'mode' && key !== 'stopAt') {
216
+ issues.push({ path: propertyPath(path, key), message: 'unknown field' });
217
+ }
218
+ }
219
+ if (value.mode === undefined) {
220
+ issues.push({ path: `${path}.mode`, message: 'is required' });
221
+ }
222
+ else if (value.mode !== 'deny-cross-parent') {
223
+ issues.push({
224
+ path: `${path}.mode`,
225
+ message: 'must be deny-cross-parent. stopAt has no meaning for deny; use the string "deny".',
226
+ });
227
+ }
228
+ const stopAt = value.stopAt;
229
+ if (stopAt === undefined) {
230
+ issues.push({ path: `${path}.stopAt`, message: 'is required' });
231
+ return;
232
+ }
233
+ if (!Array.isArray(stopAt) || stopAt.length === 0) {
234
+ issues.push({ path: `${path}.stopAt`, message: 'must be a non-empty array of paths or globs' });
235
+ return;
236
+ }
237
+ const seen = new Set();
238
+ const layerFolders = ruleLayerFolders(candidate, rule);
239
+ stopAt.forEach((entry, entryIndex) => {
240
+ const entryPath = `${path}.stopAt[${entryIndex}]`;
241
+ if (typeof entry !== 'string') {
242
+ issues.push({ path: entryPath, message: 'must be a non-empty path or glob' });
243
+ return;
244
+ }
245
+ const issue = stopAtEntryIssue(entry, layerFolders);
246
+ if (issue) {
247
+ issues.push({ path: entryPath, message: issue });
248
+ return;
249
+ }
250
+ const key = trimTrailingSlashes(entry.trim().replace(/\\/g, '/')).toLowerCase();
251
+ if (seen.has(key)) {
252
+ issues.push({ path: entryPath, message: 'duplicate stopAt entry' });
253
+ return;
254
+ }
255
+ seen.add(key);
256
+ });
257
+ });
258
+ }
259
+ const SIBLING_MODES = ['deny', 'advisory'];
260
+ /**
261
+ * String enum builds reject this object before they reach a decision.
262
+ * The message names both forms so a bad value is obvious at config load.
263
+ */
264
+ const SIBLINGS_FORM_MESSAGE = 'must be "deny", "advisory", or { "default": "deny" | "advisory", "enforce": ["<child id or subtree path>"], "ratchet": true | false }';
265
+ export function validateChildSliceSiblings(candidate, issues) {
266
+ const rules = candidate.rules;
267
+ if (!Array.isArray(rules))
268
+ return;
269
+ rules.forEach((rule, index) => {
270
+ if (!isObject(rule))
271
+ return;
272
+ const child = rule.childSlices;
273
+ if (!isObject(child) || child.siblings === undefined)
274
+ return;
275
+ const siblings = child.siblings;
276
+ const path = `$.rules[${index}].childSlices.siblings`;
277
+ if (typeof siblings === 'string') {
278
+ if (!SIBLING_MODES.includes(siblings)) {
279
+ issues.push({ path, message: SIBLINGS_FORM_MESSAGE });
280
+ }
281
+ return;
282
+ }
283
+ if (!isObject(siblings)) {
284
+ issues.push({ path, message: SIBLINGS_FORM_MESSAGE });
285
+ return;
286
+ }
287
+ for (const key of Object.keys(siblings)) {
288
+ if (key !== 'default' && key !== 'enforce' && key !== 'ratchet') {
289
+ issues.push({ path: propertyPath(path, key), message: 'unknown field' });
290
+ }
291
+ }
292
+ if (siblings.ratchet !== undefined && typeof siblings.ratchet !== 'boolean') {
293
+ issues.push({ path: `${path}.ratchet`, message: 'must be a boolean' });
294
+ }
295
+ if (siblings.default === undefined) {
296
+ issues.push({ path: `${path}.default`, message: 'is required' });
297
+ }
298
+ else if (siblings.default !== 'deny' && siblings.default !== 'advisory') {
299
+ issues.push({ path: `${path}.default`, message: 'must be deny or advisory' });
300
+ }
301
+ if (siblings.enforce === undefined)
302
+ return;
303
+ if (!Array.isArray(siblings.enforce)) {
304
+ issues.push({
305
+ path: `${path}.enforce`,
306
+ message: 'must be an array of child slice ids or subtree paths',
307
+ });
308
+ return;
309
+ }
310
+ const seen = new Set();
311
+ siblings.enforce.forEach((entry, entryIndex) => {
312
+ const entryPath = `${path}.enforce[${entryIndex}]`;
313
+ if (typeof entry !== 'string' || entry.trim().length === 0) {
314
+ issues.push({ path: entryPath, message: 'must be a non-empty child slice id or subtree path' });
315
+ return;
316
+ }
317
+ const normalized = trimTrailingSlashes(entry.trim().replace(/\\/g, '/'));
318
+ if (normalized.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) {
319
+ issues.push({ path: entryPath, message: 'must be a child slice id or subtree path without . or ..' });
320
+ return;
321
+ }
322
+ if (normalized.includes('*')) {
323
+ issues.push({
324
+ path: entryPath,
325
+ message: 'must be a child slice id or subtree path. * is not a wildcard on siblings.enforce',
326
+ });
327
+ return;
328
+ }
329
+ if (seen.has(normalized)) {
330
+ issues.push({ path: entryPath, message: 'duplicate enforce entry' });
331
+ return;
332
+ }
333
+ seen.add(normalized);
334
+ });
335
+ });
336
+ }
337
+ const BARE_CHILD_CROSS_SLICE = 'must be a slice id with a slash. A bare name is ambiguous across universes.';
338
+ const WHOLE_SEGMENT_STAR = '* matches one whole path segment. ** and a partial segment are not wildcards.';
339
+ /**
340
+ * Whole-segment `*` is legal only on this list. A bare name is rejected.
341
+ * The universe allowedCrossSlice is not checked here and still treats `*` as a literal.
342
+ */
343
+ function childCrossSlicePatternIssue(raw) {
344
+ const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
345
+ if (trimmed.length === 0)
346
+ return 'must be a non-empty slice id';
347
+ if (!trimmed.includes('/'))
348
+ return BARE_CHILD_CROSS_SLICE;
349
+ const parts = trimmed.split('/');
350
+ for (const part of parts) {
351
+ if (part.length === 0 || part === '.' || part === '..') {
352
+ return 'must be a slice id without empty, . or .. segments';
353
+ }
354
+ if (part.includes('*') && part !== '*')
355
+ return WHOLE_SEGMENT_STAR;
356
+ }
357
+ return null;
358
+ }
359
+ function childCrossSlicePatternKey(raw) {
360
+ return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase();
361
+ }
362
+ export function validateChildSliceAllowedCrossSlice(candidate, issues) {
363
+ const rules = candidate.rules;
364
+ if (!Array.isArray(rules))
365
+ return;
366
+ rules.forEach((rule, index) => {
367
+ if (!isObject(rule))
368
+ return;
369
+ const child = rule.childSlices;
370
+ if (!isObject(child) || child.allowedCrossSlice === undefined)
371
+ return;
372
+ const edges = child.allowedCrossSlice;
373
+ const path = `$.rules[${index}].childSlices.allowedCrossSlice`;
374
+ if (!Array.isArray(edges)) {
375
+ issues.push({ path, message: 'must be an array of { from, to } child slice ids' });
376
+ return;
377
+ }
378
+ const seen = new Set();
379
+ edges.forEach((edge, edgeIndex) => {
380
+ const edgePath = `${path}[${edgeIndex}]`;
381
+ if (!isObject(edge)) {
382
+ issues.push({ path: edgePath, message: 'must be an object with from and to' });
383
+ return;
384
+ }
385
+ for (const key of Object.keys(edge)) {
386
+ if (key !== 'from' && key !== 'to') {
387
+ issues.push({ path: propertyPath(edgePath, key), message: 'unknown field' });
388
+ }
389
+ }
390
+ for (const side of ['from', 'to']) {
391
+ const value = edge[side];
392
+ const sidePath = `${edgePath}.${side}`;
393
+ if (typeof value !== 'string' || value.trim().length === 0) {
394
+ issues.push({ path: sidePath, message: 'must be a non-empty slice id' });
395
+ continue;
396
+ }
397
+ const issue = childCrossSlicePatternIssue(value);
398
+ if (issue)
399
+ issues.push({ path: sidePath, message: issue });
400
+ }
401
+ if (typeof edge.from !== 'string' || typeof edge.to !== 'string')
402
+ return;
403
+ const key = `${childCrossSlicePatternKey(edge.from)}\0${childCrossSlicePatternKey(edge.to)}`;
404
+ if (!key.startsWith('\0') && !key.endsWith('\0')) {
405
+ if (seen.has(key)) {
406
+ issues.push({ path: edgePath, message: 'duplicate child slice allowance' });
407
+ return;
408
+ }
409
+ seen.add(key);
410
+ }
411
+ });
412
+ });
413
+ }
414
+ const ALIAS_TARGET_MESSAGE = 'must be a child of a universe shape this rule names (its universe sliceFolders shape plus one child segment). A bare name, a universe id alone, a target outside every universe shape, and a wildcard are rejected. Config load checks the shape only; doctor reports a target universe no file belongs to.';
415
+ const ALIAS_LEGACY_AMBIGUOUS_MESSAGE = 'is a 4.8.23 stars id (star bindings before the last literal dropped) that maps to more than one universe shape of this rule. Write the new stars id, which keeps every star binding (e.g. orders/api/v1/x for modules/*/api/*).';
416
+ const ALIAS_OVERLAP_MESSAGE = 'overlaps a slice folder. An alias covers only files outside the slice trees.';
417
+ const ALIAS_SAME_FILE_MESSAGE = 'two slice aliases match the same file';
418
+ function aliasPathSegments(raw) {
419
+ return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase().split('/').filter((part) => part.length > 0);
420
+ }
421
+ /**
422
+ * A `from` whose last segment has no wildcard is a folder form: it also covers
423
+ * everything under it at match time (as sharedRoots does), so the overlap and
424
+ * same-file checks see the subtree too.
425
+ */
426
+ function aliasSegmentsWithSubtree(segments) {
427
+ const last = segments[segments.length - 1];
428
+ if (last === undefined || last.includes('*'))
429
+ return [...segments];
430
+ return [...segments, '**'];
431
+ }
432
+ function concreteGlobPrefix(segments) {
433
+ const prefix = [];
434
+ for (const segment of segments) {
435
+ if (segment.includes('*'))
436
+ break;
437
+ prefix.push(segment);
438
+ }
439
+ return prefix;
440
+ }
441
+ function segmentsArePrefix(prefix, full) {
442
+ if (prefix.length > full.length)
443
+ return false;
444
+ for (let index = 0; index < prefix.length; index += 1) {
445
+ if (prefix[index] !== full[index])
446
+ return false;
447
+ }
448
+ return true;
449
+ }
450
+ function anchoredSlicePrefix(entry) {
451
+ const segments = entry.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
452
+ if (segments.length < 2 || segments[0] === '*' || segments[0] === '**')
453
+ return null;
454
+ if (!segments.some((part) => part === '*'))
455
+ return null;
456
+ const prefix = [];
457
+ for (const segment of segments) {
458
+ if (segment === '*' || segment === '**')
459
+ break;
460
+ if (segment.includes('*'))
461
+ return null;
462
+ prefix.push(segment);
463
+ }
464
+ return prefix.length > 0 ? prefix : null;
465
+ }
466
+ /** Trailing ** does not invent a slice-folder name the glob does not already reach. */
467
+ function globOverlapsAnchored(glob, patternPrefix) {
468
+ const prefix = concreteGlobPrefix(glob);
469
+ const wild = prefix.length < glob.length;
470
+ const rooted = prefix[0] === 'src' || prefix[0] === 'app' ? prefix.slice(1) : prefix;
471
+ if (segmentsArePrefix(patternPrefix, prefix) || segmentsArePrefix(patternPrefix, rooted))
472
+ return true;
473
+ if (!wild)
474
+ return false;
475
+ return ((segmentsArePrefix(prefix, patternPrefix) && prefix.length < patternPrefix.length) ||
476
+ (segmentsArePrefix(prefix, ['src', ...patternPrefix]) && prefix.length < patternPrefix.length + 1) ||
477
+ (segmentsArePrefix(prefix, ['app', ...patternPrefix]) && prefix.length < patternPrefix.length + 1));
478
+ }
479
+ function globOverlapsBare(glob, name) {
480
+ for (let index = 0; index < glob.length; index += 1) {
481
+ if (glob[index] !== name && glob[index] !== '*')
482
+ continue;
483
+ const rest = glob.slice(index + 1);
484
+ if (rest.length >= 2)
485
+ return true;
486
+ if (rest.some((segment) => segment === '*' || segment === '**'))
487
+ return true;
488
+ }
489
+ const prefix = concreteGlobPrefix(glob);
490
+ return (prefix.length < glob.length &&
491
+ (prefix.length === 0 || (prefix.length === 1 && (prefix[0] === 'src' || prefix[0] === 'app'))));
492
+ }
493
+ function globOverlapsSliceEntry(glob, entry) {
494
+ const segments = entry.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
495
+ if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
496
+ return globOverlapsBare(glob, segments[0]);
497
+ }
498
+ const prefix = anchoredSlicePrefix(entry);
499
+ return prefix ? globOverlapsAnchored(glob, prefix) : false;
500
+ }
501
+ function aliasGlobIssue(raw) {
502
+ const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
503
+ if (trimmed.length === 0)
504
+ return 'must be a non-empty path glob';
505
+ if (trimmed === '*' || trimmed === '**')
506
+ return 'must not cover the whole tree';
507
+ for (const part of trimmed.split('/')) {
508
+ if (part.length === 0 || part === '.' || part === '..') {
509
+ return 'must be a path glob without empty, . or .. segments';
510
+ }
511
+ if (part === '*' || part === '**')
512
+ continue;
513
+ if (part.includes('*') || part.includes('?') || part.includes('{') || part.includes('[')) {
514
+ return '* matches one whole path segment. ** is a whole segment. A partial segment is not a wildcard.';
515
+ }
516
+ }
517
+ return null;
518
+ }
519
+ function universeShapes(folders, identity) {
520
+ const shapes = [];
521
+ if (!Array.isArray(folders))
522
+ return shapes;
523
+ const stars = identity === 'stars';
524
+ for (const raw of folders) {
525
+ if (typeof raw !== 'string' || raw.length === 0)
526
+ continue;
527
+ const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
528
+ if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
529
+ shapes.push({ literals: [segments[0], null] });
530
+ continue;
531
+ }
532
+ if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
533
+ continue;
534
+ if (!segments.includes('*'))
535
+ continue;
536
+ if (stars) {
537
+ // Same rule as the slice id: drop only literals before the last literal.
538
+ let lastLiteral = -1;
539
+ for (let index = 0; index < segments.length; index += 1) {
540
+ if (segments[index] !== '*')
541
+ lastLiteral = index;
542
+ }
543
+ if (lastLiteral < 0)
544
+ continue;
545
+ const literals = segments
546
+ .filter((part, index) => index >= lastLiteral || part === '*')
547
+ .map((part) => (part === '*' ? null : part));
548
+ const legacy = segments.slice(lastLiteral).map((part) => (part === '*' ? null : part));
549
+ shapes.push(legacy.length === literals.length ? { literals } : { literals, legacy });
550
+ continue;
551
+ }
552
+ shapes.push({ literals: segments.map((part) => (part === '*' ? null : part)) });
553
+ }
554
+ return shapes;
555
+ }
556
+ function shapeMatches(shape, segments) {
557
+ if (shape.literals.length !== segments.length)
558
+ return false;
559
+ return shape.literals.every((literal, index) => literal === null || literal === segments[index]);
560
+ }
561
+ function aliasTargetIssue(raw, shapes) {
562
+ const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
563
+ if (trimmed.length === 0 || trimmed.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) {
564
+ return ALIAS_TARGET_MESSAGE;
565
+ }
566
+ if (trimmed.includes('*') || !trimmed.includes('/'))
567
+ return ALIAS_TARGET_MESSAGE;
568
+ const parts = trimmed.split('/').map((part) => part.toLowerCase());
569
+ if (shapes.length === 0)
570
+ return ALIAS_TARGET_MESSAGE;
571
+ if (shapes.some((shape) => shapeMatches(shape, parts)))
572
+ return ALIAS_TARGET_MESSAGE;
573
+ if (shapes.some((shape) => shapeMatches(shape, parts.slice(0, -1))))
574
+ return null;
575
+ // 4.8.23 stars id: accepted (with a CONFIG_SLICE_LEGACY_STARS_ID warning) when it maps
576
+ // to exactly one new universe shape; rejected only when that mapping is ambiguous.
577
+ const legacyTargets = new Set(shapes
578
+ .filter((shape) => shape.legacy !== undefined &&
579
+ !shapeMatches({ literals: shape.legacy }, parts) &&
580
+ shapeMatches({ literals: shape.legacy }, parts.slice(0, -1)))
581
+ .map((shape) => shape.literals.map((part) => part ?? '*').join('/')));
582
+ if (legacyTargets.size === 1)
583
+ return null;
584
+ if (legacyTargets.size > 1)
585
+ return ALIAS_LEGACY_AMBIGUOUS_MESSAGE;
586
+ return ALIAS_TARGET_MESSAGE;
587
+ }
588
+ /** A leading src/ or app/ is optional at match time, so both spellings are one glob. */
589
+ function aliasGlobVariants(segments) {
590
+ const head = segments[0];
591
+ if ((head === 'src' || head === 'app') && segments.length > 1)
592
+ return [segments, segments.slice(1)];
593
+ return [segments];
594
+ }
595
+ function aliasesCanMatchSameFile(left, right) {
596
+ const leftVariants = aliasGlobVariants(left);
597
+ const rightVariants = aliasGlobVariants(right);
598
+ for (const a of leftVariants) {
599
+ if (globsCanMatchSame(a, right, 0, 0, new Map()))
600
+ return true;
601
+ }
602
+ for (const b of rightVariants) {
603
+ if (globsCanMatchSame(left, b, 0, 0, new Map()))
604
+ return true;
605
+ }
606
+ return false;
607
+ }
608
+ function globsCanMatchSame(left, right, i, j, memo) {
609
+ const key = `${i}:${j}`;
610
+ const cached = memo.get(key);
611
+ if (cached !== undefined)
612
+ return cached;
613
+ let matched = false;
614
+ if (i === left.length && j === right.length)
615
+ matched = true;
616
+ else if (i < left.length && left[i] === '**') {
617
+ matched =
618
+ globsCanMatchSame(left, right, i + 1, j, memo) ||
619
+ (j < right.length && globsCanMatchSame(left, right, i, j + 1, memo));
620
+ }
621
+ else if (j < right.length && right[j] === '**') {
622
+ matched =
623
+ globsCanMatchSame(left, right, i, j + 1, memo) ||
624
+ (i < left.length && globsCanMatchSame(left, right, i + 1, j, memo));
625
+ }
626
+ else if (i < left.length && j < right.length) {
627
+ const a = left[i];
628
+ const b = right[j];
629
+ if (a === b || a === '*' || b === '*')
630
+ matched = globsCanMatchSame(left, right, i + 1, j + 1, memo);
631
+ }
632
+ memo.set(key, matched);
633
+ return matched;
634
+ }
635
+ export function validateChildSliceAliases(candidate, issues) {
636
+ const rules = candidate.rules;
637
+ if (!Array.isArray(rules))
638
+ return;
639
+ rules.forEach((rule, index) => {
640
+ if (!isObject(rule))
641
+ return;
642
+ const child = rule.childSlices;
643
+ if (!isObject(child) || child.sliceAliases === undefined)
644
+ return;
645
+ const aliases = child.sliceAliases;
646
+ const path = `$.rules[${index}].childSlices.sliceAliases`;
647
+ if (!Array.isArray(aliases)) {
648
+ issues.push({ path, message: 'must be an array of { from, to } slice aliases' });
649
+ return;
650
+ }
651
+ const shapes = universeShapes(rule.sliceFolders, rule.sliceIdentity);
652
+ const sliceEntries = [
653
+ ...(Array.isArray(rule.sliceFolders) ? rule.sliceFolders : []),
654
+ ...(Array.isArray(child.sliceFolders) ? child.sliceFolders : []),
655
+ ].filter((entry) => typeof entry === 'string' && entry.length > 0);
656
+ const globs = [];
657
+ aliases.forEach((alias, aliasIndex) => {
658
+ const aliasPath = `${path}[${aliasIndex}]`;
659
+ if (!isObject(alias)) {
660
+ issues.push({ path: aliasPath, message: 'must be an object with from and to' });
661
+ return;
662
+ }
663
+ for (const key of Object.keys(alias)) {
664
+ if (key !== 'from' && key !== 'to') {
665
+ issues.push({ path: propertyPath(aliasPath, key), message: 'unknown field' });
666
+ }
667
+ }
668
+ const from = alias.from;
669
+ const to = alias.to;
670
+ if (typeof from !== 'string' || from.trim().length === 0) {
671
+ issues.push({ path: `${aliasPath}.from`, message: 'must be a non-empty path glob' });
672
+ }
673
+ else {
674
+ const issue = aliasGlobIssue(from);
675
+ if (issue)
676
+ issues.push({ path: `${aliasPath}.from`, message: issue });
677
+ else {
678
+ const segments = aliasSegmentsWithSubtree(aliasPathSegments(from));
679
+ if (sliceEntries.some((entry) => globOverlapsSliceEntry(segments, entry))) {
680
+ issues.push({ path: `${aliasPath}.from`, message: ALIAS_OVERLAP_MESSAGE });
681
+ }
682
+ for (const previous of globs) {
683
+ if (aliasesCanMatchSameFile(previous, segments)) {
684
+ issues.push({ path: aliasPath, message: ALIAS_SAME_FILE_MESSAGE });
685
+ break;
686
+ }
687
+ }
688
+ globs.push(segments);
689
+ }
690
+ }
691
+ if (typeof to !== 'string' || to.trim().length === 0) {
692
+ issues.push({ path: `${aliasPath}.to`, message: ALIAS_TARGET_MESSAGE });
693
+ }
694
+ else {
695
+ const issue = aliasTargetIssue(to, shapes);
696
+ if (issue)
697
+ issues.push({ path: `${aliasPath}.to`, message: issue });
698
+ }
699
+ });
700
+ });
701
+ }