fix(okr): fase D akse-B — index-rekkefølge fra kaller, ikke fra disk

Vår alfabetiske `.sort()` i okf-index.mjs lå på akse B (ingest-spec.md:178-181),
som krever manifestets ekstraksjonsrekkefølge fra kalleren og eksplisitt forbyr
«filesystem enumeration order». Ruling-dokumentet (portfolio-optimiser-commons
docs/plan/2026-07-25-ordering-axes-ruling.md §4 @ a67a243) navngir alfabetisk
sortering som deterministisk FEIL på denne aksen: B ber ikke om *en*
deterministisk rekkefølge, den ber om *kallerens*.

Kontrakt (operatør-valgt), implementert i orderEntries():
1. navn som alt står i indeksen  -> beholder linjerekkefølgen (§6 «preserved
   byte for byte»)
2. nye navn med kaller-rang      -> kallerens ekstraksjonsrekkefølge
3. nye navn uten kaller-rang     -> alfabetisk

(3) er en dokumentert genesis-fallback, ikke en B-etterlevelse: CLI-en tar kun
en katalog og har ingen kaller-liste å tre gjennom, og alternativet — rå
readdirSync-rekkefølge — er nettopp det B forbyr. Fallbacken gjelder bare ved
genesis; så snart en indeks finnes vinner (1), så et alfabetisk valg overstyrer
aldri en rekkefølge en kaller har etablert.

§5 i rulingen: den som arver B arver §6-idempotensen med den. `order` er derfor
KUN rangering — medlemskapet leses fortsatt fra disk (akse A, :175-177), så en
sti i lista kan aldri opprette eller gjenopplive en fil, og et gate-discardet
dokument kan ikke snike seg inn i indeksen. parseExistingIndex bærer nå
linkOrder eksplisitt framfor å hvile på JS-objekters innsettingsrekkefølge.

innboks-ingest.mjs trer sin faktiske ekstraksjonsrekkefølge gjennom; uten det
ville fiksen vært et ubrukt API.

Tester: 182 -> 187. Alle fem nye verifisert røde mot mutant (orderEntries ->
`[...names].sort()`), de 39 øvrige grønne under samme mutasjon. Ende-til-ende-
testen diskriminerer ved at dokumentrekkefølgen (Zulu før Alfa) er den motsatte
av den alfabetiske.

Ingen versjonsbump; release avventer operatør-go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EaQkAtvpNCLxWkjdEJ11vx
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 15:50:56 +02:00
commit 3f4e89fe9b
4 changed files with 207 additions and 18 deletions

View file

