claude-dependency-test 1.0.0
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/.claude-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/assets/card.js +696 -0
- package/assets/fonts/IBMPlexMono-400.woff2 +0 -0
- package/assets/fonts/OFL-IBMPlexMono.txt +93 -0
- package/assets/fonts/OFL-VT323.txt +93 -0
- package/assets/fonts/VT323-400.woff2 +0 -0
- package/assets/report.template.html +181 -0
- package/config/scoring.json +203 -0
- package/package.json +29 -0
- package/scripts/diagnose.mjs +168 -0
- package/scripts/lib/collect.mjs +206 -0
- package/scripts/lib/metrics.mjs +267 -0
- package/scripts/lib/publish.mjs +77 -0
- package/scripts/lib/report.mjs +277 -0
- package/scripts/lib/score.mjs +69 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Publishes the aggregate report to the share site and remembers the link locally, so re-running
|
|
2
|
+
// the test updates the same page (and the same README badge) instead of creating a new one.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const TIMEOUT_MS = 10_000;
|
|
7
|
+
|
|
8
|
+
export function readState(file) {
|
|
9
|
+
try {
|
|
10
|
+
const s = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
11
|
+
return s && typeof s.slug === 'string' && typeof s.token === 'string' ? s : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function writeState(file, state) {
|
|
18
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
19
|
+
fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const endpoint = (baseUrl, slug) => `${String(baseUrl).replace(/\/+$/, '')}/api/diagnoses${slug ? `/${slug}` : ''}`;
|
|
23
|
+
|
|
24
|
+
async function call(fetchImpl, url, init) {
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
28
|
+
} catch (err) {
|
|
29
|
+
throw new Error(`could not reach ${new URL(url).host} (${err.cause?.code || err.name})`);
|
|
30
|
+
}
|
|
31
|
+
let body = null;
|
|
32
|
+
try {
|
|
33
|
+
body = await res.json();
|
|
34
|
+
} catch {
|
|
35
|
+
/* empty or non-JSON body */
|
|
36
|
+
}
|
|
37
|
+
return { status: res.status, body };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {Promise<{ url, slug, updated: boolean }>} throws with a readable message on failure.
|
|
42
|
+
*/
|
|
43
|
+
export async function publish({ baseUrl, report, stateFile, forceNew = false, fetchImpl = fetch }) {
|
|
44
|
+
const state = readState(stateFile);
|
|
45
|
+
const payload = JSON.stringify(report);
|
|
46
|
+
const headers = { 'content-type': 'application/json' };
|
|
47
|
+
|
|
48
|
+
if (state && !forceNew && state.baseUrl === baseUrl) {
|
|
49
|
+
const { status, body } = await call(fetchImpl, endpoint(baseUrl, state.slug), {
|
|
50
|
+
method: 'PUT',
|
|
51
|
+
headers: { ...headers, authorization: `Bearer ${state.token}` },
|
|
52
|
+
body: payload,
|
|
53
|
+
});
|
|
54
|
+
if (status === 200 && body && body.url) return { url: body.url, slug: state.slug, updated: true };
|
|
55
|
+
if (status !== 404 && status !== 403) throw new Error(body?.error || `share site answered ${status}`);
|
|
56
|
+
// The page expired or was deleted: fall through and create a new one.
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { status, body } = await call(fetchImpl, endpoint(baseUrl), { method: 'POST', headers, body: payload });
|
|
60
|
+
if (status !== 201 || !body || !body.slug || !body.token || !body.url) {
|
|
61
|
+
throw new Error(body?.error || `share site answered ${status}`);
|
|
62
|
+
}
|
|
63
|
+
writeState(stateFile, { baseUrl, slug: body.slug, token: body.token, url: body.url, publishedAt: new Date().toISOString() });
|
|
64
|
+
return { url: body.url, slug: body.slug, updated: false };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function unpublish({ stateFile, fetchImpl = fetch }) {
|
|
68
|
+
const state = readState(stateFile);
|
|
69
|
+
if (!state) return { deleted: false, reason: 'no published page found on this machine' };
|
|
70
|
+
const { status, body } = await call(fetchImpl, endpoint(state.baseUrl, state.slug), {
|
|
71
|
+
method: 'DELETE',
|
|
72
|
+
headers: { authorization: `Bearer ${state.token}` },
|
|
73
|
+
});
|
|
74
|
+
if (status !== 204 && status !== 404) throw new Error(body?.error || `share site answered ${status}`);
|
|
75
|
+
fs.rmSync(stateFile, { force: true });
|
|
76
|
+
return { deleted: true, url: state.url, slug: state.slug };
|
|
77
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Builds the card, the terminal bulletin and the standalone HTML report from computed results.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import {
|
|
6
|
+
ARCHETYPES,
|
|
7
|
+
REPORT_VERSION,
|
|
8
|
+
STAGES,
|
|
9
|
+
METRICS,
|
|
10
|
+
badgeUrl,
|
|
11
|
+
formatHour,
|
|
12
|
+
severityFlag as flag,
|
|
13
|
+
headlines,
|
|
14
|
+
shareIntents,
|
|
15
|
+
shareText,
|
|
16
|
+
shareUrl,
|
|
17
|
+
usageFacts,
|
|
18
|
+
} from '../../assets/card.js';
|
|
19
|
+
|
|
20
|
+
const ASSETS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets');
|
|
21
|
+
|
|
22
|
+
const num = (v) => Math.round(v).toLocaleString('en-US');
|
|
23
|
+
const pct = (v) => `${Math.round(v * 100)}%`;
|
|
24
|
+
|
|
25
|
+
const CRITERION_SYMPTOMS = {
|
|
26
|
+
hours: (m) => ({ h: Math.round(m.activeHoursPerActiveDay.value * 10) / 10 }),
|
|
27
|
+
streak: (m) => ({ sk: m.longestStreakDays.value }),
|
|
28
|
+
presence: (m) => ({ ad: Math.round(m.activeDaysShare.value * 100) }),
|
|
29
|
+
intensity: (m) => ({ p: Math.round(m.promptsPerActiveDay.value) }),
|
|
30
|
+
marathon: (m) => ({ ls: Math.round(m.longestSessionHours.value * 10) / 10 }),
|
|
31
|
+
delegation: (m) => ({ sa: m.subagentsPerActiveDay.subagents }),
|
|
32
|
+
output: (m) => ({ wd: Math.round(m.wordsPerActiveDay.value) }),
|
|
33
|
+
limits: (m) => ({ l: m.limitHitsPer30Days.limitHits }),
|
|
34
|
+
opus: (m) => ({ o: Math.round(m.opusShare.value * 100) }),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Card = what gets drawn and what the share URL encodes: the 5 most severe symptoms. */
|
|
38
|
+
export function buildCard({ score, stage, archetype, breakdown, metrics, windowDays }) {
|
|
39
|
+
const top = breakdown
|
|
40
|
+
.filter((b) => b.available)
|
|
41
|
+
.sort((a, b) => b.ratio - a.ratio || b.points - a.points)
|
|
42
|
+
.slice(0, 5);
|
|
43
|
+
const sym = {};
|
|
44
|
+
for (const b of top) Object.assign(sym, CRITERION_SYMPTOMS[b.key](metrics));
|
|
45
|
+
return { score, stage, archetype, days: windowDays, sym };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
|
|
49
|
+
|
|
50
|
+
export function renderBulletin(r) {
|
|
51
|
+
const W = 74;
|
|
52
|
+
const line = '─'.repeat(W);
|
|
53
|
+
const out = [];
|
|
54
|
+
const box = (l, rt = '') => `│ ${pad(l, W - 4 - rt.length)}${rt} │`;
|
|
55
|
+
out.push(`┌${'─'.repeat(W - 2)}┐`);
|
|
56
|
+
out.push(box('CLAUDE DEPENDENCY TEST', 'LABORATORY REPORT'));
|
|
57
|
+
out.push(box('How addicted to Claude are you?', 'Rx'));
|
|
58
|
+
out.push(`└${'─'.repeat(W - 2)}┘`);
|
|
59
|
+
const w = r.window;
|
|
60
|
+
out.push(` Specimen anonymous developer (computed locally, nothing uploaded)`);
|
|
61
|
+
out.push(` Observation ${w.days} day${w.days === 1 ? '' : 's'} · ${w.startDay} → ${w.endDay} · ${w.timeZone}`);
|
|
62
|
+
out.push(` Sample ${num(r.sample.prompts)} prompts · ${num(r.sample.sessions)} sessions · ${r.sample.activeDays} active days · ${r.sample.totalActiveHours.toFixed(1)} h`);
|
|
63
|
+
out.push('');
|
|
64
|
+
const filled = Math.round(r.score / 5);
|
|
65
|
+
out.push(` DEPENDENCY SCORE ${r.score}/100 [${'█'.repeat(filled)}${'░'.repeat(20 - filled)}]`);
|
|
66
|
+
out.push(` STAGE ${r.stage.number} of 5 — ${r.stage.name}`);
|
|
67
|
+
out.push(` DIAGNOSIS ${r.archetype.name} ${r.archetype.emoji} (${r.archetype.code})`);
|
|
68
|
+
if (r.headlines.length) {
|
|
69
|
+
out.push('');
|
|
70
|
+
out.push(' HEADLINES');
|
|
71
|
+
for (const h of r.headlines) out.push(` ${pad(h.value, 10)}${pad(h.label, 26)}${h.hint}`);
|
|
72
|
+
}
|
|
73
|
+
out.push('');
|
|
74
|
+
out.push(` ${pad('SYMPTOM', 27)}${pad('RESULT', 12)}${pad('REFERENCE', 13)}${pad('POINTS', 10)}FLAG`);
|
|
75
|
+
out.push(` ${line.slice(0, W - 4)}`);
|
|
76
|
+
for (const b of r.breakdown) {
|
|
77
|
+
if (!b.available) continue;
|
|
78
|
+
b.parts.forEach((p, i) => {
|
|
79
|
+
const f = METRICS[p.metric];
|
|
80
|
+
const pts = i === 0 ? `${b.earned.toFixed(1)}/${b.points}` : '';
|
|
81
|
+
const fl = i === 0 ? flag(b.ratio) : '';
|
|
82
|
+
out.push(` ${pad(f.label, 27)}${pad(f.value(p.value), 12)}${pad(f.ref(p.from, p.to), 13)}${pad(pts, 10)}${fl}`);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const missing = r.breakdown.filter((b) => !b.available);
|
|
86
|
+
if (missing.length) {
|
|
87
|
+
out.push('');
|
|
88
|
+
out.push(` NOT ASSESSED (score renormalized over ${r.points.available}/${r.points.max} pts)`);
|
|
89
|
+
for (const b of missing) out.push(` · ${b.label}: ${b.reason}`);
|
|
90
|
+
}
|
|
91
|
+
out.push('');
|
|
92
|
+
out.push(` Peak hour ${formatHour(r.extras.peakHour)}`);
|
|
93
|
+
const mix = Object.entries(r.extras.modelMix)
|
|
94
|
+
.filter(([, v]) => v >= 0.005)
|
|
95
|
+
.map(([k, v]) => `${k[0].toUpperCase()}${k.slice(1)} ${pct(v)}`)
|
|
96
|
+
.join(' · ');
|
|
97
|
+
if (mix) out.push(` Model mix ${mix}`);
|
|
98
|
+
out.push(` Streak now ${r.extras.currentStreak} day${r.extras.currentStreak === 1 ? '' : 's'} · longest session ${r.extras.longestSessionHours.toFixed(1)} h`);
|
|
99
|
+
const facts = usageFacts(r.usage);
|
|
100
|
+
if (facts.length) {
|
|
101
|
+
out.push('');
|
|
102
|
+
out.push(' USAGE');
|
|
103
|
+
for (const f of facts) out.push(` ${pad(f.value, 16)}${f.label}`);
|
|
104
|
+
}
|
|
105
|
+
if (w.likelyTruncatedByCleanup || w.clampedToRetention) {
|
|
106
|
+
out.push('');
|
|
107
|
+
out.push(` Note: Claude Code deletes transcripts after ${w.cleanupPeriodDays} days (cleanupPeriodDays),`);
|
|
108
|
+
out.push(` so the observation window starts on ${w.startDay}.`);
|
|
109
|
+
}
|
|
110
|
+
out.push('');
|
|
111
|
+
out.push(' Not an actual medical diagnosis. Side effects may include shipping.');
|
|
112
|
+
return out.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Closing lines of the terminal output: share invite and follow links. */
|
|
116
|
+
export function renderShareFooter(r) {
|
|
117
|
+
const lines = ['', ' SPREAD THE DIAGNOSIS', ` Post it on X: ${r.share.intents.x}`, ` Your case file: ${r.share.url}`];
|
|
118
|
+
if (r.follow.length) lines.push('', ` Follow the doctor: ${r.follow.map((f) => `${f.network} ${f.handle}`).join(' · ')}`);
|
|
119
|
+
return lines.join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildResult({ computed, scored, stage, archetype, config, baseUrl, generatedAt, version }) {
|
|
123
|
+
const card = buildCard({
|
|
124
|
+
score: scored.total,
|
|
125
|
+
stage,
|
|
126
|
+
archetype: archetype.slug,
|
|
127
|
+
breakdown: scored.breakdown,
|
|
128
|
+
metrics: computed.metrics,
|
|
129
|
+
windowDays: computed.window.days,
|
|
130
|
+
});
|
|
131
|
+
const url = shareUrl(baseUrl, card);
|
|
132
|
+
const ar = ARCHETYPES[archetype.slug];
|
|
133
|
+
const result = {
|
|
134
|
+
tool: 'claude-dependency-test',
|
|
135
|
+
version,
|
|
136
|
+
generatedAt,
|
|
137
|
+
score: scored.total,
|
|
138
|
+
stage: { number: stage, name: STAGES[stage].name, prognosis: STAGES[stage].prognosis },
|
|
139
|
+
archetype: { slug: archetype.slug, name: ar.name, code: ar.code, emoji: ar.emoji, tagline: ar.tagline, dominantCriterion: archetype.dominant },
|
|
140
|
+
window: computed.window,
|
|
141
|
+
sample: {
|
|
142
|
+
prompts: computed.extras.prompts,
|
|
143
|
+
sessions: computed.extras.sessions,
|
|
144
|
+
activeDays: computed.extras.activeDays,
|
|
145
|
+
totalActiveHours: computed.extras.totalActiveHours,
|
|
146
|
+
},
|
|
147
|
+
points: { earned: scored.earnedPoints, available: scored.availablePoints, max: scored.maxPoints, renormalized: scored.renormalized },
|
|
148
|
+
breakdown: scored.breakdown,
|
|
149
|
+
usage: {
|
|
150
|
+
...computed.usage,
|
|
151
|
+
longestTurnMinutes: Math.round(computed.usage.longestTurnMinutes * 10) / 10,
|
|
152
|
+
claudeHours: Math.round(computed.usage.claudeHours * 10) / 10,
|
|
153
|
+
},
|
|
154
|
+
extras: {
|
|
155
|
+
peakHour: computed.extras.peakHour,
|
|
156
|
+
currentStreak: computed.extras.currentStreak,
|
|
157
|
+
longestSessionHours: computed.extras.longestSessionHours,
|
|
158
|
+
modelMix: computed.extras.modelMix,
|
|
159
|
+
limitHits: computed.extras.limitHits,
|
|
160
|
+
peakWeekday: computed.extras.peakWeekday,
|
|
161
|
+
busiestDay: computed.extras.busiestDay,
|
|
162
|
+
promptsByDay: computed.extras.promptsByDay,
|
|
163
|
+
promptsByHour: computed.extras.promptsByHour,
|
|
164
|
+
promptsByWeekday: computed.extras.promptsByWeekday,
|
|
165
|
+
},
|
|
166
|
+
card,
|
|
167
|
+
share: { url, text: shareText(card), badge: badgeUrl(baseUrl, card), siteLabel: siteLabel(baseUrl) },
|
|
168
|
+
author: config.share.author || { via: {} },
|
|
169
|
+
follow: (config.share.follow || []).filter((f) => f && f.url).map(({ network, handle, url: link }) => ({ network, handle, url: link })),
|
|
170
|
+
};
|
|
171
|
+
finalizeShare(result);
|
|
172
|
+
result.headlines = headlines(result);
|
|
173
|
+
result.bulletin = renderBulletin(result);
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Ready-to-post text and one-click share links, for whatever URL the report ends up at. */
|
|
178
|
+
export function finalizeShare(result) {
|
|
179
|
+
result.share.post = `${result.share.text}\n${result.share.url}`;
|
|
180
|
+
result.share.intents = shareIntents(result.share.url, result.share.text, result.author.via);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The aggregate payload published to the share site: numbers and enum values only
|
|
186
|
+
* (see sanitizeReport in assets/card.js, which the server applies too).
|
|
187
|
+
*/
|
|
188
|
+
export function toPublicReport(r) {
|
|
189
|
+
const round = (v) => Math.round(v * 1000) / 1000;
|
|
190
|
+
return {
|
|
191
|
+
v: REPORT_VERSION,
|
|
192
|
+
score: r.score,
|
|
193
|
+
stage: r.stage.number,
|
|
194
|
+
archetype: r.archetype.slug,
|
|
195
|
+
card: r.card,
|
|
196
|
+
window: { days: r.window.days, startDay: r.window.startDay, endDay: r.window.endDay },
|
|
197
|
+
sample: {
|
|
198
|
+
prompts: r.sample.prompts,
|
|
199
|
+
sessions: r.sample.sessions,
|
|
200
|
+
activeDays: r.sample.activeDays,
|
|
201
|
+
activeHours: round(r.sample.totalActiveHours),
|
|
202
|
+
},
|
|
203
|
+
criteria: r.breakdown.map((b) =>
|
|
204
|
+
b.available
|
|
205
|
+
? { key: b.key, points: b.points, earned: round(b.earned), parts: b.parts.map((p) => ({ metric: p.metric, value: round(p.value), from: p.from, to: p.to })) }
|
|
206
|
+
: { key: b.key, points: b.points, earned: null, parts: [] },
|
|
207
|
+
),
|
|
208
|
+
extras: {
|
|
209
|
+
peakHour: r.extras.peakHour,
|
|
210
|
+
peakWeekday: r.extras.peakWeekday,
|
|
211
|
+
currentStreak: r.extras.currentStreak,
|
|
212
|
+
longestSessionHours: round(r.extras.longestSessionHours),
|
|
213
|
+
busiestDay: r.extras.busiestDay,
|
|
214
|
+
},
|
|
215
|
+
modelMix: Object.fromEntries(Object.entries(r.extras.modelMix).map(([k, v]) => [k, round(v)])),
|
|
216
|
+
usage: r.usage,
|
|
217
|
+
hours: r.extras.promptsByHour,
|
|
218
|
+
weekdays: r.extras.promptsByWeekday,
|
|
219
|
+
daily: r.extras.promptsByDay,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function siteLabel(baseUrl) {
|
|
224
|
+
try {
|
|
225
|
+
return new URL(baseUrl).host;
|
|
226
|
+
} catch {
|
|
227
|
+
return '';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function renderHtml(result) {
|
|
232
|
+
const template = fs.readFileSync(path.join(ASSETS, 'report.template.html'), 'utf8');
|
|
233
|
+
const cardJs = fs.readFileSync(path.join(ASSETS, 'card.js'), 'utf8');
|
|
234
|
+
// Fonts are inlined (the page's Content-Security-Policy forbids any network request).
|
|
235
|
+
const font = (file, family) =>
|
|
236
|
+
`@font-face { font-family: '${family}'; src: url(data:font/woff2;base64,${fs.readFileSync(path.join(ASSETS, 'fonts', file)).toString('base64')}) format('woff2'); }`;
|
|
237
|
+
const fonts = [font('VT323-400.woff2', 'VT323'), font('IBMPlexMono-400.woff2', 'IBM Plex Mono')].join('\n');
|
|
238
|
+
const rows = [];
|
|
239
|
+
for (const b of result.breakdown) {
|
|
240
|
+
if (!b.available) {
|
|
241
|
+
rows.push({ label: b.label, value: 'not assessed', ref: b.reason, points: `–/${b.points}`, flag: '' });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
b.parts.forEach((p, i) => {
|
|
245
|
+
const f = METRICS[p.metric];
|
|
246
|
+
rows.push({
|
|
247
|
+
label: f.label,
|
|
248
|
+
value: f.value(p.value),
|
|
249
|
+
ref: f.ref(p.from, p.to),
|
|
250
|
+
points: i === 0 ? `${b.earned.toFixed(1)}/${b.points}` : '',
|
|
251
|
+
flag: i === 0 ? flag(b.ratio) : '',
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const data = {
|
|
256
|
+
card: result.card,
|
|
257
|
+
share: result.share,
|
|
258
|
+
window: result.window,
|
|
259
|
+
sample: result.sample,
|
|
260
|
+
rows,
|
|
261
|
+
peakHour: formatHour(result.extras.peakHour),
|
|
262
|
+
modelMix: result.extras.modelMix,
|
|
263
|
+
renormalized: result.points.renormalized,
|
|
264
|
+
published: result.published ? { url: result.published.url } : null,
|
|
265
|
+
ecg: result.extras.promptsByDay,
|
|
266
|
+
headlines: result.headlines.slice(0, 4),
|
|
267
|
+
author: result.author,
|
|
268
|
+
facts: usageFacts(result.usage),
|
|
269
|
+
};
|
|
270
|
+
// JSON is embedded in a <script>; escape "<" so no value can close the tag.
|
|
271
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
|
272
|
+
return template
|
|
273
|
+
.replace('/*__FONTS__*/', () => fonts)
|
|
274
|
+
.replace('/*__CARD_JS__*/', () => cardJs)
|
|
275
|
+
.replace('"__DATA_JSON__"', () => json);
|
|
276
|
+
}
|
|
277
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Score, stage and archetype from metrics + config/scoring.json. Pure functions.
|
|
2
|
+
|
|
3
|
+
export const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
|
4
|
+
|
|
5
|
+
/** Linear ramp: 0 at `from`, 1 at `to`. */
|
|
6
|
+
export function linear(value, from, to) {
|
|
7
|
+
if (to === from) return value >= to ? 1 : 0;
|
|
8
|
+
return clamp01((value - from) / (to - from));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function scoreMetrics(metrics, windowDays, config) {
|
|
12
|
+
const breakdown = [];
|
|
13
|
+
let earned = 0;
|
|
14
|
+
let possible = 0;
|
|
15
|
+
|
|
16
|
+
for (const [key, c] of Object.entries(config.criteria)) {
|
|
17
|
+
const missingPart = c.parts.find((p) => !metrics[p.metric] || !metrics[p.metric].available);
|
|
18
|
+
if (missingPart) {
|
|
19
|
+
breakdown.push({
|
|
20
|
+
key,
|
|
21
|
+
label: c.label,
|
|
22
|
+
points: c.points,
|
|
23
|
+
available: false,
|
|
24
|
+
reason: metrics[missingPart.metric] ? metrics[missingPart.metric].reason : 'not computed',
|
|
25
|
+
});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const totalWeight = c.parts.reduce((s, p) => s + p.weight, 0);
|
|
29
|
+
let ratio = 0;
|
|
30
|
+
const parts = c.parts.map((p) => {
|
|
31
|
+
const to = p.capToWindow ? Math.min(p.to, windowDays) : p.to;
|
|
32
|
+
const r = linear(metrics[p.metric].value, p.from, to);
|
|
33
|
+
ratio += (r * p.weight) / totalWeight;
|
|
34
|
+
return { metric: p.metric, value: metrics[p.metric].value, from: p.from, to, ratio: r };
|
|
35
|
+
});
|
|
36
|
+
const pts = ratio * c.points;
|
|
37
|
+
earned += pts;
|
|
38
|
+
possible += c.points;
|
|
39
|
+
breakdown.push({ key, label: c.label, points: c.points, available: true, ratio, earned: pts, parts });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const maxPoints = Object.values(config.criteria).reduce((s, c) => s + c.points, 0);
|
|
43
|
+
const total = possible > 0 ? Math.round((earned / possible) * 100) : 0;
|
|
44
|
+
return {
|
|
45
|
+
total,
|
|
46
|
+
earnedPoints: earned,
|
|
47
|
+
availablePoints: possible,
|
|
48
|
+
maxPoints,
|
|
49
|
+
renormalized: possible < maxPoints,
|
|
50
|
+
breakdown,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stageFor(total, config) {
|
|
55
|
+
const s = config.stages.find((st) => total <= st.max);
|
|
56
|
+
return s ? s.stage : config.stages[config.stages.length - 1].stage;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Dominant criterion = highest ratio; ties go to the criterion worth more points. */
|
|
60
|
+
export function archetypeFor(breakdown, config) {
|
|
61
|
+
const ranked = breakdown
|
|
62
|
+
.filter((b) => b.available)
|
|
63
|
+
.sort((a, b) => b.ratio - a.ratio || b.points - a.points);
|
|
64
|
+
const top = ranked[0];
|
|
65
|
+
if (!top || top.ratio < config.archetypes.minRatio) {
|
|
66
|
+
return { slug: config.archetypes.fallback, dominant: top ? top.key : null };
|
|
67
|
+
}
|
|
68
|
+
return { slug: config.criteria[top.key].archetype, dominant: top.key };
|
|
69
|
+
}
|