mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +00:00
* fix(schema): make bundled pack inspection truthful (#2029) Two live bugs: - parseYamlMini had no block-scalar support, so a 'description: |' swallowed every following top-level key — the active gbrain-recommended pack loaded with 0 page types. Add parseBlockScalar for |/|-/|+ and >/>-/>+ in both mapping and sequence-sibling positions. - The bundled-pack list was hand-copied in three places (operations.ts had 2 names, mutate.ts had 3, load-active.ts had 7). New single registry src/core/schema-pack/bundled.ts carries all 7 shipped packs; every consumer derives from it. Takeover of #2029, rebased onto master (schema.ts hunks already landed). Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(minions): subagent default client resolves config-stored Anthropic key (#2048) The legacy subagent path constructed a bare new Anthropic() (env-only), so launchd/MCP workers whose key lives in gbrain config (anthropic_api_key) failed auth. anthropic-key.ts now exports resolveAnthropicKey() (env first, then config; hasAnthropicKey delegates) and makeSubagentHandler passes it as apiKey. Partial takeover of #2048 — only the auth patch; the path patches were superseded by the outputRoot mechanism (#2415). Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): point bundled-registry test at bundled.ts source of truth The bundled pack list moved from load-active.ts to bundled.ts in the truthful-inspection refactor; the T4 registry test still grepped load-active.ts source. Assert BUNDLED_PACK_NAMES directly instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(schema): block scalars keep '#' as literal content Inside a YAML block scalar '#' is content, not a comment; parseBlockScalar was routing lines through stripComment/isBlank, truncating descriptions like 'see issue #2029' and blanking comment-looking lines. Use the raw line inside the scalar. Adds a pinning test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: JiraiyaETH <JiraiyaETH@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
// v0.38 Schema Pack loader — YAML/JSON sniffing + normalization.
|
|
//
|
|
// Pack authors choose YAML or JSON. The loader sniffs by file extension
|
|
// (`.yaml` / `.yml` / `.json`), parses through the appropriate path, and
|
|
// normalizes to a single `SchemaPackManifest` shape before validation
|
|
// (manifest-v1.ts handles the validation half).
|
|
//
|
|
// YAML parsing: hand-rolled following the `storage-config.ts` pattern.
|
|
// Avoids js-yaml dependency add (gbrain already ships ~70% of its YAML
|
|
// touchpoints hand-parsed). For pack manifests, the YAML subset we accept
|
|
// is intentionally narrow: scalars, lists, nested objects up to 4 levels
|
|
// deep, no anchors, no aliases, no tags. If users want broader YAML,
|
|
// they ship JSON.
|
|
//
|
|
// Fail-loud: malformed YAML throws SchemaPackLoaderError with line/col
|
|
// when available. Empty file → INVALID_SHAPE. Unknown extension → falls
|
|
// through to JSON.parse attempt.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { extname } from 'node:path';
|
|
import { parseSchemaPackManifest, type SchemaPackManifest } from './manifest-v1.ts';
|
|
|
|
export class SchemaPackLoaderError extends Error {
|
|
readonly code: 'PARSE_ERROR' | 'FILE_NOT_FOUND' | 'UNSUPPORTED_EXTENSION';
|
|
readonly path: string;
|
|
|
|
constructor(code: 'PARSE_ERROR' | 'FILE_NOT_FOUND' | 'UNSUPPORTED_EXTENSION', message: string, path: string) {
|
|
super(message);
|
|
this.name = 'SchemaPackLoaderError';
|
|
this.code = code;
|
|
this.path = path;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load + parse + validate a pack from disk. Returns the validated manifest.
|
|
* Throws SchemaPackLoaderError (file/parse errors) or
|
|
* SchemaPackManifestError (shape/version errors).
|
|
*/
|
|
export function loadPackFromFile(path: string): SchemaPackManifest {
|
|
let content: string;
|
|
try {
|
|
content = readFileSync(path, 'utf-8');
|
|
} catch (e) {
|
|
throw new SchemaPackLoaderError('FILE_NOT_FOUND', `cannot read pack file: ${(e as Error).message}`, path);
|
|
}
|
|
return loadPackFromString(content, path);
|
|
}
|
|
|
|
/**
|
|
* Parse a manifest from a raw string. Extension-driven; `.json` uses
|
|
* JSON.parse, anything else uses the YAML mini-parser. Test seam.
|
|
*/
|
|
export function loadPackFromString(content: string, hint: string): SchemaPackManifest {
|
|
const ext = extname(hint).toLowerCase();
|
|
let raw: unknown;
|
|
if (ext === '.json') {
|
|
try {
|
|
raw = JSON.parse(content);
|
|
} catch (e) {
|
|
throw new SchemaPackLoaderError('PARSE_ERROR', `JSON parse error: ${(e as Error).message}`, hint);
|
|
}
|
|
} else {
|
|
// Default to YAML for .yaml, .yml, and unknown extensions.
|
|
try {
|
|
raw = parseYamlMini(content);
|
|
} catch (e) {
|
|
throw new SchemaPackLoaderError('PARSE_ERROR', `YAML parse error: ${(e as Error).message}`, hint);
|
|
}
|
|
}
|
|
return parseSchemaPackManifest(raw, { path: hint });
|
|
}
|
|
|
|
/**
|
|
* Mini YAML parser for the schema-pack manifest subset.
|
|
*
|
|
* Accepted syntax:
|
|
* - Top-level mapping (key: value pairs)
|
|
* - Nested mappings via indentation (2-space convention)
|
|
* - Sequences via "- item" lines (lists of scalars or maps)
|
|
* - Scalar values: strings (quoted or bare), integers, booleans, null
|
|
* - `#` comments to end-of-line (outside string values)
|
|
* - Block strings via `|` (literal) or `>` (folded) NOT supported in v1
|
|
*
|
|
* Rejected by design: anchors (&), aliases (*), tags (!), flow style
|
|
* ({...}, [...] except as JSON), block scalars (|, >), multi-document (---).
|
|
* Pack authors who need these features should ship JSON.
|
|
*
|
|
* This is intentionally narrow. The skill-pack and storage-config parsers
|
|
* use similar hand-rolled patterns; this one is shape-customized for pack
|
|
* manifests (4-level nest, sequences-of-maps for page_types/link_types).
|
|
*/
|
|
export function parseYamlMini(content: string): unknown {
|
|
const lines = content.split(/\r?\n/);
|
|
let i = 0;
|
|
|
|
function stripComment(line: string): string {
|
|
// Strip comments outside quoted strings. Simple state machine.
|
|
let result = '';
|
|
let inSingle = false;
|
|
let inDouble = false;
|
|
for (let j = 0; j < line.length; j++) {
|
|
const c = line[j];
|
|
if (c === "'" && !inDouble) inSingle = !inSingle;
|
|
else if (c === '"' && !inSingle) inDouble = !inDouble;
|
|
else if (c === '#' && !inSingle && !inDouble) break;
|
|
result += c;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseScalar(raw: string): unknown {
|
|
const trimmed = raw.trim();
|
|
if (trimmed === '' || trimmed === '~' || trimmed === 'null') return null;
|
|
if (trimmed === 'true') return true;
|
|
if (trimmed === 'false') return false;
|
|
// JSON-style array or object — try JSON.parse first
|
|
if ((trimmed.startsWith('[') && trimmed.endsWith(']')) ||
|
|
(trimmed.startsWith('{') && trimmed.endsWith('}'))) {
|
|
try { return JSON.parse(trimmed); } catch { /* fall through to flow-sequence parse */ }
|
|
// YAML flow sequence: [foo, bar, baz] with bare words.
|
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
const inner = trimmed.slice(1, -1).trim();
|
|
if (inner === '') return [];
|
|
return inner.split(',').map(item => parseScalar(item.trim()));
|
|
}
|
|
}
|
|
// Quoted string
|
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
// Number
|
|
if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
|
|
if (/^-?\d+\.\d+$/.test(trimmed)) return parseFloat(trimmed);
|
|
// Bare string
|
|
return trimmed;
|
|
}
|
|
|
|
function indentOf(line: string): number {
|
|
let n = 0;
|
|
while (n < line.length && line[n] === ' ') n++;
|
|
return n;
|
|
}
|
|
|
|
function isBlank(line: string): boolean {
|
|
return stripComment(line).trim() === '';
|
|
}
|
|
|
|
function parseBlock(baseIndent: number): unknown {
|
|
// Decide if this block is a sequence (starts with "- ") or a mapping.
|
|
while (i < lines.length && isBlank(lines[i])) i++;
|
|
if (i >= lines.length) return null;
|
|
const firstNonBlank = stripComment(lines[i]);
|
|
const firstIndent = indentOf(firstNonBlank);
|
|
if (firstIndent < baseIndent) return null;
|
|
const firstStripped = firstNonBlank.slice(firstIndent);
|
|
if (firstStripped.startsWith('- ')) return parseSequence(baseIndent);
|
|
return parseMapping(baseIndent);
|
|
}
|
|
|
|
function parseBlockScalar(parentIndent: number, folded: boolean): string {
|
|
const contentIndent = parentIndent + 2;
|
|
const out: string[] = [];
|
|
while (i < lines.length) {
|
|
const raw = lines[i];
|
|
// Inside a block scalar everything is literal content — '#' is NOT a
|
|
// comment here, so use the raw line (no stripComment / isBlank).
|
|
if (raw.trim() === '') {
|
|
out.push('');
|
|
i++;
|
|
continue;
|
|
}
|
|
const indent = indentOf(raw);
|
|
if (indent <= parentIndent) break;
|
|
out.push(raw.slice(Math.min(contentIndent, indent)));
|
|
i++;
|
|
}
|
|
if (folded) {
|
|
return out.join(' ').replace(/\s+$/u, '');
|
|
}
|
|
return out.join('\n').replace(/\n+$/u, '');
|
|
}
|
|
|
|
function parseSequence(baseIndent: number): unknown[] {
|
|
const result: unknown[] = [];
|
|
while (i < lines.length) {
|
|
while (i < lines.length && isBlank(lines[i])) i++;
|
|
if (i >= lines.length) break;
|
|
const line = stripComment(lines[i]);
|
|
const indent = indentOf(line);
|
|
if (indent < baseIndent) break;
|
|
const stripped = line.slice(indent);
|
|
if (!stripped.startsWith('- ')) break;
|
|
const after = stripped.slice(2);
|
|
i++;
|
|
// Inline scalar after "- "
|
|
if (!after.includes(':') || after.endsWith(':')) {
|
|
if (after.endsWith(':')) {
|
|
// "- key:" followed by nested mapping
|
|
const map: Record<string, unknown> = {};
|
|
map[after.slice(0, -1).trim()] = parseBlock(indent + 2);
|
|
// Continue parsing additional keys at the same indent
|
|
while (i < lines.length) {
|
|
while (i < lines.length && isBlank(lines[i])) i++;
|
|
if (i >= lines.length) break;
|
|
const next = stripComment(lines[i]);
|
|
const nextIndent = indentOf(next);
|
|
if (nextIndent !== indent + 2) break;
|
|
const nextStripped = next.slice(nextIndent);
|
|
const colonIdx = nextStripped.indexOf(':');
|
|
if (colonIdx < 0) break;
|
|
const key = nextStripped.slice(0, colonIdx).trim();
|
|
const rest = nextStripped.slice(colonIdx + 1).trim();
|
|
i++;
|
|
if (rest === '') {
|
|
map[key] = parseBlock(nextIndent + 2);
|
|
} else {
|
|
map[key] = parseScalar(rest);
|
|
}
|
|
}
|
|
result.push(map);
|
|
} else {
|
|
result.push(parseScalar(after));
|
|
}
|
|
} else {
|
|
// "- key: value" — start of an inline mapping entry
|
|
const colonIdx = after.indexOf(':');
|
|
const key = after.slice(0, colonIdx).trim();
|
|
const rest = after.slice(colonIdx + 1).trim();
|
|
const map: Record<string, unknown> = {};
|
|
if (rest === '') {
|
|
map[key] = parseBlock(indent + 2);
|
|
} else {
|
|
map[key] = parseScalar(rest);
|
|
}
|
|
// Continue siblings at indent+2
|
|
while (i < lines.length) {
|
|
while (i < lines.length && isBlank(lines[i])) i++;
|
|
if (i >= lines.length) break;
|
|
const next = stripComment(lines[i]);
|
|
const nextIndent = indentOf(next);
|
|
if (nextIndent !== indent + 2) break;
|
|
const nextStripped = next.slice(nextIndent);
|
|
if (nextStripped.startsWith('- ')) break;
|
|
const colonIdx2 = nextStripped.indexOf(':');
|
|
if (colonIdx2 < 0) break;
|
|
const key2 = nextStripped.slice(0, colonIdx2).trim();
|
|
const rest2 = nextStripped.slice(colonIdx2 + 1).trim();
|
|
i++;
|
|
if (rest2 === '') {
|
|
map[key2] = parseBlock(nextIndent + 2);
|
|
} else if (rest2 === '|' || rest2 === '|-' || rest2 === '|+') {
|
|
map[key2] = parseBlockScalar(nextIndent, false);
|
|
} else if (rest2 === '>' || rest2 === '>-' || rest2 === '>+') {
|
|
map[key2] = parseBlockScalar(nextIndent, true);
|
|
} else {
|
|
map[key2] = parseScalar(rest2);
|
|
}
|
|
}
|
|
result.push(map);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseMapping(baseIndent: number): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
while (i < lines.length) {
|
|
while (i < lines.length && isBlank(lines[i])) i++;
|
|
if (i >= lines.length) break;
|
|
const line = stripComment(lines[i]);
|
|
const indent = indentOf(line);
|
|
if (indent < baseIndent) break;
|
|
if (indent > baseIndent) {
|
|
// Shouldn't happen if outer loop set up correctly; treat as end.
|
|
break;
|
|
}
|
|
const stripped = line.slice(indent);
|
|
const colonIdx = stripped.indexOf(':');
|
|
if (colonIdx < 0) break;
|
|
const key = stripped.slice(0, colonIdx).trim();
|
|
const rest = stripped.slice(colonIdx + 1).trim();
|
|
i++;
|
|
if (rest === '') {
|
|
result[key] = parseBlock(indent + 2);
|
|
} else if (rest === '|' || rest === '|-' || rest === '|+') {
|
|
result[key] = parseBlockScalar(indent, false);
|
|
} else if (rest === '>' || rest === '>-' || rest === '>+') {
|
|
result[key] = parseBlockScalar(indent, true);
|
|
} else {
|
|
result[key] = parseScalar(rest);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return parseBlock(0);
|
|
}
|