diff --git a/src/resources/extensions/sf/self-feedback.js b/src/resources/extensions/sf/self-feedback.js index 0ea323500..7cfe3cbec 100644 --- a/src/resources/extensions/sf/self-feedback.js +++ b/src/resources/extensions/sf/self-feedback.js @@ -56,8 +56,14 @@ const SELF_FEEDBACK_HEADER = "durable source of truth is `.sf/sf.db`.\n\n" + "Blocking entries (severity high+) remain active until an sf fix explicitly\n" + "marks them resolved with evidence.\n\n"; -const JSONL_GENERATED_COMMENT = - "# generated from .sf/sf.db — do not edit directly; use the resolve_issue tool or sf headless triage --apply"; +// JSONL marker is a valid JSON object so strict JSONL parsers (e.g. the +// doctor's syntax check) accept line 1. The _meta sentinel keeps the +// "do not hand-edit" intent visible to anyone opening the file. +const JSONL_GENERATED_COMMENT = JSON.stringify({ + _meta: "generated from .sf/sf.db", + _warning: + "do not edit directly; use the resolve_issue tool or sf headless triage --apply", +}); const RECENT_RESOLVED_MARKDOWN_LIMIT = 20; const MARKDOWN_DETAIL_CHAR_LIMIT = 2_000; const SELF_FEEDBACK_SCHEMA_VERSION = 1; @@ -225,7 +231,12 @@ function readJsonl(path) { for (const line of readFileSync(path, "utf-8").split("\n")) { if (!line.trim()) continue; try { - out.push(JSON.parse(line)); + const parsed = JSON.parse(line); + // Skip meta header lines emitted by regenerateSelfFeedbackJsonl + // — those are generator metadata, not self-feedback entries. + // Entries always carry an `id` string; meta lines have `_meta`. + if (parsed && typeof parsed === "object" && parsed._meta) continue; + out.push(parsed); } catch { /* skip malformed lines */ } diff --git a/src/resources/extensions/sf/tests/self-feedback-db.test.mjs b/src/resources/extensions/sf/tests/self-feedback-db.test.mjs index 198f91352..e1596b48f 100644 --- a/src/resources/extensions/sf/tests/self-feedback-db.test.mjs +++ b/src/resources/extensions/sf/tests/self-feedback-db.test.mjs @@ -780,8 +780,10 @@ test("markResolved_regenerates_jsonl_from_db_with_resolved_state", () => { ) .split("\n") .filter((l) => l.trim()); - // First non-empty line is the generated comment (starts with #) - assert.ok(lines[0].startsWith("#"), "expected generated comment header"); + // First non-empty line is the generated meta marker (valid JSON object so + // strict JSONL parsers like the doctor accept it; carries _meta sentinel). + const meta = JSON.parse(lines[0]); + assert.ok(meta._meta, "expected JSONL meta header on line 1"); // Second line is the resolved entry const entry = JSON.parse(lines[1]); assert.equal(entry.id, result.entry.id); @@ -982,16 +984,16 @@ test("markResolved_db_only_entry_populates_jsonl_and_markdown_after_resolution", join(project, ".sf", "self-feedback.jsonl"), "utf-8", ); - // Comment header must be present + // Generated meta marker must be present on line 1 (JSON object with _meta key) assert.ok( - jsonlContent.includes("# generated from .sf/sf.db"), - "JSONL must have generated comment header", + jsonlContent.includes('"_meta":"generated from .sf/sf.db"'), + "JSONL must have generated meta header", ); - // Entry must appear as resolved + // Entry must appear as resolved — filter out the _meta header line const jsonlLines = jsonlContent.split("\n").filter((l) => { try { - JSON.parse(l); - return true; + const parsed = JSON.parse(l); + return parsed && typeof parsed === "object" && !parsed._meta; } catch { return false; } @@ -1045,9 +1047,14 @@ test("backfillSelfFeedbackJsonl_makes_jsonl_equal_to_db_render", () => { join(project, ".sf", "self-feedback.jsonl"), "utf-8", ); - const firstEntryLines = firstContent - .split("\n") - .filter((l) => { try { JSON.parse(l); return true; } catch { return false; } }); + const firstEntryLines = firstContent.split("\n").filter((l) => { + try { + const parsed = JSON.parse(l); + return parsed && typeof parsed === "object" && !parsed._meta; + } catch { + return false; + } + }); assert.equal(firstEntryLines.length, 2, "JSONL must have both DB entries"); // Second backfill is idempotent — exact same content @@ -1064,8 +1071,9 @@ test("backfillSelfFeedbackJsonl_makes_jsonl_equal_to_db_render", () => { }); test("regenerateSelfFeedbackJsonl_includes_generated_comment_header", () => { - // The JSONL file must start with a # comment line marking it as generated. - // readJsonl already skips non-JSON lines (the comment) gracefully. + // The JSONL file must start with a JSON-valid meta marker (so strict + // JSONL parsers like the doctor accept the file). readJsonl skips entries + // carrying the _meta sentinel so consumers don't see them as records. const project = makeForgeProject(); recordSelfFeedback( { kind: "gap:header-test", severity: "low", summary: "header check" }, @@ -1080,13 +1088,11 @@ test("regenerateSelfFeedbackJsonl_includes_generated_comment_header", () => { "utf-8", ); const firstLine = content.split("\n")[0]; + const meta = JSON.parse(firstLine); + assert.ok(meta._meta, "JSONL first line must be a JSON object with _meta key"); assert.ok( - firstLine.startsWith("#"), - "JSONL first line must be a # comment (generated header)", - ); - assert.ok( - firstLine.includes("generated from .sf/sf.db"), - "generated header must mention sf.db", + meta._meta.includes("generated from .sf/sf.db"), + "_meta must mention sf.db", ); assert.ok( firstLine.includes("resolve_issue"),