feat(okr): deterministisk frontmatter-projeksjon + vokab-snap + mtime-timestamp (SC idempotens) [skip-docs]

This commit is contained in:
Kjell Tore Guttormsen 2026-06-30 12:58:26 +02:00
commit d011fb1922
2 changed files with 199 additions and 0 deletions

View file

@ -0,0 +1,75 @@
// innboks-frontmatter.mjs
// Step 5: deterministisk frontmatter-projeksjon for et konsept (fra splitConcepts).
// Regel-basert (INGEN LLM): type/tags utledes fra title+sti og snappes mot det
// lukkede vokabularet (okf-vocab); resource = original relativ sti (kanonisk navn);
// description = foerste ikke-tomme avsnitt (whitespace kollapset, trunkert);
// timestamp = ISO-8601 av original-fil-mtime (IKKE veggklokke -> idempotens by
// construction per maskin); kilde:innboks provenans-markoer (extension key for
// retrieval-rangering). Beregner ogsaa konseptets mal-sti
// (concept.destRel = routeLevel(type)/slug.md) FOER relasjons-steget (Step 6),
// jf. Pass-2 ordering-fiks (Revisions #21). Serialiserer via writeFrontmatter
// (additiv array-gren fra Step 3). Zero npm dependencies.
import { writeFrontmatter } from './frontmatter.mjs';
import { snapType, snapTags, routeLevel, TYPE_VOCAB, TAGS_VOCAB } from './okf-vocab.mjs';
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.
function deriveType(title, sourcePath) {
const hay = `${title} ${sourcePath}`.toLowerCase();
for (const t of TYPE_BY_LENGTH) {
if (hay.includes(t.toLowerCase())) return snapType(t);
}
return snapType('');
}
// Tags: vokab-termer som forekommer i title (vokab-rekkefolge bevart), saa snappet.
function deriveTags(title) {
const hay = String(title).toLowerCase();
return snapTags(TAGS_VOCAB.filter((t) => hay.includes(t.toLowerCase())));
}
// Foerste ikke-tomme avsnitt, whitespace kollapset, trunkert deterministisk.
function deriveDescription(body) {
const para = String(body)
.split(/\n\s*\n/)
.map((p) => p.replace(/\s+/g, ' ').trim())
.find((p) => p !== '');
if (!para) return '';
return para.length > DESCRIPTION_MAX ? `${para.slice(0, DESCRIPTION_MAX).trimEnd()}...` : para;
}
// concept (fra splitConcepts) -> beriket konsept med OKF-frontmatter + destRel.
export function projectFrontmatter(concept, { sourcePath, sourceMtime } = {}) {
const resource = String(sourcePath ?? '');
const type = deriveType(concept.title, resource);
const description = deriveDescription(concept.body);
const tags = deriveTags(concept.title);
const timestamp = new Date(sourceMtime).toISOString();
const destRel = `${routeLevel(type)}/${concept.slug}.md`;
// Kanonisk noekkel-rekkefolge; description/tags utelates naar tomme.
const fields = { type, resource, title: concept.title };
if (description) fields.description = description;
if (tags.length > 0) fields.tags = tags;
fields.timestamp = timestamp;
fields.kilde = 'innboks';
const frontmatter = writeFrontmatter(fields);
return {
...concept,
type,
resource,
description,
tags,
timestamp,
kilde: 'innboks',
destRel,
frontmatter,
};
}

View file

