Upstreams eneste kanoniske eksempel med verdi (okf/SPEC.md:773) skriver
`okf_version: "0.2"` -- sitert. parseExistingIndex fanget anfoerselstegnene
raatt, UPSTREAM_VERSION_RE avviste `"0.2"` som ikke-upstream-form, og
resolveMarkers behandlet den som en layout-verdi fra foer 1.8.1-splitten:
inn: okf_version: "0.2"
ut: okf_version: 0.1 <- nedgradert til vaar konstant
okf_layout: "0.2" <- ekte upstream-versjon i feil markoer
Begge markoerene oedelagt, og dataene ikke gjenopprettelige uten aa kjenne
originalen. Bugen er i released 1.8.1-kode og traff enhver bundle som hadde
skrevet markoeren slik upstream selv viser den.
Fiks: anfoerselstegnene er YAML-strengsyntaks, ikke del av verdien, saa de
strippes ved parse -- foer verdien tolkes og foer den emitteres. Verdien
emitteres normalisert (unquoted), slik at vaar egen utskrift bestaar en
form-sjekk som kjoeres paa raa streng. Unquote er konservativ: kun et
matchende par strippes, en halv sekvens bevares uroert.
Tester: 187 -> 192. Fire nye kjoert roede foer fiksen; den femte
(ubalansert-vakten) mutasjons-verifisert roed mot en graadig unquote.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmXFhc6v9cs4YZ5AWFnQs8
279 lines
13 KiB
JavaScript
279 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// okf-index.mjs
|
|
// Genererer OKF-kompatible `index.md` per nivaa i en bundle-rot (prosjekt
|
|
// `.claude/okr/` eller home `~/.claude/okr/org/`). Verbatim OKF-«Documents/kb
|
|
// Layout»-index-form:
|
|
// # Overskrift
|
|
//
|
|
// okf_version: <upstream> (KUN rot-index -- OKF-versjonen bundelen sikter mot)
|
|
// okf_layout: <revisjon> (KUN rot-index -- vaar egen layout-revisjon, valgfri)
|
|
//
|
|
// * [title](relativ.md) - description
|
|
// Ingen frontmatter paa index.md (OKF-reservert). Konsept-filers `title`/
|
|
// `description` leses via lib/frontmatter.mjs. Underkataloger faar en peker til
|
|
// sin egen index.md. Kjoeres PER ROT (de to bundlene har ulik livssyklus).
|
|
//
|
|
// Idempotens / vedlikehold (NFR): en eksisterende index.md sin `# overskrift`,
|
|
// rotens markoer-verdier, og menneske-skrevne beskrivelser for underkatalog-
|
|
// pekere bevares; konsept-entries regenereres alltid fra frontmatter (autoritativ
|
|
// kilde). Skriving er atomisk (temp + renameSync), jf. write-org-profile.mjs.
|
|
//
|
|
// Zero npm dependencies (node:-builtins).
|
|
|
|
import { readdirSync, readFileSync, writeFileSync, existsSync, renameSync } from 'node:fs';
|
|
import { join, basename, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
|
|
|
// To distinkte markoerer (OKF-spec §12) -- ett felt skal ikke baere to urelaterte
|
|
// konsepter. `okf_version` = upstream Google OKF-versjonen bundelen sikter mot
|
|
// (verdisett eid av Google, enkeltverdi; paakrevd per §3). `okf_layout` = VAAR
|
|
// egen layout-revisjon (valgfri; verdisett eid av emitteren, trigger ingen
|
|
// kryss-plugin-rekjekk). Foer 1.8.1 baar `okf_version` layout-verdien alene --
|
|
// se resolveMarkers() for migrasjonsstien.
|
|
export const OKF_VERSION = '0.1';
|
|
export const OKF_LAYOUT = 'kb-layout-2026-06';
|
|
|
|
// Upstream-versjoner er numerisk punktnotasjon (`0.1`). Alt annet i et
|
|
// `okf_version`-felt er en layout-verdi fra foer splitten. Testes ALLTID mot en
|
|
// unquotet verdi -- se unquote().
|
|
const UPSTREAM_VERSION_RE = /^\d+(?:\.\d+)+$/;
|
|
|
|
// En markoerverdi kan vaere sitert: upstreams eget kanoniske eksempel
|
|
// (`okf/SPEC.md:773`) skriver `okf_version: "0.2"`. Anfoerselstegnene er
|
|
// YAML-strengsyntaks, ikke del av verdien, saa de maa vekk FOER verdien tolkes
|
|
// (UPSTREAM_VERSION_RE) og foer den emitteres -- ellers leses en gyldig
|
|
// upstream-versjon som en layout-verdi og migreres til feil markoer.
|
|
// KUN et matchende par strippes: en halv anfoerselstegn-sekvens er en ugyldig
|
|
// verdi som skal bevares uroert, ikke gjettes paa.
|
|
function unquote(s) {
|
|
const q = s[0];
|
|
if ((q === '"' || q === "'") && s.length >= 2 && s.endsWith(q)) return s.slice(1, -1);
|
|
return s;
|
|
}
|
|
|
|
// Drop-zone (raa innboks-filer) + skjulte kataloger (.cache osv.) er ikke nivaaer:
|
|
// de skal verken faa egen index.md eller listes som underkatalog-peker. Maa
|
|
// filtreres i BEGGE enumererings-steder (subdir-listing + rekursjon).
|
|
function isWalkableDir(name) {
|
|
return !name.startsWith('.') && name !== 'innboks';
|
|
}
|
|
|
|
// "strategisk-kontekst" -> "Strategisk kontekst"
|
|
function titleFromName(name) {
|
|
const spaced = name.replace(/[-_]+/g, ' ').trim();
|
|
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
}
|
|
|
|
// Parse en eksisterende index.md for bevaring: overskrift, begge rot-markoerene,
|
|
// 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: {}, linkOrder: [],
|
|
};
|
|
if (!existsSync(path)) return result;
|
|
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
if (result.heading === null && line.startsWith('# ')) {
|
|
result.heading = line.slice(2).trim();
|
|
}
|
|
const ver = line.match(/^okf_version:\s*(.+)$/);
|
|
if (ver) result.okfVersion = unquote(ver[1].trim());
|
|
const lay = line.match(/^okf_layout:\s*(.+)$/);
|
|
if (lay) result.okfLayout = unquote(lay[1].trim());
|
|
const entry = line.match(/^\*\s*\[([^\]]*)\]\(([^)]+)\)(?:\s*-\s*(.*))?$/);
|
|
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;
|
|
}
|
|
|
|
// Avgjoer de to rot-markoerene fra en eksisterende index + en evt. eksplisitt
|
|
// layout. MIGRASJONSSTI (1.8.1): baerer `okf_version` en ikke-upstream verdi og
|
|
// `okf_layout` mangler, FLYTTES den funne verdien til `okf_layout` (verbatim --
|
|
// ikke erstattet av vaar konstant) og `okf_version` settes til upstream-verdien.
|
|
// En allerede spec-konform `okf_version` (0.1, 0.2, ...) roeres aldri, og en
|
|
// eksisterende `okf_layout` bevares (idempotent vedlikehold).
|
|
function resolveMarkers(existing, explicitLayout) {
|
|
let version = existing.okfVersion;
|
|
let layout = existing.okfLayout;
|
|
if (layout === null && version !== null && !UPSTREAM_VERSION_RE.test(version)) {
|
|
layout = version;
|
|
version = null;
|
|
}
|
|
return {
|
|
version: version || OKF_VERSION,
|
|
layout: explicitLayout || layout || OKF_LAYOUT,
|
|
};
|
|
}
|
|
|
|
// Saner en frontmatter-avledet tittel/beskrivelse for trygg, idempotent emit:
|
|
// noytraliser markdown-lenker (RAG-injeksjon), strip kontrolltegn + strooe ]/)
|
|
// som ville korrumpert round-trip-parsen (parseExistingIndex), kollaps whitespace,
|
|
// og cap lengden. Idempotent: sanitizeEntry(sanitizeEntry(x)) === sanitizeEntry(x).
|
|
function sanitizeEntry(s) {
|
|
if (!s) return '';
|
|
return String(s)
|
|
.replace(/[\x00-\x1f\x7f\u0080-\u009f]/g, ' ') // C0 + C1 kontrolltegn -> mellomrom
|
|
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // noytraliser markdown-lenker (behold tekst)
|
|
// B2: fjern usynlige styringstegn -- zero-width (ZWSP/ZWNJ/ZWJ/LRM/RLM),
|
|
// bidi-embedding/-override/-isolater (spoofing av leseretning), BOM, og
|
|
// Unicode tag-blokken (usynlig smugle-kanal for instruksjonstekst).
|
|
.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/gu, '')
|
|
.replace(/[\])]/g, '') // strip strooe ] ) som brekker round-trip
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 200);
|
|
}
|
|
|
|
// Bygg en enkelt entry-linje paa OKF-form. Tittel/beskrivelse saneres (link
|
|
// forblir uroert -- kontrollert filnavn). Tom beskrivelse -> dropp ` - d`.
|
|
function entryLine(title, link, desc) {
|
|
const t = sanitizeEntry(title);
|
|
const d = sanitizeEntry(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)));
|
|
// Urangert sorteres sist. Sentinelen er et ENDELIG tall, ikke Infinity: to
|
|
// urangerte ville gitt Infinity - Infinity = NaN, som bare "virker" fordi NaN
|
|
// er falsy og faller gjennom til den alfabetiske sammenligneren. Det er en
|
|
// korrekt-ved-uhell-konstruksjon, og neste leser skal ikke behoeve aa se den.
|
|
const UNRANKED = Number.MAX_SAFE_INTEGER;
|
|
const rankOf = (n) => {
|
|
const r = callerRank.get(resolve(dir, n));
|
|
return r === undefined ? UNRANKED : 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). 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 = 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)));
|
|
|
|
const lines = [`# ${heading}`, ''];
|
|
if (isRoot) {
|
|
const { version, layout } = resolveMarkers(existing, explicitLayout);
|
|
lines.push(`okf_version: ${version}`, `okf_layout: ${layout}`, '');
|
|
}
|
|
|
|
for (const sd of subdirs) {
|
|
const link = `${sd}/index.md`;
|
|
const prev = existing.descByLink[link];
|
|
lines.push(entryLine(prev?.title || titleFromName(sd), link, prev?.desc || 'Underkatalog.'));
|
|
}
|
|
for (const c of concepts) {
|
|
const { get } = parseFrontmatter(readFileSync(join(dir, c), 'utf8'));
|
|
const title = get('title') || titleFromName(basename(c, '.md'));
|
|
lines.push(entryLine(title, c, get('description') || ''));
|
|
}
|
|
|
|
const content = `${lines.join('\n')}\n`;
|
|
const tmp = join(dir, `.index.md.${process.pid}.tmp`);
|
|
writeFileSync(tmp, content);
|
|
renameSync(tmp, join(dir, 'index.md'));
|
|
}
|
|
|
|
// 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, callerRank);
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false);
|
|
}
|
|
};
|
|
if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`);
|
|
walk(root, true);
|
|
}
|
|
|
|
// --- CLI ---
|
|
const isMain = process.argv[1]
|
|
&& fileURLToPath(import.meta.url) === process.argv[1];
|
|
if (isMain) {
|
|
// B2: flagg-tolerant parsing -- flagget godtas foer ELLER etter rot-argumentet,
|
|
// og manglende flagg-verdi er en bruksfeil (exit 2), ikke krasj.
|
|
// 1.8.1: `--okf-layout` er kanon; `--okf-version` beholdes som deprecated alias
|
|
// (verdien var alltid en layout-revisjon) og varsler paa stderr -- ALDRI stdout,
|
|
// som er scriptets maskinlesbare flate.
|
|
const usage = () => {
|
|
process.stderr.write('Bruk: node okf-index.mjs <bundle-rot> [--okf-layout <ver>]\n');
|
|
process.exit(2);
|
|
};
|
|
const args = process.argv.slice(2);
|
|
let okfLayout;
|
|
for (const flag of ['--okf-layout', '--okf-version']) {
|
|
const i = args.indexOf(flag);
|
|
if (i === -1) continue;
|
|
const val = args[i + 1];
|
|
if (!val || val.startsWith('--')) usage();
|
|
if (flag === '--okf-version') {
|
|
process.stderr.write(
|
|
'Advarsel: --okf-version er utgaatt og setter layout-revisjonen. Bruk --okf-layout.\n',
|
|
);
|
|
}
|
|
if (okfLayout === undefined) okfLayout = val;
|
|
args.splice(i, 2);
|
|
}
|
|
const root = args[0];
|
|
if (!root) usage();
|
|
generateIndexes(root, { okfLayout });
|
|
process.stdout.write(`OKF-index generert for ${root}\n`);
|
|
}
|