archctx-contracts 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/fixtures/invalid/recommendation-v3-structural-before-evidence.json +59 -0
  2. package/fixtures/valid/archctx-capabilities.json +3 -3
  3. package/fixtures/valid/product-version-manifest.json +6 -6
  4. package/fixtures/valid/projection-manifest.json +175 -0
  5. package/fixtures/valid/projection-result.json +5 -5
  6. package/fixtures/valid/recommendation-v3.json +69 -0
  7. package/fixtures/valid/runtime-recommendation-show.json +312 -0
  8. package/fixtures/valid/runtime-refactor-scan.json +542 -0
  9. package/package.json +1 -1
  10. package/schemas/runtime/archctx-capabilities.schema.json +7 -2
  11. package/schemas/runtime/architecture-refresh-signal.schema.json +27 -0
  12. package/schemas/runtime/changeset.schema.json +42 -16
  13. package/schemas/runtime/projection-apply-readback.schema.json +351 -10
  14. package/schemas/runtime/projection-apply-recovery.schema.json +24 -3
  15. package/schemas/runtime/projection-manifest.schema.json +491 -0
  16. package/schemas/runtime/projection-request.schema.json +11 -2
  17. package/schemas/runtime/projection-result.schema.json +108 -8
  18. package/schemas/runtime/recommendation-v3.schema.json +1166 -0
  19. package/schemas/runtime/runtime-recommendation-show.schema.json +2449 -0
  20. package/schemas/runtime/runtime-refactor-scan.schema.json +2489 -0
  21. package/schemas/runtime/structural-observation-payload.schema.json +578 -0
  22. package/src/index.ts +1 -0
  23. package/src/ledger.ts +26 -10
  24. package/src/product-version.ts +2 -2
  25. package/src/projection-manifest.ts +346 -0
  26. package/src/projection.ts +338 -6
  27. package/src/refactor.ts +268 -1
  28. package/src/schema.ts +20 -4
  29. package/src/validator.ts +87 -15
package/src/refactor.ts CHANGED
@@ -303,11 +303,100 @@ export interface ModuleStatisticsSnapshotV1 {
303
303
  extensions?: Record<string, Json>;
304
304
  }
305
305
 
