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