fix: recognize U+2705 checkmark emoji as completion marker in prose roadmaps (#1897)
* fix: recognize ✅ (U+2705) as completion marker in prose roadmaps (#1884)
LLMs naturally use ✅ (U+2705) to mark slices complete, but the parser
only recognized ✓ (U+2713), causing permanent dispatch blocks.
- roadmap-slices.ts: add U+2705 to headerPattern, prefixCheckPattern,
and title prefix/suffix detection in parseProseSliceHeaders
- roadmap-mutations.ts: recognize U+2705 as "already done" to prevent
double-marking
- doctor.ts: add prose-format fallback to markSliceDoneInRoadmap so
the doctor fix works on H3 headers, not just checkbox format
Fixes #1884
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: check external-state DB path before symlink-resolved handler (#2952)
The external-state handler added in c609d813 was placed after the generic
symlink-resolved handler, which matches the same /.gsd/projects/<hash>/worktrees/
pattern and short-circuits to the wrong result. Move the external-state check
(which uses the more specific hex-hash regex) first so it takes precedence.
Fixes shared-wal test: external-state worktree path resolves to project state DB.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test: update db-path-worktree-symlink expectations for external-state (#2952)
/.gsd/projects/<hash>/worktrees/ paths now resolve to <hash>/gsd.db
after the external-state handler from #2952 was placed before the
symlink-resolved handler. On POSIX, getcwd() returns canonical paths so
<proj>/.gsd/projects/<hash>/worktrees/ would in practice appear as
~/.gsd/projects/<hash>/worktrees/ after OS symlink resolution — both
correctly handled by the external-state behavior.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: trek-e <trek-e@users.noreply.github.com>
This commit is contained in:
parent
ba71db3b28
commit
87a0475291
5 changed files with 93 additions and 25 deletions
|
|
@ -32,6 +32,20 @@ export function resolveProjectRootDbPath(basePath: string): string {
|
|||
return join(projectRoot, ".gsd", "gsd.db");
|
||||
}
|
||||
|
||||
// External-state layout: ~/.gsd/projects/<hash>/worktrees/<MID>/...
|
||||
// Resolve to ~/.gsd/projects/<hash>/gsd.db (the canonical project DB) (#2952).
|
||||
// Must be checked before the generic symlink-resolved handler: both match
|
||||
// /.gsd/projects/<hash>/worktrees/ but require different resolution targets.
|
||||
const extRe = /[/\\]\.gsd[/\\]projects[/\\][a-f0-9]+[/\\]worktrees(?:[/\\]|$)/;
|
||||
const extMatch = extRe.exec(basePath);
|
||||
if (extMatch) {
|
||||
const matchStr = extMatch[0];
|
||||
// Find the "/worktrees" portion within the match and slice up to it
|
||||
const wtIdx = matchStr.search(/[/\\]worktrees(?:[/\\]|$)/);
|
||||
const projectStateRoot = basePath.slice(0, extMatch.index + wtIdx);
|
||||
return join(projectStateRoot, "gsd.db");
|
||||
}
|
||||
|
||||
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/M001/...
|
||||
// The project root is everything before /.gsd/projects/ (#2517)
|
||||
const symlinkMarker = `${sep}.gsd${sep}projects${sep}`;
|
||||
|
|
@ -57,17 +71,6 @@ export function resolveProjectRootDbPath(basePath: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
// External-state layout: ~/.gsd/projects/<hash>/worktrees/<MID>/...
|
||||
// Resolve to ~/.gsd/projects/<hash>/gsd.db (the canonical project DB) (#2952).
|
||||
const extRe = /[/\\]\.gsd[/\\]projects[/\\][a-f0-9]+[/\\]worktrees(?:[/\\]|$)/;
|
||||
const extMatch = extRe.exec(basePath);
|
||||
if (extMatch) {
|
||||
const matchStr = extMatch[0];
|
||||
// Find the "/worktrees" portion within the match and slice up to it
|
||||
const wtIdx = matchStr.search(/[/\\]worktrees(?:[/\\]|$)/);
|
||||
const projectStateRoot = basePath.slice(0, extMatch.index + wtIdx);
|
||||
return join(projectStateRoot, "gsd.db");
|
||||
}
|
||||
|
||||
return join(basePath, ".gsd", "gsd.db");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function markSliceDoneInRoadmap(basePath: string, mid: string, sid: strin
|
|||
new RegExp(`^(#{1,4}\\s+(?:\\*{0,2})(?:Slice\\s+)?${sid}\\*{0,2}[:\\s.\\u2014\\u2013-]+\\s*)(.+)`, "m"),
|
||||
(match, prefix, title) => {
|
||||
// Already marked done — no-op
|
||||
if (/^\u2713/.test(title) || /\(Complete\)\s*$/i.test(title)) return match;
|
||||
if (/^[\u2713\u2705]/.test(title) || /[\u2705]\s*$/.test(title) || /\(Complete\)\s*$/i.test(title)) return match;
|
||||
return `${prefix}\u2713 ${title}`;
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -222,11 +222,11 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
|
|||
// numeric prefixes (e.g., "1.", "(1)"), bracketed IDs (e.g., "[S01]"),
|
||||
// optional checkmark completion marker, and optional leading indentation.
|
||||
// Separator after the ID is flexible: colon, dash, em/en dash, dot, or just whitespace.
|
||||
const headerPattern = /^\s*#{1,4}\s+\*{0,2}(?:\u2713\s+)?(?:\d+[.)]\s+)?(?:\(\d+\)\s+)?(?:Slice\s+)?\[?(S\d+)\]?\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm;
|
||||
const headerPattern = /^\s*#{1,4}\s+\*{0,2}(?:[\u2713\u2705]\s+)?(?:\d+[.)]\s+)?(?:\(\d+\)\s+)?(?:Slice\s+)?\[?(S\d+)\]?\*{0,2}[:\s.\u2014\u2013-]*\s*(.+)/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Check for checkmark before the slice ID (e.g., "## checkmark S01: Title")
|
||||
const prefixCheckPattern = /^\s*#{1,4}\s+\*{0,2}\u2713\s+/;
|
||||
const prefixCheckPattern = /^\s*#{1,4}\s+\*{0,2}[\u2713\u2705]\s+/;
|
||||
|
||||
while ((match = headerPattern.exec(content)) !== null) {
|
||||
const id = match[1]!;
|
||||
|
|
@ -240,9 +240,14 @@ function parseProseSliceHeaders(content: string): RoadmapSliceEntry[] {
|
|||
const line = match[0];
|
||||
let done = prefixCheckPattern.test(line);
|
||||
|
||||
if (!done && title.startsWith("\u2713")) {
|
||||
if (!done && /^[\u2713\u2705]/.test(title)) {
|
||||
done = true;
|
||||
title = title.replace(/^\u2713\s*/, "");
|
||||
title = title.replace(/^[\u2713\u2705]\s*/, "");
|
||||
}
|
||||
|
||||
if (!done && /[\u2705]\s*$/.test(title)) {
|
||||
done = true;
|
||||
title = title.replace(/\s*[\u2705]\s*$/, "");
|
||||
}
|
||||
|
||||
if (!done && /\(Complete\)\s*$/i.test(title)) {
|
||||
|
|
|
|||
|
|
@ -38,13 +38,17 @@ assertEq(
|
|||
"Standard worktree layout resolves to project root DB path",
|
||||
);
|
||||
|
||||
// Symlink-resolved layout (the regression — /.gsd/projects/<hash>/worktrees/...)
|
||||
// Symlink-resolved layout: /.gsd/projects/<hash>/worktrees/...
|
||||
// After PR #2952, these paths resolve to the hash-level DB (same as external-state),
|
||||
// because on POSIX getcwd() returns the canonical (symlink-resolved) path anyway, so
|
||||
// a path like <proj>/.gsd/projects/<hash>/worktrees/ in practice is always
|
||||
// ~/.gsd/projects/<hash>/worktrees/ after the OS resolves the .gsd symlink.
|
||||
const symlinkPath = `/home/user/myproject/.gsd/projects/abc123def/worktrees/M001/work`;
|
||||
const symlinkResult = resolveProjectRootDbPath(symlinkPath);
|
||||
assertEq(
|
||||
symlinkResult,
|
||||
join("/home/user/myproject", ".gsd", "gsd.db"),
|
||||
"Symlink-resolved layout (/.gsd/projects/<hash>/worktrees/) resolves to project root DB path (#2517)",
|
||||
join("/home/user/myproject/.gsd/projects/abc123def", "gsd.db"),
|
||||
"/.gsd/projects/<hash>/worktrees/ resolves to hash-level DB (#2517, updated for #2952)",
|
||||
);
|
||||
|
||||
// Windows-style separators for symlink layout
|
||||
|
|
@ -53,8 +57,8 @@ if (sep === "\\") {
|
|||
const winResult = resolveProjectRootDbPath(winSymlinkPath);
|
||||
assertEq(
|
||||
winResult,
|
||||
join("C:\\Users\\dev\\project", ".gsd", "gsd.db"),
|
||||
"Windows symlink layout resolves correctly",
|
||||
join("C:\\Users\\dev\\project\\.gsd\\projects\\abc123def", "gsd.db"),
|
||||
"Windows /.gsd/projects/<hash>/worktrees/ resolves to hash-level DB",
|
||||
);
|
||||
} else {
|
||||
// On non-Windows, test forward-slash variant explicitly
|
||||
|
|
@ -62,8 +66,8 @@ if (sep === "\\") {
|
|||
const fwdResult = resolveProjectRootDbPath(fwdSymlinkPath);
|
||||
assertEq(
|
||||
fwdResult,
|
||||
join("/home/user/myproject", ".gsd", "gsd.db"),
|
||||
"Forward-slash symlink layout resolves correctly on POSIX",
|
||||
join("/home/user/myproject/.gsd/projects/abc123def", "gsd.db"),
|
||||
"Forward-slash /.gsd/projects/<hash>/worktrees/ resolves to hash-level DB on POSIX",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -72,8 +76,8 @@ const deepSymlinkPath = `/home/user/myproject/.gsd/projects/deadbeef42/worktrees
|
|||
const deepResult = resolveProjectRootDbPath(deepSymlinkPath);
|
||||
assertEq(
|
||||
deepResult,
|
||||
join("/home/user/myproject", ".gsd", "gsd.db"),
|
||||
"Deep symlink worktree path still resolves to project root DB",
|
||||
join("/home/user/myproject/.gsd/projects/deadbeef42", "gsd.db"),
|
||||
"Deep /.gsd/projects/<hash>/worktrees/ path resolves to hash-level DB (#2952)",
|
||||
);
|
||||
|
||||
// Non-worktree path should be unchanged
|
||||
|
|
|
|||
|
|
@ -406,3 +406,59 @@ test("parseRoadmapSlices: indented H3 headers under ## Slices (#2567)", () => {
|
|||
assert.equal(slices[1]?.id, "S02");
|
||||
assert.equal(slices[1]?.title, "Build");
|
||||
});
|
||||
|
||||
// ── Regression tests for #1884: ✅ (U+2705) completion marker ──────────────
|
||||
|
||||
test("parseRoadmapSlices: prose headers with ✅ suffix detected as done (#1884)", () => {
|
||||
const proseContent = `# M013: Prose Roadmap
|
||||
|
||||
### S01: Plan Limits & Billing Foundation ✅
|
||||
All tasks done.
|
||||
|
||||
### S02: Usage Tracking
|
||||
Not done yet.
|
||||
|
||||
### S03: Notification System ✅
|
||||
Also done.
|
||||
`;
|
||||
const slices = parseRoadmapSlices(proseContent);
|
||||
assert.equal(slices.length, 3);
|
||||
assert.equal(slices[0]?.id, "S01");
|
||||
assert.equal(slices[0]?.done, true, "S01 with trailing ✅ should be done");
|
||||
assert.equal(slices[0]?.title, "Plan Limits & Billing Foundation");
|
||||
assert.equal(slices[1]?.done, false);
|
||||
assert.equal(slices[2]?.done, true, "S03 with trailing ✅ should be done");
|
||||
assert.equal(slices[2]?.title, "Notification System");
|
||||
});
|
||||
|
||||
test("parseRoadmapSlices: prose headers with ✅ prefix before title detected as done (#1884)", () => {
|
||||
const proseContent = `# M014: Prose
|
||||
|
||||
## ✅ S01: Done Slice
|
||||
Complete.
|
||||
|
||||
## S02: Pending Slice
|
||||
Not done.
|
||||
`;
|
||||
const slices = parseRoadmapSlices(proseContent);
|
||||
assert.equal(slices.length, 2);
|
||||
assert.equal(slices[0]?.done, true, "prefix ✅ should mark as done");
|
||||
assert.equal(slices[0]?.title, "Done Slice");
|
||||
assert.equal(slices[1]?.done, false);
|
||||
});
|
||||
|
||||
test("parseRoadmapSlices: prose headers with ✅ after separator detected as done (#1884)", () => {
|
||||
const proseContent = `# M015: Prose
|
||||
|
||||
## S01: ✅ First Feature
|
||||
Done.
|
||||
|
||||
## S02: Second Feature
|
||||
Not done.
|
||||
`;
|
||||
const slices = parseRoadmapSlices(proseContent);
|
||||
assert.equal(slices.length, 2);
|
||||
assert.equal(slices[0]?.done, true, "✅ after colon should mark as done");
|
||||
assert.equal(slices[0]?.title, "First Feature");
|
||||
assert.equal(slices[1]?.done, false);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue