arkgate 4.8.23 → 4.8.25

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 (201) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +41 -91
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +707 -121
  6. package/bin/ark-mcp-runtime.mjs +448 -169
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +42 -22
  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/adoption-stance.mjs +2 -2
  13. package/bin/lib/adr-path.mjs +3 -3
  14. package/bin/lib/agent-gates.mjs +0 -61
  15. package/bin/lib/agent-homes.mjs +12 -21
  16. package/bin/lib/agent-projection-command.mjs +9 -5
  17. package/bin/lib/ambient-state.mjs +9 -30
  18. package/bin/lib/analysis-engine.mjs +15 -11
  19. package/bin/lib/architecture-scan.mjs +61 -15
  20. package/bin/lib/ark-order-doctor.mjs +4 -29
  21. package/bin/lib/ark-order-facts.mjs +319 -17
  22. package/bin/lib/ark-order-sensors.mjs +28 -101
  23. package/bin/lib/ark-order-types.mjs +3 -2
  24. package/bin/lib/ark-run-doctor.mjs +17 -28
  25. package/bin/lib/ark-run-facts.mjs +461 -48
  26. package/bin/lib/ark-run-sensors.mjs +6 -4
  27. package/bin/lib/arkgate-pins.mjs +91 -0
  28. package/bin/lib/arkrule-file-hints.mjs +2 -47
  29. package/bin/lib/arkrules-contract.mjs +124 -133
  30. package/bin/lib/arkrules-sensors.mjs +55 -252
  31. package/bin/lib/check-args.mjs +85 -1
  32. package/bin/lib/ci-and-commands.mjs +24 -78
  33. package/bin/lib/ci-merge-boundary.mjs +1 -1
  34. package/bin/lib/class-source-scan.mjs +479 -0
  35. package/bin/lib/clone-detection.mjs +474 -0
  36. package/bin/lib/codex-home.mjs +5 -3
  37. package/bin/lib/config-contract-slices.mjs +739 -0
  38. package/bin/lib/config-contract.mjs +42 -607
  39. package/bin/lib/config-extras.mjs +16 -22
  40. package/bin/lib/config-warnings.mjs +32 -0
  41. package/bin/lib/contract-smells.mjs +33 -15
  42. package/bin/lib/core-ratchet.mjs +0 -2
  43. package/bin/lib/dashboard-cli.mjs +62 -0
  44. package/bin/lib/deep-module-coach.mjs +2 -4
  45. package/bin/lib/deploy-path.mjs +13 -38
  46. package/bin/lib/design-delta.mjs +1 -1
  47. package/bin/lib/design-smells.mjs +2 -2
  48. package/bin/lib/diagnostic-catalog.mjs +23 -9
  49. package/bin/lib/doctor-advisories.mjs +78 -8
  50. package/bin/lib/doctor-green-cite.mjs +1 -1
  51. package/bin/lib/doctor-human.mjs +1 -1
  52. package/bin/lib/doctor-next-actions.mjs +19 -4
  53. package/bin/lib/doctor-plan.mjs +9 -13
  54. package/bin/lib/duplication-io.mjs +572 -0
  55. package/bin/lib/effective-contract-load.mjs +282 -83
  56. package/bin/lib/enforcement-profiles.mjs +40 -4
  57. package/bin/lib/entry-points-io.mjs +424 -0
  58. package/bin/lib/extra-merge-teeth.mjs +36 -0
  59. package/bin/lib/field-install.mjs +38 -14
  60. package/bin/lib/first-run-help.mjs +10 -5
  61. package/bin/lib/flat-parent-importers.mjs +139 -0
  62. package/bin/lib/flat-parent-pilot.mjs +40 -100
  63. package/bin/lib/gate-files.mjs +58 -90
  64. package/bin/lib/git-change-scope.mjs +109 -0
  65. package/bin/lib/github-enforcement.mjs +166 -29
  66. package/bin/lib/golden-pattern.mjs +1 -1
  67. package/bin/lib/hook-templates.mjs +17 -15
  68. package/bin/lib/host-support-matrix.mjs +3 -1
  69. package/bin/lib/html-report-advisories.mjs +4 -0
  70. package/bin/lib/html-report-depth.mjs +2 -2
  71. package/bin/lib/html-report.mjs +2 -2
  72. package/bin/lib/import-graph-projection.mjs +112 -0
  73. package/bin/lib/import-resolve.mjs +13 -1
  74. package/bin/lib/improvement-compass-doctor.mjs +1 -24
  75. package/bin/lib/install-migrate.mjs +50 -73
  76. package/bin/lib/invariant-coverage-io.mjs +4 -3
  77. package/bin/lib/invariant-coverage.mjs +85 -11
  78. package/bin/lib/invariant-probe-cli.mjs +347 -0
  79. package/bin/lib/invariant-probe-io.mjs +165 -0
  80. package/bin/lib/invariant-probe-runner.mjs +268 -0
  81. package/bin/lib/invariant-probe-sites.mjs +174 -0
  82. package/bin/lib/invariant-probe-workspace.mjs +183 -0
  83. package/bin/lib/invariant-probe.mjs +461 -0
  84. package/bin/lib/invariant-tests-path.mjs +16 -22
  85. package/bin/lib/literal-path-drift.mjs +4 -2
  86. package/bin/lib/managed-upgrade.mjs +2 -8
  87. package/bin/lib/mcp-adoption.mjs +13 -6
  88. package/bin/lib/mcp-hook-payload.mjs +403 -63
  89. package/bin/lib/orphan-modules-io.mjs +458 -0
  90. package/bin/lib/orphan-modules.mjs +251 -0
  91. package/bin/lib/outside-importers.mjs +197 -0
  92. package/bin/lib/package-manager.mjs +60 -2
  93. package/bin/lib/parse-health.mjs +1 -1
  94. package/bin/lib/passthrough-commands.mjs +83 -0
  95. package/bin/lib/persistence-write-hint.mjs +106 -0
  96. package/bin/lib/pin-dependent-gates.mjs +117 -0
  97. package/bin/lib/policy-delta-io.mjs +219 -14
  98. package/bin/lib/post-green-path.mjs +1 -1
  99. package/bin/lib/prepare-change.mjs +115 -7
  100. package/bin/lib/prepare-write.mjs +2 -2
  101. package/bin/lib/presets.mjs +59 -73
  102. package/bin/lib/product-copy.mjs +1 -6
  103. package/bin/lib/project-root.mjs +31 -62
  104. package/bin/lib/remediation.mjs +23 -4
  105. package/bin/lib/resolved-candidate-facts.mjs +405 -117
  106. package/bin/lib/rules-inventory.mjs +22 -3
  107. package/bin/lib/rules-under-contract.mjs +247 -9
  108. package/bin/lib/scan-files.mjs +36 -4
  109. package/bin/lib/schema-validation.mjs +130 -0
  110. package/bin/lib/sensor-promote-cli.mjs +2 -11
  111. package/bin/lib/sensor-promote-io.mjs +33 -22
  112. package/bin/lib/sensor-promotion.mjs +1 -1
  113. package/bin/lib/skill-catalog-apply.mjs +1 -1
  114. package/bin/lib/skill-install.mjs +42 -38
  115. package/bin/lib/snippet-analysis.mjs +140 -6
  116. package/bin/lib/source-policy.mjs +34 -0
  117. package/bin/lib/start-install-recovery.mjs +72 -2
  118. package/bin/lib/start-preview.mjs +31 -1
  119. package/bin/lib/states-transitions-presence.mjs +3 -3
  120. package/bin/lib/status-command.mjs +35 -22
  121. package/bin/lib/status-manifest.mjs +50 -30
  122. package/bin/lib/status-transition-catalog.mjs +0 -6
  123. package/bin/lib/suggestions.mjs +3 -3
  124. package/bin/lib/team-parliament-io.mjs +76 -88
  125. package/bin/lib/team-parliament.mjs +21 -10
  126. package/bin/lib/upgrade-behavior-changes.mjs +2 -2
  127. package/bin/lib/upgrade-command.mjs +2 -24
  128. package/bin/lib/upgrade-outcome.mjs +1 -1
  129. package/bin/lib/upstream-report.mjs +21 -10
  130. package/bin/lib/violations.mjs +10 -9
  131. package/bin/lib/weakest-link.mjs +13 -0
  132. package/bin/lib/write-path-capabilities.mjs +6 -4
  133. package/dist/chunk-A5EIY3HI.cjs +5 -0
  134. package/dist/chunk-AHACEKSV.js +16 -0
  135. package/dist/chunk-SVSQVGFJ.js +5 -0
  136. package/dist/chunk-UN5EH4QV.cjs +16 -0
  137. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-GPvvdleM.d.cts} +30 -4
  138. package/dist/configTypes-GPvvdleM.d.ts +257 -0
  139. package/dist/eslint/index.cjs +18 -5
  140. package/dist/eslint/index.d.cts +368 -0
  141. package/dist/eslint/index.d.ts +139 -156
  142. package/dist/eslint/index.js +11 -5
  143. package/dist/index.cjs +36 -35
  144. package/dist/index.d.cts +5547 -0
  145. package/dist/index.d.ts +3283 -198
  146. package/dist/index.js +36 -35
  147. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  148. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  149. package/dist/nestjs/index.cjs +1 -18
  150. package/dist/nestjs/index.d.cts +22 -0
  151. package/dist/nestjs/index.d.ts +1 -3
  152. package/dist/nestjs/index.js +1 -18
  153. package/dist/order/index.cjs +1 -1
  154. package/dist/order/index.d.cts +155 -0
  155. package/dist/order/index.d.ts +15 -3
  156. package/dist/order/index.js +1 -1
  157. package/dist/runtime/index.cjs +13 -30
  158. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +828 -276
  159. package/dist/runtime/index.d.ts +2741 -133
  160. package/dist/runtime/index.js +13 -30
  161. package/dist/{types-Bdj32QwA.d.ts → types-CnauMQQL.d.cts} +607 -163
  162. package/dist/types-CnauMQQL.d.ts +1800 -0
  163. package/docs/README.md +3 -1
  164. package/docs/agent-guide.md +217 -16
  165. package/docs/ai-gates.md +174 -26
  166. package/docs/arkorder.md +32 -15
  167. package/docs/brownfield-adoption.md +5 -1
  168. package/docs/configuration.md +230 -31
  169. package/docs/develop.md +2 -1
  170. package/docs/diagnostics.md +212 -19
  171. package/docs/package-surface.md +35 -9
  172. package/docs/product-voice.md +7 -1
  173. package/package.json +69 -19
  174. package/schemas/ark.analysis-result.schema.json +15 -0
  175. package/schemas/ark.config.schema.json +77 -12
  176. package/schemas/ark.invariant-probe.schema.json +368 -0
  177. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  178. package/schemas/ark.status-manifest.schema.json +22 -0
  179. package/server.json +3 -9
  180. package/templates/agent-skills/README.md +1 -1
  181. package/templates/agent-skills/ark-adopt/SKILL.md +5 -1
  182. package/templates/agent-skills/ark-contract/SKILL.md +17 -0
  183. package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
  184. package/templates/agent-skills/ark-explore/SKILL.md +7 -0
  185. package/templates/agent-skills/ark-fix/SKILL.md +20 -0
  186. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  187. package/templates/agent-skills/ark-place/SKILL.md +8 -0
  188. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  189. package/templates/arkrules/PersistenceAdapters.json +1 -1
  190. package/templates/arkrules/PresentationAdapters.json +1 -1
  191. package/templates/skills/ark-adopt.md +5 -1
  192. package/templates/skills/ark-contract.md +17 -0
  193. package/templates/skills/ark-coverage.md +2 -0
  194. package/templates/skills/ark-explore.md +7 -0
  195. package/templates/skills/ark-fix.md +20 -0
  196. package/templates/skills/ark-order.md +4 -2
  197. package/templates/skills/ark-place.md +8 -0
  198. package/templates/skills/ark-runtime.md +6 -1
  199. package/bin/lib/ark-order-error.mjs +0 -18
  200. package/bin/lib/ark-order-invariants.mjs +0 -346
  201. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -9,13 +9,15 @@
9
9
  */
10
10
 
11
11
  import { ARK_ORDER_SCHEMA_DEF, ARK_RUN_SCHEMA_DEF, defaultedArkOrder, defaultedArkRun, validateArkOrderExtra, validateArkRunExtra, } from './config-extras.mjs';
12
+ import { CHILD_SLICES_SCHEMA_DEF, SHARED_IMPORTS_SLICE_SCHEMA_DEF, validateSliceContract, } from './config-contract-slices.mjs';
13
+ import { isObject, propertyPath, validateSchemaNode, valueType } from './schema-validation.mjs';
12
14
  import { canonicalStewardId } from './team-parliament.mjs';
13
15
  /** Current published ark.config.json schema version (ADR 0027: 1.3 adds optional arkOrder). */
14
16
  export const ARK_CONFIG_SCHEMA_VERSION = '1.3';
15
17
  /** Closed layer trust tags. Optional; absence is silent. Not a schemaVersion bump. */
16
18
  export const LAYER_TRUST_BOUNDARIES = ['public', 'auth', 'admin', 'internal'];
17
19
  /** Future house: empty globs are expected; missing owners stay silent even when required. */
18
- export function isFutureHouseLayer(layer) {
20
+ function isFutureHouseLayer(layer) {
19
21
  return layer?.optional === true || layer?.reserved === true || layer?.allowEmpty === true;
20
22
  }
21
23
  /**
@@ -140,10 +142,21 @@ export const ARK_CONFIG_SCHEMA = {
140
142
  * maxFiles also bounds structural-hint preload (orchestration-only / thin-adapter /
141
143
  * writes-via-aggregate). There is no arkrules.hintBudget. */
142
144
  coverage: { $ref: '#/$defs/coverage' },
143
- /** ADR 0012 — layer name → relative path to arkrules/<Layer>.json */
145
+ /** ADR 0012 — layer name → one path, or a list of paths merged into that layer. */
144
146
  arkRules: {
145
147
  type: 'object',
146
- additionalProperties: { type: 'string', minLength: 1 },
148
+ additionalProperties: {
149
+ description: 'A project-relative ArkRules path, or a list of paths merged into one catalog for this layer. A string keeps today\'s behaviour. An older build that types this value as a string rejects an array at config load.',
150
+ oneOf: [
151
+ { type: 'string', minLength: 1 },
152
+ {
153
+ type: 'array',
154
+ minItems: 1,
155
+ uniqueItems: true,
156
+ items: { type: 'string', minLength: 1 },
157
+ },
158
+ ],
159
+ },
147
160
  default: {},
148
161
  },
149
162
  /** ADR 0020 — optional ArkRun extra. Absence is silent; unknown keys fail closed. */
@@ -217,7 +230,7 @@ export const ARK_CONFIG_SCHEMA = {
217
230
  sliceIdentity: {
218
231
  type: 'string',
219
232
  enum: ['path', 'stars'],
220
- description: 'How a starred sliceFolders prefix is named. Absent and path keep today\'s ids (every literal and star binding). stars keeps the last literal plus the star bindings, so parallel trees share one feature id. Bare names are unchanged.',
233
+ description: 'How a starred sliceFolders prefix is named. Absent and path keep today\'s ids (every literal and star binding). stars keeps the last literal plus every star binding (a star before the last literal is kept too), so parallel trees share one feature id. Bare names are unchanged.',
221
234
  },
222
235
  sharedRoots: { ...stringArraySchema, minItems: 1 },
223
236
  allowedCrossSlice: {
@@ -233,69 +246,8 @@ export const ARK_CONFIG_SCHEMA = {
233
246
  },
234
247
  },
235
248
  },
236
- sharedImportsSlice: {
237
- type: 'string',
238
- enum: ['deny', 'deny-cross-parent'],
239
- 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. arkgate 4.8.22 and older reject deny-cross-parent.',
240
- },
241
- childSlices: {
242
- type: 'object',
243
- additionalProperties: false,
244
- required: ['sliceFolders'],
245
- description: 'Optional inner wall under this rule. Absent keeps today\'s universe wall. sliceFolders names children. commonFolders and flat files are universe common. siblings is "deny" (default), "advisory", or { default, enforce }. parentMayImportChild defaults to false. 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.',
246
- properties: {
247
- sliceFolders: { ...stringArraySchema, minItems: 1 },
248
- sliceIdentity: {
249
- type: 'string',
250
- enum: ['path', 'stars'],
251
- },
252
- commonFolders: { ...stringArraySchema, minItems: 1 },
253
- siblings: {
254
- description: '"deny" or "advisory", or { default, enforce } so listed subtrees are errors while the default stays advisory. A string-enum build rejects the object at config load (must be one of deny, advisory).',
255
- oneOf: [
256
- { type: 'string', enum: ['deny', 'advisory'] },
257
- {
258
- type: 'object',
259
- additionalProperties: false,
260
- required: ['default'],
261
- properties: {
262
- default: { type: 'string', enum: ['deny', 'advisory'] },
263
- enforce: { ...stringArraySchema },
264
- },
265
- },
266
- ],
267
- },
268
- parentMayImportChild: { type: 'boolean' },
269
- sliceAliases: {
270
- type: 'array',
271
- minItems: 1,
272
- 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 bare name, a universe id alone, an unknown universe, and a wildcard in to are rejected. 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.',
273
- items: {
274
- type: 'object',
275
- additionalProperties: false,
276
- required: ['from', 'to'],
277
- properties: {
278
- from: { type: 'string', minLength: 1 },
279
- to: { type: 'string', minLength: 1 },
280
- },
281
- },
282
- },
283
- allowedCrossSlice: {
284
- type: 'array',
285
- minItems: 1,
286
- 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).',
287
- items: {
288
- type: 'object',
289
- additionalProperties: false,
290
- required: ['from', 'to'],
291
- properties: {
292
- from: { type: 'string', minLength: 1 },
293
- to: { type: 'string', minLength: 1 },
294
- },
295
- },
296
- },
297
- },
298
- },
249
+ sharedImportsSlice: SHARED_IMPORTS_SLICE_SCHEMA_DEF,
250
+ childSlices: CHILD_SLICES_SCHEMA_DEF,
299
251
  },
