149 lines
7.1 KiB
JavaScript
149 lines
7.1 KiB
JavaScript
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;
|
|
}
|