186 lines
8.5 KiB
TypeScript
186 lines
8.5 KiB
TypeScript
// lib/executive-update.ts — parser for the cited ms-executive-update Machine contract.
|
|
//
|
|
// The contract bytes are pinned and verified by Mosaic Core before this parser
|
|
// is reachable for a version-4 role. This module accepts no alternate format,
|
|
// heading, whitespace form, or identifier spelling.
|
|
|
|
import {
|
|
GOAL_REPORT_CONTRACT_BLOB,
|
|
GOAL_REPORT_CONTRACT_PATH,
|
|
GOAL_REPORT_CONTRACT_SECTION,
|
|
GOAL_REPORT_CONTRACT_SHA256,
|
|
GOAL_REPORT_FORMAT,
|
|
type GoalItemResolutionV1,
|
|
type GoalPolicyPublication,
|
|
type GoalTrackedItem,
|
|
validateGoalItemResolution,
|
|
} from "../../mosaic-core/lib/goal-policy.ts";
|
|
|
|
export {
|
|
GOAL_REPORT_FORMAT,
|
|
GOAL_REPORT_CONTRACT_PATH,
|
|
GOAL_REPORT_CONTRACT_SECTION,
|
|
GOAL_REPORT_CONTRACT_BLOB,
|
|
GOAL_REPORT_CONTRACT_SHA256,
|
|
};
|
|
|
|
export type ExecutiveUpdateSection = GoalTrackedItem["section"];
|
|
|
|
export type ExecutiveUpdateParseResult =
|
|
| Readonly<{ ok: true; kind: "update" | "no-change"; items: readonly GoalTrackedItem[] }>
|
|
| Readonly<{ ok: false; reason: string }>;
|
|
|
|
const SECTIONS: readonly ExecutiveUpdateSection[] = ["Just Completed", "Next Step", "Blocked"];
|
|
const CONTROL_RE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/;
|
|
const TASK_RE = /^T[0-9]+$/;
|
|
const PR_RE = /^#[0-9]+$/;
|
|
const ROW_RE = /^[A-Z]{1,4}\.?[0-9]+(?:\.[0-9]+)*$/;
|
|
const MARKER_RE = /^[A-Z0-9]+(?:-[A-Z0-9]+)*-Q[0-9]+$/;
|
|
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
const PATH_RE = /^`[^`\s]+`$/;
|
|
|
|
function invalid(reason: string): ExecutiveUpdateParseResult {
|
|
return Object.freeze({ ok: false, reason });
|
|
}
|
|
|
|
function valid(items: GoalTrackedItem[], kind: "update" | "no-change" = "update"): ExecutiveUpdateParseResult {
|
|
return Object.freeze({ ok: true, kind, items: Object.freeze(items) });
|
|
}
|
|
|
|
function isContractItemToken(token: string): boolean {
|
|
if (TASK_RE.test(token) || PR_RE.test(token) || ROW_RE.test(token) || MARKER_RE.test(token) || SHA_RE.test(token)) return true;
|
|
if (!PATH_RE.test(token)) return false;
|
|
const path = token.slice(1, -1);
|
|
return !path.startsWith("/") && !path.startsWith("./") && !path.startsWith("../") && !/(?:^|\/)\.\.(?:\/|$)/.test(path);
|
|
}
|
|
|
|
function hasValidBlockedSuffix(text: string): boolean {
|
|
return text.endsWith("\u2014 nothing from you")
|
|
|| /\u2014 Decision needed: \(1\) .+ \(2\) .+; recommend [1-9][0-9]*$/.test(text);
|
|
}
|
|
|
|
/** The contract's distinct single-line update form. */
|
|
function parseNoChange(raw: string): ExecutiveUpdateParseResult | undefined {
|
|
const match = /^No change since ([^;\n]+); still waiting on (.+)\.$/.exec(raw);
|
|
if (!match) return undefined;
|
|
const [, since, waiting] = match;
|
|
if (!isContractItemToken(since) || !isContractItemToken(waiting)) return invalid("No change: invalid item identifier");
|
|
// The trusted resolver's public input is deliberately the existing closed
|
|
// GoalTrackedItem shape. Both references are waiting-state identifiers; the
|
|
// no-change kind below supplies the stricter unchanged-state rule.
|
|
return valid([
|
|
Object.freeze({ token: since, section: "Next Step" }),
|
|
Object.freeze({ token: waiting, section: "Next Step" }),
|
|
], "no-change");
|
|
}
|
|
|
|
function parseSection(section: ExecutiveUpdateSection, body: string): ExecutiveUpdateParseResult | GoalTrackedItem[] {
|
|
if (body === "* none\n") return [];
|
|
if (!body.endsWith("\n")) return invalid(`${section}: final bullet must end with LF`);
|
|
const lines = body.slice(0, -1).split("\n");
|
|
if (lines.length < 1 || lines.length > 5) return invalid(`${section}: requires one to five bullets or * none`);
|
|
const items: GoalTrackedItem[] = [];
|
|
for (const line of lines) {
|
|
if (line === "" || /[ \t]$/.test(line)) return invalid(`${section}: blank or trailing-whitespace line`);
|
|
const match = /^\* ([^:\n]+): ([^\n]+)$/.exec(line);
|
|
if (!match) return invalid(`${section}: invalid bullet grammar`);
|
|
const [, token, text] = match;
|
|
if (!isContractItemToken(token)) return invalid(`${section}: invalid item identifier`);
|
|
if (text.startsWith("-")) return invalid(`${section}: bullet text may not start with -`);
|
|
if (section === "Blocked" && !hasValidBlockedSuffix(text)) return invalid("Blocked: required terminal disposition missing");
|
|
items.push(Object.freeze({ token, section }));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/** Parse the exact three-section Machine contract before any Goal state mutation. */
|
|
export function parseExecutiveUpdate(raw: unknown): ExecutiveUpdateParseResult {
|
|
if (typeof raw !== "string") return invalid("payload must be a string");
|
|
if (raw.length === 0) return invalid("payload is empty");
|
|
if (raw !== raw.normalize("NFC")) return invalid("payload is not NFC-normalized");
|
|
if (CONTROL_RE.test(raw)) return invalid("payload contains control characters or non-LF line endings");
|
|
if (/(^|\n)[^\n]*[ \t](?=\n|$)/.test(raw)) return invalid("payload contains trailing whitespace");
|
|
|
|
const noChange = parseNoChange(raw);
|
|
if (noChange) return noChange;
|
|
|
|
const first = "Just Completed:\n\n";
|
|
const second = "\nNext Step:\n\n";
|
|
const third = "\nBlocked:\n\n";
|
|
if (!raw.startsWith(first)) return invalid("first heading must be Just Completed");
|
|
const nextIndex = raw.indexOf(second, first.length);
|
|
if (nextIndex < 0 || raw.indexOf(second, nextIndex + 1) !== -1) return invalid("Next Step heading must appear exactly once after Just Completed");
|
|
const blockedIndex = raw.indexOf(third, nextIndex + second.length);
|
|
if (blockedIndex < 0 || raw.indexOf(third, blockedIndex + 1) !== -1) return invalid("Blocked heading must appear exactly once after Next Step");
|
|
if (raw.includes("\nJust Completed:\n", first.length) || raw.includes("\nNext Step:\n", 0) && raw.indexOf(second) !== nextIndex) return invalid("heading duplication or order violation");
|
|
|
|
const bodies = [
|
|
raw.slice(first.length, nextIndex),
|
|
raw.slice(nextIndex + second.length, blockedIndex),
|
|
raw.slice(blockedIndex + third.length),
|
|
];
|
|
const parsed: GoalTrackedItem[] = [];
|
|
for (let index = 0; index < SECTIONS.length; index++) {
|
|
const sectionItems = parseSection(SECTIONS[index], bodies[index]);
|
|
if (!Array.isArray(sectionItems)) return sectionItems;
|
|
parsed.push(...sectionItems);
|
|
}
|
|
return valid(parsed);
|
|
}
|
|
|
|
export type AttestedGoalReportResult =
|
|
| Readonly<{ ok: true; items: readonly GoalTrackedItem[] }>
|
|
| Readonly<{ ok: false; reason: string }>;
|
|
|
|
function resolutionError(item: GoalTrackedItem, result: GoalItemResolutionV1): AttestedGoalReportResult | undefined {
|
|
if (result.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${result.outcome}` });
|
|
if (item.section === "Just Completed" && !result.changedSincePreviousAcceptedReport) {
|
|
return Object.freeze({ ok: false, reason: "unchanged-completion" });
|
|
}
|
|
if (item.section === "Just Completed" && result.completionEvidenceId === null) {
|
|
return Object.freeze({ ok: false, reason: "completion-evidence-missing" });
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export type AttestedGoalReportStatus = "satisfied" | "blocked" | "in_progress";
|
|
|
|
/** Resolve each parser-produced item through the attested, read-only resolver. */
|
|
export async function validateAttestedGoalReport(
|
|
raw: unknown,
|
|
publication: GoalPolicyPublication,
|
|
status: AttestedGoalReportStatus,
|
|
): Promise<AttestedGoalReportResult> {
|
|
const parsed = parseExecutiveUpdate(raw);
|
|
if (!parsed.ok) return parsed;
|
|
if (parsed.kind === "no-change" && status !== "in_progress") {
|
|
return Object.freeze({ ok: false, reason: "no-change-status" });
|
|
}
|
|
for (const item of parsed.items) {
|
|
let resolution: unknown;
|
|
try {
|
|
resolution = await publication.resolver(item);
|
|
} catch {
|
|
return Object.freeze({ ok: false, reason: "resolver-unavailable" });
|
|
}
|
|
if (!validateGoalItemResolution(resolution)) {
|
|
return Object.freeze({ ok: false, reason: "resolver-malformed" });
|
|
}
|
|
if (parsed.kind === "no-change") {
|
|
if (resolution.outcome !== "resolved") return Object.freeze({ ok: false, reason: `resolver-${resolution.outcome}` });
|
|
if (resolution.changedSincePreviousAcceptedReport) return Object.freeze({ ok: false, reason: "no-change-state-changed" });
|
|
continue;
|
|
}
|
|
const error = resolutionError(item, resolution);
|
|
if (error) return error;
|
|
}
|
|
return Object.freeze({ ok: true, items: parsed.items });
|
|
}
|
|
|
|
/** Stable code for Core's payload-free denial journal. */
|
|
export function goalPolicyDenialCode(reason: string): string {
|
|
return ["resolver-zero", "resolver-multiple", "resolver-unavailable", "resolver-stale", "resolver-malformed", "unchanged-completion", "completion-evidence-missing", "no-change-status", "no-change-state-changed"].includes(reason)
|
|
? reason
|
|
: "parser-rejected";
|
|
}
|