@praxisflux/gates 0.56.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/gates/repin-window.mjs +125 -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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// repin-window.mjs — is a stale note stale *because of unmerged work on this branch*?
|
|
2
|
+
//
|
|
3
|
+
// Freshness is arithmetic: a note is STALE when `git log <pin>..HEAD -- <sources>` is
|
|
4
|
+
// non-empty (./freshness.mjs). That answer is correct but incomplete for deciding whether to
|
|
5
|
+
// BLOCK: mid-task, staleness is red **by construction**. Doctrine sequences the re-pin AFTER
|
|
6
|
+
// the commit that touched the sources (read the diff the pin covers, then bump — the honest
|
|
7
|
+
// re-pins rule), so between those two commits a stale note is the expected state, not neglect.
|
|
8
|
+
//
|
|
9
|
+
// This module draws that line, and only that line. It answers ONE question per note:
|
|
10
|
+
//
|
|
11
|
+
// Are the commits that stale this note themselves unmerged?
|
|
12
|
+
//
|
|
13
|
+
// inside the window — yes: unmerged work on this branch stales it; the re-pin is legitimately
|
|
14
|
+
// owed later (before the PR). A caller may downgrade blocking to a notice.
|
|
15
|
+
// outside the window — no: the staleness is explained by something already on the base branch,
|
|
16
|
+
// or by nothing at all. That is neglect, and callers block as before.
|
|
17
|
+
//
|
|
18
|
+
// It does NOT decide what to do about the answer — stop-docs.mjs and (later) TASK-105's
|
|
19
|
+
// sign-off gate own that. Read-only, like everything in gates/: no writes, ever.
|
|
20
|
+
//
|
|
21
|
+
// WHY THE OBVIOUS TEST IS WRONG (do not "simplify" to it). The tempting definition is
|
|
22
|
+
// "the branch has commits not on origin/main" — i.e. treat any non-base checkout as mid-task.
|
|
23
|
+
// It fails on this very repo: under the two-track landing rule, board/bookkeeping commits land
|
|
24
|
+
// directly on `main`, so a local `main` routinely sits AHEAD of `origin/main` (2 commits ahead
|
|
25
|
+
// when this was written). Under that test `main` itself reads as "mid-task" and the window
|
|
26
|
+
// opens exactly where it must stay shut. The per-note form below cannot make that mistake: it
|
|
27
|
+
// asks which commits stale THIS note, not where HEAD happens to be.
|
|
28
|
+
//
|
|
29
|
+
// FAIL CLOSED. Every unknown resolves to OUTSIDE the window (block). No base ref, no git, an
|
|
30
|
+
// unreadable note — none of them open the window. A gate that cannot prove the mid-task
|
|
31
|
+
// excuse must not grant it; the cost of a wrong "outside" is one honest re-pin, and the cost
|
|
32
|
+
// of a wrong "inside" is the silent staleness this corpus exists to prevent.
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { join, isAbsolute } from "node:path";
|
|
35
|
+
import { spawnSync } from "node:child_process";
|
|
36
|
+
import { parseFrontmatter } from "../lib/markdown.mjs";
|
|
37
|
+
import { noteSources } from "./freshness.mjs";
|
|
38
|
+
import { noteFiles } from "./capsules.mjs";
|
|
39
|
+
|
|
40
|
+
/** The base a branch's work is measured against. Overridable for tests and for hosts whose
|
|
41
|
+
* default branch isn't `main`. */
|
|
42
|
+
export const DEFAULT_BASE = "origin/main";
|
|
43
|
+
|
|
44
|
+
/** Run git, returning { ok, out }. Never throws: a git failure is data here, not an exception,
|
|
45
|
+
* because every failure mode resolves to the same fail-closed answer. */
|
|
46
|
+
function git(cwd, args) {
|
|
47
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
48
|
+
if (r.error || r.status !== 0) return { ok: false, out: "" };
|
|
49
|
+
return { ok: true, out: (r.stdout || "").trim() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does `ref` resolve in this repo? A missing base (fresh clone, no remote, detached CI
|
|
53
|
+
* checkout) is the most common reason the window must stay shut. */
|
|
54
|
+
export function baseExists(repoRoot, base = DEFAULT_BASE) {
|
|
55
|
+
return git(repoRoot, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).ok;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The window test for ONE note, given its pin and sources.
|
|
60
|
+
*
|
|
61
|
+
* Returns { inside, reason, commits } where `commits` is the unmerged staling commits (oneline)
|
|
62
|
+
* when inside, and [] otherwise. `reason` always explains the verdict in one clause, because
|
|
63
|
+
* every caller surfaces it to a human.
|
|
64
|
+
*
|
|
65
|
+
* The command is deliberately `<pin>..HEAD --not <base> -- <sources>`: the `..HEAD` half is the
|
|
66
|
+
* same range freshness.mjs uses to decide staleness at all, and `--not <base>` subtracts
|
|
67
|
+
* everything already merged. What survives is precisely "commits that stale this note AND are
|
|
68
|
+
* not yet on the base branch".
|
|
69
|
+
*/
|
|
70
|
+
export function noteWindow(repoRoot, { pin, sources, base = DEFAULT_BASE }) {
|
|
71
|
+
if (!pin) return { inside: false, reason: "no verified_against pin", commits: [] };
|
|
72
|
+
if (!sources?.length) return { inside: false, reason: "no sources listed", commits: [] };
|
|
73
|
+
if (!baseExists(repoRoot, base))
|
|
74
|
+
return { inside: false, reason: `base ref ${base} does not resolve — cannot prove the staling work is unmerged`, commits: [] };
|
|
75
|
+
|
|
76
|
+
const r = git(repoRoot, ["log", "--oneline", `${pin}..HEAD`, "--not", base, "--", ...sources]);
|
|
77
|
+
if (!r.ok)
|
|
78
|
+
return { inside: false, reason: `git log failed over ${sources.length} source path(s)`, commits: [] };
|
|
79
|
+
if (!r.out)
|
|
80
|
+
return { inside: false, reason: `no unmerged commits touch its sources — the staleness is already on ${base}`, commits: [] };
|
|
81
|
+
|
|
82
|
+
const commits = r.out.split("\n");
|
|
83
|
+
return {
|
|
84
|
+
inside: true,
|
|
85
|
+
reason: `${commits.length} unmerged commit(s) on this branch touch its sources (e.g. ${commits[0]})`,
|
|
86
|
+
commits,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The same test across a whole corpus. Returns { notes, allInside, base } where `notes` is
|
|
92
|
+
* [{ file, inside, reason }] for every note carrying a pin and sources.
|
|
93
|
+
*
|
|
94
|
+
* `allInside` is the caller's usual decision input, and it is deliberately an AND over the
|
|
95
|
+
* notes that are actually stale: a branch may legitimately explain note A's staleness while
|
|
96
|
+
* note B is stale for an unrelated, older reason. Forgiving both because one is excused is the
|
|
97
|
+
* failure this per-note grain exists to prevent — so callers pass the stale set and block
|
|
98
|
+
* unless EVERY member of it is inside.
|
|
99
|
+
*/
|
|
100
|
+
export function corpusWindow(repoRoot, corpusDir = "docs/wiki", { base = DEFAULT_BASE, only } = {}) {
|
|
101
|
+
const dir = isAbsolute(corpusDir) ? corpusDir : join(repoRoot, corpusDir);
|
|
102
|
+
const notes = [];
|
|
103
|
+
for (const file of noteFiles(dir)) {
|
|
104
|
+
if (only && !only.has(`${corpusDir}/${file}`) && !only.has(file)) continue;
|
|
105
|
+
let text;
|
|
106
|
+
try { text = readFileSync(join(dir, file), "utf8"); }
|
|
107
|
+
catch { notes.push({ file, inside: false, reason: "unreadable" }); continue; }
|
|
108
|
+
const fm = parseFrontmatter(text);
|
|
109
|
+
const w = noteWindow(repoRoot, { pin: fm?.verified_against, sources: noteSources(text, fm), base });
|
|
110
|
+
notes.push({ file, inside: w.inside, reason: w.reason });
|
|
111
|
+
}
|
|
112
|
+
return { notes, allInside: notes.length > 0 && notes.every((n) => n.inside), base };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The stale notes named by freshness.mjs `fails` lines, as a Set of corpus-relative paths.
|
|
116
|
+
* Callers pair this with corpusWindow's `only` so the window is asked only about notes that
|
|
117
|
+
* are actually stale — never about the whole corpus. */
|
|
118
|
+
export function staleNotesFrom(fails) {
|
|
119
|
+
const out = new Set();
|
|
120
|
+
for (const f of fails || []) {
|
|
121
|
+
const m = /^(\S+\.md):\s*STALE\b/.exec(f);
|
|
122
|
+
if (m) out.add(m[1]);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -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
|
+
}
|