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>
156 lines
5.2 KiB
TypeScript
156 lines
5.2 KiB
TypeScript
/**
|
|
* Per-edition lived-specifics BINDING (Fix #2, slice 2 — kilde-så-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,
|
|
};
|
|
}
|