306
+ /**
307
+ * The fixed per-observation bound on sampled evidence entries (edges, violations, paths,
308
+ * unresolved imports). It bounds entry *count* only: a path or a specifier has no fixed byte
309
+ * size, so this bound alone does not keep a recording event under the ledger's persisted-JSON
310
+ * ceiling. The recording planner enforces that separately, by measuring the event it would
311
+ * persist and shrinking every sample to one shared smaller limit until the event fits.
312
+ */
313
+ export const REFACTOR_OBSERVATION_EVIDENCE_LIMIT = 20;
314
+ /**
315
+ * The fixed bound on every id list inside evidence (`memberNodeIds`, `constraintIds`,
316
+ * `candidateOwnerNodeIds`). Each list is the sorted prefix of its population and carries the true
317
+ * size beside it (`metrics.memberCount`, `constraintCount`, `candidateOwnerCount`). It is no
318
+ * smaller than `REFACTOR_OBSERVATION_EVIDENCE_LIMIT`, so the constraints named by a violation
319
+ * sample — the smallest ids, since violations sort by constraint first — are always listed.
320
+ */
321
+ export const REFACTOR_EVIDENCE_ID_LIST_LIMIT = 32;
322
+ /** An import specifier is a module reference, not code: one line, bounded like a locator. */
323
+ export const REFACTOR_EVIDENCE_SPECIFIER_MAX_LENGTH = 512;
324
+
325
+ /**
326
+ * One observed file-level import edge, as bounded metadata only: two repo-relative paths, the
327
+ * line the import sits on, the specifier verbatim and the owning nodes. Never a source body.
328
+ * `toPath` carries no line: an import names a module, not a position inside it.
329
+ */
330
+ export interface RefactorEvidenceImportEdgeV1 {
331
+ fromPath: string;
332
+ /** 1-based line of the import statement in `fromPath`; `null` when the code index reported none. */
333
+ fromLine: number | null;
334
+ toPath: string;
335
+ specifier: string;
336
+ fromNodeId: string;
337
+ toNodeId: string;
338
+ }
339
+
340
+ export interface RefactorEvidenceDirectionViolationV1 extends RefactorEvidenceImportEdgeV1 {
341
+ constraintId: string;
342
+ }
343
+
344
+ export interface RefactorEvidencePathV1 {
345
+ path: string;
346
+ /**
347
+ * Declared nodes that bear on this path, per kind: for `ownership-ambiguous` every node that
348
+ * claims it; for `unowned-paths` the nodes that own another tracked file in the same directory;
349
+ * for `undeclared-footprint` the nodes that currently own the entrypoint path the node declares.
350
+ */
351
+ candidateOwnerNodeIds: string[];
352
+ /** How many candidate owners exist; `candidateOwnerNodeIds` is its sorted, bounded prefix. */
353
+ candidateOwnerCount: number;
354
+ }
355
+
356
+ export interface RefactorEvidenceUnresolvedImportV1 {
357
+ fromPath: string;
358
+ fromLine: number | null;
359
+ specifier: string;
360
+ }
361
+
362
+ /** A bounded sample: `totalCount` is the population, `truncated` says the list is a prefix of it. */
363
+ interface RefactorEvidenceSampleV1 {
364
+ totalCount: number;
365
+ truncated: boolean;
366
+ }
367
+
368
+ /**
369
+ * Typed evidence per observation kind: the concrete items behind the observation's metrics, so a
370
+ * reader can see *why* it exists without re-running the measurement. Samples are sorted, bounded
371
+ * by `REFACTOR_OBSERVATION_EVIDENCE_LIMIT`, and derived from the same measurement as the snapshot;
372
+ * id lists are sorted prefixes bounded by `REFACTOR_EVIDENCE_ID_LIST_LIMIT`.
373
+ *
374
+ * `memberNodeIds` is the prefix of the component's members; its population is the observation's
375
+ * `metrics.memberCount`.
376
+ */
377
+ export type RefactorObservationEvidenceV1 =
378
+ | ({ kind: "cycle"; memberNodeIds: string[]; edges: RefactorEvidenceImportEdgeV1[] } & RefactorEvidenceSampleV1)
379
+ | ({
380
+ kind: "direction-violation";
381
+ constraintIds: string[];
382
+ /** How many distinct constraints the module violates; `constraintIds` is its bounded prefix. */
383
+ constraintCount: number;
384
+ violations: RefactorEvidenceDirectionViolationV1[];
385
+ } & RefactorEvidenceSampleV1)
386
+ | ({ kind: "ownership-ambiguous" | "undeclared-footprint" | "unowned-paths"; paths: RefactorEvidencePathV1[] } & RefactorEvidenceSampleV1)
387
+ | ({
388
+ kind: "evidence-gap";
389
+ coverage: EvidenceCoverageLevelV2;
390
+ reasonCodes: RefactorScaleReasonCode[];
391
+ unresolvedImports: RefactorEvidenceUnresolvedImportV1[];
392
+ } & RefactorEvidenceSampleV1);
393
+
306
394
  export interface RefactorObservationV1 {
307
395
  kind: RefactorObservationKind;
308
396
  subjectSelectorId: string;
309
397
  signalIds: string[];
310
398
  metrics: Record<string, number | null>;
399
+ evidence: RefactorObservationEvidenceV1;
311
400
  }
312
401
 
