diff --git a/.gitignore b/.gitignore index a3939e3..8c8ded1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage/ *.vsix *.tsbuildinfo release/ +extension/.pureflow-runtime/ .env .env.* !.env.example diff --git a/docs/BUILD_LOG.md b/docs/BUILD_LOG.md index 80d113b..10d2838 100644 --- a/docs/BUILD_LOG.md +++ b/docs/BUILD_LOG.md @@ -2,6 +2,16 @@ This is a concise chronological record of material implementation work and runtime evidence. It is not a substitute for Git history; it captures intent, verification, and blockers that a commit alone may not explain. +## 2026-07-31 — R0 deterministic fixture accepted + +- Added the dependency-free `tenant-cache-key` fixture with fixed base, target, and mutated trees; controller-owned harness, oracle, mutation, and known repair; fixed SHA-1 Git identity, timestamps, branch, LF policy, and golden revisions/hashes. +- Added a fixture factory that creates a standalone repository from an empty temp directory, proves base/target/mutation/repair behavior through the pinned runtime, and removes only its validated temp root while preserving the source repository snapshot. +- Added an official Node `v22.17.0` artifact catalog and provisioner for Windows x64 and Linux x64. Download and executable hashes are pinned; the runtime is stored outside Git/VSIX payloads, rehashed on open, executes `--version`, and must not alias `process.execPath`. +- Local Windows evidence: `npm run check` passed, all 27 extension tests passed, production build passed, and the VSIX packaged at 668 KB. Protected PR #10 run `30663623200` then passed `extension`, `extension-windows`, `contract`, `web`, and `jules-rnd-policy`; Linux and Windows both provisioned their pinned standalone runtime and reproduced the fixture behavior. R0 acceptance is complete. +- Refreshed research on cognitive forcing, adaptive support, and AI productivity. Added agent near-miss replay, evidence escrow, decision-stage rotation, control reserve, and a deferred learned autonomy policy as falsifiable concepts. None is represented as implemented or effective. + +Evidence: `extension/test/fixture.test.ts`, `extension/src/twin/fixture-factory.ts`, `extension/src/twin/fixture-runtime.ts`, `extension/fixture-node-artifacts.json`, local command output on 2026-07-31, and protected GitHub Actions run `30663623200` on PR #10. + ## 2026-07-26 — Control Pulse architecture and R0a foundation - Pivoted the random-function idea into semantic Recall Probes: randomness is allowed only inside a high-value seam set selected by blast radius, surprise, evidence, novelty, and future takeover value. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 39f9d5f..a1aaa39 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,8 +1,8 @@ # Project State -Last updated: 2026-07-26 +Last updated: 2026-07-31 -## Current branch milestone — R0a fixture foundation implemented +## Current branch milestone — R0 deterministic substrate complete Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Control Development**. @@ -13,9 +13,11 @@ Branch `codex/shadow-cockpit-rnd` resets the product R&D thesis around **Dual-Co - ADR-004 defines event-driven Control Pulses: agents keep working while a developer predicts, falsifies, chooses evidence, or defends one high-value seam. A bounded Side Coach may structure the answer but cannot create readiness evidence. - Research, alternative concepts, ADR, experiments, and an agent-executable vertical-slice plan are recorded in `prd.md` and `docs/v0.3/`. - R0a now implements RFC 8785 canonical JSON, domain-separated SHA-256, UTF-8 path ordering, tree/candidate/manifest hashes, the versioned fixture types, and fail-closed manifest validation. Ten R0a tests pass on this Windows checkout, bringing the extension suite to 23/23. -- The deterministic Git fixture, fixture factory, pinned standalone Node artifact, `TrustedFixtureRunner`, `AgentDriver`, Chronicle, semantic extractor, Experience Compiler, Takeover Twin, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. R0 is therefore incomplete. +- R0b now has a committed dependency-free `tenant-cache-key` fixture, deterministic Git factory, fixed base/target revisions and state-tree hashes, controller-owned mutation/repair/harness/oracle blobs, and a separately downloaded hash-pinned Node `v22.17.0` runtime. On this Windows checkout, base and target checks pass, the mutation fails the declared tenant-isolation check, the known repair returns the exact target tree to green, and cleanup preserves the source repository snapshot. +- The full local Windows extension suite passes 27/27 with `npm run check`, build, and VSIX packaging. Protected PR #10 run `30663623200` independently reproduced the exact fixture/runtime behavior on Linux and Windows; all five required checks passed, so R0 acceptance is complete. +- `TrustedFixtureRunner`, `AgentDriver`, Chronicle, semantic extractor, Experience Compiler, Takeover Twin, Evidence Judge, Control Pulse runtime, readiness ledger, and v0.3 cockpit do not exist yet. - No skill-retention or speed metric has been measured. Values in the PRD are predeclared R&D targets. -- A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract now closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization. This is implemented only at the R0a contract/hash layer. +- A new implementation audit found five R0 ambiguities: candidate-diff identity, pre-store fixture blobs, runtime identity, check IDs, and Git object format. The normative contract closes them with structured diffs, catalog-owned blobs, standalone Node `v22.17.0`, declared test IDs, and SHA-1 Git initialization; R0a/R0b now implement and verify that complete substrate. - A guarded Jules dispatcher and PR policy are defined as a finite R0→R4 queue. They create at most one session after a successful preflight, stop after merged R4, remain inert unless dispatch is explicitly enabled, and keep plan approval on by default. Merges remain manual because the current project tests are not an independent immutable verifier. Full scheduled continuation still requires the dispatcher workflow to be reviewed into the default branch. - The R&D branch is published at `origin/codex/shadow-cockpit-rnd`. Its first Jules workflow run was correctly skipped because `JULES_RND_LOOP_ENABLED` is not enabled; no Jules session was created. - `Protect main` is active: PR, conversation resolution, strict `extension`/`contract`/`web` checks, up-to-date base, deletion protection, and force-push protection are enforced with zero required approvals for the sole owner. @@ -131,8 +133,8 @@ No external input blocks the repository-owned fixture R0–R4.5 mechanism in `do ## Next ordered actions -1. Finish R0b: commit the deterministic tenant-cache-key Git fixture/factory, provision the pinned standalone Node artifact, and prove identical revisions and tree hashes on Windows/Linux. -2. Implement R1–R3 behind stable contracts: replay AgentDriver plus Chronicle, change-evidence extractor, and safe Takeover Twin lifecycle. +1. Merge protected PR #10 into `codex/shadow-cockpit-rnd` after the final documentation commit repeats the five required checks. +2. Implement R1–R3 behind stable contracts: replay AgentDriver plus Chronicle, change-evidence extractor, and safe Takeover Twin lifecycle. R1 should record whether real agent near-miss replay has enough observable checkpoint evidence to justify a versioned contract proposal. 3. Integrate R4: one compiled recovery episode and deterministic Evidence Judge. 4. Pass R4.5: one bounded, catalog-only Explain-to-Break Pulse with replay/error fail-closed tests. 5. Run the 30-patch recovery-plus-probe technical corpus audit before expanding the product surface. diff --git a/docs/v0.3/CONCEPTS.md b/docs/v0.3/CONCEPTS.md index 3ba1523..029e634 100644 --- a/docs/v0.3/CONCEPTS.md +++ b/docs/v0.3/CONCEPTS.md @@ -171,6 +171,58 @@ These are candidate experience formats, not commitments: - **Future-self handoff:** after a delay, present the developer's own project as an unfamiliar incident with the original generation transcript hidden. - **Real intervention credit:** if the developer's twin solution is better and passes the production evidence, allow it to replace the agent solution through a normal reviewed merge. +## July 2026 mechanism refresh + +The next concepts change where the experience comes from and what the human controls. They are not extra quiz formats. + +### Agent near-miss replay + +Prefer an agent's real failed hypothesis, red test, rollback, or abandoned implementation branch over a synthetic mutation when the Chronicle contains one. PureFlow rewinds to the first observable divergence, escrows the later repair, and asks the developer to choose evidence and recover the checkpoint. + +This is more project-authentic than a generic seeded bug and makes routine agent self-repair produce operator practice as a second output. It also reduces mutation-pattern gaming. The risk is selection bias: clean first-pass runs produce no near miss, and a failed agent path may be irrelevant rather than instructive. Synthetic change-derived mutations remain the fallback. + +**Status:** promote to an R1 Chronicle and R7 corpus hypothesis. Adding intermediate checkpoint revisions requires a versioned Chronicle contract change before implementation. + +### Evidence escrow + +The controller stores the successful trace, repair, and decisive observation but initially exposes only the symptom and a bounded evidence index. The developer requests the next test, log, trace, symbol, or counterfactual. Assistance progressively reveals evidence, not a prose solution. + +This restores the information-acquisition and diagnosis stages that agent explanations normally collapse. Requested evidence becomes observable behavior for later calibration; merely opening a diff does not. + +**Status:** promote as the default interaction policy for recovery episodes after the protected judge exists. It must never hide production incident evidence or block normal delivery. + +### Decision-stage rotation + +Automation can remove information acquisition, analysis, option selection, and implementation separately. Rather than repeatedly testing code recall, the scheduler rotates the irreducible human action across those stages: choose evidence, rank causes, select a lever, predict a consequence, or direct a cold agent. + +**Status:** test as an episode-family factor. Rotation occurs only inside eligible high-value seams and does not guarantee equal quotas. + +### Control reserve + +Expose a local forecast of which project boundaries have fresh recovery and transfer evidence, which are stale, and which have never been exercised. This is closer to an aircraft's operational envelope than a developer score: it answers “where could I plausibly take over?” and always shows the underlying attempts, age, assistance, and uncertainty. + +**Status:** naming and visualization hypothesis for R5/R6. It cannot exist before the corpus gate and must not collapse evidence into a global number. + +### Learned autonomy policy + +A later router could learn when to offer no intervention, evidence escrow, a recovery twin, live steering, or a delayed transfer task while jointly optimizing software throughput, human attention, opt-out, and later takeover. Current adaptive-support research shows that optimizing immediate accuracy and optimizing learning are not interchangeable. + +**Status:** defer until after a longitudinal pilot. R0–R8 use an inspectable deterministic policy; engagement alone is never the reward. + +## Refined priority + +The preferred episode-source order is now: + +```text +real agent near miss +→ real unresolved design fork +→ change-derived counterfactual +→ intent-derived mutation +→ no episode when evidence is weak +``` + +This is a meaningful constraint: PureFlow should sometimes produce nothing rather than manufacture an easy question. The common interaction is evidence escrow plus executable recovery; a random function explanation remains only an experiment baseline. + ## Naming decision Avoid `Shadow Workspace`; Cursor already uses it for an AI validation environment. diff --git a/docs/v0.3/README.md b/docs/v0.3/README.md index bdfa8e3..abbc87a 100644 --- a/docs/v0.3/README.md +++ b/docs/v0.3/README.md @@ -19,7 +19,7 @@ PureFlow v0.3 asks whether an AI IDE can keep autonomous coding fast while behav ## Current truth - The released v0.1 VSCodium IDE exists and remains the runtime baseline. -- The v0.3 Dual-Control product runtime is not implemented. R0a now contains only canonical hashing and fail-closed fixture-contract validation. +- The v0.3 Dual-Control product runtime is not implemented. R0 is complete: R0a provides canonical hashing and fail-closed fixture-contract validation, while R0b provides the deterministic fixture, standalone hash-pinned runtime, external oracle, mutation, and exact repair. Protected PR #10 run `30663623200` reproduced the golden behavior on Linux and Windows. - No retention, takeover, productivity, or usability target has been measured. - The first valid build is one test-backed vertical slice, not a full Cursor clone. - R0–R4 may execute only finite, repository-owned fixture states. Arbitrary participant or corpus code remains blocked until ADR-003 selects and runtime-verifies a real sandbox backend. diff --git a/docs/v0.3/RESEARCH.md b/docs/v0.3/RESEARCH.md index b0dae47..38e6157 100644 --- a/docs/v0.3/RESEARCH.md +++ b/docs/v0.3/RESEARCH.md @@ -16,7 +16,7 @@ Product pages establish what a tool claims or documents, not that it improves le ### Landscape method -The public-product review was run on 2026-07-25 and refreshed on 2026-07-26. It used official documentation or first-party product pages where available. Search families included: +The public-product review was run on 2026-07-25, refreshed on 2026-07-26, and received a mechanism-focused research refresh on 2026-07-31. It used official documentation or first-party product pages where available. Search families included: ```text AI coding IDE developer skill retention @@ -113,6 +113,22 @@ A 2026 controlled experiment with 86 Python programmers, [“Programmers Are Poo The study concerns generated postconditions rather than full agent patches, so it does not directly validate PureFlow. It does falsify the assumption that another plausible explanation is a sufficient review or teaching mechanism. Control Pulses therefore end in executable evidence, not model agreement. +### Cognitive forcing reduces overreliance but can damage the experience + +Buçinca, Malaya, and Gajos compared three cognitive-forcing designs with simple explanation interfaces and a no-AI baseline in [an experiment with 199 participants](https://www.eecs.harvard.edu/~kgajos/papers/2021/bucinca2021trust.shtml). The forcing designs reduced overreliance on incorrect AI advice, but participants rated the most effective interventions least favorably, and benefits varied with motivation for effortful thinking. + +This is a warning against a compulsory checkpoint after every agent action. PureFlow should spend a user-selected attention budget only at causally important seams, let production continue, and measure voluntary return rather than assuming that more friction is better. + +### Adaptive support is a separate optimization problem + +A 2026 TOCHI paper, [“Offline Reinforcement Learning for Adaptive Support in AI-Assisted Decision-Making”](https://discovery.ucl.ac.uk/id/eprint/10226721/), learned support policies across two experiments with 316 and 964 participants. Accuracy-optimized policies improved joint decision accuracy; learning-optimized policies improved learning only at times. The authors explicitly treat task performance and human skill improvement as different objectives. + +The direct implication is architectural: the future Autonomy Router must not optimize clicks, completion, or software throughput alone. A learned policy is a later possibility only after PureFlow has enough behavioral and delayed-transfer data, a predeclared multi-objective loss, and an interpretable deterministic baseline. R0–R8 keep the router transparent and rule-based. + +### Throughput is real value worth preserving + +Three randomized field experiments covering 4,867 developers reported a noisy but combined [26.08% increase in completed tasks](https://doi.org/10.1287/mnsc.2025.00535) from access to AI code-completion assistance, with larger adoption and gains among less experienced developers. Code completion is not an autonomous swarm, and completed tasks are not a skill measure. The result still supports treating automation speed as product value rather than deliberately removing the tool developers now rely on. + ## 4. Current product landscape ### Mainstream AI coding systems diff --git a/extension/.vscodeignore b/extension/.vscodeignore index 4a87236..87759e2 100644 --- a/extension/.vscodeignore +++ b/extension/.vscodeignore @@ -6,4 +6,4 @@ tsconfig.json esbuild.mjs **/*.map node_modules/** - +.pureflow-runtime/** diff --git a/extension/fixture-node-artifacts.json b/extension/fixture-node-artifacts.json new file mode 100644 index 0000000..cd505a2 --- /dev/null +++ b/extension/fixture-node-artifacts.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 1, + "version": "v22.17.0", + "artifacts": { + "linux-x64": { + "url": "https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz", + "downloadSha256": "325c0f1261e0c61bcae369a1274028e9cfb7ab7949c05512c5b1e630f7e80e12", + "executableSha256": "8071ae0fca095a272ad698a90c7061801a86fb6392ddb81e922b68a91a4374b9", + "format": "tar-xz", + "entry": "node-v22.17.0-linux-x64/bin/node" + }, + "win32-x64": { + "url": "https://nodejs.org/dist/v22.17.0/win-x64/node.exe", + "downloadSha256": "39d45b5933f339d3ebdebd76474893dab5d7da1038920f65cf5bbcf0f20f3636", + "executableSha256": "39d45b5933f339d3ebdebd76474893dab5d7da1038920f65cf5bbcf0f20f3636", + "format": "binary", + "entry": null + } + } +} diff --git a/extension/package.json b/extension/package.json index 4ec7af5..707d027 100644 --- a/extension/package.json +++ b/extension/package.json @@ -258,7 +258,8 @@ "build": "node esbuild.mjs", "watch": "node esbuild.mjs --watch", "check": "tsc --noEmit", - "test": "vitest run", + "fixture:runtime": "node scripts/provision-fixture-node.mjs", + "test": "npm run fixture:runtime && vitest run", "package": "npm run build && vsce package --no-dependencies" }, "dependencies": { diff --git a/extension/scripts/provision-fixture-node.mjs b/extension/scripts/provision-fixture-node.mjs new file mode 100644 index 0000000..5e12ded --- /dev/null +++ b/extension/scripts/provision-fixture-node.mjs @@ -0,0 +1,144 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createWriteStream } from "node:fs"; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { get } from "node:https"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { pipeline } from "node:stream/promises"; +import { spawnSync } from "node:child_process"; + +const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const catalog = JSON.parse(await readFile(join(extensionRoot, "fixture-node-artifacts.json"), "utf8")); +const key = `${process.platform}-${process.arch}`; +const artifact = catalog.artifacts[key]; + +if (!artifact) { + throw new Error(`Fixture Node ${catalog.version} is not pinned for ${key}`); +} + +const runtimeRoot = join(extensionRoot, ".pureflow-runtime", catalog.version, key); +const executable = join(runtimeRoot, process.platform === "win32" ? "node.exe" : "node"); +const metadataPath = join(runtimeRoot, "runtime.json"); + +if (await existingRuntimeIsValid()) { + console.log(`fixture-node ${catalog.version} ready (${key})`); + process.exit(0); +} + +const tempRoot = await mkdtemp(join(tmpdir(), "pureflow-fixture-node-")); +const downloadPath = join(tempRoot, basename(new URL(artifact.url).pathname)); +const runtimeParent = dirname(runtimeRoot); +const staging = join(runtimeParent, `.staging-${randomUUID()}`); + +try { + await download(artifact.url, downloadPath); + await expectHash(downloadPath, artifact.downloadSha256, "download"); + await mkdir(runtimeParent, { recursive: true }); + await mkdir(staging, { recursive: true }); + + const stagedExecutable = join(staging, process.platform === "win32" ? "node.exe" : "node"); + if (artifact.format === "binary") { + await copyFile(downloadPath, stagedExecutable); + } else { + const extracted = join(tempRoot, "extracted"); + await mkdir(extracted); + const result = spawnSync("tar", ["-xJf", downloadPath, "-C", extracted, artifact.entry], { + encoding: "utf8", + shell: false, + windowsHide: true, + }); + if (result.status !== 0) { + throw new Error(`Could not extract fixture Node: ${result.stderr.trim()}`); + } + await copyFile(join(extracted, artifact.entry), stagedExecutable); + } + + if (process.platform !== "win32") { + await chmod(stagedExecutable, 0o755); + } + await expectHash(stagedExecutable, artifact.executableSha256, "executable"); + const version = spawnSync(stagedExecutable, ["--version"], { + encoding: "utf8", + shell: false, + windowsHide: true, + }); + if (version.status !== 0 || version.stdout.trim() !== catalog.version) { + throw new Error(`Fixture Node reported ${version.stdout.trim() || "no version"}`); + } + + await writeFile( + join(staging, "runtime.json"), + `${JSON.stringify({ + schemaVersion: 1, + handle: "fixture-node", + version: catalog.version, + platform: process.platform, + arch: process.arch, + sourceUrl: artifact.url, + sourceSha256: artifact.downloadSha256, + executableSha256: artifact.executableSha256, + }, null, 2)}\n`, + "utf8", + ); + + await rm(runtimeRoot, { recursive: true, force: true }); + await rename(staging, runtimeRoot); + console.log(`fixture-node ${catalog.version} provisioned (${key})`); +} finally { + await rm(staging, { recursive: true, force: true }); + await rm(tempRoot, { recursive: true, force: true }); +} + +async function existingRuntimeIsValid() { + try { + const metadata = JSON.parse(await readFile(metadataPath, "utf8")); + if ( + metadata.schemaVersion !== 1 || + metadata.handle !== "fixture-node" || + metadata.version !== catalog.version || + metadata.platform !== process.platform || + metadata.arch !== process.arch || + metadata.sourceUrl !== artifact.url || + metadata.sourceSha256 !== artifact.downloadSha256 || + metadata.executableSha256 !== artifact.executableSha256 + ) { + return false; + } + await expectHash(executable, artifact.executableSha256, "cached executable"); + const result = spawnSync(executable, ["--version"], { encoding: "utf8", shell: false, windowsHide: true }); + return result.status === 0 && result.stdout.trim() === catalog.version; + } catch { + return false; + } +} + +async function download(url, destination, redirects = 0) { + if (redirects > 3) { + throw new Error("Too many redirects while provisioning fixture Node"); + } + await new Promise((resolveDownload, reject) => { + const request = get(url, { headers: { "User-Agent": "PureFlow-R0-fixture-provisioner" } }, (response) => { + if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + response.resume(); + download(new URL(response.headers.location, url).href, destination, redirects + 1).then(resolveDownload, reject); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`Fixture Node download failed with HTTP ${response.statusCode}`)); + return; + } + pipeline(response, createWriteStream(destination)).then(resolveDownload, reject); + }); + request.on("error", reject); + }); +} + +async function expectHash(path, expected, label) { + const bytes = await readFile(path); + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== expected) { + throw new Error(`Fixture Node ${label} hash mismatch: expected ${expected}, got ${actual}`); + } +} diff --git a/extension/src/twin/fixture-factory.ts b/extension/src/twin/fixture-factory.ts new file mode 100644 index 0000000..614af00 --- /dev/null +++ b/extension/src/twin/fixture-factory.ts @@ -0,0 +1,404 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { rawSha256, treeHash, type TreeFile } from "../rnd/canonical"; +import { + candidateDiffHash, + fixtureManifestHash, + validateFixtureManifest, + type FixtureBlobRef, + type FixtureManifest, + type FixtureState, +} from "./fixture-contract"; +import { openFixtureNodeRuntime, type InstalledFixtureRuntime } from "./fixture-runtime"; + +const assets = fileURLToPath(new URL("../../test/fixtures/v0.3/tenant-cache-key/", import.meta.url)); +const gitDate = "2026-01-01T00:00:00.000Z"; + +export const EXPECTED_TENANT_CACHE_KEY = { + baseRevision: "dbb7f641fc6215cbeefa8a100645e1caa5b3d0a7", + targetRevision: "449bc18f0e0cb635f36e726affc42a7ae1df0211", + baseTreeHash: "1f80cddca40212c2d49010e83e963c36ec9bdaa54f76b855a36001bdd88d692b", + targetTreeHash: "f7d712a8c03136b13e752e17621c316bf3db1d4fdcadb131b84f455b79d83f91", + mutatedTreeHash: "bd7d7ad5af60b54da422579e3fe9d4a318fb06199cca63dc7e716b902a0a04fe", + manifestHash: "ad1820fd4cbfc6878c90a29732d30b6a39bf57e8c0a77f43ce73aaf47046e4f3", +} as const; + +export interface FixtureCommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; +} + +export interface TenantCacheKeyFixture { + root: string; + manifest: FixtureManifest; + manifestHash: string; + baseRevision: string; + targetRevision: string; + runtime: InstalledFixtureRuntime; + state(id: FixtureState["id"]): FixtureState; + git(args: string[]): Promise; + checkout(state: "base" | "target"): Promise; + applyMutation(): Promise; + applyKnownRepair(): Promise; + runCheck(checkId: string): Promise; + treeHash(): Promise; + dispose(): Promise; +} + +export async function createTenantCacheKeyFixture(destination: string): Promise { + const root = verifiedTempRoot(destination); + const runtime = await openFixtureNodeRuntime(); + const template = await mkdtemp(join(tmpdir(), "pureflow-empty-git-template-")); + + try { + await assertEmpty(root); + await copyTree(join(assets, "base"), root); + await git(root, ["init", "--object-format=sha1", "--initial-branch=main", `--template=${template}`]); + await git(root, ["config", "core.autocrlf", "false"]); + await git(root, ["config", "core.eol", "lf"]); + await git(root, ["config", "user.name", "PureFlow Fixture"]); + await git(root, ["config", "user.email", "fixture@pureflow.invalid"]); + await commit(root, "fixture: base"); + const baseRevision = await git(root, ["rev-parse", "HEAD"]); + + await replaceCandidateTree(root, join(assets, "target")); + await commit(root, "fixture: tenant cache key"); + const targetRevision = await git(root, ["rev-parse", "HEAD"]); + + const manifest = await buildManifest(baseRevision, targetRevision); + validateFixtureManifest(manifest, { + handle: runtime.handle, + version: runtime.version, + executableSha256: runtime.executableSha256, + }); + const manifestHash = fixtureManifestHash(manifest); + + const api: TenantCacheKeyFixture = { + root, + manifest, + manifestHash, + baseRevision, + targetRevision, + runtime, + state(id) { + const state = manifest.states.find((item) => item.id === id); + if (!state) { + throw new Error(`Unknown fixture state: ${id}`); + } + return state; + }, + git: (args) => git(root, args), + async checkout(state) { + const revision = state === "base" ? baseRevision : targetRevision; + await git(root, ["checkout", "--detach", "--force", revision]); + }, + async applyMutation() { + await assertTree(root, manifest.states[1]!.treeHash, "target"); + await git(root, ["apply", "--whitespace=nowarn", join(assets, "controller", "mutation.patch")]); + await assertTree(root, manifest.states[2]!.treeHash, "mutated"); + }, + async applyKnownRepair() { + await assertTree(root, manifest.states[2]!.treeHash, "mutated"); + await git(root, ["apply", "--whitespace=nowarn", join(assets, "controller", "repair.patch")]); + await assertTree(root, manifest.states[1]!.treeHash, "target"); + }, + async runCheck(checkId) { + const check = manifest.checks.find((item) => item.id === checkId); + if (!check) { + throw new Error(`Unknown fixture check: ${checkId}`); + } + const command = manifest.commands.find((item) => item.id === check.commandId)!; + return run( + runtime.executablePath, + [ + "--experimental-strip-types", + join(assets, "controller", "harness.mjs"), + checkId, + join(assets, "controller", "oracle.json"), + ], + root, + command.timeoutMs, + command.maxOutputBytes, + ); + }, + treeHash: () => hashCandidateTree(root), + dispose: () => removeFixtureRoot(root), + }; + + return api; + } catch (error) { + await removeFixtureRoot(root); + throw error; + } finally { + await rm(template, { recursive: true, force: true }); + } +} + +async function buildManifest(baseRevision: string, targetRevision: string): Promise { + const [baseFiles, targetFiles, mutatedFiles] = await Promise.all([ + readTree(join(assets, "base")), + readTree(join(assets, "target")), + readTree(join(assets, "mutated")), + ]); + const source = "src/cache-key.ts"; + const baseSource = baseFiles.find((file) => file.path === source)!; + const targetSource = targetFiles.find((file) => file.path === source)!; + const mutatedSource = mutatedFiles.find((file) => file.path === source)!; + const candidateDiff = { + schemaVersion: 1 as const, + baseTreeHash: treeHash(mutatedFiles), + resultTreeHash: treeHash(targetFiles), + changes: [ + { + path: source, + beforeSha256: mutatedSource.sha256, + afterSha256: targetSource.sha256, + beforeMode: mutatedSource.mode, + afterMode: targetSource.mode, + }, + ], + }; + + const command = (id: string, label: string) => ({ + schemaVersion: 1 as const, + id, + label, + runner: "trusted-fixture" as const, + fixtureId: "tenant-cache-key", + toolchainHandle: "fixture-node" as const, + args: [id], + cwd: ".", + timeoutMs: 5_000, + envAllowlist: [], + maxOutputBytes: 16_384, + network: "not-enforced-reviewed-fixture" as const, + }); + + return { + schemaVersion: 1, + fixtureId: "tenant-cache-key", + stack: "node-typescript", + defaultBranch: "main", + baseRevision, + targetRevision, + changedSymbols: [{ path: source, symbol: "cacheKey" }], + commands: [ + command("cache-key.legacy", "Legacy cache-key check"), + command("cache-key.tenant-isolation", "Tenant isolation check"), + ], + checks: [ + { id: "cache-key.legacy", commandId: "cache-key.legacy" }, + { id: "cache-key.tenant-isolation", commandId: "cache-key.tenant-isolation" }, + ], + baseChecks: ["cache-key.legacy"], + targetChecks: ["cache-key.tenant-isolation"], + mutation: { + id: "drop-tenant-from-cache-key", + changeRef: await blob("mutation.patch", "controller", "text/x-diff"), + expectedFailingChecks: ["cache-key.tenant-isolation"], + editablePaths: [source], + }, + knownRepair: { + changeRef: await blob("repair.patch", "controller", "text/x-diff"), + candidateDiff, + candidateDiffHash: candidateDiffHash(candidateDiff), + resultingState: "target", + }, + states: [ + { id: "base", treeHash: treeHash(baseFiles), files: baseFiles, commandIds: ["cache-key.legacy"] }, + { + id: "target", + treeHash: treeHash(targetFiles), + files: targetFiles, + commandIds: ["cache-key.tenant-isolation"], + }, + { + id: "mutated", + treeHash: treeHash(mutatedFiles), + files: mutatedFiles, + commandIds: ["cache-key.tenant-isolation"], + }, + ], + git: { + autocrlf: false, + eol: "lf", + userName: "PureFlow Fixture", + userEmail: "fixture@pureflow.invalid", + authorDate: gitDate, + committerDate: gitDate, + objectFormat: "sha1", + }, + toolchain: { + nodeVersion: "v22.17.0", + dependencies: "none", + harness: await blob("harness.mjs", "controller", "text/javascript"), + oracle: await blob("oracle.json", "oracle", "application/json"), + }, + }; + + async function blob( + name: string, + visibility: FixtureBlobRef["visibility"], + mediaType: string, + ): Promise { + const bytes = await readFile(join(assets, "controller", name)); + return { id: name, sha256: rawSha256(bytes), storedBytes: bytes.byteLength, mediaType, visibility }; + } +} + +async function commit(root: string, message: string): Promise { + await git(root, ["add", "--all"]); + await git(root, ["-c", "commit.gpgSign=false", "commit", "--no-gpg-sign", "-m", message]); +} + +async function git(root: string, args: string[]): Promise { + const result = await run("git", args, root, 30_000, 1_048_576, gitEnvironment()); + if (result.exitCode !== 0) { + throw new Error(`Git failed (${args[0]}): ${result.stderr || result.stdout}`); + } + return result.stdout.trim(); +} + +function gitEnvironment(): Record { + return { + GIT_AUTHOR_NAME: "PureFlow Fixture", + GIT_AUTHOR_EMAIL: "fixture@pureflow.invalid", + GIT_AUTHOR_DATE: gitDate, + GIT_COMMITTER_NAME: "PureFlow Fixture", + GIT_COMMITTER_EMAIL: "fixture@pureflow.invalid", + GIT_COMMITTER_DATE: gitDate, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: process.platform === "win32" ? "NUL" : "/dev/null", + LANG: "C", + LC_ALL: "C", + PATH: process.env.PATH ?? "", + SYSTEMROOT: process.env.SYSTEMROOT ?? "", + }; +} + +async function run( + executable: string, + args: string[], + cwd: string, + timeoutMs: number, + maxOutputBytes: number, + env: Record = { PUREFLOW_EXECUTION_ID: randomUUID() }, +): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(executable, args, { cwd, env, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let timedOut = false; + let settled = false; + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, timeoutMs); + + child.stdout.on("data", (chunk: Buffer) => { + stdoutBytes += chunk.byteLength; + if (stdoutBytes <= maxOutputBytes) stdout.push(chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderrBytes += chunk.byteLength; + if (stderrBytes <= maxOutputBytes) stderr.push(chunk); + }); + child.once("error", (error) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(error); + } + }); + child.once("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveRun({ + exitCode: code, + stdout: Buffer.concat(stdout).toString("utf8").trim(), + stderr: Buffer.concat(stderr).toString("utf8").trim(), + timedOut, + }); + }); + }); +} + +async function replaceCandidateTree(root: string, source: string): Promise { + const current = await readTree(root); + for (const file of current) { + await rm(join(root, ...file.path.split("/")), { force: true }); + } + await copyTree(source, root); +} + +async function copyTree(source: string, destination: string): Promise { + for (const file of await readTree(source)) { + const target = join(destination, ...file.path.split("/")); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, await readFile(join(source, ...file.path.split("/")))); + } +} + +async function readTree(root: string): Promise { + const files: TreeFile[] = []; + await walk(root); + return files.sort((left, right) => Buffer.compare(Buffer.from(left.path), Buffer.from(right.path))); + + async function walk(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name === ".git") continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await walk(path); + } else if (entry.isFile()) { + const rel = relative(root, path).split(sep).join("/"); + const bytes = await readFile(path); + files.push({ path: rel, mode: "100644", sha256: rawSha256(bytes) }); + } else { + throw new Error(`Unsupported fixture entry: ${path}`); + } + } + } +} + +async function hashCandidateTree(root: string): Promise { + return treeHash(await readTree(root)); +} + +async function assertTree(root: string, expected: string, state: string): Promise { + const actual = await hashCandidateTree(root); + if (actual !== expected) { + throw new Error(`Fixture tree does not match declared ${state} state`); + } +} + +async function assertEmpty(root: string): Promise { + const info = await stat(root); + if (!info.isDirectory() || (await readdir(root)).length !== 0) { + throw new Error("Fixture destination must be an empty directory"); + } +} + +function verifiedTempRoot(path: string): string { + const root = resolve(path); + const temp = resolve(tmpdir()); + if (!root.startsWith(`${temp}${sep}`) || !basename(root).startsWith("pureflow-r0b-")) { + throw new Error("Fixture root must be a dedicated PureFlow R0b temporary directory"); + } + return root; +} + +async function removeFixtureRoot(root: string): Promise { + verifiedTempRoot(root); + await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); +} diff --git a/extension/src/twin/fixture-runtime.ts b/extension/src/twin/fixture-runtime.ts new file mode 100644 index 0000000..59dae75 --- /dev/null +++ b/extension/src/twin/fixture-runtime.ts @@ -0,0 +1,116 @@ +import { execFile } from "node:child_process"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { assertExactKeys, assertSha256, rawSha256 } from "../rnd/canonical"; +import { R0_NODE_VERSION, type FixtureRuntime } from "./fixture-contract"; + +const runFile = promisify(execFile); +const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +interface Artifact { + url: string; + downloadSha256: string; + executableSha256: string; + format: "binary" | "tar-xz"; + entry: string | null; +} + +interface ArtifactCatalog { + schemaVersion: 1; + version: string; + artifacts: Record; +} + +interface RuntimeMetadata { + schemaVersion: 1; + handle: "fixture-node"; + version: string; + platform: string; + arch: string; + sourceUrl: string; + sourceSha256: string; + executableSha256: string; +} + +export interface InstalledFixtureRuntime extends FixtureRuntime { + executablePath: string; +} + +export async function openFixtureNodeRuntime(): Promise { + const catalog = await readJson(join(extensionRoot, "fixture-node-artifacts.json")); + assertExactKeys(catalog, ["schemaVersion", "version", "artifacts"], "fixture runtime catalog"); + if (catalog.schemaVersion !== 1 || catalog.version !== R0_NODE_VERSION) { + throw new Error("Fixture runtime catalog version does not match the R0 contract"); + } + + const key = `${process.platform}-${process.arch}`; + const artifact = catalog.artifacts[key]; + if (!artifact) { + throw new Error(`Fixture Node ${R0_NODE_VERSION} is not pinned for ${key}`); + } + validateArtifact(artifact); + + const root = join(extensionRoot, ".pureflow-runtime", R0_NODE_VERSION, key); + const executablePath = join(root, process.platform === "win32" ? "node.exe" : "node"); + const metadata = await readJson(join(root, "runtime.json")); + assertExactKeys( + metadata, + ["schemaVersion", "handle", "version", "platform", "arch", "sourceUrl", "sourceSha256", "executableSha256"], + "fixture runtime metadata", + ); + + if ( + metadata.schemaVersion !== 1 || + metadata.handle !== "fixture-node" || + metadata.version !== R0_NODE_VERSION || + metadata.platform !== process.platform || + metadata.arch !== process.arch || + metadata.sourceUrl !== artifact.url || + metadata.sourceSha256 !== artifact.downloadSha256 || + metadata.executableSha256 !== artifact.executableSha256 + ) { + throw new Error("Fixture runtime metadata does not match the pinned artifact catalog"); + } + + const info = await stat(executablePath); + if (!info.isFile()) { + throw new Error("Fixture runtime executable is not a regular file"); + } + const bytes = await readFile(executablePath); + const executableSha256 = rawSha256(bytes); + if (executableSha256 !== artifact.executableSha256) { + throw new Error("Fixture runtime executable hash does not match the pinned artifact"); + } + + const { stdout } = await runFile(executablePath, ["--version"], { + encoding: "utf8", + timeout: 10_000, + windowsHide: true, + }); + if (stdout.trim() !== R0_NODE_VERSION) { + throw new Error(`Fixture runtime reported an unexpected version: ${stdout.trim()}`); + } + if ((await realpath(executablePath)) === (await realpath(process.execPath))) { + throw new Error("Fixture runtime must not alias the host Node executable"); + } + + return { handle: "fixture-node", version: R0_NODE_VERSION, executableSha256, executablePath }; +} + +function validateArtifact(artifact: Artifact): void { + assertExactKeys(artifact, ["url", "downloadSha256", "executableSha256", "format", "entry"], "fixture artifact"); + if (!artifact.url.startsWith(`https://nodejs.org/dist/${R0_NODE_VERSION}/`)) { + throw new Error("Fixture runtime artifact must come from the pinned Node release"); + } + assertSha256(artifact.downloadSha256, "fixture artifact downloadSha256"); + assertSha256(artifact.executableSha256, "fixture artifact executableSha256"); + if ((artifact.format === "binary") !== (artifact.entry === null)) { + throw new Error("Fixture artifact format and entry do not match"); + } +} + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as T; +} diff --git a/extension/test/fixture.test.ts b/extension/test/fixture.test.ts new file mode 100644 index 0000000..a18b3e1 --- /dev/null +++ b/extension/test/fixture.test.ts @@ -0,0 +1,111 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createTenantCacheKeyFixture, + EXPECTED_TENANT_CACHE_KEY, + type TenantCacheKeyFixture, +} from "../src/twin/fixture-factory"; +import { openFixtureNodeRuntime } from "../src/twin/fixture-runtime"; + +const roots: string[] = []; +const runFile = promisify(execFile); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +afterEach(async () => { + await Promise.all(roots.splice(0).map(async (root) => { + const fixture = active.get(root); + if (fixture) { + await fixture.dispose(); + active.delete(root); + } + })); +}); + +const active = new Map(); + +describe("R0b tenant-cache-key fixture", () => { + it("opens a hash-verified standalone Node 22.17.0 runtime", async () => { + const runtime = await openFixtureNodeRuntime(); + + expect(runtime.version).toBe("v22.17.0"); + expect(runtime.handle).toBe("fixture-node"); + expect(runtime.executableSha256).toMatch(/^[0-9a-f]{64}$/); + expect(resolve(runtime.executablePath)).not.toBe(resolve(process.execPath)); + }); + + it("creates the exact portable base and target revisions", async () => { + const fixture = await createFixture(); + + expect(fixture.baseRevision).toBe(EXPECTED_TENANT_CACHE_KEY.baseRevision); + expect(fixture.targetRevision).toBe(EXPECTED_TENANT_CACHE_KEY.targetRevision); + expect(fixture.manifestHash).toBe(EXPECTED_TENANT_CACHE_KEY.manifestHash); + expect(fixture.state("base").treeHash).toBe(EXPECTED_TENANT_CACHE_KEY.baseTreeHash); + expect(fixture.state("target").treeHash).toBe(EXPECTED_TENANT_CACHE_KEY.targetTreeHash); + expect(fixture.state("mutated").treeHash).toBe(EXPECTED_TENANT_CACHE_KEY.mutatedTreeHash); + expect(await fixture.git(["rev-parse", "--show-object-format"])).toBe("sha1"); + + const attrs = await readFile(join(fixture.root, ".gitattributes")); + expect(attrs.includes(13)).toBe(false); + }, 30_000); + + it("proves base, target, mutation, and known repair behavior", async () => { + const fixture = await createFixture(); + + await fixture.checkout("base"); + await expect(fixture.runCheck("cache-key.legacy")).resolves.toMatchObject({ exitCode: 0 }); + + await fixture.checkout("target"); + await expect(fixture.runCheck("cache-key.tenant-isolation")).resolves.toMatchObject({ exitCode: 0 }); + + await fixture.applyMutation(); + await expect(fixture.treeHash()).resolves.toBe(EXPECTED_TENANT_CACHE_KEY.mutatedTreeHash); + await expect(fixture.runCheck("cache-key.tenant-isolation")).resolves.toMatchObject({ exitCode: 1 }); + + await fixture.applyKnownRepair(); + await expect(fixture.treeHash()).resolves.toBe(EXPECTED_TENANT_CACHE_KEY.targetTreeHash); + await expect(fixture.runCheck("cache-key.tenant-isolation")).resolves.toMatchObject({ exitCode: 0 }); + await expect(fixture.git(["status", "--porcelain"])).resolves.toBe(""); + }, 30_000); + + it("removes only its resolved temporary fixture root", async () => { + const before = await sourceRepoSnapshot(); + const fixture = await createFixture(); + const parent = resolve(fixture.root, ".."); + + await fixture.dispose(); + active.delete(fixture.root); + + await expect(stat(fixture.root)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(parent)).resolves.toBeDefined(); + await expect(sourceRepoSnapshot()).resolves.toEqual(before); + }, 30_000); +}); + +async function createFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "pureflow-r0b-")); + roots.push(root); + const fixture = await createTenantCacheKeyFixture(root); + active.set(root, fixture); + return fixture; +} + +async function sourceRepoSnapshot(): Promise { + const commands = [ + ["status", "--porcelain=v1"], + ["rev-parse", "--symbolic-full-name", "HEAD"], + ["rev-parse", "HEAD"], + ["for-each-ref", "--format=%(refname):%(objectname)", "refs/heads"], + ["remote", "-v"], + ["worktree", "list", "--porcelain"], + ]; + + return Promise.all(commands.map(async (args) => { + const { stdout } = await runFile("git", args, { cwd: repoRoot, encoding: "utf8", windowsHide: true }); + return stdout.trim(); + })); +} diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/base/.gitattributes b/extension/test/fixtures/v0.3/tenant-cache-key/base/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/base/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts b/extension/test/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts new file mode 100644 index 0000000..753f173 --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/base/src/cache-key.ts @@ -0,0 +1,3 @@ +export function cacheKey(id: string): string { + return id; +} diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/harness.mjs b/extension/test/fixtures/v0.3/tenant-cache-key/controller/harness.mjs new file mode 100644 index 0000000..8da57f5 --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/controller/harness.mjs @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const [checkId, oraclePath] = process.argv.slice(2); +if (!checkId || !oraclePath) { + throw new Error("Expected a check ID and controller oracle path"); +} + +const oracle = JSON.parse(await readFile(oraclePath, "utf8")); +const cases = oracle[checkId]; +if (!Array.isArray(cases) || cases.length === 0) { + throw new Error(`Unknown fixture check: ${checkId}`); +} + +const source = pathToFileURL(`${process.cwd()}/src/cache-key.ts`).href; +const { cacheKey } = await import(`${source}?execution=${encodeURIComponent(process.env.PUREFLOW_EXECUTION_ID ?? "fixture")}`); + +for (const item of cases) { + const actual = cacheKey(...item.args); + if (actual !== item.expected) { + console.error(JSON.stringify({ checkId, expected: item.expected, actual })); + process.exitCode = 1; + break; + } +} + +if (process.exitCode !== 1) { + console.log(JSON.stringify({ checkId, cases: cases.length, status: "passed" })); +} diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/mutation.patch b/extension/test/fixtures/v0.3/tenant-cache-key/controller/mutation.patch new file mode 100644 index 0000000..bb38605 --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/controller/mutation.patch @@ -0,0 +1,9 @@ +diff --git a/src/cache-key.ts b/src/cache-key.ts +--- a/src/cache-key.ts ++++ b/src/cache-key.ts +@@ -1,3 +1,3 @@ +-export function cacheKey(tenant: string, id: string): string { +- return `${tenant}:${id}`; ++export function cacheKey(_tenant: string, id: string): string { ++ return id; + } diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/oracle.json b/extension/test/fixtures/v0.3/tenant-cache-key/controller/oracle.json new file mode 100644 index 0000000..b9b81cf --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/controller/oracle.json @@ -0,0 +1,18 @@ +{ + "cache-key.legacy": [ + { + "args": ["account-42"], + "expected": "account-42" + } + ], + "cache-key.tenant-isolation": [ + { + "args": ["tenant-a", "account-42"], + "expected": "tenant-a:account-42" + }, + { + "args": ["tenant-b", "account-42"], + "expected": "tenant-b:account-42" + } + ] +} diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/controller/repair.patch b/extension/test/fixtures/v0.3/tenant-cache-key/controller/repair.patch new file mode 100644 index 0000000..35f8eac --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/controller/repair.patch @@ -0,0 +1,9 @@ +diff --git a/src/cache-key.ts b/src/cache-key.ts +--- a/src/cache-key.ts ++++ b/src/cache-key.ts +@@ -1,3 +1,3 @@ +-export function cacheKey(_tenant: string, id: string): string { +- return id; ++export function cacheKey(tenant: string, id: string): string { ++ return `${tenant}:${id}`; + } diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes b/extension/test/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/mutated/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts b/extension/test/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts new file mode 100644 index 0000000..bb7d5c6 --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/mutated/src/cache-key.ts @@ -0,0 +1,3 @@ +export function cacheKey(_tenant: string, id: string): string { + return id; +} diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/target/.gitattributes b/extension/test/fixtures/v0.3/tenant-cache-key/target/.gitattributes new file mode 100644 index 0000000..fcadb2c --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/target/.gitattributes @@ -0,0 +1 @@ +* text eol=lf diff --git a/extension/test/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts b/extension/test/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts new file mode 100644 index 0000000..61d1440 --- /dev/null +++ b/extension/test/fixtures/v0.3/tenant-cache-key/target/src/cache-key.ts @@ -0,0 +1,3 @@ +export function cacheKey(tenant: string, id: string): string { + return `${tenant}:${id}`; +}