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
14 changes: 14 additions & 0 deletions scripts/check-dead-exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
parseAllowlistText,
parseGuardConfig,
parseTsPruneLine,
tsPruneSpawn,
validateAllowlistEntry,
validateAllowlistOwnership,
validateAllowlistText,
Expand Down Expand Up @@ -247,6 +248,19 @@ describe("pinned scan invocation", () => {
expect(existsSync(join(repoRoot, config.tsconfig))).toBe(true);
});

test("ts-prune is launched with node, not as a Bun-executed bin", () => {
const spawn = tsPruneSpawn("/repo/node_modules/.bin/ts-prune", [
"-p",
"tsconfig.json",
]);
expect(spawn.command).toBe("node");
expect(spawn.args).toEqual([
"/repo/node_modules/.bin/ts-prune",
"-p",
"tsconfig.json",
]);
});

test("parseGuardConfig rejects an unpinned or empty invocation", () => {
const valid = {
tsconfig: "tsconfig.json",
Expand Down
20 changes: 19 additions & 1 deletion scripts/check-dead-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,18 @@ export function isGuardPassing(outcome: GuardOutcome): boolean {
return outcome.violations.length === 0 && outcome.unused.length === 0;
}

// ts-prune's analyzer (ts-morph) under-reports unused exports when the CLI
// runs on Bun: Linux CI then treats the Darwin/Node allowlist as stale
// (1 consumer-less export vs ~230). The bin shebang is `node`, but Bun's
// spawn of that file still executes it with Bun. Always launch the CLI
// with node so the gate matches `node node_modules/ts-prune/lib/index.js`.
export function tsPruneSpawn(
tsPruneBinPath: string,
tsPruneArgs: readonly string[],
): { readonly command: string; readonly args: string[] } {
return { command: "node", args: [tsPruneBinPath, ...tsPruneArgs] };
}

// Counts the TypeScript files the pinned tsconfig pulls into its program via
// tsc --listFilesOnly: the same project ts-prune analyzes. A narrowed
// tsconfig (or a moved scan root) shrinks this count, and the gate fails
Expand Down Expand Up @@ -329,10 +341,16 @@ function main(): void {
);
}
const rules = parseAllowlistText(allowlistText);
const pruned = spawnSync(tsPruneBin, [...config.tsPruneArgs], {
const prune = tsPruneSpawn(tsPruneBin, config.tsPruneArgs);
const pruned = spawnSync(prune.command, prune.args, {
cwd: repoRoot,
encoding: "utf8",
});
if (pruned.error !== undefined) {
fail(
`ts-prune failed to start with ${prune.command}: ${pruned.error.message}`,
);
}
if (pruned.status !== 0) {
fail(`ts-prune failed:\n${pruned.stderr || pruned.stdout}`);
}
Expand Down
43 changes: 28 additions & 15 deletions src/context-compactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
formatPlan,
classifyTaskBoundary,
buildLLMTurnSummary,
buildTurnSummary,
COMPACTED_PREFIX,
COMPACT_SPACER_TEXT,
LEGACY_COMPACT_SPACER_TEXT,
Expand Down Expand Up @@ -1234,7 +1235,26 @@ describe("buildTurnSummary via createPruningCompactor", () => {
expect(file).toContain("Total tool calls: 1");
});

test("truncates summary when it exceeds maxChars", async () => {
test("buildTurnSummary truncates with ellipsis when over maxChars", () => {
const maxChars = 20;
const turns: ConversationTurn[] = [
makeTurn({
role: "user",
content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }],
}),
makeTurn({
role: "assistant",
content: [
{ type: "text", text: "patch the refresh handler ".repeat(40) },
],
}),
];
const summary = buildTurnSummary(turns, maxChars);
expect(summary.endsWith("...")).toBe(true);
expect(summary.length).toBe(maxChars);
});

test("a truncated lying spine aborts instead of shipping", async () => {
const maxChars = 20;
const compactor = createPruningCompactor({
keepRecentTurns: 1,
Expand All @@ -1243,27 +1263,20 @@ describe("buildTurnSummary via createPruningCompactor", () => {
const turns: ConversationTurn[] = [
makeTurn({
role: "user",
content: [{ type: "text", text: "a".repeat(500) }],
content: [{ type: "text", text: "migrate opaque tokens ".repeat(40) }],
}),
makeTurn({
role: "assistant",
content: [{ type: "text", text: "b".repeat(500) }],
content: [
{ type: "text", text: "patch the refresh handler ".repeat(40) },
],
}),
makeTurn({ role: "user", content: [{ type: "text", text: "recent" }] }),
];

const result = await compactor.apply(turns, mockStrategyCtx);
// CL-8744: the deterministic narrative lives in the fat handoff file's
// Summary section; the live output carries only the thin spine.
const file = new TextDecoder().decode(
defined(defined(result.blobs)[0]).bytes,
);
const narrative = defined(
file.split("## Summary (this fold — may paraphrase)\n")[1],
).split("## Exact facts")[0];
// The embedded buildTurnSummary output ends with "..." when truncated...
expect(narrative).toContain("...");
// ...and the truncated narrative stays within maxChars + 3 ("..." suffix).
expect(defined(narrative?.trim()).length).toBeLessThanOrEqual(maxChars + 3);
expect(result.output).toBe(turns);
expect(result.record.reason).toBe("verify failed — keeping prior context");
expect(result.record.decisions).toMatchObject({ verifyAborted: 1 });
});
});
31 changes: 31 additions & 0 deletions src/session/compaction-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,11 +912,41 @@ function isSyntheticHandoffText(text: string): boolean {
return text.startsWith(COMPACTED_PREFIX);
}

/**
* Persist genuinely new user text in the proposed output (the new spine)
* as archive occurrences so a later fold can drop them. Output units already
* present verbatim in the input need no recording; the spine never passed
* through inbound admission. Best effort: callers treat adoption as certified.
*/
async function recordFreshHandoffOutput(
archive: CompactionArchive,
input: readonly ConversationTurn[],
output: readonly ConversationTurn[],
): Promise<void> {
const fresh = uncoveredContentUnits(output, input);
for (const unit of fresh) {
if (unit.kind !== "text" || unit.role !== "user") continue;
const text = unit.text ?? "";
if (text.length === 0) continue;
try {
await archive.recordAuthorizedPayload({
kind: "user_message",
payload: text,
provenance: "compaction-handoff",
});
} catch {
// Adoption stands; the next fold simply re-proves coverage another way.
}
}
}

/**
* Refuse a destructive compact when the evidence archive cannot certify the
* dropped prefix. Historical gap:true rows are not part of the expected set.
* Synthetic handoff spines (and pre-format fat summaries) are adopted into
* the archive as user_message so a later fold may change the live spine.
* After the fold certifies, the new spine is recorded so the next fold can
* drop it even when the summarizer does not echo it verbatim.
*/
export function wrapCompactorWithCompletenessGate(
inner: Compactor,
Expand Down Expand Up @@ -952,6 +982,7 @@ export function wrapCompactorWithCompletenessGate(
if (certificate.status !== "complete") {
return incompleteIdentity(inner, turns);
}
await recordFreshHandoffOutput(archive, turns, proposed.output);
return proposed;
},
};
Expand Down
Loading
Loading