fix(okr): gate-herding B2 scoped strict + B3 alle lenkeformer + M2 realpath-confinement + M3 deriveType basename/ord-grense

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:12:35 +02:00
commit 7f9c790f43
6 changed files with 222 additions and 18 deletions

View file

@ -10,6 +10,8 @@
// jf. Pass-2 ordering-fiks (Revisions #21). Serialiserer via writeFrontmatter
// (additiv array-gren fra Step 3). Zero npm dependencies.
import path from 'node:path';
import { writeFrontmatter } from './frontmatter.mjs';
import { snapType, snapTags, routeLevel, TYPE_VOCAB, TAGS_VOCAB } from './okf-vocab.mjs';
@ -18,11 +20,21 @@ const DESCRIPTION_MAX = 240;
// Vokab sortert lengst-foerst saa "Overordnede OKR" matcher foer "OKR".
const TYPE_BY_LENGTH = [...TYPE_VOCAB].sort((a, b) => b.length - a.length);
// Regel-utledning: foerste vokab-term som forekommer i title+sti -> kanonisk type.
// M3 (A1): vokab-term matcher kun som HELT ord (ikke substring -- kompound som
// «Statusnotat» skal ikke snappe til Status). Ord-grense = ikke-bokstav/-siffer
// paa begge sider (\b haandterer ikke ae/oe/aa -- derfor \p{L}\p{N}-lookaround).
function termMatches(hay, term) {
const esc = term.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?<![\\p{L}\\p{N}])${esc}(?![\\p{L}\\p{N}])`, 'u').test(hay);
}
// Regel-utledning: foerste vokab-term (lengst foerst) som forekommer som helt
// ord i title + BASENAME av kilden -> kanonisk type. Full sti deltar ALDRI
// (M3: et /okr/-katalogsegment skal ikke forgifte routeLevel-rutingen).
function deriveType(title, sourcePath) {
const hay = `${title} ${sourcePath}`.toLowerCase();
const hay = `${title} ${path.basename(String(sourcePath))}`.toLowerCase();
for (const t of TYPE_BY_LENGTH) {
if (hay.includes(t.toLowerCase())) return snapType(t);
if (termMatches(hay, t)) return snapType(t);
}
return snapType('');
}

View file

@ -21,7 +21,7 @@
// Reuses: atomisk-skriv-moenster (scripts/write-org-profile.mjs:34-40);
// writeFrontmatter (lib/frontmatter.mjs) for peker-frontmatter. Zero npm deps.
import { writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
import { writeFileSync, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs';
import path from 'node:path';
import { homedir } from 'node:os';
@ -52,6 +52,26 @@ function resolveUnderBundle(resolvedBundle, rel) {
return resolved;
}
// M2 (A1): den leksikalske sjekken over slipper symlinks -- en symlinket
// katalog/original INNE i bundlen kan peke UT av den. realpathSync paa den
// faktiske noden (destinasjons-parent etter mkdir / original foer peker-skriv)
// maa ogsaa lande under bundle-rotas realpath, ellers avvises skrivet.
function assertRealUnderBundle(realBundle, p, what) {
const real = realpathSync(p);
if (real !== realBundle && !real.startsWith(realBundle + path.sep)) {
throw new Error(`innboks-write: ${what} resolverer utenfor bundle-rot (symlink-escape avvist): ${p}`);
}
return real;
}
// Destinasjons-parent opprettes, realpath-sjekkes, DERETTER skrives det atomisk.
function writeConfined(realBundle, target, data, what) {
const dir = path.dirname(target);
mkdirSync(dir, { recursive: true });
assertRealUnderBundle(realBundle, dir, what);
writeAtomic(target, data);
}
// Skrevet fil = frontmatter + body verbatim, med garantert trailing newline.
function fileContent(concept) {
const out = `${concept.frontmatter}${concept.body}`;
@ -86,13 +106,16 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) {
throw new Error(`innboks-write: skriv til home-org-rot avvist: ${bundleRoot}`);
}
// Bundle-rotas realpath er ankeret for alle M2-symlink-sjekker under.
const realBundle = realpathSync(resolvedBundle);
const writtenConcepts = [];
for (const concept of concepts) {
if (!concept.destRel) {
throw new Error(`innboks-write: konsept mangler destRel: ${concept.slug ?? '?'}`);
}
const target = resolveUnderBundle(resolvedBundle, concept.destRel);
writeAtomic(target, fileContent(concept));
writeConfined(realBundle, target, fileContent(concept), `destinasjons-katalog for ${concept.destRel}`);
writtenConcepts.push(target);
}
@ -107,6 +130,9 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) {
if (relToBundle === '' || relToBundle.startsWith('..') || path.isAbsolute(relToBundle)) {
throw new Error(`innboks-write: original utenfor bundle-rot: ${original.path}`);
}
// M2: en symlink-original i drop-zonen passerer den leksikalske sjekken
// over, men realpathen kan peke UT av bundlen -> avvis.
assertRealUnderBundle(realBundle, resolvedOriginal, `original ${original.path}`);
const link = `/${relToBundle.split(path.sep).join('/')}`;
// Nivaa = nivaaet til foerste konsept fra samme kilde; default dokumenter/.
@ -114,7 +140,7 @@ export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) {
const level = sibling ? path.dirname(sibling.destRel) : 'dokumenter';
const pointerRel = path.join(level, `${original.sourceSlug}.kilde.md`);
const pointerTarget = resolveUnderBundle(resolvedBundle, pointerRel);
writeAtomic(pointerTarget, pointerContent(original, link));
writeConfined(realBundle, pointerTarget, pointerContent(original, link), `peker-katalog for ${pointerRel}`);
writtenPointers.push(pointerTarget);
}