313
402
  export interface RefactorAssessmentV1 {
@@ -674,6 +763,7 @@ export function refactorAssessmentInvariantIssues(assessment: RefactorAssessment
674
763
  for (const [index, observation] of assessment.observations.entries()) {
675
764
  if (observation.subjectSelectorId.trim() === "") issues.push(`${prefix}.observations[${index}].subjectSelectorId must not be empty`);
676
765
  issues.push(...sortedUniqueIssues(`${prefix}.observations[${index}].signalIds`, observation.signalIds));
766
+ issues.push(...refactorObservationEvidenceIssues(observation, `${prefix}.observations[${index}]`));
677
767
  }
678
768
  issues.push(...ratioIssues(`${prefix}.confidence.callerCoverage`, assessment.confidence.callerCoverage));
679
769
  if (!Number.isInteger(assessment.pressure.score) || assessment.pressure.score < 0 || assessment.pressure.score > 100) {
@@ -686,6 +776,136 @@ export function refactorAssessmentInvariantIssues(assessment: RefactorAssessment
686
776
  return issues;
687
777
  }
688
778
 
779
+ /** The one ordering for evidence edges: path, line, target, specifier, then owners. */
780
+ export function compareRefactorEvidenceImportEdges(left: RefactorEvidenceImportEdgeV1, right: RefactorEvidenceImportEdgeV1): number {
781
+ return compareText(left.fromPath, right.fromPath)
782
+ || compareLine(left.fromLine, right.fromLine)
783
+ || compareText(left.toPath, right.toPath)
784
+ || compareText(left.specifier, right.specifier)
785
+ || compareText(left.fromNodeId, right.fromNodeId)
786
+ || compareText(left.toNodeId, right.toNodeId);
787
+ }
788
+
789
+ export function compareRefactorEvidenceDirectionViolations(
790
+ left: RefactorEvidenceDirectionViolationV1,
791
+ right: RefactorEvidenceDirectionViolationV1
792
+ ): number {
793
+ return compareText(left.constraintId, right.constraintId) || compareRefactorEvidenceImportEdges(left, right);
794
+ }
795
+
796
+ export function compareRefactorEvidenceUnresolvedImports(
797
+ left: RefactorEvidenceUnresolvedImportV1,
798
+ right: RefactorEvidenceUnresolvedImportV1
799
+ ): number {
800
+ return compareText(left.fromPath, right.fromPath)
801
+ || compareLine(left.fromLine, right.fromLine)
802
+ || compareText(left.specifier, right.specifier);
803
+ }
804
+
805
+ /**
806
+ * Validates one observation's evidence against the observation it explains: matching kind, the
807
+ * fixed sample and id-list bounds, an honest `truncated` flag and id-list counts, the canonical
808
+ * order, repo-relative paths, positive lines and one-line bounded specifiers. Where a metric counts exactly the evidence population,
809
+ * `totalCount` must equal it, so the sample can never describe a different measurement.
810
+ */
811
+ export function refactorObservationEvidenceIssues(observation: RefactorObservationV1, prefix = "observation"): string[] {
812
+ const evidence = observation.evidence as RefactorObservationEvidenceV1 | undefined;
813
+ const label = `${prefix}.evidence`;
814
+ if (!evidence || typeof evidence !== "object") return [`${label} is required`];
815
+ if (evidence.kind !== observation.kind) return [`${label}.kind must equal the observation kind ${observation.kind}`];
816
+ const issues: string[] = [];
817
+ const sample = (entries: readonly unknown[], field: string): void => {
818
+ if (entries.length > REFACTOR_OBSERVATION_EVIDENCE_LIMIT) {
819
+ issues.push(`${label}.${field} must hold at most ${REFACTOR_OBSERVATION_EVIDENCE_LIMIT} entries`);
820
+ }
821
+ if (!Number.isInteger(evidence.totalCount) || evidence.totalCount < entries.length) {
822
+ issues.push(`${label}.totalCount must be an integer no smaller than the ${field} length`);
823
+ }
824
+ if (evidence.truncated !== evidence.totalCount > entries.length) {
825
+ issues.push(`${label}.truncated must be true exactly when totalCount exceeds the ${field} length`);
826
+ }
827
+ };
828
+ const ordered = <T>(entries: readonly T[], field: string, compare: (left: T, right: T) => number): void => {
829
+ for (let index = 1; index < entries.length; index += 1) {
830
+ if (compare(entries[index - 1]!, entries[index]!) >= 0) {
831
+ issues.push(`${label}.${field} must be sorted and unique`);
832
+ return;
833
+ }
834
+ }
835
+ };
836
+ const edgeIssues = (edge: RefactorEvidenceImportEdgeV1, field: string): void => {
837
+ issues.push(...repoPathIssues(`${label}.${field}.fromPath`, edge.fromPath), ...repoPathIssues(`${label}.${field}.toPath`, edge.toPath));
838
+ issues.push(...lineIssues(`${label}.${field}.fromLine`, edge.fromLine), ...specifierIssues(`${label}.${field}.specifier`, edge.specifier));
839
+ if (edge.fromNodeId.trim() === "" || edge.toNodeId.trim() === "") issues.push(`${label}.${field} node ids must not be empty`);
840
+ };
841
+ // An id list is the sorted prefix of a population of `count`: exactly min(count, limit) long.
842
+ const idList = (ids: readonly string[], count: unknown, field: string, countField: string): void => {
843
+ issues.push(...sortedUniqueIssues(`${label}.${field}`, ids));
844
+ if (!Number.isInteger(count) || (count as number) < 0) {
845
+ issues.push(`${label}.${countField} must be a non-negative integer`);
846
+ } else if (ids.length !== Math.min(count as number, REFACTOR_EVIDENCE_ID_LIST_LIMIT)) {
847
+ issues.push(`${label}.${field} must hold min(${countField}, ${REFACTOR_EVIDENCE_ID_LIST_LIMIT}) entries`);
848
+ }
849
+ };
850
+ switch (evidence.kind) {
851
+ case "cycle": {
852
+ sample(evidence.edges, "edges");
853
+ ordered(evidence.edges, "edges", compareRefactorEvidenceImportEdges);
854
+ idList(evidence.memberNodeIds, observation.metrics.memberCount, "memberNodeIds", "metrics.memberCount");
855
+ // Membership is checkable only against the complete member list.
856
+ const complete = evidence.memberNodeIds.length === observation.metrics.memberCount;
857
+ for (const edge of evidence.edges) {
858
+ edgeIssues(edge, "edges");
859
+ if (complete && (!evidence.memberNodeIds.includes(edge.fromNodeId) || !evidence.memberNodeIds.includes(edge.toNodeId))) {
860
+ issues.push(`${label}.edges must stay inside the component's member nodes`);
861
+ }
862
+ }
863
+ break;
864
+ }
865
+ case "direction-violation":
866
+ sample(evidence.violations, "violations");
867
+ ordered(evidence.violations, "violations", compareRefactorEvidenceDirectionViolations);
868
+ idList(evidence.constraintIds, evidence.constraintCount, "constraintIds", "constraintCount");
869
+ for (const violation of evidence.violations) {
870
+ edgeIssues(violation, "violations");
871
+ if (!evidence.constraintIds.includes(violation.constraintId)) issues.push(`${label}.violations must name a listed constraintId`);
872
+ }
873
+ if (evidence.totalCount !== observation.metrics.directionViolationCount) {
874
+ issues.push(`${label}.totalCount must equal metrics.directionViolationCount`);
875
+ }
876
+ break;
877
+ case "ownership-ambiguous":
878
+ case "undeclared-footprint":
879
+ case "unowned-paths":
880
+ sample(evidence.paths, "paths");
881
+ ordered(evidence.paths, "paths", (left, right) => compareText(left.path, right.path));
882
+ for (const entry of evidence.paths) {
883
+ issues.push(...repoPathIssues(`${label}.paths.path`, entry.path));
884
+ idList(entry.candidateOwnerNodeIds, entry.candidateOwnerCount, "paths.candidateOwnerNodeIds", "paths.candidateOwnerCount");
885
+ }
886
+ if (evidence.kind === "unowned-paths" && evidence.totalCount !== observation.metrics.unownedFileCount) {
887
+ issues.push(`${label}.totalCount must equal metrics.unownedFileCount`);
888
+ }
889
+ break;
890
+ case "evidence-gap":
891
+ sample(evidence.unresolvedImports, "unresolvedImports");
892
+ ordered(evidence.unresolvedImports, "unresolvedImports", compareRefactorEvidenceUnresolvedImports);
893
+ issues.push(...sortedUniqueIssues(`${label}.reasonCodes`, evidence.reasonCodes));
894
+ for (const entry of evidence.unresolvedImports) {
895
+ issues.push(...repoPathIssues(`${label}.unresolvedImports.fromPath`, entry.fromPath));
896
+ issues.push(...lineIssues(`${label}.unresolvedImports.fromLine`, entry.fromLine));
897
+ issues.push(...specifierIssues(`${label}.unresolvedImports.specifier`, entry.specifier));
898
+ }
899
+ if (evidence.totalCount !== observation.metrics.unresolvedImportCount) {
900
+ issues.push(`${label}.totalCount must equal metrics.unresolvedImportCount`);
901
+ }
902
+ break;
903
+ default:
904
+ issues.push(`${label}.kind is unsupported`);
905
+ }
906
+ return [...new Set(issues)];
907
+ }
908
+
689
909
  export function refactorResolutionEvidenceInvariantIssues(
690
910
  evidence: RefactorResolutionEvidenceV1,
691
911
  prefix = "resolutionEvidence"
@@ -787,6 +1007,18 @@ export function recommendationV3InvariantIssues(recommendation: RecommendationV3
787
1007
  if (recommendation.enforcement !== "advisory") {
788
1008
  issues.push(`${prefix}.structural_observation enforcement must be advisory`);
789
1009
  }
1010
+ if (shapeIssues.length === 0) {
1011
+ // The payload is self-contained: the observation's metrics, signals and evidence travel with
1012
+ // it, so the same evidence rules bind a recorded record and a fresh assessment.
1013
+ const payload = recommendation.payload as StructuralObservationPayloadV1;
1014
+ issues.push(
1015
+ ...sortedUniqueIssues(`${prefix}.payload.signalIds`, payload.signalIds),
1016
+ ...refactorObservationEvidenceIssues(
1017
+ { kind: payload.kind, subjectSelectorId: recommendation.subject, signalIds: payload.signalIds, metrics: payload.metrics, evidence: payload.evidence },
1018
+ `${prefix}.payload`
1019
+ )
1020
+ );
1021
+ }
790
1022
  }
791
1023
  if (recommendation.category === "refactor_proposal") {
792
1024
  if (!isRefactorProposalAuthorSource(recommendation.authoredBy.source)) {
@@ -953,7 +1185,16 @@ const REQUIRED_PAYLOAD_FIELDS: Readonly<Record<RecommendationCategory, readonly
953
1185
  "scale",
954
1186
  "targetOutcomes"
955
1187
  ],
956
- structural_observation: ["affectedNodeIds", "assessmentDigest", "baselineSnapshotDigest", "derivedOutcomes", "kind"]
1188
+ structural_observation: [
1189
+ "affectedNodeIds",
1190
+ "assessmentDigest",
1191
+ "baselineSnapshotDigest",
1192
+ "derivedOutcomes",
1193
+ "evidence",
1194
+ "kind",
1195
+ "metrics",
1196
+ "signalIds"
1197
+ ]
957
1198
  };
958
1199
 
959
1200
  function fingerprintPayloadSubset(category: RecommendationCategory, payload: RecommendationPayloadV1): Record<string, Json> {
@@ -984,6 +1225,32 @@ function outcomeIssues(label: string, outcomes: readonly RefactorTargetOutcomeV1
984
1225
  return issues;
985
1226
  }
986
1227
 
1228
+ function compareText(left: string, right: string): number {
1229
+ return left < right ? -1 : left > right ? 1 : 0;
1230
+ }
1231
+
1232
+ /** An unknown line sorts before every known one, so the order is total. */
1233
+ function compareLine(left: number | null, right: number | null): number {
1234
+ return (left ?? 0) - (right ?? 0);
1235
+ }
1236
+
1237
+ function repoPathIssues(label: string, path: string): string[] {
1238
+ return typeof path === "string" && isRepoRelativePosixPath(path) ? [] : [`${label} must be a repo-relative POSIX path`];
1239
+ }
1240
+
1241
+ function lineIssues(label: string, line: number | null): string[] {
1242
+ return line === null || (Number.isInteger(line) && line >= 1) ? [] : [`${label} must be null or a positive integer`];
1243
+ }
1244
+
1245
+ function specifierIssues(label: string, specifier: string): string[] {
1246
+ return typeof specifier === "string"
1247
+ && specifier.length > 0
1248
+ && specifier.length <= REFACTOR_EVIDENCE_SPECIFIER_MAX_LENGTH
1249
+ && !/[\u0000-\u001f\u007f]/.test(specifier)
1250
+ ? []
1251
+ : [`${label} must be a single-line specifier of 1-${REFACTOR_EVIDENCE_SPECIFIER_MAX_LENGTH} characters`];
1252
+ }
1253
+
987
1254
  function digestIssues(label: string, value: string): string[] {
988
1255
  return SHA256_DIGEST_PATTERN.test(value) ? [] : [`${label} must be a sha256:<64-hex> digest`];
989
1256
  }
package/src/schema.ts CHANGED
@@ -15,12 +15,14 @@ export type ArchContextErrorCode =
15
15
  | "AC_RUNTIME_UNAVAILABLE"
16
16
  | "AC_RUNTIME_VERSION_UNSUPPORTED"
17
17
  | "AC_CODEGRAPH_UNAVAILABLE"
18
+ | "AC_CODE_FACTS_UNAVAILABLE"
18
19
  | "AC_INDEX_STALE"
19
20
  | "AC_CONTEXT_STALE"
20
21
  | "AC_SCHEMA_INVALID"
21
22
  | "AC_POLICY_VIOLATION"
22
23
  | "AC_PATH_DENIED"
23
24
  | "AC_PRECONDITION_FAILED"
25
+ | "AC_PROJECTION_APPLY_COMMITTED"
24
26
  | "AC_CAPABILITY_UNSUPPORTED"
25
27
  | "AC_INTERVENTION_REQUIRED"
26
28
  | "AC_PROOF_REQUIRED"
@@ -32,7 +34,8 @@ export type ArchContextErrorCode =
32
34
  | "AC_MODEL_ADOPTION_REQUIRED"
33
35
  | "AC_REFACTOR_STALE"
34
36
  | "AC_REFACTOR_EVIDENCE_REQUIRED"
35
- | "AC_REFACTOR_PROPOSAL_UNAUTHORED";
37
+ | "AC_REFACTOR_PROPOSAL_UNAUTHORED"
38
+ | "AC_REFACTOR_RUN_TOO_LARGE";
36
39
 
37
40
  export interface ArchContextError {
38
41
  code: ArchContextErrorCode;
@@ -41,6 +44,8 @@ export interface ArchContextError {
41
44
  retryable: boolean;
42
45
  action: string;
43
46
  reasonCode?: string;
47
+ /** Machine-readable facts a caller needs to act on this error, never parsed from `message`. */
48
+ details?: { [key: string]: Json };
44
49
  }
45
50
 
46
51
  export interface JsonEnvelope<T extends Json = Json> {
@@ -56,12 +61,14 @@ export const ERROR_CATALOG: Record<ArchContextErrorCode, Omit<ArchContextError,
56
61
  AC_RUNTIME_UNAVAILABLE: { code: "AC_RUNTIME_UNAVAILABLE", severity: "error", retryable: true, action: "start-or-reconnect-runtime" },
57
62
  AC_RUNTIME_VERSION_UNSUPPORTED: { code: "AC_RUNTIME_VERSION_UNSUPPORTED", severity: "error", retryable: true, action: "upgrade-archctx-runtime" },
58
63
  AC_CODEGRAPH_UNAVAILABLE: { code: "AC_CODEGRAPH_UNAVAILABLE", severity: "error", retryable: true, action: "run-diagnostics" },
64
+ AC_CODE_FACTS_UNAVAILABLE: { code: "AC_CODE_FACTS_UNAVAILABLE", severity: "error", retryable: true, action: "codegraph-init" },
59
65
  AC_INDEX_STALE: { code: "AC_INDEX_STALE", severity: "warning", retryable: true, action: "sync-codefacts" },
60
66
  AC_CONTEXT_STALE: { code: "AC_CONTEXT_STALE", severity: "warning", retryable: true, action: "prepare-task-again" },
61
67
  AC_SCHEMA_INVALID: { code: "AC_SCHEMA_INVALID", severity: "error", retryable: false, action: "repair-model" },
62
68
  AC_POLICY_VIOLATION: { code: "AC_POLICY_VIOLATION", severity: "error", retryable: false, action: "revise-plan" },
63
69
  AC_PATH_DENIED: { code: "AC_PATH_DENIED", severity: "critical", retryable: false, action: "do-not-bypass" },
64
70
  AC_PRECONDITION_FAILED: { code: "AC_PRECONDITION_FAILED", severity: "warning", retryable: true, action: "rebuild-plan" },
71
+ AC_PROJECTION_APPLY_COMMITTED: { code: "AC_PROJECTION_APPLY_COMMITTED", severity: "error", retryable: false, action: "readback-committed-projection-apply" },
65
72
  AC_CAPABILITY_UNSUPPORTED: { code: "AC_CAPABILITY_UNSUPPORTED", severity: "error", retryable: false, action: "stay-within-single-repository" },
66
73
  AC_INTERVENTION_REQUIRED: { code: "AC_INTERVENTION_REQUIRED", severity: "error", retryable: false, action: "enter-intervention-sop" },
67
74
  AC_PROOF_REQUIRED: { code: "AC_PROOF_REQUIRED", severity: "warning", retryable: false, action: "execute-proof-point" },
@@ -73,19 +80,28 @@ export const ERROR_CATALOG: Record<ArchContextErrorCode, Omit<ArchContextError,
73
80
  AC_MODEL_ADOPTION_REQUIRED: { code: "AC_MODEL_ADOPTION_REQUIRED", severity: "error", retryable: false, action: "adopt-architecture-model" },
74
81
  AC_REFACTOR_STALE: { code: "AC_REFACTOR_STALE", severity: "warning", retryable: true, action: "rerun-refactor-scan" },
75
82
  AC_REFACTOR_EVIDENCE_REQUIRED: { code: "AC_REFACTOR_EVIDENCE_REQUIRED", severity: "error", retryable: false, action: "run-refactor-verify" },
76
- AC_REFACTOR_PROPOSAL_UNAUTHORED: { code: "AC_REFACTOR_PROPOSAL_UNAUTHORED", severity: "error", retryable: false, action: "attach-authoring-actor" }
83
+ AC_REFACTOR_PROPOSAL_UNAUTHORED: { code: "AC_REFACTOR_PROPOSAL_UNAUTHORED", severity: "error", retryable: false, action: "attach-authoring-actor" },
84
+ // Not retryable: the run's size is a pure function of the measured tree, so the same call at the
85
+ // same HEAD fails the same way. The read-only `refactor scan` still answers, with `recordable: false`.
86
+ AC_REFACTOR_RUN_TOO_LARGE: { code: "AC_REFACTOR_RUN_TOO_LARGE", severity: "error", retryable: false, action: "use-read-only-refactor-scan" }
77
87
  };
78
88
 
79
89
  export function okEnvelope<T extends Json>(requestId: string, data: T): JsonEnvelope<T> {
80
90
  return { schemaVersion: "archcontext.envelope/v1", ok: true, requestId, data };
81
91
  }
82
92
 
83
- export function errorEnvelope(requestId: string, code: ArchContextErrorCode, message: string, reasonCode?: string): JsonEnvelope {
93
+ export function errorEnvelope(
94
+ requestId: string,
95
+ code: ArchContextErrorCode,
96
+ message: string,
97
+ reasonCode?: string,
98
+ details?: { [key: string]: Json }
99
+ ): JsonEnvelope {
84
100
  return {
85
101
  schemaVersion: "archcontext.envelope/v1",
86
102
  ok: false,
87
103
  requestId,
88
- error: { ...ERROR_CATALOG[code], message, ...(reasonCode ? { reasonCode } : {}) }
104
+ error: { ...ERROR_CATALOG[code], message, ...(reasonCode ? { reasonCode } : {}), ...(details ? { details } : {}) }
89
105
  };
90
106
  }
91
107
 
package/src/validator.ts CHANGED
@@ -11,6 +11,7 @@ export interface ValidationResult {
11
11
  }
12
12
 
13
13
  type JsonSchema = {
14
+ $id?: string;
14
15
  $ref?: string;
15
16
  $defs?: Record<string, JsonSchema>;
16
17
  type?: string | string[];
@@ -20,6 +21,8 @@ type JsonSchema = {
20
21
  required?: string[];
21
22
  properties?: Record<string, JsonSchema>;
22
23
  items?: JsonSchema;
24
+ contains?: JsonSchema;
25
+ minContains?: number;
23
26
  oneOf?: JsonSchema[];
24
27
  anyOf?: JsonSchema[];
25
28
  allOf?: JsonSchema[];
@@ -37,26 +40,60 @@ type JsonSchema = {
37
40
  maximum?: number;
38
41
  };
39
42
 
40
- export function validateJsonSchema(schema: JsonSchema, value: Json): ValidationResult {
43
+ /**
44
+ * Loads the schema document whose `$id` is the given absolute URI (no fragment). A non-local
45
+ * `$ref` — one that does not start with `#` — resolves against the enclosing document's `$id` and
46
+ * is read through this resolver; without one, every such reference is reported as unresolved
47
+ * instead of being skipped, so a published schema can never pass on a reference it did not check.
48
+ */
49
+ export type JsonSchemaResolver = (uri: string) => JsonSchema | undefined;
50
+
51
+ export interface ValidateJsonSchemaOptions {
52
+ resolveSchema?: JsonSchemaResolver;
53
+ }
54
+
55
+ /**
56
+ * A resolver over a fixed set of schema documents, keyed by their `$id`. Every document must carry
57
+ * a unique absolute `$id`; the set is the whole universe a validation may reference.
58
+ */
59
+ export function jsonSchemaResolver(documents: readonly JsonSchema[]): JsonSchemaResolver {
60
+ const byId = new Map<string, JsonSchema>();
61
+ for (const document of documents) {
62
+ if (typeof document.$id !== "string" || document.$id.length === 0) throw new Error("json-schema-resolver-document-missing-id");
63
+ const id = new URL(document.$id).href;
64
+ if (byId.has(id)) throw new Error(`json-schema-resolver-duplicate-id: ${id}`);
65
+ byId.set(id, document);
66
+ }
67
+ return (uri) => byId.get(uri);
68
+ }
69
+
70
+ export function validateJsonSchema(schema: JsonSchema, value: Json, options: ValidateJsonSchemaOptions = {}): ValidationResult {
41
71
  const issues: ValidationIssue[] = [];
42
- visit(schema, value, "$", issues, schema);
72
+ visit(schema, value, "$", issues, schema, options.resolveSchema);
43
73
  return { valid: issues.length === 0, issues };
44
74
  }
45
75
 
46
- function visit(schema: JsonSchema, value: Json, path: string, issues: ValidationIssue[], root: JsonSchema): void {
76
+ /** `root` is the document the current subschema belongs to; local `#/...` references resolve inside it. */
77
+ function visit(
78
+ schema: JsonSchema,
79
+ value: Json,
80
+ path: string,
81
+ issues: ValidationIssue[],
82
+ root: JsonSchema,
83
+ resolveSchema: JsonSchemaResolver | undefined
84
+ ): void {
47
85
  if (schema.$ref) {
48
- if (!schema.$ref.startsWith("#/")) return;
49
- const resolved = resolveLocalRef(root, schema.$ref);
50
- if (!resolved) {
86
+ const target = resolveRef(root, resolveSchema, schema.$ref);
87
+ if (!target) {
51
88
  issues.push({ path, message: `unresolved schema reference ${schema.$ref}` });
52
89
  return;
53
90
  }
54
- visit(resolved, value, path, issues, root);
91
+ visit(target.schema, value, path, issues, target.root, resolveSchema);
55
92
  }
56
93
  if (schema.oneOf) {
57
94
  const matched = schema.oneOf.filter((candidate) => {
58
95
  const candidateIssues: ValidationIssue[] = [];
59
- visit(candidate, value, path, candidateIssues, root);
96
+ visit(candidate, value, path, candidateIssues, root, resolveSchema);
60
97
  return candidateIssues.length === 0;
61
98
  }).length;
62
99
  if (matched !== 1) {
@@ -67,21 +104,21 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
67
104
  if (schema.anyOf) {
68
105
  const matched = schema.anyOf.some((candidate) => {
69
106
  const candidateIssues: ValidationIssue[] = [];
70
- visit(candidate, value, path, candidateIssues, root);
107
+ visit(candidate, value, path, candidateIssues, root, resolveSchema);
71
108
  return candidateIssues.length === 0;
72
109
  });
73
110
  if (!matched) issues.push({ path, message: "expected at least one matching schema" });
74
111
  }
75
- for (const candidate of schema.allOf ?? []) visit(candidate, value, path, issues, root);
112
+ for (const candidate of schema.allOf ?? []) visit(candidate, value, path, issues, root, resolveSchema);
76
113
  if (schema.if) {
77
114
  const conditionIssues: ValidationIssue[] = [];
78
- visit(schema.if, value, path, conditionIssues, root);
115
+ visit(schema.if, value, path, conditionIssues, root, resolveSchema);
79
116
  const branch = conditionIssues.length === 0 ? schema.then : schema.else;
80
- if (branch) visit(branch, value, path, issues, root);
117
+ if (branch) visit(branch, value, path, issues, root, resolveSchema);
81
118
  }
82
119
  if (schema.not) {
83
120
  const candidateIssues: ValidationIssue[] = [];
84
- visit(schema.not, value, path, candidateIssues, root);
121
+ visit(schema.not, value, path, candidateIssues, root, resolveSchema);
85
122
  if (candidateIssues.length === 0) issues.push({ path, message: "matched forbidden schema" });
86
123
  }
87
124
  if (schema.const !== undefined && JSON.stringify(value) !== JSON.stringify(schema.const)) {
@@ -118,7 +155,16 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
118
155
  if (schema.uniqueItems && new Set(value.map(canonicalize)).size !== value.length) {
119
156
  issues.push({ path, message: "expected unique items" });
120
157
  }
121
- if (schema.items) value.forEach((item, index) => visit(schema.items!, item, `${path}[${index}]`, issues, root));
158
+ if (schema.items) value.forEach((item, index) => visit(schema.items!, item, `${path}[${index}]`, issues, root, resolveSchema));
159
+ if (schema.contains) {
160
+ const minContains = schema.minContains ?? 1;
161
+ const matched = value.filter((item, index) => {
162
+ const candidateIssues: ValidationIssue[] = [];
163
+ visit(schema.contains!, item, `${path}[${index}]`, candidateIssues, root, resolveSchema);
164
+ return candidateIssues.length === 0;
165
+ }).length;
166
+ if (matched < minContains) issues.push({ path, message: `expected at least ${minContains} items matching contains` });
167
+ }
122
168
  }
123
169
  if (value !== null && typeof value === "object" && !Array.isArray(value)) {
124
170
  const objectValue = value as Record<string, Json>;
@@ -126,7 +172,7 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
126
172
  if (!(key in objectValue)) issues.push({ path: `${path}.${key}`, message: "required" });
127
173
  }
128
174
  for (const [key, child] of Object.entries(schema.properties ?? {})) {
129
- if (key in objectValue) visit(child, objectValue[key], `${path}.${key}`, issues, root);
175
+ if (key in objectValue) visit(child, objectValue[key], `${path}.${key}`, issues, root, resolveSchema);
130
176
  }
131
177
  if (schema.additionalProperties === false && schema.properties) {
132
178
  for (const key of Object.keys(objectValue)) {
@@ -136,7 +182,33 @@ function visit(schema: JsonSchema, value: Json, path: string, issues: Validation
136
182
  }
137
183
  }
138
184
 
185
+ function resolveRef(
186
+ root: JsonSchema,
187
+ resolveSchema: JsonSchemaResolver | undefined,
188
+ ref: string
189
+ ): { schema: JsonSchema; root: JsonSchema } | undefined {
190
+ if (ref.startsWith("#")) {
191
+ const schema = resolveLocalRef(root, ref);
192
+ return schema ? { schema, root } : undefined;
193
+ }
194
+ if (!resolveSchema || typeof root.$id !== "string") return undefined;
195
+ let absolute: URL;
196
+ try {
197
+ absolute = new URL(ref, root.$id);
198
+ } catch {
199
+ return undefined;
200
+ }
201
+ const fragment = absolute.hash;
202
+ absolute.hash = "";
203
+ const document = resolveSchema(absolute.href);
204
+ if (!document) return undefined;
205
+ if (fragment === "" || fragment === "#") return { schema: document, root: document };
206
+ const schema = resolveLocalRef(document, fragment);
207
+ return schema ? { schema, root: document } : undefined;
208
+ }
209
+
139
210
  function resolveLocalRef(root: JsonSchema, ref: string): JsonSchema | undefined {
211
+ if (ref === "#") return root;
140
212
  if (!ref.startsWith("#/")) return undefined;
141
213
  let current: unknown = root;
142
214
  for (const rawSegment of ref.slice(2).split("/")) {