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>
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 21:33:44 +02:00
commit 33f18c35e6
8 changed files with 744 additions and 1 deletions

View file

@ -57,6 +57,28 @@ guilty-until-checked — regel 6/7) and `--source` to `manual`.
recency (`capturedAt` desc); archived material is kept but never offered.
- **Content is stored verbatim**; only the id is normalized.
## Per-edition binding (slice 2)
The bank is the *global* inventory; an edition *binds* its load-bearing claims to
real material before any prose exists. That bridge lives here too:
- `binding.ts` — the per-edition slot-map model + `validateBinding`, the
«vaghet avvises» gate (a slot must be `specific` / `abstrakt` / `ekstern`; an
`unresolved` slot, a dangling specific id, or an unjustified `abstrakt` → BLOCK;
an unverified-number-backed slot → WARN per regel 6/7).
- `kilder.ts``renderKilder`, the read-only `NN-kilder.md` sources artifact,
rendered deterministically from the slot-map + the bank.
```bash
# Gate one edition's binding (exit 1 = BLOCK)
node --import tsx src/cli.ts validate-binding --edition "<abs>/linkedin/edition-state.json"
# Render the sources ledger
node --import tsx src/cli.ts render-kilder --edition "<abs>/linkedin/edition-state.json" --out "<abs>/linkedin/NN-kilder.md"
```
Design record + the Step 2 research re-scope spec: `docs/fix2/slice2-binding.md`.
The elicitation interview that POPULATES the slot-map (Step 1.5) is slice 3.
## Test
```bash

View file

@ -0,0 +1,156 @@
/**
* Per-edition lived-specifics BINDING (Fix #2, slice 2 kilde--draft).
*
* The bank (`bank.ts`) is the operator's global inventory of real material. This
* module is the per-EDITION bridge: a slot-map that binds each load-bearing
* key-point (or, later, section) of one edition to either a real specific from
* the bank OR an explicit escape decision. It lives in the edition's
* `edition-state.json` under `articles.NN.livedSpecifics`; this module only
* validates and reports on it (pure, testable). The elicitation that POPULATES
* the slot-map the guided interview that refuses vague answers lives in the
* command layer (`/linkedin:newsletter` Step 1.5, slice 3).
*
* THE GATE (retning §3 + drømme-spec G4, «vaghet avvises»): a load-bearing claim
* must be grounded in real material (`specific`) or explicitly escape-hatched
* (`abstrakt` with a justification, or `ekstern` = backed by research, not lived).
* A slot left `unresolved` is the vagueness the gate rejects BLOCK. Numbers
* stay guilty-until-checked: a slot backed by an UNVERIFIED number is allowed but
* WARNED (regel 6/7), so Step 2 research verifies it before a draft asserts it.
*/
import type { Bank, Specific } from "./types.js";
/** A slot is a load-bearing key-point (Step 1.5) or a section (Step 2.5 refinement). */
export type SlotKind = "key-point" | "section";
/** How a slot is grounded. `unresolved` is the vagueness the gate blocks on. */
export type Binding =
| { type: "specific"; specificId: string } // grounded in real bank material (best)
| { type: "abstrakt"; rationale: string } // deliberately abstract here — must justify
| { type: "ekstern"; source?: string } // backed by external research, not lived material
| { type: "unresolved" }; // not yet decided → BLOCK
export interface Slot {
slotId: string;
kind: SlotKind;
/** Human-readable claim/key-point text. */
label: string;
binding: Binding;
}
export type LivedSpecificsStatus = "pending" | "bound";
export interface LivedSpecifics {
slots: Slot[];
status: LivedSpecificsStatus;
}
export type BindingVerdict = "PASS" | "BLOCK";
export type SlotProblemReason = "unresolved" | "dangling-specific" | "unjustified-abstrakt";
export interface SlotProblem {
slotId: string;
reason: SlotProblemReason;
detail: string;
}
export interface SlotWarning {
slotId: string;
reason: "unverified-number";
detail: string;
}
/** Coverage counts by binding TYPE (a dangling specific still counts under `specific`). */
export interface BindingCoverage {
total: number;
specific: number;
abstrakt: number;
ekstern: number;
unresolved: number;
}
export interface BindingResult {
verdict: BindingVerdict;
/** BLOCK reasons — every offending slot, not short-circuited. */
problems: SlotProblem[];
/** Non-blocking direction (regel 6/7 number-verification). */
warnings: SlotWarning[];
coverage: BindingCoverage;
}
const isBlank = (s: string): boolean => s.trim().length === 0;
/**
* Validate one edition's slot-map against the bank. Pure: no I/O, no clock.
* BLOCK iff any slot is `unresolved`, points at a `specificId` absent from the
* bank, or is an `abstrakt` escape with a blank rationale. Otherwise PASS.
* Warnings (do not block) flag slots backed by an unverified number (regel 6/7).
*/
export function validateBinding(ls: LivedSpecifics, bank: Bank): BindingResult {
const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s]));
const problems: SlotProblem[] = [];
const warnings: SlotWarning[] = [];
const coverage: BindingCoverage = {
total: ls.slots.length,
specific: 0,
abstrakt: 0,
ekstern: 0,
unresolved: 0,
};
for (const slot of ls.slots) {
const b = slot.binding;
switch (b.type) {
case "specific": {
coverage.specific++;
const spec = byId.get(b.specificId);
if (!spec) {
problems.push({
slotId: slot.slotId,
reason: "dangling-specific",
detail: `specificId '${b.specificId}' is not in the bank`,
});
} else if (spec.type === "number" && spec.verification === "unverified") {
warnings.push({
slotId: slot.slotId,
reason: "unverified-number",
detail: `number '${b.specificId}' is unverified — verify before the draft asserts it (regel 6/7)`,
});
}
break;
}
case "abstrakt": {
coverage.abstrakt++;
if (isBlank(b.rationale)) {
problems.push({
slotId: slot.slotId,
reason: "unjustified-abstrakt",
detail: "an abstrakt escape needs a rationale — an unjustified abstraction is vaghet",
});
}
break;
}
case "ekstern": {
coverage.ekstern++; // source optional — Step 2 research fills it
break;
}
case "unresolved": {
coverage.unresolved++;
problems.push({
slotId: slot.slotId,
reason: "unresolved",
detail: "slot is unresolved — bind it to a specific, or escape-hatch as abstrakt/ekstern",
});
break;
}
}
}
return {
verdict: problems.length === 0 ? "PASS" : "BLOCK",
problems,
warnings,
coverage,
};
}

View file

@ -14,6 +14,8 @@
* Exit code: 0 on success, 2 on usage error.
*/
import { readFileSync, writeFileSync } from "node:fs";
import {
addSpecific,
defaultBankPath,
@ -21,6 +23,9 @@ import {
queryByTopic,
saveBank,
} from "./bank.js";
import { validateBinding } from "./binding.js";
import { renderKilder } from "./kilder.js";
import type { LivedSpecifics } from "./binding.js";
import type { SpecificType, Verification } from "./types.js";
const VALID_TYPES: SpecificType[] = [
@ -65,11 +70,35 @@ function usage(msg: string): never {
"usage:\n" +
" query --tags <a,b> [--bank <path>] [--json]\n" +
' add --type <t> --content "<text>" --tags <a,b> [--source <s>] [--verification <v>] [--bank <path>]\n' +
" list [--bank <path>] [--json]",
" list [--bank <path>] [--json]\n" +
" validate-binding --edition <edition-state.json> [--article <id>] [--bank <path>] [--json]\n" +
" render-kilder --edition <edition-state.json> [--article <id>] [--bank <path>] [--out <path>]",
);
process.exit(2);
}
/** Minimal shape of edition-state.json that the binding subcommands read. */
interface EditionArticle {
title?: string;
livedSpecifics?: LivedSpecifics;
}
interface EditionState {
currentArticle?: string;
articles?: Record<string, EditionArticle>;
}
const EMPTY_LS: LivedSpecifics = { slots: [], status: "pending" };
function loadEdition(path: string): EditionState {
return JSON.parse(readFileSync(path, "utf8")) as EditionState;
}
/** Resolve the target article: explicit --article, else currentArticle, else "01". */
function resolveArticle(edition: EditionState, flag?: string): { id: string; article: EditionArticle } {
const id = flag ?? edition.currentArticle ?? "01";
return { id, article: edition.articles?.[id] ?? {} };
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
@ -145,6 +174,45 @@ function main(): void {
return;
}
if (command === "validate-binding") {
if (!flags.edition || flags.edition === "true") usage("validate-binding needs --edition <path>");
const edition = loadEdition(flags.edition);
const { id, article } = resolveArticle(edition, flags.article);
const ls = article.livedSpecifics ?? EMPTY_LS;
const res = validateBinding(ls, loadBank(bankPath));
if (asJson) {
console.log(JSON.stringify({ article: id, ...res }, null, 2));
} else {
const c = res.coverage;
console.log(`Binding for article ${id}: ${res.verdict}`);
console.log(
` slots ${c.total} — levd ${c.specific} · abstrakt ${c.abstrakt} · ekstern ${c.ekstern} · uavklart ${c.unresolved}`,
);
for (const p of res.problems) console.log(`${p.slotId}: ${p.reason}${p.detail}`);
for (const w of res.warnings) console.log(`${w.slotId}: ${w.detail}`);
}
process.exit(res.verdict === "BLOCK" ? 1 : 0);
}
if (command === "render-kilder") {
if (!flags.edition || flags.edition === "true") usage("render-kilder needs --edition <path>");
const edition = loadEdition(flags.edition);
const { id, article } = resolveArticle(edition, flags.article);
const md = renderKilder({
articleId: id,
title: article.title ?? id,
livedSpecifics: article.livedSpecifics ?? EMPTY_LS,
bank: loadBank(bankPath),
});
if (flags.out && flags.out !== "true") {
writeFileSync(flags.out, md, "utf8");
console.log(`Wrote ${flags.out}`);
} else {
process.stdout.write(md);
}
return;
}
usage(command ? `unknown command: ${command}` : "no command given");
}

View file

@ -0,0 +1,93 @@
/**
* Render the per-edition `NN-kilder.md` artifact (Fix #2, slice 2).
*
* A read-only, deterministically-rendered sources ledger: for each load-bearing
* slot, what backs the claim. The single source of truth is the edition's
* `livedSpecifics` slot-map + the specifics-bank `NN-kilder.md` is regenerated
* from them (like POST.html), never hand-edited. The coverage block and the
* regel-6/7 number list are computed by `validateBinding`, so the artifact and
* the skeleton-gate can never disagree.
*
* Pure + dateless on purpose (golden-testable). The CLI writes the string to
* `<serie>/linkedin/NN-kilder.md`.
*/
import type { Bank, Specific } from "./types.js";
import type { LivedSpecifics, Slot } from "./binding.js";
import { validateBinding } from "./binding.js";
export interface RenderKilderInput {
articleId: string;
title: string;
livedSpecifics: LivedSpecifics;
bank: Bank;
}
function verifMarker(spec: Specific): string {
if (spec.verification === "verified") return " · verifisert ✓";
if (spec.verification === "unverified") return " · uverifisert ⚠";
return ""; // n/a — no marker
}
function bindingLines(slot: Slot, byId: Map<string, Specific>): string[] {
const b = slot.binding;
switch (b.type) {
case "specific": {
const spec = byId.get(b.specificId);
if (!spec) {
return [`- **Binding:** ⛔ levd materiale — id \`${b.specificId}\` mangler i banken (dangling)`];
}
return [
`- **Binding:** levd materiale (${spec.type}) · \`${spec.id}\`${verifMarker(spec)}`,
`- **Materiale:** «${spec.content}»`,
`- **Opphav:** ${spec.provenance.source}, fanget ${spec.provenance.capturedAt}`,
`- **Tema-tagger:** ${spec.topicTags.join(", ")}`,
];
}
case "abstrakt":
return b.rationale.trim().length === 0
? ["- **Binding:** ⛔ abstrakt uten begrunnelse (avvist — vaghet)"]
: [`- **Binding:** abstrakt (med vilje) — ${b.rationale}`];
case "ekstern":
return [`- **Binding:** ekstern kilde — ${b.source ?? "(fylles av research, Step 2)"}`];
case "unresolved":
return ["- **Binding:** ⛔ uavklart — må bindes før skjelett-gaten (vaghet avvises)"];
}
}
export function renderKilder(input: RenderKilderInput): string {
const { articleId, title, livedSpecifics, bank } = input;
const byId = new Map<string, Specific>(bank.specifics.map((s) => [s.id, s]));
const { coverage, warnings } = validateBinding(livedSpecifics, bank);
const out: string[] = [];
out.push(`# Kilder — ${title} (${articleId})`, "");
out.push(
"> Auto-generert fra edition-state `livedSpecifics` + specifics-bank. Per lastbærende påstand: hva backer den.",
"> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7).",
"",
);
out.push("## Lastbærende påstander", "");
if (livedSpecifics.slots.length === 0) {
out.push("_Ingen lastbærende slots bundet ennå._", "");
} else {
for (const slot of livedSpecifics.slots) {
out.push(`### ${slot.slotId} · ${slot.label}`);
out.push(...bindingLines(slot, byId));
out.push("");
}
}
out.push("## Dekning");
out.push(`- Lastbærende slots: ${coverage.total}`);
out.push(`- Levd materiale: ${coverage.specific}`);
out.push(`- Abstrakt (escape): ${coverage.abstrakt}`);
out.push(`- Ekstern: ${coverage.ekstern}`);
out.push(`- Uavklart (BLOCK): ${coverage.unresolved}`);
out.push("");
const numSlots = warnings.filter((w) => w.reason === "unverified-number").map((w) => w.slotId);
out.push(`Tall som må verifiseres (regel 6/7): ${numSlots.length > 0 ? numSlots.join(", ") : "ingen"}`);
return out.join("\n") + "\n";
}