@ -0,0 +1,124 @@
// innboks-frontmatter.test.mjs
// Step 5 (SC idempotens): deterministisk frontmatter-projeksjon. projectFrontmatter
// utleder type/tags regel-basert (INGEN LLM) og snapper mot lukket vokab; resource =
// original relativ sti; description = foerste ikke-tomme avsnitt; timestamp = ISO av
// original-mtime (IKKE veggklokke); kilde:innboks; og fester concept.destRel =
// routeLevel(type)/slug.md FOER relasjons-steget. Asserterer paa parseFrontmatter().
// get() (aldri substring for verdier). Moenster: tests/oppsett-okf-write.test.mjs:93.
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 { parseFrontmatter } from '../lib/frontmatter.mjs';
import { TYPE_VOCAB, routeLevel } from '../lib/okf-vocab.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 dokA = readFileSync(join(FIX, 'dok-a.md'), 'utf8');
const MTIME = new Date('2026-03-15T08:30:00.000Z');
const OPTS = { sourcePath: 'innboks/dok-a.txt', sourceMtime: MTIME };
// Frontmatter-noekler i rekkefolge (kun linjestart-noekler; list-elementer hoppes over).
function fmKeyOrder(fm) {
const { raw } = parseFrontmatter(fm);
return (raw || '')
.split('\n')
.map((l) => {
const m = l.match(/^([a-z_]+):/);
return m ? m[1] : null;
})
.filter(Boolean);
}
test('projectFrontmatter: type snappes til lukket vokab; keyword i title -> kanonisk type', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
const { get } = parseFrontmatter(e.frontmatter);
assert.ok(TYPE_VOCAB.includes(get('type')), 'type i lukket vokab');
assert.equal(get('type'), 'Tildelingsbrev', 'keyword "Tildelingsbrev" i title -> kanonisk type');
});
test('projectFrontmatter: ukjent emne -> safe default Dokument (negativ vokab-sjekk)', () => {
const concept = {
sourceSlug: 'x',
title: 'Helt annerledes emne',
slug: 'helt-annerledes-emne',
level: 1,
body: 'Innhold uten vokab-noekkelord.',
};
const e = projectFrontmatter(concept, { sourcePath: 'innboks/x.txt', sourceMtime: MTIME });
assert.equal(parseFrontmatter(e.frontmatter).get('type'), 'Dokument');
assert.doesNotMatch(
e.frontmatter,
/^type: (Tildelingsbrev|Virksomhetsplan|OKR)$/m,
'ikke feil-snappet til en foeringstype',
);
});
test('projectFrontmatter: resource = original relativ sti (kanonisk navn, ikke "source")', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
const { get } = parseFrontmatter(e.frontmatter);
assert.equal(get('resource'), 'innboks/dok-a.txt');
assert.equal(get('source'), null, 'noekkelen heter resource, ikke source');
});
test('projectFrontmatter: description deterministisk (2 kjoeringer byte-identiske, foerste avsnitt)', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e1 = projectFrontmatter(c1, OPTS);
const e2 = projectFrontmatter(c1, OPTS);
assert.equal(e1.frontmatter, e2.frontmatter, 'byte-identisk projeksjon');
assert.match(parseFrontmatter(e1.frontmatter).get('description'), /Tildelingsbrevet gir overordnede/);
});
test('projectFrontmatter: lang body -> description trunkeres deterministisk', () => {
const longBody = 'A'.repeat(500);
const concept = { sourceSlug: 'lang', title: 'Lang', slug: 'lang', level: 1, body: longBody };
const e = projectFrontmatter(concept, { sourcePath: 'innboks/lang.txt', sourceMtime: MTIME });
const desc = parseFrontmatter(e.frontmatter).get('description');
assert.ok(desc.length <= 244, 'trunkert til <= 240 + ellipsis');
assert.match(desc, /\.\.\.$/, 'ellipsis-markoer ved trunkering');
});
test('projectFrontmatter: timestamp = ISO av sourceMtime (idempotent paa tvers av veggklokke)', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
assert.equal(parseFrontmatter(e.frontmatter).get('timestamp'), MTIME.toISOString());
});
test('projectFrontmatter: kilde:innboks provenans-markoer alltid satt', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
assert.equal(parseFrontmatter(e.frontmatter).get('kilde'), 'innboks');
assert.equal(e.kilde, 'innboks', 'kilde ogsaa paa konsept-objektet');
});
test('projectFrontmatter: concept.destRel satt + konsistent med routeLevel(type)', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
assert.ok(e.destRel, 'destRel satt FOER relasjons-steget');
assert.equal(e.destRel, `${routeLevel(e.type)}/${c1.slug}.md`);
assert.equal(e.destRel, 'strategisk-kontekst/tildelingsbrev-2026.md', 'Tildelingsbrev -> strategisk-kontekst');
});
test('projectFrontmatter: kanonisk noekkel-rekkefolge (type/resource/title/desc/tags/timestamp/kilde)', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
const order = fmKeyOrder(e.frontmatter);
const canon = ['type', 'resource', 'title', 'description', 'tags', 'timestamp', 'kilde'];
assert.deepEqual(order, canon.filter((k) => order.includes(k)), 'present keys i kanonisk rekkefolge');
assert.equal(order[0], 'type', 'type foerst');
assert.equal(order[order.length - 1], 'kilde', 'kilde sist');
});
test('projectFrontmatter: tags er multi-linje liste (writeFrontmatter array-gren) + i vokab', () => {
const [c1] = splitConcepts(dokA, { sourceSlug: 'dok-a' });
const e = projectFrontmatter(c1, OPTS);
assert.match(e.frontmatter, /^tags:$/m, 'multi-linje tags-noekkel');
assert.match(e.frontmatter, /^ {2}- Tildelingsbrev$/m, 'tag-element i vokab');
});