archctx-contracts 0.7.1 → 0.8.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 (56) hide show
  1. package/fixtures/invalid/hook-debt-checkpoint-failed-read.json +54 -0
  2. package/fixtures/invalid/native-context-invalid-model-summary.json +635 -0
  3. package/fixtures/invalid/native-context-missing-contract-bytes.json +636 -0
  4. package/fixtures/invalid/native-context-missing-input-bytes.json +636 -0
  5. package/fixtures/invalid/native-context-mixed-snapshot.json +637 -0
  6. package/fixtures/invalid/projection-result-applied-incomplete-coverage.json +78 -0
  7. package/fixtures/invalid/refactor-decision-open-status.json +38 -0
  8. package/fixtures/valid/archctx-capabilities.json +3 -3
  9. package/fixtures/valid/capability-dependencies.json +1 -1
  10. package/fixtures/valid/docs-anchors.json +2 -1
  11. package/fixtures/valid/flow-selector-check.json +1 -1
  12. package/fixtures/valid/hook-debt-checkpoint.json +54 -0
  13. package/fixtures/valid/native-context-export.json +636 -0
  14. package/fixtures/valid/product-version-manifest.json +8 -8
  15. package/fixtures/valid/projection-manifest.json +47 -169
  16. package/fixtures/valid/projection-result.json +5 -5
  17. package/fixtures/valid/refactor-decision.json +38 -0
  18. package/fixtures/valid/runtime-recommendation-show.json +1 -0
  19. package/fixtures/valid/runtime-refactor-scan.json +6 -0
  20. package/package.json +1 -1
  21. package/schemas/repo/refactor-decision.schema.json +218 -0
  22. package/schemas/runtime/archctx-capabilities.schema.json +2 -2
  23. package/schemas/runtime/architecture-debt.schema.json +222 -0
  24. package/schemas/runtime/capability-dependencies.schema.json +10 -6
  25. package/schemas/runtime/changeset.schema.json +24 -1
  26. package/schemas/runtime/docs-anchors.schema.json +4 -3
  27. package/schemas/runtime/hook-debt-checkpoint.schema.json +69 -0
  28. package/schemas/runtime/native-context-export.schema.json +823 -0
  29. package/schemas/runtime/practice-checkpoint.schema.json +1 -0
  30. package/schemas/runtime/product-version-manifest.schema.json +2 -2
  31. package/schemas/runtime/projection-apply-readback.schema.json +470 -7
  32. package/schemas/runtime/projection-apply-recovery.schema.json +2 -2
  33. package/schemas/runtime/projection-manifest.schema.json +81 -186
  34. package/schemas/runtime/projection-result.schema.json +132 -5
  35. package/schemas/runtime/projection-target.schema.json +4 -1
  36. package/schemas/runtime/recommendation-v3.schema.json +106 -0
  37. package/schemas/runtime/review-result.schema.json +1 -0
  38. package/schemas/runtime/runtime-recommendation-show.schema.json +234 -13
  39. package/schemas/runtime/runtime-refactor-scan.schema.json +171 -0
  40. package/schemas/runtime/structural-observation-payload.schema.json +106 -0
  41. package/src/architecture-debt.ts +115 -0
  42. package/src/changeset-recovery.ts +49 -0
  43. package/src/docs-anchors.ts +2 -1
  44. package/src/failure.ts +776 -0
  45. package/src/index.ts +6 -0
  46. package/src/ledger.ts +14 -3
  47. package/src/native-context.ts +277 -0
  48. package/src/ownership-dependencies.ts +39 -1
  49. package/src/practices.ts +2 -0
  50. package/src/product-version.ts +37 -4
  51. package/src/projection-manifest.ts +372 -130
  52. package/src/projection-recovery.ts +111 -0
  53. package/src/projection.ts +443 -11
  54. package/src/refactor.ts +525 -2
  55. package/src/schema.ts +6 -0
  56. package/src/validator.ts +8 -3
package/src/refactor.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { RECOMMENDATION_CATEGORIES, RECOMMENDATION_V3_SCHEMA_VERSION } from "./ledger";
2
2
  import type {
3
3
  ArchitectureActorKind,
4
+ ArchitectureSubjectSelectorKind,
4
5
  ArchitectureRepositoryIdentityV1,
5
6
  ArchitectureWorktreeIdentityV1,
6
7
  EvidenceCoverageLevelV2,
@@ -8,6 +9,7 @@ import type {
8
9
  RecommendationAuthorV1,
9
10
  RecommendationCategory,
10
11
  RecommendationPayloadV1,
12
+ RecommendationStatus,
11
13
  RecommendationV3,
12
14
  RecommendationV3Base,
13
15
  RefactorProposalPayloadV1,
@@ -47,6 +49,22 @@ export const REFACTOR_VERIFICATION_REQUEST_KEYS = [
47
49
  "schemaVersion"
48
50
  ] as const;
49
51
 
52
+ /**
53
+ * The grammar of the refactor identity namespace: the manifest `product.id`, which scopes every
54
+ * refactor subject selector so one subject has one recommendation id in every worktree, branch and
55
+ * clone (ADR-0046). It is the declared-id grammar node ids use, without the cross-repo prefix.
56
+ */
57
+ export const REFACTOR_NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(\.[a-z0-9][a-z0-9-]*)+$/;
58
+
59
+ /** Issues with a refactor namespace value; empty when it is a well-formed `product.id`. */
60
+ export function refactorNamespaceIssues(value: unknown, label = "product.id"): string[] {
61
+ if (typeof value !== "string") return [`${label} must be a string`];
62
+ if (value.trim() === "") return [`${label} must not be blank`];
63
+ return REFACTOR_NAMESPACE_PATTERN.test(value)
64
+ ? []
65
+ : [`${label} must match ${REFACTOR_NAMESPACE_PATTERN.source}`];
66
+ }
67
+
50
68
  export const REFACTOR_REQUEST_SCHEMA_VERSION = "archcontext.refactor-request/v1" as const;
51
69
  export const REFACTOR_PROPOSAL_SCHEMA_VERSION = "archcontext.refactor-proposal/v1" as const;
52
70
  export const MODULE_STATISTICS_SCHEMA_VERSION = "archcontext.module-statistics/v1" as const;
@@ -74,11 +92,16 @@ export const REFACTOR_SCALE_REASON_CODES = [
74
92
  "target-unresolved",
75
93
  "unowned-paths"
76
94
  ] as const;
95
+ /**
96
+ * `cycle` is one bidirectional module pair; `residual-cycle` is a strongly connected component that
97
+ * survives cutting the thin direction of every pair (ADR-0046).
98
+ */
77
99
  export const REFACTOR_OBSERVATION_KINDS = [
78
100
  "cycle",
79
101
  "direction-violation",
80
102
  "evidence-gap",
81
103
  "ownership-ambiguous",
104
+ "residual-cycle",
82
105
  "undeclared-footprint",
83
106
  "unowned-paths"
84
107
  ] as const;
@@ -137,6 +160,11 @@ export interface RefactorTargetOutcomeV1 {
137
160
  metric: string;
138
161
  subjectSelectorId: string;
139
162
  nodeId: string | null;
163
+ /**
164
+ * The module set a `moduleDependencies.*` metric is measured over: the two ends of a pair, or the
165
+ * members of a residual cycle. Present exactly for that metric family, sorted and unique.
166
+ */
167
+ memberNodeIds?: string[];
140
168
  operator: RefactorOutcomeOperator;
141
169
  value: number | null;
142
170
  required: boolean;
@@ -282,6 +310,17 @@ export interface ModuleStatisticsV1 {
282
310
  extensions?: Record<string, Json>;
283
311
  }
284
312
 
313
+ /**
314
+ * One ordered cross-module dependency of the measured graph: `distinctFileEdgeCount` distinct
315
+ * file-to-file import edges whose source file `fromNodeId` owns and whose target `toNodeId` owns.
316
+ * A contested file is attributed to each claimant, exactly as the module counts are.
317
+ */
318
+ export interface ModuleDependencyV1 {
319
+ fromNodeId: string;
320
+ toNodeId: string;
321
+ distinctFileEdgeCount: number;
322
+ }
323
+
285
324
  export interface ModuleStatisticsSnapshotV1 {
286
325
  schemaVersion: typeof MODULE_STATISTICS_SCHEMA_VERSION;
287
326
  repository: ArchitectureRepositoryIdentityV1;
@@ -308,6 +347,12 @@ export interface ModuleStatisticsSnapshotV1 {
308
347
  reasonCodes: RefactorScaleReasonCode[];
309
348
  };
310
349
  modules: ModuleStatisticsV1[];
350
+ /**
351
+ * The module dependency table the graph counts were measured from, sorted by
352
+ * `(fromNodeId, toNodeId)`; one entry per `repositorySummary.crossModuleEdgeCount`. Empty when the
353
+ * graph was not measured (`codeFacts.coverage: unknown`).
354
+ */
355
+ moduleDependencies: ModuleDependencyV1[];
311
356
  repositorySummary: {
312
357
  moduleCount: number;
313
358
  undeclaredFootprintNodeCount: number;
@@ -396,8 +441,28 @@ interface RefactorEvidenceSampleV1 {
396
441
  * `memberNodeIds` is the prefix of the component's members; its population is the observation's
397
442
  * `metrics.memberCount`.
398
443
  */
444
+ /**
445
+ * One direction of a bidirectional pair: its import-edge population and how many of the sampled
446
+ * `edges` it holds. The thin direction (fewer distinct file edges, ties to the canonical forward
447
+ * direction) is listed first and is sampled first.
448
+ */
449
+ export interface RefactorEvidencePairDirectionV1 {
450
+ fromNodeId: string;
451
+ toNodeId: string;
452
+ totalCount: number;
453
+ sampledCount: number;
454
+ }
455
+
399
456
  export type RefactorObservationEvidenceV1 =
400
- | ({ kind: "cycle"; memberNodeIds: string[]; edges: RefactorEvidenceImportEdgeV1[] } & RefactorEvidenceSampleV1)
457
+ | ({
458
+ kind: "cycle";
459
+ /** The pair, sorted: `[A, B]`. */
460
+ memberNodeIds: string[];
461
+ /** `[thin, thick]`; `totalCount` is their sum. */
462
+ directions: RefactorEvidencePairDirectionV1[];
463
+ edges: RefactorEvidenceImportEdgeV1[];
464
+ } & RefactorEvidenceSampleV1)
465
+ | ({ kind: "residual-cycle"; memberNodeIds: string[]; edges: RefactorEvidenceImportEdgeV1[] } & RefactorEvidenceSampleV1)
401
466
  | ({
402
467
  kind: "direction-violation";
403
468
  constraintIds: string[];
@@ -416,6 +481,12 @@ export type RefactorObservationEvidenceV1 =
416
481
  export interface RefactorObservationV1 {
417
482
  kind: RefactorObservationKind;
418
483
  subjectSelectorId: string;
484
+ /**
485
+ * The declared nodes this observation is about, sorted and unique, authored by the producer that
486
+ * measured it: a node subject is `[nodeId]`, a pair its two ends, a residual cycle its members, a
487
+ * repository-wide fact `[]`. Scopes filter on it and the planner copies it; nothing re-derives it.
488
+ */
489
+ affectedNodeIds: string[];
419
490
  signalIds: string[];
420
491
  metrics: Record<string, number | null>;
421
492
  evidence: RefactorObservationEvidenceV1;
@@ -762,6 +833,7 @@ export function moduleStatisticsSnapshotInvariantIssues(snapshot: ModuleStatisti
762
833
  for (const [field, value] of Object.entries(snapshot.repositorySummary)) {
763
834
  issues.push(...nonNegativeIntegerIssues(`${prefix}.repositorySummary.${field}`, value));
764
835
  }
836
+ issues.push(...moduleDependencyIssues(snapshot, prefix));
765
837
  if (snapshot.repositorySummary.moduleCount !== snapshot.modules.length) {
766
838
  issues.push(`${prefix}.repositorySummary.moduleCount must equal the module count`);
767
839
  }
@@ -775,6 +847,83 @@ export function moduleStatisticsSnapshotInvariantIssues(snapshot: ModuleStatisti
775
847
  return issues;
776
848
  }
777
849
 
850
+ /**
851
+ * The dependency table binds the graph counts it was measured with: sorted and unique by
852
+ * `(fromNodeId, toNodeId)`, positive counts, no self edge, both ends measured modules, one entry per
853
+ * counted cross-module edge, and nothing at all when the graph was not measured.
854
+ */
855
+ function moduleDependencyIssues(snapshot: ModuleStatisticsSnapshotV1, prefix: string): string[] {
856
+ const label = `${prefix}.moduleDependencies`;
857
+ const dependencies = (snapshot as { moduleDependencies?: unknown }).moduleDependencies;
858
+ if (!Array.isArray(dependencies)) return [`${label} must be an array`];
859
+ const issues: string[] = [];
860
+ const modules = new Set(snapshot.modules.map((module) => module.nodeId));
861
+ for (const [index, entry] of (dependencies as ModuleDependencyV1[]).entries()) {
862
+ if (!entry || typeof entry !== "object") {
863
+ issues.push(`${label}[${index}] must be an object`);
864
+ continue;
865
+ }
866
+ if (!modules.has(entry.fromNodeId) || !modules.has(entry.toNodeId)) issues.push(`${label}[${index}] must name measured modules`);
867
+ if (entry.fromNodeId === entry.toNodeId) issues.push(`${label}[${index}] must not be a self dependency`);
868
+ if (!Number.isInteger(entry.distinctFileEdgeCount) || entry.distinctFileEdgeCount < 1) {
869
+ issues.push(`${label}[${index}].distinctFileEdgeCount must be a positive integer`);
870
+ }
871
+ const previous = (dependencies as ModuleDependencyV1[])[index - 1];
872
+ if (previous && (compareText(previous.fromNodeId, entry.fromNodeId) || compareText(previous.toNodeId, entry.toNodeId)) >= 0) {
873
+ issues.push(`${label} must be sorted and unique by (fromNodeId, toNodeId)`);
874
+ }
875
+ }
876
+ if (dependencies.length !== snapshot.repositorySummary.crossModuleEdgeCount) {
877
+ issues.push(`${label} must hold one entry per repositorySummary.crossModuleEdgeCount`);
878
+ }
879
+ if (snapshot.codeFacts.coverage === "unknown" && dependencies.length > 0) {
880
+ issues.push(`${label} must be empty when codeFacts.coverage is unknown`);
881
+ }
882
+ return [...new Set(issues)];
883
+ }
884
+
885
+ /**
886
+ * How a pair's evidence sample of `limit` entries is split: the thin direction first, but one slot
887
+ * is kept for the thick direction whenever there are two or more slots, so both directions are
888
+ * always shown. Taking the thin direction first is what keeps a single thin edge visible beside
889
+ * any number of thick ones. Resampling a sample with a smaller limit gives exactly this split of
890
+ * the population, so the planner's evidence cut stays on the same rule.
891
+ */
892
+ export function refactorPairSampleAllocation(thinTotal: number, thickTotal: number, limit: number): { thin: number; thick: number } {
893
+ const slots = Math.max(0, Math.min(limit, thinTotal + thickTotal));
894
+ const reserve = thickTotal > 0 && slots >= 2 ? 1 : 0;
895
+ const thin = Math.min(thinTotal, slots - reserve);
896
+ return { thin, thick: Math.min(thickTotal, slots - thin) };
897
+ }
898
+
899
+ /**
900
+ * Cuts a pair's evidence to `limit` entries under `refactorPairSampleAllocation`. `evidence.edges`
901
+ * may be the whole population or an earlier sample of it: each direction keeps its first entries in
902
+ * canonical order, and the result is re-sorted canonically.
903
+ */
904
+ export function sampleRefactorPairEvidence(
905
+ evidence: Extract<RefactorObservationEvidenceV1, { kind: "cycle" }>,
906
+ limit: number
907
+ ): Extract<RefactorObservationEvidenceV1, { kind: "cycle" }> {
908
+ const [thin, thick] = evidence.directions as [RefactorEvidencePairDirectionV1, RefactorEvidencePairDirectionV1];
909
+ const allocation = refactorPairSampleAllocation(thin.totalCount, thick.totalCount, Math.min(limit, REFACTOR_OBSERVATION_EVIDENCE_LIMIT));
910
+ const sorted = [...evidence.edges].sort(compareRefactorEvidenceImportEdges);
911
+ const inDirection = (direction: RefactorEvidencePairDirectionV1) => sorted.filter((edge) =>
912
+ edge.fromNodeId === direction.fromNodeId && edge.toNodeId === direction.toNodeId);
913
+ const thinEdges = inDirection(thin).slice(0, allocation.thin);
914
+ const thickEdges = inDirection(thick).slice(0, allocation.thick);
915
+ const edges = [...thinEdges, ...thickEdges].sort(compareRefactorEvidenceImportEdges);
916
+ return {
917
+ ...evidence,
918
+ directions: [
919
+ { ...thin, sampledCount: thinEdges.length },
920
+ { ...thick, sampledCount: thickEdges.length }
921
+ ],
922
+ edges,
923
+ truncated: evidence.totalCount > edges.length
924
+ };
925
+ }
926
+
778
927
  export function refactorAssessmentInvariantIssues(assessment: RefactorAssessmentV1, prefix = "assessment"): string[] {
779
928
  const issues = [
780
929
  ...digestIssues(`${prefix}.assessmentDigest`, assessment.assessmentDigest),
@@ -800,6 +949,9 @@ export function refactorAssessmentInvariantIssues(assessment: RefactorAssessment
800
949
  for (const [index, observation] of assessment.observations.entries()) {
801
950
  if (observation.subjectSelectorId.trim() === "") issues.push(`${prefix}.observations[${index}].subjectSelectorId must not be empty`);
802
951
  issues.push(...sortedUniqueIssues(`${prefix}.observations[${index}].signalIds`, observation.signalIds));
952
+ issues.push(...(Array.isArray(observation.affectedNodeIds)
953
+ ? sortedUniqueIssues(`${prefix}.observations[${index}].affectedNodeIds`, observation.affectedNodeIds)
954
+ : [`${prefix}.observations[${index}].affectedNodeIds must be an array`]));
803
955
  issues.push(...refactorObservationEvidenceIssues(observation, `${prefix}.observations[${index}]`));
804
956
  }
805
957
  issues.push(...ratioIssues(`${prefix}.confidence.callerCoverage`, assessment.confidence.callerCoverage));
@@ -886,6 +1038,13 @@ export function refactorObservationEvidenceIssues(observation: RefactorObservati
886
1038
  };
887
1039
  switch (evidence.kind) {
888
1040
  case "cycle": {
1041
+ sample(evidence.edges, "edges");
1042
+ ordered(evidence.edges, "edges", compareRefactorEvidenceImportEdges);
1043
+ issues.push(...pairEvidenceIssues(observation, evidence, label));
1044
+ for (const edge of evidence.edges) edgeIssues(edge, "edges");
1045
+ break;
1046
+ }
1047
+ case "residual-cycle": {
889
1048
  sample(evidence.edges, "edges");
890
1049
  ordered(evidence.edges, "edges", compareRefactorEvidenceImportEdges);
891
1050
  idList(evidence.memberNodeIds, observation.metrics.memberCount, "memberNodeIds", "metrics.memberCount");
@@ -943,6 +1102,66 @@ export function refactorObservationEvidenceIssues(observation: RefactorObservati
943
1102
  return [...new Set(issues)];
944
1103
  }
945
1104
 
1105
+ /**
1106
+ * A pair's evidence explains exactly that pair: its two ends are the observation's affected nodes,
1107
+ * every edge runs between them in one of the two listed directions, the thin direction (by the
1108
+ * observation's own distinct-file-edge counts, ties to the forward direction) comes first, each
1109
+ * direction's `sampledCount` is the number of its edges in the sample, and the split is the one
1110
+ * `refactorPairSampleAllocation` prescribes for the sample size.
1111
+ */
1112
+ function pairEvidenceIssues(
1113
+ observation: RefactorObservationV1,
1114
+ evidence: Extract<RefactorObservationEvidenceV1, { kind: "cycle" }>,
1115
+ label: string
1116
+ ): string[] {
1117
+ const issues: string[] = [];
1118
+ const members = evidence.memberNodeIds;
1119
+ if (!Array.isArray(members) || members.length !== 2 || !(members[0]! < members[1]!)) {
1120
+ return [`${label}.memberNodeIds must be the two pair ends, sorted`];
1121
+ }
1122
+ const [first, second] = members as [string, string];
1123
+ if (Array.isArray(observation.affectedNodeIds) && (observation.affectedNodeIds.length !== 2
1124
+ || observation.affectedNodeIds[0] !== first || observation.affectedNodeIds[1] !== second)) {
1125
+ issues.push(`${label}.memberNodeIds must equal the observation affectedNodeIds`);
1126
+ }
1127
+ const forward = observation.metrics.forwardEdgeCount;
1128
+ const backward = observation.metrics.backwardEdgeCount;
1129
+ if (!Number.isInteger(forward) || !Number.isInteger(backward) || (forward as number) < 1 || (backward as number) < 1) {
1130
+ issues.push(`${label} requires positive integer metrics.forwardEdgeCount and metrics.backwardEdgeCount`);
1131
+ return issues;
1132
+ }
1133
+ if (observation.metrics.thinDirectionEdgeCount !== Math.min(forward as number, backward as number)) {
1134
+ issues.push(`${label} requires metrics.thinDirectionEdgeCount = min(forwardEdgeCount, backwardEdgeCount)`);
1135
+ }
1136
+ if (observation.metrics.totalEdgeCount !== (forward as number) + (backward as number)) {
1137
+ issues.push(`${label} requires metrics.totalEdgeCount = forwardEdgeCount + backwardEdgeCount`);
1138
+ }
1139
+ const directions = evidence.directions;
1140
+ if (!Array.isArray(directions) || directions.length !== 2) return [...issues, `${label}.directions must list the thin then the thick direction`];
1141
+ const [thin, thick] = directions as [RefactorEvidencePairDirectionV1, RefactorEvidencePairDirectionV1];
1142
+ const forwardIsThin = (forward as number) <= (backward as number);
1143
+ const expectedThin = forwardIsThin ? [first, second] : [second, first];
1144
+ if (thin.fromNodeId !== expectedThin[0] || thin.toNodeId !== expectedThin[1]
1145
+ || thick.fromNodeId !== expectedThin[1] || thick.toNodeId !== expectedThin[0]) {
1146
+ issues.push(`${label}.directions must be [thin, thick] by the pair's distinct file edge counts`);
1147
+ }
1148
+ for (const direction of directions) {
1149
+ if (!Number.isInteger(direction.totalCount) || direction.totalCount < 1) issues.push(`${label}.directions totalCount must be a positive integer`);
1150
+ const sampled = evidence.edges.filter((edge) => edge.fromNodeId === direction.fromNodeId && edge.toNodeId === direction.toNodeId).length;
1151
+ if (direction.sampledCount !== sampled) issues.push(`${label}.directions sampledCount must count the sampled edges of that direction`);
1152
+ if (sampled > direction.totalCount) issues.push(`${label}.directions sample must not exceed the direction population`);
1153
+ }
1154
+ if (thin.totalCount + thick.totalCount !== evidence.totalCount) issues.push(`${label}.totalCount must be the sum of both directions`);
1155
+ if (evidence.edges.some((edge) => !((edge.fromNodeId === first && edge.toNodeId === second) || (edge.fromNodeId === second && edge.toNodeId === first)))) {
1156
+ issues.push(`${label}.edges must run between the pair's two ends`);
1157
+ }
1158
+ const allocation = refactorPairSampleAllocation(thin.totalCount, thick.totalCount, evidence.edges.length);
1159
+ if (thin.sampledCount !== allocation.thin || thick.sampledCount !== allocation.thick) {
1160
+ issues.push(`${label}.edges must sample the thin direction first and keep one slot for the thick direction`);
1161
+ }
1162
+ return issues;
1163
+ }
1164
+
946
1165
  export function refactorResolutionEvidenceInvariantIssues(
947
1166
  evidence: RefactorResolutionEvidenceV1,
948
1167
  prefix = "resolutionEvidence"
@@ -1051,7 +1270,14 @@ export function recommendationV3InvariantIssues(recommendation: RecommendationV3
1051
1270
  issues.push(
1052
1271
  ...sortedUniqueIssues(`${prefix}.payload.signalIds`, payload.signalIds),
1053
1272
  ...refactorObservationEvidenceIssues(
1054
- { kind: payload.kind, subjectSelectorId: recommendation.subject, signalIds: payload.signalIds, metrics: payload.metrics, evidence: payload.evidence },
1273
+ {
1274
+ kind: payload.kind,
1275
+ subjectSelectorId: recommendation.subject,
1276
+ affectedNodeIds: payload.affectedNodeIds,
1277
+ signalIds: payload.signalIds,
1278
+ metrics: payload.metrics,
1279
+ evidence: payload.evidence
1280
+ },
1055
1281
  `${prefix}.payload`
1056
1282
  )
1057
1283
  );
@@ -1254,6 +1480,13 @@ function fingerprintPayloadSubset(category: RecommendationCategory, payload: Rec
1254
1480
  function outcomeIssues(label: string, outcomes: readonly RefactorTargetOutcomeV1[]): string[] {
1255
1481
  const issues = sortedUniqueIssues(`${label}.outcomeId`, outcomes.map((outcome) => outcome.outcomeId));
1256
1482
  for (const outcome of outcomes) {
1483
+ if (outcome.memberNodeIds !== undefined) {
1484
+ if (!Array.isArray(outcome.memberNodeIds) || outcome.memberNodeIds.length < 2) {
1485
+ issues.push(`${label} memberNodeIds must list at least two modules`);
1486
+ } else {
1487
+ issues.push(...sortedUniqueIssues(`${label}.memberNodeIds`, outcome.memberNodeIds));
1488
+ }
1489
+ }
1257
1490
  const valueless = outcome.operator === "absent" || outcome.operator === "present";
1258
1491
  if (valueless && outcome.value !== null) issues.push(`${label} operator ${outcome.operator} must not carry a value`);
1259
1492
  if (!valueless && outcome.value === null) issues.push(`${label} operator ${outcome.operator} requires a value`);
@@ -1302,3 +1535,293 @@ function sortedUniqueIssues(label: string, values: readonly string[]): string[]
1302
1535
  ? []
1303
1536
  : [`${label} must be sorted and unique`];
1304
1537
  }
1538
+
1539
+ /**
1540
+ * The fingerprint schema every refactor recommendation id is derived under. v3 is the first whose
1541
+ * subject selector is scoped by the product namespace instead of the worktree path (ADR-0046).
1542
+ * Owned here so the planner and the Git decision contract can never name two different versions.
1543
+ */
1544
+ export const REFACTOR_RECOMMENDATION_FINGERPRINT_SCHEMA_VERSION = "archcontext.recommendation-fingerprint/v3" as const;
1545
+
1546
+ export const REFACTOR_DECISION_SCHEMA_VERSION = "archcontext.refactor-decision/v1" as const;
1547
+ /** Git-visible home of human refactor decisions; outside `listModelFiles`, so never in `modelDigest`. */
1548
+ export const REFACTOR_DECISION_DIRECTORY = ".archcontext/refactor-decisions" as const;
1549
+ export const REFACTOR_DECISION_FILE_PATTERN = /^\.archcontext\/refactor-decisions\/(recommendation\.[a-f0-9]{16})\.json$/;
1550
+ export const REFACTOR_RECOMMENDATION_ID_PATTERN = /^recommendation\.[a-f0-9]{16}$/;
1551
+ /** The six human statuses. `open` is a machine status and is never a decision. */
1552
+ export const REFACTOR_DECISION_STATUSES = ["acknowledged", "accepted", "deferred", "rejected", "resolved", "waived"] as const;
1553
+ export const REFACTOR_DECISION_REASON_MAX_LENGTH = 1024;
1554
+ export const REFACTOR_DECISION_ACTOR_ID_MAX_LENGTH = 128;
1555
+ export const REFACTOR_DECISION_STABLE_KEY_MAX_LENGTH = 512;
1556
+ const REFACTOR_DECISION_ACTOR_KINDS: readonly ArchitectureActorKind[] = ["cli", "daemon", "developer", "hook", "mcp", "migration", "subagent", "system"];
1557
+ const REFACTOR_DECISION_SELECTOR_KINDS: readonly ArchitectureSubjectSelectorKind[] = ["node", "repository"];
1558
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
1559
+ const GIT_CONFLICT_MARKER_PATTERN = /^(<{7}|={7}|>{7}|\|{7})( |$)/m;
1560
+
1561
+ export type RefactorDecisionStatus = (typeof REFACTOR_DECISION_STATUSES)[number];
1562
+
1563
+ /**
1564
+ * The latest human decision for one refactor recommendation generation, stored in Git at
1565
+ * `.archcontext/refactor-decisions/<recommendationId>.json` (ADR-0046). It carries identity and the
1566
+ * decided measurement only: no absolute path, storage id, source, diff or evidence body.
1567
+ */
1568
+ export interface RefactorDecisionV1 {
1569
+ schemaVersion: typeof REFACTOR_DECISION_SCHEMA_VERSION;
1570
+ /** The manifest `product.id` the subject selector is scoped by. */
1571
+ namespace: string;
1572
+ recommendationId: string;
1573
+ fingerprint: string;
1574
+ fingerprintSchemaVersion: typeof REFACTOR_RECOMMENDATION_FINGERPRINT_SCHEMA_VERSION;
1575
+ category: "refactor_proposal" | "structural_observation";
1576
+ /** The observation kind; `null` for a refactor proposal. */
1577
+ kind: RefactorObservationKind | null;
1578
+ subject: {
1579
+ selectorKind: ArchitectureSubjectSelectorKind;
1580
+ stableKey: string;
1581
+ subjectSelectorId: string;
1582
+ };
1583
+ /** Sorted prefix of the affected nodes, bounded by `REFACTOR_EVIDENCE_ID_LIST_LIMIT`. */
1584
+ affectedNodeIds: string[];
1585
+ affectedNodeCount: number;
1586
+ regressesFrom: string | null;
1587
+ status: RefactorDecisionStatus;
1588
+ /** The measurement the decision was taken against; `null` when the record carries none. */
1589
+ observationMetrics: Record<string, number | null> | null;
1590
+ reason: string;
1591
+ actor: { kind: ArchitectureActorKind; id: string };
1592
+ decidedAt: string;
1593
+ provenance: {
1594
+ headSha: string;
1595
+ assessmentDigest: string;
1596
+ baselineSnapshotDigest: string;
1597
+ };
1598
+ }
1599
+
1600
+ const REFACTOR_DECISION_KEYS = [
1601
+ "actor",
1602
+ "affectedNodeCount",
1603
+ "affectedNodeIds",
1604
+ "category",
1605
+ "decidedAt",
1606
+ "fingerprint",
1607
+ "fingerprintSchemaVersion",
1608
+ "kind",
1609
+ "namespace",
1610
+ "observationMetrics",
1611
+ "provenance",
1612
+ "reason",
1613
+ "recommendationId",
1614
+ "regressesFrom",
1615
+ "schemaVersion",
1616
+ "status",
1617
+ "subject"
1618
+ ] as const;
1619
+
1620
+ export function isRefactorDecisionStatus(status: RecommendationStatus | string): status is RefactorDecisionStatus {
1621
+ return (REFACTOR_DECISION_STATUSES as readonly string[]).includes(status);
1622
+ }
1623
+
1624
+ /**
1625
+ * Who states a refactor recommendation's status (ADR-0046): its Git decision file
1626
+ * (`git-decision`), a machine status the ledger holds (`ledger`), or nobody — a human status the
1627
+ * ledger recorded whose Git file this checkout does not carry is audit only (`ledger-audit`): it
1628
+ * keeps its recorded status as history, is never reported as the current status of the subject,
1629
+ * and never appears in an open or status listing.
1630
+ */
1631
+ export type RefactorStatusAuthority = "git-decision" | "ledger" | "ledger-audit";
1632
+
1633
+ /** The one status rule every reader shares. */
1634
+ export function refactorRecommendationStatusView(
1635
+ ledgerStatus: RecommendationStatus,
1636
+ decided: RefactorDecisionStatus | undefined
1637
+ ): { status: RecommendationStatus; authority: RefactorStatusAuthority } {
1638
+ if (decided !== undefined) return { status: decided, authority: "git-decision" };
1639
+ return { status: ledgerStatus, authority: isRefactorDecisionStatus(ledgerStatus) ? "ledger-audit" : "ledger" };
1640
+ }
1641
+
1642
+ /** The repository path of the decision file for one recommendation id. */
1643
+ export function refactorDecisionPath(recommendationId: string): string {
1644
+ return `${REFACTOR_DECISION_DIRECTORY}/${recommendationId}.json`;
1645
+ }
1646
+
1647
+ /**
1648
+ * The one serialization the daemon writes: two-space JSON, LF line endings, keys in declaration
1649
+ * order at every level (`observationMetrics` keys sorted), final newline. It does not depend on the
1650
+ * key order of the value it is given, so a parsed file serializes back to its own bytes only when
1651
+ * it is already canonical.
1652
+ */
1653
+ export function serializeRefactorDecision(decision: RefactorDecisionV1): string {
1654
+ const canonical: RefactorDecisionV1 = {
1655
+ schemaVersion: decision.schemaVersion,
1656
+ namespace: decision.namespace,
1657
+ recommendationId: decision.recommendationId,
1658
+ fingerprint: decision.fingerprint,
1659
+ fingerprintSchemaVersion: decision.fingerprintSchemaVersion,
1660
+ category: decision.category,
1661
+ kind: decision.kind,
1662
+ subject: {
1663
+ selectorKind: decision.subject.selectorKind,
1664
+ stableKey: decision.subject.stableKey,
1665
+ subjectSelectorId: decision.subject.subjectSelectorId
1666
+ },
1667
+ affectedNodeIds: decision.affectedNodeIds,
1668
+ affectedNodeCount: decision.affectedNodeCount,
1669
+ regressesFrom: decision.regressesFrom,
1670
+ status: decision.status,
1671
+ observationMetrics: decision.observationMetrics === null
1672
+ ? null
1673
+ : Object.fromEntries(Object.entries(decision.observationMetrics).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))),
1674
+ reason: decision.reason,
1675
+ actor: { kind: decision.actor.kind, id: decision.actor.id },
1676
+ decidedAt: decision.decidedAt,
1677
+ provenance: {
1678
+ headSha: decision.provenance.headSha,
1679
+ assessmentDigest: decision.provenance.assessmentDigest,
1680
+ baselineSnapshotDigest: decision.provenance.baselineSnapshotDigest
1681
+ }
1682
+ };
1683
+ return `${JSON.stringify(canonical, null, 2)}\n`;
1684
+ }
1685
+
1686
+ /**
1687
+ * Validates a decision value: closed key sets at every level, the frozen vocabularies, bounded
1688
+ * one-line strings and sorted bounded id lists. Identity that needs the core hashers (the
1689
+ * recommendation id from fingerprint and `regressesFrom`, the namespace-scoped selector) is checked
1690
+ * by the reader that has them.
1691
+ */
1692
+ export function refactorDecisionInvariantIssues(value: unknown, prefix = "refactorDecision"): string[] {
1693
+ if (!isPlainObject(value)) return [`${prefix} must be an object`];
1694
+ const issues: string[] = [];
1695
+ const unknownKeys = Object.keys(value).filter((key) => !(REFACTOR_DECISION_KEYS as readonly string[]).includes(key)).sort();
1696
+ if (unknownKeys.length > 0) issues.push(`${prefix} has unsupported key(s): ${unknownKeys.join(", ")}`);
1697
+ const missing = REFACTOR_DECISION_KEYS.filter((key) => !(key in value));
1698
+ if (missing.length > 0) issues.push(`${prefix} is missing key(s): ${missing.join(", ")}`);
1699
+ if (issues.length > 0) return issues;
1700
+ const decision = value as unknown as RefactorDecisionV1;
1701
+ if (decision.schemaVersion !== REFACTOR_DECISION_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion must be ${REFACTOR_DECISION_SCHEMA_VERSION}`);
1702
+ issues.push(...refactorNamespaceIssues(decision.namespace, `${prefix}.namespace`));
1703
+ if (typeof decision.recommendationId !== "string" || !REFACTOR_RECOMMENDATION_ID_PATTERN.test(decision.recommendationId)) {
1704
+ issues.push(`${prefix}.recommendationId must match ${REFACTOR_RECOMMENDATION_ID_PATTERN.source}`);
1705
+ }
1706
+ if (typeof decision.fingerprint !== "string") issues.push(`${prefix}.fingerprint must be a sha256:<64-hex> digest`);
1707
+ else issues.push(...digestIssues(`${prefix}.fingerprint`, decision.fingerprint));
1708
+ if (decision.fingerprintSchemaVersion !== REFACTOR_RECOMMENDATION_FINGERPRINT_SCHEMA_VERSION) {
1709
+ issues.push(`${prefix}.fingerprintSchemaVersion must be ${REFACTOR_RECOMMENDATION_FINGERPRINT_SCHEMA_VERSION}`);
1710
+ }
1711
+ if (decision.category !== "structural_observation" && decision.category !== "refactor_proposal") {
1712
+ issues.push(`${prefix}.category must be structural_observation or refactor_proposal`);
1713
+ } else if (decision.category === "structural_observation"
1714
+ ? !(REFACTOR_OBSERVATION_KINDS as readonly unknown[]).includes(decision.kind)
1715
+ : decision.kind !== null) {
1716
+ issues.push(`${prefix}.kind must be an observation kind for structural_observation and null for refactor_proposal`);
1717
+ }
1718
+ issues.push(...decisionSubjectIssues(decision.subject, `${prefix}.subject`));
1719
+ if (!Array.isArray(decision.affectedNodeIds) || decision.affectedNodeIds.some((id) => typeof id !== "string" || id.trim() === "")) {
1720
+ issues.push(`${prefix}.affectedNodeIds must be an array of node ids`);
1721
+ } else {
1722
+ issues.push(...sortedUniqueIssues(`${prefix}.affectedNodeIds`, decision.affectedNodeIds));
1723
+ if (!Number.isInteger(decision.affectedNodeCount) || decision.affectedNodeCount < 0) {
1724
+ issues.push(`${prefix}.affectedNodeCount must be a non-negative integer`);
1725
+ } else if (decision.affectedNodeIds.length !== Math.min(decision.affectedNodeCount, REFACTOR_EVIDENCE_ID_LIST_LIMIT)) {
1726
+ issues.push(`${prefix}.affectedNodeIds must hold min(affectedNodeCount, ${REFACTOR_EVIDENCE_ID_LIST_LIMIT}) entries`);
1727
+ }
1728
+ }
1729
+ if (decision.regressesFrom !== null
1730
+ && (typeof decision.regressesFrom !== "string" || !REFACTOR_RECOMMENDATION_ID_PATTERN.test(decision.regressesFrom))) {
1731
+ issues.push(`${prefix}.regressesFrom must be null or a recommendation id`);
1732
+ }
1733
+ if (!isRefactorDecisionStatus(String(decision.status))) {
1734
+ issues.push(`${prefix}.status must be one of ${REFACTOR_DECISION_STATUSES.join(", ")}`);
1735
+ }
1736
+ if (decision.observationMetrics !== null) {
1737
+ if (!isPlainObject(decision.observationMetrics)
1738
+ || Object.entries(decision.observationMetrics).some(([key, metric]) =>
1739
+ key.trim() === "" || !(metric === null || (typeof metric === "number" && Number.isFinite(metric))))) {
1740
+ issues.push(`${prefix}.observationMetrics must be null or a map of finite numbers and nulls`);
1741
+ }
1742
+ }
1743
+ issues.push(...boundedLineIssues(`${prefix}.reason`, decision.reason, REFACTOR_DECISION_REASON_MAX_LENGTH));
1744
+ if (!isPlainObject(decision.actor) || Object.keys(decision.actor).sort().join(",") !== "id,kind") {
1745
+ issues.push(`${prefix}.actor must carry exactly kind and id`);
1746
+ } else {
1747
+ if (!REFACTOR_DECISION_ACTOR_KINDS.includes(decision.actor.kind)) issues.push(`${prefix}.actor.kind is unsupported`);
1748
+ issues.push(...boundedLineIssues(`${prefix}.actor.id`, decision.actor.id, REFACTOR_DECISION_ACTOR_ID_MAX_LENGTH));
1749
+ }
1750
+ if (typeof decision.decidedAt !== "string" || !ISO_TIMESTAMP_PATTERN.test(decision.decidedAt)) {
1751
+ issues.push(`${prefix}.decidedAt must be an ISO-8601 timestamp`);
1752
+ }
1753
+ if (!isPlainObject(decision.provenance)
1754
+ || Object.keys(decision.provenance).sort().join(",") !== "assessmentDigest,baselineSnapshotDigest,headSha") {
1755
+ issues.push(`${prefix}.provenance must carry exactly headSha, assessmentDigest and baselineSnapshotDigest`);
1756
+ } else {
1757
+ if (typeof decision.provenance.headSha !== "string" || !GIT_HEAD_SHA_PATTERN.test(decision.provenance.headSha)) {
1758
+ issues.push(`${prefix}.provenance.headSha must be a 40-hex Git commit sha`);
1759
+ }
1760
+ for (const field of ["assessmentDigest", "baselineSnapshotDigest"] as const) {
1761
+ const digest = decision.provenance[field];
1762
+ issues.push(...(typeof digest === "string" ? digestIssues(`${prefix}.provenance.${field}`, digest) : [`${prefix}.provenance.${field} must be a digest`]));
1763
+ }
1764
+ }
1765
+ return issues;
1766
+ }
1767
+
1768
+ /**
1769
+ * Parses one decision file exactly as it sits in the checkout. A file with an unresolved merge
1770
+ * conflict, a body that is not one JSON object, a value that breaks the contract, a basename that
1771
+ * is not its own recommendation id, or bytes that are not the canonical serialization is refused
1772
+ * with every reason; nothing is repaired.
1773
+ *
1774
+ * The one tolerated difference is CRLF line endings: a checkout with `core.autocrlf` (Windows)
1775
+ * turns every LF of a committed canonical file into CRLF, so the body is compared after rewriting
1776
+ * only `\r\n` pairs to `\n` — the projection digest's rule. A lone CR, or any other byte that
1777
+ * differs from the canonical serialization, still fails closed.
1778
+ */
1779
+ export function parseRefactorDecisionFile(path: string, body: string): RefactorDecisionV1 {
1780
+ const match = REFACTOR_DECISION_FILE_PATTERN.exec(path);
1781
+ if (!match) throw new Error(`refactor-decision-path-invalid: ${path} must be ${REFACTOR_DECISION_DIRECTORY}/recommendation.<16-hex>.json`);
1782
+ const normalized = body.replaceAll("\r\n", "\n");
1783
+ if (GIT_CONFLICT_MARKER_PATTERN.test(normalized)) throw new Error(`refactor-decision-conflicted: ${path} carries Git conflict markers; resolve the merge first`);
1784
+ let value: unknown;
1785
+ try {
1786
+ value = JSON.parse(normalized);
1787
+ } catch (error) {
1788
+ throw new Error(`refactor-decision-malformed: ${path} is not JSON (${error instanceof Error ? error.message : String(error)})`);
1789
+ }
1790
+ const issues = refactorDecisionInvariantIssues(value, path);
1791
+ if (issues.length > 0) throw new Error(`refactor-decision-invalid: ${issues.join("; ")}`);
1792
+ const decision = value as RefactorDecisionV1;
1793
+ if (decision.recommendationId !== match[1]) {
1794
+ throw new Error(`refactor-decision-invalid: ${path} must be named after its recommendationId ${decision.recommendationId}`);
1795
+ }
1796
+ if (serializeRefactorDecision(decision) !== normalized) {
1797
+ throw new Error(`refactor-decision-noncanonical: ${path} is not the canonical serialization the daemon writes; decision files are written only by archctx recommendations <verb>`);
1798
+ }
1799
+ return decision;
1800
+ }
1801
+
1802
+ function decisionSubjectIssues(subject: unknown, label: string): string[] {
1803
+ if (!isPlainObject(subject) || Object.keys(subject).sort().join(",") !== "selectorKind,stableKey,subjectSelectorId") {
1804
+ return [`${label} must carry exactly selectorKind, stableKey and subjectSelectorId`];
1805
+ }
1806
+ const issues: string[] = [];
1807
+ if (!REFACTOR_DECISION_SELECTOR_KINDS.includes(subject.selectorKind as ArchitectureSubjectSelectorKind)) {
1808
+ issues.push(`${label}.selectorKind must be node or repository`);
1809
+ }
1810
+ issues.push(...boundedLineIssues(`${label}.stableKey`, subject.stableKey, REFACTOR_DECISION_STABLE_KEY_MAX_LENGTH));
1811
+ if (typeof subject.subjectSelectorId !== "string" || !/^subject\.(node|repository)\.[a-f0-9]{16}$/.test(subject.subjectSelectorId)) {
1812
+ issues.push(`${label}.subjectSelectorId must be a refactor subject selector id`);
1813
+ }
1814
+ return issues;
1815
+ }
1816
+
1817
+ function boundedLineIssues(label: string, value: unknown, maxLength: number): string[] {
1818
+ if (typeof value !== "string" || value.trim() === "" || value !== value.trim()) {
1819
+ return [`${label} must be a non-empty string without leading or trailing whitespace`];
1820
+ }
1821
+ if (value.length > maxLength) return [`${label} must be at most ${maxLength} characters`];
1822
+ return /[\u0000-\u001f\u007f]/.test(value) ? [`${label} must be one line without control characters`] : [];
1823
+ }
1824
+
1825
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
1826
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1827
+ }
package/src/schema.ts CHANGED
@@ -11,9 +11,11 @@ export type Json =
11
11
  export type Severity = "notice" | "warning" | "error" | "critical";
12
12
 
13
13
  export type ArchContextErrorCode =
14
+ | "AC_UNCLASSIFIED_FAILURE"
14
15
  | "AC_REPO_NOT_FOUND"
15
16
  | "AC_RUNTIME_UNAVAILABLE"
16
17
  | "AC_RUNTIME_VERSION_UNSUPPORTED"
18
+ | "AC_NODE_VERSION_UNSUPPORTED"
17
19
  | "AC_CODEGRAPH_UNAVAILABLE"
18
20
  | "AC_CODE_FACTS_UNAVAILABLE"
19
21
  | "AC_INDEX_STALE"
@@ -57,9 +59,13 @@ export interface JsonEnvelope<T extends Json = Json> {
57
59
  }
58
60
 
59
61
  export const ERROR_CATALOG: Record<ArchContextErrorCode, Omit<ArchContextError, "message">> = {
62
+ AC_UNCLASSIFIED_FAILURE: { code: "AC_UNCLASSIFIED_FAILURE", severity: "error", retryable: false, action: "inspect-diagnostics" },
60
63
  AC_REPO_NOT_FOUND: { code: "AC_REPO_NOT_FOUND", severity: "error", retryable: false, action: "open-correct-repository" },
61
64
  AC_RUNTIME_UNAVAILABLE: { code: "AC_RUNTIME_UNAVAILABLE", severity: "error", retryable: true, action: "start-or-reconnect-runtime" },
62
65
  AC_RUNTIME_VERSION_UNSUPPORTED: { code: "AC_RUNTIME_VERSION_UNSUPPORTED", severity: "error", retryable: true, action: "upgrade-archctx-runtime" },
66
+ // Not retryable: the same Node binary fails the same way until the caller switches to a version
67
+ // inside ARCHCONTEXT_NODE_RANGE (`error.details` carries both).
68
+ AC_NODE_VERSION_UNSUPPORTED: { code: "AC_NODE_VERSION_UNSUPPORTED", severity: "error", retryable: false, action: "use-supported-node-version" },
63
69
  AC_CODEGRAPH_UNAVAILABLE: { code: "AC_CODEGRAPH_UNAVAILABLE", severity: "error", retryable: true, action: "run-diagnostics" },
64
70
  AC_CODE_FACTS_UNAVAILABLE: { code: "AC_CODE_FACTS_UNAVAILABLE", severity: "error", retryable: true, action: "codegraph-init" },
65
71
  AC_INDEX_STALE: { code: "AC_INDEX_STALE", severity: "warning", retryable: true, action: "sync-codefacts" },