@m8i-51/shoal 0.1.7 → 0.1.8
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.
|
@@ -167,6 +167,86 @@ describe("computeWeightedSummary", () => {
|
|
|
167
167
|
expect(result.formatted).toContain("By scenario");
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
it("14日以内に同じレンズが複数 run に登場するとボーナスが乗る", () => {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
// 同じ Accessibility レンズが2回登場 → bonus = 1 + (2-1)*0.5 = 1.5
|
|
173
|
+
setupMockCoverage({
|
|
174
|
+
entries: [
|
|
175
|
+
makeEntry({
|
|
176
|
+
runId: "run_1",
|
|
177
|
+
timestamp: new Date(now - 1000).toISOString(),
|
|
178
|
+
findingsCount: 2,
|
|
179
|
+
byLens: { Accessibility: 2 },
|
|
180
|
+
}),
|
|
181
|
+
makeEntry({
|
|
182
|
+
runId: "run_2",
|
|
183
|
+
timestamp: new Date(now).toISOString(),
|
|
184
|
+
findingsCount: 2,
|
|
185
|
+
byLens: { Accessibility: 2 },
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const resultWithRepeat = computeWeightedSummary();
|
|
191
|
+
|
|
192
|
+
// 1回しか登場しない場合と比較
|
|
193
|
+
setupMockCoverage({
|
|
194
|
+
entries: [
|
|
195
|
+
makeEntry({
|
|
196
|
+
runId: "run_1",
|
|
197
|
+
timestamp: new Date(now).toISOString(),
|
|
198
|
+
findingsCount: 2,
|
|
199
|
+
byLens: { Accessibility: 2 },
|
|
200
|
+
}),
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
const resultSingle = computeWeightedSummary();
|
|
204
|
+
|
|
205
|
+
// 繰り返しありのほうが lens の重みが高いはず
|
|
206
|
+
expect(resultWithRepeat.byLens["Accessibility"]).toBeGreaterThan(resultSingle.byLens["Accessibility"]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("14日より古いエントリーは繰り返しカウントに含まれない", () => {
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
const oldMs = 15 * 24 * 60 * 60 * 1000; // 15日前
|
|
212
|
+
setupMockCoverage({
|
|
213
|
+
entries: [
|
|
214
|
+
makeEntry({
|
|
215
|
+
runId: "run_old",
|
|
216
|
+
timestamp: new Date(now - oldMs).toISOString(),
|
|
217
|
+
findingsCount: 2,
|
|
218
|
+
byLens: { Security: 2 },
|
|
219
|
+
}),
|
|
220
|
+
makeEntry({
|
|
221
|
+
runId: "run_new",
|
|
222
|
+
timestamp: new Date(now).toISOString(),
|
|
223
|
+
findingsCount: 2,
|
|
224
|
+
byLens: { Security: 2 },
|
|
225
|
+
}),
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const result = computeWeightedSummary();
|
|
230
|
+
// 古いエントリーはウィンドウ外なのでボーナスなし(繰り返し回数=1 → bonus=1.0)
|
|
231
|
+
// ボーナスなしの場合: weight≈1.0*2 + 15日前のdecay*2 ≈ 2.06
|
|
232
|
+
expect(result.formatted).not.toContain("Repeated lenses");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("繰り返しレンズが formatted に含まれる", () => {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
setupMockCoverage({
|
|
238
|
+
entries: [
|
|
239
|
+
makeEntry({ runId: "r1", timestamp: new Date(now - 1000).toISOString(), findingsCount: 1, byLens: { "UI design": 1 } }),
|
|
240
|
+
makeEntry({ runId: "r2", timestamp: new Date(now).toISOString(), findingsCount: 1, byLens: { "UI design": 1 } }),
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = computeWeightedSummary();
|
|
245
|
+
expect(result.formatted).toContain("Repeated lenses");
|
|
246
|
+
expect(result.formatted).toContain("UI design");
|
|
247
|
+
expect(result.formatted).toContain("×2");
|
|
248
|
+
});
|
|
249
|
+
|
|
170
250
|
it("MAX_ENTRIES を超えると最新30件に切り捨てる", () => {
|
|
171
251
|
const entries = Array.from({ length: 35 }, (_, i) =>
|
|
172
252
|
makeEntry({
|
package/framework/coverage.ts
CHANGED
|
@@ -27,6 +27,9 @@ export interface WeightedSummary {
|
|
|
27
27
|
const COVERAGE_PATH = path.join(process.cwd(), "coverage", "coverage.json");
|
|
28
28
|
const MAX_ENTRIES = 30;
|
|
29
29
|
const HALF_LIFE_DAYS = 7;
|
|
30
|
+
const REPETITION_WINDOW_DAYS = 14;
|
|
31
|
+
const REPETITION_BONUS = 0.005;
|
|
32
|
+
const REPETITION_EXPONENT = 3;
|
|
30
33
|
|
|
31
34
|
export function loadCoverage(): Coverage {
|
|
32
35
|
try {
|
|
@@ -98,6 +101,20 @@ export function computeWeightedSummary(): WeightedSummary {
|
|
|
98
101
|
|
|
99
102
|
const now = Date.now();
|
|
100
103
|
const halfLifeMs = HALF_LIFE_DAYS * 24 * 60 * 60 * 1000;
|
|
104
|
+
const windowMs = REPETITION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
|
105
|
+
|
|
106
|
+
// 14日以内の run で各 lens/scenario が何回登場したかを数える
|
|
107
|
+
const lensRepeat: Record<string, number> = {};
|
|
108
|
+
const scenarioRepeat: Record<string, number> = {};
|
|
109
|
+
for (const entry of coverage.entries) {
|
|
110
|
+
if (now - new Date(entry.timestamp).getTime() > windowMs) continue;
|
|
111
|
+
for (const lens of Object.keys(entry.byLens)) {
|
|
112
|
+
lensRepeat[lens] = (lensRepeat[lens] ?? 0) + 1;
|
|
113
|
+
}
|
|
114
|
+
for (const title of Object.keys(entry.byScenario ?? {})) {
|
|
115
|
+
scenarioRepeat[title] = (scenarioRepeat[title] ?? 0) + 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
101
118
|
|
|
102
119
|
const byCategory: Record<string, number> = {};
|
|
103
120
|
const byLens: Record<string, number> = {};
|
|
@@ -106,18 +123,21 @@ export function computeWeightedSummary(): WeightedSummary {
|
|
|
106
123
|
|
|
107
124
|
for (const entry of coverage.entries) {
|
|
108
125
|
const age = now - new Date(entry.timestamp).getTime();
|
|
109
|
-
const
|
|
126
|
+
const decay = Math.pow(0.5, age / halfLifeMs);
|
|
110
127
|
|
|
111
128
|
for (const [cat, count] of Object.entries(entry.byCategory)) {
|
|
112
|
-
byCategory[cat] = (byCategory[cat] ?? 0) + count *
|
|
129
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + count * decay;
|
|
113
130
|
}
|
|
114
131
|
for (const [lens, count] of Object.entries(entry.byLens)) {
|
|
115
|
-
|
|
132
|
+
// 繰り返し呼ばれるほど「必要」とみなしてボーナスを加算
|
|
133
|
+
const bonus = 1 + Math.pow((lensRepeat[lens] ?? 1) - 1, REPETITION_EXPONENT) * REPETITION_BONUS;
|
|
134
|
+
byLens[lens] = (byLens[lens] ?? 0) + count * decay * bonus;
|
|
116
135
|
}
|
|
117
136
|
for (const [title, count] of Object.entries(entry.byScenario ?? {})) {
|
|
118
|
-
|
|
137
|
+
const bonus = 1 + Math.pow((scenarioRepeat[title] ?? 1) - 1, REPETITION_EXPONENT) * REPETITION_BONUS;
|
|
138
|
+
byScenario[title] = (byScenario[title] ?? 0) + count * decay * bonus;
|
|
119
139
|
}
|
|
120
|
-
totalWeighted += entry.findingsCount *
|
|
140
|
+
totalWeighted += entry.findingsCount * decay;
|
|
121
141
|
}
|
|
122
142
|
|
|
123
143
|
// 小数点1桁に丸める
|
|
@@ -142,12 +162,27 @@ export function computeWeightedSummary(): WeightedSummary {
|
|
|
142
162
|
? `By scenario: ${sortedScenario.map(([t, c]) => `"${t}" (${c})`).join(", ")}`
|
|
143
163
|
: null;
|
|
144
164
|
|
|
165
|
+
const repeatedLenses = Object.entries(lensRepeat)
|
|
166
|
+
.filter(([, n]) => n > 1)
|
|
167
|
+
.sort((a, b) => b[1] - a[1])
|
|
168
|
+
.map(([l, n]) => `${l} (×${n})`);
|
|
169
|
+
const repeatedScenarios = Object.entries(scenarioRepeat)
|
|
170
|
+
.filter(([, n]) => n > 1)
|
|
171
|
+
.sort((a, b) => b[1] - a[1])
|
|
172
|
+
.map(([t, n]) => `"${t}" (×${n})`);
|
|
173
|
+
|
|
145
174
|
const formatted = [
|
|
146
|
-
`Coverage summary (half-life: ${HALF_LIFE_DAYS} days, ${coverage.entries.length} run(s) tracked):`,
|
|
175
|
+
`Coverage summary (half-life: ${HALF_LIFE_DAYS} days, repetition window: ${REPETITION_WINDOW_DAYS} days, ${coverage.entries.length} run(s) tracked):`,
|
|
147
176
|
`Total weighted findings: ${totalWeighted}`,
|
|
148
177
|
`By lens: ${sortedLens.map(([l, c]) => `${l} (${c})`).join(" > ") || "(none)"}`,
|
|
149
178
|
scenarioLine,
|
|
150
179
|
`By category: ${sortedCategory.map(([c, n]) => `${c} (${n})`).join(" > ") || "(none)"}`,
|
|
180
|
+
repeatedLenses.length > 0
|
|
181
|
+
? `Repeated lenses (bonus applied): ${repeatedLenses.join(", ")}`
|
|
182
|
+
: null,
|
|
183
|
+
repeatedScenarios.length > 0
|
|
184
|
+
? `Repeated scenarios (bonus applied): ${repeatedScenarios.join(", ")}`
|
|
185
|
+
: null,
|
|
151
186
|
underrepresented.length > 0
|
|
152
187
|
? `Underrepresented lenses: ${underrepresented.join(", ")} — consider recruiting agents with these perspectives`
|
|
153
188
|
: "All lenses have comparable coverage",
|
package/package.json
CHANGED