diff --git a/docs/skill-quality-scoring-plan.md b/docs/skill-quality-scoring-plan.md new file mode 100644 index 0000000..fcebe6f --- /dev/null +++ b/docs/skill-quality-scoring-plan.md @@ -0,0 +1,78 @@ +# Skill-kvalitetsscore (Spor D) — spec + +_Objektiv 0–100 %-kvalitetsscore per skill, med 90 %-bar, forbedringsrapport og SessionStart-surfacing. Grunnet i Anthropics faktiske skill-authoring-guidance (ikke oppfunnet akademisk). Skrevet 2026-06-23 (S37). Stier relative til plugin-rot._ + +--- + +## 0. Mål + +En **rutine som objektivt måler skill-kvalitet** og rapporterer hva som må forbedres, med målsetning **alle skills ≥ 90 % til enhver tid**. Bygger oppå den eksisterende K1–K10-rubrikken i `scripts/kb-eval/eval.mjs`, som research (under) viser er nesten 1:1 med Anthropics kanoniske «Skill authoring best practices»-sjekkliste — altså allerede grunnet i reell praksis, ikke akademisk oppfunnet. + +## 1. Verifiseringskilder (Anthropic primær) + +- **«Skill authoring best practices»** — platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices (hentet 2026-06-23). Kanonisk sjekkliste + eksakte tall. +- **`skill-creator` (Anthropic-shippet skill)** — `~/.claude/plugins/.../skill-creator/SKILL.md`. Description-optimizer (trigger-presisjon via 20 queries / 60-40 train-test / 3 kjøringer), eval-loop. +- **`plugin-dev:skill-development` (Anthropic-shippet)** — third-person description, imperativ form, progressiv disclosure, «avoid duplication». +- **«Equipping agents for the real world with Agent Skills»** — anthropic.com/engineering (hentet 2026-06-23). Progressiv disclosure, evaluering fra capability-gaps, baseline-uplift. + +### Eksakte, målbare regler funnet (med kilde) +| Regel | Tall | Kilde | +|---|---|---| +| `name` | ≤64 tegn, `a-z0-9-`, ingen XML, ingen reserverte ord («claude»/«anthropic»), gerund-form anbefalt | best-practices §Skill structure | +| `description` | ≤1024 tegn, ikke-tom, 3.-person, «hva + når», spesifikke triggere | best-practices §Writing effective descriptions | +| SKILL.md body | **< 500 linjer** («token budget», «optimal performance») | best-practices §Token budgets + §Progressive disclosure | +| Refs én nivå dypt | ingen nøstede refs | best-practices §Avoid deeply nested references | +| TOC i ref-filer | for filer **> 100 linjer** | best-practices §Structure longer reference files | +| Ingen tid-sensitiv info i body | flytt til «old patterns»/`
` | best-practices §Avoid time-sensitive information | +| Forward-slash-stier | aldri Windows-stier | best-practices §Avoid Windows-style paths | +| Imperativ form | verb-først, ikke 2.-person | plugin-dev §Writing Style | +| Ingen duplisering SKILL.md↔refs | info bor ett sted | plugin-dev §Avoid duplication | +| Evaluering = sannhetskilde | ≥3 evals, **baseline uten skill → mål uplift** | best-practices §Evaluation and iteration | + +## 2. Rubrikk → score-mapping (binding) + +K1–K10 er allerede implementert i `eval.mjs` og validert mot tabellen over. Scoren legger til **fem nye deterministiske sjekker** fra Anthropic-sjekklista som rubrikken manglet (N1–N5), og aggregerer alt til 0–100. + +**Modell: vektet delpoeng + hardt gulv** (operatør-besluttet S37). + +| Kriterium | Kilde | Vekt | Gulv | Delpoeng (0–1) | +|---|---|---|---|---| +| **K1** trigger-presisjon | judge | 3 | **JA** | `precision` (korrekte/20) | +| **K10** søsken-ikke-overlapp | det | 3 | **JA** | `pass?1 : max(0, 1-(maxCombined-terskel)/terskel)` | +| **K3** body ≤500 linjer | det | 2 | — | `lines≤500?1 : max(0, 1-(lines-500)/500)` | +| **K4** ingen duplisering | judge | 2 | — | `score/5` | +| **K9** ingen volatil tid-info | judge | 2 | — | `pass?1:0` | +| **K2** description-format | det | 1 | — | `pass?1:0` | +| **K5** progressiv disclosure | det | 1 | — | `min(1, namedRatio/0.2)` | +| **K6** routing-pekere | det | 1 | — | `pass?1:0` | +| **K7** imperativ stil | judge | 1 | — | `ratio` | +| **K8** kildehenvisning refs | judge | 1 | — | `ratio` | +| ref-tall-konsistens | det | 1 | — | `consistent?1:0` | +| **N1** name-validitet | det | 1 | — | `pass?1:0` | +| **N2** description ≤1024 + ikke-tom | det | 1 | — | `pass?1:0` | +| **N3** refs én nivå dypt | det | 1 | — | `pass?1:0` | +| **N4** TOC i ref-filer >100 linjer | det | 1 | — | andel store filer med TOC | +| **N5** forward-slash-stier | det | 1 | — | `pass?1:0` | + +**Aggregering:** `score = Σ(vekt·delpoeng for TILGJENGELIGE kriterier) / Σ(vekt for tilgjengelige) × 100`. «Tilgjengelig» = deterministiske alltid; judge-kriterier kun når `skill.judge` finnes (cachet i `data/judge-results.json`). + +**Hardt gulv:** feiler et tilgjengelig gulv-kriterium (K1 eller K10) → `score = min(score, 89)`. En skill som ikke trigger riktig (K1) eller overlapper en søster (K10) kan aldri «bestå» 90 %, uansett form. (Anthropic: description/triggering «critical for skill selection».) + +**Degradering:** mangler/stale judge → judge-kriteriene ekskluderes fra både teller og nevner; `judged:false` flagges + nudge om å kjøre judge-passet. K1-gulvet kan da ikke håndheves → `score` merkes `provisional`. K10-gulvet (deterministisk) gjelder alltid. + +## 3. Komponenter (TDD, deterministisk-først) + +1. **`scripts/kb-eval/eval.mjs` (utvidelse):** N1–N5 deterministiske sjekker under `deterministic`. Disk-tilgang bor her (har allerede body/frontmatter/ref-filer). +2. **`scripts/kb-eval/lib/skill-score.mjs` (NY, ren):** `scoreSkill(evalObj)` → `{name, score, rawScore, floored, judged, provisional, criteria[], improvements[], meetsTarget}`. Ingen disk — konsumerer eval-objektet → fullt testbar med fixtures. +3. **CLI `scripts/kb-eval/score-skill.mjs` (NY):** kjører eval-pipelinen → scorer alle skills → rapport. `--json`, `--gate 90` (non-zero exit + forbedringsrapport hvis noen < 90). +4. **`summarizeSkillQuality(report)` (i detection-schedule.mjs):** one-liner «Skill-kvalitet: N skills < 90 %», `null` ved alle ≥ 90. Wires i `session-start-context.mjs` (speiler kurs-/skill-signaler). + +## 4. Verifisering (kjørbar) +- `node --test tests/kb-eval/test-skill-score.test.mjs` grønn: vektet aggregering, gulv kapper ved K1/K10-fail, degradering ekskluderer judge når `judge:null`, forbedringsliste sortert på poeng-tap, `meetsTarget`-grense. +- N1–N5 enhetstester i `test-eval.test.mjs` (gyldig/ugyldig name, >1024 description, nøstet ref, manglende TOC, Windows-sti). +- `score-skill.mjs --gate 90` returnerer non-zero når en fixture-skill er < 90; 0 når alle ≥ 90. +- `summarizeSkillQuality`-test (one-liner + null). +- kb-eval-suite + validate uendret-grønn (ingen regresjon). + +## 5. Fase 2 (roadmap, ikke nå) — effektivitet/uplift +Anthropics *dypeste* mål: «does the skill actually help vs. no-skill baseline». Krever eval-harness (Claude-in-loop, operatør-gated) modellert på `skill-creator`s benchmark (with-skill vs baseline, `pass_rate`-delta, mean±stddev). Egen, tyngre fase — form-scoren over er nødvendig, men uplift er det endelige beviset. Spec-es separat ved opt-in. diff --git a/scripts/kb-eval/lib/skill-score.mjs b/scripts/kb-eval/lib/skill-score.mjs new file mode 100644 index 0000000..dfbe3e5 --- /dev/null +++ b/scripts/kb-eval/lib/skill-score.mjs @@ -0,0 +1,214 @@ +// skill-score.mjs — PURE skill-quality scoring (Spor D). +// +// Aggregates the K1-K10 rubric (eval.mjs) into a single 0-100 quality score per +// skill, using weighted partial-credit with a HARD FLOOR on the load-bearing +// criteria (K1 trigger-precision, K10 sibling-overlap): if either fails, the +// score is capped below the target — a skill that does not trigger correctly or +// overlaps a sibling cannot "pass", regardless of formatting. Grounded in +// Anthropic's skill-authoring best practices (description/triggering is +// "critical for skill selection"). See docs/skill-quality-scoring-plan.md §2. +// +// PURE: consumes the object produced by eval.mjs (evalSkill + attachSiblingOverlap +// + merged judge results). No disk access -> fully testable with fixtures. +// Degrades gracefully when unjudged: judge criteria are excluded from both the +// numerator and the denominator, and the score is flagged `provisional` because +// the K1 floor cannot be enforced without a judge verdict. + +export const TARGET = 90; // every skill should sit at or above this +export const FLOOR_CAP = 89; // capped value when a load-bearing criterion fails +const K5_NAMED_RATIO_TARGET = 0.2; // full credit at/above this (eval.mjs K5 threshold) + +const clamp01 = (n) => Math.max(0, Math.min(1, n)); + +/** + * Criterion registry. Each entry maps the raw eval object to a 0-1 sub-score. + * `read(evalObj)` returns { available, sub, pass, detail }; `available:false` + * means the data is absent (e.g. judge criteria on an unjudged skill) and the + * criterion is dropped from the weighted mean entirely. + * weight — proportional to how load-bearing Anthropic treats the criterion + * floor — failing it caps the score at FLOOR_CAP + * source — 'det' (always available) | 'judge' (cached, operator-gated) + * fix — concrete remediation surfaced in the improvement report + */ +export const CRITERIA = [ + { + key: 'K1', label: 'trigger-presisjon', weight: 3, floor: true, source: 'judge', + fix: 'Optimaliser description-feltet (kjør trigger-eval: in-domain ≥0.90, out-domain FP ≤0.10).', + read: (e) => { + const j = e.judge?.K1_triggerPrecision; + if (!j) return { available: false }; + return { available: true, sub: clamp01(num(j.precision)), pass: !!j.pass, detail: `presisjon ${num(j.precision)}` }; + }, + }, + { + key: 'K10', label: 'søsken-ikke-overlapp', weight: 3, floor: true, source: 'det', + fix: 'Reduser overlapp mot søster-skill (plan-skill-op merge eller sanitize for å skjerpe scope-grensen).', + read: (e) => { + const k = e.deterministic?.K10_siblingScopeOverlap; + if (!k) return { available: false }; + const thr = num(k.threshold) || 7.0; + const sub = k.pass ? 1 : clamp01(1 - (num(k.maxCombined) - thr) / thr); + return { available: true, sub, pass: !!k.pass, detail: `maks ${num(k.maxCombined)} mot ${k.worstSibling} (terskel ${thr})` }; + }, + }, + { + key: 'K3', label: 'body ≤500 linjer', weight: 2, floor: false, source: 'det', + fix: 'Trim SKILL.md til ≤500 linjer; flytt detalj til references/ (Anthropic token-budget).', + read: (e) => { + const k = e.deterministic?.K3_bodyLength; + if (!k) return { available: false }; + const lines = num(k.bodyLines); + const sub = lines <= 500 ? 1 : clamp01(1 - (lines - 500) / 500); + return { available: true, sub, pass: !!k.pass, detail: `${lines} linjer` }; + }, + }, + { + key: 'K4', label: 'ingen duplisering', weight: 2, floor: false, source: 'judge', + fix: 'Fjern detalj fra SKILL.md som dupliserer ref-filer (info bor ett sted).', + read: (e) => { + const j = e.judge?.K4_noDuplication; + if (!j) return { available: false }; + return { available: true, sub: clamp01(num(j.score) / 5), pass: !!j.pass, detail: `score ${num(j.score)}/5` }; + }, + }, + { + key: 'K9', label: 'ingen volatil tid-info', weight: 2, floor: false, source: 'judge', + fix: 'Flytt volatile påstander (GA/preview/versjoner/priser) til ref-filer eller «old patterns».', + read: (e) => { + const j = e.judge?.K9_noTimeSensitive; + if (!j) return { available: false }; + return { available: true, sub: j.pass ? 1 : 0, pass: !!j.pass, detail: `${(j.findings || []).length} funn` }; + }, + }, + { + key: 'K2', label: 'description-format', weight: 1, floor: false, source: 'det', + fix: 'Skriv description i 3.-person «… should be used when …» eller med ≥3 siterte trigger-fraser.', + read: (e) => readBinary(e.deterministic?.K2_descriptionFormat), + }, + { + key: 'K5', label: 'progressiv disclosure', weight: 1, floor: false, source: 'det', + fix: 'Lenk ref-filer ved navn fra SKILL.md (ikke bare mappe-referanser).', + read: (e) => { + const k = e.deterministic?.K5_progressiveDisclosure; + if (!k) return { available: false }; + return { available: true, sub: clamp01(num(k.namedRatio) / K5_NAMED_RATIO_TARGET), pass: !!k.pass, detail: `navngitt-ratio ${num(k.namedRatio)}` }; + }, + }, + { + key: 'K6', label: 'routing-pekere', weight: 1, floor: false, source: 'det', + fix: 'Legg til minst én navngitt startfil SKILL.md peker til.', + read: (e) => readBinary(e.deterministic?.K6_routingTable), + }, + { + key: 'K7', label: 'imperativ stil', weight: 1, floor: false, source: 'judge', + fix: 'Skriv instruksjoner i imperativ/infinitiv (verb-først), ikke 2.-person.', + read: (e) => { + const j = e.judge?.K7_imperativeStyle; + if (!j) return { available: false }; + return { available: true, sub: clamp01(num(j.ratio)), pass: !!j.pass, detail: `ratio ${num(j.ratio)}` }; + }, + }, + { + key: 'K8', label: 'kildehenvisning refs', weight: 1, floor: false, source: 'judge', + fix: 'Legg dated/Verified-header eller kilde-URL i ref-filene (≥0.80 andel).', + read: (e) => { + const j = e.judge?.K8_sourceCitation; + if (!j) return { available: false }; + return { available: true, sub: clamp01(num(j.ratio)), pass: !!j.pass, detail: `ratio ${num(j.ratio)}` }; + }, + }, + { + key: 'refCount', label: 'ref-tall-konsistens', weight: 1, floor: false, source: 'det', + fix: 'Rett opp ref-tall sitert i SKILL.md som ikke matcher disk.', + read: (e) => { + const k = e.deterministic?.refCountConsistency; + if (!k) return { available: false }; + return { available: true, sub: k.consistent ? 1 : 0, pass: !!k.consistent, detail: k.consistent ? 'OK' : `${(k.mismatches || []).length} avvik` }; + }, + }, +]; + +function num(v) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +/** Binary deterministic criterion: { pass } -> sub 1/0. */ +function readBinary(k) { + if (!k) return { available: false }; + return { available: true, sub: k.pass ? 1 : 0, pass: !!k.pass, detail: k.pass ? 'OK' : 'mangler' }; +} + +/** + * Score one skill's eval object into a 0-100 quality score. + * @param {object|null} evalObj shape from eval.mjs evalSkill()+judge merge + * @param {{target?: number, floorCap?: number}} [opts] + * @returns {object|null} null on a missing/garbage object + */ +export function scoreSkill(evalObj, opts = {}) { + if (!evalObj || typeof evalObj !== 'object' || !evalObj.deterministic) return null; + const target = opts.target ?? TARGET; + const floorCap = opts.floorCap ?? FLOOR_CAP; + + const criteria = []; + let weightedSum = 0; + let weightAvail = 0; + let judged = false; + let floorFailed = false; + let provisional = false; + + for (const c of CRITERIA) { + const r = c.read(evalObj); + if (!r.available) { + // A floor criterion we cannot evaluate (e.g. K1 without a judge) makes the + // result provisional — the floor can't be enforced. + if (c.floor) provisional = true; + criteria.push({ key: c.key, label: c.label, weight: c.weight, floor: c.floor, source: c.source, available: false }); + continue; + } + if (c.source === 'judge') judged = true; + const sub = clamp01(r.sub); + weightedSum += c.weight * sub; + weightAvail += c.weight; + if (c.floor && !r.pass) floorFailed = true; + criteria.push({ + key: c.key, label: c.label, weight: c.weight, floor: c.floor, source: c.source, + available: true, sub, pass: !!r.pass, detail: r.detail || '', + pointsLost: c.weight * (1 - sub), fix: c.fix, + }); + } + + const rawScore = weightAvail > 0 ? Math.round((weightedSum / weightAvail) * 100) : 0; + const score = floorFailed ? Math.min(rawScore, floorCap) : rawScore; + + const improvements = criteria + .filter((c) => c.available && c.sub < 1) + .sort((a, b) => b.pointsLost - a.pointsLost) + .map((c) => ({ key: c.key, label: c.label, detail: c.detail, fix: c.fix, pointsLost: c.pointsLost, floor: c.floor })); + + return { + name: evalObj.name, + score, + rawScore, + floored: score < rawScore, + judged, + provisional, + meetsTarget: score >= target, + criteria, + improvements, + }; +} + +/** + * Score every skill in an eval report ({ skills: [...] }) and summarize how many + * fall below the target. + * @param {{skills: object[]}} report + * @param {{target?: number, floorCap?: number}} [opts] + * @returns {{target: number, scored: object[], below: object[]}} + */ +export function scoreReport(report, opts = {}) { + const target = opts.target ?? TARGET; + const scored = (report?.skills || []).map((s) => scoreSkill(s, opts)).filter(Boolean); + const below = scored.filter((s) => !s.meetsTarget); + return { target, scored, below }; +} diff --git a/tests/kb-eval/test-skill-score.test.mjs b/tests/kb-eval/test-skill-score.test.mjs new file mode 100644 index 0000000..293305f --- /dev/null +++ b/tests/kb-eval/test-skill-score.test.mjs @@ -0,0 +1,132 @@ +// tests/kb-eval/test-skill-score.test.mjs +// Unit tests for the PURE skill-quality scoring lib (Spor D). +// scoreSkill(evalObj) consumes the object shape produced by eval.mjs's +// evalSkill() + attachSiblingOverlap() + merged judge results, and returns a +// weighted 0-100 score with a hard floor on the load-bearing criteria (K1, K10). +// See docs/skill-quality-scoring-plan.md §2 for the rubric->score mapping. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { scoreSkill, TARGET, FLOOR_CAP } from '../../scripts/kb-eval/lib/skill-score.mjs'; + +// --- fixture builder: a fully-passing eval object (all sub-scores = 1) ------- +function perfectEval(overrides = {}) { + const base = { + name: 'ms-ai-test', + deterministic: { + K2_descriptionFormat: { pass: true }, + K3_bodyLength: { bodyLines: 120, pass: true }, + K5_progressiveDisclosure: { namedRatio: 0.5, pass: true }, + K6_routingTable: { pass: true }, + refCountConsistency: { consistent: true, mismatches: [] }, + K9_timeSensitiveHints: { timeSensitiveTokenHits: 0 }, + K10_siblingScopeOverlap: { maxCombined: 2.0, worstSibling: 'x', pass: true, threshold: 7.0 }, + }, + judgeInputs: { description: 'desc', bodyLines: 120 }, + judge: { + K1_triggerPrecision: { precision: 1, pass: true }, + K4_noDuplication: { score: 5, pass: true }, + K7_imperativeStyle: { ratio: 1, pass: true }, + K8_sourceCitation: { ratio: 1, pass: true }, + K9_noTimeSensitive: { pass: true, findings: [] }, + }, + }; + // shallow-merge deterministic + judge so a test can override one criterion + return { + ...base, + ...overrides, + deterministic: { ...base.deterministic, ...(overrides.deterministic || {}) }, + judge: overrides.judge === null ? null : { ...base.judge, ...(overrides.judge || {}) }, + }; +} + +test('scoreSkill — a fully-passing skill scores 100 and meets the target', () => { + const r = scoreSkill(perfectEval()); + assert.equal(r.score, 100); + assert.equal(r.meetsTarget, true); + assert.equal(r.judged, true); + assert.equal(r.floored, false); + assert.equal(r.improvements.length, 0); +}); + +test('scoreSkill — TARGET is 90 and FLOOR_CAP is below it', () => { + assert.equal(TARGET, 90); + assert.ok(FLOOR_CAP < TARGET); +}); + +test('scoreSkill — failing K10 (deterministic floor) caps the score below target despite a perfect rest', () => { + const r = scoreSkill(perfectEval({ + deterministic: { K10_siblingScopeOverlap: { maxCombined: 7.4, worstSibling: 'y', pass: false, threshold: 7.0 } }, + })); + assert.equal(r.score, FLOOR_CAP, 'capped at the floor'); + assert.equal(r.floored, true); + assert.equal(r.meetsTarget, false); + assert.ok(r.improvements.some((i) => i.key === 'K10' && i.floor === true)); +}); + +test('scoreSkill — failing K1 (judge floor) caps the score below target', () => { + const r = scoreSkill(perfectEval({ + judge: { K1_triggerPrecision: { precision: 0.6, pass: false } }, + })); + assert.equal(r.score, FLOOR_CAP); + assert.equal(r.meetsTarget, false); + assert.ok(r.improvements.some((i) => i.key === 'K1' && i.floor === true)); +}); + +test('scoreSkill — partial credit: K3 over-length and K4 mid-score reduce the score proportionally', () => { + const r = scoreSkill(perfectEval({ + deterministic: { K3_bodyLength: { bodyLines: 600, pass: false } }, // sub = 1-(600-500)/500 = 0.8 + judge: { K4_noDuplication: { score: 3, pass: false } }, // sub = 3/5 = 0.6 + })); + // Neither K3 nor K4 is a floor criterion, so the score is the weighted mean, not capped. + assert.ok(r.score < 100 && r.score > FLOOR_CAP, `expected high-but-imperfect, got ${r.score}`); + assert.equal(r.floored, false); + const k3 = r.criteria.find((c) => c.key === 'K3'); + const k4 = r.criteria.find((c) => c.key === 'K4'); + assert.ok(Math.abs(k3.sub - 0.8) < 1e-9, `K3 sub was ${k3.sub}`); + assert.ok(Math.abs(k4.sub - 0.6) < 1e-9, `K4 sub was ${k4.sub}`); +}); + +test('scoreSkill — degrades gracefully when unjudged: judge criteria excluded, K10 floor still enforced', () => { + const r = scoreSkill(perfectEval({ judge: null })); + assert.equal(r.judged, false); + assert.equal(r.provisional, true, 'K1 floor cannot be enforced without judge → provisional'); + // all deterministic criteria pass → score 100 over the available (det) set + assert.equal(r.score, 100); + // no judge criteria should appear as available + assert.ok(!r.criteria.some((c) => c.available && c.source === 'judge')); +}); + +test('scoreSkill — unjudged + failing K10 still floors (deterministic floor independent of judge)', () => { + const r = scoreSkill(perfectEval({ + judge: null, + deterministic: { K10_siblingScopeOverlap: { maxCombined: 9.0, worstSibling: 'z', pass: false, threshold: 7.0 } }, + })); + assert.equal(r.score, FLOOR_CAP); + assert.equal(r.meetsTarget, false); +}); + +test('scoreSkill — improvements are sorted by weighted points lost (descending)', () => { + const r = scoreSkill(perfectEval({ + deterministic: { K2_descriptionFormat: { pass: false } }, // weight 1, lose 1.0 + judge: { K4_noDuplication: { score: 0, pass: false } }, // weight 2, lose 2.0 -> first + })); + assert.ok(r.improvements.length >= 2); + assert.equal(r.improvements[0].key, 'K4', 'biggest weighted loss first'); + for (let i = 1; i < r.improvements.length; i++) { + assert.ok(r.improvements[i - 1].pointsLost >= r.improvements[i].pointsLost); + } +}); + +test('scoreSkill — every improvement carries a concrete fix string', () => { + const r = scoreSkill(perfectEval({ + deterministic: { K3_bodyLength: { bodyLines: 700, pass: false } }, + })); + const k3 = r.improvements.find((i) => i.key === 'K3'); + assert.ok(k3 && typeof k3.fix === 'string' && k3.fix.length > 0); +}); + +test('scoreSkill — tolerates a null/garbage eval object without throwing', () => { + assert.equal(scoreSkill(null), null); + assert.equal(scoreSkill({}), null); +});