@llman-sdd/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@llman-sdd/core",
3
+ "version": "0.1.0",
4
+ "description": "Pure domain logic for llman-sdd (config, gherkin specs, validation, templates)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+ssh://git@github.com/StrayDragon/llman-sdd.git",
9
+ "directory": "packages/core"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": "./src/index.ts"
17
+ },
18
+ "scripts": {
19
+ "lint": "bun run --cwd ../.. lint",
20
+ "lint:fix": "bun run --cwd ../.. lint:fix",
21
+ "format:check": "bun run --cwd ../.. format:check",
22
+ "format:write": "bun run --cwd ../.. format:write",
23
+ "typecheck": "bun run --cwd ../.. typecheck"
24
+ },
25
+ "dependencies": {
26
+ "yaml": "^2.9.1",
27
+ "zod": "^4.6.5"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.4.0",
31
+ "node": ">=24.0.0"
32
+ }
33
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Archive freeze/thaw orchestration (review-freeze capability, r24/r25).
3
+ * Port of v1 change/freeze.rs: candidates = dated archive dirs; freeze adds
4
+ * them into `freezed_changes.7z.archived` (7z a updates existing archives)
5
+ * then removes the originals; thaw extracts selected dirs back into place.
6
+ * All filesystem effects flow through the injected FreezeIo.
7
+ */
8
+ import { join } from 'node:path';
9
+
10
+ import type { SevenZipPort } from './sevenzip.ts';
11
+
12
+ export const FREEZE_ARCHIVE_NAME = 'freezed_changes.7z.archived';
13
+ export const ARCHIVE_DIR_REL = 'llmanspec/changes/archive';
14
+
15
+ export interface FreezeIo {
16
+ exists(path: string): boolean;
17
+ listDir(path: string): string[];
18
+ /** rm -rf */
19
+ removeDir(path: string): void;
20
+ mkdirp(path: string): void;
21
+ /** Move a directory within the repo (same-device; adapter may copy). */
22
+ moveDir(from: string, to: string): void;
23
+ }
24
+
25
+ const DATED_RE = /^\d{4}-\d{2}-\d{2}-/u;
26
+
27
+ export function freezeCandidates(
28
+ io: FreezeIo,
29
+ archiveDir: string,
30
+ opts: { before?: string; keepRecent?: number },
31
+ ): string[] {
32
+ const all = io.exists(archiveDir)
33
+ ? io
34
+ .listDir(archiveDir)
35
+ .filter((n) => DATED_RE.test(n))
36
+ .toSorted()
37
+ : [];
38
+ let candidates = opts.before ? all.filter((n) => n.slice(0, 10) < (opts.before ?? '')) : [...all];
39
+ const keep = opts.keepRecent ?? 0;
40
+ if (keep > 0) candidates = candidates.slice(0, Math.max(0, candidates.length - keep));
41
+ return candidates;
42
+ }
43
+
44
+ export interface FreezeOpts {
45
+ before?: string;
46
+ keepRecent?: number;
47
+ dryRun?: boolean;
48
+ }
49
+
50
+ export interface FreezeRunResult {
51
+ lines: string[];
52
+ candidates: string[];
53
+ }
54
+
55
+ export async function runFreeze(
56
+ io: FreezeIo,
57
+ sz: SevenZipPort,
58
+ rootAbs: string,
59
+ opts: FreezeOpts,
60
+ ): Promise<FreezeRunResult> {
61
+ const candidates = freezeCandidates(io, ARCHIVE_DIR_REL, opts);
62
+ if (candidates.length === 0) {
63
+ return { candidates, lines: ['No archived changes selected for freezing.'] };
64
+ }
65
+ if (opts.dryRun) {
66
+ return {
67
+ candidates,
68
+ lines: [
69
+ `Dry run: freeze target ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`,
70
+ `Would freeze ${candidates.length} archived changes:`,
71
+ ...candidates.map((c) => ` - ${c}`),
72
+ ],
73
+ };
74
+ }
75
+ const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
76
+ await sz.add(archiveAbs, join(rootAbs, ARCHIVE_DIR_REL), candidates);
77
+ for (const c of candidates) {
78
+ io.removeDir(`${ARCHIVE_DIR_REL}/${c}`);
79
+ }
80
+ return {
81
+ candidates,
82
+ lines: [
83
+ `Froze ${candidates.length} archived changes into ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`,
84
+ ],
85
+ };
86
+ }
87
+
88
+ export async function runList(io: FreezeIo, sz: SevenZipPort, rootAbs: string): Promise<string[]> {
89
+ const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
90
+ if (!io.exists(archiveAbs)) {
91
+ return [`No freeze archive found at ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`];
92
+ }
93
+ const entries = (await sz.listEntries(archiveAbs))
94
+ .map((n) => n.replace(/\/$/u, ''))
95
+ .filter((n) => DATED_RE.test(n) && n.split('/').length === 1);
96
+ const unique = [...new Set(entries)].toSorted();
97
+ if (unique.length === 0) {
98
+ return [
99
+ `Freeze archive ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} contains no archived changes`,
100
+ ];
101
+ }
102
+ return [
103
+ `Frozen archived changes in ${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME} (${unique.length}):`,
104
+ ...unique.map((c) => ` - ${c}`),
105
+ ];
106
+ }
107
+
108
+ export interface ThawResult {
109
+ lines: string[];
110
+ restored: string[];
111
+ }
112
+
113
+ export async function runThaw(
114
+ io: FreezeIo,
115
+ sz: SevenZipPort,
116
+ rootAbs: string,
117
+ names: string[],
118
+ ): Promise<ThawResult> {
119
+ const archiveAbs = join(rootAbs, ARCHIVE_DIR_REL, FREEZE_ARCHIVE_NAME);
120
+ if (!io.exists(`${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`)) {
121
+ throw new Error(`No freeze archive found at ./${ARCHIVE_DIR_REL}/${FREEZE_ARCHIVE_NAME}`);
122
+ }
123
+ const tmpRel = 'llmanspec/.thaw-tmp';
124
+ const tmpAbs = join(rootAbs, tmpRel);
125
+ io.removeDir(tmpRel);
126
+ try {
127
+ await sz.extractAll(archiveAbs, tmpAbs);
128
+ const available = new Set(io.listDir(tmpRel).filter((n) => DATED_RE.test(n)));
129
+ const missing = names.filter((n) => !available.has(n));
130
+ if (missing.length > 0) {
131
+ throw new Error(
132
+ `archived change(s) not found in freeze archive: ${missing.join(', ')} — available: ${[...available].toSorted().join(', ')}`,
133
+ );
134
+ }
135
+ const restored: string[] = [];
136
+ for (const name of names.toSorted()) {
137
+ if (io.exists(`${ARCHIVE_DIR_REL}/${name}`)) {
138
+ throw new Error(`target already exists: ${name}`);
139
+ }
140
+ io.moveDir(`${tmpRel}/${name}`, `${ARCHIVE_DIR_REL}/${name}`);
141
+ restored.push(name);
142
+ }
143
+ return {
144
+ restored,
145
+ lines: [`Thawed ${restored.length} selected archived changes to ${ARCHIVE_DIR_REL}`],
146
+ };
147
+ } finally {
148
+ io.removeDir(tmpRel);
149
+ }
150
+ }
@@ -0,0 +1,110 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+
4
+ /**
5
+ * 7z adapter (review-freeze capability, r24/r25): 7z-wasm (Emscripten) with
6
+ * NODEFS mounts — self-contained, no system 7z dependency. One module
7
+ * instance per operation; extraction target must be a fresh directory
8
+ * (WASM 7z refuses existing -o dirs). Spike-proven on Bun: compress / list /
9
+ * extract keep directory structure.
10
+ */
11
+ import SevenZip from '7z-wasm';
12
+
13
+ export interface SevenZipPort {
14
+ /** Add `entries` (paths relative to `baseDir`) into `archivePath`. */
15
+ add(archivePath: string, baseDir: string, entries: string[]): Promise<void>;
16
+ /** List entry paths stored in the archive. */
17
+ listEntries(archivePath: string): Promise<string[]>;
18
+ /** Extract the whole archive into `destDir` (created fresh, must not exist). */
19
+ extractAll(archivePath: string, destDir: string): Promise<void>;
20
+ }
21
+
22
+ export class SevenZipError extends Error {}
23
+
24
+ interface EmscriptenModule {
25
+ FS: {
26
+ mkdir(path: string): void;
27
+ mount(type: unknown, opts: { root: string }, mountpoint: string): void;
28
+ chdir(path: string): void;
29
+ };
30
+ NODEFS: unknown;
31
+ callMain(args: string[]): number;
32
+ }
33
+
34
+ type ModuleFactory = (opts: {
35
+ noInitialRun: boolean;
36
+ print?: (text: string) => void;
37
+ printErr?: (text: string) => void;
38
+ }) => Promise<EmscriptenModule>;
39
+
40
+ async function initModule(print?: (text: string) => void): Promise<EmscriptenModule> {
41
+ const sink = (text: string): void => {
42
+ if (print) print(text);
43
+ };
44
+ const factory = SevenZip as unknown as ModuleFactory;
45
+ return factory({ noInitialRun: true, print: sink, printErr: sink });
46
+ }
47
+
48
+ function mount(mod: EmscriptenModule, seq: number, hostDir: string): string {
49
+ const mountpoint = `/mnt${seq}`;
50
+ mod.FS.mkdir(mountpoint);
51
+ mod.FS.mount(mod.NODEFS, { root: hostDir }, mountpoint);
52
+ return mountpoint;
53
+ }
54
+
55
+ /** 7z `l` listing: header/footer dashed rules around fixed-ish columns; our
56
+ * entry names are space-free, so the name is column 6+. */
57
+ function parseListNames(lines: string[]): string[] {
58
+ const names: string[] = [];
59
+ let inTable = false;
60
+ let rulesSeen = 0;
61
+ for (const line of lines) {
62
+ if (/^-+/u.test(line)) {
63
+ rulesSeen += 1;
64
+ inTable = rulesSeen >= 2 && rulesSeen <= 2 ? true : inTable;
65
+ if (rulesSeen === 3) break;
66
+ continue;
67
+ }
68
+ if (inTable) {
69
+ const parts = line.trim().split(/\s+/u);
70
+ if (parts.length >= 6) {
71
+ const attr = parts[2] ?? '';
72
+ const name = parts.slice(5).join(' ');
73
+ if (name !== '' && !attr.includes('D')) names.push(name);
74
+ }
75
+ }
76
+ }
77
+ return names;
78
+ }
79
+
80
+ export async function makeWasmSevenZip(): Promise<SevenZipPort> {
81
+ return {
82
+ async add(archivePath: string, baseDir: string, entries: string[]): Promise<void> {
83
+ const mod = await initModule();
84
+ const archMount = mount(mod, 1, join(archivePath, '..'));
85
+ const baseMount = mount(mod, 2, baseDir);
86
+ mod.FS.chdir(baseMount);
87
+ const rc = mod.callMain(['a', `${archMount}/${basename(archivePath)}`, ...entries]);
88
+ if (rc !== 0) throw new SevenZipError(`7z add failed (rc=${rc})`);
89
+ },
90
+
91
+ async listEntries(archivePath: string): Promise<string[]> {
92
+ const lines: string[] = [];
93
+ const mod = await initModule((text) => lines.push(text));
94
+ const archMount = mount(mod, 1, join(archivePath, '..'));
95
+ const rc = mod.callMain(['l', `${archMount}/${basename(archivePath)}`]);
96
+ if (rc !== 0) throw new SevenZipError(`7z list failed (rc=${rc})`);
97
+ return parseListNames(lines);
98
+ },
99
+
100
+ async extractAll(archivePath: string, destDir: string): Promise<void> {
101
+ const mod = await initModule();
102
+ const archMount = mount(mod, 1, join(archivePath, '..'));
103
+ mkdirSync(destDir, { recursive: true });
104
+ const destMount = mount(mod, 2, destDir);
105
+ mod.FS.chdir(destMount);
106
+ const rc = mod.callMain(['x', `${archMount}/${basename(archivePath)}`, '-y']);
107
+ if (rc !== 0) throw new SevenZipError(`7z extract failed (rc=${rc})`);
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * proposal.md frontmatter binding (change-lifecycle capability):
3
+ * comment-preserving read/upsert of the git binding keys via the `yaml`
4
+ * package's document model. Pure string-in/string-out.
5
+ */
6
+ import { parseDocument } from 'yaml';
7
+
8
+ export interface ChangeBinding {
9
+ branch: string;
10
+ baseBranch: string;
11
+ baseSha: string;
12
+ }
13
+
14
+ function splitFrontmatter(proposal: string): { body: string; frontmatter: string | null } {
15
+ if (!proposal.startsWith('---\n')) return { body: proposal, frontmatter: null };
16
+ const end = proposal.indexOf('\n---\n', 4);
17
+ if (end === -1) return { body: proposal, frontmatter: null };
18
+ return {
19
+ frontmatter: proposal.slice(4, end + 1),
20
+ body: proposal.slice(end + 5),
21
+ };
22
+ }
23
+
24
+ export function readBinding(proposal: string): ChangeBinding | null {
25
+ const { frontmatter } = splitFrontmatter(proposal);
26
+ if (frontmatter === null) return null;
27
+ const doc = parseDocument(frontmatter);
28
+ const branch = doc.get('branch');
29
+ const baseBranch = doc.get('base_branch');
30
+ const baseSha = doc.get('base_sha');
31
+ if (typeof branch !== 'string' || typeof baseBranch !== 'string' || typeof baseSha !== 'string') {
32
+ return null;
33
+ }
34
+ return { branch, baseBranch, baseSha };
35
+ }
36
+
37
+ /** Upsert the three binding keys, preserving all comments and other keys. */
38
+ export function writeBinding(proposal: string, binding: ChangeBinding): string {
39
+ const { body, frontmatter } = splitFrontmatter(proposal);
40
+ if (frontmatter === null) {
41
+ const fm = `---\nbranch: ${binding.branch}\nbase_branch: ${binding.baseBranch}\nbase_sha: ${binding.baseSha}\n---\n`;
42
+ return `${fm}${proposal}`;
43
+ }
44
+ const doc = parseDocument(frontmatter);
45
+ doc.set('branch', binding.branch);
46
+ doc.set('base_branch', binding.baseBranch);
47
+ doc.set('base_sha', binding.baseSha);
48
+ return `---\n${String(doc).replace(/\n$/u, '')}\n---\n${body}`;
49
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Change id derivation (change-lifecycle capability): legal ids are
3
+ * verb-prefixed kebab-case ASCII, ≤60 chars (CLI-checked convention).
4
+ */
5
+ const VERBS = [
6
+ 'bootstrap',
7
+ 'port',
8
+ 'add',
9
+ 'update',
10
+ 'remove',
11
+ 'refactor',
12
+ 'fix',
13
+ 'release',
14
+ 'migrate',
15
+ 'init',
16
+ 'draft',
17
+ 'archive',
18
+ ] as const;
19
+
20
+ const ID_CAP = 60;
21
+
22
+ export function isLegalChangeId(id: string): boolean {
23
+ return (
24
+ VERBS.some((v) => id === v || id.startsWith(`${v}-`)) &&
25
+ /^[a-z0-9-]+$/u.test(id) &&
26
+ id.length <= ID_CAP
27
+ );
28
+ }
29
+
30
+ export function deriveChangeId(description: string): string {
31
+ let id = description
32
+ .toLowerCase()
33
+ .normalize('NFKD')
34
+ .replaceAll(/[^a-z0-9]+/gu, '-')
35
+ .replaceAll(/^-+|-+$/gu, '')
36
+ .slice(0, ID_CAP)
37
+ .replaceAll(/-+$/gu, '');
38
+ if (id === '') id = 'change';
39
+ if (isLegalChangeId(id)) return id;
40
+ return `add-${id}`.slice(0, ID_CAP).replaceAll(/-+$/gu, '');
41
+ }
42
+
43
+ export const DRAFT_PROPOSAL_TEMPLATE = `## Why
44
+
45
+ TODO: Why is this change needed?
46
+
47
+ ## What Changes
48
+
49
+ TODO: Bullet list of what changes.
50
+ `;
@@ -0,0 +1,141 @@
1
+ import {
2
+ currentBranch,
3
+ defaultBranch,
4
+ isCleanTree,
5
+ revParseHead,
6
+ type GitLike,
7
+ } from '../git/spawnGit.ts';
8
+ import { readBinding, writeBinding } from './frontmatter.ts';
9
+ /**
10
+ * Change lifecycle (change-lifecycle capability): new / start / attach /
11
+ * finalize with the v1 git-native contract (r14-r16). All effects flow
12
+ * through the injected GitLike and FsIo ports — this module stays pure.
13
+ * FsIo paths are ROOT-RELATIVE (e.g. `llmanspec/changes/<id>/proposal.md`);
14
+ * the git cwd binding lives in the GitLike adapter.
15
+ */
16
+ import { DRAFT_PROPOSAL_TEMPLATE, deriveChangeId } from './id.ts';
17
+
18
+ export interface FsIo {
19
+ exists(path: string): boolean;
20
+ readText(path: string): string;
21
+ /** writeText creates parent directories as needed. */
22
+ writeText(path: string, content: string): void;
23
+ rename(from: string, to: string): void;
24
+ listDir(path: string): string[];
25
+ }
26
+
27
+ export class LifecycleError extends Error {}
28
+
29
+ export const CHANGES_DIR = 'llmanspec/changes';
30
+ const proposalPath = (id: string): string => `${CHANGES_DIR}/${id}/proposal.md`;
31
+
32
+ export function changeExists(io: FsIo, id: string): boolean {
33
+ return io.exists(proposalPath(id));
34
+ }
35
+
36
+ /** `change new [id] --from DESC`: derive a legal id and write the draft shell. */
37
+ export function newChange(
38
+ io: FsIo,
39
+ opts: { id?: string; from?: string },
40
+ ): { id: string; path: string } {
41
+ const id = opts.id ?? deriveChangeId(opts.from ?? '');
42
+ const path = proposalPath(id);
43
+ if (io.exists(path)) throw new LifecycleError(`change \`${id}\` already exists: ${path}`);
44
+ io.writeText(path, `---\ndepends_on: []\n---\n\n${DRAFT_PROPOSAL_TEMPLATE}`);
45
+ return { id, path };
46
+ }
47
+
48
+ /** `change start`: clean-tree + default-branch gates, then branch + binding. */
49
+ export function startChange(
50
+ git: GitLike,
51
+ io: FsIo,
52
+ id: string,
53
+ opts: { branchPrefix?: string } = {},
54
+ ): { branch: string; baseBranch: string; baseSha: string } {
55
+ const path = proposalPath(id);
56
+ if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
57
+ if (!isCleanTree(git))
58
+ throw new LifecycleError('working tree is not clean — commit or stash first');
59
+ const baseBranch = defaultBranch(git);
60
+ const here = currentBranch(git);
61
+ if (here !== baseBranch) {
62
+ throw new LifecycleError(
63
+ `must run from the default branch (${baseBranch}), currently on: ${here ?? 'detached'}`,
64
+ );
65
+ }
66
+ const branchPrefix = opts.branchPrefix ?? 'sdd/';
67
+ const branch = `${branchPrefix}${id}`;
68
+ git.run(['switch', '-c', branch]);
69
+ const baseSha = revParseHead(git);
70
+ io.writeText(path, writeBinding(io.readText(path), { branch, baseBranch, baseSha }));
71
+ return { branch, baseBranch, baseSha };
72
+ }
73
+
74
+ /** `change attach`: bind the current branch without gates. */
75
+ export function attachChange(git: GitLike, io: FsIo, id: string): { branch: string } {
76
+ const path = proposalPath(id);
77
+ if (!io.exists(path)) throw new LifecycleError(`proposal not found: ${path}`);
78
+ const branch = currentBranch(git);
79
+ if (branch === null) throw new LifecycleError('detached HEAD — cannot attach');
80
+ const baseSha = revParseHead(git);
81
+ io.writeText(
82
+ path,
83
+ writeBinding(io.readText(path), { branch, baseBranch: defaultBranch(git), baseSha }),
84
+ );
85
+ return { branch };
86
+ }
87
+
88
+ export interface FinalizeResult {
89
+ target: string;
90
+ archiveDir: string;
91
+ warnings: string[];
92
+ commitSubject: string;
93
+ }
94
+
95
+ /** `change finalize`: merge (squash default) + archive rename + close-out commit. */
96
+ export function finalizeChange(
97
+ git: GitLike,
98
+ io: FsIo,
99
+ id: string,
100
+ opts: { into?: string; method?: 'squash' | 'ff'; today?: string } = {},
101
+ ): FinalizeResult {
102
+ const path = proposalPath(id);
103
+ const binding = readBinding(io.readText(path));
104
+ if (binding === null) {
105
+ throw new LifecycleError(`change \`${id}\` has no branch binding — run start/attach first`);
106
+ }
107
+ const method = opts.method ?? 'squash';
108
+ const target = opts.into ?? binding.baseBranch ?? defaultBranch(git);
109
+ const warnings: string[] = [];
110
+
111
+ // Read the binding while still on the feature branch (its proposal.md may
112
+ // carry uncommitted binding edits), then switch and merge.
113
+ git.run(['switch', target]);
114
+ const mergeArgs =
115
+ method === 'ff'
116
+ ? ['merge', '--ff-only', binding.branch]
117
+ : ['merge', '--squash', binding.branch];
118
+ if (git.runOpt(mergeArgs) === null) {
119
+ git.runOpt(['merge', '--abort']);
120
+ warnings.push(
121
+ `merge ${method} failed — resolve manually, e.g. \`git merge ${method === 'ff' ? '--ff-only' : '--squash'} ${binding.branch}\``,
122
+ );
123
+ }
124
+
125
+ const date = opts.today ?? new Date().toISOString().slice(0, 10);
126
+ const archiveDir = `${CHANGES_DIR}/archive/${date}-${id}`;
127
+ io.rename(`${CHANGES_DIR}/${id}`, archiveDir);
128
+
129
+ git.run(['add', '-A']);
130
+ const commitSubject = `archive(sdd): ${id}`;
131
+ git.run(['commit', '-m', commitSubject]);
132
+ return { target, archiveDir, warnings, commitSubject };
133
+ }
134
+
135
+ /** `change diff <id>`: full diff of the bound branch vs base. */
136
+ export function changeDiff(git: GitLike, io: FsIo, id: string): string {
137
+ const binding = readBinding(io.readText(proposalPath(id)));
138
+ if (binding === null) throw new LifecycleError(`change \`${id}\` has no branch binding`);
139
+ const base = binding.baseBranch ?? defaultBranch(git);
140
+ return git.run(['diff', `${base}...${binding.branch}`]);
141
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Config loading (config-schema capability): parse YAML text + validate
3
+ * against the zod contract. Pure — no filesystem access; callers read the
4
+ * file and hand over the string.
5
+ *
6
+ * Validation failures report at most 5 issue lines (r6), each prefixed with
7
+ * its field path.
8
+ */
9
+ import { parse } from 'yaml';
10
+
11
+ import { sddConfigSchema, type SddConfig } from './schema.ts';
12
+
13
+ export const MAX_REPORTED_ISSUES = 5;
14
+
15
+ export class ConfigValidationError extends Error {
16
+ /** All issue lines (untruncated) for programmatic consumers. */
17
+ readonly issues: string[];
18
+
19
+ constructor(issues: string[]) {
20
+ const shown = issues.slice(0, MAX_REPORTED_ISSUES);
21
+ super(
22
+ `invalid llmanspec/config.yaml (${issues.length} issue${issues.length === 1 ? '' : 's'}, showing up to ${MAX_REPORTED_ISSUES}):\n` +
23
+ shown.map((i) => `- ${i}`).join('\n'),
24
+ );
25
+ this.name = 'ConfigValidationError';
26
+ this.issues = issues;
27
+ }
28
+ }
29
+
30
+ export function loadConfig(source: string): SddConfig {
31
+ let data: unknown;
32
+ try {
33
+ data = parse(source);
34
+ } catch (error) {
35
+ throw new ConfigValidationError([
36
+ `YAML parse error: ${error instanceof Error ? error.message : String(error)}`,
37
+ ]);
38
+ }
39
+ const result = sddConfigSchema.safeParse(data);
40
+ if (!result.success) {
41
+ const issues = result.error.issues.map((iss) => {
42
+ const path = iss.path.map(String).join('/');
43
+ return `${path || '<root>'}: ${iss.message}`;
44
+ });
45
+ throw new ConfigValidationError(issues);
46
+ }
47
+ return result.data;
48
+ }