@mainahq/core 1.0.3 → 1.1.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.
- package/package.json +1 -1
- package/src/ai/__tests__/delegation.test.ts +55 -1
- package/src/ai/delegation.ts +5 -3
- package/src/context/__tests__/budget.test.ts +29 -6
- package/src/context/__tests__/engine.test.ts +1 -0
- package/src/context/__tests__/selector.test.ts +23 -3
- package/src/context/__tests__/wiki.test.ts +349 -0
- package/src/context/budget.ts +12 -8
- package/src/context/engine.ts +37 -0
- package/src/context/selector.ts +30 -4
- package/src/context/wiki.ts +296 -0
- package/src/db/index.ts +12 -0
- package/src/feedback/__tests__/capture.test.ts +166 -0
- package/src/feedback/__tests__/signals.test.ts +144 -0
- package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
- package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
- package/src/feedback/capture.ts +102 -0
- package/src/feedback/signals.ts +68 -0
- package/src/index.ts +104 -0
- package/src/init/__tests__/init.test.ts +400 -3
- package/src/init/index.ts +368 -12
- package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
- package/src/prompts/defaults/index.ts +3 -1
- package/src/prompts/defaults/wiki-compile.md +20 -0
- package/src/prompts/defaults/wiki-query.md +18 -0
- package/src/stats/__tests__/tool-usage.test.ts +133 -0
- package/src/stats/tracker.ts +92 -0
- package/src/verify/__tests__/pipeline.test.ts +11 -8
- package/src/verify/pipeline.ts +13 -1
- package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
- package/src/verify/tools/wiki-lint-runner.ts +38 -0
- package/src/verify/tools/wiki-lint.ts +898 -0
- package/src/wiki/__tests__/compiler.test.ts +389 -0
- package/src/wiki/__tests__/extractors/code.test.ts +99 -0
- package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
- package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
- package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
- package/src/wiki/__tests__/graph.test.ts +344 -0
- package/src/wiki/__tests__/hooks.test.ts +119 -0
- package/src/wiki/__tests__/indexer.test.ts +285 -0
- package/src/wiki/__tests__/linker.test.ts +230 -0
- package/src/wiki/__tests__/louvain.test.ts +229 -0
- package/src/wiki/__tests__/query.test.ts +316 -0
- package/src/wiki/__tests__/schema.test.ts +114 -0
- package/src/wiki/__tests__/signals.test.ts +474 -0
- package/src/wiki/__tests__/state.test.ts +168 -0
- package/src/wiki/__tests__/tracking.test.ts +118 -0
- package/src/wiki/__tests__/types.test.ts +387 -0
- package/src/wiki/compiler.ts +1075 -0
- package/src/wiki/extractors/code.ts +90 -0
- package/src/wiki/extractors/decision.ts +217 -0
- package/src/wiki/extractors/feature.ts +206 -0
- package/src/wiki/extractors/workflow.ts +112 -0
- package/src/wiki/graph.ts +445 -0
- package/src/wiki/hooks.ts +49 -0
- package/src/wiki/indexer.ts +105 -0
- package/src/wiki/linker.ts +117 -0
- package/src/wiki/louvain.ts +190 -0
- package/src/wiki/prompts/compile-architecture.md +59 -0
- package/src/wiki/prompts/compile-decision.md +66 -0
- package/src/wiki/prompts/compile-entity.md +56 -0
- package/src/wiki/prompts/compile-feature.md +60 -0
- package/src/wiki/prompts/compile-module.md +42 -0
- package/src/wiki/prompts/wiki-query.md +25 -0
- package/src/wiki/query.ts +338 -0
- package/src/wiki/schema.ts +111 -0
- package/src/wiki/signals.ts +368 -0
- package/src/wiki/state.ts +89 -0
- package/src/wiki/tracking.ts +30 -0
- package/src/wiki/types.ts +169 -0
- package/src/workflow/context.ts +26 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
calculateEbbinghausScore,
|
|
7
|
+
getPromptEffectiveness,
|
|
8
|
+
getWikiEffectivenessReport,
|
|
9
|
+
recordArticlesLoaded,
|
|
10
|
+
recordWikiUsage,
|
|
11
|
+
} from "../signals";
|
|
12
|
+
import { DECAY_HALF_LIVES } from "../types";
|
|
13
|
+
|
|
14
|
+
// ─── Setup ───────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
let wikiDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = join(
|
|
21
|
+
tmpdir(),
|
|
22
|
+
`wiki-signals-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
23
|
+
);
|
|
24
|
+
wikiDir = join(tmpDir, ".maina", "wiki");
|
|
25
|
+
mkdirSync(wikiDir, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ─── Ebbinghaus Decay ────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe("calculateEbbinghausScore", () => {
|
|
35
|
+
it("should return 1.0 for just-accessed article with 0 prior accesses", () => {
|
|
36
|
+
const score = calculateEbbinghausScore("module", 0, 0);
|
|
37
|
+
// exp(0) + 0.1 * 0 = 1.0
|
|
38
|
+
expect(score).toBeCloseTo(1.0, 5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should return approximately 0.5 decay at half-life with 0 accesses", () => {
|
|
42
|
+
// For module: halfLife = 120 days
|
|
43
|
+
const score = calculateEbbinghausScore("module", 120, 0);
|
|
44
|
+
// exp(-0.693 * 120 / 120) = exp(-0.693) ≈ 0.5
|
|
45
|
+
expect(score).toBeCloseTo(0.5, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should add reinforcement bonus for access count", () => {
|
|
49
|
+
// 0 days since access, 5 accesses
|
|
50
|
+
const score = calculateEbbinghausScore("module", 0, 5);
|
|
51
|
+
// exp(0) + 0.1 * 5 = 1.0 + 0.5 = 1.5, clamped to 1.0
|
|
52
|
+
expect(score).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should cap reinforcement at 10 accesses", () => {
|
|
56
|
+
// Very old article but many accesses
|
|
57
|
+
const score10 = calculateEbbinghausScore("module", 1000, 10);
|
|
58
|
+
const score20 = calculateEbbinghausScore("module", 1000, 20);
|
|
59
|
+
// Both should have same reinforcement (0.1 * 10 = 1.0)
|
|
60
|
+
expect(score10).toBeCloseTo(score20, 5);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should clamp score to [0, 1]", () => {
|
|
64
|
+
// Very fresh + many accesses → should be clamped to 1
|
|
65
|
+
const highScore = calculateEbbinghausScore("decision", 0, 10);
|
|
66
|
+
expect(highScore).toBeLessThanOrEqual(1);
|
|
67
|
+
expect(highScore).toBeGreaterThanOrEqual(0);
|
|
68
|
+
|
|
69
|
+
// Very old + no accesses → should be near 0 but not negative
|
|
70
|
+
const lowScore = calculateEbbinghausScore("feature", 10000, 0);
|
|
71
|
+
expect(lowScore).toBeGreaterThanOrEqual(0);
|
|
72
|
+
expect(lowScore).toBeLessThanOrEqual(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should use correct half-lives per spec", () => {
|
|
76
|
+
expect(DECAY_HALF_LIVES.decision).toBe(180);
|
|
77
|
+
expect(DECAY_HALF_LIVES.architecture).toBe(150);
|
|
78
|
+
expect(DECAY_HALF_LIVES.module).toBe(120);
|
|
79
|
+
expect(DECAY_HALF_LIVES.entity).toBe(90);
|
|
80
|
+
expect(DECAY_HALF_LIVES.feature).toBe(60);
|
|
81
|
+
expect(DECAY_HALF_LIVES.raw).toBe(90);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should decay slower for decisions than features", () => {
|
|
85
|
+
const daysSince = 90;
|
|
86
|
+
const decisionScore = calculateEbbinghausScore("decision", daysSince, 0);
|
|
87
|
+
const featureScore = calculateEbbinghausScore("feature", daysSince, 0);
|
|
88
|
+
// Decision has longer half-life (180 vs 60), so should decay less
|
|
89
|
+
expect(decisionScore).toBeGreaterThan(featureScore);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should produce known values for specific inputs", () => {
|
|
93
|
+
// entity: halfLife=90, 45 days, 3 accesses
|
|
94
|
+
// exp(-0.693 * 45 / 90) + 0.1 * 3
|
|
95
|
+
// = exp(-0.3465) + 0.3
|
|
96
|
+
// ≈ 0.7071 + 0.3 = 1.0071 → clamped to 1.0
|
|
97
|
+
const score = calculateEbbinghausScore("entity", 45, 3);
|
|
98
|
+
expect(score).toBe(1);
|
|
99
|
+
|
|
100
|
+
// feature: halfLife=60, 120 days, 0 accesses
|
|
101
|
+
// exp(-0.693 * 120 / 60) + 0
|
|
102
|
+
// = exp(-1.386) ≈ 0.25
|
|
103
|
+
const score2 = calculateEbbinghausScore("feature", 120, 0);
|
|
104
|
+
expect(score2).toBeCloseTo(0.25, 1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─── Signal Recording ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
describe("recordWikiUsage", () => {
|
|
111
|
+
it("should record and persist usage signals", () => {
|
|
112
|
+
recordWikiUsage(wikiDir, ["modules/auth.md"], "commit", true);
|
|
113
|
+
recordWikiUsage(wikiDir, ["modules/db.md"], "review", false);
|
|
114
|
+
|
|
115
|
+
// Record again and verify accumulation
|
|
116
|
+
recordWikiUsage(wikiDir, ["modules/auth.md"], "verify", true);
|
|
117
|
+
|
|
118
|
+
// Verify by checking prompt effectiveness (which reads from same store)
|
|
119
|
+
// Just ensure no errors — the data is there
|
|
120
|
+
const result = getPromptEffectiveness(wikiDir, "nonexistent");
|
|
121
|
+
expect(result.sampleSize).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should handle multiple articles in single call", () => {
|
|
125
|
+
recordWikiUsage(
|
|
126
|
+
wikiDir,
|
|
127
|
+
["modules/auth.md", "entities/user.md", "decisions/adr-001.md"],
|
|
128
|
+
"commit",
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// No errors = success. Data was written.
|
|
133
|
+
expect(true).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should create signals file if it does not exist", () => {
|
|
137
|
+
const { existsSync } = require("node:fs");
|
|
138
|
+
const signalsPath = join(wikiDir, ".signals.json");
|
|
139
|
+
|
|
140
|
+
expect(existsSync(signalsPath)).toBe(false);
|
|
141
|
+
recordWikiUsage(wikiDir, ["modules/auth.md"], "commit", true);
|
|
142
|
+
expect(existsSync(signalsPath)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should round-trip through JSON storage", () => {
|
|
146
|
+
const { readFileSync } = require("node:fs");
|
|
147
|
+
recordWikiUsage(wikiDir, ["modules/auth.md"], "commit", true);
|
|
148
|
+
recordWikiUsage(wikiDir, ["modules/db.md"], "review", false);
|
|
149
|
+
|
|
150
|
+
const signalsPath = join(wikiDir, ".signals.json");
|
|
151
|
+
const raw = readFileSync(signalsPath, "utf-8");
|
|
152
|
+
const parsed = JSON.parse(raw);
|
|
153
|
+
|
|
154
|
+
expect(parsed.usageSignals).toHaveLength(2);
|
|
155
|
+
expect(parsed.usageSignals[0].articlePath).toBe("modules/auth.md");
|
|
156
|
+
expect(parsed.usageSignals[0].command).toBe("commit");
|
|
157
|
+
expect(parsed.usageSignals[0].accepted).toBe(true);
|
|
158
|
+
expect(parsed.usageSignals[1].accepted).toBe(false);
|
|
159
|
+
expect(typeof parsed.usageSignals[0].timestamp).toBe("string");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── Prompt Effectiveness ────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe("getPromptEffectiveness", () => {
|
|
166
|
+
it("should return zero for unknown prompt hash", () => {
|
|
167
|
+
const result = getPromptEffectiveness(wikiDir, "unknown-hash");
|
|
168
|
+
expect(result.acceptRate).toBe(0);
|
|
169
|
+
expect(result.sampleSize).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should return zero when no usage signals exist for prompt articles", () => {
|
|
173
|
+
// Write prompt signals directly
|
|
174
|
+
const { writeFileSync } = require("node:fs");
|
|
175
|
+
const store = {
|
|
176
|
+
usageSignals: [],
|
|
177
|
+
promptSignals: [
|
|
178
|
+
{
|
|
179
|
+
promptHash: "hash-a",
|
|
180
|
+
articlePath: "modules/auth.md",
|
|
181
|
+
indirectAcceptRate: 0,
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
};
|
|
185
|
+
writeFileSync(join(wikiDir, ".signals.json"), JSON.stringify(store));
|
|
186
|
+
|
|
187
|
+
const result = getPromptEffectiveness(wikiDir, "hash-a");
|
|
188
|
+
expect(result.acceptRate).toBe(0);
|
|
189
|
+
expect(result.sampleSize).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should compute accept rate from linked usage signals", () => {
|
|
193
|
+
const { writeFileSync } = require("node:fs");
|
|
194
|
+
const store = {
|
|
195
|
+
usageSignals: [
|
|
196
|
+
{
|
|
197
|
+
articlePath: "modules/auth.md",
|
|
198
|
+
command: "commit",
|
|
199
|
+
accepted: true,
|
|
200
|
+
timestamp: "2026-04-01T00:00:00.000Z",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
articlePath: "modules/auth.md",
|
|
204
|
+
command: "review",
|
|
205
|
+
accepted: false,
|
|
206
|
+
timestamp: "2026-04-02T00:00:00.000Z",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
articlePath: "modules/auth.md",
|
|
210
|
+
command: "verify",
|
|
211
|
+
accepted: true,
|
|
212
|
+
timestamp: "2026-04-03T00:00:00.000Z",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
articlePath: "modules/db.md",
|
|
216
|
+
command: "commit",
|
|
217
|
+
accepted: false,
|
|
218
|
+
timestamp: "2026-04-04T00:00:00.000Z",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
promptSignals: [
|
|
222
|
+
{
|
|
223
|
+
promptHash: "hash-x",
|
|
224
|
+
articlePath: "modules/auth.md",
|
|
225
|
+
indirectAcceptRate: 0,
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
writeFileSync(join(wikiDir, ".signals.json"), JSON.stringify(store));
|
|
230
|
+
|
|
231
|
+
const result = getPromptEffectiveness(wikiDir, "hash-x");
|
|
232
|
+
// auth.md: 2 accepted out of 3 = 0.67
|
|
233
|
+
expect(result.acceptRate).toBeCloseTo(0.67, 2);
|
|
234
|
+
expect(result.sampleSize).toBe(3);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ─── recordArticlesLoaded ───────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
describe("recordArticlesLoaded", () => {
|
|
241
|
+
it("should write load signals to the signals file", () => {
|
|
242
|
+
const { existsSync, readFileSync } = require("node:fs");
|
|
243
|
+
const sp = join(wikiDir, ".signals.json");
|
|
244
|
+
|
|
245
|
+
expect(existsSync(sp)).toBe(false);
|
|
246
|
+
recordArticlesLoaded(
|
|
247
|
+
sp,
|
|
248
|
+
["modules/auth.md", "decisions/adr-001.md"],
|
|
249
|
+
"review",
|
|
250
|
+
);
|
|
251
|
+
expect(existsSync(sp)).toBe(true);
|
|
252
|
+
|
|
253
|
+
const raw = readFileSync(sp, "utf-8");
|
|
254
|
+
const parsed = JSON.parse(raw);
|
|
255
|
+
|
|
256
|
+
expect(parsed.loadSignals).toHaveLength(1);
|
|
257
|
+
expect(parsed.loadSignals[0].articles).toEqual([
|
|
258
|
+
"modules/auth.md",
|
|
259
|
+
"decisions/adr-001.md",
|
|
260
|
+
]);
|
|
261
|
+
expect(parsed.loadSignals[0].command).toBe("review");
|
|
262
|
+
expect(typeof parsed.loadSignals[0].timestamp).toBe("string");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should append to existing signals without losing data", () => {
|
|
266
|
+
const { readFileSync } = require("node:fs");
|
|
267
|
+
const sp = join(wikiDir, ".signals.json");
|
|
268
|
+
|
|
269
|
+
// First write
|
|
270
|
+
recordArticlesLoaded(sp, ["modules/auth.md"], "review");
|
|
271
|
+
// Second write
|
|
272
|
+
recordArticlesLoaded(sp, ["features/wiki.md"], "commit");
|
|
273
|
+
|
|
274
|
+
const raw = readFileSync(sp, "utf-8");
|
|
275
|
+
const parsed = JSON.parse(raw);
|
|
276
|
+
|
|
277
|
+
expect(parsed.loadSignals).toHaveLength(2);
|
|
278
|
+
expect(parsed.loadSignals[0].articles).toEqual(["modules/auth.md"]);
|
|
279
|
+
expect(parsed.loadSignals[1].articles).toEqual(["features/wiki.md"]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("should not throw on empty articles array", () => {
|
|
283
|
+
const { existsSync } = require("node:fs");
|
|
284
|
+
const sp = join(wikiDir, ".signals.json");
|
|
285
|
+
|
|
286
|
+
recordArticlesLoaded(sp, [], "review");
|
|
287
|
+
// Should not create the file for empty articles
|
|
288
|
+
expect(existsSync(sp)).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ─── getWikiEffectivenessReport ─────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe("getWikiEffectivenessReport", () => {
|
|
295
|
+
it("should return empty report when no signals exist", () => {
|
|
296
|
+
const sp = join(wikiDir, ".signals.json");
|
|
297
|
+
const report = getWikiEffectivenessReport(sp);
|
|
298
|
+
|
|
299
|
+
expect(report.totalLoads).toBe(0);
|
|
300
|
+
expect(report.totalAccepts).toBe(0);
|
|
301
|
+
expect(report.totalRejects).toBe(0);
|
|
302
|
+
expect(report.acceptRate).toBe(0);
|
|
303
|
+
expect(report.articleStats).toHaveLength(0);
|
|
304
|
+
expect(report.negativeArticles).toHaveLength(0);
|
|
305
|
+
expect(report.dormantArticles).toHaveLength(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should compute correct rates from usage signals", () => {
|
|
309
|
+
const { writeFileSync } = require("node:fs");
|
|
310
|
+
const sp = join(wikiDir, ".signals.json");
|
|
311
|
+
const now = new Date().toISOString();
|
|
312
|
+
|
|
313
|
+
const store = {
|
|
314
|
+
usageSignals: [
|
|
315
|
+
{
|
|
316
|
+
articlePath: "modules/auth.md",
|
|
317
|
+
command: "commit",
|
|
318
|
+
accepted: true,
|
|
319
|
+
timestamp: now,
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
articlePath: "modules/auth.md",
|
|
323
|
+
command: "review",
|
|
324
|
+
accepted: true,
|
|
325
|
+
timestamp: now,
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
articlePath: "modules/auth.md",
|
|
329
|
+
command: "verify",
|
|
330
|
+
accepted: false,
|
|
331
|
+
timestamp: now,
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
articlePath: "decisions/adr-001.md",
|
|
335
|
+
command: "commit",
|
|
336
|
+
accepted: true,
|
|
337
|
+
timestamp: now,
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
articlePath: "decisions/adr-001.md",
|
|
341
|
+
command: "review",
|
|
342
|
+
accepted: true,
|
|
343
|
+
timestamp: now,
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
promptSignals: [],
|
|
347
|
+
};
|
|
348
|
+
writeFileSync(sp, JSON.stringify(store));
|
|
349
|
+
|
|
350
|
+
const report = getWikiEffectivenessReport(sp);
|
|
351
|
+
|
|
352
|
+
expect(report.totalLoads).toBe(5);
|
|
353
|
+
expect(report.totalAccepts).toBe(4);
|
|
354
|
+
expect(report.totalRejects).toBe(1);
|
|
355
|
+
expect(report.acceptRate).toBeCloseTo(0.8, 2);
|
|
356
|
+
expect(report.articleStats).toHaveLength(2);
|
|
357
|
+
|
|
358
|
+
// decisions/adr-001.md has 100% (2/2), should be first
|
|
359
|
+
const adrStat = report.articleStats.find(
|
|
360
|
+
(s) => s.article === "decisions/adr-001.md",
|
|
361
|
+
);
|
|
362
|
+
expect(adrStat?.effectivenessScore).toBeCloseTo(1.0, 2);
|
|
363
|
+
expect(adrStat?.loads).toBe(2);
|
|
364
|
+
expect(adrStat?.accepts).toBe(2);
|
|
365
|
+
|
|
366
|
+
// modules/auth.md has 67% (2/3)
|
|
367
|
+
const authStat = report.articleStats.find(
|
|
368
|
+
(s) => s.article === "modules/auth.md",
|
|
369
|
+
);
|
|
370
|
+
expect(authStat?.effectivenessScore).toBeCloseTo(0.667, 2);
|
|
371
|
+
expect(authStat?.loads).toBe(3);
|
|
372
|
+
expect(authStat?.accepts).toBe(2);
|
|
373
|
+
expect(authStat?.rejects).toBe(1);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("should identify negative articles with < 50% accept rate", () => {
|
|
377
|
+
const { writeFileSync } = require("node:fs");
|
|
378
|
+
const sp = join(wikiDir, ".signals.json");
|
|
379
|
+
const now = new Date().toISOString();
|
|
380
|
+
|
|
381
|
+
const store = {
|
|
382
|
+
usageSignals: [
|
|
383
|
+
{
|
|
384
|
+
articlePath: "entities/oldFunction.md",
|
|
385
|
+
command: "commit",
|
|
386
|
+
accepted: false,
|
|
387
|
+
timestamp: now,
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
articlePath: "entities/oldFunction.md",
|
|
391
|
+
command: "review",
|
|
392
|
+
accepted: false,
|
|
393
|
+
timestamp: now,
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
articlePath: "entities/oldFunction.md",
|
|
397
|
+
command: "verify",
|
|
398
|
+
accepted: true,
|
|
399
|
+
timestamp: now,
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
articlePath: "modules/auth.md",
|
|
403
|
+
command: "commit",
|
|
404
|
+
accepted: true,
|
|
405
|
+
timestamp: now,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
articlePath: "modules/auth.md",
|
|
409
|
+
command: "review",
|
|
410
|
+
accepted: true,
|
|
411
|
+
timestamp: now,
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
promptSignals: [],
|
|
415
|
+
};
|
|
416
|
+
writeFileSync(sp, JSON.stringify(store));
|
|
417
|
+
|
|
418
|
+
const report = getWikiEffectivenessReport(sp);
|
|
419
|
+
|
|
420
|
+
// entities/oldFunction.md: 1/3 = 33% -> negative
|
|
421
|
+
expect(report.negativeArticles).toContain("entities/oldFunction.md");
|
|
422
|
+
// modules/auth.md: 2/2 = 100% -> not negative
|
|
423
|
+
expect(report.negativeArticles).not.toContain("modules/auth.md");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("should identify dormant articles from ebbinghaus score", () => {
|
|
427
|
+
const { writeFileSync } = require("node:fs");
|
|
428
|
+
const sp = join(wikiDir, ".signals.json");
|
|
429
|
+
|
|
430
|
+
// Create a signal from 500 days ago
|
|
431
|
+
const oldDate = new Date(
|
|
432
|
+
Date.now() - 500 * 24 * 60 * 60 * 1000,
|
|
433
|
+
).toISOString();
|
|
434
|
+
const recentDate = new Date().toISOString();
|
|
435
|
+
|
|
436
|
+
const store = {
|
|
437
|
+
usageSignals: [
|
|
438
|
+
{
|
|
439
|
+
articlePath: "features/old-feature.md",
|
|
440
|
+
command: "commit",
|
|
441
|
+
accepted: true,
|
|
442
|
+
timestamp: oldDate,
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
articlePath: "modules/recent.md",
|
|
446
|
+
command: "commit",
|
|
447
|
+
accepted: true,
|
|
448
|
+
timestamp: recentDate,
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
promptSignals: [],
|
|
452
|
+
};
|
|
453
|
+
writeFileSync(sp, JSON.stringify(store));
|
|
454
|
+
|
|
455
|
+
const report = getWikiEffectivenessReport(sp);
|
|
456
|
+
|
|
457
|
+
// features/old-feature.md accessed 500 days ago with feature halfLife=60
|
|
458
|
+
expect(report.dormantArticles).toContain("features/old-feature.md");
|
|
459
|
+
// modules/recent.md accessed just now -> not dormant
|
|
460
|
+
expect(report.dormantArticles).not.toContain("modules/recent.md");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("should handle empty usage signals array", () => {
|
|
464
|
+
const { writeFileSync } = require("node:fs");
|
|
465
|
+
const sp = join(wikiDir, ".signals.json");
|
|
466
|
+
|
|
467
|
+
writeFileSync(sp, JSON.stringify({ usageSignals: [], promptSignals: [] }));
|
|
468
|
+
|
|
469
|
+
const report = getWikiEffectivenessReport(sp);
|
|
470
|
+
expect(report.totalLoads).toBe(0);
|
|
471
|
+
expect(report.acceptRate).toBe(0);
|
|
472
|
+
expect(report.articleStats).toHaveLength(0);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
createEmptyState,
|
|
7
|
+
getChangedFiles,
|
|
8
|
+
hashContent,
|
|
9
|
+
loadState,
|
|
10
|
+
saveState,
|
|
11
|
+
} from "../state";
|
|
12
|
+
import type { WikiState } from "../types";
|
|
13
|
+
|
|
14
|
+
let tmpDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tmpDir = join(
|
|
18
|
+
tmpdir(),
|
|
19
|
+
`wiki-state-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
20
|
+
);
|
|
21
|
+
mkdirSync(join(tmpDir, "wiki"), { recursive: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("Wiki State", () => {
|
|
29
|
+
describe("createEmptyState", () => {
|
|
30
|
+
it("should create state with empty hashes and no timestamps", () => {
|
|
31
|
+
const state = createEmptyState();
|
|
32
|
+
expect(Object.keys(state.fileHashes)).toHaveLength(0);
|
|
33
|
+
expect(Object.keys(state.articleHashes)).toHaveLength(0);
|
|
34
|
+
expect(state.lastFullCompile).toBe("");
|
|
35
|
+
expect(state.lastIncrementalCompile).toBe("");
|
|
36
|
+
expect(state.compilationPromptHash).toBe("");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("hashContent", () => {
|
|
41
|
+
it("should produce consistent SHA-256 hashes", () => {
|
|
42
|
+
const hash1 = hashContent("hello world");
|
|
43
|
+
const hash2 = hashContent("hello world");
|
|
44
|
+
expect(hash1).toBe(hash2);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should produce different hashes for different content", () => {
|
|
48
|
+
const hash1 = hashContent("hello");
|
|
49
|
+
const hash2 = hashContent("world");
|
|
50
|
+
expect(hash1).not.toBe(hash2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should handle empty string", () => {
|
|
54
|
+
const hash = hashContent("");
|
|
55
|
+
expect(hash).toBeTruthy();
|
|
56
|
+
expect(typeof hash).toBe("string");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should produce hex-encoded hashes", () => {
|
|
60
|
+
const hash = hashContent("test");
|
|
61
|
+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("saveState / loadState round-trip", () => {
|
|
66
|
+
it("should save and load state correctly", () => {
|
|
67
|
+
const wikiDir = join(tmpDir, "wiki");
|
|
68
|
+
const state: WikiState = {
|
|
69
|
+
fileHashes: { "src/a.ts": "abc", "src/b.ts": "def" },
|
|
70
|
+
articleHashes: { "modules/auth.md": "ghi" },
|
|
71
|
+
lastFullCompile: "2026-04-07T00:00:00.000Z",
|
|
72
|
+
lastIncrementalCompile: "2026-04-07T12:00:00.000Z",
|
|
73
|
+
compilationPromptHash: "prompt_v1",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
saveState(wikiDir, state);
|
|
77
|
+
const loaded = loadState(wikiDir);
|
|
78
|
+
|
|
79
|
+
expect(loaded).not.toBeNull();
|
|
80
|
+
expect(loaded?.fileHashes["src/a.ts"]).toBe("abc");
|
|
81
|
+
expect(loaded?.articleHashes["modules/auth.md"]).toBe("ghi");
|
|
82
|
+
expect(loaded?.lastFullCompile).toBe("2026-04-07T00:00:00.000Z");
|
|
83
|
+
expect(loaded?.compilationPromptHash).toBe("prompt_v1");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should return null when no state file exists", () => {
|
|
87
|
+
const wikiDir = join(tmpDir, "wiki");
|
|
88
|
+
const loaded = loadState(wikiDir);
|
|
89
|
+
expect(loaded).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should handle corrupted state file gracefully", () => {
|
|
93
|
+
const wikiDir = join(tmpDir, "wiki");
|
|
94
|
+
writeFileSync(join(wikiDir, ".state.json"), "not valid json");
|
|
95
|
+
const loaded = loadState(wikiDir);
|
|
96
|
+
expect(loaded).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should overwrite previous state on save", () => {
|
|
100
|
+
const wikiDir = join(tmpDir, "wiki");
|
|
101
|
+
const state1: WikiState = {
|
|
102
|
+
fileHashes: { "a.ts": "old" },
|
|
103
|
+
articleHashes: {},
|
|
104
|
+
lastFullCompile: "old",
|
|
105
|
+
lastIncrementalCompile: "",
|
|
106
|
+
compilationPromptHash: "",
|
|
107
|
+
};
|
|
108
|
+
saveState(wikiDir, state1);
|
|
109
|
+
|
|
110
|
+
const state2: WikiState = {
|
|
111
|
+
fileHashes: { "a.ts": "new", "b.ts": "added" },
|
|
112
|
+
articleHashes: {},
|
|
113
|
+
lastFullCompile: "new",
|
|
114
|
+
lastIncrementalCompile: "",
|
|
115
|
+
compilationPromptHash: "",
|
|
116
|
+
};
|
|
117
|
+
saveState(wikiDir, state2);
|
|
118
|
+
|
|
119
|
+
const loaded = loadState(wikiDir);
|
|
120
|
+
expect(loaded?.fileHashes["a.ts"]).toBe("new");
|
|
121
|
+
expect(loaded?.fileHashes["b.ts"]).toBe("added");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("getChangedFiles", () => {
|
|
126
|
+
it("should detect new files not in previous state", () => {
|
|
127
|
+
const previousHashes: Record<string, string> = { "a.ts": "hash1" };
|
|
128
|
+
const currentHashes: Record<string, string> = {
|
|
129
|
+
"a.ts": "hash1",
|
|
130
|
+
"b.ts": "hash2",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const changed = getChangedFiles(previousHashes, currentHashes);
|
|
134
|
+
expect(changed).toContain("b.ts");
|
|
135
|
+
expect(changed).not.toContain("a.ts");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should detect modified files with changed hashes", () => {
|
|
139
|
+
const previousHashes: Record<string, string> = { "a.ts": "old" };
|
|
140
|
+
const currentHashes: Record<string, string> = { "a.ts": "new" };
|
|
141
|
+
|
|
142
|
+
const changed = getChangedFiles(previousHashes, currentHashes);
|
|
143
|
+
expect(changed).toContain("a.ts");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should detect deleted files", () => {
|
|
147
|
+
const previousHashes: Record<string, string> = {
|
|
148
|
+
"a.ts": "hash1",
|
|
149
|
+
"b.ts": "hash2",
|
|
150
|
+
};
|
|
151
|
+
const currentHashes: Record<string, string> = { "a.ts": "hash1" };
|
|
152
|
+
|
|
153
|
+
const changed = getChangedFiles(previousHashes, currentHashes);
|
|
154
|
+
expect(changed).toContain("b.ts");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("should return empty array when nothing changed", () => {
|
|
158
|
+
const hashes: Record<string, string> = { "a.ts": "hash1" };
|
|
159
|
+
const changed = getChangedFiles(hashes, { ...hashes });
|
|
160
|
+
expect(changed).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should handle empty previous state (first compilation)", () => {
|
|
164
|
+
const changed = getChangedFiles({}, { "a.ts": "h1", "b.ts": "h2" });
|
|
165
|
+
expect(changed).toHaveLength(2);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|