diff --git a/src/core/content-sanity.ts b/src/core/content-sanity.ts index 1d516075a..ca5260162 100644 --- a/src/core/content-sanity.ts +++ b/src/core/content-sanity.ts @@ -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; diff --git a/src/core/enrichment/completeness.ts b/src/core/enrichment/completeness.ts index 8b91afd6d..b5e7997aa 100644 --- a/src/core/enrichment/completeness.ts +++ b/src/core/enrichment/completeness.ts @@ -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 { diff --git a/src/core/markdown.ts b/src/core/markdown.ts index 19cf0474f..9a6359b6b 100644 --- a/src/core/markdown.ts +++ b/src/core/markdown.ts @@ -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; diff --git a/test/content-sanity.test.ts b/test/content-sanity.test.ts index d94bef96f..80e2aaa98 100644 --- a/test/content-sanity.test.ts +++ b/test/content-sanity.test.ts @@ -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'); + }); +}); diff --git a/test/markdown.test.ts b/test/markdown.test.ts index 2d6f165ba..2e485f521 100644 --- a/test/markdown.test.ts +++ b/test/markdown.test.ts @@ -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'); + }); +});