archctx-contracts 0.6.3 → 0.7.1

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 (38) 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/capability-dependencies.json +35 -0
  4. package/fixtures/valid/docs-anchors.json +105 -0
  5. package/fixtures/valid/flow-selector-check.json +50 -0
  6. package/fixtures/valid/product-version-manifest.json +6 -6
  7. package/fixtures/valid/projection-manifest.json +175 -0
  8. package/fixtures/valid/projection-result.json +5 -5
  9. package/fixtures/valid/recommendation-v3.json +69 -0
  10. package/fixtures/valid/runtime-recommendation-show.json +312 -0
  11. package/fixtures/valid/runtime-refactor-scan.json +543 -0
  12. package/package.json +1 -1
  13. package/schemas/runtime/archctx-capabilities.schema.json +7 -2
  14. package/schemas/runtime/architecture-refresh-signal.schema.json +27 -0
  15. package/schemas/runtime/capability-dependencies.schema.json +116 -0
  16. package/schemas/runtime/changeset.schema.json +42 -16
  17. package/schemas/runtime/docs-anchors.schema.json +143 -0
  18. package/schemas/runtime/flow-selector-check.schema.json +109 -0
  19. package/schemas/runtime/projection-apply-readback.schema.json +351 -10
  20. package/schemas/runtime/projection-apply-recovery.schema.json +24 -3
  21. package/schemas/runtime/projection-manifest.schema.json +491 -0
  22. package/schemas/runtime/projection-request.schema.json +11 -2
  23. package/schemas/runtime/projection-result.schema.json +108 -8
  24. package/schemas/runtime/recommendation-v3.schema.json +1167 -0
  25. package/schemas/runtime/runtime-recommendation-show.schema.json +2463 -0
  26. package/schemas/runtime/runtime-refactor-scan.schema.json +2508 -0
  27. package/schemas/runtime/structural-observation-payload.schema.json +579 -0
  28. package/src/architecture.ts +6 -3
  29. package/src/docs-anchors.ts +126 -0
  30. package/src/index.ts +3 -0
  31. package/src/ledger.ts +26 -10
  32. package/src/ownership-dependencies.ts +129 -0
  33. package/src/product-version.ts +2 -2
  34. package/src/projection-manifest.ts +346 -0
  35. package/src/projection.ts +406 -6
  36. package/src/refactor.ts +305 -1
  37. package/src/schema.ts +20 -4
  38. package/src/validator.ts +87 -15
package/src/refactor.ts CHANGED
@@ -42,6 +42,7 @@ export const REFACTOR_VERIFICATION_REQUEST_KEYS = [
42
42
  "executionEvidenceRefs",
43
43
  "expectedHeadSha",
44
44
  "expectedWorktreeDigest",
45
+ "importNodeLimit",
45
46
  "recommendationId",
46
47
  "schemaVersion"
47
48
  ] as const;
@@ -62,6 +63,7 @@ export const REFACTOR_SCALES = [
62
63
  ] as const;
63
64
  export const REFACTOR_SCALE_REASON_CODES = [
64
65
  "caller-coverage-unknown",
66
+ "code-facts-import-limit",
65
67
  "code-facts-missing",
66
68
  "code-facts-truncated",
67
69
  "major-change-detected",
@@ -198,6 +200,11 @@ export interface RefactorRequestV1 {
198
200
  expectedHeadSha?: string;
199
201
  expectedWorktreeDigest?: string;
200
202
  task?: string;
203
+ /**
204
+ * The most CodeGraph import nodes the scan reads. Omitted means the runtime default. A larger
205
+ * index gives partial coverage with `code-facts-import-limit`, never `code-facts-truncated`.
206
+ */
207
+ importNodeLimit?: number;
201
208
  }
202
209
 
203
210
  /**
@@ -216,6 +223,11 @@ export interface RefactorVerificationRequestV1 {
216
223
  expectedHeadSha?: string;
217
224
  expectedWorktreeDigest?: string;
218
225
  executionEvidenceRefs?: RefactorExecutionEvidenceRefV1[];
226
+ /**
227
+ * The most CodeGraph import nodes the re-measure reads. Omitted means the limit the recorded
228
+ * baseline snapshot was measured with, so BEFORE and AFTER read the same import population.
229
+ */
230
+ importNodeLimit?: number;
219
231
  }
