From daec4a149c39c1212d5af504e2ca579e58267b7d Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Thu, 30 Jul 2026 21:39:22 +0200 Subject: [PATCH 1/2] test(inform-matrix): add characterization witness for context-injection matrix Captures renderInform() output across 3 context emitters x 4 harnesses x 3 lifecycle events (32 cells) into a golden fixture, compared with toEqual (never toMatchSnapshot, which --update-snapshots would silently regenerate even in CI). Capture runs in a hermetic child process with a whitelisted env (explicit PATH/HOME/etc, never process.env spread) so the golden is independent of the calling harness/session. This witness lands before any behavior change: on this commit alone (src/ still at HEAD), the characterization test passes, proving the golden is a genuine pre-fix baseline and not a snapshot adjusted to match new code. --- test/fixtures/inform-matrix.golden.json | 34 ++++++ test/helpers/capture-inform-golden.ts | 23 ++++ test/helpers/capture-run.ts | 19 ++++ test/helpers/inform-matrix-env.ts | 110 ++++++++++++++++++++ test/helpers/inform-matrix-run.ts | 33 ++++++ test/helpers/inform-matrix.ts | 70 +++++++++++++ test/inform-matrix.characterization.test.ts | 25 +++++ 7 files changed, 314 insertions(+) create mode 100644 test/fixtures/inform-matrix.golden.json create mode 100644 test/helpers/capture-inform-golden.ts create mode 100644 test/helpers/capture-run.ts create mode 100644 test/helpers/inform-matrix-env.ts create mode 100644 test/helpers/inform-matrix-run.ts create mode 100644 test/helpers/inform-matrix.ts create mode 100644 test/inform-matrix.characterization.test.ts diff --git a/test/fixtures/inform-matrix.golden.json b/test/fixtures/inform-matrix.golden.json new file mode 100644 index 0000000..f0413f5 --- /dev/null +++ b/test/fixtures/inform-matrix.golden.json @@ -0,0 +1,34 @@ +{ + "injectRules|claude-code|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|claude-code|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|claude-code|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|codex|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|codex|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|codex|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|gemini-cli|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|gemini-cli|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|gemini-cli|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# 00 alpha\\nalpha body\\n\\n\\n# 01 beta\\nbeta body\\n\"},\"systemMessage\":\"rules 00-08 injected\"}", + "injectRules|kimi|SessionStart|-": "# 00 alpha\nalpha body\n\n\n# 01 beta\nbeta body\n\n\nrules 00-08 injected", + "injectRules|kimi|SubagentStart|-": "# 00 alpha\nalpha body\n\n\n# 01 beta\nbeta body\n\n\nrules 00-08 injected", + "injectRules|kimi|UserPromptSubmit|-": "# 00 alpha\nalpha body\n\n\n# 01 beta\nbeta body\n\n\nrules 00-08 injected", + "lessons|claude-code|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|claude-code|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|claude-code|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|codex|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|codex|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|codex|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|gemini-cli|SessionStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|gemini-cli|SubagentStart|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"SubagentStart\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|gemini-cli|UserPromptSubmit|-": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"Project lessons — never reproduce these:\\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\"},\"systemMessage\":\"lessons injected\"}", + "lessons|kimi|SessionStart|-": "Project lessons — never reproduce these:\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\n\nlessons injected", + "lessons|kimi|SubagentStart|-": "Project lessons — never reproduce these:\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\n\nlessons injected", + "lessons|kimi|UserPromptSubmit|-": "Project lessons — never reproduce these:\n- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\nYou may append OR refine/merge/dedupe bullets in MEMORY/LESSON.md — keep it terse.\n\nlessons injected", + "promptSubmit|claude-code|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.claude/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use TaskCreate to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .claude/apex/task.json to check documentation status before writing code.\\n\\n# CLAUDE.md\\n# fixture CLAUDE.md for .claude\\nBODY .claude\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}", + "promptSubmit|claude-code|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# CLAUDE.md\\n# fixture CLAUDE.md for .claude\\nBODY .claude\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}", + "promptSubmit|codex|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.codex/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use update_plan to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .codex/apex/task.json to check documentation status before writing code.\\n\\n# AGENTS.md\\n# fixture AGENTS.md for .codex\\nBODY .codex\\n\"},\"systemMessage\":\"AGENTS.md injected\"}", + "promptSubmit|codex|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# AGENTS.md\\n# fixture AGENTS.md for .codex\\nBODY .codex\\n\"},\"systemMessage\":\"AGENTS.md injected\"}", + "promptSubmit|gemini-cli|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.gemini/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use TaskCreate to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .gemini/apex/task.json to check documentation status before writing code.\\n\\n# CLAUDE.md\\n# fixture CLAUDE.md for .gemini\\nBODY .gemini\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}", + "promptSubmit|gemini-cli|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# CLAUDE.md\\n# fixture CLAUDE.md for .gemini\\nBODY .gemini\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}", + "promptSubmit|kimi|UserPromptSubmit|dev": "INSTRUCTION: This is a development task. Use APEX methodology:\n\n**TRACKING FILE**: [project]/.kimi-code/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\n\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\n - explore-codebase + research-expert + general-purpose (framework expertise)\n - Project type detected: generic\n\n2. **PLAN**: Use TodoList to break down tasks (<100 lines per file)\n\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\n\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\n\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\n\n6. **eXAMINE**: Run sniper agent after ANY modification\n\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\n\n**IMPORTANT**: Read .kimi-code/apex/task.json to check documentation status before writing code.\n\n# AGENTS.md\n# fixture AGENTS.md for .kimi-code\nBODY .kimi-code\n\n\nAGENTS.md injected", + "promptSubmit|kimi|UserPromptSubmit|plain": "# AGENTS.md\n# fixture AGENTS.md for .kimi-code\nBODY .kimi-code\n\n\nAGENTS.md injected" +} diff --git a/test/helpers/capture-inform-golden.ts b/test/helpers/capture-inform-golden.ts new file mode 100644 index 0000000..2bfb17e --- /dev/null +++ b/test/helpers/capture-inform-golden.ts @@ -0,0 +1,23 @@ +/** + * @module test/helpers/capture-inform-golden + * Manual, one-shot script: run `bun run test/helpers/capture-inform-golden.ts` + * to (re)write `test/fixtures/inform-matrix.golden.json` from the current + * `src/` behavior. + * + * NEVER re-run this in the same commit as a behavior change to `renderInform` + * (or anything it calls transitively). The golden is the WITNESS that the + * pre-change code respected the zero-regression theorem's hypotheses — it + * must be captured and committed BEFORE the change, as its own commit. + * Regenerating it alongside the change makes the proof and the change + * indistinguishable on review: a diff that "updates the golden" could just as + * easily be hiding a real regression as reflecting an intentional new branch. + */ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runMatrix } from "./inform-matrix-run"; + +const outDir = join(import.meta.dir, "..", "fixtures"); +const outFile = join(outDir, "inform-matrix.golden.json"); +mkdirSync(outDir, { recursive: true }); +writeFileSync(outFile, `${JSON.stringify(runMatrix(), null, 2)}\n`); +console.log(`wrote ${outFile}`); diff --git a/test/helpers/capture-run.ts b/test/helpers/capture-run.ts new file mode 100644 index 0000000..98b0776 --- /dev/null +++ b/test/helpers/capture-run.ts @@ -0,0 +1,19 @@ +/** + * @module test/helpers/capture-run + * Entry point of the CAPTURE CHILD PROCESS. Runs isolated from the parent + * because Bun snapshots `$HOME` at process start — mutating + * `process.env.HOME` in the parent process would NOT change what + * `os.homedir()` returns there (measured on Bun 1.3.14). Spawning a fresh + * process with `HOME` set in its own env is the only way to pin it. + * Reads the fixture root from `MATRIX_ROOT` (never `process.argv`, since the + * parent passes it via the child's env allowlist — see `childEnv`). + */ +import { pathsFor } from "./inform-matrix-env"; +import { captureMatrix } from "./inform-matrix"; + +const root = process.env.MATRIX_ROOT; +if (!root) { + console.error("capture-run: MATRIX_ROOT env var is required"); + process.exit(2); +} +process.stdout.write(JSON.stringify(captureMatrix(pathsFor(root)))); diff --git a/test/helpers/inform-matrix-env.ts b/test/helpers/inform-matrix-env.ts new file mode 100644 index 0000000..f70f03e --- /dev/null +++ b/test/helpers/inform-matrix-env.ts @@ -0,0 +1,110 @@ +/** + * @module test/helpers/inform-matrix-env + * Hermetic fixture tree for the zero-regression golden capture. + * + * The emitters under capture read AMBIENT state: `$HOME` (root instructions doc, + * the marketplace scan, the dedup sidecar), the project dir (`MEMORY/LESSON.md`, + * project-type detection), `process.env` (harness detection, SOLID ceiling), and + * a clock. Left ambient, the golden would encode THIS machine on THIS day and + * prove nothing. This module builds the fixtures; the capture itself runs in a + * CHILD process (see `inform-matrix-run`) because Bun snapshots `$HOME` at + * process start — `os.homedir()` ignores a later `process.env.HOME` mutation + * (measured on Bun 1.3.14), so in-process pinning silently reads the real home. + */ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Pinned clock for every emitter that takes one (lessons curation). */ +export const NOW = 1_700_000_000_000; + +/** + * Home segment → root instructions doc, mirroring `harnessHomeSegment` / + * `apexDocName`. Bodies differ per segment on purpose: `claude-code` and + * `gemini-cli` both read `CLAUDE.md`, and identical bodies would collide on the + * `oncePerWindow` content hash — blanking one cell of the golden in silence. + */ +const HARNESS_DOCS: ReadonlyArray = [ + [".claude", "CLAUDE.md"], + [".codex", "AGENTS.md"], + [".gemini", "CLAUDE.md"], + [".kimi-code", "AGENTS.md"], +]; + +/** Fixture lesson: one dated bullet, stable under curation at {@link NOW}. */ +const LESSON = "- [2026-01-01 10:00] Fixture lesson body. → Fixture rule. [TRIGGERS keyword:fixture]\n"; + +/** + * `package.json` marker. NOT cosmetic: `projectRoot()` walks up for `.git` then + * `package.json` and falls back to `process.cwd()` — without this marker the + * lessons emitter would read AND REWRITE this repo's own `MEMORY/LESSON.md`. + * Its content must match no framework probe, so detection stays "generic". + */ +const PKG = '{"name":"golden-fixture","version":"0.0.0"}\n'; + +/** Absolute paths of one fixture tree, shared by the parent and the capture child. */ +export interface MatrixPaths { + /** Temp root holding `home/`, `plugin/` and the project dirs. */ + root: string; + /** `$HOME` of the capture child. */ + home: string; + /** Plugin root passed to `injectRules` (holds `rules/*.md`). */ + pluginRoot: string; + /** Stable project dir, for the emitters that only READ it. */ + project: string; +} + +/** Derive every fixture path from the root, so parent and child never disagree. */ +export function pathsFor(root: string): MatrixPaths { + return { + root, + home: join(root, "home"), + pluginRoot: join(root, "plugin"), + project: join(root, "project-stable"), + }; +} + +/** + * Create a project dir carrying the markers `projectRoot` + detection need. + * Lessons curation REWRITES `LESSON.md`, so each lessons cell gets its OWN dir — + * a shared one would make cell N depend on cell N-1. + * @param root - Fixture root. + * @param tag - Unique suffix for this project dir. + * @returns The created project dir. + */ +export function makeProject(root: string, tag: string): string { + const dir = join(root, `project-${tag}`); + mkdirSync(join(dir, "MEMORY"), { recursive: true }); + writeFileSync(join(dir, "package.json"), PKG); + writeFileSync(join(dir, "MEMORY", "LESSON.md"), LESSON); + return dir; +} + +/** + * Build the fixture tree under a fresh temp root. + * @returns The fixture paths. + */ +export function buildFixtures(): MatrixPaths { + const paths = pathsFor(mkdtempSync(join(tmpdir(), "inform-golden-"))); + for (const [seg, doc] of HARNESS_DOCS) { + mkdirSync(join(paths.home, seg), { recursive: true }); + writeFileSync(join(paths.home, seg, doc), `# fixture ${doc} for ${seg}\nBODY ${seg}\n`); + } + mkdirSync(join(paths.pluginRoot, "rules"), { recursive: true }); + writeFileSync(join(paths.pluginRoot, "rules", "00-alpha.md"), "# 00 alpha\nalpha body\n"); + writeFileSync(join(paths.pluginRoot, "rules", "01-beta.md"), "# 01 beta\nbeta body\n"); + makeProject(paths.root, "stable"); + return paths; +} + +/** + * Environment of the capture child — an ALLOWLIST, never an inherit. Inheriting + * would leak `CLAUDECODE` (or any of the 15 other harness markers), making + * `detectHarness()` answer "claude-code" here and "unknown" in CI, plus every + * ambient `FUSE_*` that moves the SOLID ceiling. + * @param paths - The fixture tree. + * @returns The exact env handed to the child. + */ +export function childEnv(paths: MatrixPaths): Record { + return { PATH: process.env.PATH ?? "", HOME: paths.home, MATRIX_ROOT: paths.root }; +} diff --git a/test/helpers/inform-matrix-run.ts b/test/helpers/inform-matrix-run.ts new file mode 100644 index 0000000..f4fb0ed --- /dev/null +++ b/test/helpers/inform-matrix-run.ts @@ -0,0 +1,33 @@ +/** + * @module test/helpers/inform-matrix-run + * Parent-side driver: builds the fixtures, spawns the capture child + * (`capture-run.ts`) with an allowlisted env, parses its stdout, validates + * portability, and cleans up. Never mutates the PARENT process's own + * `process.env` — `bun test` runs every file sequentially in one shared + * process, so a parent-side mutation would leak into the other ~180 test + * files in the suite. + */ +import { spawnSync } from "node:child_process"; +import { rmSync } from "node:fs"; +import { join } from "node:path"; +import { buildFixtures, childEnv } from "./inform-matrix-env"; +import { assertPortable } from "./inform-matrix"; + +/** + * Run the full capture in an isolated child process and return the golden. + * @returns The 32-cell golden map. + */ +export function runMatrix(): Record { + const paths = buildFixtures(); + const r = spawnSync("bun", ["run", join(import.meta.dir, "capture-run.ts")], { + env: childEnv(paths), + encoding: "utf8", + }); + if (r.status !== 0) { + throw new Error(`capture-run.ts exited ${String(r.status)}: ${r.stderr}`); + } + const cells = JSON.parse(r.stdout) as Record; + assertPortable(cells, paths.root); + rmSync(paths.root, { recursive: true, force: true }); + return cells; +} diff --git a/test/helpers/inform-matrix.ts b/test/helpers/inform-matrix.ts new file mode 100644 index 0000000..698fca3 --- /dev/null +++ b/test/helpers/inform-matrix.ts @@ -0,0 +1,70 @@ +/** + * @module test/helpers/inform-matrix + * Builds the full 32-cell golden matrix: (id × event × emitter) triples, + * captured DETERMINISTICALLY inside the hermetic fixture tree from + * `inform-matrix-env`. Runs inside the capture child (see `capture-run`) so + * every ambient read resolves against the fixtures, never the real machine. + */ +import { injectRules } from "../../src/runtime/lifecycle/inject-rules"; +import { promptSubmitContext } from "../../src/runtime/inject-context"; +import { dispatchLessons } from "../../src/runtime/lifecycle/lessons/dispatch"; +import { NOW, makeProject } from "./inform-matrix-env"; +import type { MatrixPaths } from "./inform-matrix-env"; + +/** Harness targets covered by the matrix. */ +export const IDS = ["claude-code", "codex", "gemini-cli", "kimi"] as const; + +/** Hook events covered by the matrix. */ +export const EVENTS = ["SessionStart", "UserPromptSubmit", "SubagentStart"] as const; + +/** + * Prompts for `promptSubmitContext`: "dev" matches both `DEV_VERBS` + * (`src/policy/claude-md-context.ts`) and `DEV_KEYWORDS` + * (`src/policy/detect-project.ts`), so it exercises the APEX preamble path. + * "plain" matches NEITHER regex (verified against both patterns before + * writing this fixture) — it exercises the plain CLAUDE.md-only path. + */ +const PROMPTS = [ + ["dev", "implement the parser"], + ["plain", "quelle heure est-il"], +] as const; + +/** + * Capture every (id × event) cell for the three emitters under test, plus the + * two prompt-kind variants of `promptSubmitContext`. Iteration is purely + * synchronous — order comes from the loop structure, never from scheduling — + * and the final map is key-sorted so the golden JSON is diffable line by line + * regardless of iteration order. + * @param paths - The hermetic fixture tree (see {@link buildFixtures}). + * @returns The 32-cell golden map, keyed `emitter|id|event|extra`. + */ +export function captureMatrix(paths: MatrixPaths): Record { + const cells: Array<[string, string]> = []; + for (const id of IDS) { + for (const event of EVENTS) { + cells.push([`injectRules|${id}|${event}|-`, injectRules(paths.pluginRoot, event, id)]); + const lessonsDir = makeProject(paths.root, `${id}-${event}`); + cells.push([`lessons|${id}|${event}|-`, dispatchLessons(event, {}, lessonsDir, NOW, id)]); + if (event === "UserPromptSubmit") { + for (const [kind, prompt] of PROMPTS) { + cells.push([`promptSubmit|${id}|UserPromptSubmit|${kind}`, promptSubmitContext(prompt, paths.project, id)]); + } + } + } + } + cells.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return Object.fromEntries(cells); +} + +/** + * Guard against a non-portable golden: a captured value that embeds the + * fixture's own temp root would tie the golden to this run's random path and + * break on every future capture. + * @param cells - The captured golden map. + * @param root - The fixture root whose path must never leak into a value. + */ +export function assertPortable(cells: Record, root: string): void { + for (const [key, value] of Object.entries(cells)) { + if (value.includes(root)) throw new Error(`non-portable golden cell "${key}" embeds fixture root ${root}`); + } +} diff --git a/test/inform-matrix.characterization.test.ts b/test/inform-matrix.characterization.test.ts new file mode 100644 index 0000000..ed00d8e --- /dev/null +++ b/test/inform-matrix.characterization.test.ts @@ -0,0 +1,25 @@ +/** + * @module test/inform-matrix.characterization + * Zero-regression golden for `renderInform` and its three call sites + * (`injectRules`, `dispatchLessons`, `promptSubmitContext`) across every + * (harness id × hook event) combination. + * + * A diff on ANY cell other than `kimi × UserPromptSubmit` IS the regression: + * the theorem this golden witnesses is `f'(x) = g(x) if P(x), f(x) otherwise` + * ⟹ `∀x. ¬P(x) ⟹ f'(x) = f(x)` — every non-kimi/non-UserPromptSubmit cell is + * an `x` where `¬P(x)` holds, so it MUST stay byte-identical. The golden is + * never updated to make new code pass; see `capture-inform-golden.ts` for why. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { runMatrix } from "./helpers/inform-matrix-run"; + +const goldenPath = join(import.meta.dir, "fixtures", "inform-matrix.golden.json"); + +describe("renderInform zero-regression matrix", () => { + test("every (id x event x emitter) cell matches the captured golden", () => { + const golden = JSON.parse(readFileSync(goldenPath, "utf8")) as Record; + expect(runMatrix()).toEqual(golden); + }); +}); From 01d2556b878e7a2624398ede1d006ac6cc391e19 Mon Sep 17 00:00:00 2001 From: Bruno Azoulay Date: Thu, 30 Jul 2026 21:40:12 +0200 Subject: [PATCH 2/2] fix(inject-context): stop dumping rules corpus and root AGENTS.md to Kimi terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi Code CLI has no model-only output channel: a hook's stdout is delivered to both the model AND the terminal. Before this fix, promptSubmit injection for the kimi harness pushed the full rules corpus plus the project root AGENTS.md through that shared channel on every prompt, spilling it into the user's terminal. The rules corpus is already loaded natively by Kimi from /AGENTS.md, so inject-rules.ts now skips re-injecting it for kimi at UserPromptSubmit specifically, via the new kimi-rules-native helper. SessionStart and SubagentStart are untouched and still receive the full corpus, since those events don't have a native-load equivalent. Fail-safe preserved: if the native AGENTS.md fences are absent for any reason, the full corpus is injected as before — never a silent loss of rules, only a redundant re-injection avoided when we know Kimi already has it. --- src/runtime/inject-context.ts | 31 +++++++- src/runtime/lifecycle/inject-rules.ts | 12 ++- src/runtime/lifecycle/kimi-rules-native.ts | 44 +++++++++++ test/fixtures/inform-matrix.golden.json | 4 +- test/kimi-notice-only.test.ts | 92 ++++++++++++++++++++++ 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/runtime/lifecycle/kimi-rules-native.ts create mode 100644 test/kimi-notice-only.test.ts diff --git a/src/runtime/inject-context.ts b/src/runtime/inject-context.ts index f78d0d6..4709579 100644 --- a/src/runtime/inject-context.ts +++ b/src/runtime/inject-context.ts @@ -22,6 +22,23 @@ export function claudeMdKey(prompt: string, ctx: string): string { return `claude-md:${hashText(prompt)}:${hashText(ctx)}`; } +/** + * Extract the APEX preamble portion of a `buildClaudeMdContext` result, i.e. + * everything BEFORE the `# \n` root-doc block it prepends on dev + * prompts. Returns "" when `ctx` has no preamble (plain prompt: `ctx` starts + * directly with the root-doc heading). + * @param ctx - The full `buildClaudeMdContext` return value. + * @param docName - Root doc file name (e.g. "AGENTS.md"). + * @returns The preamble text, or "" when absent. + */ +function extractApexPreamble(ctx: string, docName: string): string { + const heading = `# ${docName}\n`; + if (ctx.startsWith(heading)) return ""; + const sep = `\n\n${heading}`; + const idx = ctx.indexOf(sep); + return idx === -1 ? "" : ctx.slice(0, idx); +} + /** * UserPromptSubmit context injection: render the CLAUDE.md (+ optional APEX) * preamble as a Claude `additionalContext` response, or "" when nothing to emit. @@ -29,6 +46,13 @@ export function claudeMdKey(prompt: string, ctx: string): string { * near-simultaneous double-fire of the SAME turn (identical prompt AND identical * block, within {@link DEDUP_WINDOW_MS}) is suppressed. The invariant "CLAUDE.md * is emitted on EVERY message" is thus preserved. + * + * On kimi specifically, the root doc body is dropped from the emitted text: + * Kimi loads `/AGENTS.md` natively at session start (`apex-target.ts`), + * so re-injecting its full body on every prompt is redundant terminal noise + * (kimi has no model-only hook channel — `runtime/inform.ts`). The APEX + * preamble, which is NOT part of `AGENTS.md`, is still emitted in full when the + * prompt is dev-shaped; a plain prompt leaves only the notice. * @param prompt - The raw user prompt. * @param cwd - Project root (for project-type detection). * @param id - Harness target id (defaults to "claude-code" — zero-regression default). @@ -38,7 +62,12 @@ export function promptSubmitContext(prompt: string, cwd: string, id: string = "c const ctx = buildClaudeMdContext(prompt, cwd, id); if (!ctx) return ""; if (!oncePerWindow(claudeMdKey(prompt, ctx), DEDUP_WINDOW_MS)) return ""; - return renderInform(id, "UserPromptSubmit", ctx, `${apexDocName(id)} injected`); + const notice = `${apexDocName(id)} injected`; + if (id === "kimi") { + const preamble = extractApexPreamble(ctx, apexDocName(id)); + return preamble ? renderInform(id, "UserPromptSubmit", preamble, notice) : notice; + } + return renderInform(id, "UserPromptSubmit", ctx, notice); } /** diff --git a/src/runtime/lifecycle/inject-rules.ts b/src/runtime/lifecycle/inject-rules.ts index da6af27..9f3d882 100644 --- a/src/runtime/lifecycle/inject-rules.ts +++ b/src/runtime/lifecycle/inject-rules.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { renderInform } from "../inform"; +import { rulesInAgentsMd } from "./kimi-rules-native"; /** Read & concatenate all `*.md` files (sorted) under `rulesDir`. */ export function readRules(rulesDir: string): string { @@ -29,6 +30,13 @@ export function readRules(rulesDir: string): string { * with the *actual* `hookEventName` — the spec requires it to match the firing * event (a hardcoded "SessionStart" is non-conforming and may be dropped on * UserPromptSubmit/SubagentStart). + * + * On kimi's `UserPromptSubmit` specifically, when the corpus is already fenced + * inside `/AGENTS.md` (native load, {@link rulesInAgentsMd}), only the + * notice is emitted — the re-injected corpus would otherwise dump into the + * user's terminal on every single prompt for no model-context benefit. + * `SessionStart`/`SubagentStart` always keep the full corpus (session bootstrap + * / sub-agent contexts need it regardless of the native AGENTS.md load). * @param pluginRoot - `CLAUDE_PLUGIN_ROOT` of the claude-rules plugin. * @param event - The firing hook event name (e.g. "SessionStart"). * @param id - Harness target id (defaults to "claude-code" — zero-regression default). @@ -36,5 +44,7 @@ export function readRules(rulesDir: string): string { */ export function injectRules(pluginRoot: string, event: string, id: string = "claude-code"): string { const content = readRules(join(pluginRoot, "rules")); - return content ? renderInform(id, event, content, "rules 00-08 injected") : ""; + if (!content) return ""; + if (id === "kimi" && event === "UserPromptSubmit" && rulesInAgentsMd()) return "rules 00-08 injected"; + return renderInform(id, event, content, "rules 00-08 injected"); } diff --git a/src/runtime/lifecycle/kimi-rules-native.ts b/src/runtime/lifecycle/kimi-rules-native.ts new file mode 100644 index 0000000..7844717 --- /dev/null +++ b/src/runtime/lifecycle/kimi-rules-native.ts @@ -0,0 +1,44 @@ +/** + * Guard for the kimi notice-only rules injection. Kimi Code CLI has no + * model-only hook channel (`src/runtime/inform.ts`) — every `UserPromptSubmit` + * stdout is BOTH appended to the model's context AND rendered raw in the + * user's terminal. `injectRules` (`inject-rules.ts`) normally re-injects the + * full ~18 Ko rules corpus on every prompt; when the installer has already + * merged that same corpus into `/AGENTS.md` — which Kimi loads + * natively at session start via its documented `${agents_md}` mechanism, + * independent of hooks — the re-injection is pure noise the user has to + * scroll past. Confirmed present, fenced, in the local install: + * `~/.kimi-code/AGENTS.md:87` / `:335`. + * + * Fail-safe semantics: fences absent, file absent, or any read error ⟹ + * `false` — the caller then falls back to the full corpus. A dump is + * verbose but never wrong; a false `true` would silently drop the rules. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { harnessHome } from "../../config/home-dir"; + +/** Opening fence the fusengine installer writes around the merged corpus. */ +const FENCE_START = ""; + +/** Closing fence the fusengine installer writes around the merged corpus. */ +const FENCE_END = ""; + +/** + * Check whether the kimi rules corpus is already present, fenced, inside + * `/AGENTS.md`. + * @param home - Kimi home dir override (defaults to `harnessHome("kimi")`, + * which honors `KIMI_CODE_HOME`). + * @returns `true` only when both fences are found in a readable file. + */ +export function rulesInAgentsMd(home?: string): boolean { + try { + const kimiHome = home ?? harnessHome("kimi"); + const agentsMd = join(kimiHome, "AGENTS.md"); + if (!existsSync(agentsMd)) return false; + const content = readFileSync(agentsMd, "utf-8"); + return content.includes(FENCE_START) && content.includes(FENCE_END); + } catch { + return false; + } +} diff --git a/test/fixtures/inform-matrix.golden.json b/test/fixtures/inform-matrix.golden.json index f0413f5..82d3bda 100644 --- a/test/fixtures/inform-matrix.golden.json +++ b/test/fixtures/inform-matrix.golden.json @@ -29,6 +29,6 @@ "promptSubmit|codex|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# AGENTS.md\\n# fixture AGENTS.md for .codex\\nBODY .codex\\n\"},\"systemMessage\":\"AGENTS.md injected\"}", "promptSubmit|gemini-cli|UserPromptSubmit|dev": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"INSTRUCTION: This is a development task. Use APEX methodology:\\n\\n**TRACKING FILE**: [project]/.gemini/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\\n\\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\\n - explore-codebase + research-expert + general-purpose (framework expertise)\\n - Project type detected: generic\\n\\n2. **PLAN**: Use TaskCreate to break down tasks (<100 lines per file)\\n\\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\\n\\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\\n\\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\\n\\n6. **eXAMINE**: Run sniper agent after ANY modification\\n\\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\\n\\n**IMPORTANT**: Read .gemini/apex/task.json to check documentation status before writing code.\\n\\n# CLAUDE.md\\n# fixture CLAUDE.md for .gemini\\nBODY .gemini\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}", "promptSubmit|gemini-cli|UserPromptSubmit|plain": "{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"# CLAUDE.md\\n# fixture CLAUDE.md for .gemini\\nBODY .gemini\\n\"},\"systemMessage\":\"CLAUDE.md injected\"}", - "promptSubmit|kimi|UserPromptSubmit|dev": "INSTRUCTION: This is a development task. Use APEX methodology:\n\n**TRACKING FILE**: [project]/.kimi-code/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\n\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\n - explore-codebase + research-expert + general-purpose (framework expertise)\n - Project type detected: generic\n\n2. **PLAN**: Use TodoList to break down tasks (<100 lines per file)\n\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\n\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\n\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\n\n6. **eXAMINE**: Run sniper agent after ANY modification\n\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\n\n**IMPORTANT**: Read .kimi-code/apex/task.json to check documentation status before writing code.\n\n# AGENTS.md\n# fixture AGENTS.md for .kimi-code\nBODY .kimi-code\n\n\nAGENTS.md injected", - "promptSubmit|kimi|UserPromptSubmit|plain": "# AGENTS.md\n# fixture AGENTS.md for .kimi-code\nBODY .kimi-code\n\n\nAGENTS.md injected" + "promptSubmit|kimi|UserPromptSubmit|dev": "INSTRUCTION: This is a development task. Use APEX methodology:\n\n**TRACKING FILE**: [project]/.kimi-code/apex/task.json — create it yourself via apex-methodology Step 0 (init-tracking) if missing\n\n1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):\n - explore-codebase + research-expert + general-purpose (framework expertise)\n - Project type detected: generic\n\n2. **PLAN**: Use TodoList to break down tasks (<100 lines per file)\n\n3. **EXECUTE**: general-purpose, follow SOLID principles, split at 90 lines\n\n4. **eLICIT**: self-review with NAMED elicitation techniques (apex ref 03.5-elicit) — fix findings BEFORE validation\n\n5. **VERIFY**: functional check — run it, confirm references⇔declarations consistency\n\n6. **eXAMINE**: Run sniper agent after ANY modification\n\n**GATE**: eLicit + Verify BEFORE sniper — NEVER skip.\n\n**IMPORTANT**: Read .kimi-code/apex/task.json to check documentation status before writing code.\n\nAGENTS.md injected", + "promptSubmit|kimi|UserPromptSubmit|plain": "AGENTS.md injected" } diff --git a/test/kimi-notice-only.test.ts b/test/kimi-notice-only.test.ts new file mode 100644 index 0000000..aee4802 --- /dev/null +++ b/test/kimi-notice-only.test.ts @@ -0,0 +1,92 @@ +/** + * @module test/kimi-notice-only + * Dedicated unit tests for the kimi notice-only injection (theorem + * `f'(x) = g(x) if P(x), f(x) otherwise`, `P(x) ≡ id="kimi" ∧ + * event="UserPromptSubmit"`). The golden matrix + * (`inform-matrix.characterization.test.ts`) proves the fail-safe default + * (fences absent in its fixture); these tests exercise the fenced-present + * branch directly, plus the SessionStart/SubagentStart/claude-code + * non-regression cases. + */ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rulesInAgentsMd } from "../src/runtime/lifecycle/kimi-rules-native"; +import { injectRules } from "../src/runtime/lifecycle/inject-rules"; + +const FENCED = "\nbody\n\n"; + +/** Build a fixture kimi home dir, optionally with a fenced/unfenced AGENTS.md. */ +function kimiHome(root: string, agentsMd?: string): string { + const home = mkdtempSync(join(root, "kimi-home-")); + if (agentsMd !== undefined) writeFileSync(join(home, "AGENTS.md"), agentsMd); + return home; +} + +/** Fixture plugin root carrying one rules file, for `injectRules`. */ +function pluginRoot(root: string): string { + const dir = mkdtempSync(join(root, "plugin-")); + mkdirSync(join(dir, "rules"), { recursive: true }); + writeFileSync(join(dir, "rules", "00.md"), "# rules\nbody\n"); + return dir; +} + +describe("rulesInAgentsMd", () => { + const root = mkdtempSync(join(tmpdir(), "kimi-notice-only-")); + + test("fences present -> true", () => { + expect(rulesInAgentsMd(kimiHome(root, FENCED))).toBe(true); + }); + + test("fences absent -> false", () => { + expect(rulesInAgentsMd(kimiHome(root, "# AGENTS.md\nplain body\n"))).toBe(false); + }); + + test("AGENTS.md absent -> false", () => { + expect(rulesInAgentsMd(kimiHome(root))).toBe(false); + }); + + afterAll(() => rmSync(root, { recursive: true, force: true })); +}); + +describe("injectRules: kimi notice-only gate", () => { + const root = mkdtempSync(join(tmpdir(), "kimi-notice-only-inject-")); + const prevKimiHome = process.env.KIMI_CODE_HOME; + + afterEach(() => { + if (prevKimiHome === undefined) delete process.env.KIMI_CODE_HOME; + else process.env.KIMI_CODE_HOME = prevKimiHome; + }); + + test("kimi UserPromptSubmit, fences present -> notice only", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + expect(injectRules(pluginRoot(root), "UserPromptSubmit", "kimi")).toBe("rules 00-08 injected"); + }); + + test("kimi UserPromptSubmit, fences absent -> full corpus", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, "# AGENTS.md\nplain\n"); + const out = injectRules(pluginRoot(root), "UserPromptSubmit", "kimi"); + expect(out).toContain("# rules\nbody"); + expect(out).toContain("rules 00-08 injected"); + }); + + test("kimi SessionStart, fences present -> full corpus (never gated)", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + expect(injectRules(pluginRoot(root), "SessionStart", "kimi")).toContain("# rules\nbody"); + }); + + test("kimi SubagentStart, fences present -> full corpus (never gated)", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + expect(injectRules(pluginRoot(root), "SubagentStart", "kimi")).toContain("# rules\nbody"); + }); + + test("claude-code UserPromptSubmit is unaffected by the kimi gate", () => { + process.env.KIMI_CODE_HOME = kimiHome(root, FENCED); + const out = injectRules(pluginRoot(root), "UserPromptSubmit", "claude-code"); + expect(out).toContain("additionalContext"); + expect(out).toContain("# rules\\nbody"); + }); + + afterAll(() => rmSync(root, { recursive: true, force: true })); +});