View file

@ -0,0 +1,151 @@
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);
});
});

View file

@ -0,0 +1,130 @@
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { renderKilder } from "../src/kilder.js";
import type { LivedSpecifics, Slot } from "../src/binding.js";
import type { Bank, Specific } from "../src/types.js";
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 ls = (slots: Slot[]): LivedSpecifics => ({ slots, status: "pending" });
describe("renderKilder — the NN-kilder.md artifact", () => {
test("golden render of a mixed edition (specific-number-unverified + abstrakt + ekstern + unresolved)", () => {
const bank = mkBank([
mkSpecific({
id: "num1",
type: "number",
verification: "unverified",
content: "Sparte to dager i uka.",
topicTags: ["saksbehandling", "tid"],
provenance: { capturedAt: "2026-06-19", source: "seres/05" },
}),
]);
const livedSpecifics = ls([
{
slotId: "kp1",
kind: "key-point",
label: "Saksbehandling ble raskere",
binding: { type: "specific", specificId: "num1" },
},
{
slotId: "kp2",
kind: "key-point",
label: "Verktøyet bærer faget",
binding: { type: "abstrakt", rationale: "Poenget er prinsippet, ikke et enkelttilfelle." },
},
{
slotId: "kp3",
kind: "key-point",
label: "LinkedIn ranker på relevans",
binding: { type: "ekstern" },
},
{
slotId: "kp4",
kind: "key-point",
label: "Noe uavklart",
binding: { type: "unresolved" },
},
]);
const expected = `# Kilder — Maskinrommet (05)
> Auto-generert fra edition-state \`livedSpecifics\` + specifics-bank. Per lastbærende påstand: hva backer den.
> Levd materiale er KTGs egne ord, aldri AI-oppdiktet (retning §3). Tall er uverifiserte til fakta-sjekket (regel 6/7).
## Lastbærende påstander
### kp1 · Saksbehandling ble raskere
- **Binding:** levd materiale (number) · \`num1\` · uverifisert ⚠
- **Materiale:** «Sparte to dager i uka.»
- **Opphav:** seres/05, fanget 2026-06-19
- **Tema-tagger:** saksbehandling, tid
### kp2 · Verktøyet bærer faget
- **Binding:** abstrakt (med vilje) Poenget er prinsippet, ikke et enkelttilfelle.
### kp3 · LinkedIn ranker relevans
- **Binding:** ekstern kilde (fylles av research, Step 2)
### kp4 · Noe uavklart
- **Binding:** uavklart bindes før skjelett-gaten (vaghet avvises)
## Dekning
- Lastbærende slots: 4
- Levd materiale: 1
- Abstrakt (escape): 1
- Ekstern: 1
- Uavklart (BLOCK): 1
Tall som verifiseres (regel 6/7): kp1
`;
assert.equal(renderKilder({ articleId: "05", title: "Maskinrommet", livedSpecifics, bank }), expected);
});
test("a verified named-case renders ✓ and no number-verification line", () => {
const bank = mkBank([
mkSpecific({ id: "c1", type: "named-case", verification: "verified", content: "Etat X." }),
]);
const out = renderKilder({
articleId: "01",
title: "T",
livedSpecifics: ls([
{ slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "c1" } },
]),
bank,
});
assert.match(out, /levd materiale \(named-case\) · `c1` · verifisert ✓/);
assert.match(out, /Tall som må verifiseres \(regel 6\/7\): ingen/);
});
test("a dangling specific id renders an explicit dangling note", () => {
const out = renderKilder({
articleId: "01",
title: "T",
livedSpecifics: ls([
{ slotId: "kp1", kind: "key-point", label: "L", binding: { type: "specific", specificId: "ghost" } },
]),
bank: mkBank([]),
});
assert.match(out, /⛔ levd materiale — id `ghost` mangler i banken \(dangling\)/);
});
test("an empty slot-map renders the 'ingen slots' placeholder, not an empty section", () => {
const out = renderKilder({ articleId: "01", title: "T", livedSpecifics: ls([]), bank: mkBank([]) });
assert.match(out, /_Ingen lastbærende slots bundet ennå\._/);
assert.match(out, /- Lastbærende slots: 0/);
});
});