import fs from "node:fs"; import path from "node:path"; import { CONTEXT_LIMITS, contextSizeReport, root } from "./task-context-lib.mjs"; export const GOVERNANCE_LIMITS = Object.freeze({ maxTaskInputs: 12, maxTaskCommands: 8, maxManifestArtifacts: 32, maxCatalogRecordBytes: 2048, }); export const FORBIDDEN_CONTEXT_REFERENCES = Object.freeze([ "next-task-plan.json", "CURRENT_EXECUTION_PLAN.md", "PROJECT_STATUS_AND_NEXT_WORK.md", "BLENDER_5_2_FULL_PARITY_WBS.md", "BLENDER_5_2_FULL_WEB_PARITY_EXECUTION_PLAN.md", "test-results/", ]); export const INPUT_EXCLUSION_REASONS = Object.freeze(new Set([ "ALREADY_IN_CONTEXT", "EVIDENCE_FILE_COUNT", "EVIDENCE_BYTE_BUDGET", "CONTEXT_REMAINING_SPACE", "MISSING_PATH", "PATH_OUTSIDE_REPOSITORY", "PATH_STAT_FAILED", "SYMLINK_PATH", "UNREADABLE_PATH_TYPE", "GENERATED_EVIDENCE_OUTPUT", ])); const byteLength = (value) => Buffer.byteLength(value, "utf8"); const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); function add(violations, code, detail) { violations.push({ code, detail }); } export function validateContextBundle(bundle, { current = true } = {}) { const violations = []; const { context, files, sources } = bundle; const report = contextSizeReport(bundle); const measured = [ ["queue", sources.queue.source, CONTEXT_LIMITS.queueBytes], ["task", sources.taskSource, CONTEXT_LIMITS.taskBytes], ["parentManifest", sources.parentManifestSource, CONTEXT_LIMITS.parentManifestBytes], ["parentStatus", sources.parentStatusSource, CONTEXT_LIMITS.parentStatusBytes], ]; for (const [name, source, limit] of measured) { if (byteLength(source) > limit) add(violations, "DOCUMENT_OVER_BUDGET", `${name}=${byteLength(source)}>${limit}`); } if (!report.withinBudget) add(violations, "CONTEXT_OVER_BUDGET", `tokens=${report.totalTokens}>${CONTEXT_LIMITS.contextTokens}`); if (context.inputPaths.length > GOVERNANCE_LIMITS.maxTaskInputs) { add(violations, "TASK_INPUTS_TOO_WIDE", `count=${context.inputPaths.length}`); } if (context.commands.length > GOVERNANCE_LIMITS.maxTaskCommands) { add(violations, "TASK_COMMANDS_TOO_WIDE", `count=${context.commands.length}`); } const selection = context.inputSelection; if (!selection || selection.schemaVersion !== 1 || !Array.isArray(selection.selected) || !Array.isArray(selection.excluded)) { add(violations, "INPUT_SELECTION_AUDIT_MISSING", context.task); } else { const sourceBytes = report.documents.reduce((sum, item) => sum + item.bytes, 0); if (selection.source?.bytes !== sourceBytes || selection.source?.tokens !== report.sourceTokens) { add(violations, "INPUT_SELECTION_SOURCE_MISMATCH", context.task); } const selectedPaths = selection.selected.map((item) => item?.path); if (JSON.stringify(selectedPaths) !== JSON.stringify(context.inputPaths)) { add(violations, "INPUT_SELECTION_PATHS_MISMATCH", context.task); } const selectedBytes = selection.selected.reduce((sum, item) => sum + (Number.isSafeInteger(item?.bytes) ? item.bytes : 0), 0); const selectedTokens = selection.selected.reduce((sum, item) => sum + (Number.isSafeInteger(item?.tokens) ? item.tokens : 0), 0); for (const item of selection.selected) { if (!item?.path || !Number.isSafeInteger(item.bytes) || item.bytes < 0 || item.tokens !== Math.ceil(item.bytes / 4)) { add(violations, "INPUT_SELECTION_ENTRY_INVALID", context.task); } } if (selection.selected.length > CONTEXT_LIMITS.evidenceFiles) add(violations, "EVIDENCE_FILE_COUNT_OVER_BUDGET", `count=${selection.selected.length}`); if (selectedBytes > CONTEXT_LIMITS.evidenceBytes) add(violations, "EVIDENCE_BYTES_OVER_BUDGET", `bytes=${selectedBytes}`); if (selectedBytes > selection.limits?.contextRemainingBytes) add(violations, "CONTEXT_REMAINING_BYTES_OVER_BUDGET", `bytes=${selectedBytes}`); if (selectedTokens > selection.limits?.contextRemainingTokens) add(violations, "CONTEXT_REMAINING_SPACE_OVER_BUDGET", `tokens=${selectedTokens}`); if (selection.totals?.files !== selection.selected.length || selection.totals?.bytes !== selectedBytes || selection.totals?.tokens !== selectedTokens) { add(violations, "INPUT_SELECTION_TOTALS_MISMATCH", context.task); } for (const item of selection.excluded) { if (!item?.path || typeof item.reason !== "string" || item.reason.length === 0) add(violations, "INPUT_EXCLUSION_REASON_MISSING", context.task); else if (!INPUT_EXCLUSION_REASONS.has(item.reason)) add(violations, "INPUT_EXCLUSION_REASON_UNKNOWN", `${context.task}:${item.reason}`); } } const taskSource = sources.taskSource; if (taskSource && context.task === sources.queue.currentTask) { for (const heading of ["## 目标", "## 输入与范围", "## 验收", "## 交付与回滚"]) { if (!taskSource.includes(heading)) add(violations, "TASK_CARD_SECTION_MISSING", heading); } if (!/malformed|unsupported|取消|超限|负例|失败/iu.test(taskSource)) { add(violations, "TASK_CARD_FAILURE_BOUNDARY_MISSING", context.task); } } for (const [name, source] of [["task", taskSource], ["parentStatus", sources.parentStatusSource]]) { for (const reference of FORBIDDEN_CONTEXT_REFERENCES) { if (source.includes(reference)) add(violations, "FORBIDDEN_CONTEXT_REFERENCE", `${name}:${reference}`); } } const manifest = sources.parentManifest; const artifacts = manifest?.artifacts && typeof manifest.artifacts === "object" ? Object.keys(manifest.artifacts) : []; if (artifacts.length > GOVERNANCE_LIMITS.maxManifestArtifacts) { add(violations, "MANIFEST_ARTIFACTS_TOO_WIDE", `count=${artifacts.length}`); } for (const [name, artifact] of Object.entries(manifest?.artifacts ?? {})) { if (!artifact?.path || path.isAbsolute(artifact.path) || artifact.path.includes("..")) { add(violations, "MANIFEST_PATH_NOT_REPOSITORY_RELATIVE", name); } } if (current && sources.queue.currentTask !== context.task) add(violations, "QUEUE_TASK_MISMATCH", `${sources.queue.currentTask}!=${context.task}`); if (manifest.nextTask !== context.task) add(violations, "PARENT_NEXT_TASK_MISMATCH", `${manifest.nextTask}!=${context.task}`); if (path.basename(path.dirname(files.parentManifestPath)) !== context.parentTask) { add(violations, "PARENT_MANIFEST_PATH_MISMATCH", relative(files.parentManifestPath)); } return { report, violations }; } export function validateCatalogRecords(catalogPath, index) { const violations = []; const handle = fs.openSync(catalogPath, "r"); try { for (const [id, entry] of Object.entries(index.entries ?? {})) { if (!Number.isInteger(entry.offset) || !Number.isInteger(entry.length) || entry.length <= 0) { add(violations, "CATALOG_OFFSET_INVALID", id); continue; } if (entry.length > GOVERNANCE_LIMITS.maxCatalogRecordBytes) add(violations, "CATALOG_RECORD_TOO_LARGE", `${id}=${entry.length}`); const buffer = Buffer.alloc(entry.length); fs.readSync(handle, buffer, 0, entry.length, entry.offset); if (!buffer.toString("utf8").endsWith("\n")) add(violations, "CATALOG_RECORD_NOT_LINE_DELIMITED", id); } } finally { fs.closeSync(handle); } return violations; }