/* RFC-0221: resolve the Compass-complete authored partition of an app — the exact set of files `handoff.pack` must carry (source of truth) vs everything regeneration re-derives. Drives code-file classification off the shared Compass inventory and the RFC-0081 generated marker, and classifies non-code data (content, public, config) by marker + path rules. Do not copy files or write anything — pure classification. Do not strip in-file Compass regions — absorb regenerates them via build.prepare. RFC-0221: Compass-complete authored partition replaces the conservative pack allowlist. */ import path from "node:path"; import { createCompassInventoryEntries, hasGeneratedMarker, isGeneratedMarkerTextCandidate, type KernelCommandInput, } from "@warpgogol/werkstatt-engine/kernel"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; /** Code extensions Compass itself scans — for these we defer to the Compass inventory. */ const CODE_EXT = new Set([".ts", ".tsx", ".astro", ".js", ".jsx", ".mjs", ".mts", ".css"]); /** Directories never walked (build output, deps, caches, provenance is packed separately). */ const IGNORED_DIRS = new Set([ "node_modules", "dist", ".astro", ".turbo", ".wrangler", ".vscode", ".cache", "provenance", ]); /** App-relative roots considered for the authored partition. */ const PACK_ROOTS = ["src", "public", "tools"]; /** Root-level config the app needs that is not derived from content. */ const ROOT_CONFIG = new Set([ "package.json", "tsconfig.json", "content.config.ts", "integration.shard.yaml", "astro.config.mjs", "postcss.config.cjs", "wrangler.jsonc", "env.d.ts", ]); /** * Bootstrap config force-included even though it carries the generated marker / framework-config * exclusion: the app's identity (name, deps) and build config are template-synced (RFC-0137), not * regenerated by `build.prepare`, so a brand-new app cannot `pnpm install` or build without them. */ const BOOTSTRAP_CONFIG = new Set(["package.json", "astro.config.mjs", "postcss.config.cjs"]); /** Generated public subtrees (build output mirrored into public/). */ function isGeneratedPublicPath(appRel: string): boolean { return ( appRel.startsWith("public/_astro/") || appRel.startsWith("public/_img/") || appRel.startsWith("public/_video/") ); } /** * Public outputs produced by the `build.prepare` generators (sitemap/robots/feed/llms/ai/pseo). * These are not named `*.generated.*` and may not carry the RFC-0081 marker, so they are matched * by canonical filename. Mirrors the public-emitting steps of APPS_BUILD_PREPARE_PIPELINE. */ export function isGeneratedPublicArtifact(appRel: string): boolean { if (!appRel.startsWith("public/")) return false; const base = appRel.slice(appRel.lastIndexOf("/") + 1); return ( /^sitemap[\w.-]*\.xml$/i.test(base) || base === "robots.txt" || /^(feed|rss|atom)\.xml$/i.test(base) || /^llms(-full)?\.txt$/i.test(base) || base === "ai.txt" || base.startsWith("pseo-") ); } export interface AuthoredPartition { authored: string[]; excluded: { path: string; reason: string }[]; } async function walkAppFiles(appDir: string): Promise { const out: string[] = []; async function rec(dir: string, io: WorkspaceIO = createDefaultIO().io): Promise { let entries; try { entries = await io.readdir(dir); } catch { return; } for (const e of entries) { if (e.isDirectory) { if (IGNORED_DIRS.has(e.name)) continue; await rec(path.join(dir, e.name)); } else { out.push(path.join(dir, e.name)); } } } await rec(appDir); return out; } /** * Resolve the Compass-complete authored partition for `apps/`. * * Code files (`.ts/.tsx/.astro/.js/.css`…) are classified by the shared Compass inventory * (which honours the RFC-0081 generated marker, `*.generated.*`, dist, framework-config, etc.). * Non-code data (content `.md`, `.json`/`.yaml`, public assets) is classified by the generated * marker plus path rules. Root config the app needs is always authored. */ export async function resolveAuthoredFiles( workspaceRoot: string, siteId: string, input: KernelCommandInput, io: WorkspaceIO = createDefaultIO().io, ): Promise { const appDir = path.resolve(workspaceRoot, "apps", siteId); const appPrefix = `apps/${siteId}/`; // Compass classification for code files (scoped to the app). const compassStatus = new Map(); const entries = await createCompassInventoryEntries(workspaceRoot, input, appDir); for (const e of entries) { const norm = e.path.replace(/\\/g, "/"); if (!norm.startsWith(appPrefix)) continue; compassStatus.set(norm.slice(appPrefix.length), { status: e.authoringStatus, reason: e.exclusionReason, }); } const authored: string[] = []; const excluded: { path: string; reason: string }[] = []; for (const abs of await walkAppFiles(appDir)) { const appRel = path.relative(appDir, abs).replace(/\\/g, "/"); const base = path.basename(appRel); const ext = path.extname(appRel).toLowerCase(); // Bootstrap config travels regardless of marker / framework-config exclusion. if (BOOTSTRAP_CONFIG.has(appRel)) { authored.push(appRel); continue; } // Scope: only src/, public/, tools/, or a known root config file. const inRoot = PACK_ROOTS.some((r) => appRel.startsWith(`${r}/`)); if (!inRoot && !ROOT_CONFIG.has(appRel)) { continue; } // Hard exclusions shared by all file kinds. if (/\.generated\.[a-z]+$/i.test(base)) { excluded.push({ path: appRel, reason: "generated-file" }); continue; } if (isGeneratedPublicPath(appRel) || isGeneratedPublicArtifact(appRel)) { excluded.push({ path: appRel, reason: "generated-public-output" }); continue; } // Code files: defer to Compass. if (CODE_EXT.has(ext)) { const g = compassStatus.get(appRel); if (g && g.status === "authored") { authored.push(appRel); } else { excluded.push({ path: appRel, reason: g?.reason ?? "compass-excluded" }); } continue; } // Non-code data: the generated marker is authoritative for text candidates. if (isGeneratedMarkerTextCandidate(appRel)) { try { const content = await io.readFile(abs); if (hasGeneratedMarker(content)) { excluded.push({ path: appRel, reason: "generated-marker" }); continue; } } catch { /* unreadable — fall through and treat as authored */ } } // Remaining data + binary assets are authored source of truth. authored.push(appRel); } authored.sort(); excluded.sort((a, b) => a.path.localeCompare(b.path)); return { authored, excluded }; }