Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/hooks/anatomy-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,18 @@ export function parseAnatomy(content: string): Map<string, AnatomyEntry[]> {
continue;
}
if (!currentSection) continue;
const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/);
// Tolerant on purpose. anatomy.md is re-rendered from what this parses, and
// importFromMarkdown() reads hand-edits through it, so an entry this regex cannot
// match is invisible: the human's wording is ignored and the line disappears on the
// next render. Requiring BOTH an em-dash separator AND a trailing "(~N tok)" meant a
// plain hyphen, or an entry written without a token estimate, was silently dropped.
// Accept any of — – - : as the separator and treat the token estimate as optional.
const em = line.match(/^- `([^`]+)`\s*(?:[—–\-:]\s*(.*?))?\s*(?:\(~(\d+)\s*tok\))?\s*$/);
if (em) {
sections.get(currentSection)!.push({
file: em[1],
description: em[2] || "",
tokens: parseInt(em[3], 10),
description: (em[2] || "").trim(),
tokens: em[3] ? parseInt(em[3], 10) : 0,
});
}
}
Expand Down
17 changes: 14 additions & 3 deletions src/hooks/post-write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import * as crypto from "node:crypto";
import {
getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown,
extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath,
isSensitiveFile, getProjectDir
isSensitiveFile, getProjectDir,
realPath,
} from "./shared.js";
import { loadStoreReconciled, saveStore, renderToFile, sha256 } from "./anatomy-store.js";
import { withAnatomyLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
Expand Down Expand Up @@ -68,7 +69,17 @@ async function main(): Promise<void> {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);

// Skip processing for .wolf/ internal files to avoid slow self-referential updates
const relPath = normalizePath(path.relative(projectRoot, absolutePath));
// Resolve symlinks/junctions FIRST, or the outside-root guard below can be bypassed.
// On Windows a directory junction can expose the same tree under a second drive letter
// (e.g. C:\project -> D:\project). Reached through the junction, path.relative() cannot
// produce a relative path across drives and returns an ABSOLUTE one, which does not start
// with ".." — so the guard misses it and an absolute "C:/..." section key is written into
// anatomy.md, the very churn that guard exists to prevent. Resolving first makes both
// spellings agree. Resolution is also what stops one physical directory being indexed
// under several keys, which additionally defeats pre-read's section lookup.
const realAbsolute = realPath(absolutePath);
const realRoot = realPath(projectRoot);
const relPath = normalizePath(path.relative(realRoot, realAbsolute));
if (relPath.startsWith(".wolf/")) { process.exit(0); return; }

// Never track files outside the project root (e.g. the Claude Code scratchpad under
Expand All @@ -88,7 +99,7 @@ async function main(): Promise<void> {
// All of this happens under the anatomy lock; if the lock cannot be
// acquired within budget we skip — a later writer converges the state.
try {
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath));
const relPathLocal = normalizePath(path.relative(realRoot, realAbsolute));

let fileContent = "";
try {
Expand Down
16 changes: 16 additions & 0 deletions src/hooks/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,22 @@ export function readStdin(): Promise<string> {
});
}

/**
* Resolve symlinks and junctions so one physical path always has one identity.
*
* Needed before any path is turned into an anatomy section key or compared against the
* project root: on Windows a directory junction can expose the same tree under a second
* drive letter, and path.relative() cannot relativise across drives, so it silently returns
* an ABSOLUTE path instead. Falls back to the input when the path does not exist yet.
*/
export function realPath(p: string): string {
try {
return fs.realpathSync.native(p);
} catch {
return p;
}
}

export function normalizePath(p: string): string {
return p.replace(/\\/g, "/");
}
Expand Down
62 changes: 62 additions & 0 deletions tests/anatomy-parse-tolerance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { test, describe } from "node:test";
import * as assert from "node:assert";

import { parseAnatomy } from "../src/hooks/anatomy-store.ts";

/**
* anatomy.md is re-rendered from whatever parseAnatomy() understands, and importFromMarkdown()
* reads hand-edits through the same parser. An entry the regex cannot match is therefore not
* merely ignored — it is deleted on the next render, taking any curated description with it.
*
* The original pattern required BOTH an em-dash separator AND a trailing "(~N tok)", so an
* entry written with a plain hyphen, or without a token estimate, silently vanished.
*/
describe("parseAnatomy tolerance", () => {
const md = [
"# anatomy.md",
"",
"## scripts/",
"",
"- `emdash.py` — canonical shape (~120 tok)",
"- `hyphen.py` - plain hyphen separator (~120 tok)",
"- `endash.py` – en dash separator (~120 tok)",
"- `colon.py`: colon separator (~120 tok)",
"- `notok.py` — no token estimate",
"- `bare.py` (~55 tok)",
"",
].join("\n");

const sections = parseAnatomy(md);
const entries = sections.get("scripts/") ?? [];
const byFile = new Map(entries.map((e) => [e.file, e]));

test("keeps every reasonable entry shape", () => {
assert.equal(entries.length, 6, `expected 6 entries, got ${entries.length}`);
});

for (const f of ["emdash.py", "hyphen.py", "endash.py", "colon.py", "notok.py", "bare.py"]) {
test(`keeps ${f}`, () => assert.ok(byFile.has(f), `${f} was dropped by the parser`));
}

test("preserves the description text", () => {
assert.equal(byFile.get("hyphen.py")?.description, "plain hyphen separator");
assert.equal(byFile.get("notok.py")?.description, "no token estimate");
});

test("treats a missing token estimate as zero rather than dropping the entry", () => {
assert.equal(byFile.get("notok.py")?.tokens, 0);
});

test("an entry with no description still parses", () => {
assert.equal(byFile.get("bare.py")?.description, "");
assert.equal(byFile.get("bare.py")?.tokens, 55);
});

test("descriptions containing separators and parentheses survive intact", () => {
const tricky = parseAnatomy(
"## s/\n\n- `x.py` — does A — then B (see notes): done (~10 tok)\n"
).get("s/")![0];
assert.equal(tricky.description, "does A — then B (see notes): done");
assert.equal(tricky.tokens, 10);
});
});