Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dec8a13952 | |||
| 3b45be70be | |||
| 935c1d0ae9 | |||
| 2b0c520efd | |||
| d011fb1922 | |||
| 136093da38 | |||
| ca6c89e7be | |||
| a9d9c45192 | |||
| abfcc6b48e | |||
| f0ef6c1abc | |||
| 75bfc9b47d |
24 changed files with 1327 additions and 25 deletions
|
|
@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **OKF second-brain spec v0.1 ratifisert (cross-plugin koordinering)** — den delte konvensjonen er kanonisert i `catalog/docs/okf-second-brain/spec.md` (katalog-eid, single source of truth). okr ratifiserer den per handoff §3/§4: `okf-check.mjs`-semantikken (kun `type` påkrevd; anbefalte felt → advarsler; `okf_version`-ekko) står som referansekontrakt (spec §7); okr bruker `resource` (ikke `source`); ingen feltgap (profil/config-nøkler bæres som extension keys, spec §5). Innboks-ingestion-planen (`docs/innboks-ingestion-veivalg-2026-06.md`) bygger nå mot spec-en; delings-scope for en eventuell Stage-3-skill = okr + ms-ai-architect (ikke linkedin-studio). Ingen kode- eller versjonsendring.
|
||||
|
||||
## [1.6.1] - 2026-06-26
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ The second skill (`okr-second-brain-search`) does on-demand retrieval over the u
|
|||
## State Management
|
||||
|
||||
User configuration: `.claude/okr.local.md` in the project directory (not in plugin root).
|
||||
Org profile (reinstall-surviving): `~/.claude/okr/org/profil.md` — the machine-global org identity (`organisasjon:`/`program:`), written atomically (temp + `renameSync`) by `scripts/write-org-profile.mjs` during `/okr:oppsett`. Resolution is most-specific-wins: a project-local `.claude/okr.local.md` overrides the home profile; the home profile is the backwards-compatible fallback when no project config exists (read side: `hooks/scripts/inject-okr-context.mjs`). On any home-write failure the helper circuit-breaks to the gitignored project-local `.claude/okr.local.md`. Only the org *profile* migrates to home — cycle/`historikk` data stays cwd-bound.
|
||||
Org profile (reinstall-surviving): `~/.claude/okr/org/profil.md` — machine-global org identity (`organisasjon:`/`program:`), written atomically (temp + `renameSync`) by `scripts/write-org-profile.mjs` during `/okr:oppsett`. Resolution is most-specific-wins: project-local `.claude/okr.local.md` overrides the home profile (the backwards-compatible fallback when no project config exists; read side `hooks/scripts/inject-okr-context.mjs`). Home-write failure → circuit-break to the gitignored project-local file. Only the org *profile* migrates to home; cycle/`historikk` data stays cwd-bound.
|
||||
Template: `templates/okr.local.md.template`
|
||||
Context tree: `.claude/okr/` — `strategisk-kontekst/`, `syklus/[id]/`, `historikk/`, `dokumenter/`
|
||||
Onboarding state: `onboarding_status` field in okr.local.md (`partial` | `fullfort`)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@
|
|||
> leses ved oppstart av ingestion-løpet. **Teknisk grunnlag (web-verifisert):**
|
||||
> `docs/innboks-ingestion-funn-2026-06.md`. Status-of-play: `STATE.md`.
|
||||
|
||||
> **RATIFISERT 2026-06-29 (handoff `catalog/docs/okf-second-brain/handoff-2026-06-29.md` §3).** Den
|
||||
> delte konvensjonen er nå kanonisert i `catalog/docs/okf-second-brain/spec.md` v0.1 (single source of
|
||||
> truth, katalog-eid). Dette dokumentet **refererer** spec-en — det redefinerer den ikke. Innboks-løpet
|
||||
> bygger mot spec §3 (minimal kontrakt = gulv, ikke tak) + §5 (extension keys). okr-bekreftelser (§4):
|
||||
> `okf-check.mjs`-semantikken står som referansekontrakt (spec §7); okr bruker `resource` (ikke
|
||||
> `source`); ingen feltgap (profil/config-nøkler = extension keys). Premiss-korreksjonene i §2 under er
|
||||
> nå kanonisert i spec §9.
|
||||
|
||||
## 1. Låste beslutninger (operatør, 2026-06-26)
|
||||
|
||||
1. **Innboks-ingestion SKAL bygges for okr.** Ikke valgfritt, ikke «defer». Krever **grundig
|
||||
|
|
@ -78,6 +86,11 @@ verifiserte tekniske fakta (beholdes) fra strategisk framing (avvises):
|
|||
|
||||
## 6. Forhold til søsken-pluginer
|
||||
|
||||
- **Delings-scope (operatør-låst 2026-06-29):** en eventuell delt Stage-3-skill deles mellom
|
||||
**okr + ms-ai-architect** — **IKKE linkedin-studio**. linkedin-studio har sin egen mekanisme
|
||||
(rikere ikke-OKF-brain, reference-design — «not levelled down to bare OKF», brief §1) og leveres
|
||||
ikke ned til en delt skill. Låsen endrer kun *hvem* som deler, ikke *når*: Stage 3 er fortsatt
|
||||
betinget på Stage-2-måling og utsatt.
|
||||
- `ms-ai-architect` har samme oppgave (designet, ikke bygget). **Vurder felles abstraksjon før
|
||||
dobbel-implementasjon** — men felles ≠ kopiere linkedin-studio. Eventuell deling skjer på okr-egne
|
||||
premisser.
|
||||
|
|
@ -86,7 +99,8 @@ verifiserte tekniske fakta (beholdes) fra strategisk framing (avvises):
|
|||
|
||||
## 7. Kilder
|
||||
|
||||
- **Delt konvensjon (single source of truth):** `catalog/docs/okf-second-brain/spec.md` v0.1 + koordinerings-`log.md` + `handoff-2026-06-29.md` (samme mappe, katalog-repo).
|
||||
- `docs/innboks-ingestion-funn-2026-06.md` — web-verifisert Google-repo-analyse (kilder i §9 der).
|
||||
- `linkedin-studio/docs/okf-convergence-brief.md` — cross-cutting convergence (avstemt over).
|
||||
- `linkedin-studio/docs/okf-convergence-brief.md` — cross-cutting convergence (avstemt over; nå foldet inn i spec-en).
|
||||
- OKF v0.1: `github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md`.
|
||||
- Leveranse vi bygger på: okr 1.6.0 (`scripts/okf-*`, `lib/frontmatter.mjs`, skill `okr-second-brain-search`).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
_Notert 2026-06-26. **Fremtidig initiativ — IKKE implementer før plan er laget og godkjent.** Dette notatet er grunnlagsmateriale for neste sesjons `/trekbrief` → `/trekplan`. Bygger på (a) verifisert lesning av OKF SPEC v0.1 og (b) det ferdige referansedesignet i søsken-pluginen `ms-ai-architect` (`docs/okf-second-brain-brief-2026-06.md`, operatør-bekreftet 2026-06-26). State-of-play i `STATE.md`._
|
||||
|
||||
> **Oppdatert 2026-06-29:** konvensjonen er nå kanonisert i `catalog/docs/okf-second-brain/spec.md` (v0.1, single source of truth, katalog-eid). Denne noten er **design-historikk/grunnlag** — referer spec-en for den normative kontrakten, ikke denne.
|
||||
|
||||
## Hva oppgaven er (operatør 2026-06-26)
|
||||
Adopter **Google Open Knowledge Format (OKF)** som formatet brukeren lagrer sin egen kontekst i — en bruker-eid «LLM-wiki» / «second brain» **utenfor** pluginen. Brukeren legger inn så mye org-/strategisk kontekst som ønskes; **deler av den injiseres/hentes smart** når okr-pluginen brukes — både i fri **chat** (plugin lastet) og når `/okr:*`-kommandoer kjøres. Speiler arbeidet som nå designes i `ms-ai-architect`.
|
||||
|
||||
|
|
|
|||
|
|
@ -41,14 +41,26 @@ export function parseFrontmatter(content) {
|
|||
return { raw, get };
|
||||
}
|
||||
|
||||
// Siter naar verdien inneholder '#'/':' eller har kant-whitespace, slik at
|
||||
// round-trip via parseFrontmatter bevarer den eksakt (jf. siter-#-regelen).
|
||||
const quoteIfNeeded = (s) =>
|
||||
/[#:]/.test(s) || /^\s|\s$/.test(s) || /^["']/.test(s) ? JSON.stringify(s) : s;
|
||||
|
||||
export function writeFrontmatter(fields) {
|
||||
const lines = ['---'];
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
const s = String(value);
|
||||
// Siter naar verdien inneholder '#'/':' eller har kant-whitespace, slik at
|
||||
// round-trip via parseFrontmatter bevarer den eksakt (jf. siter-#-regelen).
|
||||
const needsQuote = /[#:]/.test(s) || /^\s|\s$/.test(s) || /^["']/.test(s);
|
||||
lines.push(`${key}: ${needsQuote ? JSON.stringify(s) : s}`);
|
||||
// Additiv array-gren (Step 3): emit OKF multi-linje list-verdi (f.eks.
|
||||
// `tags`) som `key:\n - item`. Lese-siden er uendret -- parseFrontmatter.
|
||||
// get() returnerer fortsatt null for list-keys. Skalar-grenen under er
|
||||
// bevart bit-for-bit (kun loftet ut til quoteIfNeeded, samme regel).
|
||||
if (Array.isArray(value)) {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(` - ${quoteIfNeeded(String(item))}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
lines.push(`${key}: ${quoteIfNeeded(String(value))}`);
|
||||
}
|
||||
lines.push('---');
|
||||
return lines.join('\n') + '\n';
|
||||
|
|
|
|||
75
lib/innboks-frontmatter.mjs
Normal file
75
lib/innboks-frontmatter.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
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 };
|
||||
});
|
||||
}
|
||||
96
lib/innboks-split.mjs
Normal file
96
lib/innboks-split.mjs
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// innboks-split.mjs
|
||||
// Step 4: deterministisk heading-split av (allerede konvertert) markdown til
|
||||
// konsepter. REN funksjon -- fil-settet er en funksjon av input alene (INGEN
|
||||
// LLM, ingen veggklokke), saa run1 og run2 gir byte-identisk resultat (SC
|
||||
// idempotens by construction). Split paa #/## (MarkdownHeaderTextSplitter-
|
||||
// moenster): hver #/##-heading starter et nytt konsept; ### og dypere forblir
|
||||
// body-innhold. Dokument uten brukbare overskrifter -> fallback "ett dokument =
|
||||
// ett konsept". Innhold foer foerste heading bevares som ledende konsept (ingen
|
||||
// datatap). Zero npm dependencies.
|
||||
|
||||
// Kebab-slug: lowercase, strip diakritika defensivt, ikke-alfanum -> '-', trim.
|
||||
// Never-empty fallback 'konsept' (deterministisk; aldri tomt filnavn).
|
||||
function slugify(text) {
|
||||
const s = String(text)
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return s || 'konsept';
|
||||
}
|
||||
|
||||
// #/## heading-linje -> { level, title }. ### og dypere matcher IKKE: '#{1,2}'
|
||||
// kan ikke etterfoelges av '\s+' naar tredje tegn er '#', saa de forblir body.
|
||||
const HEADING_RE = /^(#{1,2})\s+(.+?)\s*$/;
|
||||
|
||||
// markdown -> Concept[]; opts.sourceSlug identifiserer kilde-dokumentet (brukt
|
||||
// som tittel/slug for flat-fallback + preamble). Concept-form:
|
||||
// { sourceSlug, title, slug, level, body }
|
||||
export function splitConcepts(markdown, { sourceSlug } = {}) {
|
||||
const src =
|
||||
typeof sourceSlug === 'string' && sourceSlug.trim() !== '' ? sourceSlug : 'konsept';
|
||||
const normalized = String(markdown).replace(/\r\n/g, '\n');
|
||||
const lines = normalized.split('\n');
|
||||
|
||||
// Segmenter: ett per #/##-heading. Linjer foer foerste heading -> preamble.
|
||||
const segments = [];
|
||||
const preambleLines = [];
|
||||
let current = null;
|
||||
for (const line of lines) {
|
||||
const m = line.match(HEADING_RE);
|
||||
if (m) {
|
||||
current = { title: m[2].trim(), level: m[1].length, lines: [] };
|
||||
segments.push(current);
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
} else {
|
||||
preambleLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
const concepts = [];
|
||||
const seen = new Map();
|
||||
const uniqueSlug = (base) => {
|
||||
const n = seen.get(base) || 0;
|
||||
seen.set(base, n + 1);
|
||||
return n === 0 ? base : `${base}-${n + 1}`;
|
||||
};
|
||||
|
||||
// Reelt innhold foer foerste heading -> ledende konsept (titulert av kilde).
|
||||
if (preambleLines.join('').trim() !== '') {
|
||||
concepts.push({
|
||||
sourceSlug: src,
|
||||
title: src,
|
||||
slug: uniqueSlug(slugify(src)),
|
||||
level: 1,
|
||||
body: preambleLines.join('\n').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
// Flat dokument (ingen #/##): hele dokumentet = ett konsept.
|
||||
if (segments.length === 0) {
|
||||
if (concepts.length === 0) {
|
||||
concepts.push({
|
||||
sourceSlug: src,
|
||||
title: src,
|
||||
slug: uniqueSlug(slugify(src)),
|
||||
level: 1,
|
||||
body: normalized.trim(),
|
||||
});
|
||||
}
|
||||
return concepts;
|
||||
}
|
||||
|
||||
for (const seg of segments) {
|
||||
concepts.push({
|
||||
sourceSlug: src,
|
||||
title: seg.title,
|
||||
slug: uniqueSlug(slugify(seg.title)),
|
||||
level: seg.level,
|
||||
body: seg.lines.join('\n').trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return concepts;
|
||||
}
|
||||
122
lib/innboks-write.mjs
Normal file
122
lib/innboks-write.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// innboks-write.mjs
|
||||
// Step 7: persister berikede konsepter (splitConcepts -> projectFrontmatter ->
|
||||
// resolveRelations) inn i prosjekt-bundlen, ikke-destruktivt og idempotent.
|
||||
//
|
||||
// (1) Hvert konsept skrives til sin FORHAANDSBEREGNEDE concept.destRel (rutet
|
||||
// via routeLevel i Step 5 -- skriveren ruter ikke selv). Skrevet fil =
|
||||
// concept.frontmatter (ferdig OKF-blokk) + concept.body (baerer allerede
|
||||
// relasjons-seksjonen) verbatim -- INGEN re-serialisering. Trailing newline
|
||||
// garanteres (POSIX-ren, deterministisk -> byte-idempotent re-kjoering).
|
||||
// (2) Atomisk skriv: temp i SAMME katalog + renameSync over maalet (en krasj
|
||||
// midt i skriv kan aldri etterlate en halv-skrevet fil).
|
||||
// (3) Original-bevaring (SC3, ikke-destruktiv): skriveren roerer ALDRI
|
||||
// drop-zone-originalene -- den asserterer kun at de finnes (saa pekeren
|
||||
// aldri dangler). Per original skrives EN markdown-peker i konseptets nivaa
|
||||
// som lenker bundle-root-relativt til originalen i .claude/okr/innboks/.
|
||||
// (4) Path-confinement (innboks-dok = fiendtlig, RAG-poisoning): hver maal-sti
|
||||
// maa resolvere UNDER bundle-rota (avvis '..'-escape / absolutt-override),
|
||||
// og bundle-rota selv maa ikke vaere den home-kanoniske org-profilen
|
||||
// (~/.claude/okr/org) -- ingestion skriver kun i prosjekt-bundlen.
|
||||
//
|
||||
// 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 path from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
import { writeFrontmatter } from './frontmatter.mjs';
|
||||
|
||||
// Den home-kanoniske org-profil-rota. Ingestion skal ALDRI skrive hit (den eies
|
||||
// av write-org-profile.mjs); prosjekt-bundlen er .claude/okr under cwd.
|
||||
const HOME_ORG = path.join(homedir(), '.claude', 'okr', 'org');
|
||||
|
||||
// Atomisk: temp-fil i samme katalog, deretter renameSync over maalet (atomisk
|
||||
// paa samme filsystem). Speiler write-org-profile.mjs:34-40.
|
||||
function writeAtomic(target, data) {
|
||||
const dir = path.dirname(target);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `${path.basename(target)}.${process.pid}.tmp`);
|
||||
writeFileSync(tmp, data);
|
||||
renameSync(tmp, target);
|
||||
}
|
||||
|
||||
// Resolver en bundle-relativ sti og asserter at den blir UNDER bundle-rota.
|
||||
// Avviser '..'-escape og absolutt-override (path.resolve lar en absolutt rel
|
||||
// vinne -- containment-sjekken fanger det).
|
||||
function resolveUnderBundle(resolvedBundle, rel) {
|
||||
const resolved = path.resolve(resolvedBundle, rel);
|
||||
if (resolved !== resolvedBundle && !resolved.startsWith(resolvedBundle + path.sep)) {
|
||||
throw new Error(`innboks-write: maal-sti utenfor bundle-rot avvist: ${rel}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Skrevet fil = frontmatter + body verbatim, med garantert trailing newline.
|
||||
function fileContent(concept) {
|
||||
const out = `${concept.frontmatter}${concept.body}`;
|
||||
return out.endsWith('\n') ? out : `${out}\n`;
|
||||
}
|
||||
|
||||
// Peker-fil: minimal gyldig OKF-fil (type i lukket vokab) som lenker til den
|
||||
// bevarte originalen. resource = bundle-relativ original-sti (uten leading '/').
|
||||
function pointerContent(original, link) {
|
||||
const basename = path.basename(original.path);
|
||||
const frontmatter = writeFrontmatter({
|
||||
type: 'Notat',
|
||||
resource: link.replace(/^\/+/, ''),
|
||||
title: `Kilde: ${basename}`,
|
||||
kilde: 'innboks',
|
||||
});
|
||||
const body = `Peker til bevart original i drop-zonen (ikke-destruktiv ingestion).\n\n- [${basename}](${link})\n`;
|
||||
return `${frontmatter}${body}`;
|
||||
}
|
||||
|
||||
// concepts (berikede, m/ destRel + frontmatter + body) + { bundleRoot, originals }
|
||||
// -> skriver konsept-filer + peker-filer atomisk under bundle-rota.
|
||||
// originals: [{ sourceSlug, path }] -- path = originalens plassering i drop-zonen.
|
||||
// Returnerer { concepts: [skrevne konsept-stier], pointers: [skrevne peker-stier] }
|
||||
// (absolutte stier; pipelinen (Step 10) bruker dette til discard-on-fail rollback).
|
||||
export function writeConcepts(concepts, { bundleRoot, originals = [] } = {}) {
|
||||
if (!bundleRoot) throw new Error('innboks-write: bundleRoot kreves');
|
||||
const resolvedBundle = path.resolve(bundleRoot);
|
||||
|
||||
// Confinement: avvis skriv til home-org-rota -- ingestion eier kun prosjekt-bundlen.
|
||||
if (resolvedBundle === HOME_ORG || resolvedBundle.startsWith(HOME_ORG + path.sep)) {
|
||||
throw new Error(`innboks-write: skriv til home-org-rot avvist: ${bundleRoot}`);
|
||||
}
|
||||
|
||||
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));
|
||||
writtenConcepts.push(target);
|
||||
}
|
||||
|
||||
const writtenPointers = [];
|
||||
for (const original of originals) {
|
||||
const resolvedOriginal = path.resolve(original.path);
|
||||
if (!existsSync(resolvedOriginal)) {
|
||||
throw new Error(`innboks-write: original mangler (peker ville dangle): ${original.path}`);
|
||||
}
|
||||
// Original maa ligge under bundle-rota (drop-zonen .claude/okr/innboks/).
|
||||
const relToBundle = path.relative(resolvedBundle, resolvedOriginal);
|
||||
if (relToBundle === '' || relToBundle.startsWith('..') || path.isAbsolute(relToBundle)) {
|
||||
throw new Error(`innboks-write: original utenfor bundle-rot: ${original.path}`);
|
||||
}
|
||||
const link = `/${relToBundle.split(path.sep).join('/')}`;
|
||||
|
||||
// Nivaa = nivaaet til foerste konsept fra samme kilde; default dokumenter/.
|
||||
const sibling = concepts.find((c) => c.sourceSlug === original.sourceSlug && c.destRel);
|
||||
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));
|
||||
writtenPointers.push(pointerTarget);
|
||||
}
|
||||
|
||||
return { concepts: writtenConcepts, pointers: writtenPointers };
|
||||
}
|
||||
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;
|
||||
}
|
||||
73
lib/okf-vocab.mjs
Normal file
73
lib/okf-vocab.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// okf-vocab.mjs
|
||||
// Lukket kontrollert vokabular for innboks-ingestion (type + tags) og
|
||||
// type->nivaa-ruting. Zero npm dependencies.
|
||||
//
|
||||
// Net-new (premiss #1): handheves KUN paa ingestion-skrivestien (snapType/
|
||||
// snapTags) og av okf-check++ --strict-ingest. Lese-siden (okr-second-brain-
|
||||
// search, okf-check default) behandler fortsatt ukjente typer som gyldige --
|
||||
// denne modulen er den ENESTE strenge porten, aktiv kun ved ingestion.
|
||||
|
||||
// Title-Case kanoniske OKF-typer (jf. catalog-spec Documents/kb Layout).
|
||||
export const TYPE_VOCAB = [
|
||||
'Organisasjonsprofil',
|
||||
'Tildelingsbrev',
|
||||
'Virksomhetsplan',
|
||||
'Overordnede OKR',
|
||||
'OKR',
|
||||
'Retrospektiv',
|
||||
'Status',
|
||||
'Notat',
|
||||
'Dokument',
|
||||
];
|
||||
|
||||
// Safe default naar raw type ikke matcher vokabularet exact (premiss #1).
|
||||
const DEFAULT_TYPE = 'Dokument';
|
||||
|
||||
// Lukket start-sett av Title-Case tags for ingestion-snap.
|
||||
export const TAGS_VOCAB = [
|
||||
'Strategi',
|
||||
'Tildelingsbrev',
|
||||
'Virksomhetsplan',
|
||||
'OKR',
|
||||
'Styring',
|
||||
'Governance',
|
||||
'Retrospektiv',
|
||||
'Status',
|
||||
'Maal',
|
||||
'Risiko',
|
||||
];
|
||||
|
||||
// type -> bundle-nivaa (katalog under bundle-rot). Organisasjonsprofil rutes
|
||||
// til dokumenter/ (IKKE strategisk-kontekst/) for aa ikke konkurrere med den
|
||||
// home-kanoniske ~/.claude/okr/org/profil.md (Revisions #25).
|
||||
const LEVEL_BY_TYPE = {
|
||||
Tildelingsbrev: 'strategisk-kontekst',
|
||||
Virksomhetsplan: 'strategisk-kontekst',
|
||||
'Overordnede OKR': 'strategisk-kontekst',
|
||||
Retrospektiv: 'historikk',
|
||||
OKR: 'dokumenter',
|
||||
Status: 'dokumenter',
|
||||
Notat: 'dokumenter',
|
||||
Dokument: 'dokumenter',
|
||||
Organisasjonsprofil: 'dokumenter',
|
||||
};
|
||||
|
||||
const DEFAULT_LEVEL = 'dokumenter';
|
||||
|
||||
// Exact-match -> kanonisk type; ellers safe default Dokument.
|
||||
export function snapType(raw) {
|
||||
return TYPE_VOCAB.includes(raw) ? raw : DEFAULT_TYPE;
|
||||
}
|
||||
|
||||
// Behold kun tags i det lukkede vokabularet (dropp ukjente); bevar rekkefolge.
|
||||
export function snapTags(rawList) {
|
||||
if (!Array.isArray(rawList)) return [];
|
||||
return rawList.filter((t) => TAGS_VOCAB.includes(t));
|
||||
}
|
||||
|
||||
// type -> bundle-nivaa; ukjent/ikke-vokab -> default dokumenter/. Plassert her
|
||||
// i Session 1 slik at relasjons-steget (Step 6) kan resolvere konseptets
|
||||
// mal-sti FOR skriv (Pass-2 ordering-fiks, Revisions #21).
|
||||
export function routeLevel(type) {
|
||||
return LEVEL_BY_TYPE[type] ?? DEFAULT_LEVEL;
|
||||
}
|
||||
|
|
@ -15,17 +15,34 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|||
import { join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
||||
import { TYPE_VOCAB } from '../lib/okf-vocab.mjs';
|
||||
import { resolveBundleLink } from '../lib/okf-links.mjs';
|
||||
|
||||
const RECOMMENDED = ['resource', 'title', 'description', 'timestamp'];
|
||||
|
||||
// Markdown-lenker [tekst](maal) -- brukt av --strict-ingest til aa validere at
|
||||
// hvert lenke-maal er en trygg, on-disk bundle-root-relativ .md (anti-RAG-poison).
|
||||
const MD_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g;
|
||||
// En title som selv baerer en markdown-lenke er en injeksjons-vektor -> avvises.
|
||||
const TITLE_LINK_RE = /\[[^\]]*\]\([^)]*\)/;
|
||||
|
||||
// Drop-zone (raa innboks-filer) + skjulte kataloger (.cache osv.) skannes ALDRI:
|
||||
// raa, ukonverterte og potensielt fiendtlige filer er ikke konsepter.
|
||||
function isWalkableDir(name) {
|
||||
return !name.startsWith('.') && name !== 'innboks';
|
||||
}
|
||||
|
||||
// Alle konsept-filer (.md unntatt index.md) under root, rekursivt.
|
||||
function walkConcepts(root) {
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p);
|
||||
else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') out.push(p);
|
||||
if (e.isDirectory()) {
|
||||
if (isWalkableDir(e.name)) walk(p);
|
||||
} else if (e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md') {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
|
@ -40,17 +57,41 @@ function rootOkfVersion(root) {
|
|||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export function checkBundle(root) {
|
||||
// strictIngest (default AV): paa skrivestien handheves det lukkede vokabularet
|
||||
// + lenke-allow-listen. Lese-siden (default) forblir tolerant (exit 0/1 kun paa
|
||||
// manglende type) -- denne porten er aktiv KUN ved ingestion (--strict-ingest).
|
||||
export function checkBundle(root, { strictIngest = false } = {}) {
|
||||
const concepts = walkConcepts(root);
|
||||
const missingType = [];
|
||||
const warnings = [];
|
||||
const strictErrors = [];
|
||||
for (const f of concepts) {
|
||||
const { get } = parseFrontmatter(readFileSync(f, 'utf8'));
|
||||
const raw = readFileSync(f, 'utf8');
|
||||
const { get } = parseFrontmatter(raw);
|
||||
const rel = relative(root, f);
|
||||
if (!get('type')) {
|
||||
const type = get('type');
|
||||
if (!type) {
|
||||
missingType.push(rel);
|
||||
continue;
|
||||
}
|
||||
if (strictIngest) {
|
||||
// 1. type maa vaere i det lukkede ingestion-vokabularet.
|
||||
if (!TYPE_VOCAB.includes(type)) {
|
||||
strictErrors.push(`${rel}: type «${type}» utenfor ingestion-vokabular`);
|
||||
}
|
||||
// 2. en title som baerer en markdown-lenke er en injeksjons-vektor.
|
||||
const title = get('title');
|
||||
if (title && TITLE_LINK_RE.test(title)) {
|
||||
strictErrors.push(`${rel}: lenke-baerende title «${title}»`);
|
||||
}
|
||||
// 3. hver markdown-lenke maa resolvere til en trygg, on-disk bundle-fil.
|
||||
for (const m of raw.matchAll(MD_LINK_RE)) {
|
||||
const target = m[1];
|
||||
const resolved = resolveBundleLink(target, root);
|
||||
if (!resolved) strictErrors.push(`${rel}: utrygg lenke ${target}`);
|
||||
else if (!existsSync(resolved)) strictErrors.push(`${rel}: dangling lenke ${target}`);
|
||||
}
|
||||
}
|
||||
for (const field of RECOMMENDED) {
|
||||
if (!get(field)) warnings.push(`${rel}: mangler anbefalt felt «${field}»`);
|
||||
}
|
||||
|
|
@ -59,6 +100,8 @@ export function checkBundle(root) {
|
|||
scanned: concepts.length,
|
||||
missingType,
|
||||
warnings,
|
||||
strictErrors,
|
||||
strictIngest,
|
||||
okfVersion: rootOkfVersion(root),
|
||||
};
|
||||
}
|
||||
|
|
@ -67,25 +110,36 @@ export function checkBundle(root) {
|
|||
const isMain = process.argv[1]
|
||||
&& fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMain) {
|
||||
const root = process.argv[2];
|
||||
const args = process.argv.slice(2);
|
||||
const strictIngest = args.includes('--strict-ingest');
|
||||
const root = args.find((a) => !a.startsWith('--'));
|
||||
if (!root) {
|
||||
process.stderr.write('Bruk: node okf-check.mjs <bundle-rot>\n');
|
||||
process.stderr.write('Bruk: node okf-check.mjs <bundle-rot> [--strict-ingest]\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!existsSync(root)) {
|
||||
process.stderr.write(`Bundle-rot finnes ikke: ${root}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const r = checkBundle(root);
|
||||
const r = checkBundle(root, { strictIngest });
|
||||
const out = [];
|
||||
out.push(`OKF-sjekk: ${root}`);
|
||||
out.push(`OKF-sjekk: ${root}${strictIngest ? ' (strict-ingest)' : ''}`);
|
||||
out.push(` Konsept-filer skannet: ${r.scanned}`);
|
||||
out.push(` ${r.missingType.length} filer uten type:`);
|
||||
for (const f of r.missingType) out.push(` - ${f}`);
|
||||
if (strictIngest) {
|
||||
out.push(` ${r.strictErrors.length} strict-ingest-feil:`);
|
||||
for (const e of r.strictErrors) out.push(` x ${e}`);
|
||||
}
|
||||
out.push(` okf_version: ${r.okfVersion || 'MANGLER (rot-index uten okf_version)'}`);
|
||||
out.push(` Advarsler (anbefalte felt): ${r.warnings.length}`);
|
||||
for (const w of r.warnings) out.push(` ! ${w}`);
|
||||
out.push(r.missingType.length === 0 ? 'OK: gyldig OKF-bundle' : `FEIL: ${r.missingType.length} fil(er) mangler type:`);
|
||||
const failed = r.missingType.length > 0 || (strictIngest && r.strictErrors.length > 0);
|
||||
let verdict;
|
||||
if (!failed) verdict = 'OK: gyldig OKF-bundle';
|
||||
else if (r.missingType.length > 0) verdict = `FEIL: ${r.missingType.length} fil(er) mangler type:`;
|
||||
else verdict = `FEIL: ${r.strictErrors.length} strict-ingest-feil`;
|
||||
out.push(verdict);
|
||||
process.stdout.write(`${out.join('\n')}\n`);
|
||||
process.exit(r.missingType.length === 0 ? 0 : 1);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ import { parseFrontmatter } from '../lib/frontmatter.mjs';
|
|||
|
||||
export const OKF_VERSION = 'kb-layout-2026-06';
|
||||
|
||||
// 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();
|
||||
|
|
@ -49,16 +56,37 @@ function parseExistingIndex(path) {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Bygg en enkelt entry-linje paa OKF-form. Tom beskrivelse -> dropp ` - d`.
|
||||
// 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]/g, ' ') // kontrolltegn -> mellomrom
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // noytraliser markdown-lenker (behold tekst)
|
||||
.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) {
|
||||
return desc ? `* [${title}](${link}) - ${desc}` : `* [${title}](${link})`;
|
||||
const t = sanitizeEntry(title);
|
||||
const d = sanitizeEntry(desc);
|
||||
return d ? `* [${t}](${link}) - ${d}` : `* [${t}](${link})`;
|
||||
}
|
||||
|
||||
// Generer og skriv index.md for EN katalog (ikke rekursivt). isRoot styrer okf_version.
|
||||
function writeIndexFor(dir, isRoot, okfVersion) {
|
||||
const existing = parseExistingIndex(join(dir, 'index.md'));
|
||||
const dirents = readdirSync(dir, { withFileTypes: true });
|
||||
const subdirs = dirents.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
||||
const subdirs = dirents
|
||||
.filter((e) => e.isDirectory() && isWalkableDir(e.name))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
const concepts = dirents
|
||||
.filter((e) => e.isFile() && e.name.endsWith('.md') && e.name !== 'index.md')
|
||||
.map((e) => e.name)
|
||||
|
|
@ -95,7 +123,7 @@ export function generateIndexes(root, opts = {}) {
|
|||
const walk = (dir, isRoot) => {
|
||||
writeIndexFor(dir, isRoot, okfVersion);
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (e.isDirectory()) walk(join(dir, e.name), false);
|
||||
if (e.isDirectory() && isWalkableDir(e.name)) walk(join(dir, e.name), false);
|
||||
}
|
||||
};
|
||||
if (!existsSync(root)) throw new Error(`Bundle-rot finnes ikke: ${root}`);
|
||||
|
|
|
|||
7
tests/fixtures/inbox-converted/dok-a.md
vendored
Normal file
7
tests/fixtures/inbox-converted/dok-a.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Tildelingsbrev 2026
|
||||
|
||||
Tildelingsbrevet gir overordnede foringer for virksomheten i 2026.
|
||||
|
||||
## Oppfolging mot Virksomhetsplan 2026
|
||||
|
||||
Tildelingsbrevet bygger paa Virksomhetsplan 2026 og maalene som er satt der.
|
||||
7
tests/fixtures/inbox-converted/dok-b.md
vendored
Normal file
7
tests/fixtures/inbox-converted/dok-b.md
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Virksomhetsplan 2026
|
||||
|
||||
Virksomhetsplanen konkretiserer de overordnede maalene for perioden.
|
||||
|
||||
## Mal og rammer (2026) [utkast]
|
||||
|
||||
Rammene for perioden er forelopige, jf. tildelingsbrev.
|
||||
7
tests/fixtures/inbox-sample/dok-a.txt
vendored
Normal file
7
tests/fixtures/inbox-sample/dok-a.txt
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Tildelingsbrev 2026
|
||||
|
||||
Tildelingsbrevet gir overordnede foringer for virksomheten i 2026.
|
||||
|
||||
## Oppfolging mot Virksomhetsplan 2026
|
||||
|
||||
Tildelingsbrevet bygger paa Virksomhetsplan 2026 og maalene som er satt der.
|
||||
7
tests/fixtures/inbox-sample/dok-b.txt
vendored
Normal file
7
tests/fixtures/inbox-sample/dok-b.txt
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Virksomhetsplan 2026
|
||||
|
||||
Virksomhetsplanen konkretiserer de overordnede maalene for perioden.
|
||||
|
||||
## Mal og rammer (2026) [utkast]
|
||||
|
||||
Rammene for perioden er forelopige, jf. tildelingsbrev.
|
||||
|
|
@ -107,3 +107,29 @@ test('writeFrontmatter: verdi med "#" siteres og round-tripper', () => {
|
|||
const { get } = parseFrontmatter(block);
|
||||
assert.equal(get('kortform'), 'A #B', 'sitert # round-tripper');
|
||||
});
|
||||
|
||||
test('writeFrontmatter: array-verdi (tags) -> OKF multi-linje liste (Step 3)', () => {
|
||||
const block = writeFrontmatter({ type: 'OKR', tags: ['a', 'b'], kilde: 'innboks' });
|
||||
assert.match(block, /tags:\n - a\n - b/, 'array -> innrykket OKF-liste');
|
||||
const { get } = parseFrontmatter(block);
|
||||
assert.equal(get('type'), 'OKR', 'skalar FOR list-blokk resolver');
|
||||
assert.equal(get('kilde'), 'innboks', 'skalar ETTER innrykket list-blokk resolver fortsatt');
|
||||
// Lese-siden uendret (Step 3 scope = kun skrive-siden): get() paa en list-key
|
||||
// krasjer ikke (eksisterende tolerance-kontrakt, frontmatter.mjs:14-16). Den
|
||||
// returnerer foerste list-element fordi parser-\s* spiser newline -- IKKE
|
||||
// null; konsumentene leser aldri tag-VERDIER, kun at nabo-skalarer resolver.
|
||||
assert.doesNotThrow(() => get('tags'), 'get paa list-key krasjer ikke');
|
||||
});
|
||||
|
||||
test('writeFrontmatter: skalar uendret av array-gren (additiv)', () => {
|
||||
const block = writeFrontmatter({ navn: 'Org', type: 'Notat' });
|
||||
assert.match(block, /navn: Org/);
|
||||
assert.match(block, /type: Notat/);
|
||||
assert.doesNotMatch(block, /^\s*-\s/m, 'ingen list-syntaks for skalarer');
|
||||
});
|
||||
|
||||
test('writeFrontmatter: tom array -> kun key-linje, ingen items', () => {
|
||||
const block = writeFrontmatter({ tags: [] });
|
||||
assert.match(block, /tags:\n/, 'tom array gir key-linje');
|
||||
assert.doesNotMatch(block, /^\s*-\s/m, 'ingen item-linjer for tom array');
|
||||
});
|
||||
|
|
|
|||
124
tests/innboks-frontmatter.test.mjs
Normal file
124
tests/innboks-frontmatter.test.mjs
Normal 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');
|
||||
});
|
||||
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);
|
||||
});
|
||||
77
tests/innboks-split.test.mjs
Normal file
77
tests/innboks-split.test.mjs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// innboks-split.test.mjs
|
||||
// Step 4 (SC idempotens): deterministisk heading-split av (allerede konvertert)
|
||||
// markdown til konsepter. splitConcepts er en REN funksjon -- samme input gir
|
||||
// identisk konsept-sett + filnavn (kebab-slug). Header-tekst -> title; flat
|
||||
// dokument (ingen #/##) -> ett konsept; slug-kollisjon -> stabil numerisk
|
||||
// disambiguering; preamble foer foerste heading bevares (ingen datatap).
|
||||
// Direkte import (zero npm deps). Moenster: tests/frontmatter.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 { splitConcepts } from '../lib/innboks-split.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 dokB = readFileSync(join(FIX, 'dok-b.md'), 'utf8');
|
||||
|
||||
test('splitConcepts: deterministisk -- samme input 2x gir identisk konsept-sett', () => {
|
||||
const a1 = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
const a2 = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
assert.deepEqual(a1, a2, 'ren funksjon: identisk output for identisk input');
|
||||
});
|
||||
|
||||
test('splitConcepts: #/## heading -> ett konsept per seksjon, header-tekst -> title', () => {
|
||||
const concepts = splitConcepts(dokA, { sourceSlug: 'dok-a' });
|
||||
assert.equal(concepts.length, 2, 'dok-a har en H1 + en H2 -> 2 konsepter');
|
||||
assert.equal(concepts[0].title, 'Tildelingsbrev 2026');
|
||||
assert.equal(concepts[0].slug, 'tildelingsbrev-2026');
|
||||
assert.equal(concepts[0].level, 1);
|
||||
assert.equal(concepts[1].title, 'Oppfolging mot Virksomhetsplan 2026');
|
||||
assert.equal(concepts[1].level, 2);
|
||||
// body baerer seksjons-innholdet (uten heading-linja).
|
||||
assert.match(concepts[0].body, /Tildelingsbrevet gir overordnede/);
|
||||
assert.doesNotMatch(concepts[0].body, /^#/, 'heading-linja er ikke i body');
|
||||
});
|
||||
|
||||
test('splitConcepts: slug stripper klammer/parenteser (adversarial heading)', () => {
|
||||
const concepts = splitConcepts(dokB, { sourceSlug: 'dok-b' });
|
||||
assert.equal(concepts.length, 2);
|
||||
assert.equal(concepts[1].title, 'Mal og rammer (2026) [utkast]');
|
||||
assert.equal(concepts[1].slug, 'mal-og-rammer-2026-utkast', 'klammer/parenteser strippet fra slug');
|
||||
});
|
||||
|
||||
test('splitConcepts: ### og dypere er IKKE split-punkt (forblir body)', () => {
|
||||
const md = '# Topp\n\nIntro.\n\n### Underseksjon\n\nDetalj.';
|
||||
const concepts = splitConcepts(md, { sourceSlug: 'dyp' });
|
||||
assert.equal(concepts.length, 1, 'kun H1 splitter; ### forblir i body');
|
||||
assert.match(concepts[0].body, /### Underseksjon/, '### bevart som body-innhold');
|
||||
});
|
||||
|
||||
test('splitConcepts: flat dokument (ingen #/##) -> ett konsept', () => {
|
||||
const flat = 'Bare en paragraf uten overskrift.\n\nEnda en linje.';
|
||||
const concepts = splitConcepts(flat, { sourceSlug: 'notat' });
|
||||
assert.equal(concepts.length, 1);
|
||||
assert.equal(concepts[0].slug, 'notat', 'flat-fallback slug fra sourceSlug');
|
||||
assert.match(concepts[0].body, /Bare en paragraf/);
|
||||
});
|
||||
|
||||
test('splitConcepts: slug-kollisjon -> stabil numerisk disambiguering', () => {
|
||||
const md = '# Samme tittel\n\nA\n\n## Samme tittel\n\nB';
|
||||
const concepts = splitConcepts(md, { sourceSlug: 'kollisjon' });
|
||||
assert.equal(concepts.length, 2);
|
||||
assert.equal(concepts[0].slug, 'samme-tittel');
|
||||
assert.equal(concepts[1].slug, 'samme-tittel-2', 'andre forekomst faar -2 suffiks');
|
||||
});
|
||||
|
||||
test('splitConcepts: preamble foer foerste heading bevares som ledende konsept', () => {
|
||||
const md = 'Forord uten overskrift.\n\n# Ekte overskrift\n\nKropp.';
|
||||
const concepts = splitConcepts(md, { sourceSlug: 'med-forord' });
|
||||
assert.equal(concepts.length, 2, 'preamble + en heading -> 2 konsepter (ingen datatap)');
|
||||
assert.equal(concepts[0].slug, 'med-forord');
|
||||
assert.match(concepts[0].body, /Forord uten overskrift/);
|
||||
assert.equal(concepts[1].title, 'Ekte overskrift');
|
||||
});
|
||||
152
tests/innboks-write.test.mjs
Normal file
152
tests/innboks-write.test.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// innboks-write.test.mjs
|
||||
// Step 7 (SC original + riktig nivaa): konsept-skriver med type->nivaa-ruting og
|
||||
// ikke-destruktiv original-bevaring. Driver den reelle kjernen
|
||||
// (splitConcepts -> projectFrontmatter -> resolveRelations) per original og lar
|
||||
// writeConcepts persistere settet i en temp-bundle. Verifiserer:
|
||||
// - konsept med strategisk-kontekst/-destRel skrives dit, dokumenter/-destRel dit
|
||||
// - skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline
|
||||
// - originalens sha256 er uendret etter skriv (ikke-destruktiv, SC3)
|
||||
// - peker-fil i konseptets nivaa lenker til originalen i drop-zonen (grep-bar)
|
||||
// - mal-sti utenfor bundle-rot avvist (../-escape) og home-org-rot avvist
|
||||
// - ingen .tmp lekker (atomisk temp+renameSync)
|
||||
// Zero npm deps. Moenster: tests/org-profile-write.test.mjs (mkdtemp + realpath/sha).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
||||
import { splitConcepts } from '../lib/innboks-split.mjs';
|
||||
import { projectFrontmatter } from '../lib/innboks-frontmatter.mjs';
|
||||
import { resolveRelations } from '../lib/innboks-relations.mjs';
|
||||
import { writeConcepts } from '../lib/innboks-write.mjs';
|
||||
|
||||
const MTIME = new Date('2026-03-15T08:30:00.000Z');
|
||||
|
||||
function sha256(path) {
|
||||
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
}
|
||||
|
||||
// Kjor den reelle kjernen for ett dokument -> konsepter (m/ destRel + frontmatter + body).
|
||||
function ingestOne(markdown, sourceSlug, sourcePath) {
|
||||
const raw = splitConcepts(markdown, { sourceSlug });
|
||||
const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: MTIME }));
|
||||
return resolveRelations(projected);
|
||||
}
|
||||
|
||||
// Temp-bundle: <tmp>/.claude/okr (+ innboks/ drop-zone). Ryddes alltid.
|
||||
function withBundle(fn) {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'okrwrite-'));
|
||||
const bundleRoot = join(tmp, '.claude', 'okr');
|
||||
const inbox = join(bundleRoot, 'innboks');
|
||||
mkdirSync(inbox, { recursive: true });
|
||||
try {
|
||||
fn({ tmp, bundleRoot, inbox });
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const TILDELING = '# Tildelingsbrev 2026\n\nStatens vegvesen skal levere paa foelgende maal i 2026.\n';
|
||||
const MERKNAD = '# Generell merknad\n\nEt fritt notat uten foeringsord i tittel.\n';
|
||||
|
||||
test('writeConcepts: type->nivaa-ruting (strategisk-kontekst + dokumenter)', () => {
|
||||
withBundle(({ bundleRoot, inbox }) => {
|
||||
const tPath = join(inbox, 'tildeling.txt');
|
||||
const mPath = join(inbox, 'merknad.txt');
|
||||
writeFileSync(tPath, TILDELING);
|
||||
writeFileSync(mPath, MERKNAD);
|
||||
|
||||
const tConcepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
|
||||
const mConcepts = ingestOne(MERKNAD, 'merknad', 'innboks/merknad.txt');
|
||||
const [tc] = tConcepts;
|
||||
const [mc] = mConcepts;
|
||||
|
||||
// Forventning fra Step 5-ruting (sanity foer skriv).
|
||||
assert.equal(dirname(tc.destRel), 'strategisk-kontekst', 'Tildelingsbrev -> strategisk-kontekst/');
|
||||
assert.equal(dirname(mc.destRel), 'dokumenter', 'default Dokument -> dokumenter/');
|
||||
|
||||
writeConcepts([...tConcepts, ...mConcepts], {
|
||||
bundleRoot,
|
||||
originals: [
|
||||
{ sourceSlug: 'tildeling', path: tPath },
|
||||
{ sourceSlug: 'merknad', path: mPath },
|
||||
],
|
||||
});
|
||||
|
||||
const tFile = join(bundleRoot, tc.destRel);
|
||||
const mFile = join(bundleRoot, mc.destRel);
|
||||
assert.ok(existsSync(tFile), 'tildelingsbrev-konsept skrevet til strategisk-kontekst/');
|
||||
assert.ok(existsSync(mFile), 'merknad-konsept skrevet til dokumenter/');
|
||||
|
||||
// Skrevet fil = frontmatter (verbatim) + body (verbatim), trailing newline. IKKE re-serialisert.
|
||||
const tContent = readFileSync(tFile, 'utf8');
|
||||
assert.ok(tContent.startsWith(tc.frontmatter), 'frontmatter verbatim oeverst');
|
||||
assert.ok(tContent.includes(tc.body), 'body verbatim bevart');
|
||||
assert.ok(tContent.endsWith('\n'), 'fil avsluttes med newline');
|
||||
});
|
||||
});
|
||||
|
||||
test('writeConcepts: original uendret (sha256) + peker-fil lenker til drop-zone-original', () => {
|
||||
withBundle(({ bundleRoot, inbox }) => {
|
||||
const tPath = join(inbox, 'tildeling.txt');
|
||||
writeFileSync(tPath, TILDELING);
|
||||
const before = sha256(tPath);
|
||||
|
||||
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
|
||||
writeConcepts(concepts, {
|
||||
bundleRoot,
|
||||
originals: [{ sourceSlug: 'tildeling', path: tPath }],
|
||||
});
|
||||
|
||||
// SC3: ikke-destruktiv -- originalen er byte-uendret.
|
||||
assert.equal(sha256(tPath), before, 'original-sha256 uendret etter skriv');
|
||||
assert.ok(existsSync(tPath), 'original finnes fortsatt i drop-zonen');
|
||||
|
||||
// Peker-fil i konseptets nivaa, grep-bar lenke til originalen.
|
||||
const level = dirname(concepts[0].destRel);
|
||||
const pointer = join(bundleRoot, level, 'tildeling.kilde.md');
|
||||
assert.ok(existsSync(pointer), 'peker-fil skrevet i konseptets nivaa');
|
||||
const pointerBody = readFileSync(pointer, 'utf8');
|
||||
assert.match(pointerBody, /\(\/innboks\/tildeling\.txt\)/, 'peker lenker til original i drop-zonen');
|
||||
assert.match(pointerBody, /^type: Notat$/m, 'peker baerer gyldig OKF-type (vokab)');
|
||||
});
|
||||
});
|
||||
|
||||
test('writeConcepts: ../-escape destRel avvist (path-confinement)', () => {
|
||||
withBundle(({ bundleRoot }) => {
|
||||
const evil = { slug: 'evil', sourceSlug: 'evil', destRel: '../escape.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
|
||||
assert.throws(
|
||||
() => writeConcepts([evil], { bundleRoot, originals: [] }),
|
||||
/utenfor bundle-rot/,
|
||||
'destRel som escaper bundle-rota skal kaste',
|
||||
);
|
||||
assert.ok(!existsSync(join(bundleRoot, '..', 'escape.md')), 'ingenting skrevet utenfor bundle');
|
||||
});
|
||||
});
|
||||
|
||||
test('writeConcepts: skriv til home-org-rot avvist (ingestion roerer aldri ~/.claude/okr/org)', () => {
|
||||
const homeOrg = join(homedir(), '.claude', 'okr', 'org');
|
||||
const concept = { slug: 'p', sourceSlug: 'p', destRel: 'dokumenter/p.md', frontmatter: '---\ntype: Dokument\n---\n', body: 'x' };
|
||||
// Kaster FOER noen skriv -- home-profilen roeres aldri.
|
||||
assert.throws(
|
||||
() => writeConcepts([concept], { bundleRoot: homeOrg, originals: [] }),
|
||||
/home-org-rot avvist/,
|
||||
'home-org bundleRoot skal kaste',
|
||||
);
|
||||
});
|
||||
|
||||
test('writeConcepts: atomisk -- ingen .tmp lekker etter skriv', () => {
|
||||
withBundle(({ bundleRoot, inbox }) => {
|
||||
const tPath = join(inbox, 'tildeling.txt');
|
||||
writeFileSync(tPath, TILDELING);
|
||||
const concepts = ingestOne(TILDELING, 'tildeling', 'innboks/tildeling.txt');
|
||||
writeConcepts(concepts, { bundleRoot, originals: [{ sourceSlug: 'tildeling', path: tPath }] });
|
||||
|
||||
const level = dirname(concepts[0].destRel);
|
||||
const leftover = readdirSync(join(bundleRoot, level)).filter((f) => f.endsWith('.tmp'));
|
||||
assert.deepEqual(leftover, [], 'ingen temp-fil igjen etter atomisk renameSync');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,7 +10,7 @@ import { test } from 'node:test';
|
|||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync,
|
||||
mkdtempSync, cpSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, mkdirSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
|
@ -26,15 +26,36 @@ function tmpRoot() {
|
|||
}
|
||||
|
||||
// Kjoer okf-check som subprosess; fang non-zero exit (execFileSync kaster da).
|
||||
function runCheck(root) {
|
||||
// extra: ekstra CLI-flagg (f.eks. '--strict-ingest').
|
||||
function runCheck(root, ...extra) {
|
||||
try {
|
||||
const stdout = execFileSync('node', [CHECK, root], { encoding: 'utf8' });
|
||||
const stdout = execFileSync('node', [CHECK, root, ...extra], { encoding: 'utf8' });
|
||||
return { status: 0, stdout };
|
||||
} catch (e) {
|
||||
return { status: e.status ?? 1, stdout: `${e.stdout || ''}${e.stderr || ''}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Bygg en ren ingestion-bundle: kanoniske vokab-typer + en trygg, on-disk relasjon.
|
||||
function buildCleanIngest(dir) {
|
||||
mkdirSync(join(dir, 'strategisk-kontekst'), { recursive: true });
|
||||
mkdirSync(join(dir, 'dokumenter'), { recursive: true });
|
||||
writeFileSync(join(dir, 'index.md'), '# Bundle\n\nokf_version: kb-layout-2026-06\n');
|
||||
writeFileSync(
|
||||
join(dir, 'strategisk-kontekst', 'tildelingsbrev.md'),
|
||||
'---\ntype: Tildelingsbrev\nresource: urn:okr:tb\ntitle: Tildelingsbrev 2026\n'
|
||||
+ "description: Styringssignaler.\ntimestamp: '2026-01-15T09:00:00+00:00'\n---\n"
|
||||
+ '# Tildelingsbrev 2026\n\nHoveddokument.\n',
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, 'dokumenter', 'notat.md'),
|
||||
'---\ntype: Notat\nresource: urn:okr:notat\ntitle: Internt notat\n'
|
||||
+ "description: Et notat.\ntimestamp: '2026-02-01T09:00:00+00:00'\n---\n"
|
||||
+ '# Internt notat\n\n## Relaterte dokumenter\n\n'
|
||||
+ '- [Tildelingsbrev 2026](/strategisk-kontekst/tildelingsbrev.md)\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Alle kataloger under root (inkl. root selv), rekursivt.
|
||||
function allDirs(root) {
|
||||
const out = [root];
|
||||
|
|
@ -44,6 +65,16 @@ function allDirs(root) {
|
|||
return out;
|
||||
}
|
||||
|
||||
// Byte-snapshot av alle index.md under root (for idempotens-sammenligning).
|
||||
function snapshotIndexes(root) {
|
||||
return allDirs(root)
|
||||
.map((d) => join(d, 'index.md'))
|
||||
.filter((p) => existsSync(p))
|
||||
.sort()
|
||||
.map((p) => `${p}\n${readFileSync(p, 'utf8')}`)
|
||||
.join('\n=====\n');
|
||||
}
|
||||
|
||||
// --- okf-index ---
|
||||
|
||||
test('okf-index: genererer index.md per nivaa (hver katalog) i temp-kopi', () => {
|
||||
|
|
@ -135,3 +166,147 @@ test('okf-check: rapporterer okf_version fra rot-index', () => {
|
|||
const { stdout } = runCheck(REALISTIC);
|
||||
assert.match(stdout, /okf_version:\s*kb-layout-2026-06/, 'skal ekko okf_version for menneskelig sammenligning');
|
||||
});
|
||||
|
||||
// --- okf-check --strict-ingest (Step 8): lukket vokab + lenke-allow-liste ---
|
||||
|
||||
test('okf-check --strict-ingest: ren ingestion-bundle (vokab-type + trygg on-disk relasjon) -> exit 0', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
buildCleanIngest(dir);
|
||||
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||
assert.equal(status, 0, `ren bundle skal gi exit 0:\n${stdout}`);
|
||||
assert.match(stdout, /0 strict-ingest-feil/, 'skal rapportere null strict-feil');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('okf-check --strict-ingest: type utenfor vokabular -> exit 1 + navngir filen', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
buildCleanIngest(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'dokumenter', 'rar.md'),
|
||||
'---\ntype: Tilfeldig\ntitle: Rar\ndescription: x\n---\n# Rar\n',
|
||||
);
|
||||
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||
assert.equal(status, 1, 'out-of-vocab type skal gi exit 1');
|
||||
assert.match(stdout, /rar\.md: type .* utenfor ingestion-vokabular/, 'skal navngi feilen');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('okf-check --strict-ingest: usikker lenke (escape ut av bundle) -> exit 1', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
buildCleanIngest(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'dokumenter', 'ond.md'),
|
||||
'---\ntype: Notat\ntitle: Ond\ndescription: x\n---\n# Ond\n\n- [exfil](../../../etc/passwd)\n',
|
||||
);
|
||||
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||
assert.equal(status, 1, 'utrygg lenke skal gi exit 1');
|
||||
assert.match(stdout, /utrygg lenke/, 'skal rapportere utrygg lenke');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('okf-check --strict-ingest: dangling bundle-lenke (trygg form, mangler on-disk) -> exit 1', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
buildCleanIngest(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'dokumenter', 'henger.md'),
|
||||
'---\ntype: Notat\ntitle: Henger\ndescription: x\n---\n# Henger\n\n'
|
||||
+ '- [Mangler](/strategisk-kontekst/finnes-ikke.md)\n',
|
||||
);
|
||||
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||
assert.equal(status, 1, 'dangling lenke skal gi exit 1');
|
||||
assert.match(stdout, /dangling lenke/, 'skal rapportere dangling lenke');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('okf-check --strict-ingest: lenke-baerende title -> exit 1', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
buildCleanIngest(dir);
|
||||
writeFileSync(
|
||||
join(dir, 'dokumenter', 'tittel.md'),
|
||||
'---\ntype: Notat\ntitle: "[Klikk her](http://evil.example)"\ndescription: x\n---\n# T\n',
|
||||
);
|
||||
const { status, stdout } = runCheck(dir, '--strict-ingest');
|
||||
assert.equal(status, 1, 'lenke-baerende title skal gi exit 1');
|
||||
assert.match(stdout, /lenke-baerende title/, 'skal rapportere lenke-baerende title');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- walk-eksklusjon (Step 8): drop-zone + dot-kataloger skannes aldri ---
|
||||
|
||||
test('okf-check: raa filer i innboks/ og .cache/ ignoreres av walk (ingen falsk exit 1)', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
buildCleanIngest(dir);
|
||||
mkdirSync(join(dir, 'innboks'), { recursive: true });
|
||||
mkdirSync(join(dir, '.cache'), { recursive: true });
|
||||
// Raa filer UTEN type -- ville gitt exit 1 hvis walk ikke ekskluderte dem.
|
||||
writeFileSync(join(dir, 'innboks', 'raa.md'), '# Raa innboks-fil uten frontmatter\n');
|
||||
writeFileSync(join(dir, '.cache', 'c.md'), '# Cache uten type\n');
|
||||
const { status, stdout } = runCheck(dir);
|
||||
assert.equal(status, 0, `drop-zone/dot-filer skal ignoreres:\n${stdout}`);
|
||||
assert.match(stdout, /0 filer uten type:/, 'walk skal ikke telle drop-zone-filer');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// --- okf-index herding (Step 9): saner title/desc + skip innboks/dot ---
|
||||
|
||||
test('okf-index: tittel/beskrivelse med ]/) + lenke saneres -> 2x byte-identisk + round-trip-trygg', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
mkdirSync(join(dir, 'dokumenter'), { recursive: true });
|
||||
writeFileSync(join(dir, 'index.md'), '# Bundle\n\nokf_version: kb-layout-2026-06\n');
|
||||
writeFileSync(
|
||||
join(dir, 'dokumenter', 'kr.md'),
|
||||
'---\ntype: OKR\ntitle: "KR1 [resultat] (maal) [lenke](http://x)"\n'
|
||||
+ 'description: "Status (delvis) [ref](http://y)"\n---\n# KR\n',
|
||||
);
|
||||
generateIndexes(dir);
|
||||
const run1 = snapshotIndexes(dir);
|
||||
generateIndexes(dir);
|
||||
const run2 = snapshotIndexes(dir);
|
||||
assert.equal(run1, run2, 'generateIndexes skal vaere byte-idempotent');
|
||||
const idx = readFileSync(join(dir, 'dokumenter', 'index.md'), 'utf8');
|
||||
const entry = idx.split('\n').find((l) => l.startsWith('* '));
|
||||
assert.ok(entry, 'kr.md skal ha en entry');
|
||||
assert.match(entry, /^\* \[[^\]]*\]\([^)]+\)/, 'entry skal vaere round-trip-trygg OKF-form');
|
||||
assert.ok(!entry.includes('http'), 'injisert lenke skal vaere noytralisert');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('okf-index: innboks/ + dot-katalog -> ingen egen index.md OG ikke listet i rot-index', () => {
|
||||
const dir = tmpRoot();
|
||||
try {
|
||||
cpSync(REALISTIC, dir, { recursive: true });
|
||||
mkdirSync(join(dir, 'innboks'), { recursive: true });
|
||||
mkdirSync(join(dir, '.cache'), { recursive: true });
|
||||
writeFileSync(join(dir, 'innboks', 'raa.md'), '# Raa\n');
|
||||
writeFileSync(join(dir, '.cache', 'c.md'), '# Cache\n');
|
||||
generateIndexes(dir);
|
||||
assert.ok(!existsSync(join(dir, 'innboks', 'index.md')), 'innboks/ skal ikke faa index.md');
|
||||
assert.ok(!existsSync(join(dir, '.cache', 'index.md')), 'dot-katalog skal ikke faa index.md');
|
||||
const root = readFileSync(join(dir, 'index.md'), 'utf8');
|
||||
assert.ok(!root.includes('innboks/index.md'), 'rot-index skal ikke liste innboks/ som peker');
|
||||
assert.ok(!root.includes('.cache/index.md'), 'rot-index skal ikke liste dot-katalog som peker');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
65
tests/okf-vocab.test.mjs
Normal file
65
tests/okf-vocab.test.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// okf-vocab.test.mjs
|
||||
// Tester det lukkede OKF-vokabularet (type+tags) og type->nivaa-ruting for
|
||||
// innboks-ingestion (Step 2). snapType: exact-match -> kanonisk, ellers safe
|
||||
// default Dokument; snapTags dropper ukjente; routeLevel mapper type til
|
||||
// bundle-nivaa. Direkte import (zero npm deps). Moenster: tests/frontmatter.test.mjs.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TYPE_VOCAB,
|
||||
TAGS_VOCAB,
|
||||
snapType,
|
||||
snapTags,
|
||||
routeLevel,
|
||||
} from '../lib/okf-vocab.mjs';
|
||||
|
||||
test('snapType: kjent type beholdes kanonisk', () => {
|
||||
assert.equal(snapType('OKR'), 'OKR');
|
||||
assert.equal(snapType('Tildelingsbrev'), 'Tildelingsbrev');
|
||||
});
|
||||
|
||||
test('snapType: ukjent type -> safe default Dokument', () => {
|
||||
assert.equal(snapType('ukjent'), 'Dokument');
|
||||
assert.equal(snapType(''), 'Dokument');
|
||||
assert.equal(snapType(undefined), 'Dokument');
|
||||
});
|
||||
|
||||
test('snapTags: dropper ukjente, beholder kjente (bevart rekkefolge)', () => {
|
||||
assert.deepEqual(snapTags(['Strategi', 'xyz']), ['Strategi']);
|
||||
assert.deepEqual(snapTags(['xyz', 'abc']), []);
|
||||
assert.deepEqual(snapTags([]), []);
|
||||
});
|
||||
|
||||
test('snapTags: ikke-array -> tom liste (ingen krasj)', () => {
|
||||
assert.deepEqual(snapTags(undefined), []);
|
||||
assert.deepEqual(snapTags(null), []);
|
||||
});
|
||||
|
||||
test('routeLevel: strategisk-kontekst for foringsdokumenter', () => {
|
||||
assert.equal(routeLevel('Tildelingsbrev'), 'strategisk-kontekst');
|
||||
assert.equal(routeLevel('Virksomhetsplan'), 'strategisk-kontekst');
|
||||
assert.equal(routeLevel('Overordnede OKR'), 'strategisk-kontekst');
|
||||
});
|
||||
|
||||
test('routeLevel: historikk for Retrospektiv', () => {
|
||||
assert.equal(routeLevel('Retrospektiv'), 'historikk');
|
||||
});
|
||||
|
||||
test('routeLevel: dokumenter for OKR/Status/Notat/Dokument/Organisasjonsprofil', () => {
|
||||
assert.equal(routeLevel('OKR'), 'dokumenter');
|
||||
assert.equal(routeLevel('Status'), 'dokumenter');
|
||||
assert.equal(routeLevel('Notat'), 'dokumenter');
|
||||
assert.equal(routeLevel('Dokument'), 'dokumenter');
|
||||
assert.equal(routeLevel('Organisasjonsprofil'), 'dokumenter');
|
||||
});
|
||||
|
||||
test('routeLevel: ukjent type -> default dokumenter', () => {
|
||||
assert.equal(routeLevel('Whatever'), 'dokumenter');
|
||||
});
|
||||
|
||||
test('vokabular non-tomt + default i settet', () => {
|
||||
assert.ok(TYPE_VOCAB.length > 0, 'TYPE_VOCAB non-tomt');
|
||||
assert.ok(TAGS_VOCAB.length > 0, 'TAGS_VOCAB non-tomt');
|
||||
assert.ok(TYPE_VOCAB.includes('Dokument'), 'default Dokument er i vokabularet');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue