diff --git a/src/rendering/orchestrator/css-candidate-manifest.test.ts b/src/rendering/orchestrator/css-candidate-manifest.test.ts index 30e0221c62..b4e7c96a0a 100644 --- a/src/rendering/orchestrator/css-candidate-manifest.test.ts +++ b/src/rendering/orchestrator/css-candidate-manifest.test.ts @@ -2,6 +2,11 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createStyleScopeProfile } from "#veryfront/html/styles-builder/style-scope-profile.ts"; +import { + __registerLogRecordEmitter, + __resetLogRecordEmitterForTests, + type LogEntry, +} from "#veryfront/utils/logger/logger.ts"; import { getCandidateManifestCacheStats, getProjectCandidates, @@ -9,6 +14,17 @@ import { invalidateProjectCandidateManifests, } from "./css-candidate-manifest.ts"; +function captureLogs(): { entries: LogEntry[]; restore: () => void } { + const entries: LogEntry[] = []; + __registerLogRecordEmitter((entry) => entries.push(entry)); + return { + entries, + restore: () => { + __resetLogRecordEmitterForTests(); + }, + }; +} + describe("rendering/orchestrator/css-candidate-manifest", () => { describe("invalidateProjectCandidateManifests", () => { it("should clear all caches when no scope provided", () => { @@ -229,6 +245,109 @@ describe("rendering/orchestrator/css-candidate-manifest", () => { assertEquals(result.has("text-blue-500"), false); }); + it("degrades a file that exceeds the candidate-count admission cap instead of throwing", () => { + invalidateProjectCandidateManifests(); + // >MAX_CSS_SELECTOR_TOKENS (100_000) distinct candidates in one file — + // the shape of a large minified vendor bundle in project sources. + const poisonContent = Array.from({ length: 100_001 }, (_, i) => `tok-${i}`).join(" "); + const options = { + projectScope: "project-poison-count", + projectVersion: "v1", + projectDir: "/project", + files: [ + { path: "/project/vendor/minified.js", content: poisonContent }, + { + path: "/project/pages/index.tsx", + content: '
Home
', + }, + ], + developmentMode: false, + }; + + const result = getProjectCandidates(options); + + assertEquals(result.has("text-red-500"), true); + assertEquals(result.has("tok-0"), false); + + // The completed manifest must be cached so the pathological file is not + // re-scanned (and cannot re-fail) on every request. + const statsAfterFirst = getCandidateManifestCacheStats(); + assertEquals(statsAfterFirst.manifests.entries, 1); + const second = getProjectCandidates(options); + assertEquals(second.has("text-red-500"), true); + }); + + it("logs rejected source files without exposing absolute project paths", () => { + invalidateProjectCandidateManifests(); + const captured = captureLogs(); + try { + const projectDir = "/Users/someone/private/path/my-project"; + const absoluteSourcePath = `${projectDir}/vendor/minified.js`; + const outsideSourcePath = "/Users/someone/other-parent/minified.js"; + const poisonContent = Array.from({ length: 100_001 }, (_, i) => `tok-${i}`).join(" "); + + getProjectCandidates({ + projectScope: "project-poison-log-redaction", + projectVersion: "v1", + projectDir, + files: [ + { path: absoluteSourcePath, content: poisonContent }, + { path: outsideSourcePath, content: poisonContent }, + ], + developmentMode: false, + }); + + const projectWarning = captured.entries.find((entry) => + entry.message === "Skipping file rejected by candidate extraction" && + entry.context?.path === "vendor/minified.js" + ); + assertEquals(projectWarning !== undefined, true, "the in-project warning must be emitted"); + assertEquals(JSON.stringify(projectWarning!.context).includes(projectDir), false); + assertEquals(JSON.stringify(projectWarning!.context).includes(absoluteSourcePath), false); + + const outsideWarning = captured.entries.find((entry) => + entry.message === "Skipping file rejected by candidate extraction" && + entry.context?.path === "[outside-project]/minified.js" + ); + assertEquals( + outsideWarning !== undefined, + true, + "the outside-project warning must be emitted", + ); + assertEquals(outsideWarning!.context?.path, "[outside-project]/minified.js"); + const outsideContext = JSON.stringify(outsideWarning!.context); + assertEquals(outsideContext.includes(outsideSourcePath), false); + assertEquals(outsideContext.includes("/Users/someone"), false); + assertEquals(outsideContext.includes("other-parent"), false); + } finally { + captured.restore(); + } + }); + + it("degrades a file that exceeds the byte-size admission cap instead of throwing", () => { + invalidateProjectCandidateManifests(); + // >MAX_CSS_FILE_BYTES (16MB) — e.g. a giant generated asset in sources. + const oversized = "text-blue-500 ".repeat( + Math.ceil((16 * 1024 * 1024 + 1) / "text-blue-500 ".length), + ); + const result = getProjectCandidates({ + projectScope: "project-poison-bytes", + projectVersion: "v1", + projectDir: "/project", + files: [ + { path: "/project/generated/blob.js", content: oversized }, + { + path: "/project/pages/index.tsx", + content: '
Home
', + }, + ], + developmentMode: false, + }); + + assertEquals(result.has("text-red-500"), true); + assertEquals(result.has("text-blue-500"), false); + }); + it("keeps configured runtime roots in the candidate graph", () => { invalidateProjectCandidateManifests(); const result = getProjectCandidates({ diff --git a/src/rendering/orchestrator/css-candidate-manifest.ts b/src/rendering/orchestrator/css-candidate-manifest.ts index d2194e4ac6..05ad9e4f26 100644 --- a/src/rendering/orchestrator/css-candidate-manifest.ts +++ b/src/rendering/orchestrator/css-candidate-manifest.ts @@ -85,6 +85,19 @@ function toRelativeProjectPath(path: string, projectDir: string): string { return normalized.replace(/^\/+/, ""); } +function toDiagnosticProjectPath(path: string, projectDir: string): string { + const normalized = normalizePath(path); + const normalizedProjectDir = normalizePath(projectDir).replace(/\/+$/, ""); + if (normalized === normalizedProjectDir) return "."; + if (normalized.startsWith(`${normalizedProjectDir}/`)) { + return normalized.slice(normalizedProjectDir.length + 1); + } + if (/^(?:[A-Za-z]:)?\//.test(normalized)) { + return `[outside-project]/${normalized.split("/").pop() ?? "unknown"}`; + } + return normalized.replace(/^\/+/, ""); +} + function buildManifestCacheKey( projectScope: string, projectVersion: string, @@ -123,7 +136,23 @@ function buildCandidateManifest(files: SourceFileLike[], projectDir: string): Ca if (!file.content) continue; if (!SOURCE_EXTENSIONS.some((ext) => file.path.endsWith(ext))) continue; - const candidates = new Set(extractCandidates(file.content)); + // A file the tokenizer refuses to admit (over the byte or candidate-count + // cap) must degrade to "contributes no candidates", not abort the manifest: + // an escaping throw here propagates to the SSR boundary and, because it + // happens before manifestCache.set, is rebuilt and re-thrown on every + // request to the project (VERYFRONT-SERVER-F). + let extracted: string[]; + try { + extracted = extractCandidates(file.content); + } catch (error) { + logger.warn("Skipping file rejected by candidate extraction", { + path: toDiagnosticProjectPath(file.path, projectDir), + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + const candidates = new Set(extracted); const relativePath = toRelativeProjectPath(file.path, projectDir); const absolutePath = normalizePath(file.path);