diff --git a/hooks/scripts/coaching-hook.mjs b/hooks/scripts/coaching-hook.mjs index fd6eae5..0c186ff 100644 --- a/hooks/scripts/coaching-hook.mjs +++ b/hooks/scripts/coaching-hook.mjs @@ -7,6 +7,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; +import { parseFrontmatter } from '../../lib/frontmatter.mjs'; const cwd = process.cwd(); const configPath = join(cwd, '.claude', 'okr.local.md'); @@ -17,14 +18,8 @@ if (!existsSync(configPath)) { try { const content = readFileSync(configPath, 'utf8'); - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) process.exit(0); - - const fm = match[1]; - const get = (key) => { - const m = fm.match(new RegExp(`${key}:\\s*["']?([^"'\\n]+)["']?`)); - return m ? m[1].trim() : null; - }; + const { raw, get } = parseFrontmatter(content); + if (raw === null) process.exit(0); const cycleId = get('id'); const fase = get('fase'); diff --git a/hooks/scripts/inject-okr-context.mjs b/hooks/scripts/inject-okr-context.mjs index fadd85e..faeb3fe 100644 --- a/hooks/scripts/inject-okr-context.mjs +++ b/hooks/scripts/inject-okr-context.mjs @@ -8,6 +8,7 @@ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { parseFrontmatter } from '../../lib/frontmatter.mjs'; const cwd = process.cwd(); const projectConfigPath = join(cwd, '.claude', 'okr.local.md'); @@ -56,20 +57,14 @@ if (!configPath) { try { const content = readFileSync(configPath, 'utf8'); - const match = content.match(/^---\n([\s\S]*?)\n---/); - if (!match) process.exit(0); - - const fm = match[1]; - const get = (key) => { - const m = fm.match(new RegExp(`${key}:\\s*["']?([^"'\\n]+)["']?`)); - return m ? m[1].trim() : null; - }; + const { raw, get } = parseFrontmatter(content); + if (raw === null) process.exit(0); // Core fields (backwards-compatible with old 4-field format) const org = get('navn') || get('name'); const syklus = get('gjeldende') || get('id') || get('current_cycle'); const sektor = get('sektor') || get('sector') || get('domene'); - const linear = fm.includes('aktivert: true') || fm.includes('enabled: true'); + const linear = raw.includes('aktivert: true') || raw.includes('enabled: true'); // New v1.1 fields (silently skipped if absent) const modenhet = get('modenhetsnivaa'); diff --git a/tests/frontmatter-compat.test.mjs b/tests/frontmatter-compat.test.mjs new file mode 100644 index 0000000..34517ef --- /dev/null +++ b/tests/frontmatter-compat.test.mjs @@ -0,0 +1,109 @@ +// frontmatter-compat.test.mjs +// Karakteriserings-/kompatibilitetstest (SC2): beviser at ruting av +// inject-okr-context.mjs gjennom den delte lib/frontmatter.mjs er +// ATFERDS-BEVARENDE for de 9 leste noeklene, med EN tilsiktet endring: +// comment-leak paa usitert `okr_frikoblet_fra_loenn`-linje forsvinner. +// Spawner hooken som subprosess (full 9-felts frontmatter, rikere enn +// inject-okr-context.test.mjs sin makeProjectConfig som kun skriver navn). +// Zero npm deps. Moenster: tests/inject-okr-context.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HOOK = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'hooks', + 'scripts', + 'inject-okr-context.mjs', +); + +// Bygger en full 9-felts okr.local.md. okr_frikoblet_fra_loenn skrives USITERT +// MED en trailing "# ..."-kommentar — det er denne lekkasjen den delte +// parseren skal fjerne. +function makeFullConfig(workDir, fields = {}) { + const f = { + navn: 'TestOrg', + kortform: 'TO', + gjeldende: 'T2-2026', + sektor: 'transport', + modenhetsnivaa: 'nivaa-2', + fase: 'utforsker', + okr_frikoblet_fra_loenn: 'true', + psykologisk_trygghet: 'hoy', + linear: true, + ...fields, + }; + const body = + [ + '---', + `navn: "${f.navn}"`, + `kortform: "${f.kortform}"`, + `gjeldende: "${f.gjeldende}"`, + `sektor: "${f.sektor}"`, + `modenhetsnivaa: "${f.modenhetsnivaa}"`, + `fase: "${f.fase}"`, + `okr_frikoblet_fra_loenn: ${f.okr_frikoblet_fra_loenn} # frikoblet fra lonn`, + `psykologisk_trygghet: "${f.psykologisk_trygghet}"`, + 'linear:', + ` aktivert: ${f.linear}`, + '---', + '', + ].join('\n'); + const p = join(workDir, '.claude'); + mkdirSync(p, { recursive: true }); + writeFileSync(join(p, 'okr.local.md'), body); +} + +const OKR_PROMPT = JSON.stringify({ prompt: 'hjelp meg skrive OKR' }); + +function runHook(cwd, home, input = OKR_PROMPT) { + return execFileSync('node', [HOOK], { + cwd, + env: { ...process.env, HOME: home }, + input, + encoding: 'utf8', + }); +} + +function withDirs(fn) { + const home = mkdtempSync(join(tmpdir(), 'okrhome-')); + const work = mkdtempSync(join(tmpdir(), 'okrwork-')); + try { + fn(home, work); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(work, { recursive: true, force: true }); + } +} + +test('full config: alle 9 noekler resolver likt som foer', () => { + withDirs((home, work) => { + makeFullConfig(work); + const out = runHook(work, home); + assert.match(out, /Organisasjon: TestOrg \(TO\)/, 'navn + kortform'); + assert.match(out, /Syklus: T2-2026 \[utforsker\]/, 'gjeldende + fase'); + assert.match(out, /Sektor: transport/, 'sektor'); + assert.match(out, /Modenhet: nivaa-2/, 'modenhetsnivaa'); + assert.match(out, /OKR frikoblet fra lonn: true/, 'frikoblet-verdi'); + assert.match(out, /Psykologisk trygghet: hoy/, 'psykologisk_trygghet'); + assert.match(out, /Linear: aktivert/, 'linear aktivert: true'); + }); +}); + +test('comment-leak fjernet: ingen "#" i injisert melding', () => { + withDirs((home, work) => { + makeFullConfig(work); + const out = runHook(work, home); + assert.doesNotMatch( + out, + /#/, + 'usitert frikoblet-linjes "# ..."-kommentar skal ikke lekke inn i meldingen', + ); + }); +});