feat(okr): delt bundle-root-relativ lenke-resolver + zero-dangling relasjoner (SC relasjoner) [skip-docs]
This commit is contained in:
parent
d011fb1922
commit
2b0c520efd
3 changed files with 174 additions and 0 deletions
49
lib/innboks-relations.mjs
Normal file
49
lib/innboks-relations.mjs
Normal file
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
39
lib/okf-links.mjs
Normal file
39
lib/okf-links.mjs
Normal file
|
|
@ -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;
|
||||
}
|
||||
86
tests/innboks-relations.test.mjs
Normal file
86
tests/innboks-relations.test.mjs
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue