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