01ca965c7f
Coordinate registered resource edits bottom-up into retained exact remote sources. Use one Nix-owned source graph for provisional checking, template publication, explicit baseline upgrades and host activation; retain independent runtime pins. Add scoped contract inspection, historical recovery, derived worklists, crash-safe locks, named dependency adoption and plain-QX structural editing. Repair TODO ownership and template instantiation, and document the supported agent workflow. Validated with protocol and command suites, real jj/Nix convergence and cache checks, TS/React installed-command acceptance, and fresh TODO first-edit acceptance. No live deployment or public publication performed. Props projection generation and a one-command rich feature generator remain explicitly outside this delivery.
157 lines
12 KiB
TypeScript
157 lines
12 KiB
TypeScript
import { CharStream, CommonTokenStream } from "antlr4ng";
|
|
import { QuixosLockLexer } from "../resource-lock/generated/QuixosLockLexer.js";
|
|
import { QuixosLockParser } from "../resource-lock/generated/QuixosLockParser.js";
|
|
import { parseQuixosLockDocument } from "../resource-lock/parser.js";
|
|
import { parseQx, walkSyntax, applySourceEdits, type SyntaxNode } from "./source.js";
|
|
|
|
export type StructuralSelector = { kind: string; id?: string; name?: string; names?: string[] };
|
|
export type StructuralEdit =
|
|
| { operation: "append"; parent: StructuralSelector; source: string }
|
|
| { operation: "replace"; target: StructuralSelector; source: string }
|
|
| { operation: "remove"; target: StructuralSelector }
|
|
| { operation: "import"; kind: "interface" | "package"; name: string }
|
|
| { operation: "semantic-major"; target: StructuralSelector; major: number }
|
|
| { operation: "conformance-id"; target: StructuralSelector; id: string }
|
|
| { operation: "quixos-pin"; source: {repository: string; commit: string} }
|
|
| { operation: "dependency"; kind: "interface" | "package"; name: string; source: {repository: string; commit: string} | null };
|
|
|
|
// Deliberately exclude valueType/identifier/stringLiteral: callers operate on
|
|
// declaration structure, not arbitrary token offsets or lockfile text patches.
|
|
const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceDecl", "packageResourceDecl", "atomDecl",
|
|
"valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport",
|
|
"conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]);
|
|
|
|
/** Bind only the template root identity; schema/atom identities are reusable.
|
|
* The revision's real identity is derived from its containing commit at compile time. */
|
|
export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => {
|
|
if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID");
|
|
const syntax = parseQx(source);
|
|
if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace");
|
|
const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl");
|
|
const literals = declaration?.children.filter(node => node.kind === "stringLiteral");
|
|
if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration");
|
|
return applySourceEdits(source, [
|
|
{ ...literals[0], text: JSON.stringify(workspaceId) },
|
|
{ ...literals[1], text: JSON.stringify(`workspace-revision:${workspaceId}:source`) },
|
|
]);
|
|
};
|
|
|
|
const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType<typeof parseQx>} => {
|
|
if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`);
|
|
const syntax = parseQx(source);
|
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX");
|
|
const matches = [...walkSyntax(syntax.root)].filter((node) => {
|
|
if (node.kind !== selector.kind) return false;
|
|
if (selector.name && !node.children.some((child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name)) return false;
|
|
if (selector.names && JSON.stringify(node.children.filter((child) => child.kind === "identifier").map((child) => source.slice(child.start, child.end))) !== JSON.stringify(selector.names)) return false;
|
|
if (selector.id) {
|
|
// Only an explicit ID field counts, not a coincidentally equal revision,
|
|
// default value, nested declaration, or comment.
|
|
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end);
|
|
const literal = node.children.find((child) => child.kind === "stringLiteral" && tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID"));
|
|
if (!literal || JSON.parse(source.slice(literal.start, literal.end)) !== selector.id) return false;
|
|
}
|
|
return true;
|
|
});
|
|
if (matches.length !== 1) throw new Error(`Structural selector must resolve exactly once (found ${matches.length})`);
|
|
return {node: matches[0], syntax};
|
|
};
|
|
|
|
/** Comment-preserving structural edits; every result is parsed before returning. */
|
|
export const editStructure = (source: string, edit: StructuralEdit): string => {
|
|
if (edit.operation === "quixos-pin") {
|
|
const parsed = parseQuixosLockDocument(source);
|
|
if (!parsed.ok || parsed.document.kind !== "root") throw new Error("Quixos pins belong in a valid root lockfile");
|
|
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
|
|
const entries = parser.document().quixosEntry();
|
|
if (entries.length !== 1) throw new Error("Expected one Quixos source declaration");
|
|
const literals = entries[0].quixosSourceBlock().stringLiteral();
|
|
const offsets = [0];
|
|
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
|
|
const result = applySourceEdits(source, [
|
|
{start: offsets[literals[0].start!.start], end: offsets[literals[0].stop!.stop + 1], text: JSON.stringify(edit.source.repository)},
|
|
{start: offsets[literals[literals.length - 1].start!.start], end: offsets[literals[literals.length - 1].stop!.stop + 1], text: JSON.stringify(edit.source.commit)},
|
|
]);
|
|
const checked = parseQuixosLockDocument(result);
|
|
if (!checked.ok) throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
|
return result;
|
|
}
|
|
if (edit.operation === "import") {
|
|
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid resource import");
|
|
const syntax = parseQx(source);
|
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX");
|
|
const text = `import ${edit.kind} ${edit.name};`;
|
|
if ([...walkSyntax(syntax.root)].some((entry) => entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text)) return source;
|
|
const root = syntax.root.children[0];
|
|
const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end : root.start;
|
|
const result = applySourceEdits(source, [{start: position, end: position, text: `\n${text}\n`}]);
|
|
if (parseQx(result).diagnostics.length) throw new Error("Invalid resource import position");
|
|
return result;
|
|
}
|
|
if (edit.operation === "dependency") {
|
|
const parsed = parseQuixosLockDocument(source);
|
|
if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile");
|
|
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid dependency selector");
|
|
const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source))));
|
|
const tree = parser.document();
|
|
const offsets = [0];
|
|
for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length);
|
|
const entries = tree.resourceEntry().filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name);
|
|
if (entries.length > 1) throw new Error("Ambiguous dependency selector");
|
|
const entry = entries[0];
|
|
const replacement = edit.source ? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}` : "";
|
|
if (!entry && !edit.source) throw new Error("Cannot remove an absent dependency");
|
|
const start = entry ? offsets[entry.start!.start] : offsets[tree.RBRACE().symbol.start];
|
|
const end = entry ? offsets[entry.stop!.stop + 1] : start;
|
|
const result = applySourceEdits(source, [{start, end, text: entry ? replacement : `${replacement}\n`}]);
|
|
const checked = parseQuixosLockDocument(result);
|
|
if (!checked.ok) throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
|
return result;
|
|
}
|
|
const {node, syntax} = select(source, edit.operation === "append" ? edit.parent : edit.target);
|
|
let result: string;
|
|
if (edit.operation === "semantic-major") {
|
|
if (!["conformanceDecl", "packageResourceDecl"].includes(node.kind) || !Number.isSafeInteger(edit.major) || edit.major < 1) throw new Error("Semantic major requires a package/conformance and positive integer");
|
|
const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end);
|
|
const marker = tokens.findIndex((token) => token.kind === "SEMANTIC_MAJOR");
|
|
const value = marker < 0 ? undefined : tokens[marker + 1];
|
|
const brace = tokens.find((token) => token.kind === "LBRACE")!;
|
|
result = applySourceEdits(source, [{start: value?.start ?? brace.start, end: value?.end ?? brace.start, text: value ? String(edit.major) : `semantic-major ${edit.major} `}]);
|
|
} else if (edit.operation === "conformance-id") {
|
|
if (node.kind !== "conformanceDecl" || !edit.id) throw new Error("Identity enrollment requires a conformance and stable ID");
|
|
const existing = node.children.find((entry) => entry.kind === "stringLiteral");
|
|
if (existing) {
|
|
if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id) throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly");
|
|
return source;
|
|
}
|
|
const identifiers = node.children.filter((entry) => entry.kind === "identifier");
|
|
const position = identifiers[identifiers.length - 1].end;
|
|
result = applySourceEdits(source, [{start: position, end: position, text: ` id ${JSON.stringify(edit.id)}`}]);
|
|
} else if (edit.operation === "append") {
|
|
const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end);
|
|
if (!closing) throw new Error("Append requires a declaration with a body");
|
|
result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]);
|
|
} else {
|
|
// A resource parser node includes imports/external declarations preceding
|
|
// its header. Replacing the declaration must not delete that preamble.
|
|
const identifier = node.children.find(child => child.kind === "identifier");
|
|
const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier
|
|
? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start &&
|
|
token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined;
|
|
const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind)
|
|
? [...walkSyntax(syntax.root)].filter((entry) => ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && entry.start <= node.start && entry.end >= node.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0]
|
|
: undefined;
|
|
result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]);
|
|
}
|
|
const checked = parseQx(result);
|
|
if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
|
return result;
|
|
};
|
|
|
|
export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => {
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) throw new Error("Resource scaffold requires a PascalCase name and explicit identities");
|
|
const source = `${kind} ${name} id ${JSON.stringify(id)} revision ${JSON.stringify(revision)} {\n}\n`;
|
|
if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold");
|
|
return source;
|
|
};
|