300
252
  },
301
253
  safety: {
@@ -338,538 +290,34 @@ export class ArkConfigValidationError extends Error {
338
290
  this.issues = issues;
339
291
  }
340
292
  }
341
- function isObject(value) {
342
- return value !== null && typeof value === 'object' && !Array.isArray(value);
343
- }
344
- function propertyPath(parent, key) {
345
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
346
- ? `${parent}.${key}`
347
- : `${parent}[${JSON.stringify(key)}]`;
348
- }
349
- function valueType(value) {
350
- if (value === null)
351
- return 'null';
352
- if (Array.isArray(value))
353
- return 'array';
354
- return typeof value;
355
- }
356
- function resolveSchemaRef(ref, root) {
357
- const prefix = '#/$defs/';
358
- if (!ref.startsWith(prefix))
359
- return undefined;
360
- return root.$defs[ref.slice(prefix.length)];
361
- }
362
- function validateNode(value, schema, path, root, issues) {
363
- if (schema.$ref) {
364
- const referenced = resolveSchemaRef(schema.$ref, root);
365
- if (!referenced) {
366
- issues.push({ path, message: `schema reference ${schema.$ref} cannot be resolved` });
367
- return;
368
- }
369
- validateNode(value, referenced, path, root, issues);
370
- return;
371
- }
372
- if (schema.const !== undefined && !Object.is(value, schema.const)) {
373
- issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
374
- return;
375
- }
376
- if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
377
- issues.push({ path, message: `must be one of ${schema.enum.map(String).join(', ')}` });
378
- return;
379
- }
380
- if (schema.type === 'object') {
381
- if (!isObject(value)) {
382
- issues.push({ path, message: `must be an object; received ${valueType(value)}` });
383
- return;
384
- }
385
- const properties = schema.properties ?? {};
386
- for (const key of schema.required ?? []) {
387
- if (value[key] === undefined) {
388
- issues.push({ path: propertyPath(path, key), message: 'is required' });
389
- }
390
- }
391
- if (schema.additionalProperties === false) {
392
- for (const key of Object.keys(value)) {
393
- if (!(key in properties)) {
394
- issues.push({ path: propertyPath(path, key), message: 'unknown field' });
395
- }
396
- }
397
- }
398
- else if (schema.additionalProperties !== undefined &&
399
- schema.additionalProperties !== true &&
400
- typeof schema.additionalProperties === 'object') {
401
- const additional = schema.additionalProperties;
402
- for (const key of Object.keys(value)) {
403
- if (!(key in properties)) {
404
- validateNode(value[key], additional, propertyPath(path, key), root, issues);
405
- }
406
- }
407
- }
408
- for (const [key, childSchema] of Object.entries(properties)) {
409
- if (value[key] !== undefined) {
410
- validateNode(value[key], childSchema, propertyPath(path, key), root, issues);
411
- }
412
- }
413
- return;
414
- }
415
- if (schema.type === 'array') {
416
- if (!Array.isArray(value)) {
417
- issues.push({ path, message: `must be an array; received ${valueType(value)}` });
418
- return;
419
- }
420
- if (schema.minItems !== undefined && value.length < schema.minItems) {
421
- issues.push({ path, message: `must contain at least ${schema.minItems} item(s)` });
422
- }
423
- if (schema.uniqueItems) {
424
- const serialized = value.map((entry) => JSON.stringify(entry));
425
- if (new Set(serialized).size !== serialized.length) {
426
- issues.push({ path, message: 'must not contain duplicate items' });
427
- }
428
- }
429
- if (schema.items) {
430
- value.forEach((entry, index) => validateNode(entry, schema.items, `${path}[${index}]`, root, issues));
431
- }
432
- return;
433
- }
434
- if (schema.type === 'string') {
435
- if (typeof value !== 'string') {
436
- issues.push({ path, message: `must be a string; received ${valueType(value)}` });
437
- return;
438
- }
439
- if (schema.minLength !== undefined && value.length < schema.minLength) {
440
- issues.push({ path, message: `must contain at least ${schema.minLength} character(s)` });
441
- }
442
- return;
443
- }
444
- if (schema.type === 'boolean') {
445
- if (typeof value !== 'boolean') {
446
- issues.push({ path, message: `must be a boolean; received ${valueType(value)}` });
447
- }
448
- return;
449
- }
450
- if (schema.type === 'integer') {
451
- if (!Number.isInteger(value)) {
452
- issues.push({ path, message: `must be an integer; received ${valueType(value)}` });
453
- return;
454
- }
455
- if (schema.minimum !== undefined && value < schema.minimum) {
456
- issues.push({ path, message: `must be at least ${schema.minimum}` });
457
- }
458
- }
459
- }
460
- const SIBLING_MODES = ['deny', 'advisory'];
461
- /**
462
- * String enum builds reject this object before they reach a decision.
463
- * The message names both forms so a bad value is obvious at config load.
464
- */
465
- const SIBLINGS_FORM_MESSAGE = 'must be "deny", "advisory", or { "default": "deny" | "advisory", "enforce": ["<child id or subtree path>"] }';
466
- function validateChildSliceSiblings(candidate, issues) {
467
- const rules = candidate.rules;
468
- if (!Array.isArray(rules))
469
- return;
470
- rules.forEach((rule, index) => {
471
- if (!isObject(rule))
472
- return;
473
- const child = rule.childSlices;
474
- if (!isObject(child) || child.siblings === undefined)
475
- return;
476
- const siblings = child.siblings;
477
- const path = `$.rules[${index}].childSlices.siblings`;
478
- if (typeof siblings === 'string') {
479
- if (!SIBLING_MODES.includes(siblings)) {
480
- issues.push({ path, message: SIBLINGS_FORM_MESSAGE });
481
- }
482
- return;
483
- }
484
- if (!isObject(siblings)) {
485
- issues.push({ path, message: SIBLINGS_FORM_MESSAGE });
486
- return;
487
- }
488
- for (const key of Object.keys(siblings)) {
489
- if (key !== 'default' && key !== 'enforce') {
490
- issues.push({ path: propertyPath(path, key), message: 'unknown field' });
491
- }
492
- }
493
- if (siblings.default === undefined) {
494
- issues.push({ path: `${path}.default`, message: 'is required' });
495
- }
496
- else if (siblings.default !== 'deny' && siblings.default !== 'advisory') {
497
- issues.push({ path: `${path}.default`, message: 'must be deny or advisory' });
498
- }
499
- if (siblings.enforce === undefined)
500
- return;
501
- if (!Array.isArray(siblings.enforce)) {
502
- issues.push({
503
- path: `${path}.enforce`,
504
- message: 'must be an array of child slice ids or subtree paths',
505
- });
506
- return;
507
- }
508
- const seen = new Set();
509
- siblings.enforce.forEach((entry, entryIndex) => {
510
- const entryPath = `${path}.enforce[${entryIndex}]`;
511
- if (typeof entry !== 'string' || entry.trim().length === 0) {
512
- issues.push({ path: entryPath, message: 'must be a non-empty child slice id or subtree path' });
513
- return;
514
- }
515
- const normalized = trimTrailingSlashes(entry.trim().replace(/\\/g, '/'));
516
- if (normalized.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) {
517
- issues.push({ path: entryPath, message: 'must be a child slice id or subtree path without . or ..' });
518
- return;
519
- }
520
- if (normalized.includes('*')) {
521
- issues.push({
522
- path: entryPath,
523
- message: 'must be a child slice id or subtree path. * is not a wildcard on siblings.enforce',
524
- });
525
- return;
526
- }
527
- if (seen.has(normalized)) {
528
- issues.push({ path: entryPath, message: 'duplicate enforce entry' });
529
- return;
530
- }
531
- seen.add(normalized);
532
- });
533
- });
534
- }
535
- const BARE_CHILD_CROSS_SLICE = 'must be a slice id with a slash. A bare name is ambiguous across universes.';
536
- const WHOLE_SEGMENT_STAR = '* matches one whole path segment. ** and a partial segment are not wildcards.';
537
- /**
538
- * Whole-segment `*` is legal only on this list. A bare name is rejected.
539
- * The universe allowedCrossSlice is not checked here and still treats `*` as a literal.
540
- */
541
- function childCrossSlicePatternIssue(raw) {
542
- const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
543
- if (trimmed.length === 0)
544
- return 'must be a non-empty slice id';
545
- if (!trimmed.includes('/'))
546
- return BARE_CHILD_CROSS_SLICE;
547
- const parts = trimmed.split('/');
548
- for (const part of parts) {
549
- if (part.length === 0 || part === '.' || part === '..') {
550
- return 'must be a slice id without empty, . or .. segments';
551
- }
552
- if (part.includes('*') && part !== '*')
553
- return WHOLE_SEGMENT_STAR;
554
- }
555
- return null;
556
- }
557
- function childCrossSlicePatternKey(raw) {
558
- return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase();
559
- }
560
- function validateChildSliceAllowedCrossSlice(candidate, issues) {
561
- const rules = candidate.rules;
562
- if (!Array.isArray(rules))
293
+ /** The schema walk has no oneOf, so string-or-array `arkRules` values are checked here. */
294
+ function validateArkRulesRefs(candidate, issues) {
295
+ const refs = candidate.arkRules;
296
+ if (refs === undefined || !isObject(refs))
563
297
  return;
564
- rules.forEach((rule, index) => {
565
- if (!isObject(rule))
566
- return;
567
- const child = rule.childSlices;
568
- if (!isObject(child) || child.allowedCrossSlice === undefined)
569
- return;
570
- const edges = child.allowedCrossSlice;
571
- const path = `$.rules[${index}].childSlices.allowedCrossSlice`;
572
- if (!Array.isArray(edges)) {
573
- issues.push({ path, message: 'must be an array of { from, to } child slice ids' });
574
- return;
575
- }
576
- const seen = new Set();
577
- edges.forEach((edge, edgeIndex) => {
578
- const edgePath = `${path}[${edgeIndex}]`;
579
- if (!isObject(edge)) {
580
- issues.push({ path: edgePath, message: 'must be an object with from and to' });
581
- return;
582
- }
583
- for (const key of Object.keys(edge)) {
584
- if (key !== 'from' && key !== 'to') {
585
- issues.push({ path: propertyPath(edgePath, key), message: 'unknown field' });
586
- }
587
- }
588
- for (const side of ['from', 'to']) {
589
- const value = edge[side];
590
- const sidePath = `${edgePath}.${side}`;
591
- if (typeof value !== 'string' || value.trim().length === 0) {
592
- issues.push({ path: sidePath, message: 'must be a non-empty slice id' });
593
- continue;
594
- }
595
- const issue = childCrossSlicePatternIssue(value);
596
- if (issue)
597
- issues.push({ path: sidePath, message: issue });
598
- }
599
- if (typeof edge.from !== 'string' || typeof edge.to !== 'string')
600
- return;
601
- const key = `${childCrossSlicePatternKey(edge.from)}\0${childCrossSlicePatternKey(edge.to)}`;
602
- if (!key.startsWith('\0') && !key.endsWith('\0')) {
603
- if (seen.has(key)) {
604
- issues.push({ path: edgePath, message: 'duplicate child slice allowance' });
605
- return;
606
- }
607
- seen.add(key);
608
- }
609
- });
610
- });
611
- }
612
- const ALIAS_TARGET_MESSAGE = 'must be a child of an existing universe (the universe id this rule already names, plus one child segment). A bare name, a universe id alone, an unknown universe, and a wildcard are rejected.';
613
- const ALIAS_OVERLAP_MESSAGE = 'overlaps a slice folder. An alias covers only files outside the slice trees.';
614
- const ALIAS_SAME_FILE_MESSAGE = 'two slice aliases match the same file';
615
- function aliasPathSegments(raw) {
616
- return trimTrailingSlashes(raw.trim().replace(/\\/g, '/')).toLowerCase().split('/').filter((part) => part.length > 0);
617
- }
618
- function concreteGlobPrefix(segments) {
619
- const prefix = [];
620
- for (const segment of segments) {
621
- if (segment.includes('*'))
622
- break;
623
- prefix.push(segment);
624
- }
625
- return prefix;
626
- }
627
- function segmentsArePrefix(prefix, full) {
628
- if (prefix.length > full.length)
629
- return false;
630
- for (let index = 0; index < prefix.length; index += 1) {
631
- if (prefix[index] !== full[index])
632
- return false;
633
- }
634
- return true;
635
- }
636
- function anchoredSlicePrefix(entry) {
637
- const segments = entry.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
638
- if (segments.length < 2 || segments[0] === '*' || segments[0] === '**')
639
- return null;
640
- if (!segments.some((part) => part === '*'))
641
- return null;
642
- const prefix = [];
643
- for (const segment of segments) {
644
- if (segment === '*' || segment === '**')
645
- break;
646
- if (segment.includes('*'))
647
- return null;
648
- prefix.push(segment);
649
- }
650
- return prefix.length > 0 ? prefix : null;
651
- }
652
- /** Trailing ** does not invent a slice-folder name the glob does not already reach. */
653
- function globOverlapsAnchored(glob, patternPrefix) {
654
- const prefix = concreteGlobPrefix(glob);
655
- const wild = prefix.length < glob.length;
656
- const rooted = prefix[0] === 'src' || prefix[0] === 'app' ? prefix.slice(1) : prefix;
657
- if (segmentsArePrefix(patternPrefix, prefix) || segmentsArePrefix(patternPrefix, rooted))
658
- return true;
659
- if (!wild)
660
- return false;
661
- return ((segmentsArePrefix(prefix, patternPrefix) && prefix.length < patternPrefix.length) ||
662
- (segmentsArePrefix(prefix, ['src', ...patternPrefix]) && prefix.length < patternPrefix.length + 1) ||
663
- (segmentsArePrefix(prefix, ['app', ...patternPrefix]) && prefix.length < patternPrefix.length + 1));
664
- }
665
- function globOverlapsBare(glob, name) {
666
- for (let index = 0; index < glob.length; index += 1) {
667
- if (glob[index] !== name && glob[index] !== '*')
298
+ for (const [layer, value] of Object.entries(refs)) {
299
+ const path = propertyPath('$.arkRules', layer);
300
+ if (typeof value === 'string') {
301
+ if (value.length === 0)
302
+ issues.push({ path, message: 'must be a non-empty relative path string' });
668
303
  continue;
669
- const rest = glob.slice(index + 1);
670
- if (rest.length >= 2)
671
- return true;
672
- if (rest.some((segment) => segment === '*' || segment === '**'))
673
- return true;
674
- }
675
- const prefix = concreteGlobPrefix(glob);
676
- return (prefix.length < glob.length &&
677
- (prefix.length === 0 || (prefix.length === 1 && (prefix[0] === 'src' || prefix[0] === 'app'))));
678
- }
679
- function globOverlapsSliceEntry(glob, entry) {
680
- const segments = entry.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
681
- if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
682
- return globOverlapsBare(glob, segments[0]);
683
- }
684
- const prefix = anchoredSlicePrefix(entry);
685
- return prefix ? globOverlapsAnchored(glob, prefix) : false;
686
- }
687
- function aliasGlobIssue(raw) {
688
- const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
689
- if (trimmed.length === 0)
690
- return 'must be a non-empty path glob';
691
- if (trimmed === '*' || trimmed === '**')
692
- return 'must not cover the whole tree';
693
- for (const part of trimmed.split('/')) {
694
- if (part.length === 0 || part === '.' || part === '..') {
695
- return 'must be a path glob without empty, . or .. segments';
696
304
  }
697
- if (part === '*' || part === '**')
305
+ if (!Array.isArray(value) || value.length === 0) {
306
+ issues.push({ path, message: 'must be a non-empty path string or an array of paths' });
698
307
  continue;
699
- if (part.includes('*') || part.includes('?') || part.includes('{') || part.includes('[')) {
700
- return '* matches one whole path segment. ** is a whole segment. A partial segment is not a wildcard.';
701
308
  }
702
- }
703
- return null;
704
- }
705
- function universeShapes(folders, identity) {
706
- const shapes = [];
707
- if (!Array.isArray(folders))
708
- return shapes;
709
- const stars = identity === 'stars';
710
- for (const raw of folders) {
711
- if (typeof raw !== 'string' || raw.length === 0)
712
- continue;
713
- const segments = raw.split(/[/\\]/).filter(Boolean).map((part) => part.toLowerCase());
714
- if (segments.length === 1 && segments[0] && !segments[0].includes('*')) {
715
- shapes.push({ literals: [segments[0], null] });
716
- continue;
717
- }
718
- if (segments.some((part) => part === '**' || (part.includes('*') && part !== '*')))
719
- continue;
720
- if (!segments.includes('*'))
721
- continue;
722
- if (stars) {
723
- let lastLiteral = -1;
724
- for (let index = 0; index < segments.length; index += 1) {
725
- if (segments[index] !== '*')
726
- lastLiteral = index;
727
- }
728
- if (lastLiteral < 0)
729
- continue;
730
- shapes.push({
731
- literals: segments.slice(lastLiteral).map((part) => (part === '*' ? null : part)),
732
- });
733
- continue;
734
- }
735
- shapes.push({ literals: segments.map((part) => (part === '*' ? null : part)) });
736
- }
737
- return shapes;
738
- }
739
- function shapeMatches(shape, segments) {
740
- if (shape.literals.length !== segments.length)
741
- return false;
742
- return shape.literals.every((literal, index) => literal === null || literal === segments[index]);
743
- }
744
- function aliasTargetIssue(raw, shapes) {
745
- const trimmed = trimTrailingSlashes(raw.trim().replace(/\\/g, '/'));
746
- if (trimmed.length === 0 || trimmed.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) {
747
- return ALIAS_TARGET_MESSAGE;
748
- }
749
- if (trimmed.includes('*') || !trimmed.includes('/'))
750
- return ALIAS_TARGET_MESSAGE;
751
- const parts = trimmed.split('/').map((part) => part.toLowerCase());
752
- if (shapes.length === 0)
753
- return ALIAS_TARGET_MESSAGE;
754
- if (shapes.some((shape) => shapeMatches(shape, parts)))
755
- return ALIAS_TARGET_MESSAGE;
756
- if (shapes.some((shape) => shapeMatches(shape, parts.slice(0, -1))))
757
- return null;
758
- return ALIAS_TARGET_MESSAGE;
759
- }
760
- /** A leading src/ or app/ is optional at match time, so both spellings are one glob. */
761
- function aliasGlobVariants(segments) {
762
- const head = segments[0];
763
- if ((head === 'src' || head === 'app') && segments.length > 1)
764
- return [segments, segments.slice(1)];
765
- return [segments];
766
- }
767
- function aliasesCanMatchSameFile(left, right) {
768
- const leftVariants = aliasGlobVariants(left);
769
- const rightVariants = aliasGlobVariants(right);
770
- for (const a of leftVariants) {
771
- if (globsCanMatchSame(a, right, 0, 0, new Map()))
772
- return true;
773
- }
774
- for (const b of rightVariants) {
775
- if (globsCanMatchSame(left, b, 0, 0, new Map()))
776
- return true;
777
- }
778
- return false;
779
- }
780
- function globsCanMatchSame(left, right, i, j, memo) {
781
- const key = `${i}:${j}`;
782
- const cached = memo.get(key);
783
- if (cached !== undefined)
784
- return cached;
785
- let matched = false;
786
- if (i === left.length && j === right.length)
787
- matched = true;
788
- else if (i < left.length && left[i] === '**') {
789
- matched =
790
- globsCanMatchSame(left, right, i + 1, j, memo) ||
791
- (j < right.length && globsCanMatchSame(left, right, i, j + 1, memo));
792
- }
793
- else if (j < right.length && right[j] === '**') {
794
- matched =
795
- globsCanMatchSame(left, right, i, j + 1, memo) ||
796
- (i < left.length && globsCanMatchSame(left, right, i + 1, j, memo));
797
- }
798
- else if (i < left.length && j < right.length) {
799
- const a = left[i];
800
- const b = right[j];
801
- if (a === b || a === '*' || b === '*')
802
- matched = globsCanMatchSame(left, right, i + 1, j + 1, memo);
803
- }
804
- memo.set(key, matched);
805
- return matched;
806
- }
807
- function validateChildSliceAliases(candidate, issues) {
808
- const rules = candidate.rules;
809
- if (!Array.isArray(rules))
810
- return;
811
- rules.forEach((rule, index) => {
812
- if (!isObject(rule))
813
- return;
814
- const child = rule.childSlices;
815
- if (!isObject(child) || child.sliceAliases === undefined)
816
- return;
817
- const aliases = child.sliceAliases;
818
- const path = `$.rules[${index}].childSlices.sliceAliases`;
819
- if (!Array.isArray(aliases)) {
820
- issues.push({ path, message: 'must be an array of { from, to } slice aliases' });
821
- return;
822
- }
823
- const shapes = universeShapes(rule.sliceFolders, rule.sliceIdentity);
824
- const sliceEntries = [
825
- ...(Array.isArray(rule.sliceFolders) ? rule.sliceFolders : []),
826
- ...(Array.isArray(child.sliceFolders) ? child.sliceFolders : []),
827
- ].filter((entry) => typeof entry === 'string' && entry.length > 0);
828
- const globs = [];
829
- aliases.forEach((alias, aliasIndex) => {
830
- const aliasPath = `${path}[${aliasIndex}]`;
831
- if (!isObject(alias)) {
832
- issues.push({ path: aliasPath, message: 'must be an object with from and to' });
309
+ const seen = new Set();
310
+ value.forEach((entry, index) => {
311
+ const itemPath = `${path}[${index}]`;
312
+ if (typeof entry !== 'string' || entry.length === 0) {
313
+ issues.push({ path: itemPath, message: 'must be a non-empty relative path string' });
833
314
  return;
834
315
  }
835
- for (const key of Object.keys(alias)) {
836
- if (key !== 'from' && key !== 'to') {
837
- issues.push({ path: propertyPath(aliasPath, key), message: 'unknown field' });
838
- }
839
- }
840
- const from = alias.from;
841
- const to = alias.to;
842
- if (typeof from !== 'string' || from.trim().length === 0) {
843
- issues.push({ path: `${aliasPath}.from`, message: 'must be a non-empty path glob' });
844
- }
845
- else {
846
- const issue = aliasGlobIssue(from);
847
- if (issue)
848
- issues.push({ path: `${aliasPath}.from`, message: issue });
849
- else {
850
- const segments = aliasPathSegments(from);
851
- if (sliceEntries.some((entry) => globOverlapsSliceEntry(segments, entry))) {
852
- issues.push({ path: `${aliasPath}.from`, message: ALIAS_OVERLAP_MESSAGE });
853
- }
854
- for (const previous of globs) {
855
- if (aliasesCanMatchSameFile(previous, segments)) {
856
- issues.push({ path: aliasPath, message: ALIAS_SAME_FILE_MESSAGE });
857
- break;
858
- }
859
- }
860
- globs.push(segments);
861
- }
862
- }
863
- if (typeof to !== 'string' || to.trim().length === 0) {
864
- issues.push({ path: `${aliasPath}.to`, message: ALIAS_TARGET_MESSAGE });
865
- }
866
- else {
867
- const issue = aliasTargetIssue(to, shapes);
868
- if (issue)
869
- issues.push({ path: `${aliasPath}.to`, message: issue });
870
- }
316
+ if (seen.has(entry))
317
+ issues.push({ path: itemPath, message: 'duplicate path' });
318
+ seen.add(entry);
871
319
  });
872
- });
320
+ }
873
321
  }
874
322
  function validateLayerOwners(candidate, issues) {
875
323
  const layers = candidate.layers;
@@ -997,13 +445,12 @@ export function migrateArkConfig(input, source = 'ark.config.json') {
997
445
  export function loadArkConfigContract(input, source = 'ark.config.json') {
998
446
  const { candidate, migratedFrom } = migrateArkConfig(input, source);
999
447
  const issues = [];
1000
- validateNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
448
+ validateSchemaNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
1001
449
  validateArkRunExtra(candidate, issues);
1002
450
  validateArkOrderExtra(candidate, issues);
1003
451
  validateLayerOwners(candidate, issues);
1004
- validateChildSliceSiblings(candidate, issues);
1005
- validateChildSliceAllowedCrossSlice(candidate, issues);
1006
- validateChildSliceAliases(candidate, issues);
452
+ validateSliceContract(candidate, issues);
453
+ validateArkRulesRefs(candidate, issues);
1007
454
  if (issues.length > 0)
1008
455
  throw new ArkConfigValidationError(source, issues);
1009
456
  return { config: candidate, migratedFrom };
@@ -1036,15 +483,3 @@ export function withArkConfigMetadata(config) {
1036
483
  }
1037
484
  return result;
1038
485
  }
1039
- /**
1040
- * Trim trailing slashes without a regex.
1041
- *
1042
- * `/\/+$/` is a polynomial ReDoS on a value that comes from the repo's own
1043
- * contract but is still library input. A scan is linear and says the same thing.
1044
- */
1045
- function trimTrailingSlashes(value) {
1046
- let end = value.length;
1047
- while (end > 0 && value[end - 1] === '/')
1048
- end -= 1;
1049
- return value.slice(0, end);
1050
- }