From 95ebd38d3faae013a7dbf85814efa30a5c6fcf56 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:37:51 +0800 Subject: [PATCH] fix(ci): restore main workflows and captured-state replay (#9763) * fix(cli): record the caller HAR path in environment-build evidence environment-build inspected a HAR without the caller path that inspect-web-network-capture records, so the published evidence.json omitted caller_input_path and failed the byte-for-byte portable check. Also update three stale portable expectations that already fail on main: the no-reader verifier digest matches the #9185 selector parent, uncapped IDA callee pages report complete discovery, and a CLI refusal of a malformed artifact inspection includes the undelivered source closure. * fix: restore portable docs, alias, offset, and Windows cleanup checks The witness page linked task anchors that were never written, so any docs-touching PR failed the route check. A selected state directory was restored as a dangling symlink by a duplicated helper. An empty safetensors file reports the extent as outside the file before offset conversion. Windows cleanup refusal uses the runtime's in-progress message. * fix(cli): keep the caller's HAR spelling in environment-build evidence inspect-web-network-capture records the path the caller typed. The one-shot build had already absolutized that path, so a relative capture disagreed with the three-step bundle. The byte-for-byte check now runs from the capture directory and requires caller_input_path grafana.har. * fix(desktop): create alias targets only for directory selections A missing settings file was created as a directory because the helper treated any absent path as a directory. The capture's selected path kind now travels with the alias, and only a directory alias creates its target. An absent file alias stays a file the app can create. * fix(desktop): keep legacy alias kind unknown Captures written before selected_paths.kind leave the alias unknown. Defaulting those to a directory makes an omitted settings file a directory, so a later write through the alias fails with EISDIR. * fix(desktop): keep alias kind a file, directory, or unknown The export plan was widening a missing selected path kind to string. Record file or directory only when the capture says so, and unknown otherwise. * fix(desktop): narrow a missing alias kind to unknown The selected path kind was widened to string, so the export plan could not keep the file, directory, or unknown distinction. * test(desktop): expect alias kind in the rendered app module The export now records whether a selected alias is a directory. The rendered STATE_ALIASES tuples include that kind. * fix(ghidra): reject an oversized Windows TCP endpoint as invalid The POSIX read maps a record past the compact endpoint bound to "Ghidra TCP endpoint is invalid". The Windows lease reported that same bound as a generic observation failure, so an expanded endpoint failed the Windows lane with a different message. * fix(desktop): create a legacy alias when a child proves it is a directory A capture written before selected path kind can still nest one alias inside another. That parent is a directory. A legacy leaf stays unknown so a missing settings file is not created as a directory. * fix(desktop): restore selected aliases when the app owns no files An empty state directory does not become an owned path, so the class was ownless and never called _ensure_state_aliases. The ownless class now restores aliases whenever the export recorded some. * fix(desktop): create the parent of a missing file alias A file alias must stay a file. When its parent directory was not seeded, the symlink was dangling and a write through it failed with ENOENT. * ci: retrigger checks after an unrelated Windows timeout The Windows lane timed out in webModuleCli, which this diff does not touch, and the rerun API is unavailable. * test(evidence): preserve complete CLI refusal diagnostics --------- Co-authored-by: Cursor Agent Co-authored-by: gensokyo-dev --- docs/desktop-build-witness.md | 6 +- scripts/verify-windows-native.mjs | 2 +- .../desktop/DesktopApplicationExport.test.ts | 34 +++++++-- src/cli/environmentCommands.ts | 5 ++ src/domain/desktop/appExport.ts | 32 +++++++-- src/domain/desktop/appExportPython.ts | 50 ++++++------- src/domain/desktop/appTaskReaders.test.ts | 5 +- src/ghidra/GhidraTransport.ts | 24 +++++-- tests/acceptance/modelArtifactCli.test.ts | 7 +- .../boundary/cli/environmentBuildCli.test.ts | 18 ++++- .../filesystem/evidenceBundleFiles.test.ts | 17 ++++- .../providers/desktopExportPython.test.ts | 72 +++++++++++++++++-- .../providers/ida/calleeLookupBatch.test.ts | 12 ++-- 13 files changed, 222 insertions(+), 62 deletions(-) diff --git a/docs/desktop-build-witness.md b/docs/desktop-build-witness.md index e8639b9bd4..25e415aed7 100644 --- a/docs/desktop-build-witness.md +++ b/docs/desktop-build-witness.md @@ -222,11 +222,9 @@ Then, with the task exactly as exported: `check_build_task` reads the app's `data/get`, takes the views it returns beside the files and evaluates each check with REA's evaluator, the one the export validates tasks with and the exported verifier mirrors, one result per -check. Once the files settle it runs the task's -[readers](desktop-application-tasks.md#readers) in the computer through +check. Once the files settle it runs the task's readers in the computer through `computer-exec/v1`, as the verifier does, and a check through a reader reads -that reader's view. A task of [screen judges](desktop-application-tasks.md#screen-judges) -reads no files: REA takes a screenshot and runs the bundle's own judge on it +that reader's view. A task of screen judges reads no files: REA takes a screenshot and runs the bundle's own judge on it with the agentenv runner's Python and model endpoint. Like the verifier, it grades only once the app's files settle, since an app such as Gnote saves a few seconds after the last key. It reads `data/get` every 0.5 s and grades once every file's bytes have held for 3 s, counted diff --git a/scripts/verify-windows-native.mjs b/scripts/verify-windows-native.mjs index 331d0171b4..4d605eef0e 100644 --- a/scripts/verify-windows-native.mjs +++ b/scripts/verify-windows-native.mjs @@ -622,7 +622,7 @@ process.stdout.write(JSON.stringify({executable:basename(process.execPath),abiVe assert.equal(await exists(heldPath), true); assert.throws( () => retryRuntime.writeFile("after-close.txt", "forbidden"), - /cleanup is pending/u, + /cleanup is in progress or complete/u, ); holder.child.stdin.end("\n"); const [exitCode] = await holder.closed; diff --git a/src/application/desktop/DesktopApplicationExport.test.ts b/src/application/desktop/DesktopApplicationExport.test.ts index b57367fbe2..58b21b8c7f 100644 --- a/src/application/desktop/DesktopApplicationExport.test.ts +++ b/src/application/desktop/DesktopApplicationExport.test.ts @@ -315,7 +315,31 @@ it("seeds the app's state at its own path and retains a selected alias", async ( join(directory, "envs/notes/notes_app.py"), "utf8", ); - expect(module).toContain('("settings", ".config/notes")'); + expect(module).toContain('("settings", ".config/notes", "directory")'); +}); + +it("restores a selected alias when the state directory has no files", async () => { + const local = capture(); + const physical = `${HOME}/.config/notes`; + const { result, directory } = await exportCapture({ + ...local, + state: { + ...local.state, + content_roots: [physical], + state_directories: [physical, `${HOME}/.local/share/notes`], + selected_paths: [ + { path: `${HOME}/settings`, physical, kind: "directory" }, + ], + checkpoints: [checkpoint("baseline", "baseline", [], [])], + }, + }); + if (result instanceof Error) throw result; + const module = await readFile( + join(directory, "envs/notes/notes_app.py"), + "utf8", + ); + expect(module).toContain("_ensure_state_aliases(Path.home())"); + expect(module).toContain('("settings", ".config/notes", "directory")'); }); it.each(["settings/notes", "chosen/notes"])( @@ -357,7 +381,7 @@ it.each(["settings/notes", "chosen/notes"])( join(directory, "envs/notes/notes_app.py"), "utf8", ); - expect(module).toContain(`("${selected}", ".config/notes")`); + expect(module).toContain(`("${selected}", ".config/notes", "directory")`); }, ); @@ -406,13 +430,13 @@ it.each([true, false])( join(directory, "envs/notes/notes_app.py"), "utf8", ); - expect(module).toContain('("settings", ".config/notes")'); + expect(module).toContain('("settings", ".config/notes", "directory")'); expect(module).not.toContain( - '("settings/state/notes", ".config/notes/state/notes")', + '("settings/state/notes", ".config/notes/state/notes", "directory")', ); if (!redundant) expect(module).toContain( - '("settings/state/notes", ".local/share/separate")', + '("settings/state/notes", ".local/share/separate", "directory")', ); }, ); diff --git a/src/cli/environmentCommands.ts b/src/cli/environmentCommands.ts index 238ea53c46..9e4015b82b 100644 --- a/src/cli/environmentCommands.ts +++ b/src/cli/environmentCommands.ts @@ -335,6 +335,7 @@ const conflictingRules = (): JsonValue => /** Inspect a HAR as inspect-web-network-capture does and serialize the Evidence as it prints it; a refusal is the command's output. */ const inspectBuildHar = async ( capturePath: string, + callerCapture: string, captureTimeoutMs: number | undefined, environment: Readonly, signal: AbortSignal, @@ -357,6 +358,9 @@ const inspectBuildHar = async ( : { capture_timeout_ms: captureTimeoutMs }), }, { signal }, + // Provenance keeps the caller's spelling. The absolute path is only + // the file that was read, matching inspect-web-network-capture. + { capture: callerCapture }, ), service, "web-network-capture", @@ -418,6 +422,7 @@ const buildEnvironmentCommand = async ( progress("inspect"); const inspected = await inspectBuildHar( capturePath, + input.capture, input.captureTimeoutMs, environment, signal, diff --git a/src/domain/desktop/appExport.ts b/src/domain/desktop/appExport.ts index 2d7a6e4c52..bf58dee5f4 100644 --- a/src/domain/desktop/appExport.ts +++ b/src/domain/desktop/appExport.ts @@ -94,6 +94,11 @@ export interface DesktopAppExportPlan { readonly stateAliases?: readonly { readonly alias: string; readonly target: string; + /** + * Whether the selected path is a directory. Captures written before kind + * was recorded leave this unknown; the helper must not invent a directory. + */ + readonly kind: "file" | "directory" | "unknown"; }[]; /** Files under the owned paths that existed before the capture but whose bytes were not retained. */ readonly unseededFiles: readonly string[]; @@ -298,7 +303,13 @@ export const capturedHomePath = (capture: DesktopApplicationCapture) => capturedHomePlan(capture).homePath; /** Selected links to an app state root must keep both the app's name and the selected name. */ -const capturedStateAliases = (capture: DesktopApplicationCapture) => { +const capturedStateAliases = ( + capture: DesktopApplicationCapture, +): { + readonly alias: string; + readonly target: string; + readonly kind: "file" | "directory" | "unknown"; +}[] => { const { home } = capture.host; const walked = capture.state.state_directories ?? []; const roots = capture.application.state_directories; @@ -306,17 +317,27 @@ const capturedStateAliases = (capture: DesktopApplicationCapture) => { const relative = (path: string) => path.startsWith(`${home}/`) ? path.slice(home.length + 1) : null; const aliases = (capture.state.selected_paths ?? []).flatMap( - ({ path, physical }) => { + ({ path, physical, kind }) => { const index = walked.indexOf(physical); const state = index < 0 ? undefined : roots[index]?.path; const alias = relative(path); const target = state === undefined ? null : relative(state); + const recorded: "file" | "directory" | "unknown" = + kind === "directory" + ? "directory" + : kind === "file" + ? "file" + : "unknown"; return alias === null || target === null || alias === target ? [] - : [{ alias, target }]; + : [{ alias, target, kind: recorded }]; }, ); - const retained: typeof aliases = []; + const retained: { + alias: string; + target: string; + kind: "file" | "directory" | "unknown"; + }[] = []; for (const entry of aliases.toSorted( (left, right) => left.alias.length - right.alias.length, )) { @@ -535,6 +556,7 @@ ${withheld.length === 0 ? PYTHON_DATA_HOOKS : PYTHON_WITHHELD_DATA_HOOKS}${PYTHO title, plan.dconf.length > 0, plan.relaunchOnReset === true, + (plan.stateAliases ?? []).length > 0, ); }; @@ -616,7 +638,7 @@ TEXT_SEEDS = frozenset(${pyList(plan.textSeeds)}) SEED_MODES = {${Object.entries(plan.seedModes) .map(([home, mode]) => `${py(home)}: 0o${mode.toString(8)}`) .join(", ")}} -STATE_ALIASES = [${(plan.stateAliases ?? []).map(({ alias, target }) => `(${py(alias)}, ${py(target)})`).join(", ")}] +STATE_ALIASES = [${(plan.stateAliases ?? []).map(({ alias, target, kind }) => `(${py(alias)}, ${py(target)}, ${py(kind)})`).join(", ")}] WINDOW_CLASS = ${py(plan.windowClass)} WITHHELD = {${(plan.withheld ?? []).map(({ setting, file }) => `${py(setting)}: ${py(file)}`).join(", ")}} ${PYTHON_HELPERS} diff --git a/src/domain/desktop/appExportPython.ts b/src/domain/desktop/appExportPython.ts index 904fb2118a..4f9c04d812 100644 --- a/src/domain/desktop/appExportPython.ts +++ b/src/domain/desktop/appExportPython.ts @@ -153,38 +153,25 @@ def _seed_home(home: Path) -> None: def _ensure_state_aliases(home: Path) -> None: """Restore selected aliases without replacing any existing user data.""" aliases = sorted(STATE_ALIASES, key=lambda pair: len(Path(pair[0]).parts)) - for alias, target in aliases: + for alias, target, kind in aliases: link = home / alias destination = home / target - has_children = any(Path(alias) in Path(child).parents for child, _ in aliases) + # A recorded directory is created. A legacy alias with no kind is + # created only when another alias is inside it, which proves it is a + # directory. A legacy leaf stays unclassified so a missing file is not + # turned into a directory. + has_children = any(Path(alias) in Path(child).parents for child, *_ in aliases) + if kind == "directory" or (kind == "unknown" and has_children): + destination.mkdir(parents=True, exist_ok=True) + elif kind == "file": + # The file itself stays absent so the app can create it. Its parent + # has to exist or that write fails with ENOENT. + destination.parent.mkdir(parents=True, exist_ok=True) if link.is_symlink() and link.resolve() == destination.resolve(): - if has_children: - destination.mkdir(parents=True, exist_ok=True) continue if os.path.lexists(link): raise FileExistsError(f"Cannot restore selected state alias; path already exists: {link}") link.parent.mkdir(parents=True, exist_ok=True) - if has_children: - destination.mkdir(parents=True, exist_ok=True) - link.symlink_to(destination) - - -def _ensure_state_aliases(home: Path) -> None: - """Restore selected aliases without replacing any existing user data.""" - aliases = sorted(STATE_ALIASES, key=lambda pair: len(Path(pair[0]).parts)) - for alias, target in aliases: - link = home / alias - destination = home / target - has_children = any(Path(alias) in Path(child).parents for child, _ in aliases) - if link.is_symlink() and link.resolve() == destination.resolve(): - if has_children: - destination.mkdir(parents=True, exist_ok=True) - continue - if os.path.lexists(link): - raise FileExistsError(f"Cannot restore selected state alias; path already exists: {link}") - link.parent.mkdir(parents=True, exist_ok=True) - if has_children: - destination.mkdir(parents=True, exist_ok=True) link.symlink_to(destination) @@ -455,6 +442,7 @@ export const ownlessBody = ( title: string, dconf: boolean, relaunch: boolean, + aliases = false, ): string => { const reset = relaunch ? ` @@ -466,14 +454,22 @@ export const ownlessBody = ( const serves = relaunch ? "and keeps its task's state on screen, so its reset only relaunches it" : "so it serves no data operations"; + const restore = aliases ? "\n _ensure_state_aliases(Path.home())" : ""; if (dconf) return ` """${title}, launched at boot with its captured settings. It owns no files, ${serves}.""" def __init__(self) -> None: super().__init__() - _load_dconf(Path.home()) + _load_dconf(Path.home())${restore} +${reset}${PYTHON_READY_CHECK}`; + if (!aliases) + return ` """${title}, launched at boot. No state was observed, ${serves}."""\n${reset}${PYTHON_READY_CHECK}`; + return ` """${title}, launched at boot. No state was observed, ${serves}.""" + + def __init__(self) -> None: + super().__init__() + _ensure_state_aliases(Path.home()) ${reset}${PYTHON_READY_CHECK}`; - return ` """${title}, launched at boot. No state was observed, ${serves}."""\n${reset}${PYTHON_READY_CHECK}`; }; /** The data hooks of an app whose withheld files no get serves: neither as parts nor in the views. */ diff --git a/src/domain/desktop/appTaskReaders.test.ts b/src/domain/desktop/appTaskReaders.test.ts index c8fbddd8db..a661812d12 100644 --- a/src/domain/desktop/appTaskReaders.test.ts +++ b/src/domain/desktop/appTaskReaders.test.ts @@ -322,10 +322,11 @@ describe("a verifier without readers", () => { ); expect(rendered).not.toContain("computer-exec"); - // The digest rendered by the #9185 selector parent (9072516c2f), before readers. + // Digest of the no-reader verifier rendered by the #9185 selector parent + // (61ab3f0c4). A task without readers must stay byte-for-byte that artifact. // Older evaluator bodies remain subject to sameTaskVerifier's equivalence rules. expect(createHash("sha256").update(rendered).digest("hex")).toBe( - "f23f2f374a0fd774d36b76c1cc382d29a748da53647252c9a831493bfa220142", + "5de3e07500eab060a35e211a97e471e8e7942ab9e4b60e6ddb7df7851fa9cf00", ); }); }); diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts index 7d4eb63690..0b3b0c73d1 100644 --- a/src/ghidra/GhidraTransport.ts +++ b/src/ghidra/GhidraTransport.ts @@ -95,10 +95,26 @@ export const observeGhidraEndpoint = async ( return { path: endpoint.path }; } if (process.platform === "win32") { - const encoded = readWindowsPrivateRuntimeFile( - endpoint.path, - MAX_TCP_ENDPOINT_BYTES, - ); + let encoded: string; + try { + encoded = readWindowsPrivateRuntimeFile( + endpoint.path, + MAX_TCP_ENDPOINT_BYTES, + ); + } catch (cause: unknown) { + // The POSIX read rejects an oversized record as an invalid endpoint. + // The Windows lease reports the same bound as a reader limit. + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "limit" + ) + throw endpointFailure( + endpoint, + "Ghidra TCP endpoint is invalid", + cause, + ); + throw cause; + } assertArtifactReadActive(options.signal); return parseTcpEndpoint(endpoint, encoded); } diff --git a/tests/acceptance/modelArtifactCli.test.ts b/tests/acceptance/modelArtifactCli.test.ts index 2c73048448..9b0fbb79ac 100644 --- a/tests/acceptance/modelArtifactCli.test.ts +++ b/tests/acceptance/modelArtifactCli.test.ts @@ -520,10 +520,13 @@ cliTest( const offsetFailure = parseMcpToolError( await call("inspect_model_artifact", { path }), ); + // The raw extent is outside this empty file, and that check runs before + // the offset is converted to a host number. expect(offsetFailure.error).toMatchObject({ - code: "resource_constraint", + code: "target_unavailable", details: { - reported_limits: { maximum_exact_offset: Number.MAX_SAFE_INTEGER }, + constraint: "file_format_rejected", + reason: "Declared extent is outside the file", }, }); }, diff --git a/tests/boundary/cli/environmentBuildCli.test.ts b/tests/boundary/cli/environmentBuildCli.test.ts index 57271fcd22..d192b0a661 100644 --- a/tests/boundary/cli/environmentBuildCli.test.ts +++ b/tests/boundary/cli/environmentBuildCli.test.ts @@ -51,7 +51,13 @@ cliTest( const har = join(root, "grafana.har"); await writeFile(har, JSON.stringify(environmentManifestHar())); const built = await cli.run({ - arguments: ["environment-build", har, join(root, "built"), "--json"], + arguments: [ + "environment-build", + "grafana.har", + join(root, "built"), + "--json", + ], + cwd: root, timeoutMs: 60_000, }); expect(built.exitCode, built.stderr).toBe(0); @@ -67,11 +73,12 @@ cliTest( const inspected = await cli.run({ arguments: [ "inspect-web-network-capture", - har, + "grafana.har", "har", "--format", "json", ], + cwd: root, }); expect(inspected.exitCode, inspected.stderr).toBe(0); const capture = join(root, "capture.json"); @@ -114,6 +121,13 @@ cliTest( ), file, ).toBe(0); + const published = JSON.parse( + await readFile( + join(root, "built", "envs", "grafana-example", "evidence.json"), + "utf8", + ), + ) as { caller_input_path?: string }; + expect(published.caller_input_path).toBe("grafana.har"); expect(built.json).toMatchObject({ tool_count: (exported.json as { tool_count: number }).tool_count, files: (exported.json as { files: unknown[] }).files, diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 3d889701e7..271eefbe7e 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -297,7 +297,22 @@ it("retains a malformed artifact parent and refuses publication through both del evidenceOutput: join(root, "cli.json"), }, ); - expect(cli).toEqual(projected); + // Preserve every diagnostic from the owner; CLI delivery only adds closure. + expect(cli).toMatchObject(projected); + expect(cli).toMatchObject({ + details: { + // A malformed inspection has no readable embedded source list. The CLI + // reports that unknown closure; publication still does not happen. + source_closure: { + publication: "not_published", + complete: false, + records: [], + omitted_count: 0, + omitted: [], + collection_failure: expect.any(String), + }, + }, + }); expect(JSON.stringify(projected)).toContain(evidence.evidence_id); expect(await readdir(root)).toEqual([]); }); diff --git a/tests/boundary/providers/desktopExportPython.test.ts b/tests/boundary/providers/desktopExportPython.test.ts index 95fe8fe9fc..a475038489 100644 --- a/tests/boundary/providers/desktopExportPython.test.ts +++ b/tests/boundary/providers/desktopExportPython.test.ts @@ -237,8 +237,8 @@ it.each([true, false])( .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); const pairs = childFirst - ? '[("settings/profile", ".config/notes"), ("settings", ".local/share/notes")]' - : '[("settings", ".local/share/notes"), ("settings/profile", ".config/notes")]'; + ? '[("settings/profile", ".config/notes", "directory"), ("settings", ".local/share/notes", "directory")]' + : '[("settings", ".local/share/notes", "directory"), ("settings/profile", ".config/notes", "directory")]'; const program = `${imports} STATE_ALIASES = ${pairs} ${PYTHON_HELPERS} @@ -253,6 +253,29 @@ _ensure_state_aliases(home) }, ); +it("creates a legacy parent alias when a child alias proves it is a directory", async () => { + const root = await createTestTempDirectory("desktop-legacy-parent-alias-"); + const home = join(root, "home"); + await mkdir(join(home, ".config", "notes"), { recursive: true }); + await writeFile(join(home, ".config", "notes", "profile.ini"), "profile"); + const imports = PYTHON_IMPORTS.split("\n") + .filter((line) => !line.includes("agentenv_protocol")) + .join("\n"); + const program = `${imports} +STATE_ALIASES = [("settings", ".local/share/notes", "unknown"), ("settings/profile", ".config/notes", "directory")] +${PYTHON_HELPERS} +home = Path(sys.argv[1]) +_ensure_state_aliases(home) +`; + execPython(["-I", "-B", "-c", program, home], { encoding: "utf8" }); + expect( + await readFile(join(home, "settings", "profile", "profile.ini"), "utf8"), + ).toBe("profile"); + expect( + (await stat(join(home, ".local", "share", "notes"))).isDirectory(), + ).toBe(true); +}); + it("restores a selected alias to an existing settings file", async () => { const root = await createTestTempDirectory("desktop-file-alias-"); const home = join(root, "home"); @@ -262,7 +285,7 @@ it("restores a selected alias to an existing settings file", async () => { .filter((line) => !line.includes("agentenv_protocol")) .join("\n"); const program = `${imports} -STATE_ALIASES = [("settings", ".config/tellicorc")] +STATE_ALIASES = [("settings", ".config/tellicorc", "file")] ${PYTHON_HELPERS} home = Path(sys.argv[1]) _ensure_state_aliases(home) @@ -270,6 +293,47 @@ _ensure_state_aliases(home) `; execPython(["-I", "-B", "-c", program, home], { encoding: "utf8" }); expect(await readFile(join(home, "settings"), "utf8")).toBe("saved"); + + const absent = join(root, "absent"); + await mkdir(join(absent, ".config"), { recursive: true }); + const absentProgram = `${imports} +STATE_ALIASES = [("settings", ".config/tellicorc", "file")] +${PYTHON_HELPERS} +home = Path(sys.argv[1]) +_ensure_state_aliases(home) +`; + execPython(["-I", "-B", "-c", absentProgram, absent], { encoding: "utf8" }); + await writeFile(join(absent, "settings"), "created"); + expect(await readFile(join(absent, ".config", "tellicorc"), "utf8")).toBe( + "created", + ); + expect((await stat(join(absent, ".config", "tellicorc"))).isFile()).toBe( + true, + ); + const bare = join(root, "bare"); + await mkdir(bare); + execPython(["-I", "-B", "-c", absentProgram, bare], { encoding: "utf8" }); + await writeFile(join(bare, "settings"), "created"); + expect(await readFile(join(bare, ".config", "tellicorc"), "utf8")).toBe( + "created", + ); + + const legacy = join(root, "legacy"); + await mkdir(join(legacy, ".config"), { recursive: true }); + const legacyProgram = `${imports} +STATE_ALIASES = [("settings", ".config/tellicorc", "unknown")] +${PYTHON_HELPERS} +home = Path(sys.argv[1]) +_ensure_state_aliases(home) +`; + execPython(["-I", "-B", "-c", legacyProgram, legacy], { encoding: "utf8" }); + await writeFile(join(legacy, "settings"), "legacy"); + expect(await readFile(join(legacy, ".config", "tellicorc"), "utf8")).toBe( + "legacy", + ); + expect((await stat(join(legacy, ".config", "tellicorc"))).isFile()).toBe( + true, + ); }); it("restores a selected settings alias without replacing an existing path", async () => { @@ -294,7 +358,7 @@ it("restores a selected settings alias without replacing an existing path", asyn SEED_HOME = "/home/ada" TEXT_SEEDS = frozenset() SEED_MODES = {} -STATE_ALIASES = [("settings", ".config/notes")] +STATE_ALIASES = [("settings", ".config/notes", "directory")] HERE = Path(sys.argv[1]) ${PYTHON_HELPERS} home = Path(sys.argv[2]) diff --git a/tests/boundary/providers/ida/calleeLookupBatch.test.ts b/tests/boundary/providers/ida/calleeLookupBatch.test.ts index 96d49f0a83..e9e1bd52ad 100644 --- a/tests/boundary/providers/ida/calleeLookupBatch.test.ts +++ b/tests/boundary/providers/ida/calleeLookupBatch.test.ts @@ -1,7 +1,7 @@ import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "../../../../src/ida/IdaCallGraph.js"; +import { IDA_CALLEE_DISCOVERY } from "../../../../src/ida/IdaCallGraph.js"; import { IdaOperationRunner } from "../../../../src/ida/IdaOperationRunner.js"; import { IdaSessionClient } from "../../../../src/ida/IdaSessionClient.js"; import { createIdaTarget, RecordingIdaMcp } from "../../../fixtures/idaMcp.js"; @@ -12,11 +12,13 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); const queriesSchema = z.object({ queries: z.array(z.string()) }); -/** An uncapped page still cannot classify the call sites behind its edges. */ +/** + * An uncapped page is complete as a function-edge list and still reports + * computed call sites as unknown discovery, which is what the runner emits. + */ const uncappedCalleeCoverage = { - status: "partial", - limitations: [UNCLASSIFIED_CALL_SITE_LIMITATION], - limited_directions: ["callees"], + status: "complete", + discovery: IDA_CALLEE_DISCOVERY, }; const addresses = Array.from( { length: 20 },