linkedin-studio/scripts/specifics-bank/tests/binding.test.ts
Kjell Tore Guttormsen 33f18c35e6 feat(linkedin-studio): per-edition lived-specifics binding + kilder artifact (fix #2 slice 2)
Slice 2 of fix #2 (kilde-så-draft): the per-edition bridge between the global
specifics-bank (slice 1) and a draft. In scripts/specifics-bank:

- binding.ts — livedSpecifics slot-map model + validateBinding, the «vaghet
  avvises» gate (G4). BLOCK on unresolved / dangling specific-id / unjustified
  abstrakt; WARN on an unverified-number-backed slot (regel 6/7); PASS otherwise.
- kilder.ts — renderKilder, the read-only NN-kilder.md sources ledger, rendered
  deterministically from the slot-map + bank (coverage via validateBinding, so
  the artifact and the skeleton-gate cannot disagree).
- cli.ts — validate-binding (exit 1 = BLOCK) + render-kilder subcommands.

edition-state schema gains articles.NN.livedSpecifics ({ slots, status }) + _doc.
Design record + the Step 2 research re-scope spec: docs/fix2/slice2-binding.md.

Wiring into newsletter.md (Step 1.5 elicitation + Step 2.5 gate + pipeline 17->18)
is slice 3 — not in this commit.

Tests 28/28 (specifics-bank; +9 binding +4 kilder), tsc clean, plugin gate 83/0/0
unaffected, gitleaks clean on all 8 files.

[skip-docs] internal plumbing — no command/agent/pipeline surface change until
slice 3 wires it; documenting it in the user-facing root CLAUDE.md/README now would
overclaim unreachable functionality. Docs ARE updated where they belong: the design
record docs/fix2/slice2-binding.md, scripts/specifics-bank/README.md, and the
edition-state _doc. Root-doc update lands with the slice-3 wiring feat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:33:44 +02:00

151 lines
5.4 KiB
TypeScript

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { validateBinding } from "../src/binding.js";
import type { LivedSpecifics, Slot } from "../src/binding.js";
import type { Bank, Specific } from "../src/types.js";
// ---- fixtures ---------------------------------------------------------------
function mkSpecific(over: Partial<Specific> & { id: string }): Specific {
return {
id: over.id,
type: over.type ?? "other",
content: over.content ?? "x",
topicTags: over.topicTags ?? ["t"],
provenance: over.provenance ?? { capturedAt: "2026-06-20", source: "test" },
verification: over.verification ?? "n/a",
status: over.status ?? "active",
};
}
const mkBank = (specifics: Specific[]): Bank => ({ schemaVersion: 1, specifics });
const slot = (slotId: string, binding: Slot["binding"], over: Partial<Slot> = {}): Slot => ({
slotId,
kind: over.kind ?? "key-point",
label: over.label ?? `label ${slotId}`,
binding,
});
const ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" });
// ---- tests ------------------------------------------------------------------
describe("validateBinding — the «vaghet avvises» gate (G4)", () => {
test("every slot resolved (specific + abstrakt + ekstern) → PASS, coverage counted", () => {
const bank = mkBank([mkSpecific({ id: "aaa", type: "named-case" })]);
const res = validateBinding(
ls([
slot("kp1", { type: "specific", specificId: "aaa" }),
slot("kp2", { type: "abstrakt", rationale: "bevisst abstrakt her" }),
slot("kp3", { type: "ekstern", source: "research note 2" }),
]),
bank,
);
assert.equal(res.verdict, "PASS");
assert.equal(res.problems.length, 0);
assert.deepEqual(res.coverage, {
total: 3,
specific: 1,
abstrakt: 1,
ekstern: 1,
unresolved: 0,
});
});
test("an unresolved slot → BLOCK with reason 'unresolved'", () => {
const res = validateBinding(
ls([
slot("kp1", { type: "specific", specificId: "aaa" }),
slot("kp2", { type: "unresolved" }),
]),
mkBank([mkSpecific({ id: "aaa" })]),
);
assert.equal(res.verdict, "BLOCK");
assert.ok(
res.problems.some((p) => p.slotId === "kp2" && p.reason === "unresolved"),
"kp2 should be flagged unresolved",
);
assert.equal(res.coverage.unresolved, 1);
});
test("a specific slot whose id is not in the bank → BLOCK 'dangling-specific'", () => {
const res = validateBinding(
ls([slot("kp1", { type: "specific", specificId: "missing" })]),
mkBank([]),
);
assert.equal(res.verdict, "BLOCK");
assert.ok(
res.problems.some((p) => p.slotId === "kp1" && p.reason === "dangling-specific"),
"dangling id must be caught",
);
// dangling still counts under `specific` in coverage (binding TYPE is specific)
assert.equal(res.coverage.specific, 1);
});
test("an abstrakt escape with a blank rationale is vaghet → BLOCK 'unjustified-abstrakt'", () => {
const res = validateBinding(ls([slot("kp1", { type: "abstrakt", rationale: " " })]), mkBank([]));
assert.equal(res.verdict, "BLOCK");
assert.ok(
res.problems.some((p) => p.slotId === "kp1" && p.reason === "unjustified-abstrakt"),
"an unjustified abstraction must be rejected",
);
});
test("ekstern needs no source (research fills it later) → PASS", () => {
const res = validateBinding(
ls([
slot("a", { type: "ekstern" }),
slot("b", { type: "abstrakt", rationale: "x" }),
slot("c", { type: "abstrakt", rationale: "y" }),
]),
mkBank([]),
);
assert.equal(res.verdict, "PASS");
assert.deepEqual(res.coverage, {
total: 3,
specific: 0,
abstrakt: 2,
ekstern: 1,
unresolved: 0,
});
});
test("a slot backed by an UNVERIFIED number → PASS + warning (regel 6/7)", () => {
const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "unverified" })]);
const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank);
assert.equal(res.verdict, "PASS", "an unverified number warns, it does not block");
assert.ok(
res.warnings.some((w) => w.slotId === "kp1" && w.reason === "unverified-number"),
"unverified number must surface as a warning",
);
});
test("a slot backed by a VERIFIED number → PASS, no warning", () => {
const bank = mkBank([mkSpecific({ id: "num1", type: "number", verification: "verified" })]);
const res = validateBinding(ls([slot("kp1", { type: "specific", specificId: "num1" })]), bank);
assert.equal(res.verdict, "PASS");
assert.equal(res.warnings.length, 0);
});
test("empty slot-map → PASS, total 0 (the command decides whether ≥1 slot is required)", () => {
const res = validateBinding(ls([]), mkBank([]));
assert.equal(res.verdict, "PASS");
assert.equal(res.coverage.total, 0);
assert.equal(res.problems.length, 0);
});
test("multiple problems are all reported (gate is not short-circuited)", () => {
const res = validateBinding(
ls([
slot("a", { type: "unresolved" }),
slot("b", { type: "specific", specificId: "nope" }),
slot("c", { type: "abstrakt", rationale: "" }),
]),
mkBank([]),
);
assert.equal(res.verdict, "BLOCK");
assert.equal(res.problems.length, 3);
});
});