Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
311292c
Cache the output of `codeql resolve languages`
mario-campos Jun 3, 2026
6010f85
Reimplement `resolveExtractor()` as wrapper over `resolveLanguages()`
mario-campos Jun 3, 2026
445107e
Validate numbers, objects, and undefinables in the `json` module
mario-campos Jun 16, 2026
587fcb3
Refactor `isVersionInfo()`` to use `json` module
mario-campos Jun 16, 2026
889ae42
Refactor CLI executions into helper functions
mario-campos Jun 17, 2026
dc8e1e9
Refactor CLI JSON handling into a dedicated `runCliJson` function
mario-campos Jun 17, 2026
a602287
Refactor CLI caching with in-memory and file storage
mario-campos Jun 18, 2026
b18df17
Rebased onto main; fixups were needed
mario-campos Jun 18, 2026
553eef0
Add error handling for undefined extractors in language resolution
mario-campos Jun 18, 2026
c8e32e4
Refactor `resolveLanguages()` to cache output according to CLI featur…
mario-campos Jun 18, 2026
defcf1b
Rename `optional` and `undefinable` functions for clarity; update rel…
mario-campos Jun 19, 2026
420c97e
Improve documentation for cache file and command keys in CLI
mario-campos Jun 19, 2026
dd7ff30
Replace `string` with `CommandCacheKey` for better type safety
mario-campos Jun 19, 2026
126166c
Rename `CacheEntry` to `CommandCacheEntry` for consistency
mario-campos Jun 19, 2026
94b1260
Refactor cache file reading to check for existence before attempting …
mario-campos Jun 19, 2026
d1bd5ec
Add logging for command cache file read/write failures
mario-campos Jun 19, 2026
805b590
Remove duplicate key check in cacheCommandOutput and related tests
mario-campos Jun 19, 2026
895ffe7
Front-load caching and rear-load saving to disk
mario-campos Jun 19, 2026
649baba
Refactor cache storage to use cacheCommandOutput for consistency
mario-campos Jun 19, 2026
b3c7aa7
Add warning log for invalid data in command cache retrieval
mario-campos Jun 19, 2026
41965b0
Move `cache.ts` to `cli/output-cache.ts`
mario-campos Jun 30, 2026
c2f4379
Refactor `CommandCacheKey` as a string union
mario-campos Jun 30, 2026
0d69817
Move output type validators to `output-cache.ts` module
mario-campos Jun 30, 2026
da14981
Switch `CommandCacheKey` back to an `enum`
mario-campos Jun 30, 2026
bba0d6c
Revert JSON module changes
mario-campos Jul 1, 2026
7f4f27e
Revert change to `resolveExtractor`
mario-campos Jul 1, 2026
252ad66
Perform deep validation of `VersionInfo.features`
mario-campos Jul 1, 2026
5110948
Revert `runCliJson` implementation
mario-campos Jul 9, 2026
d4b99ad
Merge branch 'main' into mario-campos/cache-cli-resolve-langs
mario-campos Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,426 changes: 1,227 additions & 1,199 deletions lib/entry-points.js

Large diffs are not rendered by default.

226 changes: 226 additions & 0 deletions src/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import * as fs from "fs";
import * as os from "os";
import path from "path";

import test from "ava";

import {
cacheCommandOutput,
getCachedCommandOutput,
resetCachedCommandOutputs,
CommandCacheKey,
} from "./cache";
import { isVersionInfo } from "./codeql";
import { setupTests } from "./testing-utils";

setupTests(test);

const COMMAND_CACHE_FILENAME = "codeql-action-command-cache.json";

/**
* Runs `body` with a temporary directory configured as the cache's backing
* store (`RUNNER_TEMP`). `CODEQL_ACTION_TEMP` is cleared so that
* `getTemporaryDirectory()` falls back to `RUNNER_TEMP`.
*
* `setupTests` snapshots and restores `process.env` around every test, so we
* don't restore the environment variables we set here ourselves.
*/
async function withCacheDir(
body: (cacheFilePath: string) => Promise<void> | void,
): Promise<void> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cache-test-"));
process.env["RUNNER_TEMP"] = tmpDir;
delete process.env["CODEQL_ACTION_TEMP"];
resetCachedCommandOutputs();
try {
await body(path.join(tmpDir, COMMAND_CACHE_FILENAME));
} finally {
await fs.promises.rm(tmpDir, { force: true, recursive: true });
}
}

function writeCacheFile(
cacheFilePath: string,
contents: Record<string, unknown>,
): void {
fs.writeFileSync(cacheFilePath, JSON.stringify(contents));
}

test.serial(
"getCachedCommandOutput reuses an output persisted by an earlier step",
async (t) => {
await withCacheDir((cacheFilePath) => {
writeCacheFile(cacheFilePath, {
[CommandCacheKey.Version]: {
cmd: "/path/to/codeql",
output: { version: "2.20.0" },
},
});
t.deepEqual(
getCachedCommandOutput(
CommandCacheKey.Version,
"/path/to/codeql",
isVersionInfo,
),
{ version: "2.20.0" },
);
});
},
);

test.serial(
"getCachedCommandOutput ignores an output persisted from a different CLI",
async (t) => {
await withCacheDir((cacheFilePath) => {
writeCacheFile(cacheFilePath, {
[CommandCacheKey.Version]: {
cmd: "/path/to/other-codeql",
output: { version: "2.20.0" },
},
});
t.is(
getCachedCommandOutput(
CommandCacheKey.Version,
"/path/to/codeql",
isVersionInfo,
),
undefined,
);
});
},
);

test.serial(
"getCachedCommandOutput ignores a malformed cache file",
async (t) => {
await withCacheDir((cacheFilePath) => {
fs.writeFileSync(cacheFilePath, "not valid json");
t.is(
getCachedCommandOutput(
CommandCacheKey.Version,
"/path/to/codeql",
isVersionInfo,
),
undefined,
);
});
},
);

test.serial(
"getCachedCommandOutput returns undefined when there is no cache file",
async (t) => {
await withCacheDir(() => {
t.is(
getCachedCommandOutput(
CommandCacheKey.Version,
"/path/to/codeql",
isVersionInfo,
),
undefined,
);
});
},
);

test.serial(
"getCachedCommandOutput ignores an output that fails validation",
async (t) => {
await withCacheDir((cacheFilePath) => {
for (const output of [
{},
{ version: 2 },
{ version: "2.20.0", overlayVersion: "1" },
{ version: "2.20.0", features: "nope" },
]) {
resetCachedCommandOutputs();
writeCacheFile(cacheFilePath, {
[CommandCacheKey.Version]: { cmd: "/path/to/codeql", output },
});
t.is(
getCachedCommandOutput(
CommandCacheKey.Version,
"/path/to/codeql",
isVersionInfo,
),
undefined,
JSON.stringify(output),
);
}
});
},
);

test.serial(
"getCachedCommandOutput ignores an entry missing the cmd field",
async (t) => {
await withCacheDir((cacheFilePath) => {
writeCacheFile(cacheFilePath, {
[CommandCacheKey.Version]: { output: { version: "2.20.0" } },
});
t.is(
getCachedCommandOutput(
CommandCacheKey.Version,
"/path/to/codeql",
isVersionInfo,
),
undefined,
);
});
},
);

test.serial(
"cacheCommandOutput persists the output to both the memo and the file",
async (t) => {
await withCacheDir((cacheFilePath) => {
cacheCommandOutput("some-command", "/path/to/codeql", {
hello: "world",
});

// Tier 2: the temporary file contains the entry.
const onDisk = JSON.parse(
fs.readFileSync(cacheFilePath, "utf8"),
) as Record<string, unknown>;
t.deepEqual(onDisk["some-command"], {
cmd: "/path/to/codeql",
output: { hello: "world" },
});

// Tier 1: the value is served from the memo even after the file is gone.
fs.rmSync(cacheFilePath);
t.deepEqual(getCachedCommandOutput("some-command", "/path/to/codeql"), {
hello: "world",
});
});
},
);

test.serial(
"getCachedCommandOutput prefers the in-memory memo over the file",
async (t) => {
await withCacheDir((cacheFilePath) => {
cacheCommandOutput("some-command", "/path/to/codeql", { value: 1 });

// Overwrite the file with a different value; the memo (tier 1) should win.
writeCacheFile(cacheFilePath, {
"some-command": { cmd: "/path/to/codeql", output: { value: 2 } },
});
t.deepEqual(getCachedCommandOutput("some-command", "/path/to/codeql"), {
value: 1,
});
});
},
);

test.serial(
"cacheCommandOutput throws if called twice for the same key",
async (t) => {
await withCacheDir(() => {
cacheCommandOutput("some-command", "/path/to/codeql", { value: 1 });
t.throws(() =>
cacheCommandOutput("some-command", "/path/to/codeql", { value: 2 }),
);
});
},
);
147 changes: 147 additions & 0 deletions src/cache.ts
Comment thread
mario-campos marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import * as fs from "fs";
import * as path from "path";

import { getTemporaryDirectory } from "./actions-util";
import * as json from "./json";

/** The name of the temporary file backing the cache (tier 2). */
Comment thread
mario-campos marked this conversation as resolved.
Outdated
const COMMAND_CACHE_FILENAME = "codeql-action-command-cache.json";

/**
* The keys under which the output of cached `codeql` commands is stored. Each
* key is shared by the producer (the corresponding method in `codeql.ts`) and
* any consumers (e.g. `status-report.ts`, which peeks the cached version
* without invoking the CLI).
*/
Comment thread
mario-campos marked this conversation as resolved.
Outdated
export enum CommandCacheKey {
Version = "version",
ResolveLanguages = "resolveLanguages",
}

/** A single cached command output together with the CLI path it came from. */
interface CacheEntry {
/**
* The path to the CodeQL CLI that produced `output`. Persisted so that a
* different step using a different CodeQL bundle doesn't pick up a stale
* value.
*/
cmd: string;
output: unknown;
Comment thread
mario-campos marked this conversation as resolved.
Outdated
}

/**
* Tier 1: the in-process memo. Consulted first on every lookup and populated
* whenever a value is read from the file (tier 2) or computed via the CLI
* (tier 3).
*/
const inMemoryCache = new Map<string, CacheEntry>();
Comment thread
mario-campos marked this conversation as resolved.
Outdated

function getCommandCacheFilePath(): string {
return path.join(getTemporaryDirectory(), COMMAND_CACHE_FILENAME);
}

/**
* Reads and parses the temporary cache file. Best-effort: a missing, malformed,
* or otherwise unreadable file is treated as an empty cache.
*/
function readCommandCacheFile(): Record<string, CacheEntry> {
let contents: string;
try {
contents = fs.readFileSync(getCommandCacheFilePath(), "utf8");
} catch {
return {};
}
Comment thread
mario-campos marked this conversation as resolved.
Outdated
try {
const parsed = json.parseString(contents);
if (json.isObject(parsed)) {
return parsed;
}
} catch {
// Fall through and treat a malformed file as empty.
Comment thread
mario-campos marked this conversation as resolved.
Outdated
}
return {};
}

/**
* Persists the cache to the temporary file. Best-effort: a failure to write
* just means a later step re-runs the CLI.
*/
function writeCommandCacheFile(data: Record<string, CacheEntry>): void {
try {
fs.writeFileSync(getCommandCacheFilePath(), JSON.stringify(data));
} catch {
// Best-effort; ignore write failures.
Comment thread
mario-campos marked this conversation as resolved.
Outdated
}
}

/**
* Stores the output of a command under `key`, writing it to both the in-memory
* memo (tier 1) and the temporary file (tier 2).
*
* Should only be called once per key within a single process; doing otherwise
* indicates a logic error, since a value that has already been cached should be
* served from the memo rather than recomputed.
*/
export function cacheCommandOutput(
key: string,
Comment thread
mario-campos marked this conversation as resolved.
Outdated
cmd: string,
output: unknown,
): void {
if (inMemoryCache.has(key)) {
throw new Error(
`cacheCommandOutput() should be called only once per key, but was called more than once for '${key}'.`,
);
Comment thread
mario-campos marked this conversation as resolved.
Outdated
}
const entry: CacheEntry = { cmd, output };
inMemoryCache.set(key, entry);

const data = readCommandCacheFile();
data[key] = entry;
writeCommandCacheFile(data);
Comment thread
mario-campos marked this conversation as resolved.
Outdated
}

/**
* Returns the cached output for `key`, or `undefined` if it isn't cached.
*
* Resolves tier 1 (in-memory memo) first, then tier 2 (temporary file). A value
* loaded from the file is ignored unless its `cmd` matches the optional `cmd`
* argument, and it satisfies the optional `validate` type guard; valid values
* are memoized into tier 1 before being returned.
*
* A return value of `undefined` signals the caller to fall back to tier 3 (the
* CLI).
*/
export function getCachedCommandOutput<T>(
key: string,
cmd?: string,
validate?: (output: unknown) => output is T,
): T | undefined {
// Tier 1: the in-memory variable.
const memoized = inMemoryCache.get(key);
if (memoized !== undefined) {
return memoized.output as T;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems correct? At least with respect to the cmd aspect?


// Tier 2: the temporary file persisted by an earlier step, if any.
const entry = readCommandCacheFile()[key] as unknown;
Comment thread
mario-campos marked this conversation as resolved.
Outdated
if (
!json.isObject<CacheEntry>(entry) ||
!json.isString(entry.cmd) ||
(cmd !== undefined && entry.cmd !== cmd) ||
(validate !== undefined && !validate(entry.output))
) {
return undefined;
Comment thread
mario-campos marked this conversation as resolved.
Outdated
}

// Memoize so subsequent lookups in this process hit tier 1.
inMemoryCache.set(key, { cmd: entry.cmd, output: entry.output });
Comment thread
mario-campos marked this conversation as resolved.
Outdated
return entry.output as T;
}

/**
* Clears the in-process memo (tier 1). Only for use in tests, which exercise
* multiple "steps" within a single process.
*/
export function resetCachedCommandOutputs(): void {
inMemoryCache.clear();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some other files, we export these test-only functions in a nested object to make it clearer that they are for test-use only.

That said, my general preference for global state in the CodeQL Action is to try and make it explicit in the way I have done recently in e.g. #3963

Loading
Loading