linkedin-studio/scripts/brain/tests/id.test.ts
Kjell Tore Guttormsen bc47c1842a fix(linkedin-studio): N24.6 — brain-restposter (slugify-translitterering + okf_version til rot-frontmatter) [skip-docs]
To restposter fra N24.5s FUNN-liste, begge i scripts/brain, i én berøring.

1. slugify() var tapsgivende for alt annet enn engelsk: hver ikke-ASCII-bokstav
   falt i [^a-z0-9]-kjøringen og ble en bindestrek. «Løkkene» ble «l-kkene», og
   verre: ø og å ble BEGGE «-», så «møte» og «måte» kollapset til én id.
   Nå: NFD + strip av kombinerende tegn (å, é, ñ), så en eksplisitt tabell for de
   ikke-dekomponerbare (æ→ae, ø→o, œ→oe, ß→ss, đ/ð→d, þ→th, ł→l).
   REKKEFØLGEN: translitterering kjører FØR kollapsen, så kollapsen forblir siste
   gate. Presist om hva det beviser — utdata-whitelisten [a-z0-9-] bæres av
   kollapsen selv, ikke av rekkefølgen; dagens tabell produserer bare [a-z], så
   rekkefølgen ville ikke brutt whitelisten i dag. Den er en invariant for
   FREMTIDIGE tabelloppføringer, og den er nå pinnet av test. Det er whitelisten
   ingestion-guardens tall hviler på (0/81 %-escapes, 0/81 bilde-URL-er,
   rapportert til guard-eieren som strukturelt); repo-vid grep bekrefter at
   scripts/brain/src/id.ts er den ENESTE slugify-implementasjonen i repoet, så
   attribusjonen holder.

   ID-STABILITET (den åpne beslutningen, avgjort på bevis, ikke skjønn):
   endre in-place — ingen versjonering av slugifieren, ingen migrering.
   - Ingen mintede ider finnes på disk: $DATA/brain og $DATA/ingest inneholder
     bare tomme kataloger (opprettet 23.06, null filer — initBrain lager kataloger
     OG filer i ett kall, så brain-en er aldri blitt genuint initialisert her).
   - Alle 27 profile-field-labels den shippede malen minter er ASCII (målt ved å
     kjøre extractFields' faktiske regler mot malen) ⇒ endringen er et BEVIST
     no-op for profil-laget. Fem golden-ider pinner det.
   - observed-ider (consolidate) mintes fra brukerskrevne nøkler og ville endret
     seg — men ingenting er persistert, så migrasjonsflaten er null rader.
   Ærlig avgrensning: dette KRYMPER kollisjonsklassen, det lukker den ikke —
   «møte» og «mote» møtes fortsatt, som de må for at stabiliteten over case og
   whitespace skal holde. Og en label uten latinske tegn i det hele tatt
   («日本語») slugger fortsatt til tom streng, så to slike minter samme id —
   pre-eksisterende, ikke innført her, men det hører hjemme ved siden av
   «krymper, lukker ikke» framfor å stå uskrevet. Adopter-forbehold: repoet er offentlig, men brain init
   er ikke session-start-wiret (SB-S2 eier det), så en persistert brain krever en
   eksplisitt invokasjon.

2. okf_version: 0.1 lå som BRØDTEKST i rot-index.md. Kanonisk plassering er
   frontmatter-blokken (OKF-form spec §6, v0.3) — upstreams ene utskårne unntak
   fra «index-filer har ingen frontmatter», og unntaket er oppregnet til ÉN
   nøkkel, så ingenting annet blir med (okf_layout blir i brødtekst per §12).
   Verdien flytter, den bumpes ikke: vi blir på 0.1.

VERIFISERING
- TDD: 6 røde først, så grønt. Brain 134 → 142 (+8), floor 127 → 142.
- Mutasjonstestet mot de FAKTISKE kildefilene, ikke bare self-tester:
  okf_version tilbake til brødtekst → 3 røde · fjern translitterering → 1 rød ·
  flytt translitterering til ETTER kollapsen → 1 rød · restaurert → 142/0.
- De to okf-testene dette erstattet var VAKUØSE: /^okf_version:/m matcher en
  frontmatter-linje like gjerne som en brødtekstlinje, og frontmatterType() leser
  type:, så den ga null med eller uten blokk. Begge gikk grønt på begge
  plasseringer — nøyaktig gjeldsklassen N24.5 feide. Meldingen «index.md carries
  NO frontmatter» ble usann og er skrevet om.
- Delt gate (katalogen, read-only): node catalog/scripts/okf-check.mjs <bundle>
  → exit 0, «OK: valid OKF bundle», okf_version 0.1. Bevist at verdien leses FRA
  frontmatter: fjern blokken → «MISSING». check-okf-parity-signaturen er
  conceptCount|untyped|okfVersion|okfVersionAccepted — plassering inngår ikke, så
  det å migrere først splitter ingen paritet (okr skriver fortsatt brødtekst).
- Ti suiter grønne: test-runner 303/0 · trends 300 · analytics 202 · hooks 191 ·
  brain 142 · editions 72 · render 63 · specifics-bank 45 · tests 35 ·
  contract-gate 33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EgUSPs7vDejiHr7iZw8xUx
2026-08-01 20:05:54 +02:00

119 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { slugify, mintEntityId, normalizeProvenance } from "../src/id.js";
describe("id module (SC4)", () => {
describe("slugify", () => {
test("lowercases, trims, and dashes non-alphanumeric runs", () => {
assert.equal(slugify("Current Role"), "current-role");
});
test("collapses repeated separators and strips edge dashes", () => {
assert.equal(slugify(" Core Expertise Areas (5 topics) "), "core-expertise-areas-5-topics");
});
test("is stable across case + whitespace variation of the same label", () => {
assert.equal(slugify("Current Role"), slugify(" current ROLE "));
});
// N24.6 — transliteration. Before this, every non-ASCII letter fell into the
// `[^a-z0-9]+` run and became a dash, so a Norwegian label was mangled
// ("Løkkene" → "l-kkene") and distinct labels collapsed onto one slug.
test("transliterates the non-decomposable Nordic/German letters", () => {
assert.equal(slugify("Løkkene"), "lokkene");
assert.equal(slugify("Æresmedlem"), "aeresmedlem");
assert.equal(slugify("Straße"), "strasse");
});
test("strips combining diacritics rather than dashing them", () => {
assert.equal(slugify("Måte"), "mate");
assert.equal(slugify("Café résumé"), "cafe-resume");
assert.equal(slugify("Señor"), "senor");
});
test("keeps ø-labels and å-labels distinct (they both collapsed to `-` before)", () => {
assert.notEqual(slugify("Møte"), slugify("Måte"));
});
test("output stays whitelisted to [a-z0-9-] for any input", () => {
// Load-bearing beyond this repo: the ingestion-guard exposure numbers reported
// to the guard owner rest on this whitelist being STRUCTURAL (no %-escape and no
// image URL can survive a slug). Transliteration runs BEFORE the collapse so the
// collapse stays the final gate.
for (const raw of ["%2Fetc%2Fpasswd", "https://x.tld/a b.png?q=1#f", "Løkke%C3%B8", "日本語", "ab"]) {
assert.match(slugify(raw), /^[a-z0-9-]*$/, `slug of ${JSON.stringify(raw)} is whitelisted`);
}
});
});
describe("mintEntityId", () => {
test("is deterministic — same seed yields the same id", () => {
assert.equal(
mintEntityId({ kind: "profile-field", key: "Current Role" }),
mintEntityId({ kind: "profile-field", key: "Current Role" }),
);
});
test("is stable across label case/whitespace (id keyed on the slug)", () => {
assert.equal(
mintEntityId({ kind: "profile-field", key: "Current Role" }),
mintEntityId({ kind: "profile-field", key: " current ROLE " }),
);
});
test("different key yields a different id", () => {
assert.notEqual(
mintEntityId({ kind: "profile-field", key: "Current Role" }),
mintEntityId({ kind: "profile-field", key: "Organization" }),
);
});
test("different kind yields a different id (kind is part of the seed)", () => {
assert.notEqual(
mintEntityId({ kind: "profile-field", key: "role" }),
mintEntityId({ kind: "expertise-area", key: "role" }),
);
});
// N24.6 id-stability pin. The transliteration change re-mints any id whose key
// carries a non-ASCII letter. Every label the SHIPPED profile template mints is
// ASCII (27/27, measured), so for the profile layer the change is a provable
// no-op — these goldens were computed BEFORE the change and must not move.
test("ASCII keys keep their pre-transliteration ids (profile-field goldens)", () => {
const golden: Record<string, string> = {
Name: "4f321cef28b9",
"Current Role": "25c03976c855",
"expertise-area-1": "ad3cac5a825f",
"Topics to AVOID": "36803b03c55f",
"90-day growth goal": "e18da9c9e66a",
};
for (const [key, id] of Object.entries(golden)) {
assert.equal(mintEntityId({ kind: "profile-field", key }), id, `id for ${JSON.stringify(key)}`);
}
});
test("id is 12 lowercase hex characters", () => {
assert.match(mintEntityId({ kind: "profile-field", key: "Name" }), /^[0-9a-f]{12}$/);
});
});
describe("normalizeProvenance", () => {
test("returns each accepted value unchanged", () => {
assert.equal(normalizeProvenance("human"), "human");
assert.equal(normalizeProvenance("published"), "published");
assert.equal(normalizeProvenance("ai-draft"), "ai-draft");
});
test("trims and lowercases before matching", () => {
assert.equal(normalizeProvenance(" Human "), "human");
assert.equal(normalizeProvenance("AI-DRAFT"), "ai-draft");
});
test("throws on anything outside the provenance vocabulary", () => {
assert.throws(() => normalizeProvenance("robot"));
assert.throws(() => normalizeProvenance(""));
assert.throws(() => normalizeProvenance("humanoid"));
});
});
});