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:
parent
6b61d63196
commit
3f4e89fe9b
4 changed files with 207 additions and 18 deletions
|
|
@ -193,12 +193,18 @@ export async function ingestInbox(inboxDir, bundleRoot, { onNotice = (msg) => pr
|
|||
writeConcepts(withRelations, { bundleRoot: resolvedBundle, claimed });
|
||||
}
|
||||
|
||||
// Fase 4: indekser det overlevende settet (rot + alle nivaaer).
|
||||
generateIndexes(resolvedBundle);
|
||||
// `survivors` staar i den rekkefoelgen dokumentene ble ekstrahert, og
|
||||
// `s.written` i den rekkefoelgen konseptene ble skrevet pr. dokument -- dette
|
||||
// ER manifestets ekstraksjonsrekkefoelge (akse B, ingest-spec.md:178-181).
|
||||
const allWritten = survivors.flatMap((s) => s.written);
|
||||
|
||||
// Fase 4: indekser det overlevende settet (rot + alle nivaaer). `order` er
|
||||
// kun rangering: medlemskapet leses fortsatt fra disk (akse A), saa en fil som
|
||||
// ble discardet av gaten kan ikke snike seg inn i indeksen via denne lista.
|
||||
generateIndexes(resolvedBundle, { order: allWritten });
|
||||
|
||||
// Fase 5 (belte+seler): alle overlevende skrevne filer maa passere scoped
|
||||
// strict. Feil her er et internt invariant-brudd, ikke en dokument-feil.
|
||||
const allWritten = survivors.flatMap((s) => s.written);
|
||||
if (allWritten.length > 0) {
|
||||
const final = checkBundle(resolvedBundle, { strictIngest: true, files: allWritten });
|
||||
const finalErrors = [...final.missingType, ...final.strictErrors];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,3 +244,32 @@ test('ingest: binaer-original (.docx/.pdf) passerer strict -- pekerfil uten lenk
|
|||
assert.deepEqual(manifest(bundleRoot), m1, 'binaer-ingest idempotent');
|
||||
});
|
||||
});
|
||||
|
||||
// AKSE B ende-til-ende (ingest-spec.md:178-181 + §6). Primitivet er dekket i
|
||||
// okf-check.test.mjs; DENNE testen beviser at ingest faktisk TRER sin
|
||||
// ekstraksjonsrekkefoelge gjennom til indeksen -- uten den ville fiksen vaert
|
||||
// et ubrukt API. Diskriminatoren er at dokumentrekkefoelgen (Zulu foer Alfa)
|
||||
// er den MOTSATTE av den alfabetiske, saa en regresjon til .sort() er synlig.
|
||||
test('akse B ende-til-ende: index foelger ekstraksjonsrekkefoelge, ikke alfabetisk', async () => {
|
||||
await withBundle(async ({ bundleRoot, inbox }) => {
|
||||
writeFileSync(
|
||||
join(inbox, 'notat.txt'),
|
||||
'# Notat 2026\n\nIntro.\n\n## Zulu tema\n\nZulu-tekst.\n\n## Alfa tema\n\nAlfa-tekst.\n',
|
||||
);
|
||||
await ingestInbox(inbox, bundleRoot);
|
||||
|
||||
const links = readFileSync(join(bundleRoot, 'dokumenter', 'index.md'), 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.match(/^\*\s*\[[^\]]*\]\(([^)]+)\)/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
|
||||
assert.deepEqual(
|
||||
links,
|
||||
['notat-2026.md', 'zulu-tema.md', 'alfa-tema.md', 'notat.kilde.md'],
|
||||
'index-lenker skal staa i dokumentets ekstraksjonsrekkefoelge',
|
||||
);
|
||||
const alphabetical = [...links].sort();
|
||||
assert.notDeepEqual(links, alphabetical, 'testen er kun gyldig hvis den skiller seg fra alfabetisk');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -310,6 +310,111 @@ test('okf-index: tittel/beskrivelse med ]/) + lenke saneres -> 2x byte-identisk
|
|||
}
|
||||
});
|
||||
|
||||
// --- Akse B: generering/lenkerekkefoelge (ingest-spec.md:178-181 + §6) ---
|
||||
//
|
||||
// B sier at rekkefoelgen paa genererte lenker er kallerens ekstraksjons-
|
||||
// rekkefoelge, "never filesystem enumeration order". Ruling-dokumentet
|
||||
// (portfolio-optimiser-commons docs/plan/2026-07-25-ordering-axes-ruling.md §4)
|
||||
// navngir alfabetisk sortering eksplisitt som deterministisk FEIL paa denne
|
||||
// aksen: B ber ikke om *en* deterministisk rekkefoelge, den ber om *kallerens*.
|
||||
// §5 i samme dokument: den som arver B, arver §6-idempotensen med den.
|
||||
|
||||
// Hjelper: lenkene i en index.md, i linjerekkefoelge.
|
||||
function entryLinks(path) {
|
||||
return readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.map((l) => l.match(/^\*\s*\[[^\]]*\]\(([^)]+)\)/))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1]);
|
||||
}
|
||||
|
||||
test('akse B: eksisterende index-rekkefoelge bevares (ikke re-sortert alfabetisk)', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const n of ['a', 'b', 'c']) {
|
||||
writeFileSync(join(dir, `${n}.md`), `---\ntype: OKR\ntitle: ${n.toUpperCase()}\n---\n# ${n}\n`);
|
||||
}
|
||||
// Eksisterende indeks staar i IKKE-alfabetisk rekkefoelge: c, a, b.
|
||||
writeFileSync(
|
||||
join(dir, 'index.md'),
|
||||
'# Bundle\n\nokf_version: 0.1\nokf_layout: kb-layout-2026-06\n\n'
|
||||
+ '* [C](c.md) - tredje\n* [A](a.md) - foerste\n* [B](b.md) - andre\n',
|
||||
);
|
||||
generateIndexes(dir);
|
||||
assert.deepEqual(
|
||||
entryLinks(join(dir, 'index.md')),
|
||||
['c.md', 'a.md', 'b.md'],
|
||||
'entries som allerede staar i indeksen skal beholde sin linjerekkefoelge',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('akse B: nye entries appendes i KALLERENS rekkefoelge, ikke alfabetisk', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const n of ['a', 'm', 'z']) {
|
||||
writeFileSync(join(dir, `${n}.md`), `---\ntype: OKR\ntitle: ${n.toUpperCase()}\n---\n# ${n}\n`);
|
||||
}
|
||||
writeFileSync(
|
||||
join(dir, 'index.md'),
|
||||
'# Bundle\n\nokf_version: 0.1\nokf_layout: kb-layout-2026-06\n\n* [A](a.md) - finnes\n',
|
||||
);
|
||||
// Kaller ekstraherte z FOER m. Alfabetisk ville gitt m, z.
|
||||
generateIndexes(dir, { order: ['z.md', 'm.md'] });
|
||||
assert.deepEqual(
|
||||
entryLinks(join(dir, 'index.md')),
|
||||
['a.md', 'z.md', 'm.md'],
|
||||
'nye entries skal appendes i kallerens ekstraksjonsrekkefoelge',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('akse B + §6: lenking er idempotent by target (ingen duplikat paa andre kjoering)', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const n of ['a', 'z']) {
|
||||
writeFileSync(join(dir, `${n}.md`), `---\ntype: OKR\ntitle: ${n.toUpperCase()}\n---\n# ${n}\n`);
|
||||
}
|
||||
generateIndexes(dir, { order: ['z.md', 'a.md'] });
|
||||
const run1 = readFileSync(join(dir, 'index.md'), 'utf8');
|
||||
generateIndexes(dir, { order: ['z.md', 'a.md'] });
|
||||
const run2 = readFileSync(join(dir, 'index.md'), 'utf8');
|
||||
assert.equal(run1, run2, 'andre kjoering skal vaere byte-identisk');
|
||||
const links = entryLinks(join(dir, 'index.md'));
|
||||
assert.deepEqual(links, ['z.md', 'a.md'], 'kallerens rekkefoelge skal gjelde ogsaa ved genesis');
|
||||
assert.equal(new Set(links).size, links.length, 'ingen lenke skal forekomme to ganger');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('akse B: fjernet fil mister sin lenke, gjenvaerende beholder rekkefoelge', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
for (const n of ['a', 'b', 'c']) {
|
||||
writeFileSync(join(dir, `${n}.md`), `---\ntype: OKR\ntitle: ${n.toUpperCase()}\n---\n# ${n}\n`);
|
||||
}
|
||||
generateIndexes(dir, { order: ['c.md', 'a.md', 'b.md'] });
|
||||
rmSync(join(dir, 'a.md'));
|
||||
generateIndexes(dir);
|
||||
assert.deepEqual(
|
||||
entryLinks(join(dir, 'index.md')),
|
||||
['c.md', 'b.md'],
|
||||
'lenke til fjernet fil skal bort; resten beholder innbyrdes rekkefoelge',
|
||||
);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('okf-index: innboks/ + dot-katalog -> ingen egen index.md OG ikke listet i rot-index', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue