mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +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
@@ -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