220
232
 
221
233
  export interface ModuleStatisticsV1 {
@@ -281,8 +293,18 @@ export interface ModuleStatisticsSnapshotV1 {
281
293
  binaryDigest: string;
282
294
  indexedWorktreeDigest: string | null;
283
295
  coverage: EvidenceCoverageLevelV2;
296
+ /**
297
+ * True only when the read is shorter than the population the index reported. A read cut by
298
+ * `edgeLimit` is not truncated: it reports `code-facts-import-limit` instead.
299
+ */
284
300
  truncated: boolean;
301
+ /** The import-node read limit the scan applied. */
285
302
  edgeLimit: number | null;
303
+ /**
304
+ * Import nodes the index reported, or `null` when no index gave a count. Absent on snapshots
305
+ * recorded before the field existed.
306
+ */
307
+ importNodeCount?: number | null;
286
308
  reasonCodes: RefactorScaleReasonCode[];
287
309
  };
288
310
  modules: ModuleStatisticsV1[];
@@ -303,11 +325,100 @@ export interface ModuleStatisticsSnapshotV1 {
303
325
  extensions?: Record<string, Json>;
304
326
  }
305
327
 
328
+ /**
329
+ * The fixed per-observation bound on sampled evidence entries (edges, violations, paths,
330
+ * unresolved imports). It bounds entry *count* only: a path or a specifier has no fixed byte
331
+ * size, so this bound alone does not keep a recording event under the ledger's persisted-JSON
332
+ * ceiling. The recording planner enforces that separately, by measuring the event it would
333
+ * persist and shrinking every sample to one shared smaller limit until the event fits.
334
+ */
335
+ export const REFACTOR_OBSERVATION_EVIDENCE_LIMIT = 20;
336
+ /**
337
+ * The fixed bound on every id list inside evidence (`memberNodeIds`, `constraintIds`,
338
+ * `candidateOwnerNodeIds`). Each list is the sorted prefix of its population and carries the true
339
+ * size beside it (`metrics.memberCount`, `constraintCount`, `candidateOwnerCount`). It is no
340
+ * smaller than `REFACTOR_OBSERVATION_EVIDENCE_LIMIT`, so the constraints named by a violation
341
+ * sample — the smallest ids, since violations sort by constraint first — are always listed.
342
+ */
343
+ export const REFACTOR_EVIDENCE_ID_LIST_LIMIT = 32;
344
+ /** An import specifier is a module reference, not code: one line, bounded like a locator. */
345
+ export const REFACTOR_EVIDENCE_SPECIFIER_MAX_LENGTH = 512;
346
+
347
+ /**
348
+ * One observed file-level import edge, as bounded metadata only: two repo-relative paths, the
349
+ * line the import sits on, the specifier verbatim and the owning nodes. Never a source body.
350
+ * `toPath` carries no line: an import names a module, not a position inside it.
351
+ */
352
+ export interface RefactorEvidenceImportEdgeV1 {
353
+ fromPath: string;
354
+ /** 1-based line of the import statement in `fromPath`; `null` when the code index reported none. */
355
+ fromLine: number | null;
356
+ toPath: string;
357
+ specifier: string;
358
+ fromNodeId: string;
359
+ toNodeId: string;
360
+ }
361
+
362
+ export interface RefactorEvidenceDirectionViolationV1 extends RefactorEvidenceImportEdgeV1 {
363
+ constraintId: string;
364
+ }
365
+
366
+ export interface RefactorEvidencePathV1 {
367
+ path: string;
368
+ /**
369
+ * Declared nodes that bear on this path, per kind: for `ownership-ambiguous` every node that
370
+ * claims it; for `unowned-paths` the nodes that own another tracked file in the same directory;
371
+ * for `undeclared-footprint` the nodes that currently own the entrypoint path the node declares.
372
+ */
373
+ candidateOwnerNodeIds: string[];
374
+ /** How many candidate owners exist; `candidateOwnerNodeIds` is its sorted, bounded prefix. */
375
+ candidateOwnerCount: number;
376
+ }
377
+
378
+ export interface RefactorEvidenceUnresolvedImportV1 {
379
+ fromPath: string;
380
+ fromLine: number | null;
381
+ specifier: string;
382
+ }
383
+
384
+ /** A bounded sample: `totalCount` is the population, `truncated` says the list is a prefix of it. */
385
+ interface RefactorEvidenceSampleV1 {
386
+ totalCount: number;
387
+ truncated: boolean;
388
+ }
389
+
390
+ /**
391
+ * Typed evidence per observation kind: the concrete items behind the observation's metrics, so a
392
+ * reader can see *why* it exists without re-running the measurement. Samples are sorted, bounded
393
+ * by `REFACTOR_OBSERVATION_EVIDENCE_LIMIT`, and derived from the same measurement as the snapshot;
394
+ * id lists are sorted prefixes bounded by `REFACTOR_EVIDENCE_ID_LIST_LIMIT`.
395
+ *
396
+ * `memberNodeIds` is the prefix of the component's members; its population is the observation's
397
+ * `metrics.memberCount`.
398
+ */
399
+ export type RefactorObservationEvidenceV1 =
400
+ | ({ kind: "cycle"; memberNodeIds: string[]; edges: RefactorEvidenceImportEdgeV1[] } & RefactorEvidenceSampleV1)
401
+ | ({
402
+ kind: "direction-violation";
403
+ constraintIds: string[];
404
+ /** How many distinct constraints the module violates; `constraintIds` is its bounded prefix. */
405
+ constraintCount: number;
406
+ violations: RefactorEvidenceDirectionViolationV1[];
407
+ } & RefactorEvidenceSampleV1)
408
+ | ({ kind: "ownership-ambiguous" | "undeclared-footprint" | "unowned-paths"; paths: RefactorEvidencePathV1[] } & RefactorEvidenceSampleV1)
409
+ | ({
410
+ kind: "evidence-gap";
411
+ coverage: EvidenceCoverageLevelV2;
412
+ reasonCodes: RefactorScaleReasonCode[];
413
+ unresolvedImports: RefactorEvidenceUnresolvedImportV1[];
414
+ } & RefactorEvidenceSampleV1);
415
+
306
416
  export interface RefactorObservationV1 {
307
417
  kind: RefactorObservationKind;
308
418
  subjectSelectorId: string;
309
419
  signalIds: string[];
310
420
  metrics: Record<string, number | null>;
421
+ evidence: RefactorObservationEvidenceV1;
311
422
  }
312
423
 
313
424
  export interface RefactorAssessmentV1 {
@@ -505,6 +616,9 @@ export function refactorRequestInvariantIssues(request: RefactorRequestV1, prefi
505
616
  }
506
617
  }
507
618
  if (request.proposal) issues.push(...refactorProposalInvariantIssues(request.proposal, `${prefix}.proposal`));
619
+ if (request.importNodeLimit !== undefined && !(Number.isInteger(request.importNodeLimit) && request.importNodeLimit >= 1)) {
620
+ issues.push(`${prefix}.importNodeLimit must be a positive integer`);
621
+ }
508
622
  return issues;
509
623
  }
510
624
 
@@ -541,6 +655,9 @@ export function refactorVerificationRequestInvariantIssues(
541
655
  if (request.expectedWorktreeDigest !== undefined) {
542
656
  issues.push(...digestIssues(`${prefix}.expectedWorktreeDigest`, request.expectedWorktreeDigest));
543
657
  }
658
+ if (request.importNodeLimit !== undefined && !(Number.isInteger(request.importNodeLimit) && request.importNodeLimit >= 1)) {
659
+ issues.push(`${prefix}.importNodeLimit must be a positive integer`);
660
+ }
544
661
  if (request.executionEvidenceRefs !== undefined) {
545
662
  if (!Array.isArray(request.executionEvidenceRefs)) {
546
663
  issues.push(`${prefix}.executionEvidenceRefs must be an array`);
@@ -633,6 +750,15 @@ export function moduleStatisticsSnapshotInvariantIssues(snapshot: ModuleStatisti
633
750
  }
634
751
  }
635
752
  issues.push(...nonNegativeIntegerIssues(`${prefix}.codeFacts.edgeLimit`, snapshot.codeFacts.edgeLimit));
753
+ const importNodeCount = snapshot.codeFacts.importNodeCount ?? null;
754
+ issues.push(...nonNegativeIntegerIssues(`${prefix}.codeFacts.importNodeCount`, importNodeCount));
755
+ if (snapshot.codeFacts.reasonCodes.includes("code-facts-import-limit")) {
756
+ const { edgeLimit } = snapshot.codeFacts;
757
+ if (snapshot.codeFacts.coverage !== "partial") issues.push(`${prefix}.codeFacts.coverage must be partial when the import limit was reached`);
758
+ if (edgeLimit === null || (importNodeCount !== null && importNodeCount <= edgeLimit)) {
759
+ issues.push(`${prefix}.codeFacts.importNodeCount must exceed edgeLimit when the import limit was reached`);
760
+ }
761
+ }
636
762
  for (const [field, value] of Object.entries(snapshot.repositorySummary)) {
637
763
  issues.push(...nonNegativeIntegerIssues(`${prefix}.repositorySummary.${field}`, value));
638
764
  }
@@ -674,6 +800,7 @@ export function refactorAssessmentInvariantIssues(assessment: RefactorAssessment
674
800
  for (const [index, observation] of assessment.observations.entries()) {
675
801
  if (observation.subjectSelectorId.trim() === "") issues.push(`${prefix}.observations[${index}].subjectSelectorId must not be empty`);
676
802
  issues.push(...sortedUniqueIssues(`${prefix}.observations[${index}].signalIds`, observation.signalIds));
803
+ issues.push(...refactorObservationEvidenceIssues(observation, `${prefix}.observations[${index}]`));
677
804
  }
678
805
  issues.push(...ratioIssues(`${prefix}.confidence.callerCoverage`, assessment.confidence.callerCoverage));
679
806
  if (!Number.isInteger(assessment.pressure.score) || assessment.pressure.score < 0 || assessment.pressure.score > 100) {
@@ -686,6 +813,136 @@ export function refactorAssessmentInvariantIssues(assessment: RefactorAssessment
686
813
  return issues;
687
814
  }
688
815
 
816
+ /** The one ordering for evidence edges: path, line, target, specifier, then owners. */
817
+ export function compareRefactorEvidenceImportEdges(left: RefactorEvidenceImportEdgeV1, right: RefactorEvidenceImportEdgeV1): number {
818
+ return compareText(left.fromPath, right.fromPath)
819
+ || compareLine(left.fromLine, right.fromLine)
820
+ || compareText(left.toPath, right.toPath)
821
+ || compareText(left.specifier, right.specifier)
822
+ || compareText(left.fromNodeId, right.fromNodeId)
823
+ || compareText(left.toNodeId, right.toNodeId);
824
+ }
825
+
826
+ export function compareRefactorEvidenceDirectionViolations(
827
+ left: RefactorEvidenceDirectionViolationV1,
828
+ right: RefactorEvidenceDirectionViolationV1
829
+ ): number {
830
+ return compareText(left.constraintId, right.constraintId) || compareRefactorEvidenceImportEdges(left, right);
831
+ }
832
+
833
+ export function compareRefactorEvidenceUnresolvedImports(
834
+ left: RefactorEvidenceUnresolvedImportV1,
835
+ right: RefactorEvidenceUnresolvedImportV1
836
+ ): number {
837
+ return compareText(left.fromPath, right.fromPath)
838
+ || compareLine(left.fromLine, right.fromLine)
839
+ || compareText(left.specifier, right.specifier);
840
+ }
841
+
842
+ /**
843
+ * Validates one observation's evidence against the observation it explains: matching kind, the
844
+ * fixed sample and id-list bounds, an honest `truncated` flag and id-list counts, the canonical
845
+ * order, repo-relative paths, positive lines and one-line bounded specifiers. Where a metric counts exactly the evidence population,
846
+ * `totalCount` must equal it, so the sample can never describe a different measurement.
847
+ */
848
+ export function refactorObservationEvidenceIssues(observation: RefactorObservationV1, prefix = "observation"): string[] {
849
+ const evidence = observation.evidence as RefactorObservationEvidenceV1 | undefined;
850
+ const label = `${prefix}.evidence`;
851
+ if (!evidence || typeof evidence !== "object") return [`${label} is required`];
852
+ if (evidence.kind !== observation.kind) return [`${label}.kind must equal the observation kind ${observation.kind}`];
853
+ const issues: string[] = [];
854
+ const sample = (entries: readonly unknown[], field: string): void => {
855
+ if (entries.length > REFACTOR_OBSERVATION_EVIDENCE_LIMIT) {
856
+ issues.push(`${label}.${field} must hold at most ${REFACTOR_OBSERVATION_EVIDENCE_LIMIT} entries`);
857
+ }
858
+ if (!Number.isInteger(evidence.totalCount) || evidence.totalCount < entries.length) {
859
+ issues.push(`${label}.totalCount must be an integer no smaller than the ${field} length`);
860
+ }
861
+ if (evidence.truncated !== evidence.totalCount > entries.length) {
862
+ issues.push(`${label}.truncated must be true exactly when totalCount exceeds the ${field} length`);
863
+ }
864
+ };
865
+ const ordered = <T>(entries: readonly T[], field: string, compare: (left: T, right: T) => number): void => {
866
+ for (let index = 1; index < entries.length; index += 1) {
867
+ if (compare(entries[index - 1]!, entries[index]!) >= 0) {
868
+ issues.push(`${label}.${field} must be sorted and unique`);
869
+ return;
870
+ }
871
+ }
872
+ };
873
+ const edgeIssues = (edge: RefactorEvidenceImportEdgeV1, field: string): void => {
874
+ issues.push(...repoPathIssues(`${label}.${field}.fromPath`, edge.fromPath), ...repoPathIssues(`${label}.${field}.toPath`, edge.toPath));
875
+ issues.push(...lineIssues(`${label}.${field}.fromLine`, edge.fromLine), ...specifierIssues(`${label}.${field}.specifier`, edge.specifier));
876
+ if (edge.fromNodeId.trim() === "" || edge.toNodeId.trim() === "") issues.push(`${label}.${field} node ids must not be empty`);
877
+ };
878
+ // An id list is the sorted prefix of a population of `count`: exactly min(count, limit) long.
879
+ const idList = (ids: readonly string[], count: unknown, field: string, countField: string): void => {
880
+ issues.push(...sortedUniqueIssues(`${label}.${field}`, ids));
881
+ if (!Number.isInteger(count) || (count as number) < 0) {
882
+ issues.push(`${label}.${countField} must be a non-negative integer`);
883
+ } else if (ids.length !== Math.min(count as number, REFACTOR_EVIDENCE_ID_LIST_LIMIT)) {
884
+ issues.push(`${label}.${field} must hold min(${countField}, ${REFACTOR_EVIDENCE_ID_LIST_LIMIT}) entries`);
885
+ }
886
+ };
887
+ switch (evidence.kind) {
888
+ case "cycle": {
889
+ sample(evidence.edges, "edges");
890
+ ordered(evidence.edges, "edges", compareRefactorEvidenceImportEdges);
891
+ idList(evidence.memberNodeIds, observation.metrics.memberCount, "memberNodeIds", "metrics.memberCount");
892
+ // Membership is checkable only against the complete member list.
893
+ const complete = evidence.memberNodeIds.length === observation.metrics.memberCount;
894
+ for (const edge of evidence.edges) {
895
+ edgeIssues(edge, "edges");
896
+ if (complete && (!evidence.memberNodeIds.includes(edge.fromNodeId) || !evidence.memberNodeIds.includes(edge.toNodeId))) {
897
+ issues.push(`${label}.edges must stay inside the component's member nodes`);
898
+ }
899
+ }
900
+ break;
901
+ }
902
+ case "direction-violation":
903
+ sample(evidence.violations, "violations");
904
+ ordered(evidence.violations, "violations", compareRefactorEvidenceDirectionViolations);
905
+ idList(evidence.constraintIds, evidence.constraintCount, "constraintIds", "constraintCount");
906
+ for (const violation of evidence.violations) {
907
+ edgeIssues(violation, "violations");
908
+ if (!evidence.constraintIds.includes(violation.constraintId)) issues.push(`${label}.violations must name a listed constraintId`);
909
+ }
910
+ if (evidence.totalCount !== observation.metrics.directionViolationCount) {
911
+ issues.push(`${label}.totalCount must equal metrics.directionViolationCount`);
912
+ }
913
+ break;
914
+ case "ownership-ambiguous":
915
+ case "undeclared-footprint":
916
+ case "unowned-paths":
917
+ sample(evidence.paths, "paths");
918
+ ordered(evidence.paths, "paths", (left, right) => compareText(left.path, right.path));
919
+ for (const entry of evidence.paths) {
920
+ issues.push(...repoPathIssues(`${label}.paths.path`, entry.path));
921
+ idList(entry.candidateOwnerNodeIds, entry.candidateOwnerCount, "paths.candidateOwnerNodeIds", "paths.candidateOwnerCount");
922
+ }
923
+ if (evidence.kind === "unowned-paths" && evidence.totalCount !== observation.metrics.unownedFileCount) {
924
+ issues.push(`${label}.totalCount must equal metrics.unownedFileCount`);
925
+ }
926
+ break;
927
+ case "evidence-gap":
928
+ sample(evidence.unresolvedImports, "unresolvedImports");
929
+ ordered(evidence.unresolvedImports, "unresolvedImports", compareRefactorEvidenceUnresolvedImports);
930
+ issues.push(...sortedUniqueIssues(`${label}.reasonCodes`, evidence.reasonCodes));
931
+ for (const entry of evidence.unresolvedImports) {
932
+ issues.push(...repoPathIssues(`${label}.unresolvedImports.fromPath`, entry.fromPath));
933
+ issues.push(...lineIssues(`${label}.unresolvedImports.fromLine`, entry.fromLine));
934
+ issues.push(...specifierIssues(`${label}.unresolvedImports.specifier`, entry.specifier));
935
+ }
936
+ if (evidence.totalCount !== observation.metrics.unresolvedImportCount) {
937
+ issues.push(`${label}.totalCount must equal metrics.unresolvedImportCount`);
938
+ }
939
+ break;
940
+ default:
941
+ issues.push(`${label}.kind is unsupported`);
942
+ }
943
+ return [...new Set(issues)];
944
+ }
945
+
689
946
  export function refactorResolutionEvidenceInvariantIssues(
690
947
  evidence: RefactorResolutionEvidenceV1,
691
948
  prefix = "resolutionEvidence"
@@ -787,6 +1044,18 @@ export function recommendationV3InvariantIssues(recommendation: RecommendationV3
787
1044
  if (recommendation.enforcement !== "advisory") {
788
1045
  issues.push(`${prefix}.structural_observation enforcement must be advisory`);
789
1046
  }
1047
+ if (shapeIssues.length === 0) {
1048
+ // The payload is self-contained: the observation's metrics, signals and evidence travel with
1049
+ // it, so the same evidence rules bind a recorded record and a fresh assessment.
1050
+ const payload = recommendation.payload as StructuralObservationPayloadV1;
1051
+ issues.push(
1052
+ ...sortedUniqueIssues(`${prefix}.payload.signalIds`, payload.signalIds),
1053
+ ...refactorObservationEvidenceIssues(
1054
+ { kind: payload.kind, subjectSelectorId: recommendation.subject, signalIds: payload.signalIds, metrics: payload.metrics, evidence: payload.evidence },
1055
+ `${prefix}.payload`
1056
+ )
1057
+ );
1058
+ }
790
1059
  }
791
1060
  if (recommendation.category === "refactor_proposal") {
792
1061
  if (!isRefactorProposalAuthorSource(recommendation.authoredBy.source)) {
@@ -953,7 +1222,16 @@ const REQUIRED_PAYLOAD_FIELDS: Readonly<Record<RecommendationCategory, readonly
953
1222
  "scale",
954
1223
  "targetOutcomes"
955
1224
  ],
956
- structural_observation: ["affectedNodeIds", "assessmentDigest", "baselineSnapshotDigest", "derivedOutcomes", "kind"]
1225
+ structural_observation: [
1226
+ "affectedNodeIds",
1227
+ "assessmentDigest",
1228
+ "baselineSnapshotDigest",
1229
+ "derivedOutcomes",
1230
+ "evidence",
1231
+ "kind",
1232
+ "metrics",
1233
+ "signalIds"
1234
+ ]
957
1235
  };
958
1236
 
959
1237
  function fingerprintPayloadSubset(category: RecommendationCategory, payload: RecommendationPayloadV1): Record<string, Json> {
@@ -984,6 +1262,32 @@ function outcomeIssues(label: string, outcomes: readonly RefactorTargetOutcomeV1
984
1262
  return issues;
985
1263
  }
986
1264
 
1265
+ function compareText(left: string, right: string): number {
1266
+ return left < right ? -1 : left > right ? 1 : 0;
1267
+ }
1268
+
1269
+ /** An unknown line sorts before every known one, so the order is total. */
1270
+ function compareLine(left: number | null, right: number | null): number {
1271
+ return (left ?? 0) - (right ?? 0);
1272
+ }
1273
+
1274
+ function repoPathIssues(label: string, path: string): string[] {
1275
+ return typeof path === "string" && isRepoRelativePosixPath(path) ? [] : [`${label} must be a repo-relative POSIX path`];
1276
+ }
1277
+
1278
+ function lineIssues(label: string, line: number | null): string[] {
1279
+ return line === null || (Number.isInteger(line) && line >= 1) ? [] : [`${label} must be null or a positive integer`];
1280
+ }
1281
+
1282
+ function specifierIssues(label: string, specifier: string): string[] {
1283
+ return typeof specifier === "string"
1284
+ && specifier.length > 0
1285
+ && specifier.length <= REFACTOR_EVIDENCE_SPECIFIER_MAX_LENGTH
1286
+ && !/[\u0000-\u001f\u007f]/.test(specifier)
1287
+ ? []
1288
+ : [`${label} must be a single-line specifier of 1-${REFACTOR_EVIDENCE_SPECIFIER_MAX_LENGTH} characters`];
1289
+ }
1290
+
987
1291
  function digestIssues(label: string, value: string): string[] {
988
1292
  return SHA256_DIGEST_PATTERN.test(value) ? [] : [`${label} must be a sha256:<64-hex> digest`];
989
1293
  }
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("/")) {