refactor(artifacts): select extraction by logical path

This commit is contained in:
morluto 2026-09-29 10:53:33 +08:00
parent dca293a446
commit 7ba3580c86
7 changed files with 190 additions and 83 deletions

View File

@ -144,23 +144,22 @@ represented by metadata only.
## Bounds and coverage
The input schema bounds artifact entries, cumulative artifact bytes, bytes per
entry, compression ratio, path depth and length, selected text files, text bytes,
AST nodes, static findings, bundle modules, source-map originals, and cooperative
parse time. Source-map original limits are shared across all maps in one
reconstruction. Combined input limits are rejected if their conservative graph
projection could exceed the v1 contract's 100,000 nodes or 200,000 edges.
The reconstruction keeps internal resource safeguards for artifact entries,
cumulative artifact bytes, bytes per entry, compression ratio, path depth and
length, selected text files, text bytes, AST nodes, and cooperative parse time.
These safeguards are not caller-selectable output budgets. Source-map parsing
reports truncation when its format or parser safety boundary is reached. The
application graph has no aggregate node, edge, root, or observation prefix cap.
Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is
checked before and between bounded parsing and traversal phases; the synchronous
Babel and JSON parser calls cannot be preempted mid-call, so their input byte
bounds remain the hard protection for an individual call.
Byte-identical assets share a content-digest node. At most 64 distinct
observations are retained on that node, as required by the graph contract; every
inventoried containment edge remains present, and any omitted observations make
top-level coverage partial and truncated. When an exact omission count is not
knowable, `omitted_count` is `null` rather than a guessed value.
Byte-identical assets share a content-digest node. Every distinct observation
and inventoried containment edge is retained. If parsing or artifact safety
boundaries omit input, coverage reports that omission; when an exact omission
count is not knowable, `omitted_count` is `null` rather than a guessed value.
## Verification boundary

View File

