gstack/scripts/lenses/yaml-subset.ts

239 lines
7.5 KiB
TypeScript

/**
* Minimal YAML subset parser for gstack lens metadata and project policy files.
*
* Supported syntax:
* - mappings by indentation
* - scalar sequences (`- value`)
* - inline arrays (`[a, "b", c]`)
* - quoted and unquoted strings, booleans, numbers, and null
*
* Deliberately unsupported:
* - anchors, aliases, tags, block scalars, flow mappings, and multi-document YAML
*
* The lens contract does not need the full YAML language. Keeping the parser
* constrained makes the accepted configuration surface explicit and testable.
*/
export type YamlScalar = string | number | boolean | null;
export type YamlValue = YamlScalar | YamlValue[] | { [key: string]: YamlValue };
export type YamlObject = { [key: string]: YamlValue };
interface ParsedLine {
indent: number;
text: string;
line: number;
}
function stripComment(raw: string): string {
let quote: '"' | "'" | null = null;
let escaped = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\' && quote === '"') {
escaped = true;
continue;
}
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '#' && (i === 0 || /\s/.test(raw[i - 1]))) {
return raw.slice(0, i).trimEnd();
}
}
return raw.trimEnd();
}
function splitInlineArray(input: string): string[] {
const values: string[] = [];
let quote: '"' | "'" | null = null;
let escaped = false;
let start = 0;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\' && quote === '"') {
escaped = true;
continue;
}
if (quote) {
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === ',') {
values.push(input.slice(start, i).trim());
start = i + 1;
}
}
values.push(input.slice(start).trim());
return values.filter((value) => value.length > 0);
}
function parseQuoted(input: string): string {
if (input.startsWith('"')) {
try {
return JSON.parse(input);
} catch (error) {
throw new Error(`Invalid double-quoted YAML string: ${input} (${String(error)})`);
}
}
return input.slice(1, -1).replace(/''/g, "'");
}
export function parseYamlScalar(input: string): YamlValue {
const value = input.trim();
if (value === '') return '';
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return parseQuoted(value);
}
if (value.startsWith('[') && value.endsWith(']')) {
return splitInlineArray(value.slice(1, -1)).map(parseYamlScalar);
}
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'null' || value === '~') return null;
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) return Number(value);
return value;
}
function tokenize(input: string): ParsedLine[] {
const lines: ParsedLine[] = [];
input.split(/\r?\n/).forEach((raw, index) => {
if (/\t/.test(raw.slice(0, raw.length - raw.trimStart().length))) {
throw new Error(`Tabs are not allowed for YAML indentation (line ${index + 1})`);
}
const noComment = stripComment(raw);
if (noComment.trim() === '') return;
const indent = noComment.length - noComment.trimStart().length;
if (indent % 2 !== 0) {
throw new Error(`YAML indentation must use multiples of two spaces (line ${index + 1})`);
}
lines.push({ indent, text: noComment.trim(), line: index + 1 });
});
return lines;
}
function parseSequence(lines: ParsedLine[], start: number, indent: number): [YamlValue[], number] {
const result: YamlValue[] = [];
let index = start;
while (index < lines.length) {
const line = lines[index];
if (line.indent < indent) break;
if (line.indent !== indent || !line.text.startsWith('-')) break;
const rest = line.text.slice(1).trim();
if (rest === '') {
const next = lines[index + 1];
if (!next || next.indent <= indent) {
result.push(null);
index += 1;
} else {
const [child, nextIndex] = parseBlock(lines, index + 1, next.indent);
result.push(child);
index = nextIndex;
}
continue;
}
// The lens configuration intentionally represents structured trigger entries
// as strings such as `pr_label=privileged-surface`. Reject sequence mappings
// rather than silently accepting a YAML shape the runtime cannot validate.
if (/^[A-Za-z0-9_.-]+:\s*/.test(rest)) {
throw new Error(`Sequence mappings are not supported by the lens YAML subset (line ${line.line})`);
}
result.push(parseYamlScalar(rest));
index += 1;
}
return [result, index];
}
function parseMapping(lines: ParsedLine[], start: number, indent: number): [YamlObject, number] {
const result: YamlObject = {};
let index = start;
while (index < lines.length) {
const line = lines[index];
if (line.indent < indent) break;
if (line.indent !== indent) {
throw new Error(`Unexpected indentation at line ${line.line}`);
}
if (line.text.startsWith('-')) break;
const match = line.text.match(/^([A-Za-z0-9_.-]+):(?:\s+(.*))?$/);
if (!match) throw new Error(`Invalid YAML mapping at line ${line.line}: ${line.text}`);
const [, key, rest] = match;
if (Object.prototype.hasOwnProperty.call(result, key)) {
throw new Error(`Duplicate YAML key '${key}' at line ${line.line}`);
}
if (rest !== undefined) {
result[key] = parseYamlScalar(rest);
index += 1;
continue;
}
const next = lines[index + 1];
if (!next || next.indent <= indent) {
result[key] = {};
index += 1;
continue;
}
const [child, nextIndex] = parseBlock(lines, index + 1, next.indent);
result[key] = child;
index = nextIndex;
}
return [result, index];
}
function parseBlock(lines: ParsedLine[], start: number, indent: number): [YamlValue, number] {
if (start >= lines.length) return [{}, start];
const first = lines[start];
if (first.indent !== indent) {
throw new Error(`Expected indentation ${indent}, found ${first.indent} at line ${first.line}`);
}
return first.text.startsWith('-')
? parseSequence(lines, start, indent)
: parseMapping(lines, start, indent);
}
export function parseYamlSubset(input: string): YamlObject {
const lines = tokenize(input);
if (lines.length === 0) return {};
if (lines[0].indent !== 0) throw new Error(`Top-level YAML must start at column 1 (line ${lines[0].line})`);
const [value, next] = parseBlock(lines, 0, 0);
if (next !== lines.length) {
throw new Error(`Could not parse YAML at line ${lines[next].line}`);
}
if (Array.isArray(value) || value === null || typeof value !== 'object') {
throw new Error('Top-level YAML must be a mapping');
}
return value as YamlObject;
}
export interface FrontmatterDocument {
frontmatter: YamlObject;
body: string;
}
export function parseFrontmatterDocument(content: string): FrontmatterDocument {
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
throw new Error('Lens file must begin with YAML frontmatter');
}
const normalized = content.replace(/\r\n/g, '\n');
const end = normalized.indexOf('\n---\n', 4);
if (end < 0) throw new Error('Lens file frontmatter is missing a closing --- marker');
return {
frontmatter: parseYamlSubset(normalized.slice(4, end)),
body: normalized.slice(end + 5).trim(),
};
}