mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
v0.37.9.0 fix(frontmatter): canonical-style normalization for tag arrays (#1252)
Aligns the auto-fix engine, the inferred-frontmatter serializer, and the agent-facing skill on a single canonical YAML shape for tag arrays. v0.37.5.0 fixed the validator (it stopped flagging valid YAML); this release lines up everything else with that fix. Layer 1 (brain-writer.ts step 3a): allow-listed to `tags:` / `aliases:` keys. Rewrites `tags: ["yc"]` to `tags: ['yc']`; apostrophe fallback for `"Men's Fashion"`. Shares a NESTED_QUOTES dedup gate with the existing step 3 so one file with both rewrites surfaces as one audit entry, not two. Layer 4 (frontmatter-inference.ts): serializer emits the same canonical single-quote form by default. Inferred frontmatter on import and `--fix` output now match byte-for-byte. Layer 5 (frontmatter-guard SKILL.md): new "Prevention" section showing canonical vs JSON-style arrays + the JSON.stringify trap that produces the non-canonical form. Future agent writes start canonical. Parity test added to markdown-validation.test.ts pinning agreement between per-value safeLoad parsing and gray-matter full-document parse on the load-bearing inputs. PR #1238's "Layer 3" (put_page auto-normalization) was dropped during plan review: put_page parses YAML into typed fields and hashes them, so single-quoted vs double-quoted arrays are functionally identical in storage. The fix lives where the writes happen, not on the read path. Source PRs absorbed: #1217 (closed, serializer fix) + #1238 (closed, four-layer defense-in-depth narrowed to three layers). PR #1229 already merged as v0.37.5.0. Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
garrytan-agents
Claude Opus 4.7
parent
54a0629745
commit
f2e11d6967
@@ -2,6 +2,60 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.37.9.0] - 2026-05-20
|
||||
|
||||
**Tags get written the same way everywhere now.**
|
||||
|
||||
v0.37.5.0 fixed the doctor (it stopped flagging `tags: ["yc", "w2025"]` as broken). This release lines up everything else with that fix. When gbrain itself emits a tag list (inferred frontmatter on import, or `frontmatter validate --fix` rewriting a file), it writes `tags: ['yc', 'w2025']` in the canonical single-quote form. Your new pages and your repaired pages now look the same in `git diff`. No more cosmetic noise.
|
||||
|
||||
If a tag actually contains an apostrophe like `Men's Fashion`, it falls back to double quotes, so YAML stays valid without ugly `''` escaping.
|
||||
|
||||
**Skill update for agents that write into gbrain:** the `frontmatter-guard` skill now has a Prevention section showing the canonical YAML shapes side by side. Future agents should write the single-quote form on the first try.
|
||||
|
||||
**How to use it**
|
||||
|
||||
```bash
|
||||
gbrain upgrade
|
||||
# Existing files stay valid. Only new writes use the canonical form.
|
||||
# To normalize an existing brain's tag style:
|
||||
gbrain frontmatter validate <path> --fix
|
||||
```
|
||||
|
||||
The `--fix` pass now rewrites `tags: ["yc", "w2025"]` to `tags: ['yc', 'w2025']` in place, with a backup under `~/.gbrain/backups/frontmatter/`. Only `tags:` and `aliases:` are touched. Other typed arrays (`metrics: ["1", "2"]`, `scores: ["a", "b"]`) are left alone on purpose, because rewriting them could change what gbrain reads them as.
|
||||
|
||||
**What's safe to know about**
|
||||
|
||||
- This is canonical-style normalization, not a bug fix. Both forms (`["yc"]` and `['yc']`) parse to the same array `['yc']` and hash identically in storage. The v0.37.5.0 validator already accepts both. This release is about making gbrain's own writes consistent with each other.
|
||||
- The auto-fix engine and the validator share a dedup gate. A file with both a JSON-array tag rewrite and a nested-quote title rewrite produces ONE `NESTED_QUOTES` audit entry, not two, so `frontmatter_integrity` counts stay honest about distinct files affected.
|
||||
- The validator gained a parity test that pins agreement between per-value `safeLoad` parsing and `gray-matter`'s full-document parse on the load-bearing inputs. Catches future per-value parse drift before it ships.
|
||||
|
||||
**Why this is a small release**
|
||||
|
||||
The hard problem was the validator (shipped in v0.37.5.0). What's left is alignment: the emitter, the auto-fix engine, and the agent guidance. Each piece touches one place and ships with its own tests. The "auto-heal on put_page" idea explored during planning was dropped after Codex's outside-voice review pointed out that `put_page` parses YAML into typed fields and hashes them, so single-quoted vs double-quoted arrays are functionally identical in storage. The fix lives where the writes happen, not on the read path.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
|
||||
- `src/core/brain-writer.ts:155-220` — new step 3a in `autoFixFrontmatter()`. Allow-listed to `tags:` / `aliases:` keys (`^(\s*(tags|aliases)\s*:\s*)\[(.*)\]\s*$`). Rewrites JSON-style double-quoted items to single-quoted YAML; items containing `'` fall back to double quotes via `JSON.stringify`. Empty items render as `''`. Idempotent.
|
||||
- `src/core/brain-writer.ts:154` — shared `nestedQuotesFixed` dedup gate between step 3a and existing step 3. One `NESTED_QUOTES` fix record per file when both fire.
|
||||
- `skills/frontmatter-guard/SKILL.md:170-217` — new "Prevention — Writing Valid Frontmatter" section. Correct/incorrect YAML examples, explanation of why `JSON.stringify`-style arrays are valid-but-non-canonical post-v0.37.5.0, and a quoting decision rule.
|
||||
- `test/brain-writer.test.ts` — 7 new cases for step 3a (JSON-array rewrite, apostrophe fallback, empty item, non-allow-list keys untouched, `aliases:` parity, step 3a + step 3 dedup, idempotency).
|
||||
- `test/markdown-validation.test.ts` — parity test covering the per-value-safeLoad vs gray-matter-full-document axis. Pins the load-bearing inputs the validator must agree with itself on, so a future per-value parse drift surfaces as a test failure not a silent flag-storm.
|
||||
|
||||
#### Changed
|
||||
|
||||
- `src/core/frontmatter-inference.ts:411-416` — `serializeFrontmatter()` emits `tags: ['yc', 'w2025']` (single-quoted) by default; falls back to `JSON.stringify` (double-quoted) only when a tag contains `'`. Aligns inferred-frontmatter output with the auto-fix engine.
|
||||
- `test/frontmatter-inference.test.ts:239` — updated assertion to match new canonical output; added apostrophe-fallback regression case.
|
||||
|
||||
#### Removed
|
||||
|
||||
- `TODOS.md` — closed out the "v0.37.5.0 NESTED_QUOTES validator follow-up" entry (unify `serializeFrontmatter` quoting with `brain-writer.ts:184` style). Resolved by this release.
|
||||
|
||||
#### For contributors
|
||||
|
||||
- The auto-fix engine's key-scope decision matters. If a future need shows up to normalize arrays for keys beyond `tags`/`aliases` (e.g. an `authors:` field), extend the regex allow-list explicitly. Don't broaden to `[A-Za-z_][\w-]*` — that would rewrite typed-numeric arrays (`scores: ["1", "2"]`) into string arrays and break downstream typed-claim extraction.
|
||||
- Codex outside-voice review on this wave produced 11 findings; 1 dropped a planned layer (put_page auto-normalization, because storage hashes parsed fields not raw bytes), 1 narrowed the allow-list, 5 fixed plan housekeeping. Original PRs #1217 (closed, serializer fix) and #1238 (closed, four-layer defense-in-depth) absorbed into this wave.
|
||||
## [0.37.8.0] - 2026-05-20
|
||||
|
||||
**For agents indexing source code with gbrain, the right embedding model is now obvious — and the brain tells you so out loud.**
|
||||
|
||||
@@ -23,9 +23,7 @@
|
||||
|
||||
- [ ] **v0.37.x: Guard cli.ts `main()` so importing `buildGatewayConfig` doesn't print help.** v0.37.6.0 exported `buildGatewayConfig` from `src/cli.ts` for test access. Importing it triggers the file's top-level `main()` which prints help to stdout during tests — functionally harmless (tests pass) but noisy. Fix: wrap `main()` in `if (import.meta.main)` so it only runs when cli.ts is the entry point, not when imported. Touches one line; trivial. Filed during v0.37.6.0 implementation.
|
||||
|
||||
## v0.37.5.0 NESTED_QUOTES validator follow-up
|
||||
|
||||
- [ ] **v0.37.x+: unify `serializeFrontmatter` tag/title quoting with `brain-writer.ts:184`'s single-quote-with-`''`-escape style for consistency.** Cosmetic only now that the validator at `src/core/markdown.ts:219-238` is YAML-aware (v0.37.5.0). Today the emitter still produces `tags: ["yc"]` (double-quoted via `JSON.stringify`) while the repair path produces `tags: ['yc']` (single-quoted). Both are valid YAML and the validator accepts both, so this is cosmetic — but new writes drifting from repair-side output reads as inconsistency. Original signal: PR #1217 by @garrytan-agents (closed in favor of the validator fix). Touch `src/core/frontmatter-inference.ts:391-416` only; should be ~5 LOC + the existing test at `test/frontmatter-inference.test.ts:239` updated.
|
||||
## v0.37.4.0 pgGraph CI scaffolding follow-ups (v0.37.x+)
|
||||
|
||||
- [ ] **T8 truncation signal — defer until dedupe-then-cap SQL + Postgres parity E2E.** v0.37.4.0 ships `frontierCap` as the actually-useful protection but strips the `onTruncation` callback after /review adversarial pass (Claude + Codex both flagged). Two bugs in the v1 algorithm: (a) FALSE POSITIVE — `count == cap` at a depth fires the callback even when the graph organically has exactly cap unique nodes at that depth with no truncation; (b) FALSE NEGATIVE — recursive `LIMIT N` runs BEFORE outer `SELECT DISTINCT`, so diamond graphs (one parent fans out to N+5 candidates with duplicates) can have the LIMIT eat its slots on dupes, then DISTINCT collapses to <cap unique nodes, missing real truncation. Fix shape: rewrite both engine impls to dedupe candidates (by `(slug, id)` or page id, source-scoped) BEFORE applying the LIMIT — i.e., `(SELECT DISTINCT ON ... ORDER BY slug, id LIMIT N)` inside the recursive term instead of post-CTE DISTINCT. Then write the missing `test/e2e/engine-parity-frontier-cap.test.ts` (Postgres against PGLite, identical chosen slugs when cap fires + stable ordering). Restore `TruncationInfo` + `opts.onTruncation` to `TraverseGraphOpts` with the cap-after-dedupe shape. Callers that need truncation visibility in the interim can compare `result.length` against expected fanout bounds. /review found it; not a blocker for v0.37.4.0 because the cap itself works correctly and is back-compat (default unset = no behavior change).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.37.8.0",
|
||||
"version": "0.37.9.0",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
|
||||
@@ -167,6 +167,57 @@ JSON envelope (when `--json` is passed):
|
||||
|
||||
`gbrain frontmatter validate <path> --json` returns a similar envelope keyed on per-file results instead of per-source.
|
||||
|
||||
## Prevention — Writing Valid Frontmatter
|
||||
|
||||
**This is the most important section.** Fixing broken frontmatter is good. Not writing broken frontmatter in the first place is better.
|
||||
|
||||
### YAML arrays (the historical #1 error source)
|
||||
|
||||
```yaml
|
||||
# Correct: single-quoted YAML flow (canonical form gbrain emits)
|
||||
tags: ['yc', 'w2025', 'ai']
|
||||
|
||||
# Correct: unquoted scalars (fine when values have no special chars)
|
||||
tags: [yc, w2025, ai]
|
||||
|
||||
# Correct: block style
|
||||
tags:
|
||||
- yc
|
||||
- w2025
|
||||
|
||||
# Tolerated post-v0.37.5.0 but non-canonical: JSON-style double quotes
|
||||
tags: ["yc", "w2025"]
|
||||
|
||||
# Broken: mixed JSON objects and strings (invalid YAML)
|
||||
tags: [{"name": "sports"}, "posterous"]
|
||||
```
|
||||
|
||||
**Why this used to break:** before v0.37.5.0, the validator counted unescaped `"` characters and flagged any line with 3+. A flow sequence like `tags: ["yc", "w2025"]` has 4 unescaped `"` by design — it's valid YAML, but the dumb counter flagged it anyway. One brain saw 6,981 of these on a single doctor run. v0.37.5.0 parses suspicious values with `js-yaml.safeLoad` before flagging, so JSON-style arrays no longer trigger NESTED_QUOTES.
|
||||
|
||||
**Why you should still write the canonical form:** the auto-fix engine (`gbrain frontmatter validate --fix`) and the inferred-frontmatter serializer both emit single-quoted YAML for `tags:` / `aliases:`. Writing the canonical form in new content keeps the source files stylistically consistent and makes diffs against `--fix` runs empty.
|
||||
|
||||
**The classic LLM trap:** code like `tags: [${items.map(t => JSON.stringify(t)).join(', ')}]` produces `tags: ["yc", "w2025"]`. Use single quotes with an apostrophe fallback: `tags: [${items.map(t => t.includes("'") ? JSON.stringify(t) : "'" + t + "'").join(', ')}]`. Or use a YAML library that knows how to emit canonical YAML.
|
||||
|
||||
### Quoted scalars
|
||||
|
||||
```yaml
|
||||
# Correct: single quotes for values with special chars
|
||||
title: 'My "Quoted" Title'
|
||||
|
||||
# Correct: double quotes when value has apostrophes
|
||||
title: "Men's Fashion Guide"
|
||||
|
||||
# Broken: double quotes wrapping inner double quotes
|
||||
title: "My "Quoted" Title"
|
||||
```
|
||||
|
||||
### When to quote at all
|
||||
|
||||
- **Unquoted** is fine for simple values: `type: person`, `batch: w2025`
|
||||
- **Quote** when the value contains `: " ' # [ ] { } | > & * ! ? ,` or starts with `@`
|
||||
- **Single quotes** are the default safe choice
|
||||
- **Double quotes** only when the value itself contains apostrophes
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
**Don't auto-fix `MISSING_OPEN` or `EMPTY_FRONTMATTER` without user input.** These usually mean a human author started a page and didn't finish — silently inserting `---` markers around an unfinished draft is wrong.
|
||||
|
||||
@@ -152,6 +152,75 @@ export function autoFixFrontmatter(
|
||||
}
|
||||
}
|
||||
|
||||
// Both step 3a and step 3 produce NESTED_QUOTES fix records on different
|
||||
// patterns. When both fire on the same file, push ONE merged record rather
|
||||
// than two — keeps the audit count honest about distinct files affected.
|
||||
let nestedQuotesFixed = false;
|
||||
|
||||
// 3a. Canonical-style normalization for `tags:` / `aliases:` flow arrays.
|
||||
// Post-v0.37.5.0 validator (PR #1229), `tags: ["yc", "w2025"]` is already
|
||||
// valid YAML and no longer flagged. This pass rewrites it to the
|
||||
// canonical single-quoted form (`tags: ['yc', 'w2025']`) so disk-side
|
||||
// `frontmatter validate --fix` produces output consistent with the
|
||||
// v0.37.9.0 serializer. Allow-list keys deliberately scoped to
|
||||
// `tags` / `aliases` — extending to arbitrary keys would rewrite typed
|
||||
// arrays (e.g. `scores: ["1", "2"]` would lose numeric intent).
|
||||
{
|
||||
const lines = working.split('\n');
|
||||
let firstNonEmpty = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().length > 0) { firstNonEmpty = i; break; }
|
||||
}
|
||||
if (firstNonEmpty >= 0 && lines[firstNonEmpty].trim() === '---') {
|
||||
let closeIdx = lines.length;
|
||||
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') { closeIdx = i; break; }
|
||||
}
|
||||
let fixedAny = false;
|
||||
for (let i = firstNonEmpty + 1; i < closeIdx; i++) {
|
||||
// Allow-list: only `tags` and `aliases` (the keys this wave targets).
|
||||
const arrMatch = lines[i].match(/^(\s*(?:tags|aliases)\s*:\s*)\[(.*)\]\s*$/);
|
||||
if (!arrMatch || !arrMatch[2].includes('"')) continue;
|
||||
const [, prefix, inner] = arrMatch;
|
||||
// Quote-aware comma split — items may contain commas inside quotes.
|
||||
const items: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
for (let j = 0; j < inner.length; j++) {
|
||||
const ch = inner[j];
|
||||
if (ch === '"' && (j === 0 || inner[j - 1] !== '\\')) {
|
||||
inQuote = !inQuote;
|
||||
} else if (ch === ',' && !inQuote) {
|
||||
items.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current.trim()) items.push(current.trim());
|
||||
|
||||
// Re-quote: single quotes by default, double-quote fallback when the
|
||||
// item contains an apostrophe (YAML's single-quoted form would need
|
||||
// `''` escaping which the validator accepts but reads poorly).
|
||||
const reQuoted = items.map(v => {
|
||||
const clean = v.replace(/^"|"$/g, '').trim();
|
||||
if (!clean) return "''";
|
||||
return clean.includes("'") ? `"${clean}"` : `'${clean}'`;
|
||||
});
|
||||
lines[i] = `${prefix}[${reQuoted.join(', ')}]`;
|
||||
fixedAny = true;
|
||||
}
|
||||
if (fixedAny) {
|
||||
working = lines.join('\n');
|
||||
fixes.push({
|
||||
code: 'NESTED_QUOTES',
|
||||
description: 'Normalized JSON-style double-quoted tag/alias arrays to single-quoted YAML',
|
||||
});
|
||||
nestedQuotesFixed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. NESTED_QUOTES — rewrite `key: "...inner..."` lines that have 3+ unescaped
|
||||
// double-quotes by switching the outer wrapper to single quotes and
|
||||
// leaving inner quotes alone.
|
||||
@@ -188,10 +257,13 @@ export function autoFixFrontmatter(
|
||||
}
|
||||
if (fixedAny) {
|
||||
working = lines.join('\n');
|
||||
fixes.push({
|
||||
code: 'NESTED_QUOTES',
|
||||
description: 'Rewrote nested double-quoted YAML values to single-quoted',
|
||||
});
|
||||
if (!nestedQuotesFixed) {
|
||||
fixes.push({
|
||||
code: 'NESTED_QUOTES',
|
||||
description: 'Rewrote nested double-quoted YAML values to single-quoted',
|
||||
});
|
||||
nestedQuotesFixed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +408,11 @@ export function serializeFrontmatter(fm: InferredFrontmatter): string {
|
||||
}
|
||||
|
||||
if (fm.tags && fm.tags.length > 0) {
|
||||
lines.push(`tags: [${fm.tags.map(t => JSON.stringify(t)).join(', ')}]`);
|
||||
// Single-quoted YAML flow is the canonical form (matches the auto-fix
|
||||
// engine's step 3a output and v0.37.5.0's YAML-aware validator). Fall
|
||||
// back to JSON.stringify (double quotes) only when the value contains an
|
||||
// apostrophe — YAML's single-quote escaping (`''`) reads poorly.
|
||||
lines.push(`tags: [${fm.tags.map(t => t.includes("'") ? JSON.stringify(t) : `'${t}'`).join(', ')}]`);
|
||||
}
|
||||
|
||||
lines.push('---');
|
||||
|
||||
@@ -61,6 +61,67 @@ describe('autoFixFrontmatter', () => {
|
||||
expect(content).toBe(input);
|
||||
expect(fixes).toEqual([]);
|
||||
});
|
||||
|
||||
// v0.37.9.0 — Step 3a canonical-style normalization for tags/aliases arrays.
|
||||
// The validator post-v0.37.5.0 no longer flags `tags: ["yc"]` as broken,
|
||||
// but this pass still rewrites it for consistency with serializeFrontmatter.
|
||||
test('step 3a: normalizes JSON-style double-quoted tags to single-quoted', () => {
|
||||
const input = `${fence}\ntype: person\ntags: ["yc", "w2025"]\n${fence}\n\nbody`;
|
||||
const { content, fixes } = autoFixFrontmatter(input);
|
||||
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(true);
|
||||
expect(content).toContain("tags: ['yc', 'w2025']");
|
||||
expect(content).not.toContain('tags: ["yc", "w2025"]');
|
||||
});
|
||||
|
||||
test('step 3a: apostrophe in item falls back to double quotes', () => {
|
||||
const input = `${fence}\ntype: person\ntags: ["Men's Fashion", "yc"]\n${fence}\n\nbody`;
|
||||
const { content } = autoFixFrontmatter(input);
|
||||
// Apostrophe item keeps double quotes; clean item uses single.
|
||||
expect(content).toContain(`tags: ["Men's Fashion", 'yc']`);
|
||||
});
|
||||
|
||||
test('step 3a: empty item handled as empty single-quoted scalar', () => {
|
||||
const input = `${fence}\ntype: person\ntags: ["", "yc"]\n${fence}\n\nbody`;
|
||||
const { content } = autoFixFrontmatter(input);
|
||||
expect(content).toContain(`tags: ['', 'yc']`);
|
||||
});
|
||||
|
||||
test('step 3a: non-allow-listed keys untouched (metrics, scores, etc.)', () => {
|
||||
const input = `${fence}\ntype: company\nmetrics: ["1", "2", "3"]\nscores: ["a", "b"]\n${fence}\n\nbody`;
|
||||
const { content, fixes } = autoFixFrontmatter(input);
|
||||
// Only `tags` and `aliases` are in the allow-list.
|
||||
expect(content).toContain('metrics: ["1", "2", "3"]');
|
||||
expect(content).toContain('scores: ["a", "b"]');
|
||||
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(false);
|
||||
});
|
||||
|
||||
test('step 3a applies to aliases: key as well as tags:', () => {
|
||||
const input = `${fence}\ntype: person\naliases: ["Bob", "Robert"]\n${fence}\n\nbody`;
|
||||
const { content, fixes } = autoFixFrontmatter(input);
|
||||
expect(fixes.some(f => f.code === 'NESTED_QUOTES')).toBe(true);
|
||||
expect(content).toContain("aliases: ['Bob', 'Robert']");
|
||||
});
|
||||
|
||||
// codex outside-voice review (D7-2): when step 3a AND step 3 both fire on
|
||||
// the same file, the audit must record ONE NESTED_QUOTES entry, not two.
|
||||
// Otherwise frontmatter_integrity counts double-rewrites as two separate
|
||||
// files needing repair.
|
||||
test('step 3a + step 3 dedup: one NESTED_QUOTES fix record per file', () => {
|
||||
const input = `${fence}\ntype: person\ntitle: "Phil "Nick" Last"\ntags: ["yc", "w2025"]\n${fence}\n\nbody`;
|
||||
const { content, fixes } = autoFixFrontmatter(input);
|
||||
const nestedQuotesFixes = fixes.filter(f => f.code === 'NESTED_QUOTES');
|
||||
expect(nestedQuotesFixes.length).toBe(1);
|
||||
// Both rewrites applied to the content.
|
||||
expect(content).toContain("tags: ['yc', 'w2025']");
|
||||
expect(content).toMatch(/^title: '.*'\s*$/m);
|
||||
});
|
||||
|
||||
test('step 3a: idempotent (running twice on already-normalized leaves content unchanged)', () => {
|
||||
const input = `${fence}\ntype: person\ntags: ['yc', 'w2025']\n${fence}\n\nbody`;
|
||||
const { content, fixes } = autoFixFrontmatter(input);
|
||||
expect(content).toBe(input);
|
||||
expect(fixes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeBrainPage', () => {
|
||||
|
||||
@@ -236,7 +236,19 @@ describe('serializeFrontmatter', () => {
|
||||
expect(fm).toContain('type: apple-note');
|
||||
expect(fm).toContain('date: "2010-04-13"');
|
||||
expect(fm).toContain('source: apple-notes');
|
||||
expect(fm).toContain('tags: ["yc"]');
|
||||
// v0.37.9.0 — canonical single-quoted YAML flow. Aligns with
|
||||
// brain-writer's step 3a auto-fix output. Was double-quoted pre-v0.37.9.0.
|
||||
expect(fm).toContain(`tags: ['yc']`);
|
||||
});
|
||||
|
||||
test('tags with apostrophe fall back to double quotes', () => {
|
||||
const fm = serializeFrontmatter({
|
||||
title: 'fashion note',
|
||||
type: 'note',
|
||||
tags: ["Men's Fashion", 'yc'],
|
||||
});
|
||||
// Apostrophe item keeps double quotes (JSON.stringify); clean item uses single.
|
||||
expect(fm).toContain(`tags: ["Men's Fashion", 'yc']`);
|
||||
});
|
||||
|
||||
test('quotes title with special chars', () => {
|
||||
|
||||
@@ -177,6 +177,37 @@ describe('parseMarkdown validation surface', () => {
|
||||
);
|
||||
expect(broken.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// v0.37.9.0 — parity test (codex outside-voice review D7-3).
|
||||
// The validator parses ONLY the value with safeLoad. Gray-matter parses
|
||||
// the whole frontmatter document. These two can disagree on edge cases
|
||||
// (e.g. a value valid in isolation but ambiguous in document context).
|
||||
// For the load-bearing inputs this wave targets, both paths must agree:
|
||||
// valid YAML doesn't trigger NESTED_QUOTES, and clearly broken YAML
|
||||
// either triggers NESTED_QUOTES or YAML_PARSE (never silent).
|
||||
test('parity: validator per-value safeLoad agrees with gray-matter full-document parse', () => {
|
||||
const cases: { md: string; shouldFlag: boolean; label: string }[] = [
|
||||
// Valid: gray-matter parses cleanly, validator should NOT flag.
|
||||
{ md: `${fence}\ntype: concept\ntags: ["yc", "w2025"]\n${fence}\n\nbody`, shouldFlag: false, label: 'JSON-style array (valid YAML)' },
|
||||
{ md: `${fence}\ntype: concept\ntags: ['yc', 'w2025']\n${fence}\n\nbody`, shouldFlag: false, label: 'single-quoted array' },
|
||||
{ md: `${fence}\ntype: concept\ntitle: 'a: "b" "c"'\n${fence}\n\nbody`, shouldFlag: false, label: 'single-quoted scalar with literal inner quotes' },
|
||||
{ md: `${fence}\ntype: concept\ntitle: ok\n${fence}\n\nbody`, shouldFlag: false, label: 'clean scalar' },
|
||||
// Broken: gray-matter would fail OR produce ambiguous parse, validator
|
||||
// should surface either NESTED_QUOTES or YAML_PARSE.
|
||||
{ md: `${fence}\ntype: concept\ntitle: "Foo "bar" baz "qux" end"\n${fence}\n\nbody`, shouldFlag: true, label: 'nested scalar quotes' },
|
||||
];
|
||||
for (const c of cases) {
|
||||
const parsed = parseMarkdown(c.md, undefined, { validate: true });
|
||||
const errors = parsed.errors!.filter(
|
||||
e => e.code === 'NESTED_QUOTES' || e.code === 'YAML_PARSE'
|
||||
);
|
||||
if (c.shouldFlag) {
|
||||
expect(errors.length, `[${c.label}] expected at least one NESTED_QUOTES or YAML_PARSE error`).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(errors.length, `[${c.label}] expected no NESTED_QUOTES/YAML_PARSE errors but got ${JSON.stringify(errors)}`).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EMPTY_FRONTMATTER', () => {
|
||||
|
||||
Reference in New Issue
Block a user