Files
gitmost/packages/mcp/test/unit/footnote-analyze.test.mjs
claude code agent 227 17e683a311 feat(footnotes): reuse semantics + import diagnostics (#166)
Footnotes were strict 1:1: a repeated `[^a]` reference was treated as a
collision and re-id'd to `a__2`, and a reference with no definition synthesized
its own empty one — so an agent-authored article with reused labels produced
dozens of empty `kowiki__N` footnotes. Move to Pandoc REUSE semantics and add
non-fatal import diagnostics.

Reuse (core):
- resolveCollisions (footnote-sync): repeated references sharing an id are REUSE
  (recorded once in document order, never re-id'd) — one number, one shared
  definition. Only a duplicate DEFINITION is re-id'd deterministically and, with
  no matching reference, dropped by the existing orphan policy (first-wins).
  CollisionPlan.refReids is now always empty (harmless no-op downstream).
- extractFootnoteDefinitions (marked) and extractFootnotes (MCP): duplicate
  definition ids are FIRST-WINS (keep first, drop rest); reference markers are
  never rewritten. Removed the marker-rewriting and the now-dead deriveFootnoteId
  mirror + helpers from the MCP path.

Import diagnostics:
- New analyzeFootnotes() (MCP): fence-aware pure scan reporting dangling
  references, empty/duplicate definitions and `[^id]` markers inside table rows.
- createPage / updatePage / importPageMarkdown now attach `footnoteWarnings`
  (only when non-empty) so an agent can fix its markup; the page is still created.

Paste-reuse:
- footnotePastePlugin remaps only ids the pasted slice DEFINES (a colliding
  definition); a pasted lone reference to an existing id keeps it (reuse).

Tests: reuse/first-wins rewrites of footnote.test, footnote-markdown.test,
footnote.marked.orphan.test and the MCP footnotes.test; new footnote-paste.test
(editor-ext) and footnote-analyze.test (MCP). Deleted derive-id-parity.test.mjs
(the MCP no longer derives ids; editor-ext's deriveFootnoteId keeps its own
golden test). editor-ext 128, MCP 299, server roundtrip 2, client views 3,
client+server tsc clean.

Two review suggestions applied: corrected a stale "duplicated in MCP" comment and
the dangling-reference warning wording.

Note: the multi-backlink editor UI (a reused definition linking back to each of
its references) is deferred to a follow-up — this PR delivers the data-integrity
core (reuse + warnings + paste-reuse). Forward links and numbering already reuse
correctly; the backlink currently targets the first reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:34:41 +03:00

107 lines
3.5 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js";
test("clean footnotes produce no diagnostics", () => {
const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.danglingReferences, []);
assert.deepEqual(d.emptyDefinitions, []);
assert.deepEqual(d.duplicateDefinitions, []);
assert.deepEqual(d.referencesInTables, []);
assert.deepEqual(d.warnings, []);
});
test("reuse (repeated references to one definition) is NOT a warning", () => {
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.danglingReferences, []);
assert.deepEqual(d.warnings, []);
});
test("dangling reference (no definition) is reported", () => {
const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.danglingReferences, ["missing"]);
assert.equal(d.warnings.length, 1);
assert.match(d.warnings[0], /no matching definition/);
assert.match(d.warnings[0], /\[\^missing\]/);
});
test("empty definition text is reported", () => {
const md = ["See[^a].", "", "[^a]: "].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.emptyDefinitions, ["a"]);
assert.match(d.warnings.join("\n"), /empty text/);
});
test("duplicate definition id is reported (first-wins)", () => {
const md = ["See[^d].", "", "[^d]: first", "[^d]: second"].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.duplicateDefinitions, ["d"]);
assert.match(d.warnings.join("\n"), /defined more than once/);
});
test("reference inside a GFM table row is reported (heuristic)", () => {
const md = [
"| Col |",
"| --- |",
"| cell[^t] |",
"",
"[^t]: table note",
].join("\n");
const d = analyzeFootnotes(md);
assert.deepEqual(d.referencesInTables, ["t"]);
assert.match(d.warnings.join("\n"), /table/);
// It is defined, so it is NOT also dangling.
assert.deepEqual(d.danglingReferences, []);
});
test("footnote syntax inside a code fence is ignored", () => {
const md = [
"Intro.",
"",
"```",
"Example[^demo]",
"[^demo]: not a real definition",
"```",
"",
"Outro[^a].",
"",
"[^a]: real",
].join("\n");
const d = analyzeFootnotes(md);
// `[^demo]` lives only in the fenced block, so it is neither a reference nor a
// dangling one, and `[^demo]:` is not counted as a definition.
assert.deepEqual(d.danglingReferences, []);
assert.deepEqual(d.duplicateDefinitions, []);
assert.deepEqual(d.warnings, []);
});
test("a reference that only appears inside a definition's text is not dangling", () => {
// `[^b]` is referenced from within [^a]'s text and has its own definition.
const md = ["See[^a].", "", "[^a]: see also [^b]", "[^b]: the other"].join(
"\n",
);
const d = analyzeFootnotes(md);
assert.deepEqual(d.danglingReferences, []);
});
test("multiple problem classes accumulate distinct warnings", () => {
const md = [
"Ref[^x] and[^dup].",
"",
"[^dup]: one",
"[^dup]: two",
"[^empty]:",
].join("\n");
const d = analyzeFootnotes(md);
// x has no definition; dup is defined twice; empty is empty AND has no ref.
assert.ok(d.danglingReferences.includes("x"));
assert.deepEqual(d.duplicateDefinitions, ["dup"]);
assert.deepEqual(d.emptyDefinitions, ["empty"]);
// One warning line per problem class present.
assert.ok(d.warnings.length >= 3);
});