diff --git a/scripts/innboks-ingest.mjs b/scripts/innboks-ingest.mjs new file mode 100644 index 0000000..edd58b9 --- /dev/null +++ b/scripts/innboks-ingest.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +// innboks-ingest.mjs +// Step 12 (A2): pipeline-orkestrator for innboks-ingestion. Kjoerer drop-zonen +// (.claude/okr/innboks/) gjennom den deterministiske kjeden og persisterer +// konsepter i prosjekt-bundlen. INGEN LLM i v1 (--enrich = v1.1-soem, ingen flagg). +// +// Faserekkefoelge (A2-redesign av plan.md Step 12 -- begrunnelse under): +// 1. discover: flat walk av drop-zonen (kun filer, dotfiler skippes), sortert +// (deterministisk rekkefoelge = stabil disambiguering). Ukjent extension -> +// skip + norsk notice (via convert), aldri feil. +// 2. PER DOKUMENT (staging + gate, laast A0): convert -> splitConcepts -> +// projectFrontmatter (timestamp = original-mtime, IKKE veggklokke) -> +// kryss-kilde destRel-disambiguering (B5: kolliderende destRel fra annen +// kilde faar ---suffiks, stabilt gitt sortert rekkefoelge) -> +// writeConcepts (delt claimed-register) -> per-dokument-gate +// checkBundle(root, {strictIngest: true, files: }) +// -- strict scopes til KUN kjoeringens skrevne filer, ALDRI hele roten +// (B2: kuratert innhold med lovlige eksterne lenker/out-of-vocab-type kan +// ikke felle ingestion). Gate-feil -> discard-on-fail: KUN det dokumentets +// skrivinger rulles tilbake (Map-Reduce-isolasjon -- ett fiendtlig dok +// forgifter ikke andre). +// 3. resolveRelations over ALLE overlevende dokumenters konsepter, DERETTER +// relasjons-omskriv av konsepter som fikk relasjoner. +// A2-REDESIGN (avvik fra plan-kjedens "relations foer write"): relasjoner +// per dokument alene kan aldri emittere kryss-dokument-lenker (fixture-SC +// dok-a -> dok-b), og relasjoner FOER gaten ville latt et discardet +// dokument etterlate dangling relasjonslenker i overlevende dokumenter +// (isolasjonsbrudd). Gate foerst, relasjoner blant overlevende etterpaa: +// hvert relasjonsmaal er da garantert paa disk. +// 4. generateIndexes(root) over det overlevende settet. +// 5. Belte+seler: scoped strict-sjekk av ALLE overlevende skrevne filer -- +// feil her er et internt invariant-brudd (skal aldri skje), ikke dok-feil. +// +// Idempotens by construction: fil-settet er en funksjon av drop-zonens innhold +// + mtime alene (run1 vs run2 byte-identisk, testet via sha256-manifest). +// Project-root-only: writeConcepts avviser home-org-rota; ingen cross-repo. +// +// Bruk: node scripts/innboks-ingest.mjs +// Exit: 0 = alle dokumenter OK (skip av ukjent filtype er OK), 1 = minst ett +// dokument feilet/discardet (eller internt invariant-brudd), 2 = bruksfeil. + +import { readdirSync, statSync, unlinkSync, rmdirSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { convert } from '../lib/convert/index.mjs'; +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'; +import { generateIndexes } from './okf-index.mjs'; +import { checkBundle } from './okf-check.mjs'; + +// Kebab-slug -- speiler innboks-split.mjs:13-21 (frosset modul, eksporterer +// ikke slugify; semantikken MAA vaere identisk med konsept-slugging). +function slugify(text) { + const s = String(text) + .toLowerCase() + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return s || 'konsept'; +} + +// Flat, sortert discover av drop-zonen: kun regulaere filer, dotfiler skippes. +// (Underkataloger i drop-zonen er udefinert i v1 -- de ignoreres stille.) +function discover(inboxDir) { + return readdirSync(inboxDir, { withFileTypes: true }) + .filter((e) => !e.name.startsWith('.') && !e.isDirectory()) + .map((e) => e.name) + .sort((a, b) => a.localeCompare(b)); +} + +// Unik sourceSlug per fil: basename uten extension; kollisjon (dok.docx vs +// dok.pdf) -> extension-suffiks; deretter teller. Deterministisk i sortert orden. +function assignSourceSlug(name, taken) { + const ext = path.extname(name); + const base = slugify(path.basename(name, ext)); + const candidates = [base, `${base}-${slugify(ext)}`]; + for (const c of candidates) { + if (!taken.has(c)) { + taken.add(c); + return c; + } + } + for (let i = 2; ; i += 1) { + const c = `${candidates[1]}-${i}`; + if (!taken.has(c)) { + taken.add(c); + return c; + } + } +} + +// B5: destRel-disambiguering PAA TVERS av kilder. claimed = det delte registeret +// (absolutt maal -> sourceSlug) som writeConcepts ogsaa haandhever. Kolliderende +// destRel fra en ANNEN kilde faar ---suffiks (stabilt: avhenger kun +// av input-settet + sortert rekkefoelge, aldri veggklokke). +function disambiguate(concepts, resolvedBundle, claimed) { + return concepts.map((c) => { + let destRel = c.destRel; + const owner = claimed.get(path.resolve(resolvedBundle, destRel)); + if (owner !== undefined && owner !== c.sourceSlug) { + const dir = path.dirname(destRel); + const base = path.basename(destRel, '.md'); + destRel = `${dir}/${base}--${c.sourceSlug}.md`; + for (let i = 2; claimed.has(path.resolve(resolvedBundle, destRel)); i += 1) { + destRel = `${dir}/${base}--${c.sourceSlug}-${i}.md`; + } + } + return destRel === c.destRel ? c : { ...c, destRel }; + }); +} + +// Discard-on-fail: fjern dokumentets skrevne filer + toemte foreldre-kataloger +// (opp til bundle-rota), og slipp claimed-registreringene. +function discard(written, resolvedBundle, claimed) { + for (const file of written) { + if (existsSync(file)) unlinkSync(file); + claimed.delete(file); + let dir = path.dirname(file); + while (dir !== resolvedBundle && dir.startsWith(resolvedBundle + path.sep)) { + try { + rmdirSync(dir); // kaster hvis ikke tom -> ferdig aa rydde + } catch { + break; + } + dir = path.dirname(dir); + } + } +} + +// Orkestrer hele ingest-kjoeringen. Returnerer +// { ingested: [sourceSlug], skipped: [filnavn], failed: [{source, reason}], written: [absolutt sti] } +export async function ingestInbox(inboxDir, bundleRoot, { onNotice = (msg) => process.stderr.write(`${msg}\n`) } = {}) { + const resolvedBundle = path.resolve(bundleRoot); + const claimed = new Map(); // absolutt maal -> sourceSlug (delt med writeConcepts, B5) + const takenSlugs = new Set(); + const ingested = []; + const skipped = []; + const failed = []; + const survivors = []; // { source, concepts (disambiguert), written } + + for (const name of discover(inboxDir)) { + const filePath = path.join(inboxDir, name); + const sourceSlug = assignSourceSlug(name, takenSlugs); + let written = null; + try { + const markdown = await convert(filePath, { onNotice }); + if (markdown === null) { + skipped.push(name); + continue; + } + const mtime = statSync(filePath).mtime; + const sourcePath = path.relative(resolvedBundle, path.resolve(filePath)).split(path.sep).join('/'); + const raw = splitConcepts(markdown, { sourceSlug }); + const projected = raw.map((c) => projectFrontmatter(c, { sourcePath, sourceMtime: mtime })); + const concepts = disambiguate(projected, resolvedBundle, claimed); + + const result = writeConcepts(concepts, { + bundleRoot: resolvedBundle, + originals: [{ sourceSlug, path: filePath }], + claimed, + }); + written = [...result.concepts, ...result.pointers]; + + // Per-dokument-gate, scoped til KUN dette dokumentets skrevne filer (B2). + const gate = checkBundle(resolvedBundle, { strictIngest: true, files: written }); + const errors = [...gate.missingType.map((f) => `mangler type: ${f}`), ...gate.strictErrors]; + if (errors.length > 0) { + discard(written, resolvedBundle, claimed); + failed.push({ source: name, reason: errors.join('; ') }); + continue; + } + + survivors.push({ source: name, sourceSlug, concepts, written }); + ingested.push(sourceSlug); + } catch (e) { + // Skrive-/konverteringsfeil (kuratert-kollisjon, symlink, manglende dep): + // discard det som maatte vaere skrevet, kjoeringen fortsetter (isolasjon). + if (written) discard(written, resolvedBundle, claimed); + failed.push({ source: name, reason: e.message }); + } + } + + // Fase 3: relasjoner blant OVERLEVENDE dokumenters konsepter (kryss-dokument; + // hvert maal er garantert paa disk). Omskriv kun konsepter som fikk relasjoner. + const allConcepts = survivors.flatMap((s) => s.concepts); + const related = resolveRelations(allConcepts); + const withRelations = related.filter((c) => c.relations.length > 0); + if (withRelations.length > 0) { + writeConcepts(withRelations, { bundleRoot: resolvedBundle, claimed }); + } + + // Fase 4: indekser det overlevende settet (rot + alle nivaaer). + generateIndexes(resolvedBundle); + + // Fase 5 (belte+seler): alle overlevende skrevne filer maa passere scoped + // strict. Feil her er et internt invariant-brudd, ikke en dokument-feil. + const allWritten = survivors.flatMap((s) => s.written); + if (allWritten.length > 0) { + const final = checkBundle(resolvedBundle, { strictIngest: true, files: allWritten }); + const finalErrors = [...final.missingType, ...final.strictErrors]; + if (finalErrors.length > 0) { + throw new Error(`innboks-ingest: intern invariant brutt etter relasjons-fasen: ${finalErrors.join('; ')}`); + } + } + + return { ingested, skipped, failed, written: allWritten }; +} + +// --- CLI --- +const isMain = process.argv[1] + && fileURLToPath(import.meta.url) === process.argv[1]; +if (isMain) { + const [inboxDir, bundleRoot] = process.argv.slice(2); + if (!inboxDir || !bundleRoot) { + process.stderr.write('Bruk: node innboks-ingest.mjs \n'); + process.exit(2); + } + if (!existsSync(inboxDir) || !existsSync(bundleRoot)) { + process.stderr.write(`Finnes ikke: ${existsSync(inboxDir) ? bundleRoot : inboxDir}\n`); + process.exit(2); + } + try { + const r = await ingestInbox(inboxDir, bundleRoot); + const lines = [`Innboks-ingest: ${inboxDir} -> ${bundleRoot}`]; + lines.push(` Ingested: ${r.ingested.length} dokument(er)${r.ingested.length ? ` (${r.ingested.join(', ')})` : ''}`); + lines.push(` Skippet (ukjent filtype): ${r.skipped.length}`); + lines.push(` Feilet/discardet: ${r.failed.length}`); + for (const f of r.failed) lines.push(` x ${f.source}: ${f.reason}`); + lines.push(r.failed.length === 0 ? 'OK: alle dokumenter ingested' : 'FEIL: se discardede dokumenter over'); + process.stdout.write(`${lines.join('\n')}\n`); + process.exit(r.failed.length === 0 ? 0 : 1); + } catch (e) { + process.stderr.write(`innboks-ingest: ${e.message}\n`); + process.exit(1); + } +} diff --git a/tests/innboks-ingest.test.mjs b/tests/innboks-ingest.test.mjs new file mode 100644 index 0000000..9542b16 --- /dev/null +++ b/tests/innboks-ingest.test.mjs @@ -0,0 +1,246 @@ +// innboks-ingest.test.mjs +// Step 12 (A2): end-to-end-test av pipeline-orkestratoren scripts/innboks-ingest.mjs. +// Kjoeres i mkdtempSync UTENFOR .claude/ med innboksen NESTET under bundle-rota +// (/innboks inni ) saa walk-skippet ekserseres end-to-end. Verifiserer +// (plan.md Step 12, verifies 1-8): +// (1) idempotens: 2 kjoeringer med ULIK veggklokke -> sha256-manifest deepEqual +// (by-construction-determinisme via original-mtime, ikke cache) +// (2) konformitet: okf-check subprosess BAADE default OG --strict-ingest exit 0 +// (3) index-integritet: generateIndexes 2x byte-identisk OG rot-index lister +// ikke innboks/ +// (4) original-bevaring: drop-zone-originalene er byte-uendret (sha256) +// (5) >= 1 relasjon: kryss-dokument tittel-omtale (dok-a -> dok-b) emitteres +// som bundle-root-relativ .md-lenke (SC-regex) +// (6) .txt-only full-pipeline kjoerer groent zero-dep (ingen npm-deps) +// (7) strict-scoping (B2, laast A0): pre-eksisterende kuratert fil med ekstern +// lenke + out-of-vocab-type feller IKKE kjoeringen; kuratert fil uroert +// (8) per-dokument-staging/gate (laast A0): multi-doc-drop der ett dok feiler +// gaten -> KUN det dokumentet discardes (ingen spor i tre/index), det +// andre bestaar +// Binaer-originaler (.docx/.pdf) testes conditional { skip: !engines } -- B1- +// aksept: en ren kjoering med ikke-.md-original passerer strict. +// Moenster: tests/okf-check.test.mjs (subprosess-exit) + innboks-write.test.mjs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, copyFileSync, + existsSync, rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ingestInbox } from '../scripts/innboks-ingest.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const CHECK = join(ROOT, 'scripts', 'okf-check.mjs'); +const SAMPLE = join(ROOT, 'tests', 'fixtures', 'inbox-sample'); + +// Er binaer-konverterings-motorene installert? (npm-deps = dokumentert prerequisite.) +async function enginesAvailable() { + try { + await import('mammoth'); + await import('turndown'); + await import('unpdf'); + return true; + } catch { + return false; + } +} +const engines = await enginesAvailable(); + +// Temp-bundle: bundle-rot = , drop-zone = /innboks (nestet, som prod). +async function withBundle(fn) { + const bundleRoot = mkdtempSync(join(tmpdir(), 'okringest-')); + const inbox = join(bundleRoot, 'innboks'); + mkdirSync(inbox, { recursive: true }); + try { + await fn({ bundleRoot, inbox }); + } finally { + rmSync(bundleRoot, { recursive: true, force: true }); + } +} + +function sha256(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +// Rekursivt sha256-manifest av bundlen: relativ sti -> hash (deterministisk sortert). +function manifest(root) { + const out = {}; + const walk = (dir) => { + for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else out[relative(root, p)] = sha256(readFileSync(p)); + } + }; + walk(root); + return out; +} + +// Alle .md-filer under root (rekursivt), relativ sti. +function mdFiles(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.name.endsWith('.md')) out.push(relative(root, p)); + } + }; + walk(root); + return out; +} + +function runCheck(root, ...extra) { + try { + 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 || ''}` }; + } +} + +function dropTxtFixtures(inbox) { + copyFileSync(join(SAMPLE, 'dok-a.txt'), join(inbox, 'dok-a.txt')); + copyFileSync(join(SAMPLE, 'dok-b.txt'), join(inbox, 'dok-b.txt')); +} + +test('ingest: .txt-only full pipeline zero-dep + konformitet + relasjon + original-bevaring (1,2,4,5,6)', async () => { + await withBundle(async ({ bundleRoot, inbox }) => { + dropTxtFixtures(inbox); + const originalsBefore = { + a: sha256(readFileSync(join(inbox, 'dok-a.txt'))), + b: sha256(readFileSync(join(inbox, 'dok-b.txt'))), + }; + + const run1 = await ingestInbox(inbox, bundleRoot); + assert.equal(run1.failed.length, 0, `ingen feilede dokumenter: ${JSON.stringify(run1.failed)}`); + assert.equal(run1.ingested.length, 2, 'begge .txt-dokumenter ingested'); + const m1 = manifest(bundleRoot); + + // (1) Idempotens: run2 paa ULIK veggklokke -> byte-identisk bundle. + const run2 = await ingestInbox(inbox, bundleRoot); + assert.equal(run2.failed.length, 0, 'run2 feiler ikke'); + assert.deepEqual(manifest(bundleRoot), m1, 'sha256-manifest run1 vs run2 identisk (idempotens by construction)'); + + // (2) Konformitet: default OG strict-ingest exit 0 (subprosess, kontrakt). + assert.equal(runCheck(bundleRoot).status, 0, 'okf-check default exit 0'); + assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'okf-check --strict-ingest exit 0'); + + // (4) Original-bevaring: drop-zonen er byte-uendret. + assert.equal(sha256(readFileSync(join(inbox, 'dok-a.txt'))), originalsBefore.a, 'dok-a.txt uendret'); + assert.equal(sha256(readFileSync(join(inbox, 'dok-b.txt'))), originalsBefore.b, 'dok-b.txt uendret'); + + // (5) >= 1 relasjon: kryss-dokument tittel-omtale emitteres som trygg lenke. + const all = mdFiles(bundleRoot) + .map((f) => readFileSync(join(bundleRoot, f), 'utf8')) + .join('\n'); + assert.match(all, /\]\(\/[^)]*\.md\)/, 'minst en bundle-root-relativ .md-relasjon emittert'); + assert.match(all, /## Relaterte dokumenter/, 'relasjons-seksjon skrevet'); + }); +}); + +test('ingest: index-integritet -- regen byte-identisk + rot-index lister ikke innboks/ (3)', async () => { + await withBundle(async ({ bundleRoot, inbox }) => { + dropTxtFixtures(inbox); + await ingestInbox(inbox, bundleRoot); + + const rootIndex = readFileSync(join(bundleRoot, 'index.md'), 'utf8'); + assert.doesNotMatch(rootIndex, /innboks/, 'rot-index lister ikke drop-zonen'); + + const { generateIndexes } = await import('../scripts/okf-index.mjs'); + const before = manifest(bundleRoot); + generateIndexes(bundleRoot); + assert.deepEqual(manifest(bundleRoot), before, 'generateIndexes re-kjoert er byte-identisk'); + }); +}); + +test('ingest: strict-gaten scopes til kjoeringens filer -- kuratert innhold feller ikke (7)', async () => { + await withBundle(async ({ bundleRoot, inbox }) => { + // Pre-eksisterende kuratert fil: out-of-vocab-type + ekstern lenke er LOVLIG + // paa lese-siden -- den skal aldri felle ingestion (B2). + const curatedPath = join(bundleRoot, 'syklus', 'kuratert.md'); + mkdirSync(dirname(curatedPath), { recursive: true }); + const curated = '---\ntype: Egendefinert\ntitle: Kuratert notat\n---\n' + + 'Se [veilederen](https://www.regjeringen.no/veileder) for detaljer.\n'; + writeFileSync(curatedPath, curated); + + dropTxtFixtures(inbox); + const run = await ingestInbox(inbox, bundleRoot); + assert.equal(run.failed.length, 0, 'kuratert fil feller ikke kjoeringen (scoped strict)'); + assert.equal(run.ingested.length, 2, 'begge dokumenter ingested'); + assert.equal(readFileSync(curatedPath, 'utf8'), curated, 'kuratert fil byte-uroert'); + }); +}); + +test('ingest: per-dokument-staging/gate -- fiendtlig dok discardes alene, uten spor (8)', async () => { + await withBundle(async ({ bundleRoot, inbox }) => { + dropTxtFixtures(inbox); + // Dangling bundle-lenke overlever noeytralisering (trygg FORM, mangler paa + // disk) -> strict-gaten feller dokumentet deterministisk. + writeFileSync( + join(inbox, 'fiendtlig.txt'), + '# Fiendtlig notat\n\nSe [detaljer](/dokumenter/finnes-ikke.md) for mer.\n', + ); + + const run = await ingestInbox(inbox, bundleRoot); + assert.equal(run.failed.length, 1, 'noeyaktig ett dokument discardet'); + assert.match(run.failed[0].source, /fiendtlig/, 'det fiendtlige dokumentet'); + assert.equal(run.ingested.length, 2, 'de to legitime dokumentene bestaar (Map-Reduce-isolasjon)'); + + // Ingen spor: verken konsept-fil, peker-fil eller index-oppfoering. + const rest = mdFiles(bundleRoot).map((f) => `${f}\n${readFileSync(join(bundleRoot, f), 'utf8')}`).join('\n'); + assert.doesNotMatch(rest, /fiendtlig/i, 'ingen spor av discardet dokument i tre/index'); + + // Treet som bestaar er fortsatt konformt (default + strict). + assert.equal(runCheck(bundleRoot).status, 0, 'okf-check default exit 0 etter discard'); + assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'strict exit 0 etter discard'); + }); +}); + +test('ingest: ukjent extension skippes med notice, feller ikke kjoeringen', async () => { + await withBundle(async ({ bundleRoot, inbox }) => { + dropTxtFixtures(inbox); + writeFileSync(join(inbox, 'regneark.xlsx'), 'ikke-stoettet'); + + const notices = []; + const run = await ingestInbox(inbox, bundleRoot, { onNotice: (m) => notices.push(m) }); + assert.equal(run.skipped.length, 1, 'xlsx skippet'); + assert.equal(run.failed.length, 0, 'skip er ikke feil'); + assert.equal(run.ingested.length, 2, 'txt-dokumentene ingested'); + assert.ok(notices.some((n) => n.includes('xlsx')), 'norsk notice om ukjent filtype'); + }); +}); + +test('ingest: binaer-original (.docx/.pdf) passerer strict -- pekerfil uten lenkeform (B1-aksept)', { skip: !engines }, async () => { + await withBundle(async ({ bundleRoot, inbox }) => { + copyFileSync(join(SAMPLE, 'dok.docx'), join(inbox, 'dok.docx')); + copyFileSync(join(SAMPLE, 'dok.pdf'), join(inbox, 'dok.pdf')); + + const run = await ingestInbox(inbox, bundleRoot); + assert.equal(run.failed.length, 0, `binaer-ingest feiler ikke: ${JSON.stringify(run.failed)}`); + assert.equal(run.ingested.length, 2, 'begge binaer-dokumenter ingested'); + + // B1-aksept: hele bundlen passerer strict med ikke-.md-originaler. + assert.equal(runCheck(bundleRoot, '--strict-ingest').status, 0, 'strict exit 0 med .docx/.pdf-originaler'); + + // Pekerfiler finnes og baerer original-stien i resource (uten lenkeform). + const pointers = mdFiles(bundleRoot).filter((f) => f.endsWith('.kilde.md')); + assert.equal(pointers.length, 2, 'en pekerfil per original'); + for (const p of pointers) { + const content = readFileSync(join(bundleRoot, p), 'utf8'); + assert.match(content, /^resource: innboks\/dok[^\n]*$/m, 'resource baerer original-stien'); + assert.doesNotMatch(content, /\]\(/, 'ingen lenkeform i pekerfil (B1)'); + } + + // Idempotens gjelder ogsaa binaer-stien (convert-twice-identical + mtime). + const m1 = manifest(bundleRoot); + await ingestInbox(inbox, bundleRoot); + assert.deepEqual(manifest(bundleRoot), m1, 'binaer-ingest idempotent'); + }); +});