@praxisflux/gates 0.55.0 → 0.57.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.
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {