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.
- package/fixtures/invalid/hook-debt-checkpoint-failed-read.json +54 -0
- package/fixtures/invalid/native-context-invalid-model-summary.json +635 -0
- package/fixtures/invalid/native-context-missing-contract-bytes.json +636 -0
- package/fixtures/invalid/native-context-missing-input-bytes.json +636 -0
- package/fixtures/invalid/native-context-mixed-snapshot.json +637 -0
- package/fixtures/invalid/projection-result-applied-incomplete-coverage.json +78 -0
- package/fixtures/invalid/refactor-decision-open-status.json +38 -0
- package/fixtures/valid/archctx-capabilities.json +3 -3
- package/fixtures/valid/capability-dependencies.json +1 -1
- package/fixtures/valid/docs-anchors.json +2 -1
- package/fixtures/valid/flow-selector-check.json +1 -1
- package/fixtures/valid/hook-debt-checkpoint.json +54 -0
- package/fixtures/valid/native-context-export.json +636 -0
- package/fixtures/valid/product-version-manifest.json +8 -8
- package/fixtures/valid/projection-manifest.json +47 -169
- package/fixtures/valid/projection-result.json +5 -5
- package/fixtures/valid/refactor-decision.json +38 -0
- package/fixtures/valid/runtime-recommendation-show.json +1 -0
- package/fixtures/valid/runtime-refactor-scan.json +6 -0
- package/package.json +1 -1
- package/schemas/repo/refactor-decision.schema.json +218 -0
- package/schemas/runtime/archctx-capabilities.schema.json +2 -2
- package/schemas/runtime/architecture-debt.schema.json +222 -0
- package/schemas/runtime/capability-dependencies.schema.json +10 -6
- package/schemas/runtime/changeset.schema.json +24 -1
- package/schemas/runtime/docs-anchors.schema.json +4 -3
- package/schemas/runtime/hook-debt-checkpoint.schema.json +69 -0
- package/schemas/runtime/native-context-export.schema.json +823 -0
- package/schemas/runtime/practice-checkpoint.schema.json +1 -0
- package/schemas/runtime/product-version-manifest.schema.json +2 -2
- package/schemas/runtime/projection-apply-readback.schema.json +470 -7
- package/schemas/runtime/projection-apply-recovery.schema.json +2 -2
- package/schemas/runtime/projection-manifest.schema.json +81 -186
- package/schemas/runtime/projection-result.schema.json +132 -5
- package/schemas/runtime/projection-target.schema.json +4 -1
- package/schemas/runtime/recommendation-v3.schema.json +106 -0
- package/schemas/runtime/review-result.schema.json +1 -0
- package/schemas/runtime/runtime-recommendation-show.schema.json +234 -13
- package/schemas/runtime/runtime-refactor-scan.schema.json +171 -0
- package/schemas/runtime/structural-observation-payload.schema.json +106 -0
- package/src/architecture-debt.ts +115 -0
- package/src/changeset-recovery.ts +49 -0
- package/src/docs-anchors.ts +2 -1
- package/src/failure.ts +776 -0
- package/src/index.ts +6 -0
- package/src/ledger.ts +14 -3
- package/src/native-context.ts +277 -0
- package/src/ownership-dependencies.ts +39 -1
- package/src/practices.ts +2 -0
- package/src/product-version.ts +37 -4
- package/src/projection-manifest.ts +372 -130
- package/src/projection-recovery.ts +111 -0
- package/src/projection.ts +443 -11
- package/src/refactor.ts +525 -2
- package/src/schema.ts +6 -0
- 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
|
-
| ({
|
|
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
|
-
{
|
|
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" },
|