@refract-org/analyzers 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +38 -0
  2. package/dist/src/category-tracker.d.ts +8 -0
  3. package/dist/src/category-tracker.d.ts.map +1 -0
  4. package/dist/src/category-tracker.js +60 -0
  5. package/dist/src/category-tracker.js.map +1 -0
  6. package/dist/src/citation-tracker.d.ts +13 -0
  7. package/dist/src/citation-tracker.d.ts.map +1 -0
  8. package/dist/src/citation-tracker.js +200 -0
  9. package/dist/src/citation-tracker.js.map +1 -0
  10. package/dist/src/claim-differ.d.ts +2 -0
  11. package/dist/src/claim-differ.d.ts.map +1 -0
  12. package/dist/src/claim-differ.js +6 -0
  13. package/dist/src/claim-differ.js.map +1 -0
  14. package/dist/src/edit-cluster-detector.d.ts +14 -0
  15. package/dist/src/edit-cluster-detector.d.ts.map +1 -0
  16. package/dist/src/edit-cluster-detector.js +57 -0
  17. package/dist/src/edit-cluster-detector.js.map +1 -0
  18. package/dist/src/heuristic-classifier.d.ts +9 -0
  19. package/dist/src/heuristic-classifier.d.ts.map +1 -0
  20. package/dist/src/heuristic-classifier.js +33 -0
  21. package/dist/src/heuristic-classifier.js.map +1 -0
  22. package/dist/src/index.d.ts +70 -0
  23. package/dist/src/index.d.ts.map +1 -0
  24. package/dist/src/index.js +16 -0
  25. package/dist/src/index.js.map +1 -0
  26. package/dist/src/observation-differ.d.ts +8 -0
  27. package/dist/src/observation-differ.d.ts.map +1 -0
  28. package/dist/src/observation-differ.js +16 -0
  29. package/dist/src/observation-differ.js.map +1 -0
  30. package/dist/src/page-move-detector.d.ts +11 -0
  31. package/dist/src/page-move-detector.d.ts.map +1 -0
  32. package/dist/src/page-move-detector.js +21 -0
  33. package/dist/src/page-move-detector.js.map +1 -0
  34. package/dist/src/protection-tracker.d.ts +23 -0
  35. package/dist/src/protection-tracker.d.ts.map +1 -0
  36. package/dist/src/protection-tracker.js +74 -0
  37. package/dist/src/protection-tracker.js.map +1 -0
  38. package/dist/src/revert-detector.d.ts +3 -0
  39. package/dist/src/revert-detector.d.ts.map +1 -0
  40. package/dist/src/revert-detector.js +43 -0
  41. package/dist/src/revert-detector.js.map +1 -0
  42. package/dist/src/section-differ.d.ts +26 -0
  43. package/dist/src/section-differ.d.ts.map +1 -0
  44. package/dist/src/section-differ.js +268 -0
  45. package/dist/src/section-differ.js.map +1 -0
  46. package/dist/src/talk-activity-detector.d.ts +16 -0
  47. package/dist/src/talk-activity-detector.d.ts.map +1 -0
  48. package/dist/src/talk-activity-detector.js +76 -0
  49. package/dist/src/talk-activity-detector.js.map +1 -0
  50. package/dist/src/talk-correlator.d.ts +7 -0
  51. package/dist/src/talk-correlator.d.ts.map +1 -0
  52. package/dist/src/talk-correlator.js +53 -0
  53. package/dist/src/talk-correlator.js.map +1 -0
  54. package/dist/src/talk-section-parser.d.ts +22 -0
  55. package/dist/src/talk-section-parser.d.ts.map +1 -0
  56. package/dist/src/talk-section-parser.js +109 -0
  57. package/dist/src/talk-section-parser.js.map +1 -0
  58. package/dist/src/template-tracker.d.ts +12 -0
  59. package/dist/src/template-tracker.d.ts.map +1 -0
  60. package/dist/src/template-tracker.js +225 -0
  61. package/dist/src/template-tracker.js.map +1 -0
  62. package/dist/src/wikilink-extractor.d.ts +8 -0
  63. package/dist/src/wikilink-extractor.d.ts.map +1 -0
  64. package/dist/src/wikilink-extractor.js +81 -0
  65. package/dist/src/wikilink-extractor.js.map +1 -0
  66. package/dist/src/wikitext-parser.d.ts +15 -0
  67. package/dist/src/wikitext-parser.d.ts.map +1 -0
  68. package/dist/src/wikitext-parser.js +85 -0
  69. package/dist/src/wikitext-parser.js.map +1 -0
  70. package/dist/tsconfig 2.tsbuildinfo +1 -0
  71. package/dist/tsconfig.tsbuildinfo +1 -0
  72. package/package.json +28 -0
  73. package/src/__tests__/category-tracker.test.ts +79 -0
  74. package/src/__tests__/citation-tracker.test.ts +185 -0
  75. package/src/__tests__/edit-cluster-detector.test.ts +79 -0
  76. package/src/__tests__/heuristic-classifier.test.ts +67 -0
  77. package/src/__tests__/observation-differ.test.ts +58 -0
  78. package/src/__tests__/page-move-detector.test.ts +64 -0
  79. package/src/__tests__/protection-tracker.test.ts +72 -0
  80. package/src/__tests__/revert-detector.test.ts +76 -0
  81. package/src/__tests__/section-differ.test.ts +120 -0
  82. package/src/__tests__/talk-activity-detector.test.ts +112 -0
  83. package/src/__tests__/talk-correlator.test.ts +71 -0
  84. package/src/__tests__/talk-section-parser.test.ts +105 -0
  85. package/src/__tests__/template-tracker.test.ts +159 -0
  86. package/src/__tests__/wikilink-extractor.test.ts +101 -0
  87. package/src/__tests__/wikitext-parser.test.ts +142 -0
  88. package/src/category-tracker.ts +75 -0
  89. package/src/citation-tracker.ts +226 -0
  90. package/src/claim-differ.ts +4 -0
  91. package/src/edit-cluster-detector.ts +78 -0
  92. package/src/heuristic-classifier.ts +59 -0
  93. package/src/index.ts +88 -0
  94. package/src/observation-differ.ts +26 -0
  95. package/src/page-move-detector.ts +32 -0
  96. package/src/protection-tracker.ts +103 -0
  97. package/src/revert-detector.ts +51 -0
  98. package/src/section-differ.ts +315 -0
  99. package/src/talk-activity-detector.ts +105 -0
  100. package/src/talk-correlator.ts +70 -0
  101. package/src/talk-section-parser.ts +151 -0
  102. package/src/template-tracker.ts +253 -0
  103. package/src/wikilink-extractor.ts +100 -0
  104. package/src/wikitext-parser.ts +92 -0
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildSectionLineage, sectionDiffer } from "../section-differ.js";
3
+
4
+ describe("buildSectionLineage", () => {
5
+ it("tracks section creation and modification across revisions", () => {
6
+ const revisions = [
7
+ { revId: 1, timestamp: "2024-01-01T00:00:00Z", content: "Lead content" },
8
+ { revId: 2, timestamp: "2024-01-02T00:00:00Z", content: "Lead content\n\n== History ==\nOld history" },
9
+ { revId: 3, timestamp: "2024-01-03T00:00:00Z", content: "Lead content\n\n== History ==\nNew history text" },
10
+ ];
11
+
12
+ const lineage = buildSectionLineage(revisions);
13
+ expect(lineage.length).toBeGreaterThan(0);
14
+
15
+ const history = lineage.find((l) => l.sectionName === "History");
16
+ expect(history).toBeDefined();
17
+ expect(history?.firstSeenRevisionId).toBe(2);
18
+ expect(history?.events.length).toBeGreaterThanOrEqual(2);
19
+ expect(history?.isActive).toBe(true);
20
+ });
21
+
22
+ it("detects section removal", () => {
23
+ const revisions = [
24
+ { revId: 1, timestamp: "2024-01-01T00:00:00Z", content: "Lead\n\n== A ==\nContent A\n\n== B ==\nContent B" },
25
+ { revId: 2, timestamp: "2024-01-02T00:00:00Z", content: "Lead\n\n== A ==\nContent A" },
26
+ ];
27
+
28
+ const lineage = buildSectionLineage(revisions);
29
+ const removedB = lineage.find((l) => l.sectionName === "B");
30
+ expect(removedB).toBeDefined();
31
+ expect(removedB?.isActive).toBe(false);
32
+ expect(removedB?.events.some((e) => e.eventType === "removed")).toBe(true);
33
+ });
34
+
35
+ it("returns empty array for no revisions", () => {
36
+ expect(buildSectionLineage([])).toEqual([]);
37
+ });
38
+
39
+ it("detects renamed sections", () => {
40
+ const revisions = [
41
+ { revId: 1, timestamp: "2024-01-01T00:00:00Z", content: "Lead\n\n== History ==\nSame content" },
42
+ { revId: 2, timestamp: "2024-01-02T00:00:00Z", content: "Lead\n\n== Background ==\nSame content" },
43
+ ];
44
+
45
+ const lineage = buildSectionLineage(revisions);
46
+ const renamed = lineage.find((l) => l.sectionName === "Background");
47
+ expect(renamed).toBeDefined();
48
+ expect(renamed?.events.some((e) => e.eventType === "renamed")).toBe(true);
49
+ expect(renamed?.firstSeenRevisionId).toBe(1);
50
+ });
51
+
52
+ it("sorts lineages by section name", () => {
53
+ const revisions = [
54
+ { revId: 1, timestamp: "2024-01-01T00:00:00Z", content: "Lead\n\n== Z ==\nZ content\n\n== A ==\nA content" },
55
+ ];
56
+
57
+ const lineage = buildSectionLineage(revisions);
58
+ expect(lineage[0].sectionName).toBe("(lead)");
59
+ expect(lineage[1].sectionName).toBe("A");
60
+ expect(lineage[2].sectionName).toBe("Z");
61
+ });
62
+ });
63
+
64
+ describe("extractSections", () => {
65
+ it("extracts lead section when no headers", () => {
66
+ const sections = sectionDiffer.extractSections("Plain text content.");
67
+ expect(sections).toHaveLength(1);
68
+ expect(sections[0].title).toBe("");
69
+ });
70
+
71
+ it("extracts sections with headers", () => {
72
+ const wikitext = "Lead\n\n== History ==\nOld history\n\n== References ==\n{{reflist}}";
73
+ const sections = sectionDiffer.extractSections(wikitext);
74
+ expect(sections.length).toBeGreaterThanOrEqual(3);
75
+ const history = sections.find((s) => s.title === "History");
76
+ expect(history).toBeDefined();
77
+ expect(history?.content).toContain("Old history");
78
+ });
79
+
80
+ it("parses heading levels", () => {
81
+ const wikitext = "Lead\n\n== Level 2 ==\nContent\n\n=== Level 3 ===\nDeeper";
82
+ const sections = sectionDiffer.extractSections(wikitext);
83
+ const level3 = sections.find((s) => s.title === "Level 3");
84
+ expect(level3).toBeDefined();
85
+ expect(level3?.level).toBe(3);
86
+ });
87
+ });
88
+
89
+ describe("diffSections", () => {
90
+ it("detects added sections", () => {
91
+ const before = sectionDiffer.extractSections("Lead content");
92
+ const after = sectionDiffer.extractSections("Lead content\n\n== New ==\nFresh");
93
+ const changes = sectionDiffer.diffSections(before, after);
94
+ const added = changes.find((c) => c.changeType === "added");
95
+ expect(added).toBeDefined();
96
+ expect(added?.section).toBe("New");
97
+ });
98
+
99
+ it("detects removed sections", () => {
100
+ const before = sectionDiffer.extractSections("Lead\n\n== Gone ==\nBye");
101
+ const after = sectionDiffer.extractSections("Lead");
102
+ const changes = sectionDiffer.diffSections(before, after);
103
+ const removed = changes.find((c) => c.changeType === "removed");
104
+ expect(removed).toBeDefined();
105
+ });
106
+
107
+ it("detects modified sections", () => {
108
+ const before = sectionDiffer.extractSections("Lead\n\n== Same ==\nOld content");
109
+ const after = sectionDiffer.extractSections("Lead\n\n== Same ==\nNew content");
110
+ const changes = sectionDiffer.diffSections(before, after);
111
+ const modified = changes.find((c) => c.changeType === "modified");
112
+ expect(modified).toBeDefined();
113
+ });
114
+
115
+ it("marks unchanged sections", () => {
116
+ const sections = sectionDiffer.extractSections("Lead\n\n== Stable ==\nSame");
117
+ const changes = sectionDiffer.diffSections(sections, sections);
118
+ expect(changes.every((c) => c.changeType === "unchanged")).toBe(true);
119
+ });
120
+ });
@@ -0,0 +1,112 @@
1
+ import type { Revision } from "@refract-org/evidence-graph";
2
+ import { describe, expect, it } from "vitest";
3
+ import { detectTalkActivitySpikes } from "../talk-activity-detector.js";
4
+
5
+ function makeTalkRev(revId: number, timestamp: string): Revision {
6
+ return { revId, title: "Talk:Test", timestamp, user: "Editor", comment: "talk", content: "" };
7
+ }
8
+
9
+ function makeArticleRev(revId: number, timestamp: string): Revision {
10
+ return { revId, title: "Test", timestamp, user: "Editor", comment: "edit", content: "" };
11
+ }
12
+
13
+ describe("detectTalkActivitySpikes", () => {
14
+ const today = new Date();
15
+ function dayOffset(offset: number): string {
16
+ const d = new Date(today);
17
+ d.setDate(d.getDate() + offset);
18
+ return `${d.toISOString().slice(0, 10)}T12:00:00Z`;
19
+ }
20
+
21
+ it("returns empty for no talk revisions", () => {
22
+ const result = detectTalkActivitySpikes([], [makeArticleRev(1, dayOffset(0))]);
23
+ expect(result.spikes).toHaveLength(0);
24
+ expect(result.movingAverage).toBe(0);
25
+ });
26
+
27
+ it("returns empty when insufficient data for moving average", () => {
28
+ const talkRevs = [makeTalkRev(1, dayOffset(-2)), makeTalkRev(2, dayOffset(-1))];
29
+ const result = detectTalkActivitySpikes(talkRevs, []);
30
+ expect(result.spikes).toHaveLength(0);
31
+ expect(result.activityByDay).toHaveLength(2);
32
+ });
33
+
34
+ it("detects spike when activity exceeds moving average threshold", () => {
35
+ const talkRevs: Revision[] = [];
36
+ let id = 1;
37
+
38
+ for (let d = 4; d >= 1; d--) {
39
+ talkRevs.push(makeTalkRev(id++, dayOffset(-d)));
40
+ }
41
+
42
+ // Spike day: 10 edits (exceeds 3x the moving average of ~1)
43
+ for (let e = 0; e < 10; e++) {
44
+ talkRevs.push(makeTalkRev(id++, dayOffset(0)));
45
+ }
46
+
47
+ const result = detectTalkActivitySpikes(talkRevs, [], {
48
+ lookbackWindowMs: 30 * 24 * 60 * 60 * 1000,
49
+ spikeFactor: 3.0,
50
+ movingAveragePeriods: 4,
51
+ });
52
+
53
+ expect(result.spikes.length).toBeGreaterThanOrEqual(1);
54
+ expect(result.spikes[0].eventType).toBe("talk_activity_spike");
55
+ });
56
+
57
+ it("does not flag normal activity as spikes", () => {
58
+ const talkRevs: Revision[] = [];
59
+ let id = 1;
60
+
61
+ for (let d = 7; d >= 1; d--) {
62
+ talkRevs.push(makeTalkRev(id++, dayOffset(-d)));
63
+ }
64
+
65
+ const result = detectTalkActivitySpikes(talkRevs, [], {
66
+ lookbackWindowMs: 30 * 24 * 60 * 60 * 1000,
67
+ spikeFactor: 3.0,
68
+ movingAveragePeriods: 4,
69
+ });
70
+
71
+ expect(result.spikes).toHaveLength(0);
72
+ });
73
+
74
+ it("includes nearby article edit count in spike facts", () => {
75
+ const talkRevs: Revision[] = [];
76
+ const articleRevs: Revision[] = [];
77
+ let tid = 1;
78
+ let aid = 1;
79
+
80
+ for (let d = 4; d >= 1; d--) {
81
+ talkRevs.push(makeTalkRev(tid++, dayOffset(-d)));
82
+ }
83
+
84
+ for (let e = 0; e < 10; e++) {
85
+ talkRevs.push(makeTalkRev(tid++, dayOffset(0)));
86
+ }
87
+ articleRevs.push(makeArticleRev(aid++, dayOffset(0)));
88
+
89
+ const result = detectTalkActivitySpikes(talkRevs, articleRevs, {
90
+ lookbackWindowMs: 30 * 24 * 60 * 60 * 1000,
91
+ spikeFactor: 3.0,
92
+ movingAveragePeriods: 4,
93
+ });
94
+
95
+ expect(result.spikes.length).toBeGreaterThanOrEqual(1);
96
+ expect(result.spikes[0].deterministicFacts[0].detail).toContain("nearby_article_edits=1");
97
+ });
98
+
99
+ it("returns daily activity buckets", () => {
100
+ const talkRevs: Revision[] = [];
101
+ let id = 1;
102
+ talkRevs.push(makeTalkRev(id++, "2024-03-01T00:00:00Z"));
103
+ talkRevs.push(makeTalkRev(id++, "2024-03-01T12:00:00Z"));
104
+ talkRevs.push(makeTalkRev(id++, "2024-03-02T00:00:00Z"));
105
+
106
+ const result = detectTalkActivitySpikes(talkRevs, []);
107
+ expect(result.activityByDay).toEqual([
108
+ { date: "2024-03-01", count: 2 },
109
+ { date: "2024-03-02", count: 1 },
110
+ ]);
111
+ });
112
+ });
@@ -0,0 +1,71 @@
1
+ import type { Revision } from "@refract-org/evidence-graph";
2
+ import { describe, expect, it } from "vitest";
3
+ import { correlateTalkRevisions } from "../talk-correlator.js";
4
+
5
+ function makeArticleRev(revId: number, daysFromNow: number): Revision {
6
+ const d = new Date("2026-01-15T12:00:00Z");
7
+ d.setDate(d.getDate() + daysFromNow);
8
+ return {
9
+ revId,
10
+ pageId: 1,
11
+ pageTitle: "Test",
12
+ timestamp: d.toISOString(),
13
+ comment: "",
14
+ content: "",
15
+ size: 100,
16
+ minor: false,
17
+ };
18
+ }
19
+
20
+ function makeTalkRev(revId: number, daysFromNow: number, comment = ""): Revision {
21
+ const d = new Date("2026-01-15T12:00:00Z");
22
+ d.setDate(d.getDate() + daysFromNow);
23
+ return {
24
+ revId,
25
+ pageId: 2,
26
+ pageTitle: "Talk:Test",
27
+ timestamp: d.toISOString(),
28
+ comment,
29
+ content: "",
30
+ size: 50,
31
+ minor: false,
32
+ };
33
+ }
34
+
35
+ describe("correlateTalkRevisions", () => {
36
+ it("matches a talk revision within the window", () => {
37
+ const articleRevs = [makeArticleRev(100, 0)];
38
+ const talkRevs = [makeTalkRev(200, 1, "discussing source")];
39
+
40
+ const events = correlateTalkRevisions(articleRevs, talkRevs);
41
+ expect(events).toHaveLength(1);
42
+ expect(events[0].eventType).toBe("talk_page_correlated");
43
+ expect(events[0].fromRevisionId).toBe(100);
44
+ expect(events[0].toRevisionId).toBe(200);
45
+ expect(events[0].deterministicFacts[0].detail).toContain("discussing source");
46
+ });
47
+
48
+ it("does not match a talk revision outside the window", () => {
49
+ const articleRevs = [makeArticleRev(100, 0)];
50
+ const talkRevs = [makeTalkRev(200, -20)];
51
+
52
+ const events = correlateTalkRevisions(articleRevs, talkRevs);
53
+ expect(events).toHaveLength(0);
54
+ });
55
+
56
+ it("matches the closest talk revision for each article revision", () => {
57
+ const articleRevs = [makeArticleRev(100, 0), makeArticleRev(101, 5)];
58
+ const talkRevs = [makeTalkRev(200, 1), makeTalkRev(201, 2), makeTalkRev(202, 6)];
59
+
60
+ const events = correlateTalkRevisions(articleRevs, talkRevs);
61
+ expect(events).toHaveLength(2);
62
+ expect(events[0].toRevisionId).toBe(200);
63
+ expect(events[1].toRevisionId).toBe(202);
64
+ });
65
+
66
+ it("returns empty when either revision list is empty", () => {
67
+ const rev = makeArticleRev(100, 0);
68
+ expect(correlateTalkRevisions([], [rev])).toHaveLength(0);
69
+ expect(correlateTalkRevisions([rev], [])).toHaveLength(0);
70
+ });
71
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildTalkThreadEvents, diffTalkThreads, parseTalkThreads } from "../talk-section-parser.js";
3
+
4
+ const SAMPLE_TALK = `
5
+ == Proposal for merger ==
6
+ I propose merging this article with the main one. ~~~~
7
+ : I support this merger. [[User:Alice|Alice]]
8
+ :: I oppose. [[User:Bob|Bob]]
9
+
10
+ {{resolved}}
11
+
12
+ == Citation dispute ==
13
+ The third paragraph needs better sources. ~~~~
14
+ : Agreed, I've added a citation. [[User:Charlie|Charlie]]
15
+ `;
16
+
17
+ const EMPTY_TALK = "This is a talk page with no thread headings.";
18
+
19
+ describe("parseTalkThreads", () => {
20
+ it("extracts threads with headings", () => {
21
+ const threads = parseTalkThreads(SAMPLE_TALK);
22
+ expect(threads.length).toBeGreaterThanOrEqual(2);
23
+ expect(threads[0].heading).toBe("Proposal for merger");
24
+ expect(threads[1].heading).toBe("Citation dispute");
25
+ });
26
+
27
+ it("detects resolved threads", () => {
28
+ const threads = parseTalkThreads(SAMPLE_TALK);
29
+ const merger = threads.find((t) => t.heading === "Proposal for merger");
30
+ expect(merger?.isResolved).toBe(true);
31
+ });
32
+
33
+ it("extracts replies at different depths", () => {
34
+ const threads = parseTalkThreads(SAMPLE_TALK);
35
+ const merger = threads.find((t) => t.heading === "Proposal for merger");
36
+ expect(merger).toBeDefined();
37
+ expect(merger?.replies.length).toBeGreaterThanOrEqual(3);
38
+ });
39
+
40
+ it("extracts participants from User links", () => {
41
+ const threads = parseTalkThreads(SAMPLE_TALK);
42
+ const merger = threads.find((t) => t.heading === "Proposal for merger");
43
+ expect(merger?.participants.length).toBeGreaterThanOrEqual(1);
44
+ });
45
+
46
+ it("returns empty array for pages with no thread headings", () => {
47
+ const threads = parseTalkThreads(EMPTY_TALK);
48
+ expect(Array.isArray(threads)).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe("diffTalkThreads", () => {
53
+ it("detects a new thread as opened", () => {
54
+ const before = parseTalkThreads("");
55
+ const after = parseTalkThreads(SAMPLE_TALK);
56
+
57
+ const changes = diffTalkThreads(before, after);
58
+ const opened = changes.filter((c) => c.type === "opened");
59
+ expect(opened.length).toBeGreaterThanOrEqual(2);
60
+ });
61
+
62
+ it("detects a removed thread as archived", () => {
63
+ const before = parseTalkThreads(SAMPLE_TALK);
64
+ const after = parseTalkThreads(EMPTY_TALK);
65
+
66
+ const changes = diffTalkThreads(before, after);
67
+ const archived = changes.filter((c) => c.type === "archived");
68
+ expect(archived.length).toBeGreaterThanOrEqual(2);
69
+ });
70
+
71
+ it("detects a reply added to existing thread", () => {
72
+ const before = parseTalkThreads("== Test ==\nFirst post.");
73
+ const after = parseTalkThreads("== Test ==\nFirst post.\n: Reply.");
74
+
75
+ const changes = diffTalkThreads(before, after);
76
+ const replyAdded = changes.find((c) => c.type === "reply_added");
77
+ expect(replyAdded).toBeDefined();
78
+ });
79
+ });
80
+
81
+ describe("buildTalkThreadEvents", () => {
82
+ it("produces talk_thread_opened for new threads", () => {
83
+ const events = buildTalkThreadEvents("", SAMPLE_TALK, 1, 2, "2026-01-01T00:00:00Z");
84
+ const opened = events.filter((e) => e.eventType === "talk_thread_opened");
85
+ expect(opened.length).toBeGreaterThanOrEqual(2);
86
+ expect(opened[0].fromRevisionId).toBe(1);
87
+ expect(opened[0].toRevisionId).toBe(2);
88
+ expect(opened[0].layer).toBe("observed");
89
+ });
90
+
91
+ it("produces talk_thread_archived for removed threads", () => {
92
+ const events = buildTalkThreadEvents(SAMPLE_TALK, EMPTY_TALK, 1, 2, "2026-01-01T00:00:00Z");
93
+ const archived = events.filter((e) => e.eventType === "talk_thread_archived");
94
+ expect(archived.length).toBeGreaterThanOrEqual(2);
95
+ });
96
+
97
+ it("produces talk_reply_added for new replies", () => {
98
+ const before = "== Test ==\nFirst post.";
99
+ const after = "== Test ==\nFirst post.\n: Reply.";
100
+
101
+ const events = buildTalkThreadEvents(before, after, 1, 2, "2026-01-01T00:00:00Z");
102
+ const replies = events.filter((e) => e.eventType === "talk_reply_added");
103
+ expect(replies).toHaveLength(1);
104
+ });
105
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Template } from "../index.js";
3
+ import { buildParamChangeEvents, diffTemplateParams, templateTracker } from "../template-tracker.js";
4
+
5
+ const TMPL_A: Template = {
6
+ name: "Infobox character",
7
+ type: "other",
8
+ params: { name: "Darth Vader", status: "alive", affiliation: "Sith" },
9
+ };
10
+
11
+ const TMPL_B: Template = {
12
+ name: "Infobox character",
13
+ type: "other",
14
+ params: { name: "Darth Vader", status: "deceased", affiliation: "Sith" },
15
+ };
16
+
17
+ const TMPL_C: Template = {
18
+ name: "Infobox character",
19
+ type: "other",
20
+ params: { name: "Darth Vader", status: "deceased", affiliation: "Galactic Empire" },
21
+ };
22
+
23
+ describe("extractTemplates", () => {
24
+ it("extracts a simple template", () => {
25
+ const templates = templateTracker.extractTemplates("{{citation needed}}");
26
+ expect(templates).toHaveLength(1);
27
+ expect(templates[0].name).toBe("citation needed");
28
+ expect(templates[0].type).toBe("citation");
29
+ });
30
+
31
+ it("extracts templates with parameters", () => {
32
+ const wikitext = "{{Infobox person|name=John|age=30}}";
33
+ const templates = templateTracker.extractTemplates(wikitext);
34
+ expect(templates).toHaveLength(1);
35
+ expect(templates[0].params).toBeDefined();
36
+ expect(templates[0].params?.name).toBe("John");
37
+ });
38
+
39
+ it("extracts multiple templates", () => {
40
+ const wikitext = "{{npov}} text {{citation needed}} more {{BLP sources}}";
41
+ const templates = templateTracker.extractTemplates(wikitext);
42
+ expect(templates.length).toBeGreaterThanOrEqual(3);
43
+ });
44
+
45
+ it("returns empty for no templates", () => {
46
+ expect(templateTracker.extractTemplates("Plain text.")).toEqual([]);
47
+ });
48
+
49
+ it("deduplicates identical templates", () => {
50
+ const templates = templateTracker.extractTemplates("{{foo}}{{foo}}");
51
+ expect(templates).toHaveLength(1);
52
+ });
53
+
54
+ it("handles nested templates", () => {
55
+ const wikitext = "{{Infobox|data={{nested}}}}";
56
+ const templates = templateTracker.extractTemplates(wikitext);
57
+ expect(templates).toHaveLength(1);
58
+ });
59
+ });
60
+
61
+ describe("diffTemplates", () => {
62
+ it("detects added templates", () => {
63
+ const before = templateTracker.extractTemplates("plain text");
64
+ const after = templateTracker.extractTemplates("plain text {{citation needed}}");
65
+ const changes = templateTracker.diffTemplates(before, after);
66
+ const added = changes.filter((c) => c.type === "added");
67
+ expect(added).toHaveLength(1);
68
+ expect(added[0].template.name).toBe("citation needed");
69
+ });
70
+
71
+ it("detects removed templates", () => {
72
+ const before = templateTracker.extractTemplates("text {{npov}} more");
73
+ const after = templateTracker.extractTemplates("text more");
74
+ const changes = templateTracker.diffTemplates(before, after);
75
+ expect(changes.filter((c) => c.type === "removed")).toHaveLength(1);
76
+ });
77
+
78
+ it("marks unchanged templates", () => {
79
+ const before = templateTracker.extractTemplates("text {{citation needed}} more");
80
+ const after = templateTracker.extractTemplates("text {{citation needed}} more");
81
+ const changes = templateTracker.diffTemplates(before, after);
82
+ expect(changes.filter((c) => c.type === "unchanged")).toHaveLength(1);
83
+ });
84
+
85
+ it("handles both added and removed simultaneously", () => {
86
+ const before = templateTracker.extractTemplates("{{a}} text");
87
+ const after = templateTracker.extractTemplates("text {{b}}");
88
+ const changes = templateTracker.diffTemplates(before, after);
89
+ expect(changes.filter((c) => c.type === "added")).toHaveLength(1);
90
+ expect(changes.filter((c) => c.type === "removed")).toHaveLength(1);
91
+ });
92
+ });
93
+
94
+ describe("diffTemplateParams", () => {
95
+ it("detects a parameter change", () => {
96
+ const changes = diffTemplateParams([TMPL_A], [TMPL_B]);
97
+ expect(changes).toHaveLength(1);
98
+ expect(changes[0].paramName).toBe("status");
99
+ expect(changes[0].oldValue).toBe("alive");
100
+ expect(changes[0].newValue).toBe("deceased");
101
+ });
102
+
103
+ it("detects a parameter added", () => {
104
+ const before: Template = { name: "Infobox", type: "other", params: { name: "Test" } };
105
+ const after: Template = { name: "Infobox", type: "other", params: { name: "Test", newParam: "value" } };
106
+
107
+ const changes = diffTemplateParams([before], [after]);
108
+ const added = changes.find((c) => c.paramName === "newparam");
109
+ expect(added).toBeDefined();
110
+ expect(added?.oldValue).toBeUndefined();
111
+ expect(added?.newValue).toBe("value");
112
+ });
113
+
114
+ it("detects a parameter removed", () => {
115
+ const before: Template = { name: "Infobox", type: "other", params: { name: "Test", oldParam: "value" } };
116
+ const after: Template = { name: "Infobox", type: "other", params: { name: "Test" } };
117
+
118
+ const changes = diffTemplateParams([before], [after]);
119
+ const removed = changes.find((c) => c.paramName === "oldparam");
120
+ expect(removed).toBeDefined();
121
+ expect(removed?.oldValue).toBe("value");
122
+ expect(removed?.newValue).toBeUndefined();
123
+ });
124
+
125
+ it("returns empty when no params changed", () => {
126
+ const changes = diffTemplateParams([TMPL_A], [{ ...TMPL_A }]);
127
+ expect(changes).toHaveLength(0);
128
+ });
129
+
130
+ it("does not process templates not present in both revisions", () => {
131
+ const different: Template = { name: "Other template", type: "other", params: { x: "y" } };
132
+ const changes = diffTemplateParams([TMPL_A], [different]);
133
+ expect(changes).toHaveLength(0);
134
+ });
135
+
136
+ it("normalizes parameter names (lowercase, trimmed)", () => {
137
+ const before: Template = { name: "Infobox", type: "other", params: { " Name ": "Old" } };
138
+ const after: Template = { name: "Infobox", type: "other", params: { name: "New" } };
139
+
140
+ const changes = diffTemplateParams([before], [after]);
141
+ expect(changes).toHaveLength(1);
142
+ expect(changes[0].paramName).toBe("name");
143
+ expect(changes[0].oldValue).toBe("Old");
144
+ expect(changes[0].newValue).toBe("New");
145
+ });
146
+ });
147
+
148
+ describe("buildParamChangeEvents", () => {
149
+ it("produces template_parameter_changed events", () => {
150
+ const events = buildParamChangeEvents([TMPL_A], [TMPL_C], 1, 2, "2026-01-01T00:00:00Z");
151
+ expect(events).toHaveLength(2);
152
+ expect(events[0].eventType).toBe("template_parameter_changed");
153
+ expect(events[0].fromRevisionId).toBe(1);
154
+ expect(events[0].toRevisionId).toBe(2);
155
+ expect(events[0].layer).toBe("observed");
156
+ const details = events.map((e) => e.deterministicFacts[0].detail ?? "");
157
+ expect(details.some((d) => d.includes("affiliation"))).toBe(true);
158
+ });
159
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildWikilinkEvents, diffWikilinks, extractWikilinks } from "../wikilink-extractor.js";
3
+
4
+ describe("extractWikilinks", () => {
5
+ it("extracts standard wikilinks", () => {
6
+ const wikitext = "According to [[Fan fiction]] and [[Transformative work]].";
7
+ const links = extractWikilinks(wikitext);
8
+ expect(links).toEqual(["fan fiction", "transformative work"]);
9
+ });
10
+
11
+ it("extracts piped wikilinks", () => {
12
+ const wikitext = "See [[Doctor Who|the Doctor]] for details.";
13
+ const links = extractWikilinks(wikitext);
14
+ expect(links).toContain("doctor who");
15
+ });
16
+
17
+ it("returns empty array for no links", () => {
18
+ const wikitext = "This is plain text without any wikilinks.";
19
+ const links = extractWikilinks(wikitext);
20
+ expect(links).toEqual([]);
21
+ });
22
+
23
+ it("skips File: and Image: links", () => {
24
+ const wikitext = "[[File:photo.jpg]] and [[Image:icon.png]] with [[Foo]].";
25
+ const links = extractWikilinks(wikitext);
26
+ expect(links).toEqual(["foo"]);
27
+ });
28
+
29
+ it("skips interwiki links", () => {
30
+ const wikitext = "See [[wikipedia:Fandom]] for more.";
31
+ const links = extractWikilinks(wikitext);
32
+ expect(links).toEqual([]);
33
+ });
34
+
35
+ it("deduplicates identical links", () => {
36
+ const wikitext = "[[Foo]] and [[foo|bar]] and [[FOO]].";
37
+ const links = extractWikilinks(wikitext);
38
+ expect(links).toEqual(["foo"]);
39
+ });
40
+
41
+ it("normalizes underscores to spaces", () => {
42
+ const wikitext = "[[Fan_fiction]] is a genre.";
43
+ const links = extractWikilinks(wikitext);
44
+ expect(links).toContain("fan fiction");
45
+ });
46
+ });
47
+
48
+ describe("diffWikilinks", () => {
49
+ it("detects added links", () => {
50
+ const { added, removed } = diffWikilinks(["foo", "bar"], ["foo", "bar", "baz"]);
51
+ expect(added).toEqual(["baz"]);
52
+ expect(removed).toEqual([]);
53
+ });
54
+
55
+ it("detects removed links", () => {
56
+ const { added, removed } = diffWikilinks(["foo", "bar", "baz"], ["foo"]);
57
+ expect(added).toEqual([]);
58
+ expect(removed).toEqual(["bar", "baz"]);
59
+ });
60
+
61
+ it("detects both added and removed", () => {
62
+ const { added, removed } = diffWikilinks(["foo", "bar"], ["bar", "baz"]);
63
+ expect(added).toEqual(["baz"]);
64
+ expect(removed).toEqual(["foo"]);
65
+ });
66
+
67
+ it("returns empty for no changes", () => {
68
+ const { added, removed } = diffWikilinks(["foo", "bar"], ["foo", "bar"]);
69
+ expect(added).toEqual([]);
70
+ expect(removed).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe("buildWikilinkEvents", () => {
75
+ it("produces correctly shaped events for added links", () => {
76
+ const before = "[[Foo]].";
77
+ const after = "[[Foo]] and [[Bar]].";
78
+ const events = buildWikilinkEvents(before, after, 1, 2, "body", "2024-01-01T00:00:00Z");
79
+
80
+ expect(events.length).toBe(1);
81
+ expect(events[0].eventType).toBe("wikilink_added");
82
+ expect(events[0].fromRevisionId).toBe(1);
83
+ expect(events[0].toRevisionId).toBe(2);
84
+ expect(events[0].section).toBe("body");
85
+ expect(events[0].after).toBe("bar");
86
+ expect(events[0].layer).toBe("observed");
87
+ expect(events[0].timestamp).toBe("2024-01-01T00:00:00Z");
88
+ });
89
+
90
+ it("produces correctly shaped events for removed links", () => {
91
+ const before = "[[Foo]] and [[Bar]].";
92
+ const after = "[[Foo]].";
93
+ const events = buildWikilinkEvents(before, after, 1, 2, "body", "2024-01-01T00:00:00Z");
94
+
95
+ expect(events.length).toBe(1);
96
+ expect(events[0].eventType).toBe("wikilink_removed");
97
+ expect(events[0].fromRevisionId).toBe(1);
98
+ expect(events[0].toRevisionId).toBe(2);
99
+ expect(events[0].before).toBe("bar");
100
+ });
101
+ });