@ -1,4 +1,4 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { isDeepStrictEqual } from "node:util";
import { join } from "node:path";
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
@ -126,37 +126,41 @@ const verifyPackagedArtifactExtraction = async ({
artifactInventory,
cli,
environment,
workspace,
}) => {
const occurrence = artifactInventory?.occurrences?.items?.find(
({ logical_path: path }) => path === "app/main.js",
);
if (occurrence?.occurrence_id === undefined)
if (occurrence?.logical_path === undefined)
throw new Error("packaged artifact inventory omitted the selected member");
const outputRoot = join(workspace, "artifact-extraction");
const extraction = json(
await run(
cli,
[
"extract-artifact",
artifactArchive,
outputRoot,
"--occurrence-ids",
occurrence.occurrence_id,
"--paths",
occurrence.logical_path,
"--json",
],
environment,
),
);
if (
extraction.operation !== "extract_artifact" ||
extraction.provider?.id !== "rea-artifact-graph" ||
extraction.normalized_result?.containment_verified !== true ||
extraction.normalized_result?.artifacts?.items?.[0]?.relative_path !==
"app/main.js" ||
(await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();"
)
throw new Error("packaged artifact extraction CLI failed");
const outputRoot = extraction.normalized_result?.output_root;
try {
if (
extraction.operation !== "extract_artifact" ||
extraction.provider?.id !== "rea-artifact-graph" ||
extraction.normalized_result?.containment_verified !== true ||
extraction.normalized_result?.artifacts?.items?.[0]?.relative_path !==
"app/main.js" ||
typeof outputRoot !== "string" ||
(await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();"
)
throw new Error("packaged artifact extraction CLI failed");
} finally {
if (typeof outputRoot === "string")
await rm(outputRoot, { recursive: true, force: true });
}
};
const assertRoutedApplicationAnalysis = (analysis) => {

View File

@ -34,7 +34,9 @@ export interface ArtifactExtractionInput {
readonly inputPath: string;
readonly inputFormat: BinaryTarget["format"];
readonly outputRoot: string;
readonly occurrenceIds: readonly string[];
readonly selection:
| { readonly paths: readonly string[] }
| { readonly occurrenceIds: readonly string[] };
readonly limits: ArtifactLimits;
}
@ -43,22 +45,34 @@ export const extractArtifact = async (
input: ArtifactExtractionInput,
signal?: AbortSignal,
): Promise<ArtifactExtractionResult> => {
validateSelection(input.occurrenceIds);
validateSelection(input.selection, input.limits);
const sourcePath = await realpath(input.inputPath);
const selectedIds = new Set(input.occurrenceIds);
const inventory = await loadInventory(
sourcePath,
input.limits,
selectedIds,
const snapshot = await scanArtifactInventory(sourcePath, input.limits, {
signal,
});
const selectedOccurrences = resolveSelection(
snapshot.occurrences,
input.selection,
);
const selected = input.occurrenceIds.map((id) => {
const occurrence = inventory.occurrences.get(id);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact occurrence was not found: ${id}`,
);
const selectedIds = new Set(
selectedOccurrences.map(({ occurrence_id: id }) => id),
);
const occurrences = new Map<string, ArtifactOccurrence>();
const neededNodes = new Set<string>();
collectOccurrences(
snapshot.occurrences,
selectedIds,
occurrences,
neededNodes,
);
const nodes = new Map<string, ArtifactNode>();
collectNodes(snapshot.nodes, neededNodes, nodes);
const inventory: LoadedInventory = {
manifest: snapshot.manifest,
occurrences,
nodes,
};
const selected = selectedOccurrences.map((occurrence) => {
if (
(occurrence.entry_kind !== "file" && occurrence.entry_kind !== "slice") ||
occurrence.artifact_id === null ||
@ -67,13 +81,13 @@ export const extractArtifact = async (
)
throw new ArtifactReaderFailure(
"format",
`Selected occurrence is not an extractable regular child file: ${id}`,
`Selected occurrence is not an extractable regular child file: ${occurrence.occurrence_id}`,
);
const node = inventory.nodes.get(occurrence.artifact_id);
if (node === undefined)
throw new ArtifactReaderFailure(
"integrity",
`Selected occurrence has no inventory node: ${id}`,
`Selected occurrence has no inventory node: ${occurrence.occurrence_id}`,
);
return { occurrence, node };
});
@ -214,26 +228,6 @@ interface LoadedInventory {
readonly nodes: ReadonlyMap<string, ArtifactNode>;
}
const loadInventory = async (
path: string,
limits: ArtifactLimits,
selectedIds: ReadonlySet<string>,
signal?: AbortSignal,
): Promise<LoadedInventory> => {
const snapshot = await scanArtifactInventory(path, limits, { signal });
const occurrences = new Map<string, ArtifactOccurrence>();
const neededNodes = new Set<string>();
collectOccurrences(
snapshot.occurrences,
selectedIds,
occurrences,
neededNodes,
);
const nodes = new Map<string, ArtifactNode>();
collectNodes(snapshot.nodes, neededNodes, nodes);
return { manifest: snapshot.manifest, occurrences, nodes };
};
const collectOccurrences = (
items: readonly ArtifactOccurrence[],
selected: ReadonlySet<string>,
@ -278,19 +272,79 @@ const createReader = async (
);
};
const validateSelection = (ids: readonly string[]): void => {
if (ids.length === 0)
type ArtifactSelection = ArtifactExtractionInput["selection"];
const validateSelection = (
selection: ArtifactSelection,
limits: ArtifactLimits,
): void => {
const values =
"paths" in selection ? selection.paths : selection.occurrenceIds;
if (values.length === 0)
throw new ArtifactReaderFailure(
"limit",
"Extraction requires at least one explicitly selected occurrence",
"Extraction requires at least one selected path or occurrence",
);
if (new Set(ids).size !== ids.length)
const normalized =
"paths" in selection
? values.map((path) => normalizeArtifactPath(path, limits))
: values;
if (new Set(normalized).size !== normalized.length)
throw new ArtifactReaderFailure(
"path",
"Extraction occurrence selection contains duplicates",
"Extraction selection contains duplicates",
);
};
const resolveSelection = (
occurrences: readonly ArtifactOccurrence[],
selection: ArtifactSelection,
): ArtifactOccurrence[] => {
if ("occurrenceIds" in selection) {
const byId = new Map(
occurrences.map((occurrence) => [occurrence.occurrence_id, occurrence]),
);
return selection.occurrenceIds.map((id) => {
const occurrence = byId.get(id);
if (occurrence === undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact occurrence was not found: ${id}`,
);
return occurrence;
});
}
const byPath = new Map<string, ArtifactOccurrence[]>();
for (const occurrence of occurrences) {
const matches = byPath.get(occurrence.logical_path);
if (matches === undefined)
byPath.set(occurrence.logical_path, [occurrence]);
else matches.push(occurrence);
}
return selection.paths.map((path) => {
const normalizedPath = path.normalize("NFC");
const matches = byPath.get(normalizedPath) ?? [];
if (matches.length === 0)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact path was not found: ${path}`,
);
if (matches.length > 1)
throw new ArtifactReaderFailure(
"path",
`Selected artifact path is ambiguous: ${path}`,
);
const match = matches[0];
if (match === undefined)
throw new ArtifactReaderFailure(
"unavailable",
`Selected artifact path was not found: ${path}`,
);
return match;
});
};
const preflight = (entry: ArtifactEntry, limits: ArtifactLimits): void => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
throw new ArtifactReaderFailure(

View File

@ -119,7 +119,10 @@ class ArtifactClient implements AnalysisClient {
inputPath: this.target.sourcePath ?? this.target.path,
inputFormat: this.target.format,
outputRoot: parsed.output_root,
occurrenceIds: parsed.occurrence_ids,
selection:
"paths" in parsed
? { paths: parsed.paths }
: { occurrenceIds: parsed.occurrence_ids },
limits: DEFAULT_ARTIFACT_LIMITS,
},
options?.signal,

View File

@ -21,12 +21,24 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
args: z.object({
path: z.string().describe("Application or package path"),
}),
options: z.object({
occurrenceIds: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.describe("Exact artifact occurrence IDs selected for extraction"),
}),
options: z
.object({
paths: z
.array(z.string().min(1))
.min(1)
.optional()
.describe("Logical artifact paths selected for extraction"),
occurrenceIds: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.optional()
.describe("Alternate exact occurrence IDs selected for extraction"),
})
.refine(
({ paths, occurrenceIds }) =>
(paths === undefined) !== (occurrenceIds === undefined),
"Provide paths or occurrence IDs",
),
alias: { occurrenceIds: "occurrence-ids" },
run: ({ args, options }) =>
logCliCommand(logger, "extract-artifact", () =>
@ -35,7 +47,11 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
"extract_artifact",
{
output_root: createArtifactExtractionDestination(),
occurrence_ids: options.occurrenceIds,
...(options.paths !== undefined
? { paths: options.paths }
: options.occurrenceIds !== undefined
? { occurrence_ids: options.occurrenceIds }
: {}),
},
logger,
),

View File

@ -27,19 +27,34 @@ export const artifactInventoryInputSchema = z.union([
]);
/** Exact caller boundary for approved artifact extraction. */
export const artifactExtractionInputSchema = z.object({
occurrence_ids: z.array(z.string().regex(/^occ_[a-f0-9]{64}$/u)).min(1),
const artifactOccurrenceSelectionSchema = z.strictObject({
occurrence_ids: z
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
.min(1)
.describe("Alternate exact occurrence IDs from inspect_artifact"),
});
const artifactPathSelectionSchema = z.strictObject({
paths: z
.array(z.string().min(1))
.min(1)
.describe("Exact logical_path values from inspect_artifact"),
});
export const artifactExtractionInputSchema = z.union([
artifactPathSelectionSchema,
artifactOccurrenceSelectionSchema,
]);
/** Provider input after the local permission boundary chooses its destination. */
export const artifactExtractionExecutionSchema =
artifactExtractionInputSchema.extend({ output_root: z.string().min(1) });
export const artifactExtractionExecutionSchema = z.union([
artifactPathSelectionSchema.extend({ output_root: z.string().min(1) }),
artifactOccurrenceSelectionSchema.extend({ output_root: z.string().min(1) }),
]);
const exampleInputSchema = z.record(z.string(), jsonValueSchema);
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
inspect_artifact: {},
extract_artifact: {
occurrence_ids: [`occ_${"0".repeat(64)}`],
paths: ["Contents/Info.plist"],
},
};
@ -77,7 +92,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
),
artifact(
"extract_artifact",
"Extract selected graph artifacts into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
"Extract selected regular files by logical paths from inspect_artifact (or by occurrence IDs) into a fresh temporary directory chosen by REA. The result includes its location. Rejects traversal and symlink escapes, never overwrites, enforces archive integrity checks, and verifies cleanup.",
artifactExtractionInputSchema,
),
] as const satisfies readonly ToolContract[];

View File

@ -52,7 +52,7 @@ describe("artifact extraction", () => {
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: output,
occurrence_ids: [selected.occurrence_id],
paths: ["assets/selected.js"],
}),
);
expect(result.ok).toBe(true);
@ -75,6 +75,22 @@ describe("artifact extraction", () => {
access(join(output, "assets", "ignored.js")),
).rejects.toThrow();
const traversalOutput = join(root, "traversal-output");
const traversal = await new ArtifactProvider()
.createClient(targetValue)
.execute(
"extract_artifact",
artifactExtractionExecutionSchema.parse({
output_root: traversalOutput,
paths: ["../source/assets/ignored.js"],
}),
);
expect(traversal).toMatchObject({
ok: false,
error: { _tag: "ArtifactOperationError", reason: "path" },
});
await expect(access(traversalOutput)).rejects.toThrow();
const relocatedOutput = join(root, "relocated-output");
const relocated = await new ArtifactProvider()
.createClient(targetValue)