From 2b0c520efd4d2d0cdbbfdcdb1886e827bc9968ba Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 30 Jun 2026 13:01:19 +0200 Subject: [PATCH] feat(okr): delt bundle-root-relativ lenke-resolver + zero-dangling relasjoner (SC relasjoner) [skip-docs] --- lib/innboks-relations.mjs | 49 ++++++++++++++++++ lib/okf-links.mjs | 39 +++++++++++++++ tests/innboks-relations.test.mjs | 86 ++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 lib/innboks-relations.mjs create mode 100644 lib/okf-links.mjs create mode 100644 tests/innboks-relations.test.mjs diff --git a/lib/innboks-relations.mjs b/lib/innboks-relations.mjs new file mode 100644 index 0000000..92d86fc --- /dev/null +++ b/lib/innboks-relations.mjs @@ -0,0 +1,49 @@ +// innboks-relations.mjs +// Step 6: deterministisk relasjons-resolusjon innen KJOERINGENS konsept-sett +// (lukket lenke-vokabular). For hvert konsept oppdages referanser til ANDRE +// emitterte konsepter (exact-title substring, case-insensitivt) og emitteres som +// leading-'/' bundle-root-lenker bygd paa malet-konseptets destRel (satt i Step 5, +// kjent FOER skriv -- Pass-2 ordering-fiks). Asserterer zero-dangling mot de +// emitterte destRel-stiene og at hver generert lenke er trygg (delt okf-links- +// allow-list). v1-narrowing: kun innen-kjoering-settet -- lenking til pre- +// eksisterende tre-konsepter er v1.1. Zero npm dependencies. + +import { isSafeBundleLink } from './okf-links.mjs'; + +const REL_HEADING = '## Relaterte dokumenter'; + +// concepts (beriket av projectFrontmatter, m/ destRel) -> samme konsepter m/ +// relations[] + body utvidet med en deterministisk relasjons-seksjon. +export function resolveRelations(concepts) { + const emitted = new Set(concepts.map((c) => c.destRel)); + + return concepts.map((concept) => { + const haystack = `${concept.title}\n${concept.body}`.toLowerCase(); + const found = new Map(); // target -> { title, target, destRel } + + for (const other of concepts) { + if (other === concept) continue; + if (!other.title || !other.destRel) continue; + if (!haystack.includes(other.title.toLowerCase())) continue; + + const target = `/${other.destRel}`; + if (!isSafeBundleLink(target)) { + throw new Error(`innboks-relations: utrygg generert lenke ${target}`); + } + if (!emitted.has(other.destRel)) { + throw new Error(`innboks-relations: dangling relasjon ${other.destRel}`); + } + found.set(target, { title: other.title, target, destRel: other.destRel }); + } + + const relations = [...found.values()].sort((a, b) => a.target.localeCompare(b.target)); + + let body = concept.body; + if (relations.length > 0) { + const links = relations.map((r) => `- [${r.title}](${r.target})`).join('\n'); + body = `${concept.body}\n\n${REL_HEADING}\n\n${links}\n`; + } + + return { ...concept, relations, body }; + }); +} diff --git a/lib/okf-links.mjs b/lib/okf-links.mjs new file mode 100644 index 0000000..5c45c94 --- /dev/null +++ b/lib/okf-links.mjs @@ -0,0 +1,39 @@ +// okf-links.mjs +// Delt bundle-root-relativ lenke-resolver (Step 6), brukt av BAADE relasjons- +// emitteringen (innboks-relations) og okf-check++ --strict-ingest (Step 8). +// Konvensjon: en trygg bundle-lenke er ROOT-RELATIV med leading '/' (resolveres +// mot bundle-rota, jf. SC-regex \]\(/.*\.md\)). Avviser '../'-escape, absolutt +// Windows-sti (backslash), og scheme-lenker (file://, http(s)://). Den ENESTE +// lenke-allow-listen -- delt slik at emit og validering aldri divergerer. +// Zero npm dependencies. + +import path from 'node:path'; + +// Scheme-prefiks (file://, http://, https://, ...) -- avvises uansett. +const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i; +// '..'-segment hvor som helst i stien (escape ut av bundle-rota). +const PARENT_SEGMENT_RE = /(^|\/)\.\.(\/|$)/; + +// Sann hvis target er en trygg, bundle-root-relativ .md-lenke (leading '/'). +export function isSafeBundleLink(target) { + if (typeof target !== 'string' || target === '') return false; + if (target.includes('\0')) return false; // null-byte + if (target.includes('\\')) return false; // backslash (Windows-sti) + if (SCHEME_RE.test(target)) return false; // file://, http(s)://, ... + if (!target.startsWith('/')) return false; // maa vaere bundle-root-relativ + if (PARENT_SEGMENT_RE.test(target)) return false; // ingen '..'-escape + if (!target.endsWith('.md')) return false; // bundle-konseptlenker er .md + return true; +} + +// Resolverer en trygg bundle-lenke til absolutt sti UNDER bundleRoot, ellers null. +// Belt-and-suspenders: isSafeBundleLink avviser '..' allerede, men confinement +// re-sjekkes paa den resolverte stien. +export function resolveBundleLink(target, bundleRoot) { + if (!isSafeBundleLink(target)) return null; + const rel = target.replace(/^\/+/, ''); + const root = path.resolve(bundleRoot); + const resolved = path.resolve(root, rel); + if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; + return resolved; +} diff --git a/tests/innboks-relations.test.mjs b/tests/innboks-relations.test.mjs new file mode 100644 index 0000000..95b0e4e --- /dev/null +++ b/tests/innboks-relations.test.mjs @@ -0,0 +1,86 @@ +// innboks-relations.test.mjs +// Step 6 (SC relasjoner): delt bundle-root-relativ lenke-resolver (okf-links) + +// deterministisk relasjons-resolusjon (innboks-relations). Beviser: >=1 emittert +// lenke matcher SC-regex /\]\(\/.*\.md\)/ (leading '/'); hver lenke resolverer til +// et emittert konsept (zero-dangling); '../'/absolutt/file://-/http-lenker avvises +// av isSafeBundleLink (allow-list). Bygger det emitterte settet gjennom Step 4+5. +// Moenster: tests/okf-retrieval.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isSafeBundleLink, resolveBundleLink } from '../lib/okf-links.mjs'; +import { resolveRelations } from '../lib/innboks-relations.mjs'; +import { splitConcepts } from '../lib/innboks-split.mjs'; +import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIX = join(HERE, 'fixtures', 'inbox-converted'); +const MTIME = new Date('2026-03-15T08:30:00.000Z'); +const SC_REGEX = /\]\(\/.*\.md\)/; + +// Det emitterte konsept-settet (split + project) for begge fixture-dokumentene. +function buildConcepts() { + const out = []; + for (const slug of ['dok-a', 'dok-b']) { + const md = readFileSync(join(FIX, `${slug}.md`), 'utf8'); + for (const c of splitConcepts(md, { sourceSlug: slug })) { + out.push(projectFrontmatter(c, { sourcePath: `innboks/${slug}.txt`, sourceMtime: MTIME })); + } + } + return out; +} + +test('resolveRelations: >=1 emittert lenke matcher SC-regex (leading-/ bundle-link)', () => { + const related = resolveRelations(buildConcepts()); + const targets = related.flatMap((c) => c.relations.map((r) => r.target)); + assert.ok(targets.length >= 1, 'minst en relasjon emitteres fra fixturene'); + assert.ok(targets.every((t) => SC_REGEX.test(`](${t})`)), 'hver lenke matcher SC-regex'); + // Samme lenke maa finnes i body-en som faktisk skrives + grepes av SC4. + const bodies = related.map((c) => c.body).join('\n'); + assert.match(bodies, SC_REGEX, 'relasjons-lenke finnes i konsept-body'); +}); + +test('resolveRelations: hver lenke resolverer til et emittert konsept (zero-dangling)', () => { + const concepts = buildConcepts(); + const emitted = new Set(concepts.map((c) => c.destRel)); + const related = resolveRelations(concepts); + for (const c of related) { + for (const r of c.relations) { + assert.ok(emitted.has(r.destRel), `lenke-mal ${r.destRel} er et emittert konsept`); + assert.equal(r.target, `/${r.destRel}`, 'leading-/ bundle-root-lenke'); + assert.equal(isSafeBundleLink(r.target), true, 'emittert lenke er trygg'); + } + } +}); + +test('resolveRelations: deterministisk (2 kjoeringer byte-like body + relations)', () => { + const pick = (arr) => arr.map((c) => ({ slug: c.slug, body: c.body, relations: c.relations })); + assert.deepEqual(pick(resolveRelations(buildConcepts())), pick(resolveRelations(buildConcepts()))); +}); + +test('isSafeBundleLink: aksepterer leading-/ .md bundle-lenke', () => { + assert.equal(isSafeBundleLink('/strategisk-kontekst/virksomhetsplan-2026.md'), true); + assert.equal(isSafeBundleLink('/dokumenter/notat.md'), true); +}); + +test('isSafeBundleLink: avviser escape/scheme/relativ/ikke-md/backslash', () => { + assert.equal(isSafeBundleLink('../escape.md'), false, 'relativ ..'); + assert.equal(isSafeBundleLink('/foo/../../etc/passwd.md'), false, '.. segment'); + assert.equal(isSafeBundleLink('file:///etc/passwd.md'), false, 'file:// scheme'); + assert.equal(isSafeBundleLink('http://evil.example/x.md'), false, 'http scheme'); + assert.equal(isSafeBundleLink('relativ/sti.md'), false, 'ingen leading /'); + assert.equal(isSafeBundleLink('/strategisk-kontekst/uten-ext'), false, 'ikke .md'); + assert.equal(isSafeBundleLink('C:\\win\\path.md'), false, 'backslash'); + assert.equal(isSafeBundleLink(''), false, 'tom streng'); + assert.equal(isSafeBundleLink(null), false, 'ikke-streng'); +}); + +test('resolveBundleLink: trygg lenke -> sti under bundleRoot; escape/scheme -> null', () => { + const root = '/tmp/bundle'; + assert.equal(resolveBundleLink('/dokumenter/notat.md', root), join(root, 'dokumenter/notat.md')); + assert.equal(resolveBundleLink('../escape.md', root), null); + assert.equal(resolveBundleLink('http://x/y.md', root), null); +});