fix(import): coerce non-string frontmatter title/slug/type (#1939)

YAML `title: 2024-06-01` parses to a Date and `title: 1458` to a number;
the old `(frontmatter.X as string)` cast was a compile-time lie, so
downstream `.toLowerCase()` threw and (via the importer failure gate)
could wedge sync indefinitely. parseMarkdown now coerces via
coerceFrontmatterString (Date -> UTC ISO date, deterministic), and the
pure assessContentSanity self-protects against a non-string title.
This commit is contained in:
Garry Tan
2026-06-07 08:44:51 -07:00
parent 805814451e
commit 8873c2c34f
5 changed files with 96 additions and 6 deletions
+6 -2
View File
@@ -376,14 +376,18 @@ export function assessContentSanity(opts: {
// doesn't repeat the lowercase per literal.
const bodyHead = body.slice(0, SCAN_HEAD_BYTES);
const bodyHeadLower = bodyHead.toLowerCase();
const titleLower = opts.title.toLowerCase();
// Defensive coercion (issue #1939): this is a pure exported fn; lint.ts and
// import-file both pass `parsed.title`, which a malformed YAML date/number
// title could make non-string. Never throw on a bad title.
const title = String(opts.title ?? '');
const titleLower = title.toLowerCase();
const junk_pattern_matches: string[] = [];
for (const p of BUILT_IN_JUNK_PATTERNS) {
const scope = p.applies_to ?? 'both';
let matched = false;
if (scope === 'title' || scope === 'both') {
if (p.pattern.test(opts.title)) matched = true;
if (p.pattern.test(title)) matched = true;
}
if (!matched && (scope === 'body' || scope === 'both')) {
if (p.pattern.test(bodyHead)) matched = true;
+2 -1
View File
@@ -112,7 +112,8 @@ function nonRedundancy(page: Page): number {
}
function hasTitle(page: Page): number {
return page.title && page.title.trim().length > 0 ? 1 : 0;
// Coerce (issue #1939): a malformed YAML date/number title could be non-string.
return String(page.title ?? '').trim().length > 0 ? 1 : 0;
}
function hasBody(page: Page): number {
+22 -3
View File
@@ -49,6 +49,25 @@ export interface ParsedMarkdown {
errors?: ParseValidationError[];
}
/**
* Coerce a raw YAML frontmatter value into a string.
*
* js-yaml parses unquoted scalars by type: `title: 2024-06-01` becomes a JS
* `Date`, `title: 1458` becomes a `number`. The old `(frontmatter.X as string)`
* cast was a compile-time lie — at runtime the value stayed a Date/number, so
* any downstream `.toLowerCase()` / `.trim()` threw and (via the importer's
* failure gate) could wedge sync indefinitely (issue #1939).
*
* Dates coerce to their UTC ISO date (`2024-06-01`) — deterministic across
* machines and matching the on-disk source token, unlike `String(date)` which
* renders a timezone-dependent long form. Everything else uses `String()`.
*/
export function coerceFrontmatterString(v: unknown): string {
if (v == null) return '';
if (v instanceof Date) return v.toISOString().slice(0, 10);
return String(v);
}
/**
* Parse a markdown file with YAML frontmatter into its components.
*
@@ -105,12 +124,12 @@ export function parseMarkdown(
const { compiled_truth, timeline } = splitBody(body);
const type = (frontmatter.type as string) || (
const type = coerceFrontmatterString(frontmatter.type) || (
opts?.activePack ? inferTypeFromPack(filePath, opts.activePack) : inferType(filePath)
);
const title = (frontmatter.title as string) || inferTitle(filePath);
const title = coerceFrontmatterString(frontmatter.title).trim() || inferTitle(filePath);
const tags = extractTags(frontmatter);
const slug = (frontmatter.slug as string) || inferSlug(filePath);
const slug = coerceFrontmatterString(frontmatter.slug) || inferSlug(filePath);
const cleanFrontmatter = { ...frontmatter };
delete cleanFrontmatter.type;
+25
View File
@@ -640,3 +640,28 @@ describe('assessContentSanity — confidence split (Q1=A)', () => {
expect(r.shouldFlag).toBe(false);
});
});
// issue #1939 — assessContentSanity is a pure exported fn; lint.ts and
// import-file both pass `parsed.title`, which a malformed YAML date/number title
// could make non-string. It must coerce defensively and never throw.
describe('issue #1939 — non-string title defensive coercion', () => {
const base = { compiled_truth: 'some prose body here', timeline: '' };
test('Date title does not throw', () => {
expect(() =>
assessContentSanity({ ...base, title: new Date('2024-06-01') as unknown as string }),
).not.toThrow();
});
test('number title does not throw', () => {
expect(() =>
assessContentSanity({ ...base, title: 1458 as unknown as string }),
).not.toThrow();
});
test('null/undefined title does not throw and yields a normal result', () => {
const r = assessContentSanity({ ...base, title: undefined as unknown as string });
expect(r).toBeDefined();
expect(typeof r.shouldQuarantine).toBe('boolean');
});
});
+41
View File
@@ -302,3 +302,44 @@ Some content.`;
expect(parseMarkdown('', 'projects/blog/writing/essay.md').type).toBe('writing');
});
});
// issue #1939 — js-yaml parses `title: 2024-06-01` as a Date and `title: 1458`
// as a number. The old `(frontmatter.title as string)` cast was a compile-time
// lie; at runtime downstream `.toLowerCase()` threw and wedged sync. Coercion
// must be non-throwing AND deterministic (UTC ISO for dates, no timezone drift).
describe('issue #1939 — non-string frontmatter coercion', () => {
test('date title coerces to its UTC ISO date string', () => {
const parsed = parseMarkdown('---\ntitle: 2024-06-01\n---\nbody\n', 'apple-notes/x.md');
expect(parsed.title).toBe('2024-06-01');
expect(typeof parsed.title).toBe('string');
});
test('number title coerces to its string form', () => {
const parsed = parseMarkdown('---\ntitle: 1458\n---\nbody\n', 'apple-notes/x.md');
expect(parsed.title).toBe('1458');
});
test('date title is timezone-independent (UTC) — repro file shape', () => {
// sources/apple-notes/YC/Talks YC/2023-04-25 1458.md style page.
const parsed = parseMarkdown('---\ntitle: 2023-04-25\n---\nnotes\n', 'apple-notes/2023-04-25 1458.md');
expect(parsed.title).toBe('2023-04-25'); // never "Mon Apr 24 2023 ...GMT-0700"
});
test('date/number slug + type coerce without throwing', () => {
const parsed = parseMarkdown('---\nslug: 2024-06-01\ntype: 2024\n---\nbody\n', 'x.md');
expect(typeof parsed.slug).toBe('string');
expect(parsed.slug).toBe('2024-06-01');
expect(typeof parsed.type).toBe('string');
});
test('missing/empty title falls back to inferred title (no throw)', () => {
const parsed = parseMarkdown('---\ntype: note\n---\nbody\n', 'people/alice-example.md');
expect(typeof parsed.title).toBe('string');
expect(parsed.title.length).toBeGreaterThan(0);
});
test('string title still passes through unchanged', () => {
const parsed = parseMarkdown('---\ntitle: A Normal Title\n---\nbody\n', 'x.md');
expect(parsed.title).toBe('A Normal Title');
});
});