Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 7 additions & 6 deletions docs/dependency-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ was not published at review time. The alias switches publisher to
adds entry-length, bounds, and forward-progress checks to the affected parsers.
The lockfile pins the published tarball integrity.

The published package was tested with malformed ICNS, JXL, and HEIF inputs:
upstream 2.0.2 timed out, while the fork rejected them. Valid ICNS, JXL, HEIF,
PNG, and SVG headers retained their dimensions. Keep the bounded regressions
in `tests/dependency-overrides.test.mjs` when evaluating a replacement.
A clean npm audit alone is insufficient here because aliasing changes the
package identity used for advisory matching.
Direct parser tests with malformed ICNS, JXL, and HEIF inputs timed out on
upstream 2.0.2, while the fork rejected them. The bounded regression in
`tests/dependency-overrides.test.mjs` now checks Blume's public image audit: it
must finish on those malformed files and report the exact dimensions of valid
PNG and SVG files. Keep this behavior check when evaluating a replacement. A
clean npm audit alone is insufficient here because aliasing changes the package
identity used for advisory matching.

Re-evaluate the fork's maintenance and security status at the next dependency
update. Prefer a maintained upstream patched release if one becomes available;
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-diff-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1341,6 +1341,8 @@ function build() {
...(generatedFor ? { generatedFor } : {}),
fresh: summariesAreFresh,
complete: summariesAreComplete,
changeReady: Boolean(agentFiles.length && completeChangeSummary(sourceSummaries.change)),
updatedAt: sourceSummaries.meta?.generatedAt || sourceSummaries.meta?.startedAt,
status: noteStatus,
completedFiles,
totalFiles: agentFiles.length,
Expand Down
2 changes: 2 additions & 0 deletions scripts/generate-summaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,8 @@ function publishSnapshot(
generatedFor: agentReviewFingerprint,
fresh: true,
complete: state.complete,
changeReady: Boolean(includedAgentFiles(publishedSnapshot).length && completeChangeNote(summaries.change)),
updatedAt: summaries.meta?.generatedAt || summaries.meta?.startedAt,
status: state.complete
? 'complete'
: summaries.meta?.status || 'generating',
Expand Down
34 changes: 24 additions & 10 deletions scripts/present.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { createInterface } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { isDeepStrictEqual } from 'node:util';
import { helpText, parseCliArgs } from './cli-args.mjs';
import { applyAgentConfigOperation } from './agent-config.mjs';
import {
Expand Down Expand Up @@ -45,6 +46,7 @@ import {
agentRunCompleted,
agentRunFailed,
agentRunNeeded,
preserveAgentNotes,
agentRunSuperseded,
ensureBuiltAssets,
failedAgentRunForFingerprint,
Expand Down Expand Up @@ -776,15 +778,16 @@ function markSnapshotReady() {
);
}

function snapshotForPresentation(snapshot) {
function snapshotForPresentation(snapshot, previous) {
const zeroUsage = usageSummary(emptyUsageAccumulator());
const hasCurrentNotes = snapshotStateFromSnapshot(snapshot)
.hasCurrentAgentNotes;
const current = {
...snapshot,
usage: reviewUsage(zeroUsage, zeroUsage),
usage: previous && snapshotReviewFingerprint(previous) === snapshotReviewFingerprint(snapshot)
? previous.usage ?? reviewUsage(zeroUsage, zeroUsage)
: reviewUsage(zeroUsage, zeroUsage),
};
if (hasCurrentNotes && snapshot.notes?.fast === cli.fast) return current;
const content = {
...current,
notes: {
Expand All @@ -795,22 +798,33 @@ function snapshotForPresentation(snapshot) {
: { complete: false, status: 'generating' }),
},
};
delete content.version;
delete content.generatedAt;
if (hasCurrentNotes && snapshot.notes?.fast === cli.fast) content.notes = snapshot.notes;
const next = previous && agentSettingsMatch(previous.notes) &&
previous.notes.fast === cli.fast && previous.notes.accessMode === accessMode.mode
? preserveAgentNotes(content, previous)
: content;
delete next.version;
delete next.generatedAt;
return {
version: createHash('sha256')
.update(JSON.stringify(content))
.update(JSON.stringify(next))
.digest('hex')
.slice(0, 12),
generatedAt: new Date().toISOString(),
...content,
...next,
};
}

function seedPresentationSnapshot() {
const snapshot = snapshotForPresentation(
JSON.parse(readFileSync(rawSnapshotPath, 'utf8')),
);
const previous = snapshotReady
? JSON.parse(readFileSync(outputPath, 'utf8'))
: undefined;
const rawSnapshot = JSON.parse(readFileSync(rawSnapshotPath, 'utf8'));
const snapshot = snapshotForPresentation(rawSnapshot, previous);
if (previous && isDeepStrictEqual(
{ ...previous, version: undefined, generatedAt: undefined },
{ ...snapshot, version: undefined, generatedAt: undefined },
)) return;
mkdirSync(dirname(outputPath), { recursive: true });
const pendingOutput = `${outputPath}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
writeFileSync(pendingOutput, `${JSON.stringify(snapshot, null, 2)}\n`);
Expand Down
47 changes: 47 additions & 0 deletions scripts/presenter-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,50 @@ export function ensureBuiltAssets(options) {
}
return true;
}

function matchingAgentNotes(snapshot, previous) {
return previous?.notes.fresh &&
previous.notes.reviewFingerprint === snapshot.notes.reviewFingerprint &&
previous.notes.agentReviewFingerprint === snapshot.notes.agentReviewFingerprint &&
previous.notes.generatedFor === snapshot.notes.agentReviewFingerprint;
}

function newerAgentNotes(current, previous) {
if (current.updatedAt !== previous.updatedAt) {
return Boolean(current.updatedAt &&
(!previous.updatedAt || current.updatedAt > previous.updatedAt));
}
return ['complete', 'changeReady', 'completedFiles'].some(
(key) => Number(current[key] || 0) > Number(previous[key] || 0),
);
}

function copyNoteFields(target, source, keys) {
const next = { ...target };
for (const key of keys) {
if (Object.hasOwn(source, key)) next[key] = source[key];
else delete next[key];
}
return next;
}

export function preserveAgentNotes(snapshot, previous) {
if (!matchingAgentNotes(snapshot, previous)) return snapshot;
if (newerAgentNotes(snapshot.notes, previous.notes)) return snapshot;

const priorFiles = new Map(previous.files.map((file) => [file.path, file]));
const files = snapshot.files.map((file) => {
const prior = priorFiles.get(file.path);
return prior ? copyNoteFields(file, prior, ['summary', 'noteReady', 'noteFailure']) : file;
});
const change = previous.notes.changeReady
? copyNoteFields(snapshot.change, previous.change, ['title', 'summary', 'why', 'highlights', 'risks'])
: { ...snapshot.change };
return {
...snapshot,
files,
change,
notes: { ...previous.notes, totalFiles: snapshot.notes.totalFiles },
usage: previous.usage,
};
}
125 changes: 74 additions & 51 deletions tests/dependency-overrides.test.mjs
Original file line number Diff line number Diff line change
@@ -1,69 +1,92 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import test from "node:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { after, test } from "node:test";

const blume = join(
dirname(fileURLToPath(import.meta.resolve("blume/package.json"))),
"bin/blume.mjs"
);
const fixture = await mkdtemp(join(tmpdir(), "diffsplain-blume-audit-"));
after(() => rm(fixture, { force: true, recursive: true }));

// Resolve the actual parser used by Blume, including any temporary alias.
const require = createRequire(import.meta.url);
const parser = createRequire(require.resolve("blume/package.json")).resolve("image-size");
const box = (name, bytes = Buffer.alloc(0), size = 8 + bytes.length) => {
const header = Buffer.alloc(8);
header.writeUInt32BE(size);
header.write(name, 4);
return Buffer.concat([header, Buffer.from(bytes)]);
};
const u32 = (value) => {
const bytes = Buffer.alloc(4);
bytes.writeUInt32BE(value);
return bytes;
};
const jxlHeader = () => Buffer.concat([
box("JXL ", [13, 10, 135, 10]),
box("ftyp", Buffer.from("jxl ")),
]);

function measure(bytes) {
const images = {
"bad.heif": Buffer.from(
"00000010667479706176696600000000000000246d657461000000000000000869707270000000146970636f000000006973706500000000000000000000000000000000",
"hex"
),
"bad.icns": Buffer.from("69636e73000000106973333200000000", "hex"),
"bad.jxl": Buffer.concat([jxlHeader(), box("jxlp", [0, 0, 0, 0], 0)]),
"small.png": Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=",
"base64"
),
"small.svg": Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="24"></svg>'
),
};

await mkdir(join(fixture, "content"), { recursive: true });
await mkdir(join(fixture, "dist", "images"), { recursive: true });
await writeFile(
join(fixture, "blume.config.mjs"),
`export default {
title: "Image audit fixture",
description: "Checks image audit behavior.",
content: { root: "content" },
deployment: { output: "static", site: "https://example.com" },
};\n`
);
await writeFile(join(fixture, "content", "index.mdx"), "# Image audit fixture\n");

for (const [name, bytes] of Object.entries(images)) {
await writeFile(join(fixture, "dist", "images", name), bytes);
await mkdir(join(fixture, "dist", name), { recursive: true });
await writeFile(
join(fixture, "dist", name, "index.html"),
`<!doctype html><html lang="en"><head>
<title>${name}</title>
<meta property="og:image" content="/images/${name}">
</head><body><main>${name}</main></body></html>\n`
);
}

test("Blume audits image dimensions and handles malformed images without hanging", () => {
// A node:test timeout cannot interrupt a synchronous parser infinite loop.
const result = spawnSync(process.execPath, ["-e", `
try {
console.log(JSON.stringify(require(process.argv[1]).imageSize(Buffer.from(process.argv[2], "hex"))));
} catch (error) {
console.log(JSON.stringify({ error: error.message }));
const result = spawnSync(
process.execPath,
[blume, "audit", "--only", "BLUME_AUDIT_OG_IMAGE_SMALL", "--json"],
{
cwd: fixture,
encoding: "utf8",
env: { ...process.env, NO_COLOR: "1" },
timeout: 10_000,
}
`, parser, bytes.toString("hex")], { timeout: 3000, encoding: "utf8" });
);

assert.ifError(result.error);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}

const malformed = {
ICNS: Buffer.from("69636e73000000106973333200000000", "hex"),
JXL: Buffer.concat([jxlHeader(), box("jxlp", [0, 0, 0, 0], 0)]),
HEIF: Buffer.from("00000010667479706176696600000000000000246d657461000000000000000869707270000000146970636f000000006973706500000000000000000000000000000000", "hex"),
};
for (const [format, bytes] of Object.entries(malformed)) {
test(`image parser rejects non-advancing ${format} structures without hanging`, () => {
assert.equal(typeof measure(bytes).error, "string");
});
}

const controls = [
["ICNS", Buffer.from("69636e73000000106973333200000008", "hex"), 16, 16],
["JXL", Buffer.concat([jxlHeader(), box("jxlc", [255, 10, 1, 0])]), 8, 8],
["HEIF", Buffer.concat([
box("ftyp", Buffer.from("avif0000")),
box("meta", Buffer.concat([
Buffer.alloc(4),
box("iprp", box("ipco", box("ispe", Buffer.concat([Buffer.alloc(4), u32(32), u32(24)])))),
])),
]), 32, 24],
["PNG", Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl1sAAAAASUVORK5CYII=", "base64"), 1, 1],
["SVG", Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="48" height="24"></svg>'), 48, 24],
];
for (const [format, bytes, width, height] of controls) {
test(`image parser preserves valid ${format} dimensions`, () => {
const measured = measure(bytes);
assert.equal(measured.width, width);
assert.equal(measured.height, height);
});
}
const report = JSON.parse(result.stdout);
assert.equal(report.audit.pages, Object.keys(images).length);
const findings = report.diagnostics.filter(
({ code }) => code === "BLUME_AUDIT_OG_IMAGE_SMALL"
);
assert.equal(findings.length, 2);
const messages = findings.map(({ message }) => message).join("\n");
assert.match(messages, /small\.png is 1×1/u);
assert.match(messages, /small\.svg is 48×24/u);
});
3 changes: 3 additions & 0 deletions tests/present-agent.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ test("starts the note agent after the watch snapshot and stops cleanly", async (
: undefined;
});
assert.equal(snapshot.files[0].summary.title, "Update text");
assert.equal(snapshot.usage.agentNotes.calls, 2);

const result = await stop(presenter);
presenter = undefined;
Expand Down Expand Up @@ -322,6 +323,8 @@ test("starts the note agent after the watch snapshot and stops cleanly", async (
await waitForOutput(presenter, /^Reusing current agent notes\.$/m);
const reuseLog = await readFile(events, "utf8");
assert.equal(agentCallCount(reuseLog), firstRunCalls + 2);
const reusedSnapshot = JSON.parse(await readFile(output, "utf8"));
assert.equal(reusedSnapshot.usage.agentNotes.calls, 0);

const reuseResult = await stop(presenter);
presenter = undefined;
Expand Down
Loading
Loading