@praxisflux/gates 0.57.0 → 0.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/codebase-to-course/lib/spec-derive.mjs +3 -7
- package/codebase-to-course/lib/spec-source.mjs +108 -0
- package/grounding-wiki/lib/spec-derive.mjs +3 -7
- package/grounding-wiki/lib/spec-source.mjs +108 -0
- package/lib/spec-derive.mjs +3 -7
- package/lib/spec-source.mjs +108 -0
- package/package.json +1 -1
- package/spec-bridge/lib/spec-derive.mjs +3 -7
- package/spec-bridge/lib/spec-source.mjs +108 -0
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
// CRITICAL findings. The scan is line-based: a line containing the word CRITICAL counts as an
|
|
25
25
|
// unresolved finding unless the same line says "resolved" (or carries a checked box).
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import { join } from "node:path";
|
|
27
|
+
import { resolveSpecSource } from "./spec-source.mjs";
|
|
29
28
|
|
|
30
29
|
export const STATUS = {
|
|
31
30
|
TODO: "To Do",
|
|
@@ -146,10 +145,7 @@ export function findCriticalFindings(markdown) {
|
|
|
146
145
|
* rather than crashing a sync or a Stop hook.
|
|
147
146
|
*/
|
|
148
147
|
export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
149
|
-
const has
|
|
150
|
-
const read = (name) => {
|
|
151
|
-
try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
152
|
-
};
|
|
148
|
+
const { has, read, source } = resolveSpecSource(specDir);
|
|
153
149
|
|
|
154
150
|
const tasksMd = read("tasks.md");
|
|
155
151
|
const phases = parseTasks(tasksMd);
|
|
@@ -180,7 +176,7 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
return {
|
|
183
|
-
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
|
|
179
|
+
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal, source,
|
|
184
180
|
// phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
|
|
185
181
|
// existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
|
|
186
182
|
phaseBoxes,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// spec-source.mjs — resolve a spec dir's has/read closures: working tree first, then git refs,
|
|
2
|
+
// so a spec dir that only lives on an unmerged task branch still derives its true state (spec
|
|
3
|
+
// 058) from a checkout that doesn't contain it.
|
|
4
|
+
//
|
|
5
|
+
// Precedence: if the dir exists in the working tree, it wins unconditionally and git is never
|
|
6
|
+
// consulted (zero subprocess cost — the hot path, R2/R7). Otherwise the first ref among local
|
|
7
|
+
// HEAD then pushed `refs/remotes/origin/task-*` branches whose tree contains `<specDir>/spec.md`
|
|
8
|
+
// backs the closures via `git show <ref>:<path>`. No match anywhere, no git binary, or not a
|
|
9
|
+
// repo -> degrade to "nothing there", never throw (R5).
|
|
10
|
+
//
|
|
11
|
+
// Read-only plumbing only: show / rev-parse / for-each-ref. No fetch, no checkout, no index
|
|
12
|
+
// writes (R4). Ref enumeration and every (ref, path) read are memoized per repo root so a Stop
|
|
13
|
+
// hook re-deriving the same branch-held spec on every turn pays for git exactly once (R7).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { join, relative, isAbsolute, dirname, sep } from "node:path";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
22
|
+
} catch {
|
|
23
|
+
return null; // git missing, not a repo, or the ref/path doesn't exist -> caller degrades
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk up from an (possibly nonexistent) absolute path to the nearest dir that actually exists,
|
|
28
|
+
// so `git rev-parse` has somewhere real to run from even when specDir itself is unmerged.
|
|
29
|
+
function nearestExistingDir(absPath) {
|
|
30
|
+
let dir = absPath;
|
|
31
|
+
while (!existsSync(dir)) {
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir) return process.cwd();
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rootCache = new Map(); // startDir -> repo root, or null when not inside a repo
|
|
40
|
+
function repoRoot(startDir) {
|
|
41
|
+
if (!rootCache.has(startDir)) {
|
|
42
|
+
const out = git(["rev-parse", "--show-toplevel"], startDir);
|
|
43
|
+
rootCache.set(startDir, out ? out.trim() : null);
|
|
44
|
+
}
|
|
45
|
+
return rootCache.get(startDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const refsCache = new Map(); // repo root -> ["HEAD", ...pushed task branches], computed once
|
|
49
|
+
function listRefs(root) {
|
|
50
|
+
if (!refsCache.has(root)) {
|
|
51
|
+
const refs = ["HEAD"];
|
|
52
|
+
const out = git(["for-each-ref", "--format=%(refname)", "refs/remotes/origin/task-*"], root);
|
|
53
|
+
if (out) for (const line of out.split("\n")) { const ref = line.trim(); if (ref) refs.push(ref); }
|
|
54
|
+
refsCache.set(root, refs);
|
|
55
|
+
}
|
|
56
|
+
return refsCache.get(root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const showCache = new Map(); // `${root}\0${ref}\0${path}` -> file content, or null when absent
|
|
60
|
+
function showAt(root, ref, gitPath) {
|
|
61
|
+
const key = `${root}\0${ref}\0${gitPath}`;
|
|
62
|
+
if (!showCache.has(key)) showCache.set(key, git(["show", `${ref}:${gitPath}`], root));
|
|
63
|
+
return showCache.get(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toGitPath(root, absSpecDir) {
|
|
67
|
+
return relative(root, absSpecDir).split(sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NONE = { has: () => false, read: () => "", source: { kind: "none" } };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `{ has, read, source }` for one spec dir. `has(name)`/`read(name)` behave like the
|
|
74
|
+
* fs-backed closures they replace; `source` names where the answer came from:
|
|
75
|
+
* `{ kind: "worktree", path }`, `{ kind: "ref", ref }`, or `{ kind: "none" }`.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveSpecSource(specDir) {
|
|
78
|
+
if (existsSync(specDir)) {
|
|
79
|
+
return {
|
|
80
|
+
has: (name) => existsSync(join(specDir, name)),
|
|
81
|
+
read: (name) => {
|
|
82
|
+
try { return existsSync(join(specDir, name)) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
83
|
+
},
|
|
84
|
+
source: { kind: "worktree", path: specDir },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve through symlinks (e.g. macOS's /var -> /private/var) so this path and git's own
|
|
89
|
+
// `--show-toplevel` output share one real filesystem root -- otherwise `relative()` below
|
|
90
|
+
// computes nonsense and no ref ever matches.
|
|
91
|
+
const absSpecDir = isAbsolute(specDir) ? specDir : join(process.cwd(), specDir);
|
|
92
|
+
const existingAncestor = nearestExistingDir(absSpecDir);
|
|
93
|
+
const suffix = relative(existingAncestor, absSpecDir);
|
|
94
|
+
const resolvedSpecDir = join(realpathSync(existingAncestor), suffix);
|
|
95
|
+
|
|
96
|
+
const root = repoRoot(realpathSync(existingAncestor));
|
|
97
|
+
if (!root) return NONE;
|
|
98
|
+
|
|
99
|
+
const gitPath = toGitPath(root, resolvedSpecDir);
|
|
100
|
+
const ref = listRefs(root).find((r) => showAt(root, r, `${gitPath}/spec.md`) !== null);
|
|
101
|
+
if (!ref) return NONE;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
has: (name) => showAt(root, ref, `${gitPath}/${name}`) !== null,
|
|
105
|
+
read: (name) => showAt(root, ref, `${gitPath}/${name}`) ?? "",
|
|
106
|
+
source: { kind: "ref", ref },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
// CRITICAL findings. The scan is line-based: a line containing the word CRITICAL counts as an
|
|
25
25
|
// unresolved finding unless the same line says "resolved" (or carries a checked box).
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import { join } from "node:path";
|
|
27
|
+
import { resolveSpecSource } from "./spec-source.mjs";
|
|
29
28
|
|
|
30
29
|
export const STATUS = {
|
|
31
30
|
TODO: "To Do",
|
|
@@ -146,10 +145,7 @@ export function findCriticalFindings(markdown) {
|
|
|
146
145
|
* rather than crashing a sync or a Stop hook.
|
|
147
146
|
*/
|
|
148
147
|
export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
149
|
-
const has
|
|
150
|
-
const read = (name) => {
|
|
151
|
-
try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
152
|
-
};
|
|
148
|
+
const { has, read, source } = resolveSpecSource(specDir);
|
|
153
149
|
|
|
154
150
|
const tasksMd = read("tasks.md");
|
|
155
151
|
const phases = parseTasks(tasksMd);
|
|
@@ -180,7 +176,7 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
return {
|
|
183
|
-
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
|
|
179
|
+
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal, source,
|
|
184
180
|
// phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
|
|
185
181
|
// existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
|
|
186
182
|
phaseBoxes,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// spec-source.mjs — resolve a spec dir's has/read closures: working tree first, then git refs,
|
|
2
|
+
// so a spec dir that only lives on an unmerged task branch still derives its true state (spec
|
|
3
|
+
// 058) from a checkout that doesn't contain it.
|
|
4
|
+
//
|
|
5
|
+
// Precedence: if the dir exists in the working tree, it wins unconditionally and git is never
|
|
6
|
+
// consulted (zero subprocess cost — the hot path, R2/R7). Otherwise the first ref among local
|
|
7
|
+
// HEAD then pushed `refs/remotes/origin/task-*` branches whose tree contains `<specDir>/spec.md`
|
|
8
|
+
// backs the closures via `git show <ref>:<path>`. No match anywhere, no git binary, or not a
|
|
9
|
+
// repo -> degrade to "nothing there", never throw (R5).
|
|
10
|
+
//
|
|
11
|
+
// Read-only plumbing only: show / rev-parse / for-each-ref. No fetch, no checkout, no index
|
|
12
|
+
// writes (R4). Ref enumeration and every (ref, path) read are memoized per repo root so a Stop
|
|
13
|
+
// hook re-deriving the same branch-held spec on every turn pays for git exactly once (R7).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { join, relative, isAbsolute, dirname, sep } from "node:path";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
22
|
+
} catch {
|
|
23
|
+
return null; // git missing, not a repo, or the ref/path doesn't exist -> caller degrades
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk up from an (possibly nonexistent) absolute path to the nearest dir that actually exists,
|
|
28
|
+
// so `git rev-parse` has somewhere real to run from even when specDir itself is unmerged.
|
|
29
|
+
function nearestExistingDir(absPath) {
|
|
30
|
+
let dir = absPath;
|
|
31
|
+
while (!existsSync(dir)) {
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir) return process.cwd();
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rootCache = new Map(); // startDir -> repo root, or null when not inside a repo
|
|
40
|
+
function repoRoot(startDir) {
|
|
41
|
+
if (!rootCache.has(startDir)) {
|
|
42
|
+
const out = git(["rev-parse", "--show-toplevel"], startDir);
|
|
43
|
+
rootCache.set(startDir, out ? out.trim() : null);
|
|
44
|
+
}
|
|
45
|
+
return rootCache.get(startDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const refsCache = new Map(); // repo root -> ["HEAD", ...pushed task branches], computed once
|
|
49
|
+
function listRefs(root) {
|
|
50
|
+
if (!refsCache.has(root)) {
|
|
51
|
+
const refs = ["HEAD"];
|
|
52
|
+
const out = git(["for-each-ref", "--format=%(refname)", "refs/remotes/origin/task-*"], root);
|
|
53
|
+
if (out) for (const line of out.split("\n")) { const ref = line.trim(); if (ref) refs.push(ref); }
|
|
54
|
+
refsCache.set(root, refs);
|
|
55
|
+
}
|
|
56
|
+
return refsCache.get(root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const showCache = new Map(); // `${root}\0${ref}\0${path}` -> file content, or null when absent
|
|
60
|
+
function showAt(root, ref, gitPath) {
|
|
61
|
+
const key = `${root}\0${ref}\0${gitPath}`;
|
|
62
|
+
if (!showCache.has(key)) showCache.set(key, git(["show", `${ref}:${gitPath}`], root));
|
|
63
|
+
return showCache.get(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toGitPath(root, absSpecDir) {
|
|
67
|
+
return relative(root, absSpecDir).split(sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NONE = { has: () => false, read: () => "", source: { kind: "none" } };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `{ has, read, source }` for one spec dir. `has(name)`/`read(name)` behave like the
|
|
74
|
+
* fs-backed closures they replace; `source` names where the answer came from:
|
|
75
|
+
* `{ kind: "worktree", path }`, `{ kind: "ref", ref }`, or `{ kind: "none" }`.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveSpecSource(specDir) {
|
|
78
|
+
if (existsSync(specDir)) {
|
|
79
|
+
return {
|
|
80
|
+
has: (name) => existsSync(join(specDir, name)),
|
|
81
|
+
read: (name) => {
|
|
82
|
+
try { return existsSync(join(specDir, name)) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
83
|
+
},
|
|
84
|
+
source: { kind: "worktree", path: specDir },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve through symlinks (e.g. macOS's /var -> /private/var) so this path and git's own
|
|
89
|
+
// `--show-toplevel` output share one real filesystem root -- otherwise `relative()` below
|
|
90
|
+
// computes nonsense and no ref ever matches.
|
|
91
|
+
const absSpecDir = isAbsolute(specDir) ? specDir : join(process.cwd(), specDir);
|
|
92
|
+
const existingAncestor = nearestExistingDir(absSpecDir);
|
|
93
|
+
const suffix = relative(existingAncestor, absSpecDir);
|
|
94
|
+
const resolvedSpecDir = join(realpathSync(existingAncestor), suffix);
|
|
95
|
+
|
|
96
|
+
const root = repoRoot(realpathSync(existingAncestor));
|
|
97
|
+
if (!root) return NONE;
|
|
98
|
+
|
|
99
|
+
const gitPath = toGitPath(root, resolvedSpecDir);
|
|
100
|
+
const ref = listRefs(root).find((r) => showAt(root, r, `${gitPath}/spec.md`) !== null);
|
|
101
|
+
if (!ref) return NONE;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
has: (name) => showAt(root, ref, `${gitPath}/${name}`) !== null,
|
|
105
|
+
read: (name) => showAt(root, ref, `${gitPath}/${name}`) ?? "",
|
|
106
|
+
source: { kind: "ref", ref },
|
|
107
|
+
};
|
|
108
|
+
}
|
package/lib/spec-derive.mjs
CHANGED
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
// CRITICAL findings. The scan is line-based: a line containing the word CRITICAL counts as an
|
|
25
25
|
// unresolved finding unless the same line says "resolved" (or carries a checked box).
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import { join } from "node:path";
|
|
27
|
+
import { resolveSpecSource } from "./spec-source.mjs";
|
|
29
28
|
|
|
30
29
|
export const STATUS = {
|
|
31
30
|
TODO: "To Do",
|
|
@@ -146,10 +145,7 @@ export function findCriticalFindings(markdown) {
|
|
|
146
145
|
* rather than crashing a sync or a Stop hook.
|
|
147
146
|
*/
|
|
148
147
|
export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
149
|
-
const has
|
|
150
|
-
const read = (name) => {
|
|
151
|
-
try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
152
|
-
};
|
|
148
|
+
const { has, read, source } = resolveSpecSource(specDir);
|
|
153
149
|
|
|
154
150
|
const tasksMd = read("tasks.md");
|
|
155
151
|
const phases = parseTasks(tasksMd);
|
|
@@ -180,7 +176,7 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
return {
|
|
183
|
-
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
|
|
179
|
+
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal, source,
|
|
184
180
|
// phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
|
|
185
181
|
// existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
|
|
186
182
|
phaseBoxes,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// spec-source.mjs — resolve a spec dir's has/read closures: working tree first, then git refs,
|
|
2
|
+
// so a spec dir that only lives on an unmerged task branch still derives its true state (spec
|
|
3
|
+
// 058) from a checkout that doesn't contain it.
|
|
4
|
+
//
|
|
5
|
+
// Precedence: if the dir exists in the working tree, it wins unconditionally and git is never
|
|
6
|
+
// consulted (zero subprocess cost — the hot path, R2/R7). Otherwise the first ref among local
|
|
7
|
+
// HEAD then pushed `refs/remotes/origin/task-*` branches whose tree contains `<specDir>/spec.md`
|
|
8
|
+
// backs the closures via `git show <ref>:<path>`. No match anywhere, no git binary, or not a
|
|
9
|
+
// repo -> degrade to "nothing there", never throw (R5).
|
|
10
|
+
//
|
|
11
|
+
// Read-only plumbing only: show / rev-parse / for-each-ref. No fetch, no checkout, no index
|
|
12
|
+
// writes (R4). Ref enumeration and every (ref, path) read are memoized per repo root so a Stop
|
|
13
|
+
// hook re-deriving the same branch-held spec on every turn pays for git exactly once (R7).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { join, relative, isAbsolute, dirname, sep } from "node:path";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
22
|
+
} catch {
|
|
23
|
+
return null; // git missing, not a repo, or the ref/path doesn't exist -> caller degrades
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk up from an (possibly nonexistent) absolute path to the nearest dir that actually exists,
|
|
28
|
+
// so `git rev-parse` has somewhere real to run from even when specDir itself is unmerged.
|
|
29
|
+
function nearestExistingDir(absPath) {
|
|
30
|
+
let dir = absPath;
|
|
31
|
+
while (!existsSync(dir)) {
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir) return process.cwd();
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rootCache = new Map(); // startDir -> repo root, or null when not inside a repo
|
|
40
|
+
function repoRoot(startDir) {
|
|
41
|
+
if (!rootCache.has(startDir)) {
|
|
42
|
+
const out = git(["rev-parse", "--show-toplevel"], startDir);
|
|
43
|
+
rootCache.set(startDir, out ? out.trim() : null);
|
|
44
|
+
}
|
|
45
|
+
return rootCache.get(startDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const refsCache = new Map(); // repo root -> ["HEAD", ...pushed task branches], computed once
|
|
49
|
+
function listRefs(root) {
|
|
50
|
+
if (!refsCache.has(root)) {
|
|
51
|
+
const refs = ["HEAD"];
|
|
52
|
+
const out = git(["for-each-ref", "--format=%(refname)", "refs/remotes/origin/task-*"], root);
|
|
53
|
+
if (out) for (const line of out.split("\n")) { const ref = line.trim(); if (ref) refs.push(ref); }
|
|
54
|
+
refsCache.set(root, refs);
|
|
55
|
+
}
|
|
56
|
+
return refsCache.get(root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const showCache = new Map(); // `${root}\0${ref}\0${path}` -> file content, or null when absent
|
|
60
|
+
function showAt(root, ref, gitPath) {
|
|
61
|
+
const key = `${root}\0${ref}\0${gitPath}`;
|
|
62
|
+
if (!showCache.has(key)) showCache.set(key, git(["show", `${ref}:${gitPath}`], root));
|
|
63
|
+
return showCache.get(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toGitPath(root, absSpecDir) {
|
|
67
|
+
return relative(root, absSpecDir).split(sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NONE = { has: () => false, read: () => "", source: { kind: "none" } };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `{ has, read, source }` for one spec dir. `has(name)`/`read(name)` behave like the
|
|
74
|
+
* fs-backed closures they replace; `source` names where the answer came from:
|
|
75
|
+
* `{ kind: "worktree", path }`, `{ kind: "ref", ref }`, or `{ kind: "none" }`.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveSpecSource(specDir) {
|
|
78
|
+
if (existsSync(specDir)) {
|
|
79
|
+
return {
|
|
80
|
+
has: (name) => existsSync(join(specDir, name)),
|
|
81
|
+
read: (name) => {
|
|
82
|
+
try { return existsSync(join(specDir, name)) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
83
|
+
},
|
|
84
|
+
source: { kind: "worktree", path: specDir },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve through symlinks (e.g. macOS's /var -> /private/var) so this path and git's own
|
|
89
|
+
// `--show-toplevel` output share one real filesystem root -- otherwise `relative()` below
|
|
90
|
+
// computes nonsense and no ref ever matches.
|
|
91
|
+
const absSpecDir = isAbsolute(specDir) ? specDir : join(process.cwd(), specDir);
|
|
92
|
+
const existingAncestor = nearestExistingDir(absSpecDir);
|
|
93
|
+
const suffix = relative(existingAncestor, absSpecDir);
|
|
94
|
+
const resolvedSpecDir = join(realpathSync(existingAncestor), suffix);
|
|
95
|
+
|
|
96
|
+
const root = repoRoot(realpathSync(existingAncestor));
|
|
97
|
+
if (!root) return NONE;
|
|
98
|
+
|
|
99
|
+
const gitPath = toGitPath(root, resolvedSpecDir);
|
|
100
|
+
const ref = listRefs(root).find((r) => showAt(root, r, `${gitPath}/spec.md`) !== null);
|
|
101
|
+
if (!ref) return NONE;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
has: (name) => showAt(root, ref, `${gitPath}/${name}`) !== null,
|
|
105
|
+
read: (name) => showAt(root, ref, `${gitPath}/${name}`) ?? "",
|
|
106
|
+
source: { kind: "ref", ref },
|
|
107
|
+
};
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
// CRITICAL findings. The scan is line-based: a line containing the word CRITICAL counts as an
|
|
25
25
|
// unresolved finding unless the same line says "resolved" (or carries a checked box).
|
|
26
26
|
|
|
27
|
-
import {
|
|
28
|
-
import { join } from "node:path";
|
|
27
|
+
import { resolveSpecSource } from "./spec-source.mjs";
|
|
29
28
|
|
|
30
29
|
export const STATUS = {
|
|
31
30
|
TODO: "To Do",
|
|
@@ -146,10 +145,7 @@ export function findCriticalFindings(markdown) {
|
|
|
146
145
|
* rather than crashing a sync or a Stop hook.
|
|
147
146
|
*/
|
|
148
147
|
export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
149
|
-
const has
|
|
150
|
-
const read = (name) => {
|
|
151
|
-
try { return has(name) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
152
|
-
};
|
|
148
|
+
const { has, read, source } = resolveSpecSource(specDir);
|
|
153
149
|
|
|
154
150
|
const tasksMd = read("tasks.md");
|
|
155
151
|
const phases = parseTasks(tasksMd);
|
|
@@ -180,7 +176,7 @@ export function deriveSpecState(specDir, { requireAnalysis = false } = {}) {
|
|
|
180
176
|
}
|
|
181
177
|
|
|
182
178
|
return {
|
|
183
|
-
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal,
|
|
179
|
+
status: coarseStatus(stage), stage, phases, tasksDone, tasksTotal, source,
|
|
184
180
|
// phaseBoxes is strictly additive — .phases keeps its { name, done, total } shape (pinned by
|
|
185
181
|
// existing tests); phaseBoxes carries the box text spec 050's message needs, nothing more.
|
|
186
182
|
phaseBoxes,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// spec-source.mjs — resolve a spec dir's has/read closures: working tree first, then git refs,
|
|
2
|
+
// so a spec dir that only lives on an unmerged task branch still derives its true state (spec
|
|
3
|
+
// 058) from a checkout that doesn't contain it.
|
|
4
|
+
//
|
|
5
|
+
// Precedence: if the dir exists in the working tree, it wins unconditionally and git is never
|
|
6
|
+
// consulted (zero subprocess cost — the hot path, R2/R7). Otherwise the first ref among local
|
|
7
|
+
// HEAD then pushed `refs/remotes/origin/task-*` branches whose tree contains `<specDir>/spec.md`
|
|
8
|
+
// backs the closures via `git show <ref>:<path>`. No match anywhere, no git binary, or not a
|
|
9
|
+
// repo -> degrade to "nothing there", never throw (R5).
|
|
10
|
+
//
|
|
11
|
+
// Read-only plumbing only: show / rev-parse / for-each-ref. No fetch, no checkout, no index
|
|
12
|
+
// writes (R4). Ref enumeration and every (ref, path) read are memoized per repo root so a Stop
|
|
13
|
+
// hook re-deriving the same branch-held spec on every turn pays for git exactly once (R7).
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
16
|
+
import { join, relative, isAbsolute, dirname, sep } from "node:path";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
22
|
+
} catch {
|
|
23
|
+
return null; // git missing, not a repo, or the ref/path doesn't exist -> caller degrades
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Walk up from an (possibly nonexistent) absolute path to the nearest dir that actually exists,
|
|
28
|
+
// so `git rev-parse` has somewhere real to run from even when specDir itself is unmerged.
|
|
29
|
+
function nearestExistingDir(absPath) {
|
|
30
|
+
let dir = absPath;
|
|
31
|
+
while (!existsSync(dir)) {
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir) return process.cwd();
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rootCache = new Map(); // startDir -> repo root, or null when not inside a repo
|
|
40
|
+
function repoRoot(startDir) {
|
|
41
|
+
if (!rootCache.has(startDir)) {
|
|
42
|
+
const out = git(["rev-parse", "--show-toplevel"], startDir);
|
|
43
|
+
rootCache.set(startDir, out ? out.trim() : null);
|
|
44
|
+
}
|
|
45
|
+
return rootCache.get(startDir);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const refsCache = new Map(); // repo root -> ["HEAD", ...pushed task branches], computed once
|
|
49
|
+
function listRefs(root) {
|
|
50
|
+
if (!refsCache.has(root)) {
|
|
51
|
+
const refs = ["HEAD"];
|
|
52
|
+
const out = git(["for-each-ref", "--format=%(refname)", "refs/remotes/origin/task-*"], root);
|
|
53
|
+
if (out) for (const line of out.split("\n")) { const ref = line.trim(); if (ref) refs.push(ref); }
|
|
54
|
+
refsCache.set(root, refs);
|
|
55
|
+
}
|
|
56
|
+
return refsCache.get(root);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const showCache = new Map(); // `${root}\0${ref}\0${path}` -> file content, or null when absent
|
|
60
|
+
function showAt(root, ref, gitPath) {
|
|
61
|
+
const key = `${root}\0${ref}\0${gitPath}`;
|
|
62
|
+
if (!showCache.has(key)) showCache.set(key, git(["show", `${ref}:${gitPath}`], root));
|
|
63
|
+
return showCache.get(key);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toGitPath(root, absSpecDir) {
|
|
67
|
+
return relative(root, absSpecDir).split(sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const NONE = { has: () => false, read: () => "", source: { kind: "none" } };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve `{ has, read, source }` for one spec dir. `has(name)`/`read(name)` behave like the
|
|
74
|
+
* fs-backed closures they replace; `source` names where the answer came from:
|
|
75
|
+
* `{ kind: "worktree", path }`, `{ kind: "ref", ref }`, or `{ kind: "none" }`.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveSpecSource(specDir) {
|
|
78
|
+
if (existsSync(specDir)) {
|
|
79
|
+
return {
|
|
80
|
+
has: (name) => existsSync(join(specDir, name)),
|
|
81
|
+
read: (name) => {
|
|
82
|
+
try { return existsSync(join(specDir, name)) ? readFileSync(join(specDir, name), "utf8") : ""; } catch { return ""; }
|
|
83
|
+
},
|
|
84
|
+
source: { kind: "worktree", path: specDir },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve through symlinks (e.g. macOS's /var -> /private/var) so this path and git's own
|
|
89
|
+
// `--show-toplevel` output share one real filesystem root -- otherwise `relative()` below
|
|
90
|
+
// computes nonsense and no ref ever matches.
|
|
91
|
+
const absSpecDir = isAbsolute(specDir) ? specDir : join(process.cwd(), specDir);
|
|
92
|
+
const existingAncestor = nearestExistingDir(absSpecDir);
|
|
93
|
+
const suffix = relative(existingAncestor, absSpecDir);
|
|
94
|
+
const resolvedSpecDir = join(realpathSync(existingAncestor), suffix);
|
|
95
|
+
|
|
96
|
+
const root = repoRoot(realpathSync(existingAncestor));
|
|
97
|
+
if (!root) return NONE;
|
|
98
|
+
|
|
99
|
+
const gitPath = toGitPath(root, resolvedSpecDir);
|
|
100
|
+
const ref = listRefs(root).find((r) => showAt(root, r, `${gitPath}/spec.md`) !== null);
|
|
101
|
+
if (!ref) return NONE;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
has: (name) => showAt(root, ref, `${gitPath}/${name}`) !== null,
|
|
105
|
+
read: (name) => showAt(root, ref, `${gitPath}/${name}`) ?? "",
|
|
106
|
+
source: { kind: "ref", ref },
|
|
107
|
+
};
|
|
108
|
+
}
|