diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7a22382..c11d806 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -25,11 +25,16 @@ jobs: - name: Syntax check tools run: | node --check tools/prepare-deck.mjs + node --check tools/validate-brief.mjs + node --check tools/test-brief-validation.mjs node --check tools/test-agent-workflow.mjs node --check tools/evaluate-edit.mjs node --check tools/test-editability-eval.mjs node --check tools/browser-qa.mjs + - name: Test deck brief validation gate + run: node tools/test-brief-validation.mjs + - name: Test editability evaluator run: node tools/test-editability-eval.mjs diff --git a/AGENTS.md b/AGENTS.md index 961a915..e49e666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ LICENSE MIT .agents/skills/getslide/ Agent Skills / Codex project skill .claude/skills/getslide/ Byte-identical Claude Code project skill docs/ - DECK_BRIEF.schema.md Deck brief standard + DECK_BRIEF.schema.md Deck brief standard + executable shape HTML_DECK_CONTRACT.md Requirements every generated deck must satisfy STUDENT_DEVELOPER_PATTERNS.md Slide pattern catalog VALIDATION.md Static + real-browser validation checklist @@ -62,6 +62,8 @@ prompts/ review-deck-structure.md Review prompt tools/ prepare-deck.mjs Arbitrary text/Markdown source staging + validate-brief.mjs Structural/source-sufficiency brief gate + test-brief-validation.mjs Brief-gate regression checks validate-deck.mjs Structural validator evaluate-edit.mjs Before/after edit-containment evaluator test-editability-eval.mjs Positive/negative editability regressions @@ -78,14 +80,23 @@ Do not add new top-level folders without a decision recorded in `PRODUCT_DECISIO - **Decks follow the contract.** Any HTML deck you create or edit must satisfy `docs/HTML_DECK_CONTRACT.md`: single file, `section.slide` with unique `data-slide-id` and a `data-pattern` from the catalog, TOC/nav, page numbers, keyboard navigation, print CSS, `:root` design tokens. - **Edit slides, not the system.** When changing deck content, do not restructure CSS tokens, navigation script, or slide markup conventions unless that is the explicit task. - **Source grounding is mandatory.** New factual content must be traceable to the source/brief. Missing facts stay in `missing_information`; low-risk framing defaults stay visible in `auto_filled_assumptions`. +- **Briefs are executable contracts.** Before generating a deck, `DECK_BRIEF.md` must pass `tools/validate-brief.mjs`. A structural PASS never substitutes for semantic source/brief review. - **Templates may contain placeholders; examples may not.** `templates/` uses clearly-safe placeholder content. Anything under `examples/` must be complete, with zero unresolved placeholders (`TODO`, `TBD`, double curly braces, `[PLACEHOLDER]`, lorem ipsum). - **Keep schema and examples in sync.** If you change `docs/DECK_BRIEF.schema.md`, update the fictional example brief and prompts that reference the fields. - **Record decisions.** Any scope or positioning change goes into `PRODUCT_DECISIONS.md` in the same change. - **Portable workflow only.** Public agent helpers may stage user-supplied text locally and assemble checked-in public resources; they may not fetch, upload, host, or call a model/provider. -- **Mechanical gates are bounded evidence.** `evaluate-edit.mjs` proves declared structural/change containment, and `browser-qa.mjs` proves specific runtime behaviors. Neither is semantic source-fidelity or general visual-quality proof. +- **Mechanical gates are bounded evidence.** `validate-brief.mjs` proves brief shape/mechanical sufficiency, `evaluate-edit.mjs` proves declared structural/change containment, and `browser-qa.mjs` proves specific runtime behaviors. None proves semantic source fidelity or general visual quality. ## Validation expectations +After creating or changing `DECK_BRIEF.md`, run: + +```sh +node tools/validate-brief.mjs +``` + +`prepare-deck.mjs` also enforces this gate before creating `brief-to-deck-packet.md`. + After editing or creating any deck, run the static validator on every deck you touched and confirm exit code `0`: ```sh @@ -107,9 +118,12 @@ node tools/browser-qa.mjs When changing the agent/editability workflow or validation tooling, run the proportional regression set: ```sh +node --check tools/validate-brief.mjs +node --check tools/test-brief-validation.mjs node --check tools/evaluate-edit.mjs node --check tools/test-editability-eval.mjs node --check tools/browser-qa.mjs +node tools/test-brief-validation.mjs node tools/test-editability-eval.mjs node tools/browser-qa.mjs examples/hackathon-demo/index.html node tools/test-agent-workflow.mjs @@ -121,13 +135,13 @@ Run existing benchmark aggregation only when the completed local benchmark runs Then perform remaining manual checks in `docs/VALIDATION.md` when applicable, especially: -- source/brief fidelity for every changed factual claim, +- source/brief fidelity for every factual claim, - print preview with one slide per page, - projector readability and composition, - the 1280×800 overflow viewport until it is automated, - no ghostwriting language or private traces in the diff. -For claims about iterative AI editability, a validator PASS alone is insufficient. For claims about visual/browser quality, static inspection alone is insufficient. +For claims about source grounding, a brief-validator PASS alone is insufficient. For claims about iterative AI editability, a deck-validator PASS alone is insufficient. For claims about visual/browser quality, static inspection alone is insufficient. Report what you actually checked; never convert a mechanical containment check, static validator, or single browser viewport into broader proof than it provides. diff --git a/PRODUCT_DECISIONS.md b/PRODUCT_DECISIONS.md index f68a863..f54bb03 100644 --- a/PRODUCT_DECISIONS.md +++ b/PRODUCT_DECISIONS.md @@ -2,7 +2,7 @@ Current decision log for getslide.md. This file records what has been decided, what is deferred, what is explicitly out of scope for now, and what remains open. Update it whenever a decision changes. -Last updated: 2026-08-24 +Last updated: 2026-08-25 ## Decided @@ -27,13 +27,14 @@ Last updated: 2026-08-24 | Repo language | **English-first** for all canonical public repo content. Translated README content may exist but must not become the source of truth. | | License | MIT. | | Dependencies | None. No build step, no npm packages, no CDN assets. Decks and deterministic helper scripts use platform/browser or Node built-ins only. | -| CI validation | GitHub Actions may run the zero-dependency Node regression checks, edit-containment tests, real installed-browser QA, and the example validator on pull requests and `main`. CI performs **no deploy, publish, artifact upload, model call, browser package installation, or external service integration**. | +| CI validation | GitHub Actions may run the zero-dependency Node regression checks, brief/edit-containment tests, real installed-browser QA, and the example validator on pull requests and `main`. CI performs **no deploy, publish, artifact upload, model call, browser package installation, or external service integration**. | | Experimental harness | v0.2 provides a provider-neutral, file-based Markdown-to-brief-to-deck benchmark harness. It uses prompts and Node built-ins only; it is not hosted generation or automated factual verification. | | Generated deck navigation | v0.2.1 requires generated decks to preserve the canonical base-template navigation script. Static validation checks conservative behavior signals; runtime browser and print QA remain separate gates. | | Agent-native source staging | v0.3 adds `tools/prepare-deck.mjs` so one arbitrary text/Markdown source can be staged into the same brief/deck packet flow without benchmark metadata or a model/network call. | | Editability evaluation | v0.3 treats post-generation targeted editing as a first-class quality axis. Validator PASS is necessary but insufficient; source grounding, change containment, navigation integrity, and visual/manual QA are evaluated separately. | | Executable edit containment | v0.3.1 adds `tools/evaluate-edit.mjs` with explicit `targeted`, `split`, `reorder`, and `compression` policies. It invokes the deck validator and blocks undeclared slide/system changes, `:root` drift, and navigation/script drift. This is mechanical containment evidence, **not semantic factual proof**. | | Real browser QA | v0.3.1 adds `tools/browser-qa.mjs`, which uses an already installed Chrome/Chromium through the DevTools protocol and Node built-ins to check actual `file://` runtime navigation, TOC/hash/page-number synchronization, and one canonical viewport overflow gate. No Puppeteer/Playwright or browser download is added. | +| Executable brief gate | v0.3.2 validates the documented `DECK_BRIEF.md` top-level scalar/list subset before generation. Required fields, confidence, optional field shapes, duplicate/malformed syntax, and the mechanical `high`-confidence/no-unresolved-gap invariant are checked; invalid briefs cannot produce a generation packet. This is structural evidence, **not semantic source-fidelity proof**. | ## Deferred @@ -88,4 +89,4 @@ A future hosted wrapper should sell convenience and operational quality, not acc 5. Does `DECK_BRIEF.md` need an explicit schema-version field before external tools start producing briefs? 6. How much of the pattern catalog should remain public as it grows: all of it or a stable public core plus specialized packs? 7. If agent-native usage is strong but setup friction blocks non-agent users, what is the thinnest hosted wrapper that removes that friction without becoming a generic slide SaaS? -8. After mechanical containment and one canonical browser viewport are reliable, which next quality gate delivers more value: semantic source-review automation, 1280×800 responsive QA, or print/PDF rendering evidence? +8. After mechanical containment, executable brief validation, and one canonical browser viewport are reliable, which next quality gate delivers more value: semantic source-review automation, 1280×800 responsive QA, or print/PDF rendering evidence? diff --git a/docs/AGENT_WORKFLOW.md b/docs/AGENT_WORKFLOW.md index 3b2520f..49b708b 100644 --- a/docs/AGENT_WORKFLOW.md +++ b/docs/AGENT_WORKFLOW.md @@ -71,6 +71,14 @@ The important separation is: The agent must never turn a missing metric, user count, result, team role, feature, architecture detail, or implementation fact into an assumption merely to make the deck look complete. +Before generation, validate the brief itself: + +```sh +node tools/validate-brief.mjs /DECK_BRIEF.md +``` + +The validator checks the documented top-level scalar/list shape, required fields, confidence enum, optional field types, duplicate fields, and the mechanical rule that `confidence: high` cannot retain unresolved `missing_information`. It does **not** prove that `key_points` are semantically supported by the source; source/brief review remains a separate requirement. + ## Source-sufficiency gate The agent uses the existing confidence field as an execution gate. @@ -84,7 +92,7 @@ A direct end-to-end deck request may proceed without another approval turn when: - no unresolved factual gap is required for the proposed narrative, - no new risky assumption is needed. -The brief still remains a saved, reviewable artifact. +The brief still remains a saved, reviewable artifact. `validate-brief.mjs` will mechanically reject `high` confidence if `missing_information` is non-empty. ### Medium @@ -104,7 +112,9 @@ After `DECK_BRIEF.md` exists, rerun the same preparation command: node tools/prepare-deck.mjs --out ``` -The command now creates `brief-to-deck-packet.md`, which bundles: +`prepare-deck.mjs` reruns `validate-brief.mjs` before generation. If validation fails, it removes any stale `brief-to-deck-packet.md` and stops. A malformed or mechanically inconsistent brief therefore cannot silently advance to deck generation. + +When the brief passes, the command creates `brief-to-deck-packet.md`, which bundles: - the current brief - `prompts/brief-to-html-deck.md` @@ -137,6 +147,7 @@ Use `docs/EDITABILITY_EVAL.md` when testing post-generation changes. A structura Changes to the agent-native workflow should run: ```sh +node tools/test-brief-validation.mjs node tools/test-agent-workflow.mjs node tools/test-generation-harness.mjs node tools/validate-deck.mjs examples/hackathon-demo/index.html diff --git a/docs/DECK_BRIEF.schema.md b/docs/DECK_BRIEF.schema.md index e7f0c22..d2a0945 100644 --- a/docs/DECK_BRIEF.schema.md +++ b/docs/DECK_BRIEF.schema.md @@ -14,6 +14,16 @@ A deck brief exists so that: A `DECK_BRIEF.md` file is a Markdown file whose content is a single YAML frontmatter block, optionally followed by free-form Markdown notes. The YAML block is the machine-readable part; the notes are for humans. +The public workflow intentionally uses a small YAML subset: top-level scalar fields and indented dash lists. Avoid nested mappings, block scalars, anchors, aliases, or other advanced YAML features. This keeps the brief portable and allows zero-dependency validation. + +Run the executable structural gate before generation: + +```sh +node tools/validate-brief.mjs +``` + +`tools/prepare-deck.mjs` runs the same gate automatically and refuses to create `brief-to-deck-packet.md` when the brief is invalid. This validates shape and the mechanical confidence/gap invariant; it does **not** prove that a claim is actually supported by the source. + ## Fields ### Required fields @@ -49,6 +59,8 @@ A `DECK_BRIEF.md` file is a Markdown file whose content is a single YAML frontma | `medium` | Sources cover the core story, but some slides depend on assumptions or have gaps. | Review `missing_information` and `auto_filled_assumptions` before generating. | | `low` | Sources are thin or ambiguous; major content had to be assumed. | Add material or answer the missing-information items first. | +The executable validator enforces one mechanical invariant from this table: a brief cannot claim `confidence: high` while `missing_information` still contains unresolved items. + ## Rules for `missing_information` and `auto_filled_assumptions` - Anything the deck states that is **not in the source materials** must appear in `auto_filled_assumptions`. diff --git a/tools/prepare-deck.mjs b/tools/prepare-deck.mjs index 9eacd3d..fea6f34 100644 --- a/tools/prepare-deck.mjs +++ b/tools/prepare-deck.mjs @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; import { basename, dirname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; const args = process.argv.slice(2); if (args.length !== 3 || args[1] !== '--out') { @@ -54,13 +55,25 @@ const sourcePacket = [ writeFileSync(resolve(out, 'source-to-brief-packet.md'), sourcePacket, 'utf8'); if (!existsSync(briefPath)) { - if (existsSync(deckPacketPath)) unlinkSync(deckPacketPath); + removeDeckPacket(); console.log('Prepared ' + display(out) + '. Next: create DECK_BRIEF.md from source-to-brief-packet.md, then rerun this command.'); process.exit(0); } const brief = readFileSync(briefPath, 'utf8'); -if (!brief.trim()) fail('DECK_BRIEF.md exists but is empty: ' + display(briefPath)); +if (!brief.trim()) { + removeDeckPacket(); + fail('DECK_BRIEF.md exists but is empty: ' + display(briefPath)); +} + +const briefValidator = resolve(root, 'tools', 'validate-brief.mjs'); +const validation = spawnSync(process.execPath, [briefValidator, briefPath], { encoding: 'utf8' }); +if (validation.status !== 0) { + removeDeckPacket(); + const output = (validation.stdout || validation.stderr || 'brief validation failed').trim(); + if (output) console.error(output); + fail('DECK_BRIEF.md failed validation. Fix the brief before preparing a generation packet.'); +} const files = [ 'prompts/brief-to-html-deck.md', @@ -75,6 +88,10 @@ const deckPacket = '# Brief-to-deck packet\n\n## Current brief\n\n' + brief + '\ writeFileSync(deckPacketPath, deckPacket, 'utf8'); console.log('Prepared ' + display(out) + '. Next: create index.html from brief-to-deck-packet.md, then validate it.'); +function removeDeckPacket() { + if (existsSync(deckPacketPath)) unlinkSync(deckPacketPath); +} + function display(path) { return (relative(process.cwd(), path) || basename(path)).replace(/\\/g, '/'); } diff --git a/tools/test-brief-validation.mjs b/tools/test-brief-validation.mjs new file mode 100644 index 0000000..f7c616d --- /dev/null +++ b/tools/test-brief-validation.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// Zero-dependency regression cases for tools/validate-brief.mjs and prepare-deck gating. +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = process.cwd(); +const node = process.execPath; +const validator = join(root, 'tools', 'validate-brief.mjs'); +const prepare = join(root, 'tools', 'prepare-deck.mjs'); +const exampleBrief = readFileSync(join(root, 'examples', 'hackathon-demo', 'DECK_BRIEF.md'), 'utf8'); +const work = mkdtempSync(join(tmpdir(), 'getslide-brief-validation-')); + +try { + expectValidatorPass('public fictional example brief', exampleBrief); + + expectValidatorFail( + 'missing required scalar', + exampleBrief.replace(/^title:.*\n/m, ''), + /title present.*missing required field/i + ); + + expectValidatorFail( + 'key_points must be a list', + exampleBrief.replace(/key_points:\n(?:\s+-.*\n)+/, 'key_points: "single scalar"\n'), + /key_points valid.*expected non-empty list/i + ); + + expectValidatorFail( + 'invalid confidence enum', + exampleBrief.replace(/^confidence: medium$/m, 'confidence: certain'), + /confidence valid.*expected high\|medium\|low/i + ); + + expectValidatorFail( + 'high confidence cannot retain unresolved gaps', + exampleBrief.replace(/^confidence: medium$/m, 'confidence: high'), + /high-confidence source-sufficiency gate/i + ); + + expectValidatorFail( + 'duplicate top-level field', + exampleBrief.replace(/^audience:/m, 'title: "Duplicate"\naudience:'), + /duplicate: title/i + ); + + expectValidatorFail( + 'unsupported nested mapping', + exampleBrief.replace(/^presentation_goal:.*$/m, 'presentation_goal:\n nested: value'), + /unsupported or malformed frontmatter syntax/i + ); + + testPrepareBlocksInvalidBrief(); + testPrepareAcceptsValidBrief(); + + console.log('PASS: brief validator and prepare-deck gate regression suite'); +} finally { + rmSync(work, { recursive: true, force: true }); +} + +function expectValidatorPass(name, brief) { + const path = writeBrief(name, brief); + const run = spawnSync(node, [validator, path], { cwd: root, encoding: 'utf8' }); + if (run.status !== 0) throw new Error(`${name} should PASS\n${run.stdout}\n${run.stderr}`); + console.log('PASS:', name); +} + +function expectValidatorFail(name, brief, expected) { + const path = writeBrief(name, brief); + const run = spawnSync(node, [validator, path], { cwd: root, encoding: 'utf8' }); + if (run.status === 0) throw new Error(`${name} should FAIL\n${run.stdout}`); + if (!expected.test(run.stdout + run.stderr)) throw new Error(`${name} failed for the wrong reason\n${run.stdout}\n${run.stderr}`); + console.log('PASS:', name); +} + +function testPrepareBlocksInvalidBrief() { + const dir = join(work, 'prepare-invalid'); + const source = join(work, 'source-invalid.md'); + writeFileSync(source, '# Demo source\n\nA small project.', 'utf8'); + const first = spawnSync(node, [prepare, source, '--out', dir], { cwd: root, encoding: 'utf8' }); + assert(first.status === 0, 'initial prepare should succeed'); + + writeFileSync(join(dir, 'DECK_BRIEF.md'), exampleBrief.replace(/^title:.*\n/m, ''), 'utf8'); + writeFileSync(join(dir, 'brief-to-deck-packet.md'), 'STALE PACKET', 'utf8'); + const second = spawnSync(node, [prepare, source, '--out', dir], { cwd: root, encoding: 'utf8' }); + assert(second.status !== 0, 'prepare should reject invalid brief'); + assert(!existsSync(join(dir, 'brief-to-deck-packet.md')), 'invalid brief should remove stale generation packet'); + assert(/failed validation/i.test(second.stdout + second.stderr), 'prepare failure should name brief validation'); + console.log('PASS: prepare-deck blocks invalid brief and removes stale packet'); +} + +function testPrepareAcceptsValidBrief() { + const dir = join(work, 'prepare-valid'); + const source = join(work, 'source-valid.md'); + writeFileSync(source, '# Demo source\n\nA small project.', 'utf8'); + const first = spawnSync(node, [prepare, source, '--out', dir], { cwd: root, encoding: 'utf8' }); + assert(first.status === 0, 'initial prepare should succeed'); + writeFileSync(join(dir, 'DECK_BRIEF.md'), exampleBrief, 'utf8'); + const second = spawnSync(node, [prepare, source, '--out', dir], { cwd: root, encoding: 'utf8' }); + assert(second.status === 0, `prepare with valid brief should succeed\n${second.stdout}\n${second.stderr}`); + assert(existsSync(join(dir, 'brief-to-deck-packet.md')), 'valid brief should create generation packet'); + console.log('PASS: prepare-deck accepts validated brief and creates generation packet'); +} + +function writeBrief(name, content) { + const path = join(work, name.replace(/[^a-z0-9]+/gi, '-').toLowerCase() + '.md'); + writeFileSync(path, content, 'utf8'); + return path; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} diff --git a/tools/validate-brief.mjs b/tools/validate-brief.mjs new file mode 100644 index 0000000..145659d --- /dev/null +++ b/tools/validate-brief.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// getslide.md — zero-dependency DECK_BRIEF.md validator. +// Validates the project's documented top-level YAML-frontmatter subset. +// This is structural/source-sufficiency validation, not semantic factual proof. +import { existsSync, readFileSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; + +const targetArg = process.argv.slice(2).find((arg) => !arg.startsWith('--')); +if (!targetArg) { + console.error('Usage: node tools/validate-brief.mjs '); + process.exit(1); +} + +const target = resolve(targetArg); +const results = []; +const add = (status, name, detail = '') => results.push({ status, name, detail }); + +if (!existsSync(target)) { + add('FAIL', 'Brief exists', 'Not found: ' + target); + finish(); +} + +const raw = readFileSync(target, 'utf8'); +add(raw.trim() ? 'PASS' : 'FAIL', 'Brief is non-empty', raw.trim() ? basename(target) : 'file is empty'); + +let parsed; +try { + parsed = parseFrontmatter(raw); + add('PASS', 'YAML frontmatter parsed', `${Object.keys(parsed.values).length} top-level field(s)`); +} catch (error) { + add('FAIL', 'YAML frontmatter parsed', error instanceof Error ? error.message : String(error)); + finish(); +} + +for (const duplicate of parsed.duplicates) { + add('FAIL', 'No duplicate top-level fields', `duplicate: ${duplicate}`); +} +if (!parsed.duplicates.length) add('PASS', 'No duplicate top-level fields', 'none'); + +const requiredScalars = [ + 'title', + 'audience', + 'presentation_context', + 'presentation_goal', + 'core_message', +]; +for (const field of requiredScalars) requireScalar(parsed, field); + +requireNonEmptyList(parsed, 'key_points'); +requireNonEmptyList(parsed, 'source_materials'); + +const confidence = scalar(parsed, 'confidence'); +if (!confidence) { + add('FAIL', 'confidence valid', 'missing or empty'); +} else if (!['high', 'medium', 'low'].includes(confidence)) { + add('FAIL', 'confidence valid', `expected high|medium|low, got ${confidence}`); +} else { + add('PASS', 'confidence valid', confidence); +} + +for (const field of ['required_links', 'required_images', 'missing_information', 'auto_filled_assumptions']) { + if (has(parsed, field)) requireList(parsed, field); +} + +for (const field of ['time_limit_minutes', 'slide_count_target']) { + if (has(parsed, field)) validateNumberOrUnknown(parsed, field); +} + +if (has(parsed, 'recommended_direction')) { + const direction = scalar(parsed, 'recommended_direction'); + const allowed = ['developer-demo', 'clean-academic', 'portfolio-case-study', 'other']; + if (!direction || !allowed.includes(direction)) { + add('FAIL', 'recommended_direction valid', `expected ${allowed.join('|')}, got ${direction || 'empty'}`); + } else { + add('PASS', 'recommended_direction valid', direction); + } +} + +const missing = list(parsed, 'missing_information') || []; +if (confidence === 'high' && missing.length > 0) { + add('FAIL', 'High-confidence source-sufficiency gate', `high confidence cannot retain unresolved missing_information (${missing.length} item(s))`); +} else if (confidence === 'high') { + add('PASS', 'High-confidence source-sufficiency gate', 'no unresolved missing_information'); +} else { + add('INFO', 'Source-sufficiency gate', `${confidence || 'invalid'} confidence; semantic review still required`); +} + +add('INFO', 'Semantic source grounding', 'NOT mechanically verified — confirm every key point and assumption against the supplied source'); +finish(); + +function parseFrontmatter(text) { + const normalized = text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); + if (!normalized.startsWith('---\n')) throw new Error('file must start with a YAML frontmatter delimiter (---)'); + const end = normalized.indexOf('\n---', 4); + if (end === -1) throw new Error('closing YAML frontmatter delimiter (---) not found'); + + const block = normalized.slice(4, end); + const lines = block.split('\n'); + const values = {}; + const kinds = {}; + const duplicates = []; + let currentList = null; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const lineNo = index + 2; + if (!line.trim() || /^\s*#/.test(line)) continue; + + const top = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$/); + if (top) { + const [, key, rest = ''] = top; + if (Object.prototype.hasOwnProperty.call(values, key)) duplicates.push(key); + currentList = null; + + if (!rest.trim()) { + values[key] = []; + kinds[key] = 'pending-list'; + currentList = key; + continue; + } + + const trimmed = rest.trim(); + if (trimmed === '[]') { + values[key] = []; + kinds[key] = 'list'; + } else if (trimmed.startsWith('[') || trimmed.startsWith('{') || trimmed === '|' || trimmed === '>') { + throw new Error(`unsupported YAML shape for ${key} on line ${lineNo}; use a scalar or indented dash list`); + } else { + values[key] = decodeScalar(trimmed, lineNo); + kinds[key] = 'scalar'; + } + continue; + } + + const item = line.match(/^\s{2,}-\s+(.*)$/); + if (item && currentList) { + const value = item[1].trim(); + if (!value) throw new Error(`empty list item on line ${lineNo}`); + values[currentList].push(decodeScalar(value, lineNo)); + kinds[currentList] = 'list'; + continue; + } + + throw new Error(`unsupported or malformed frontmatter syntax on line ${lineNo}: ${line.trim()}`); + } + + for (const [key, kind] of Object.entries(kinds)) { + if (kind === 'pending-list') kinds[key] = 'list'; + } + return { values, kinds, duplicates }; +} + +function decodeScalar(value, lineNo) { + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + if (value.length < 2) throw new Error(`malformed quoted scalar on line ${lineNo}`); + return value.slice(1, -1).trim(); + } + if (value.startsWith('"') || value.startsWith("'") || value.endsWith('"') || value.endsWith("'")) { + throw new Error(`unbalanced quoted scalar on line ${lineNo}`); + } + return value.trim(); +} + +function has(doc, field) { + return Object.prototype.hasOwnProperty.call(doc.values, field); +} + +function scalar(doc, field) { + return doc.kinds[field] === 'scalar' ? String(doc.values[field]).trim() : ''; +} + +function list(doc, field) { + return doc.kinds[field] === 'list' ? doc.values[field] : null; +} + +function requireScalar(doc, field) { + if (!has(doc, field)) return add('FAIL', `${field} present`, 'missing required field'); + if (doc.kinds[field] !== 'scalar') return add('FAIL', `${field} valid`, 'expected non-empty scalar'); + const value = scalar(doc, field); + add(value ? 'PASS' : 'FAIL', `${field} valid`, value ? 'non-empty scalar' : 'empty scalar'); +} + +function requireList(doc, field) { + if (doc.kinds[field] !== 'list') add('FAIL', `${field} valid`, 'expected list'); + else add('PASS', `${field} valid`, `${doc.values[field].length} item(s)`); +} + +function requireNonEmptyList(doc, field) { + if (!has(doc, field)) return add('FAIL', `${field} present`, 'missing required field'); + if (doc.kinds[field] !== 'list') return add('FAIL', `${field} valid`, 'expected non-empty list'); + add(doc.values[field].length > 0 ? 'PASS' : 'FAIL', `${field} valid`, `${doc.values[field].length} item(s)`); +} + +function validateNumberOrUnknown(doc, field) { + if (doc.kinds[field] !== 'scalar') return add('FAIL', `${field} valid`, 'expected positive number or unknown'); + const value = scalar(doc, field); + if (value === 'unknown') return add('PASS', `${field} valid`, 'unknown'); + const number = Number(value); + add(Number.isFinite(number) && number > 0 ? 'PASS' : 'FAIL', `${field} valid`, Number.isFinite(number) && number > 0 ? String(number) : `expected positive number or unknown, got ${value || 'empty'}`); +} + +function finish() { + const icon = { PASS: '[PASS]', FAIL: '[FAIL]', INFO: '[INFO]' }; + console.log(`getslide.md brief validator — ${basename(target)}\n`); + for (const result of results) console.log(`${icon[result.status]} ${result.name}${result.detail ? ' — ' + result.detail : ''}`); + const failures = results.filter((result) => result.status === 'FAIL'); + console.log(''); + console.log(failures.length ? `RESULT: FAIL (${failures.length} failure(s))` : 'RESULT: PASS'); + process.exit(failures.length ? 1 : 0); +}