Introduce repository-backed capability workspaces
- define and validate the capability and resource-lock languages - provision workspace source repositories through Central Gitea - build package runtimes from pinned standalone sources - replace legacy schema compilation with workspace persistence plans - add stable optimistic registers and Automerge CRDT documents - modernize TypeScript/Nix package builds and runtime activation readiness
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
BaseErrorListener,
|
||||
CharStream,
|
||||
CommonTokenStream,
|
||||
type ATNSimulator,
|
||||
type RecognitionException,
|
||||
type Recognizer,
|
||||
type Token,
|
||||
} from "antlr4ng";
|
||||
import { QuixosLockLexer } from "./generated/QuixosLockLexer.js";
|
||||
import {
|
||||
QuixosLockParser,
|
||||
type SourceBlockContext,
|
||||
} from "./generated/QuixosLockParser.js";
|
||||
import type {
|
||||
GitSource,
|
||||
LockedResource,
|
||||
QuixosRepositoryLock,
|
||||
} from "./types.js";
|
||||
|
||||
export type QuixosLockDiagnostic = {
|
||||
phase: "syntax" | "validation";
|
||||
code: string;
|
||||
message: string;
|
||||
fileName: string;
|
||||
line: number;
|
||||
column: number;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type QuixosLockParseResult =
|
||||
| { ok: true; lock: QuixosRepositoryLock; diagnostics: [] }
|
||||
| { ok: false; diagnostics: QuixosLockDiagnostic[] };
|
||||
|
||||
class SyntaxErrorListener extends BaseErrorListener {
|
||||
constructor(
|
||||
private readonly fileName: string,
|
||||
private readonly diagnostics: QuixosLockDiagnostic[],
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override syntaxError<S extends Token, T extends ATNSimulator>(
|
||||
_recognizer: Recognizer<T>,
|
||||
_offendingSymbol: S | null,
|
||||
line: number,
|
||||
column: number,
|
||||
message: string,
|
||||
_error: RecognitionException | null,
|
||||
): void {
|
||||
this.diagnostics.push({
|
||||
phase: "syntax",
|
||||
code: "syntax-error",
|
||||
message,
|
||||
fileName: this.fileName,
|
||||
line,
|
||||
column,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stringValue = (context: { getText(): string }): string =>
|
||||
JSON.parse(context.getText()) as string;
|
||||
|
||||
const lowerSource = (context: SourceBlockContext): GitSource => ({
|
||||
resolver: "git",
|
||||
repository: stringValue(context.stringLiteral(0)!),
|
||||
commit: stringValue(context.stringLiteral(1)!).toLowerCase(),
|
||||
});
|
||||
|
||||
const issue = (
|
||||
diagnostics: QuixosLockDiagnostic[],
|
||||
fileName: string,
|
||||
code: string,
|
||||
message: string,
|
||||
path?: string,
|
||||
) => diagnostics.push({
|
||||
phase: "validation",
|
||||
code,
|
||||
message,
|
||||
fileName,
|
||||
line: 1,
|
||||
column: 0,
|
||||
path,
|
||||
});
|
||||
|
||||
const validateSource = (
|
||||
source: GitSource,
|
||||
path: string,
|
||||
fileName: string,
|
||||
diagnostics: QuixosLockDiagnostic[],
|
||||
) => {
|
||||
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)) {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"invalid-git-commit",
|
||||
`${path}.commit must be a full 40- or 64-character Git object ID`,
|
||||
`${path}.commit`,
|
||||
);
|
||||
}
|
||||
let repository: URL;
|
||||
try {
|
||||
repository = new URL(source.repository);
|
||||
} catch {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"invalid-git-repository",
|
||||
`${path}.repository must be an absolute Git URL`,
|
||||
`${path}.repository`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!["https:", "ssh:"].includes(repository.protocol)) {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"unsupported-git-transport",
|
||||
`${path}.repository must use https:// or ssh://`,
|
||||
`${path}.repository`,
|
||||
);
|
||||
}
|
||||
if (repository.search || repository.hash) {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"decorated-git-repository",
|
||||
`${path}.repository must be a base repository URL without a query or fragment`,
|
||||
`${path}.repository`,
|
||||
);
|
||||
}
|
||||
if (repository.password || (repository.protocol === "https:" && repository.username)) {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"embedded-git-credential",
|
||||
`${path}.repository must not embed credentials`,
|
||||
`${path}.repository`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const parseQuixosLock = (
|
||||
source: string,
|
||||
fileName = "<memory>",
|
||||
): QuixosLockParseResult => {
|
||||
const diagnostics: QuixosLockDiagnostic[] = [];
|
||||
const listener = new SyntaxErrorListener(fileName, diagnostics);
|
||||
const lexer = new QuixosLockLexer(CharStream.fromString(source));
|
||||
lexer.removeErrorListeners();
|
||||
lexer.addErrorListener(listener);
|
||||
const parser = new QuixosLockParser(new CommonTokenStream(lexer));
|
||||
parser.removeErrorListeners();
|
||||
parser.addErrorListener(listener);
|
||||
const tree = parser.document();
|
||||
if (diagnostics.length > 0) return { ok: false, diagnostics };
|
||||
|
||||
const formatVersion = Number.parseInt(tree.INTEGER().getText(), 10);
|
||||
if (formatVersion !== 1) {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"unsupported-lock-version",
|
||||
`Unsupported Quixos lock format version ${formatVersion}`,
|
||||
"formatVersion",
|
||||
);
|
||||
}
|
||||
|
||||
const quixos = lowerSource(tree.quixosEntry().sourceBlock());
|
||||
validateSource(quixos, "quixos", fileName, diagnostics);
|
||||
|
||||
const resources: LockedResource[] = [];
|
||||
const bindings = new Set<string>();
|
||||
for (const [index, context] of tree.resourceEntry().entries()) {
|
||||
const kind = context.resourceKind().INTERFACE() ? "interface" : "package";
|
||||
const binding = context.identifier().getText();
|
||||
const key = `${kind}\0${binding}`;
|
||||
if (bindings.has(key)) {
|
||||
issue(
|
||||
diagnostics,
|
||||
fileName,
|
||||
"duplicate-resource-binding",
|
||||
`Duplicate ${kind} binding ${binding}`,
|
||||
`resources[${index}].binding`,
|
||||
);
|
||||
}
|
||||
bindings.add(key);
|
||||
const resource = { kind, binding, source: lowerSource(context.sourceBlock()) } as LockedResource;
|
||||
validateSource(resource.source, `resources[${index}].source`, fileName, diagnostics);
|
||||
resources.push(resource);
|
||||
}
|
||||
|
||||
return diagnostics.length > 0
|
||||
? { ok: false, diagnostics }
|
||||
: {
|
||||
ok: true,
|
||||
lock: { formatVersion: 1, quixos, resources },
|
||||
diagnostics: [],
|
||||
};
|
||||
};
|
||||
|
||||
const quoted = (value: string) => JSON.stringify(value);
|
||||
|
||||
const sourceLines = (source: GitSource, indentation: string): string[] => [
|
||||
`${indentation}repository ${quoted(source.repository)};`,
|
||||
`${indentation}commit ${quoted(source.commit.toLowerCase())};`,
|
||||
];
|
||||
|
||||
export const formatQuixosLock = (lock: QuixosRepositoryLock): string => {
|
||||
const lines = [
|
||||
"quixos-lock version 1 {",
|
||||
" quixos source {",
|
||||
...sourceLines(lock.quixos, " "),
|
||||
" }",
|
||||
];
|
||||
for (const resource of lock.resources) {
|
||||
lines.push(
|
||||
"",
|
||||
` ${resource.kind} ${resource.binding} source {`,
|
||||
...sourceLines(resource.source, " "),
|
||||
" }",
|
||||
);
|
||||
}
|
||||
lines.push("}", "");
|
||||
return lines.join("\n");
|
||||
};
|
||||
Reference in New Issue
Block a user