380 lines
18 KiB
JavaScript
380 lines
18 KiB
JavaScript
import fs from "node:fs";
|
||
import crypto from "node:crypto";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||
export const CONTEXT_LIMITS = Object.freeze({
|
||
queueBytes: 4096,
|
||
taskBytes: 8192,
|
||
parentManifestBytes: 24576,
|
||
parentStatusBytes: 6144,
|
||
evidenceFiles: 12,
|
||
evidenceBytes: 8192,
|
||
contextTokens: 3500,
|
||
});
|
||
|
||
const read = (file) => fs.readFileSync(file, "utf8");
|
||
const json = (file) => JSON.parse(read(file));
|
||
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
|
||
const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4);
|
||
|
||
const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md");
|
||
const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json");
|
||
const taskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json");
|
||
const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||
let cachedTaskIndex;
|
||
let taskIndexLoaded = false;
|
||
const safeName = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "gap";
|
||
const expandTask = (task) => ({
|
||
...task,
|
||
fixture: { path: `tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`, state: "REQUIRED" },
|
||
desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${task.id}.py -- tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`,
|
||
webCommand: `npm --prefix web run test:generated-gap -- --task ${task.id}`,
|
||
comparator: `node tools/web/check-generated-gap.mjs --task ${task.id}`,
|
||
exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"],
|
||
});
|
||
|
||
export function parseQueue() {
|
||
const source = read(queuePath);
|
||
const current = source.match(/当前任务\s*[::]\s*`([^`]+)`/u)?.[1]
|
||
?? source.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1];
|
||
const parentManifest = source.match(/parent manifest\s*[::]\s*`?([^`\n]+)`?/iu)?.[1]?.trim()
|
||
?? source.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/u)?.[1];
|
||
if (!current || !parentManifest) throw new Error("EXECUTION_QUEUE.md must declare current task and parent manifest");
|
||
return { path: queuePath, source, currentTask: current, parentManifest };
|
||
}
|
||
|
||
function loadTaskIndex() {
|
||
if (taskIndexLoaded) return cachedTaskIndex;
|
||
taskIndexLoaded = true;
|
||
if (!fs.existsSync(taskIndexPath)) {
|
||
if (fs.existsSync(planPath)) throw new Error(`missing task index; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
|
||
return null;
|
||
}
|
||
const index = json(taskIndexPath);
|
||
if (index.schemaVersion !== 1 || !index.source?.path || !index.catalog?.path || !index.entries) {
|
||
throw new Error(`invalid task index: ${relative(taskIndexPath)}`);
|
||
}
|
||
cachedTaskIndex = index;
|
||
return cachedTaskIndex;
|
||
}
|
||
|
||
export function verifyTaskIndex() {
|
||
const index = loadTaskIndex();
|
||
if (!index) return null;
|
||
const sourcePath = path.join(root, index.source.path);
|
||
const catalogPath = path.join(root, index.catalog.path);
|
||
if (!fs.existsSync(sourcePath) || sha256File(sourcePath) !== index.source.sha256) {
|
||
throw new Error(`task index is stale; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
|
||
}
|
||
if (!fs.existsSync(catalogPath) || sha256File(catalogPath) !== index.catalog.sha256) {
|
||
throw new Error(`task catalog hash mismatch; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`);
|
||
}
|
||
return index;
|
||
}
|
||
|
||
function indexedEntry(task, index = loadTaskIndex()) {
|
||
const metadata = index?.entries?.[task];
|
||
if (!metadata) return null;
|
||
const catalogPath = path.join(root, index.catalog.path);
|
||
if (!fs.existsSync(catalogPath)) throw new Error(`missing task catalog: ${relative(catalogPath)}`);
|
||
const handle = fs.openSync(catalogPath, "r");
|
||
try {
|
||
const buffer = Buffer.alloc(metadata.length);
|
||
fs.readSync(handle, buffer, 0, metadata.length, metadata.offset);
|
||
const entry = { ...expandTask(JSON.parse(buffer.toString("utf8"))), previous: metadata.previous, next: metadata.next };
|
||
if (entry.id !== task) throw new Error(`task catalog id mismatch for ${task}`);
|
||
return entry;
|
||
} finally {
|
||
fs.closeSync(handle);
|
||
}
|
||
}
|
||
|
||
export function readIndexedTask(task) {
|
||
return indexedEntry(task);
|
||
}
|
||
|
||
function planEntry(task) {
|
||
const indexed = indexedEntry(task);
|
||
if (indexed) return indexed;
|
||
return null;
|
||
}
|
||
|
||
function planParent(task) {
|
||
const index = loadTaskIndex();
|
||
if (index?.entries?.[task]) return index.entries[task].previous;
|
||
return null;
|
||
}
|
||
|
||
function cardFields(source) {
|
||
const get = (name) => source.match(new RegExp(`^[-*]?\\s*\\x60?${name}\\x60?\\s*[::]\\s*\\x60?([^\\n\\x60]+)`, "imu"))?.[1]?.trim();
|
||
const task = get("task");
|
||
const parent = get("parent");
|
||
const status = get("status");
|
||
const gap = get("gap");
|
||
const ownerFamily = get("ownerFamily");
|
||
const targetImplementationClass = get("targetImplementationClass");
|
||
return { task, parent, status, gap, ownerFamily, targetImplementationClass };
|
||
}
|
||
|
||
function commandLines(source) {
|
||
const candidates = [];
|
||
for (const line of source.split("\n")) {
|
||
const clean = line.replaceAll("`", "").replace(/^\s*[-*]\s*/, "").trim();
|
||
if (clean === "bash") continue;
|
||
if (/^(npm|node|cmake|ctest|bash|build_[A-Za-z0-9_./-]+\/bin\/blender)\b/u.test(clean)) candidates.push(clean);
|
||
}
|
||
return [...new Set(candidates)].slice(0, 8);
|
||
}
|
||
|
||
function inputPaths(source) {
|
||
const paths = [];
|
||
for (const match of source.matchAll(/`([^`]+)`/gu)) {
|
||
const value = match[1].trim();
|
||
if (!value || /\s/u.test(value)) continue;
|
||
if (/^(docs|tests|tools|web|blender-5\.2\.0|build_blender_5\.2\.0)\//u.test(value)) paths.push(value);
|
||
}
|
||
return [...new Set(paths)];
|
||
}
|
||
|
||
function pathBytes(file, limit) {
|
||
let stat;
|
||
try {
|
||
stat = fs.lstatSync(file);
|
||
} catch (error) {
|
||
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return { bytes: null, reason: "MISSING_PATH" };
|
||
return { bytes: null, reason: "PATH_STAT_FAILED" };
|
||
}
|
||
if (stat.isSymbolicLink()) return { bytes: null, reason: "SYMLINK_PATH" };
|
||
if (stat.isFile()) return { bytes: stat.size };
|
||
if (!stat.isDirectory()) return { bytes: null, reason: "UNREADABLE_PATH_TYPE" };
|
||
|
||
// Directory evidence is measured recursively, but stop as soon as it cannot fit.
|
||
let bytes = 0;
|
||
const stack = [file];
|
||
while (stack.length > 0) {
|
||
const current = stack.pop();
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||
} catch {
|
||
return { bytes: null, reason: "PATH_STAT_FAILED" };
|
||
}
|
||
for (const entry of entries) {
|
||
const child = path.join(current, entry.name);
|
||
if (entry.isSymbolicLink()) return { bytes: null, reason: "SYMLINK_PATH" };
|
||
if (entry.isDirectory()) {
|
||
stack.push(child);
|
||
continue;
|
||
}
|
||
if (entry.isFile()) {
|
||
try {
|
||
bytes += fs.statSync(child).size;
|
||
} catch {
|
||
return { bytes: null, reason: "PATH_STAT_FAILED" };
|
||
}
|
||
if (bytes > limit) return { bytes };
|
||
}
|
||
}
|
||
}
|
||
return { bytes };
|
||
}
|
||
|
||
const pathWithinRoot = (candidate) => {
|
||
if (typeof candidate !== "string" || candidate.length === 0 || path.isAbsolute(candidate)) return false;
|
||
const absolute = path.resolve(root, candidate);
|
||
return absolute === root || absolute.startsWith(`${root}${path.sep}`);
|
||
};
|
||
const normalizedPathKey = (candidate) => path.normalize(candidate).replace(/[\\/]+$/u, "");
|
||
|
||
export function selectInputPaths(candidates, {
|
||
sourceBytes = 0,
|
||
sourceTokens = 0,
|
||
reservedPaths = [],
|
||
generatedPaths = [],
|
||
limits = {},
|
||
} = {}) {
|
||
const effectiveLimits = { ...CONTEXT_LIMITS, ...limits };
|
||
const unique = [...new Set((candidates ?? []).filter((candidate) => typeof candidate === "string" && candidate.length > 0))];
|
||
const reserved = new Set(reservedPaths.map(normalizedPathKey));
|
||
const generated = new Set(generatedPaths.map(normalizedPathKey));
|
||
const contextRemainingTokens = Math.max(0, effectiveLimits.contextTokens - sourceTokens);
|
||
const contextRemainingBytes = Math.max(0, effectiveLimits.contextTokens * 4 - sourceBytes);
|
||
const excluded = [];
|
||
const selected = [];
|
||
let selectedBytes = 0;
|
||
let selectedTokens = 0;
|
||
|
||
for (const candidate of unique) {
|
||
const normalized = normalizedPathKey(candidate);
|
||
if (generated.has(normalized)) {
|
||
excluded.push({ path: candidate, bytes: null, reason: "GENERATED_EVIDENCE_OUTPUT" });
|
||
continue;
|
||
}
|
||
if (reserved.has(normalized)) {
|
||
excluded.push({ path: candidate, bytes: 0, reason: "ALREADY_IN_CONTEXT" });
|
||
continue;
|
||
}
|
||
if (!pathWithinRoot(candidate)) {
|
||
excluded.push({ path: candidate, bytes: null, reason: "PATH_OUTSIDE_REPOSITORY" });
|
||
continue;
|
||
}
|
||
const absolute = path.join(root, candidate);
|
||
const measured = pathBytes(absolute, effectiveLimits.evidenceBytes);
|
||
if (measured.reason) {
|
||
excluded.push({ path: candidate, bytes: measured.bytes, reason: measured.reason });
|
||
continue;
|
||
}
|
||
const bytes = measured.bytes;
|
||
const tokens = Math.ceil(bytes / 4);
|
||
if (selected.length >= effectiveLimits.evidenceFiles) {
|
||
excluded.push({ path: candidate, bytes, reason: "EVIDENCE_FILE_COUNT" });
|
||
continue;
|
||
}
|
||
if (bytes > effectiveLimits.evidenceBytes || selectedBytes + bytes > effectiveLimits.evidenceBytes) {
|
||
excluded.push({ path: candidate, bytes, reason: "EVIDENCE_BYTE_BUDGET" });
|
||
continue;
|
||
}
|
||
if (bytes > contextRemainingBytes || selectedBytes + bytes > contextRemainingBytes
|
||
|| tokens > contextRemainingTokens || selectedTokens + tokens > contextRemainingTokens) {
|
||
excluded.push({ path: candidate, bytes, reason: "CONTEXT_REMAINING_SPACE" });
|
||
continue;
|
||
}
|
||
selected.push({ path: candidate, bytes, tokens });
|
||
selectedBytes += bytes;
|
||
selectedTokens += tokens;
|
||
}
|
||
|
||
return {
|
||
inputPaths: selected.map(({ path: candidate }) => candidate),
|
||
inputSelection: {
|
||
schemaVersion: 1,
|
||
limits: {
|
||
maxFiles: effectiveLimits.evidenceFiles,
|
||
evidenceBytes: effectiveLimits.evidenceBytes,
|
||
contextRemainingBytes,
|
||
contextRemainingTokens,
|
||
},
|
||
source: { bytes: sourceBytes, tokens: sourceTokens },
|
||
selected,
|
||
excluded,
|
||
totals: { files: selected.length, bytes: selectedBytes, tokens: selectedTokens },
|
||
},
|
||
};
|
||
}
|
||
|
||
function nextFromPlan(task) {
|
||
const index = loadTaskIndex();
|
||
if (index?.entries?.[task]) return index.entries[task].next;
|
||
return null;
|
||
}
|
||
|
||
export function buildTaskContext(requestedTask) {
|
||
verifyTaskIndex();
|
||
const queue = parseQueue();
|
||
const task = requestedTask ?? queue.currentTask;
|
||
const taskPath = path.join(root, "docs/tasks", `${task}.md`);
|
||
const entry = planEntry(task);
|
||
const taskExists = fs.existsSync(taskPath);
|
||
const taskSource = taskExists ? read(taskPath) : "";
|
||
const fields = taskExists ? cardFields(taskSource) : {};
|
||
if (taskExists && fields.task !== task) throw new Error(`task card id mismatch: ${relative(taskPath)}`);
|
||
const parent = fields.parent ?? (task === queue.currentTask ? path.basename(path.dirname(queue.parentManifest)) : planParent(task));
|
||
if (!parent) throw new Error(`task ${task} has no parent; add a compact task card or plan parent`);
|
||
const parentManifestPath = path.join(root, "tests/golden", parent, "manifest.json");
|
||
if (!fs.existsSync(parentManifestPath)) throw new Error(`missing parent manifest: ${relative(parentManifestPath)}`);
|
||
const parentManifestSource = read(parentManifestPath);
|
||
const parentManifest = JSON.parse(parentManifestSource);
|
||
if (parentManifest.nextTask !== task) throw new Error(`parent manifest nextTask=${parentManifest.nextTask} does not point to ${task}`);
|
||
if (parentManifest.status && parentManifest.status !== "done" && !parentManifest.enablingTask) {
|
||
throw new Error(`parent manifest status=${parentManifest.status} is not complete`);
|
||
}
|
||
const parentStatusPath = path.join(root, "docs/status", `${parent}.md`);
|
||
const parentStatusSource = fs.existsSync(parentStatusPath) ? read(parentStatusPath) : "";
|
||
const ownManifestPath = path.join(root, "tests/golden", task, "manifest.json");
|
||
const ownManifest = fs.existsSync(ownManifestPath) ? json(ownManifestPath) : null;
|
||
if (!requestedTask && task === queue.currentTask && ownManifest?.status === "done") {
|
||
throw new Error(`queue current task ${task} is already done; advance EXECUTION_QUEUE.md to ${ownManifest.nextTask}`);
|
||
}
|
||
if (!requestedTask && task === queue.currentTask && path.basename(path.dirname(queue.parentManifest)) !== parent) {
|
||
throw new Error(`queue parent manifest does not match task card parent ${parent}`);
|
||
}
|
||
const goal = taskExists
|
||
? (taskSource.match(/^##\s+目标\s*\n([\s\S]*?)(?=^##\s|$)/mu)?.[1].trim()
|
||
?? taskSource.match(/^#\s+[^\n::]+[::]\s*(.+)$/mu)?.[1].trim()
|
||
?? taskSource.match(/^#\s+[^\n]+\n\n([^\n]+)/u)?.[1].trim()
|
||
?? `完成 ${entry?.gapId ?? task} 的最小可观察切片`)
|
||
: `完成 ${entry?.gapId ?? task} 的最小可观察切片`;
|
||
const commands = [...commandLines(taskSource), ...(entry ? [entry.desktopCommand, entry.webCommand, entry.comparator] : [])].filter(Boolean).filter((value, index, values) => values.indexOf(value) === index).slice(0, 8);
|
||
const taskBytes = Buffer.byteLength(taskSource, "utf8");
|
||
const taskInputPaths = taskExists ? inputPaths(taskSource) : [];
|
||
const candidatePaths = taskExists && taskInputPaths.length > 0
|
||
? taskInputPaths
|
||
: [entry?.fixture?.path, entry?.comparator && "tools/web/check-generated-gap.mjs"].filter(Boolean);
|
||
const contextSources = [queue.source, taskSource, parentManifestSource, parentStatusSource];
|
||
const sourceBytes = contextSources.reduce((sum, source) => sum + Buffer.byteLength(source, "utf8"), 0);
|
||
const sourceTokens = contextSources.reduce((sum, source) => sum + tokenEstimate(source), 0);
|
||
const reservedPaths = [relative(queuePath), relative(taskPath), relative(parentManifestPath), relative(parentStatusPath)];
|
||
const ownStatusPath = path.join(root, "docs/status", `${task}.md`);
|
||
const ownOutputDir = path.join(root, "tests/golden", task);
|
||
const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)];
|
||
const selectedInputs = selectInputPaths(candidatePaths, { sourceBytes, sourceTokens, reservedPaths, generatedPaths });
|
||
const nextTask = ownManifest?.nextTask ?? nextFromPlan(task) ?? null;
|
||
const context = {
|
||
schemaVersion: 1,
|
||
task,
|
||
parentTask: parent,
|
||
status: fields.status ?? entry?.state ?? "pending",
|
||
goal,
|
||
scope: {
|
||
gap: fields.gap ?? entry?.gapId ?? "NOT_APPLICABLE",
|
||
ownerFamily: fields.ownerFamily ?? entry?.ownerFamily ?? "NOT_APPLICABLE",
|
||
implementationClass: fields.targetImplementationClass ?? entry?.targetImplementationClass ?? "NOT_APPLICABLE",
|
||
},
|
||
commands,
|
||
inputPaths: selectedInputs.inputPaths,
|
||
inputSelection: selectedInputs.inputSelection,
|
||
exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"],
|
||
nextTask,
|
||
sourceDocuments: {
|
||
queue: relative(queuePath),
|
||
taskCard: taskExists ? relative(taskPath) : "TASK_INDEX_ENTRY",
|
||
taskIndex: relative(taskIndexPath),
|
||
parentManifest: relative(parentManifestPath),
|
||
parentStatus: fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE",
|
||
},
|
||
readPolicy: {
|
||
required: [relative(queuePath), taskExists ? relative(taskPath) : "TASK_INDEX_ENTRY", relative(parentManifestPath), fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE"],
|
||
machineOnly: [relative(taskIndexPath), "tests/golden/M15-03A/task-catalog.jsonl"],
|
||
optionalByNeed: ["docs/WEB_BLENDER_MODELER_V1_SCOPE.md", "任务卡列出的精确协议/生产路径", "任务卡明确命名的长期计划小节"],
|
||
forbiddenByDefault: ["完整 CURRENT_EXECUTION_PLAN.md", "完整 PROJECT_STATUS_AND_NEXT_WORK.md", "全部 docs/status/*.md", "完整 parity/WBS 计划", "README 历史和 test-results/"],
|
||
},
|
||
parent: {
|
||
manifest: {
|
||
schemaVersion: parentManifest.schemaVersion,
|
||
task: parentManifest.task,
|
||
parentTask: parentManifest.parentTask,
|
||
status: parentManifest.status ?? "done",
|
||
nextTask: parentManifest.nextTask,
|
||
artifactCount: Object.keys(parentManifest.artifacts ?? {}).length,
|
||
},
|
||
statusSummary: parentStatusSource.split("\n").filter(Boolean).slice(0, 8),
|
||
},
|
||
budgets: { ...CONTEXT_LIMITS, taskBytes, taskLines: taskSource ? taskSource.split("\n").length : 0, taskTokens: tokenEstimate(taskSource) },
|
||
};
|
||
return { context, files: { queuePath, taskPath, parentManifestPath, parentStatusPath }, sources: { queue, taskSource, parentManifest, parentManifestSource, parentStatusSource } };
|
||
}
|
||
|
||
export function contextSizeReport(bundle) {
|
||
const { context, files, sources } = bundle;
|
||
const measure = (file, source) => ({ path: relative(file), bytes: Buffer.byteLength(source, "utf8"), lines: source ? source.split("\n").length : 0, tokens: tokenEstimate(source) });
|
||
const docs = [measure(files.queuePath, sources.queue.source), measure(files.taskPath, sources.taskSource), measure(files.parentManifestPath, sources.parentManifestSource), measure(files.parentStatusPath, sources.parentStatusSource)];
|
||
const sourceTokens = docs.reduce((sum, item) => sum + item.tokens, 0);
|
||
const evidenceBytes = context?.inputSelection?.totals?.bytes ?? 0;
|
||
const evidenceFiles = context?.inputSelection?.totals?.files ?? 0;
|
||
const evidenceTokens = context?.inputSelection?.totals?.tokens ?? 0;
|
||
const totalTokens = sourceTokens + evidenceTokens;
|
||
return { documents: docs, sourceTokens, evidenceFiles, evidenceBytes, evidenceTokens, totalTokens, withinBudget: docs[0].bytes <= CONTEXT_LIMITS.queueBytes && docs[1].bytes <= CONTEXT_LIMITS.taskBytes && docs[2].bytes <= CONTEXT_LIMITS.parentManifestBytes && docs[3].bytes <= CONTEXT_LIMITS.parentStatusBytes && evidenceFiles <= CONTEXT_LIMITS.evidenceFiles && evidenceBytes <= CONTEXT_LIMITS.evidenceBytes && totalTokens <= CONTEXT_LIMITS.contextTokens };
|
||
}
|