/*
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 };
}