2026-09-10 18:27:41 -07:00
|
|
|
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 }
|
2026-09-15 15:23:24 -07:00
|
|
|
| { operation: "quixos-pin"; source: { repository: string; commit: string } }
|
|
|
|
|
| {
|
|
|
|
|
operation: "dependency";
|
|
|
|
|
kind: "interface" | "package";
|
|
|
|
|
name: string;
|
|
|
|
|
source: { repository: string; commit: string } | null;
|
|
|
|
|
};
|
2026-09-10 18:27:41 -07:00
|
|
|
|
|
|
|
|
// Deliberately exclude valueType/identifier/stringLiteral: callers operate on
|
|
|
|
|
// declaration structure, not arbitrary token offsets or lockfile text patches.
|
2026-09-15 15:23:24 -07:00
|
|
|
const selectable = new Set([
|
|
|
|
|
"workspaceDecl",
|
|
|
|
|
"fragmentDecl",
|
|
|
|
|
"interfaceResourceDecl",
|
|
|
|
|
"packageResourceDecl",
|
|
|
|
|
"atomDecl",
|
|
|
|
|
"valueMember",
|
|
|
|
|
"relationshipMember",
|
|
|
|
|
"operationMember",
|
|
|
|
|
"packageOperationExport",
|
|
|
|
|
"packageFunctionExport",
|
|
|
|
|
"packageConstructorExport",
|
|
|
|
|
"conformanceDecl",
|
|
|
|
|
"stateDecl",
|
|
|
|
|
"edgeDecl",
|
|
|
|
|
"constructorBindingDecl",
|
|
|
|
|
"resourceImportDecl",
|
|
|
|
|
"sourceImportDecl",
|
|
|
|
|
"operationBindingDecl",
|
|
|
|
|
]);
|
2026-09-10 18:27:41 -07:00
|
|
|
|
2026-09-14 10:26:11 -07:00
|
|
|
/** 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 => {
|
2026-09-15 15:23:24 -07:00
|
|
|
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");
|
2026-09-14 10:26:11 -07:00
|
|
|
const syntax = parseQx(source);
|
|
|
|
|
if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace");
|
2026-09-15 15:23:24 -07:00
|
|
|
const declaration = syntax.root.children.find((node) => node.kind === "workspaceDecl");
|
|
|
|
|
const literals = declaration?.children.filter((node) => node.kind === "stringLiteral");
|
2026-09-14 10:26:11 -07:00
|
|
|
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`) },
|
|
|
|
|
]);
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-15 15:23:24 -07:00
|
|
|
const select = (
|
|
|
|
|
source: string,
|
|
|
|
|
selector: StructuralSelector,
|
|
|
|
|
): { node: SyntaxNode; syntax: ReturnType<typeof parseQx> } => {
|
2026-09-10 18:27:41 -07:00
|
|
|
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;
|
2026-09-15 15:23:24 -07:00
|
|
|
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;
|
2026-09-10 18:27:41 -07:00
|
|
|
if (selector.id) {
|
|
|
|
|
// Only an explicit ID field counts, not a coincidentally equal revision,
|
|
|
|
|
// default value, nested declaration, or comment.
|
2026-09-15 15:23:24 -07:00
|
|
|
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"),
|
|
|
|
|
);
|
2026-09-10 18:27:41 -07:00
|
|
|
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})`);
|
2026-09-15 15:23:24 -07:00
|
|
|
return { node: matches[0], syntax };
|
2026-09-10 18:27:41 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 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, [
|
2026-09-15 15:23:24 -07:00
|
|
|
{
|
|
|
|
|
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),
|
|
|
|
|
},
|
2026-09-10 18:27:41 -07:00
|
|
|
]);
|
|
|
|
|
const checked = parseQuixosLockDocument(result);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!checked.ok)
|
|
|
|
|
throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
2026-09-10 18:27:41 -07:00
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
if (edit.operation === "import") {
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name))
|
|
|
|
|
throw new Error("Invalid resource import");
|
2026-09-10 18:27:41 -07:00
|
|
|
const syntax = parseQx(source);
|
|
|
|
|
if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX");
|
|
|
|
|
const text = `import ${edit.kind} ${edit.name};`;
|
2026-09-15 15:23:24 -07:00
|
|
|
if (
|
|
|
|
|
[...walkSyntax(syntax.root)].some(
|
|
|
|
|
(entry) =>
|
|
|
|
|
entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return source;
|
2026-09-10 18:27:41 -07:00
|
|
|
const root = syntax.root.children[0];
|
2026-09-15 15:23:24 -07:00
|
|
|
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` }]);
|
2026-09-10 18:27:41 -07:00
|
|
|
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");
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name))
|
|
|
|
|
throw new Error("Invalid dependency selector");
|
2026-09-10 18:27:41 -07:00
|
|
|
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);
|
2026-09-15 15:23:24 -07:00
|
|
|
const entries = tree
|
|
|
|
|
.resourceEntry()
|
|
|
|
|
.filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name);
|
2026-09-10 18:27:41 -07:00
|
|
|
if (entries.length > 1) throw new Error("Ambiguous dependency selector");
|
|
|
|
|
const entry = entries[0];
|
2026-09-15 15:23:24 -07:00
|
|
|
const replacement = edit.source
|
|
|
|
|
? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}`
|
|
|
|
|
: "";
|
2026-09-10 18:27:41 -07:00
|
|
|
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;
|
2026-09-15 15:23:24 -07:00
|
|
|
const result = applySourceEdits(source, [{ start, end, text: entry ? replacement : `${replacement}\n` }]);
|
2026-09-10 18:27:41 -07:00
|
|
|
const checked = parseQuixosLockDocument(result);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!checked.ok)
|
|
|
|
|
throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
2026-09-10 18:27:41 -07:00
|
|
|
return result;
|
|
|
|
|
}
|
2026-09-15 15:23:24 -07:00
|
|
|
const { node, syntax } = select(source, edit.operation === "append" ? edit.parent : edit.target);
|
2026-09-10 18:27:41 -07:00
|
|
|
let result: string;
|
|
|
|
|
if (edit.operation === "semantic-major") {
|
2026-09-15 15:23:24 -07:00
|
|
|
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");
|
2026-09-10 18:27:41 -07:00
|
|
|
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")!;
|
2026-09-15 15:23:24 -07:00
|
|
|
result = applySourceEdits(source, [
|
|
|
|
|
{
|
|
|
|
|
start: value?.start ?? brace.start,
|
|
|
|
|
end: value?.end ?? brace.start,
|
|
|
|
|
text: value ? String(edit.major) : `semantic-major ${edit.major} `,
|
|
|
|
|
},
|
|
|
|
|
]);
|
2026-09-10 18:27:41 -07:00
|
|
|
} else if (edit.operation === "conformance-id") {
|
2026-09-15 15:23:24 -07:00
|
|
|
if (node.kind !== "conformanceDecl" || !edit.id)
|
|
|
|
|
throw new Error("Identity enrollment requires a conformance and stable ID");
|
2026-09-10 18:27:41 -07:00
|
|
|
const existing = node.children.find((entry) => entry.kind === "stringLiteral");
|
|
|
|
|
if (existing) {
|
2026-09-15 15:23:24 -07:00
|
|
|
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");
|
2026-09-10 18:27:41 -07:00
|
|
|
return source;
|
|
|
|
|
}
|
|
|
|
|
const identifiers = node.children.filter((entry) => entry.kind === "identifier");
|
|
|
|
|
const position = identifiers[identifiers.length - 1].end;
|
2026-09-15 15:23:24 -07:00
|
|
|
result = applySourceEdits(source, [{ start: position, end: position, text: ` id ${JSON.stringify(edit.id)}` }]);
|
2026-09-10 18:27:41 -07:00
|
|
|
} 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");
|
2026-09-15 15:23:24 -07:00
|
|
|
result = applySourceEdits(source, [{ start: closing.start, end: closing.start, text: `\n${edit.source}\n` }]);
|
2026-09-10 18:27:41 -07:00
|
|
|
} else {
|
2026-09-14 10:26:11 -07:00
|
|
|
// A resource parser node includes imports/external declarations preceding
|
|
|
|
|
// its header. Replacing the declaration must not delete that preamble.
|
2026-09-15 15:23:24 -07:00
|
|
|
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 : "",
|
|
|
|
|
},
|
|
|
|
|
]);
|
2026-09-10 18:27:41 -07:00
|
|
|
}
|
|
|
|
|
const checked = parseQx(result);
|
2026-09-15 15:23:24 -07:00
|
|
|
if (checked.diagnostics.length)
|
|
|
|
|
throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`);
|
2026-09-10 18:27:41 -07:00
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => {
|
2026-09-15 15:23:24 -07:00
|
|
|
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision)
|
|
|
|
|
throw new Error("Resource scaffold requires a PascalCase name and explicit identities");
|
2026-09-10 18:27:41 -07:00
|
|
|
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;
|
|
|
|
|
};
|