@ -21,7 +21,7 @@
// Zero npm dependencies (node:-builtins).
import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
import { join, basename } from 'node:path';
import { join, basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseFrontmatter } from '../lib/frontmatter.mjs';
@ -52,10 +52,13 @@ function titleFromName(name) {
}
// Parse en eksisterende index.md for bevaring: overskrift, begge rot-markoerene,
// og beskrivelser pr. lenke (for underkatalog-pekere). Kaster aldri.
// beskrivelser pr. lenke (for underkatalog-pekere), og lenkenes LINJEREKKEFOELGE.
// `linkOrder` er eksplisitt og ikke utledet av descByLink-noekkelrekkefoelgen:
// akse B gjoer rekkefoelgen til en observerbar egenskap, og da skal den ikke
// hvile paa JS-objekters innsettingsrekkefoelge. Kaster aldri.
function parseExistingIndex(path) {
const result = {
heading: null, okfVersion: null, okfLayout: null, descByLink: {},
heading: null, okfVersion: null, okfLayout: null, descByLink: {}, linkOrder: [],
};
if (!existsSync(path)) return result;
for (const line of readFileSync(path, 'utf8').split('\n')) {
@ -67,7 +70,10 @@ function parseExistingIndex(path) {
const lay = line.match(/^okf_layout:\s*(.+)$/);
if (lay) result.okfLayout = lay[1].trim();
const entry = line.match(/^\*\s*\[([^\]]*)\]\(([^)]+)\)(?:\s*-\s*(.*))?$/);
if (entry) result.descByLink[entry[2]] = { title: entry[1], desc: (entry[3] || '').trim() };
if (entry) {
if (!(entry[2] in result.descByLink)) result.linkOrder.push(entry[2]);
result.descByLink[entry[2]] = { title: entry[1], desc: (entry[3] || '').trim() };
}
}
return result;
}
@ -118,21 +124,57 @@ function entryLine(title, link, desc) {
return d ? `* [${t}](${link}) - ${d}` : `* [${t}](${link})`;
}
// AKSE B (ingest-spec.md:178-181 + §6): rekkefoelgen paa genererte index-lenker
// er kallerens ekstraksjonsrekkefoelge -- "never filesystem enumeration order".
// MEDLEMSKAPET (hvilke navn som er med) kommer fortsatt fra disk; det er akse A
// (`:175-177`) og skal IKKE ta imot en kaller-liste. Denne funksjonen ordner kun
// et sett som allerede er lest fra disk:
// 1. navn som alt staar i indeksen -> beholder sin linjerekkefoelge (§6:
// "every line it does not itself manage is preserved byte for byte");
// 2. nye navn med kaller-rang -> kallerens ekstraksjonsrekkefoelge;
// 3. nye navn UTEN kaller-rang -> alfabetisk.
// (3) er en dokumentert genesis-fallback, ikke en B-etterlevelse: naar det ikke
// finnes noen kaller (CLI-en tar kun en katalog) finnes det ingen ekstraksjons-
// rekkefoelge aa tre gjennom, og alternativet -- raa readdirSync-rekkefoelge --
// er nettopp det B forbyr. Fallbacken gjelder bare ved genesis: saa snart en
// indeks finnes, vinner (1), saa et alfabetisk valg overstyrer aldri en
// rekkefoelge en kaller har etablert.
function orderEntries(names, linkOf, existing, callerRank, dir) {
const pos = new Map(existing.linkOrder.map((l, i) => [l, i]));
const known = names.filter((n) => pos.has(linkOf(n)));
const fresh = names.filter((n) => !pos.has(linkOf(n)));
known.sort((a, b) => pos.get(linkOf(a)) - pos.get(linkOf(b)));
const rankOf = (n) => {
const r = callerRank.get(resolve(dir, n));
return r === undefined ? Infinity : r;
};
fresh.sort((a, b) => (rankOf(a) - rankOf(b)) || (a < b ? -1 : a > b ? 1 : 0));
return [...known, ...fresh];
}
// Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer de to
// markoer-linjene. explicitLayout (B2): en eksplisitt oppgitt layout-revisjon
// VINNER over eksisterende rot-verdi (bump-mekanisme); ellers bevares eksisterende
// (idempotent vedlikehold).
function writeIndexFor(dir, isRoot, explicitLayout) {
// (idempotent vedlikehold). callerRank: absolutt sti -> ekstraksjonsrang (akse B).
function writeIndexFor(dir, isRoot, explicitLayout, callerRank) {
const existing = parseExistingIndex(join(dir, 'index.md'));
const dirents = readdirSync(dir, { withFileTypes: true });
const subdirs = dirents
.filter((e) => e.isDirectory() && isWalkableDir(e.name))
.map((e) => e.name)
.sort();
const concepts = dirents
.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md')
.map((e) => e.name)
.sort();
const subdirs = orderEntries(
dirents.filter((e) => e.isDirectory() && isWalkableDir(e.name)).map((e) => e.name),
(n) => `${n}/index.md`,
existing,
callerRank,
dir,
);
const concepts = orderEntries(
dirents
.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md')
.map((e) => e.name),
(n) => n,
existing,
callerRank,
dir,
);
const heading = existing.heading
|| (isRoot ? 'OKF second brain' : titleFromName(basename(dir)));
@ -163,11 +205,18 @@ function writeIndexFor(dir, isRoot, explicitLayout) {
// Generer index.md for rot + alle underkataloger, rekursivt.
// opts.okfVersion er et DEPRECATED alias for opts.okfLayout: verdien kallere har
// sendt inn her var alltid en layout-revisjon, ogsaa foer splitten (§12).
// opts.order (akse B): kallerens ekstraksjonsrekkefoelge som stier -- absolutte,
// eller relative til `root`. Kun RANGERING; den kan aldri utvide eller innskrenke
// medlemskapet (akse A leser det fra disk), saa en sti som ikke finnes paa disk
// er en no-op, ikke en oppretting.
export function generateIndexes(root, opts = {}) {
const raw = opts.okfLayout ?? opts.okfVersion;
const explicitLayout = typeof raw === 'string' && raw !== '' ? raw : undefined;
const callerRank = new Map(
(Array.isArray(opts.order) ? opts.order : []).map((p, i) => [resolve(root, p), i]),
);
const walk = (dir, isRoot) => {
writeIndexFor(dir, isRoot, explicitLayout);
writeIndexFor(dir, isRoot, explicitLayout, callerRank);
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false);
}