fix(usage-log): reject non-array token logs instead of resetting history

readJsonArray treated any valid JSON that is not an array as [], causing
appendRecord to overwrite the file with only the new entry — silently
deleting all prior data. This is the same data-loss mode the
malformed-JSON fix was trying to prevent.

Fix: throw ERR_UNEXPECTED_TOKEN_LOG_SHAPE when parsed JSON is not an
array so appendRecord aborts and the existing file is preserved.
This commit is contained in:
jiarung 2026-03-13 23:35:12 +00:00
parent f267ff7888
commit a7a7923d09
2 changed files with 30 additions and 1 deletions

View File

@ -120,6 +120,24 @@ describe("recordTokenUsage", () => {
expect(records[0].outputTokens).toBe(50);
});
it("does not overwrite a valid-but-non-array token-usage.json — rejects unexpected shape", async () => {
// Simulate a manual edit or migration that left a valid JSON object
await fs.mkdir(path.join(tmpDir, "memory"), { recursive: true });
await fs.writeFile(usageFile, '{"legacy": true, "records": []}', "utf-8");
await expect(
recordTokenUsage({
workspaceDir: tmpDir,
label: "llm_output",
usage: { input: 100, output: 50, total: 150 },
}),
).rejects.toThrow("not an array");
// File must be unchanged — the legacy data is preserved.
const content = await fs.readFile(usageFile, "utf-8");
expect(content).toBe('{"legacy": true, "records": []}');
});
it("does not overwrite a malformed token-usage.json — preserves corrupted file", async () => {
// Simulate an interrupted write that left partial JSON
await fs.mkdir(path.join(tmpDir, "memory"), { recursive: true });

View File

@ -27,7 +27,18 @@ async function readJsonArray(file: string): Promise<TokenUsageRecord[]> {
try {
const raw = await fs.readFile(file, "utf-8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as TokenUsageRecord[]) : [];
if (!Array.isArray(parsed)) {
// Valid JSON but unexpected shape (object, number, string, …).
// Returning [] here would cause appendRecord to overwrite the file
// with only the new entry, silently deleting prior data.
throw Object.assign(
new Error(
`token-usage.json contains valid JSON but is not an array (got ${typeof parsed})`,
),
{ code: "ERR_UNEXPECTED_TOKEN_LOG_SHAPE" },
);
}
return parsed as TokenUsageRecord[];
} catch (err) {
// File does not exist yet — start with an empty array.
if ((err as NodeJS.ErrnoException).code === "ENOENT") {