mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 17:30:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd8b36199 | ||
|
|
b191d9b84f | ||
|
|
2eb7fb1c2d |
No files matched your search
@@ -5,8 +5,6 @@ import { expect, it, vi } from "vitest";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { apiContractSchema } from "../../domain/apiContract/apiContract.js";
|
||||
import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { projectAnalysisView } from "../../domain/analysisView/analysisView.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { inspectApiContractEvidence } from "./ApiContractService.js";
|
||||
import * as readOwner from "./ApiContractRead.js";
|
||||
@@ -319,89 +317,3 @@ it("cancels a cooperative document traversal after the actual verified file read
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["between-paths", "after-projection"] as const)(
|
||||
"preserves authenticated OpenAPI facts on cancellation %s",
|
||||
async (stage) => {
|
||||
const bytes = JSON.stringify({
|
||||
openapi: "3.1.2",
|
||||
info: { title: "Cancellation", version: "1" },
|
||||
paths: { "/first": { get: {} }, "/second": { post: {} } },
|
||||
});
|
||||
const { path } = await inputFile(bytes);
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const realParse = parser.parseApiContractSteps;
|
||||
try {
|
||||
const baseline = await inspectApiContractEvidence({ path }, resources);
|
||||
if (!baseline.ok) throw baseline.error;
|
||||
const complete = apiContractSchema.parse(
|
||||
baseline.value.normalized_result,
|
||||
);
|
||||
const scheduled = vi
|
||||
.spyOn(parser, "parseApiContractSteps")
|
||||
.mockImplementationOnce(function* (...args) {
|
||||
const steps = realParse(...args);
|
||||
if (stage === "between-paths") {
|
||||
expect(steps.next().done).toBe(false);
|
||||
expect(steps.next().done).toBe(false);
|
||||
setImmediate(() => controller.abort(stage));
|
||||
return yield* steps;
|
||||
}
|
||||
const result = yield* steps;
|
||||
setImmediate(() => controller.abort(stage));
|
||||
return result;
|
||||
});
|
||||
try {
|
||||
const result = await inspectApiContractEvidence({ path }, resources, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect(result.error.cause).toBe(stage);
|
||||
const evidence = parseEvidence(result.error.partialObservation);
|
||||
const retained = apiContractSchema.parse(evidence.normalized_result);
|
||||
expect(evidence.subject).toEqual(baseline.value.subject);
|
||||
expect(evidence.parameters).toEqual(baseline.value.parameters);
|
||||
expect(retained.artifact).toEqual(complete.artifact);
|
||||
expect(retained.contract_id).toBe(complete.contract_id);
|
||||
if (stage === "between-paths") {
|
||||
expect(retained.operations).toEqual(complete.operations.slice(0, 1));
|
||||
expect(retained.source_coverage.status).toBe("partial");
|
||||
expect(evidence.evidence_id).not.toBe(baseline.value.evidence_id);
|
||||
const page = projectAnalysisView(
|
||||
{
|
||||
evidenceId: evidence.evidence_id,
|
||||
operation: evidence.operation,
|
||||
predicateType: evidence.predicate_type,
|
||||
normalizedResult: evidence.normalized_result,
|
||||
limitations: evidence.limitations,
|
||||
},
|
||||
{
|
||||
kind: "api-contract",
|
||||
collection: "operations",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
},
|
||||
);
|
||||
expect(page).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
parent_evidence_id: evidence.evidence_id,
|
||||
items: [{ path: "/first", state: "declared" }],
|
||||
coverage: { exhausted: true },
|
||||
source_coverage: { status: "partial" },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
expect(retained).toEqual(complete);
|
||||
expect(evidence.evidence_id).toBe(baseline.value.evidence_id);
|
||||
}
|
||||
} finally {
|
||||
scheduled.mockRestore();
|
||||
}
|
||||
} finally {
|
||||
await resources.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -11,8 +11,6 @@ import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
API_CONTRACT_OPERATION as operation,
|
||||
API_CONTRACT_PROVIDER,
|
||||
apiContractSchema,
|
||||
type ApiContract,
|
||||
type ApiContractArtifact,
|
||||
} from "../../domain/apiContract/apiContract.js";
|
||||
import {
|
||||
@@ -72,32 +70,8 @@ function* documentSteps(document: unknown, artifact: ApiContractArtifact) {
|
||||
return yield* parseApiContractSteps(document, artifact);
|
||||
}
|
||||
|
||||
type OpenApiInput = Extract<
|
||||
InspectApiContractInput,
|
||||
{ format: "openapi-json" }
|
||||
>;
|
||||
|
||||
const documentEvidence = (input: OpenApiInput, result: ApiContract): Evidence =>
|
||||
createEvidence(
|
||||
{ path: input.path, format: "file", sha256: result.artifact.sha256 },
|
||||
API_CONTRACT_PROVIDER,
|
||||
{
|
||||
operation,
|
||||
predicateType: "rea.api-contract-inventory",
|
||||
parameters: {
|
||||
path: input.path,
|
||||
format: input.format,
|
||||
max_file_bytes: input.max_file_bytes,
|
||||
},
|
||||
result,
|
||||
confidence: "observed",
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
},
|
||||
);
|
||||
|
||||
const importDocument = async (
|
||||
input: OpenApiInput,
|
||||
input: Extract<InspectApiContractInput, { format: "openapi-json" }>,
|
||||
bytes: Buffer,
|
||||
sha256: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -112,16 +86,26 @@ const importDocument = async (
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
if (!parsed.ok) {
|
||||
const partial = apiContractSchema.safeParse(
|
||||
parsed.error.partialObservation,
|
||||
);
|
||||
if (partial.success)
|
||||
parsed.error.partialObservation = documentEvidence(input, partial.data);
|
||||
return parsed;
|
||||
}
|
||||
if (!parsed.ok) return parsed;
|
||||
const result = parsed.value;
|
||||
return completeProviderEvidence(
|
||||
documentEvidence(input, parsed.value),
|
||||
createEvidence(
|
||||
{ path: input.path, format: "file", sha256 },
|
||||
API_CONTRACT_PROVIDER,
|
||||
{
|
||||
operation,
|
||||
predicateType: "rea.api-contract-inventory",
|
||||
parameters: {
|
||||
path: input.path,
|
||||
format: input.format,
|
||||
max_file_bytes: input.max_file_bytes,
|
||||
},
|
||||
result,
|
||||
confidence: "observed",
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
},
|
||||
),
|
||||
signal,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -260,11 +260,11 @@ describe("source-map artifact relationships", () => {
|
||||
);
|
||||
sourceMap.artifact_id = contentId("3");
|
||||
|
||||
const result = createArtifactEdges(contentId("4"), [
|
||||
directory,
|
||||
...targets,
|
||||
sourceMap,
|
||||
]);
|
||||
const result = createArtifactEdges(
|
||||
contentId("4"),
|
||||
[directory, ...targets, sourceMap],
|
||||
"complete",
|
||||
);
|
||||
const edge = result.edges.find(
|
||||
({ occurrence_id }) => occurrence_id === sourceMap.occurrence_id,
|
||||
);
|
||||
@@ -298,11 +298,11 @@ describe("source-map artifact relationships", () => {
|
||||
);
|
||||
directory.artifact_id = contentId("3");
|
||||
|
||||
const { edges, limitations } = createArtifactEdges(contentId("4"), [
|
||||
archive,
|
||||
script,
|
||||
directory,
|
||||
]);
|
||||
const { edges, limitations } = createArtifactEdges(
|
||||
contentId("4"),
|
||||
[archive, script, directory],
|
||||
"complete",
|
||||
);
|
||||
|
||||
expect(directory).toMatchObject({
|
||||
artifact_kind: "container",
|
||||
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
occurrenceIdForLocation,
|
||||
logicalPathNfc,
|
||||
} from "../../domain/artifactIdentity.js";
|
||||
import type { ArtifactEntry, ArtifactReader } from "../ArtifactReader.js";
|
||||
import type {
|
||||
ArtifactEntry,
|
||||
ArtifactReader,
|
||||
ArtifactReaderTraversal,
|
||||
} from "../ArtifactReader.js";
|
||||
import type {
|
||||
ArtifactCommand,
|
||||
ArtifactEdge,
|
||||
@@ -56,6 +60,9 @@ export interface MutableOccurrence {
|
||||
occurrence_id: string;
|
||||
artifact_id: string | null;
|
||||
parent_occurrence_id: string | null;
|
||||
relationship_coverage?: NonNullable<
|
||||
ArtifactOccurrence["relationship_coverage"]
|
||||
>;
|
||||
logical_path: string;
|
||||
entry_kind: ArtifactOccurrence["entry_kind"];
|
||||
artifact_kind: ArtifactOccurrence["artifact_kind"];
|
||||
@@ -344,6 +351,7 @@ export const rekeyOccurrences = (
|
||||
export const createArtifactEdges = (
|
||||
rootArtifactId: string,
|
||||
occurrences: MutableOccurrence[],
|
||||
traversalStatus: ArtifactReaderTraversal["status"],
|
||||
producer?: ArtifactCommand,
|
||||
): { readonly edges: ArtifactEdge[]; readonly limitations: string[] } => {
|
||||
const byId = new Map(occurrences.map((item) => [item.occurrence_id, item]));
|
||||
@@ -351,12 +359,17 @@ export const createArtifactEdges = (
|
||||
const edges: ArtifactEdge[] = [];
|
||||
const limitations: string[] = [];
|
||||
for (const occurrence of occurrences) {
|
||||
let relationshipComplete =
|
||||
occurrence.logical_path === "." ||
|
||||
(occurrence.parent_occurrence_id !== null &&
|
||||
occurrence.artifact_id !== null);
|
||||
// Use the occurrence's own classification: a directory named `x.map` is
|
||||
// a container, not a source map of its sibling.
|
||||
const isSourceMap = occurrence.artifact_kind === "source-map";
|
||||
const sourcePath = isSourceMap
|
||||
? occurrence.logical_path.slice(0, -".map".length)
|
||||
: undefined;
|
||||
const sourcePath =
|
||||
isSourceMap && occurrence.logical_path !== "."
|
||||
? occurrence.logical_path.slice(0, -".map".length)
|
||||
: undefined;
|
||||
const sourceCandidates =
|
||||
sourcePath === undefined ? [] : (byPath.get(sourcePath) ?? []);
|
||||
const mappedSource =
|
||||
@@ -381,10 +394,15 @@ export const createArtifactEdges = (
|
||||
}
|
||||
: undefined;
|
||||
if (unknownMapping !== undefined) {
|
||||
relationshipComplete = false;
|
||||
occurrence.limitations.push(unknownMapping.occurrence);
|
||||
limitations.push(unknownMapping.coverage);
|
||||
}
|
||||
if (traversalStatus === "unknown") relationshipComplete = false;
|
||||
}
|
||||
occurrence.relationship_coverage = relationshipComplete
|
||||
? "complete"
|
||||
: "unknown";
|
||||
if (
|
||||
occurrence.parent_occurrence_id === null ||
|
||||
occurrence.artifact_id === null
|
||||
@@ -424,15 +442,17 @@ interface ArtifactParentIndex {
|
||||
occurrences?: readonly MutableOccurrence[];
|
||||
}
|
||||
|
||||
export interface ArtifactParentResolution {
|
||||
readonly limitations: readonly string[];
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
}
|
||||
|
||||
/** Resolve strict observed ancestors without rebuilding every path prefix. */
|
||||
export const resolveOccurrenceParents = (
|
||||
occurrences: readonly MutableOccurrence[],
|
||||
byPath: ReadonlyMap<string, readonly MutableOccurrence[]>,
|
||||
expandedContainerIds: ReadonlySet<string>,
|
||||
): {
|
||||
readonly limitations: readonly string[];
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
} => {
|
||||
): ArtifactParentResolution => {
|
||||
const root: ArtifactParentIndex = { children: new Map() };
|
||||
for (const matches of byPath.values()) {
|
||||
const parents = matches.filter(
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
materializeDirectoryNodes,
|
||||
rekeyOccurrences,
|
||||
rootOccurrenceFor,
|
||||
type ArtifactParentResolution,
|
||||
type MutableOccurrence,
|
||||
} from "./ArtifactGraphConstruction.js";
|
||||
import {
|
||||
@@ -160,7 +161,7 @@ const scanCanonical = async (
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
inesRegionScan,
|
||||
unknownParentOccurrences,
|
||||
parentResolution,
|
||||
traversal,
|
||||
rootContradiction,
|
||||
} = await scanReader(
|
||||
@@ -186,7 +187,7 @@ const scanCanonical = async (
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
inesRegionScan,
|
||||
unknownParentOccurrences,
|
||||
parentResolution,
|
||||
traversal,
|
||||
rootContradiction,
|
||||
recognitionLimitations,
|
||||
@@ -220,7 +221,10 @@ const scanCanonical = async (
|
||||
pendingContradictions: [],
|
||||
caseCollisionLimitation: undefined,
|
||||
inesRegionScan: undefined,
|
||||
unknownParentOccurrences: new Set(),
|
||||
parentResolution: {
|
||||
unknownParentOccurrences: new Set(),
|
||||
limitations: [],
|
||||
},
|
||||
rootContradiction: undefined,
|
||||
traversal: {
|
||||
status: "unknown",
|
||||
@@ -423,7 +427,7 @@ interface SnapshotBuildInput {
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly inesRegionScan: InesRegionScan | undefined;
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
readonly parentResolution: ArtifactParentResolution;
|
||||
readonly traversal: ArtifactReaderTraversal;
|
||||
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
|
||||
}
|
||||
@@ -451,7 +455,7 @@ const buildInventorySnapshot = async (
|
||||
for (const occurrence of input.occurrences)
|
||||
if (
|
||||
occurrence.parent_occurrence_id === null &&
|
||||
!input.unknownParentOccurrences.has(occurrence)
|
||||
!input.parentResolution.unknownParentOccurrences.has(occurrence)
|
||||
)
|
||||
occurrence.parent_occurrence_id = rootOccurrence.occurrence_id;
|
||||
input.occurrences.unshift(rootOccurrence);
|
||||
@@ -482,6 +486,7 @@ const buildInventorySnapshot = async (
|
||||
const edgeResult = createArtifactEdges(
|
||||
rootNode.artifact_id,
|
||||
input.occurrences,
|
||||
input.traversal.status,
|
||||
reader?.provenance()[0],
|
||||
);
|
||||
const { edges } = edgeResult;
|
||||
@@ -528,8 +533,12 @@ const buildInventorySnapshot = async (
|
||||
);
|
||||
}
|
||||
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
|
||||
const relationshipLimitations = [
|
||||
...input.parentResolution.limitations,
|
||||
...edgeResult.limitations,
|
||||
];
|
||||
const coverage =
|
||||
edgeResult.limitations.length === 0
|
||||
relationshipLimitations.length === 0
|
||||
? traversal.coverage
|
||||
: { ...traversal.coverage, status: "unknown" as const };
|
||||
return {
|
||||
@@ -544,7 +553,7 @@ const buildInventorySnapshot = async (
|
||||
limitations: buildLimitations(
|
||||
[
|
||||
...traversal.limitations,
|
||||
...edgeResult.limitations,
|
||||
...relationshipLimitations,
|
||||
...(input.recognitionLimitations ?? []),
|
||||
],
|
||||
integrityContradictions,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
noteUnreadContainerSuffix,
|
||||
indexDestinationOccurrencesByPath,
|
||||
resolveOccurrenceParents,
|
||||
type ArtifactParentResolution,
|
||||
type MutableOccurrence,
|
||||
} from "./ArtifactGraphConstruction.js";
|
||||
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
||||
@@ -89,7 +90,7 @@ export const scanReader = async (
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly inesRegionScan: InesRegionScan | undefined;
|
||||
readonly unknownParentOccurrences: ReadonlySet<MutableOccurrence>;
|
||||
readonly parentResolution: ArtifactParentResolution;
|
||||
readonly traversal: ArtifactReaderTraversal;
|
||||
/** A recorded disagreement between the root's own checksum and its bytes. */
|
||||
readonly rootContradiction: ArtifactContainerIntegrityPair | undefined;
|
||||
@@ -102,7 +103,10 @@ export const scanReader = async (
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: undefined,
|
||||
inesRegionScan: undefined,
|
||||
unknownParentOccurrences: new Set(),
|
||||
parentResolution: {
|
||||
unknownParentOccurrences: new Set(),
|
||||
limitations: [],
|
||||
},
|
||||
traversal: { status: "complete", limitations: [] },
|
||||
rootContradiction: undefined,
|
||||
};
|
||||
@@ -134,11 +138,6 @@ export const scanReader = async (
|
||||
byPath,
|
||||
context.expandedContainerIds,
|
||||
);
|
||||
const structuralLimitations = [...parentResolution.limitations];
|
||||
context.traversal.onCoverage({
|
||||
status: structuralLimitations.length === 0 ? "complete" : "unknown",
|
||||
limitations: structuralLimitations,
|
||||
});
|
||||
return {
|
||||
nodes,
|
||||
occurrences,
|
||||
@@ -152,7 +151,7 @@ export const scanReader = async (
|
||||
bytes: context.reconstruction.bytes,
|
||||
sha256: context.reconstruction.hash.digest("hex"),
|
||||
},
|
||||
unknownParentOccurrences: parentResolution.unknownParentOccurrences,
|
||||
parentResolution,
|
||||
traversal: context.traversal.snapshot(),
|
||||
rootContradiction,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ const inventory = (digit: string) => {
|
||||
}),
|
||||
artifact_id: artifactId,
|
||||
parent_occurrence_id: null,
|
||||
relationship_coverage: "complete",
|
||||
logical_path: ".",
|
||||
entry_kind: "file",
|
||||
artifact_kind: "resource",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { PartialTaskMeasurement } from "./desktop/taskDifficulty.js";
|
||||
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
|
||||
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
|
||||
import type { ApiContract } from "./apiContract/apiContract.js";
|
||||
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
|
||||
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
|
||||
import type { AnalysisSettingLimit } from "./analysisSettings.js";
|
||||
@@ -499,7 +498,6 @@ export type AnalysisPartialObservation =
|
||||
}
|
||||
| { readonly kind: "unretained-evidence"; readonly evidence: Evidence }
|
||||
| { readonly kind: "retained-evidence"; readonly evidence_id: string }
|
||||
| ApiContract
|
||||
| GraphqlContract
|
||||
| PlaywrightTrace
|
||||
| SavedAnalysisSnapshotPartialObservation
|
||||
|
||||
@@ -185,7 +185,7 @@ export const projectApiContractView = (
|
||||
unknowns:
|
||||
context.source_coverage.status === "partial"
|
||||
? [
|
||||
"This inventory is partial. Inspect limitations for coverage and query path-item-references and diagnostics for any retained source pointers.",
|
||||
"Unresolved or malformed declarations limit this inventory; query path-item-references and diagnostics for exact source pointers.",
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
import type { AnalysisViewParent } from "./analysisView.js";
|
||||
|
||||
const carrier = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||
const freshInventory = () => {
|
||||
const freshInventory = (version: "current" | "historical" = "current") => {
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
carrier.normalized_result,
|
||||
);
|
||||
const root = inventory.occurrences[0]!;
|
||||
if (version === "historical") delete root.relationship_coverage;
|
||||
const rootNode = inventory.nodes[0]!;
|
||||
root.artifact_kind = "container";
|
||||
root.artifact_format = "zip";
|
||||
@@ -259,6 +260,19 @@ it("does not fabricate a selected field absent from the owning report", () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("reads absent historical relationship coverage as unknown without changing the retained row", () => {
|
||||
const inventory = freezeJsonSnapshot(freshInventory("historical"));
|
||||
expect(
|
||||
page(parent(inventory), {
|
||||
kind: "inventory",
|
||||
collection: "occurrences",
|
||||
fields: ["relationship_coverage"],
|
||||
limit: 1,
|
||||
}).items,
|
||||
).toMatchObject([{ relationship_coverage: "unknown" }]);
|
||||
expect(inventory.occurrences[0]).not.toHaveProperty("relationship_coverage");
|
||||
});
|
||||
|
||||
it("borrows authenticated immutable facts and validates once across retained queries", () => {
|
||||
const inventory = freezeJsonSnapshot(freshInventory());
|
||||
const source = parent(inventory);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
artifactInventoryCoverageSchema,
|
||||
artifactNodeSchema,
|
||||
artifactOccurrenceSchema,
|
||||
artifactOccurrenceRelationshipCoverage,
|
||||
artifactEdgeSchema,
|
||||
integrityContradictionSchema,
|
||||
} from "../artifactGraph.js";
|
||||
@@ -574,6 +575,12 @@ const selectOccurrenceFields = (
|
||||
? row
|
||||
: {
|
||||
...selectFields(row, fields),
|
||||
...(fields.includes("relationship_coverage")
|
||||
? {
|
||||
relationship_coverage:
|
||||
artifactOccurrenceRelationshipCoverage(row),
|
||||
}
|
||||
: {}),
|
||||
occurrence_id: row.occurrence_id,
|
||||
artifact_id: row.artifact_id,
|
||||
parent_occurrence_id: row.parent_occurrence_id,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { apiContractSchema, type ApiContractArtifact } from "./apiContract.js";
|
||||
import { parseApiContractSteps } from "./parseApiContract.js";
|
||||
import { AnalysisCancelledError } from "../analysisErrorCore.js";
|
||||
|
||||
const artifact: ApiContractArtifact = {
|
||||
path: "/contract.json",
|
||||
@@ -306,39 +305,9 @@ it("handles 3.2 QUERY and case-sensitive additional methods, diagnosing invalid
|
||||
|
||||
it("bounds expanded identities and source pointers before building a giant row", () => {
|
||||
const result = parse(
|
||||
document({
|
||||
"/retained": { get: {} },
|
||||
[`/${"a".repeat(1_100_000)}`]: { get: {} },
|
||||
}),
|
||||
document({ [`/${"a".repeat(1_100_000)}`]: { get: {} } }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected a retained-facts refusal");
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
const partial = apiContractSchema.parse(result.error.partialObservation);
|
||||
expect(partial.operations.map((row) => row.path)).toEqual(["/retained"]);
|
||||
expect(partial.source_coverage.status).toBe("partial");
|
||||
});
|
||||
|
||||
it("retains an interpreted path when cancellation interrupts traversal", () => {
|
||||
const input = document({
|
||||
"/first": { get: { operationId: "first" } },
|
||||
"/second": { get: { operationId: "second" } },
|
||||
});
|
||||
const steps = parseApiContractSteps(input, artifact);
|
||||
for (let checkpoint = 0; checkpoint < 3; checkpoint++)
|
||||
expect(steps.next().done).toBe(false);
|
||||
const cancellation = new AnalysisCancelledError("inspect_api_contract");
|
||||
const stopped = steps.throw(cancellation);
|
||||
if (!stopped.done || stopped.value.ok)
|
||||
throw new Error("Expected cancellation");
|
||||
expect(stopped.value.error).toBe(cancellation);
|
||||
const partial = apiContractSchema.parse(cancellation.partialObservation);
|
||||
const complete = report(input);
|
||||
expect(partial.operations).toEqual(complete.operations.slice(0, 1));
|
||||
expect(partial.summary.operation_slots).toBe(1);
|
||||
expect(partial.source_coverage).toMatchObject({
|
||||
status: "partial",
|
||||
path_entries_examined: 1,
|
||||
});
|
||||
expect(complete.source_coverage.status).toBe("complete-within-scope");
|
||||
if (!result.ok)
|
||||
expect(result.error._tag).toBe("AnalysisResourceConstraintError");
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
@@ -317,17 +316,14 @@ function* pathItem(
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
interface ContractReportContext {
|
||||
readonly openapi: string;
|
||||
readonly info: ApiContract["info"];
|
||||
readonly pathsState: ApiContract["source_coverage"]["paths_state"];
|
||||
examined: number;
|
||||
}
|
||||
|
||||
const contractReport = (
|
||||
const completedInventory = (
|
||||
inventory: ContractInventory,
|
||||
context: ContractReportContext,
|
||||
incomplete = false,
|
||||
context: {
|
||||
openapi: string;
|
||||
info: ApiContract["info"];
|
||||
pathsState: ApiContract["source_coverage"]["paths_state"];
|
||||
examined: number;
|
||||
},
|
||||
): ApiContract => {
|
||||
const artifact = inventory.artifact;
|
||||
let declared = 0;
|
||||
@@ -352,9 +348,7 @@ const contractReport = (
|
||||
source_coverage: {
|
||||
scope: "inline-path-operations",
|
||||
status:
|
||||
incomplete ||
|
||||
inventory.references.length > 0 ||
|
||||
inventory.diagnostics.length > 0
|
||||
inventory.references.length > 0 || inventory.diagnostics.length > 0
|
||||
? "partial"
|
||||
: "complete-within-scope",
|
||||
paths_state: context.pathsState,
|
||||
@@ -364,11 +358,6 @@ const contractReport = (
|
||||
duplicate_json_members: "last-value-wins",
|
||||
},
|
||||
limitations: [
|
||||
...(incomplete
|
||||
? [
|
||||
"Traversal stopped before the inventory was complete. Counts describe the retained rows.",
|
||||
]
|
||||
: []),
|
||||
"Operations are document declarations, not evidence of runtime implementation, reachability or authorization.",
|
||||
"Only inline operations under paths are inventoried; callbacks, webhooks, components, servers, parameters, schemas, request/response contracts and security are not analyzed.",
|
||||
"No reference is resolved, including local fragments. A Path Item reference makes its effective operations unknown; adjacent inline slots are preserved without merging referenced definitions.",
|
||||
@@ -379,32 +368,6 @@ const contractReport = (
|
||||
};
|
||||
};
|
||||
|
||||
function* collectPaths(
|
||||
sourcePaths: Record<string, unknown>,
|
||||
inventory: ContractInventory,
|
||||
context: ContractReportContext,
|
||||
modern: boolean,
|
||||
): Generator<void, Result<void, AnalysisError>> {
|
||||
for (const path in sourcePaths) {
|
||||
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
|
||||
yield;
|
||||
context.examined++;
|
||||
// Reserve expanded pointer/path strings before escaping or building a row.
|
||||
const admitted = inventory.reserve(512 + path.length * 16);
|
||||
if (!admitted.ok) return admitted;
|
||||
const pointer = `/paths/${pointerPart(path)}`;
|
||||
const read = path.startsWith("/")
|
||||
? yield* pathItem(sourcePaths[path], { inventory, path, pointer, modern })
|
||||
: inventory.diagnostic(
|
||||
"invalid-path-key",
|
||||
pointer,
|
||||
"A Paths entry must start with /; it was not interpreted as an endpoint.",
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
/** Enumerate declarations only; never resolve references, servers, callbacks or runtime expressions. */
|
||||
export function* parseApiContractSteps(
|
||||
value: unknown,
|
||||
@@ -454,32 +417,36 @@ export function* parseApiContractSteps(
|
||||
);
|
||||
if (!diagnosed.ok) return diagnosed;
|
||||
}
|
||||
const context: ContractReportContext = {
|
||||
openapi: root.openapi,
|
||||
info: info.value,
|
||||
pathsState,
|
||||
examined: 0,
|
||||
};
|
||||
const interrupted = (error: AnalysisError) => {
|
||||
const retainedRows =
|
||||
inventory.operations.length +
|
||||
inventory.references.length +
|
||||
inventory.diagnostics.length;
|
||||
if (retainedRows > 0)
|
||||
error.retainPartialObservation(contractReport(inventory, context, true));
|
||||
return err(error);
|
||||
};
|
||||
try {
|
||||
const read = yield* collectPaths(
|
||||
paths ?? {},
|
||||
inventory,
|
||||
context,
|
||||
version[1] === "2",
|
||||
);
|
||||
if (!read.ok) return interrupted(read.error);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) return interrupted(cause);
|
||||
throw cause;
|
||||
let examined = 0;
|
||||
const sourcePaths = paths ?? {};
|
||||
for (const path in sourcePaths) {
|
||||
if (!Object.hasOwn(sourcePaths, path) || path.startsWith("x-")) continue;
|
||||
yield;
|
||||
examined++;
|
||||
// Reserve expanded pointer/path strings before escaping or building a row.
|
||||
const admitted = inventory.reserve(512 + path.length * 16);
|
||||
if (!admitted.ok) return admitted;
|
||||
const pointer = `/paths/${pointerPart(path)}`;
|
||||
const read = path.startsWith("/")
|
||||
? yield* pathItem(sourcePaths[path], {
|
||||
inventory,
|
||||
path,
|
||||
pointer,
|
||||
modern: version[1] === "2",
|
||||
})
|
||||
: inventory.diagnostic(
|
||||
"invalid-path-key",
|
||||
pointer,
|
||||
"A Paths entry must start with /; it was not interpreted as an endpoint.",
|
||||
);
|
||||
if (!read.ok) return read;
|
||||
}
|
||||
return ok(contractReport(inventory, context));
|
||||
return ok(
|
||||
completedInventory(inventory, {
|
||||
openapi: root.openapi,
|
||||
info: info.value,
|
||||
pathsState,
|
||||
examined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -11,27 +11,18 @@ import {
|
||||
type ArtifactOccurrence,
|
||||
} from "./artifactGraph.js";
|
||||
import {
|
||||
artifactEdgeId,
|
||||
artifactGraphDigest,
|
||||
artifactIdForContent,
|
||||
artifactManifestId,
|
||||
occurrenceIdForLocation,
|
||||
} from "./artifactIdentity.js";
|
||||
import { createEvidence } from "./evidence.js";
|
||||
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
|
||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||
|
||||
/** Recommit observation variants so comparisons exercise valid Evidence carriers. */
|
||||
const commit = (inventory: ArtifactInventoryResult) => {
|
||||
const graphSha256 = artifactGraphDigest({
|
||||
nodes: inventory.nodes,
|
||||
occurrences: inventory.occurrences,
|
||||
edges: inventory.edges,
|
||||
contradictions: inventory.integrity_contradictions,
|
||||
});
|
||||
inventory.manifest.graph_sha256 = graphSha256;
|
||||
inventory.manifest.manifest_id = artifactManifestId(
|
||||
inventory.manifest.root_artifact_id,
|
||||
graphSha256,
|
||||
);
|
||||
return createEvidence(
|
||||
const inventoryCarrier = (inventory: ArtifactInventoryResult) =>
|
||||
createEvidence(
|
||||
{
|
||||
path: "packing-observation",
|
||||
sha256: inventory.manifest.root_sha256,
|
||||
@@ -46,8 +37,210 @@ const commit = (inventory: ArtifactInventoryResult) => {
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
);
|
||||
|
||||
/** Recommit observation variants so comparisons exercise valid Evidence carriers. */
|
||||
const commit = (inventory: ArtifactInventoryResult) => {
|
||||
const graphSha256 = artifactGraphDigest({
|
||||
nodes: inventory.nodes,
|
||||
occurrences: inventory.occurrences,
|
||||
edges: inventory.edges,
|
||||
contradictions: inventory.integrity_contradictions,
|
||||
});
|
||||
inventory.manifest.graph_sha256 = graphSha256;
|
||||
inventory.manifest.manifest_id = artifactManifestId(
|
||||
inventory.manifest.root_artifact_id,
|
||||
graphSha256,
|
||||
);
|
||||
return inventoryCarrier(inventory);
|
||||
};
|
||||
|
||||
const relationInventory = (
|
||||
secondContent = "2",
|
||||
firstRelation: ArtifactInventoryResult["edges"][number]["relation"] = "contains",
|
||||
) => {
|
||||
const inventory = artifactInventoryResultSchema.parse(
|
||||
ARTIFACT_COMPARISON_EXAMPLE.left.normalized_result,
|
||||
);
|
||||
const root = inventory.occurrences[0]!;
|
||||
const rootNode = inventory.nodes[0]!;
|
||||
const rootId = inventory.manifest.root_artifact_id;
|
||||
const members = [
|
||||
{ path: "one.js", digit: "1", relation: firstRelation },
|
||||
{ path: "two.js", digit: secondContent, relation: "contains" },
|
||||
] as const;
|
||||
for (const [ordinal, { path, digit, relation }] of members.entries()) {
|
||||
const sha256 = digit.repeat(64);
|
||||
const artifactId = artifactIdForContent(sha256);
|
||||
const occurrence: ArtifactOccurrence = {
|
||||
...root,
|
||||
occurrence_id: occurrenceIdForLocation({
|
||||
rootArtifactId: rootId,
|
||||
logicalPath: path,
|
||||
entryKind: "file",
|
||||
}),
|
||||
artifact_id: artifactId,
|
||||
parent_occurrence_id: root.occurrence_id,
|
||||
logical_path: path,
|
||||
};
|
||||
const edge = {
|
||||
ordinal,
|
||||
parent_artifact_id: rootId,
|
||||
relation,
|
||||
occurrence_id: occurrence.occurrence_id,
|
||||
producer: null,
|
||||
};
|
||||
inventory.nodes.push({ ...rootNode, artifact_id: artifactId, sha256 });
|
||||
inventory.occurrences.push(occurrence);
|
||||
inventory.edges.push({
|
||||
...edge,
|
||||
edge_id: artifactEdgeId(edge, occurrence),
|
||||
});
|
||||
}
|
||||
inventory.nodes.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.artifact_id, right.artifact_id),
|
||||
);
|
||||
inventory.edges.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.edge_id, right.edge_id),
|
||||
);
|
||||
inventory.coverage = { scope: "container-entries", status: "complete" };
|
||||
inventory.manifest.node_count = inventory.nodes.length;
|
||||
inventory.manifest.occurrence_count = inventory.occurrences.length;
|
||||
inventory.manifest.edge_count = inventory.edges.length;
|
||||
return { inventory, evidence: commit(inventory) };
|
||||
};
|
||||
|
||||
it.each(["left", "right"] as const)(
|
||||
"does not infer a relation change from an omitted edge on the %s",
|
||||
(partialSide) => {
|
||||
const { inventory, evidence } = relationInventory();
|
||||
const omitted = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "one.js",
|
||||
)!;
|
||||
const page = inventoryCarrier({
|
||||
...inventory,
|
||||
edges: inventory.edges.filter(
|
||||
({ occurrence_id }) => occurrence_id !== omitted.occurrence_id,
|
||||
),
|
||||
});
|
||||
const result =
|
||||
partialSide === "left"
|
||||
? compareArtifacts(page, evidence)
|
||||
: compareArtifacts(evidence, page);
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
summary: { changed: 0 },
|
||||
changes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "one.js",
|
||||
classification: "unknown",
|
||||
dimensions: ["availability"],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(compareArtifacts(evidence, evidence)).toMatchObject({
|
||||
status: "unchanged",
|
||||
changes: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a proven content change with incomplete relation observations", () => {
|
||||
const left = relationInventory().evidence;
|
||||
const { inventory } = relationInventory("3");
|
||||
const result = compareArtifacts(
|
||||
left,
|
||||
inventoryCarrier({ ...inventory, edges: inventory.edges.slice(0, 1) }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
changes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "two.js",
|
||||
classification: "changed",
|
||||
dimensions: ["content", "availability"],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer a relation change when a retained edge's logical parent is omitted", () => {
|
||||
const { inventory, evidence } = relationInventory();
|
||||
const result = compareArtifacts(
|
||||
evidence,
|
||||
inventoryCarrier({
|
||||
...inventory,
|
||||
occurrences: inventory.occurrences.slice(1),
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
summary: { changed: 0 },
|
||||
changes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "one.js",
|
||||
classification: "unknown",
|
||||
dimensions: ["availability"],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("compares complete relations when an unrelated node is omitted", () => {
|
||||
const left = relationInventory().evidence;
|
||||
const { inventory } = relationInventory("2", "embeds");
|
||||
const unrelated = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "two.js",
|
||||
)!;
|
||||
const result = compareArtifacts(
|
||||
left,
|
||||
inventoryCarrier({
|
||||
...inventory,
|
||||
nodes: inventory.nodes.filter(
|
||||
({ artifact_id }) => artifact_id !== unrelated.artifact_id,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
status: "truncated",
|
||||
changes: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "one.js",
|
||||
classification: "changed",
|
||||
dimensions: ["relations"],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a historical edge and its Evidence while reporting omitted coverage as unknown", () => {
|
||||
const { inventory, evidence } = relationInventory();
|
||||
const oldInventory = structuredClone(inventory);
|
||||
const occurrence = oldInventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "one.js",
|
||||
)!;
|
||||
delete occurrence.relationship_coverage;
|
||||
const historical = commit(oldInventory);
|
||||
const parsed = parseArtifactInventoryEvidence(historical);
|
||||
expect(parsed.evidence[0]).toEqual(historical);
|
||||
expect(parsed.inventory.manifest).toEqual(oldInventory.manifest);
|
||||
expect(parsed.inventory.edges).toEqual(oldInventory.edges);
|
||||
expect(
|
||||
parsed.inventory.occurrences.find(
|
||||
({ occurrence_id }) => occurrence_id === occurrence.occurrence_id,
|
||||
),
|
||||
).not.toHaveProperty("relationship_coverage");
|
||||
expect(compareArtifacts(historical, evidence)).toMatchObject({
|
||||
status: "unknown",
|
||||
changes: [
|
||||
{
|
||||
logical_path: "one.js",
|
||||
classification: "unknown",
|
||||
dimensions: ["availability"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const observation = (
|
||||
facts: Pick<ArtifactOccurrence, "hash_status"> & Partial<ArtifactOccurrence>,
|
||||
changedContent = false,
|
||||
@@ -303,6 +496,7 @@ const recordObservation = (
|
||||
}),
|
||||
artifact_id: artifactId,
|
||||
parent_occurrence_id: root.occurrence_id,
|
||||
relationship_coverage: artifactId === null ? "unknown" : "complete",
|
||||
logical_path: "record",
|
||||
entry_kind: "metadata",
|
||||
artifact_kind: "package-metadata",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
ArtifactInventoryResult,
|
||||
ArtifactNode,
|
||||
ArtifactOccurrence,
|
||||
import {
|
||||
artifactOccurrenceRelationshipCoverage,
|
||||
type ArtifactInventoryResult,
|
||||
type ArtifactNode,
|
||||
type ArtifactOccurrence,
|
||||
} from "./artifactGraph.js";
|
||||
import { AnalysisInputError } from "./analysisErrorCore.js";
|
||||
import {
|
||||
@@ -256,8 +257,8 @@ const compareOccurrences = (
|
||||
const rightNodes = new Map(
|
||||
right.nodes.map((item) => [item.artifact_id, item]),
|
||||
);
|
||||
const leftRelations = relationsByPath(left);
|
||||
const rightRelations = relationsByPath(right);
|
||||
const leftRelationsFor = relationsForOccurrence(left);
|
||||
const rightRelationsFor = relationsForOccurrence(right);
|
||||
const paths = [
|
||||
...new Set([...leftByPath.keys(), ...rightByPath.keys()]),
|
||||
].sort(compareUnicodeCodePoints);
|
||||
@@ -302,8 +303,10 @@ const compareOccurrences = (
|
||||
rightOccurrence,
|
||||
leftNode: nodeFor(leftOccurrence, leftNodes),
|
||||
rightNode: nodeFor(rightOccurrence, rightNodes),
|
||||
leftRelations: leftRelations.get(path) ?? [],
|
||||
rightRelations: rightRelations.get(path) ?? [],
|
||||
relations: compareRelations(
|
||||
leftRelationsFor(leftOccurrence),
|
||||
rightRelationsFor(rightOccurrence),
|
||||
),
|
||||
rootCovered: coverage.root,
|
||||
entriesCovered: coverage.entries,
|
||||
evidenceLinks,
|
||||
@@ -338,8 +341,7 @@ interface ClassifyPathInput {
|
||||
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
||||
readonly leftNode: ArtifactNode | undefined;
|
||||
readonly rightNode: ArtifactNode | undefined;
|
||||
readonly leftRelations: readonly RelationProjection[];
|
||||
readonly rightRelations: readonly RelationProjection[];
|
||||
readonly relations: FactComparison;
|
||||
readonly rootCovered: boolean;
|
||||
readonly entriesCovered: boolean;
|
||||
readonly evidenceLinks: readonly string[];
|
||||
@@ -420,7 +422,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => {
|
||||
if (known.kind) dimensions.push("kind");
|
||||
if (known.format) dimensions.push("format");
|
||||
if (known.executable) dimensions.push("executable");
|
||||
if (known.relations) dimensions.push("relations");
|
||||
if (input.relations === "changed") dimensions.push("relations");
|
||||
if (known.metadata || declaredDigestsDiffer(input))
|
||||
dimensions.push("metadata");
|
||||
dimensions.push("availability");
|
||||
@@ -438,6 +440,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => {
|
||||
declarationUnknown(input.metadata) ||
|
||||
occurrence.kindUnknown ||
|
||||
occurrence.formatUnknown ||
|
||||
input.relations === "unknown" ||
|
||||
differsWithUnknown(input.leftNode?.format, input.rightNode?.format) ||
|
||||
differsWithMissing(
|
||||
input.leftNode?.media_type,
|
||||
@@ -466,20 +469,17 @@ type OccurrencePair = Pick<
|
||||
"leftOccurrence" | "rightOccurrence"
|
||||
>;
|
||||
|
||||
type MetadataComparison = "unchanged" | "changed" | "unknown";
|
||||
type FactComparison = "unchanged" | "changed" | "unknown";
|
||||
|
||||
interface OccurrenceMetadata {
|
||||
/** ASAR storage is an occurrence field, not format metadata. */
|
||||
readonly asarPacking: MetadataComparison;
|
||||
readonly asarPacking: FactComparison;
|
||||
/** Every occurrence records `encrypted`, so a pair's comparison is known. */
|
||||
readonly encryption: Exclude<MetadataComparison, "unknown">;
|
||||
readonly encryption: Exclude<FactComparison, "unknown">;
|
||||
/** Absent when neither occurrence records a compared declaration. */
|
||||
readonly formatDeclaration: MetadataComparison | "absent";
|
||||
readonly formatDeclaration: FactComparison | "absent";
|
||||
/** Unknown when either side could not read a compared field. */
|
||||
readonly formatDeclarationAvailability: Exclude<
|
||||
MetadataComparison,
|
||||
"changed"
|
||||
>;
|
||||
readonly formatDeclarationAvailability: Exclude<FactComparison, "changed">;
|
||||
}
|
||||
|
||||
/** A declaration that differs even when content is unavailable. */
|
||||
@@ -513,8 +513,6 @@ const occurrenceDifferences = (input: {
|
||||
readonly metadata: OccurrenceMetadata;
|
||||
readonly leftOccurrence: ArtifactOccurrence | undefined;
|
||||
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
||||
readonly leftRelations: readonly RelationProjection[];
|
||||
readonly rightRelations: readonly RelationProjection[];
|
||||
}) => ({
|
||||
kind:
|
||||
input.leftOccurrence?.artifact_kind !==
|
||||
@@ -540,24 +538,15 @@ const occurrenceDifferences = (input: {
|
||||
),
|
||||
executable:
|
||||
input.leftOccurrence?.executable !== input.rightOccurrence?.executable,
|
||||
// A missing content identity prevents inventory from emitting its edges.
|
||||
// An empty projection in that case is unknown, not evidence of no relation.
|
||||
relations:
|
||||
input.leftOccurrence?.artifact_id != null &&
|
||||
input.rightOccurrence?.artifact_id != null &&
|
||||
JSON.stringify(input.leftRelations) !==
|
||||
JSON.stringify(input.rightRelations),
|
||||
metadata:
|
||||
declaredFactChanged(input.metadata) ||
|
||||
input.leftOccurrence?.entry_kind !== input.rightOccurrence?.entry_kind ||
|
||||
JSON.stringify(input.leftOccurrence?.limitations) !==
|
||||
JSON.stringify(input.rightOccurrence?.limitations),
|
||||
input.leftOccurrence?.entry_kind !== input.rightOccurrence?.entry_kind,
|
||||
});
|
||||
|
||||
const compareAsarPacking = (
|
||||
left: boolean | undefined,
|
||||
right: boolean | undefined,
|
||||
): MetadataComparison =>
|
||||
): FactComparison =>
|
||||
left === right
|
||||
? "unchanged"
|
||||
: left === undefined || right === undefined
|
||||
@@ -682,8 +671,7 @@ const changedDimensions = (input: {
|
||||
readonly rightNode: ArtifactNode | undefined;
|
||||
readonly leftOccurrence: ArtifactOccurrence | undefined;
|
||||
readonly rightOccurrence: ArtifactOccurrence | undefined;
|
||||
readonly leftRelations: readonly RelationProjection[];
|
||||
readonly rightRelations: readonly RelationProjection[];
|
||||
readonly relations: FactComparison;
|
||||
}): z.infer<typeof comparisonDimensionSchema>[] => {
|
||||
const left = input.leftNode;
|
||||
const right = input.rightNode;
|
||||
@@ -703,7 +691,7 @@ const changedDimensions = (input: {
|
||||
],
|
||||
["size", left.size !== right.size],
|
||||
["executable", occurrence.executable],
|
||||
["relations", occurrence.relations],
|
||||
["relations", input.relations === "changed"],
|
||||
["metadata", occurrence.metadata || metadataChanged(left, right)],
|
||||
];
|
||||
return checks
|
||||
@@ -716,8 +704,7 @@ const metadataChanged = (left: ArtifactNode, right: ArtifactNode): boolean =>
|
||||
!differsWithMissing(left.media_type, right.media_type)) ||
|
||||
(left.architecture !== right.architecture &&
|
||||
!differsWithMissing(left.architecture, right.architecture)) ||
|
||||
left.content_state !== right.content_state ||
|
||||
JSON.stringify(left.limitations) !== JSON.stringify(right.limitations);
|
||||
left.content_state !== right.content_state;
|
||||
|
||||
const nodeFor = (
|
||||
occurrence: ArtifactOccurrence | undefined,
|
||||
@@ -727,10 +714,16 @@ const nodeFor = (
|
||||
? undefined
|
||||
: nodes.get(occurrence.artifact_id);
|
||||
|
||||
const relationsByPath = (
|
||||
/** An empty projection means observed absence. Missing edges or parent facts leave it unavailable. */
|
||||
const relationsForOccurrence = (
|
||||
inventory: InventorySet,
|
||||
): ReadonlyMap<string, readonly RelationProjection[]> => {
|
||||
): ((
|
||||
occurrence: ArtifactOccurrence | undefined,
|
||||
) => readonly RelationProjection[] | undefined) => {
|
||||
if (inventory.edges.length !== inventory.manifest.edge_count)
|
||||
return () => undefined;
|
||||
const output = new Map<string, RelationProjection[]>();
|
||||
const unresolvedPaths = new Set<string>();
|
||||
const occurrences = new Map(
|
||||
inventory.occurrences.map((item) => [item.occurrence_id, item]),
|
||||
);
|
||||
@@ -739,13 +732,17 @@ const relationsByPath = (
|
||||
// An incomplete inventory can omit the occurrence that names the child.
|
||||
if (occurrence?.artifact_id === null || occurrence === undefined) continue;
|
||||
const path = occurrence.logical_path;
|
||||
const parent =
|
||||
occurrence.parent_occurrence_id === null
|
||||
? null
|
||||
: occurrences.get(occurrence.parent_occurrence_id);
|
||||
if (parent === undefined) {
|
||||
unresolvedPaths.add(path);
|
||||
continue;
|
||||
}
|
||||
const values = output.get(path);
|
||||
const projection: RelationProjection = {
|
||||
parent_logical_path:
|
||||
occurrence.parent_occurrence_id === null
|
||||
? null
|
||||
: (occurrences.get(occurrence.parent_occurrence_id)?.logical_path ??
|
||||
null),
|
||||
parent_logical_path: parent?.logical_path ?? null,
|
||||
// Containment roots are path-normalized to avoid graph-wide deltas, but
|
||||
// a source map's target is a semantic content identity, not its parent
|
||||
// directory. Preserve that identity in the relation comparison.
|
||||
@@ -763,7 +760,23 @@ const relationsByPath = (
|
||||
values.sort((left, right) =>
|
||||
compareUnicodeCodePoints(canonicalJson(left), canonicalJson(right)),
|
||||
);
|
||||
return output;
|
||||
return (occurrence) => {
|
||||
if (occurrence?.artifact_id == null) return undefined;
|
||||
if (artifactOccurrenceRelationshipCoverage(occurrence) === "unknown")
|
||||
return undefined;
|
||||
const path = occurrence.logical_path;
|
||||
return unresolvedPaths.has(path) ? undefined : (output.get(path) ?? []);
|
||||
};
|
||||
};
|
||||
|
||||
const compareRelations = (
|
||||
left: readonly RelationProjection[] | undefined,
|
||||
right: readonly RelationProjection[] | undefined,
|
||||
): FactComparison => {
|
||||
if (left === undefined || right === undefined) return "unknown";
|
||||
return JSON.stringify(left) === JSON.stringify(right)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
};
|
||||
|
||||
interface RelationProjection {
|
||||
|
||||
@@ -173,6 +173,8 @@ export const artifactOccurrenceSchema = z.object({
|
||||
occurrence_id: occurrenceIdSchema,
|
||||
artifact_id: artifactIdSchema.nullable(),
|
||||
parent_occurrence_id: occurrenceIdSchema.nullable(),
|
||||
/** Producer coverage of this occurrence's modeled relationships. Historical omission is unknown. */
|
||||
relationship_coverage: z.enum(["complete", "unknown"]).exactOptional(),
|
||||
logical_path: boundedRelativePathSchema,
|
||||
entry_kind: z.enum(["file", "directory", "symlink", "slice", "metadata"]),
|
||||
artifact_kind: artifactKindSchema,
|
||||
@@ -317,6 +319,12 @@ export type ArtifactEdge = z.infer<typeof artifactEdgeSchema>;
|
||||
|
||||
/** Parsed artifact occurrence. */
|
||||
export type ArtifactOccurrence = z.infer<typeof artifactOccurrenceSchema>;
|
||||
|
||||
/** Read relationship coverage without changing authenticated historical rows. */
|
||||
export const artifactOccurrenceRelationshipCoverage = (
|
||||
occurrence: ArtifactOccurrence,
|
||||
) => occurrence.relationship_coverage ?? "unknown";
|
||||
|
||||
export type IntegrityContradiction = z.infer<
|
||||
typeof integrityContradictionSchema
|
||||
>;
|
||||
|
||||
@@ -570,6 +570,45 @@ describe("artifact comparison completeness", () => {
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)(
|
||||
"retains an observed map edge without certifying completeness after an omitted subtree",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-map-omitted-subtree-");
|
||||
const omitted = join(root, "omitted");
|
||||
await mkdir(omitted);
|
||||
await Promise.all([
|
||||
writeFile(join(root, "main.js"), "source();"),
|
||||
writeFile(join(root, "main.js.map"), "{}"),
|
||||
writeFile(join(omitted, "member.txt"), "unexamined member"),
|
||||
]);
|
||||
const complete = await observe(root);
|
||||
await chmod(omitted, 0);
|
||||
const partial = await observe(root).finally(() => chmod(omitted, 0o700));
|
||||
const { inventory } = parseArtifactInventoryEvidence(partial);
|
||||
const map = inventory.occurrences.find(
|
||||
({ logical_path }) => logical_path === "main.js.map",
|
||||
);
|
||||
expect(inventory.coverage.status).toBe("unknown");
|
||||
expect(inventory.occurrences).not.toContainEqual(
|
||||
expect.objectContaining({ logical_path: "omitted/member.txt" }),
|
||||
);
|
||||
expect(inventory.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
occurrence_id: map?.occurrence_id,
|
||||
relation: "maps-source",
|
||||
}),
|
||||
);
|
||||
expect(map).toHaveProperty("relationship_coverage", "unknown");
|
||||
expect(compareArtifacts(complete, partial).changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js.map",
|
||||
classification: "unknown",
|
||||
dimensions: ["availability"],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe("artifact source-map relationship coverage", () => {
|
||||
it("reports when an unchanged map points to changed source content", async () => {
|
||||
const root = await createTestTempDirectory("rea-map-target-change-");
|
||||
@@ -617,11 +656,18 @@ describe("artifact source-map relationship coverage", () => {
|
||||
it("preserves unknown source-map relationships for duplicate ZIP paths", async () => {
|
||||
const root = await createTestTempDirectory("rea-duplicate-source-map-");
|
||||
const path = join(root, "duplicate-source-map.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("main.js", new TextReader("first();"), { level: 0 });
|
||||
await writer.add("your.js", new TextReader("second();"), { level: 0 });
|
||||
await writer.add("main.js.map", new TextReader("{}"), { level: 0 });
|
||||
const bytes = Buffer.from(await writer.close());
|
||||
const uniquePath = join(root, "unique-source-map.zip");
|
||||
const zip = async (otherSource: string) => {
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("main.js", new TextReader("first();"), { level: 0 });
|
||||
await writer.add("your.js", new TextReader("second();"), { level: 0 });
|
||||
await writer.add("main.js.map", new TextReader("{}"), { level: 0 });
|
||||
await writer.add("other.js", new TextReader(otherSource), { level: 0 });
|
||||
await writer.add("other.js.map", new TextReader("{}"), { level: 0 });
|
||||
return Buffer.from(await writer.close());
|
||||
};
|
||||
const bytes = await zip("before();");
|
||||
await writeFile(uniquePath, await zip("after();"));
|
||||
for (const [signature, headerSize] of [
|
||||
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
||||
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
||||
@@ -660,6 +706,29 @@ describe("artifact source-map relationship coverage", () => {
|
||||
limitation.toLowerCase().includes("integrity"),
|
||||
),
|
||||
).toBe(false);
|
||||
const unique = await observe(uniquePath);
|
||||
for (const [left, right] of [
|
||||
[unique, evidence],
|
||||
[evidence, unique],
|
||||
] as const) {
|
||||
expect(compareArtifacts(left, right).changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
logical_path: "main.js.map",
|
||||
classification: "unknown",
|
||||
dimensions: ["availability"],
|
||||
left_artifact_id: map.artifact_id,
|
||||
right_artifact_id: map.artifact_id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
logical_path: "other.js.map",
|
||||
classification: "changed",
|
||||
dimensions: ["relations"],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
expect(map).toHaveProperty("relationship_coverage", "unknown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -669,11 +738,15 @@ describe("duplicate archive occurrence ownership", () => {
|
||||
"rea-artifact-duplicate-directory-",
|
||||
);
|
||||
const path = join(root, "duplicate-directories.zip");
|
||||
const uniquePath = join(root, "unique-directories.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("x/", new TextReader(""));
|
||||
await writer.add("y/", new TextReader(""));
|
||||
await writer.add("x/file.txt", new TextReader("member"));
|
||||
await writer.add("standalone.js", new TextReader("source();"));
|
||||
await writer.add("standalone.js.map", new TextReader("{}"));
|
||||
const bytes = Buffer.from(await writer.close());
|
||||
await writeFile(uniquePath, bytes);
|
||||
for (const [signature, headerSize] of [
|
||||
[Buffer.from([0x50, 0x4b, 0x03, 0x04]), 30],
|
||||
[Buffer.from([0x50, 0x4b, 0x01, 0x02]), 46],
|
||||
@@ -706,6 +779,27 @@ describe("duplicate archive occurrence ownership", () => {
|
||||
'The parent occurrence for "x/file.txt" is unknown because multiple observed directory or container occurrences share "x".',
|
||||
);
|
||||
expect(inventory.coverage.status).toBe("unknown");
|
||||
const unique = await observe(uniquePath);
|
||||
for (const [left, right] of [
|
||||
[unique, evidence],
|
||||
[evidence, unique],
|
||||
] as const)
|
||||
expect(compareArtifacts(left, right).changes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "x/file.txt",
|
||||
classification: "unknown",
|
||||
dimensions: ["availability"],
|
||||
left_artifact_id: child?.artifact_id,
|
||||
right_artifact_id: child?.artifact_id,
|
||||
}),
|
||||
);
|
||||
expect(child).toHaveProperty("relationship_coverage", "unknown");
|
||||
expect(inventory.occurrences).toContainEqual(
|
||||
expect.objectContaining({
|
||||
logical_path: "standalone.js.map",
|
||||
relationship_coverage: "complete",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains duplicate path occurrences across pages and leaves pairing unknown", async () => {
|
||||
|
||||
Reference in new issue
Block a user