baesian 0.0.0-stage → 1.0.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.
Files changed (69) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +217 -2
  3. package/dist/agents.js +249 -0
  4. package/dist/api.js +13 -0
  5. package/dist/bin.js +14 -0
  6. package/dist/ci.js +82 -0
  7. package/dist/cite.js +66 -0
  8. package/dist/cli.js +831 -0
  9. package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
  10. package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
  11. package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
  12. package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
  13. package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
  14. package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
  15. package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
  16. package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
  17. package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
  18. package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
  19. package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
  20. package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
  21. package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
  22. package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
  23. package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
  24. package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
  25. package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
  26. package/dist/client/assets/index-B3CFhLg-.css +1 -0
  27. package/dist/client/assets/index-DUcPUPI_.js +15 -0
  28. package/dist/client/index.html +15 -0
  29. package/dist/facts.js +278 -0
  30. package/dist/folder-storage.js +71 -0
  31. package/dist/frontmatter.js +38 -0
  32. package/dist/gate-paths.js +77 -0
  33. package/dist/gate.js +237 -0
  34. package/dist/git.js +100 -0
  35. package/dist/globs.js +120 -0
  36. package/dist/hookblock.js +146 -0
  37. package/dist/hooks.js +911 -0
  38. package/dist/ids.js +23 -0
  39. package/dist/login.js +301 -0
  40. package/dist/mcp.js +303 -0
  41. package/dist/merges.js +849 -0
  42. package/dist/modes.js +245 -0
  43. package/dist/notify.js +91 -0
  44. package/dist/ops.js +381 -0
  45. package/dist/passkey.js +86 -0
  46. package/dist/personal.js +163 -0
  47. package/dist/practice-answers.js +42 -0
  48. package/dist/proposals.js +197 -0
  49. package/dist/quote.js +28 -0
  50. package/dist/report.js +261 -0
  51. package/dist/routing.js +411 -0
  52. package/dist/schema.js +1178 -0
  53. package/dist/server.js +514 -0
  54. package/dist/similarity.js +45 -0
  55. package/dist/sshsig.js +159 -0
  56. package/dist/state.js +135 -0
  57. package/dist/storage.js +61 -0
  58. package/dist/store.js +2259 -0
  59. package/dist/style.js +408 -0
  60. package/dist/sync.js +797 -0
  61. package/dist/trailers.js +121 -0
  62. package/dist/trust.js +222 -0
  63. package/dist/usage-fields.js +161 -0
  64. package/dist/vault.js +849 -0
  65. package/dist/verify.js +443 -0
  66. package/dist/version.js +70 -0
  67. package/dist/wait.js +449 -0
  68. package/dist/wikilinks.js +25 -0
  69. package/package.json +71 -4
package/dist/gate.js ADDED
@@ -0,0 +1,237 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { cite } from "./cite.js";
3
+ import { cmd, dim, failed, failure, head, id, indent, next, note, table, text, trailer, warning } from "./style.js";
4
+ import { CONFIG_PATH, detectFacts } from "./facts.js";
5
+ import { matcher } from "./globs.js";
6
+ import { answerLine, byLine, q, reviewerOf } from "./quote.js";
7
+ import { ACK_TRAILER, CARD_TRAILER } from "./trailers.js";
8
+ // The commit gate (D24, D26, D30, D46, D52, D53): every detected decision is covered by a card or an
9
+ // ack, and no commit builds on a rejected card or on a hard card without a verdict.
10
+ /** Which detected facts a card covers, by the facts it declares in `touches` (D53). */
11
+ export const KIND_OF_TOUCH = {
12
+ dependency: "dep",
13
+ schema: "schema",
14
+ "public-api": "api",
15
+ infra: "infra",
16
+ };
17
+ function lastVerdict(store, id) {
18
+ const card = store.card(id);
19
+ return store
20
+ .verdicts(id)
21
+ .filter((v) => v.revision === card?.meta.revision)
22
+ .at(-1);
23
+ }
24
+ /** Why a commit may not cite this card, or undefined if it may. */
25
+ function cardProblem(store, id) {
26
+ const card = store.card(id);
27
+ if (!card) {
28
+ return {
29
+ card: id,
30
+ problem: "unknown",
31
+ lines: [`${id} isn't a card here. File it first (\`baesian file\`), or fix the ID.`],
32
+ };
33
+ }
34
+ const status = store.status(id);
35
+ if (status === "withdrawn" || status === "superseded") {
36
+ return { card: id, problem: status, lines: [`${id} is ${status}: it no longer applies, so nothing should build on it.`] };
37
+ }
38
+ const last = lastVerdict(store, id);
39
+ if (status === "returned" && last?.response === "reject") {
40
+ const built = store.ledger(id).map((e) => e.meta.commit?.slice(0, 7)).filter(Boolean);
41
+ return {
42
+ card: id,
43
+ problem: "rejected",
44
+ lines: [
45
+ `${id} was rejected by ${byLine(reviewerOf(last))}${last.comment ? `: ${q(last.comment)}` : "."}`,
46
+ `Don't build on it: revise it (\`baesian revise ${id}\`) or withdraw it, and rework what depends on it.`,
47
+ ...(built.length ? [`Commits already built on it: ${built.join(", ")}`] : []),
48
+ ],
49
+ };
50
+ }
51
+ // The level in force: the card's, raised in a hosted repo by the relay's signed gate record (P16 section 3).
52
+ if (store.levelOf(id) === "hard" && status !== "resolved") {
53
+ return {
54
+ card: id,
55
+ problem: "hard-unresolved",
56
+ lines: [`${id} is hard and isn't resolved yet: nothing can build on it until it is${cite("D46")}.`],
57
+ };
58
+ }
59
+ return undefined;
60
+ }
61
+ export function evaluate(store, detection, trailers) {
62
+ // One consistent read: a card's status reads the others, and in a hosted repo checks each answer's signature (D138).
63
+ return store.view(() => evaluateIn(store, detection, trailers));
64
+ }
65
+ function evaluateIn(store, detection, trailers) {
66
+ const problems = trailers.cards.map((id) => cardProblem(store, id)).filter((p) => p !== undefined);
67
+ const usable = trailers.cards.filter((id) => !problems.some((p) => p.card === id));
68
+ const rules = store
69
+ .rules()
70
+ .filter((r) => !r.retired)
71
+ .map((r) => ({ id: r.meta.id, covers: matcher(r.meta.covers) }));
72
+ const covered = [];
73
+ const uncovered = [];
74
+ for (const fact of detection.facts) {
75
+ const reason = trailers.acks.get(fact.id);
76
+ const card = usable.find((id) => store.card(id).meta.touches.some((t) => KIND_OF_TOUCH[t] === fact.kind));
77
+ const rule = rules.find((r) => r.covers(fact.id));
78
+ if (reason)
79
+ covered.push({ fact, cover: { by: "ack", reason } });
80
+ else if (card)
81
+ covered.push({ fact, cover: { by: "card", card } });
82
+ else if (rule)
83
+ covered.push({ fact, cover: { by: "rule", rule: rule.id } });
84
+ else
85
+ uncovered.push(fact);
86
+ }
87
+ const ids = new Set(detection.facts.map((f) => f.id));
88
+ const unusedAcks = [...trailers.acks.keys()].filter((id) => !ids.has(id));
89
+ return {
90
+ facts: detection.facts,
91
+ covered,
92
+ uncovered,
93
+ problems,
94
+ malformed: trailers.malformed,
95
+ unusedAcks,
96
+ warnings: detection.warnings,
97
+ ok: uncovered.length === 0 && problems.length === 0 && trailers.malformed.length === 0,
98
+ };
99
+ }
100
+ /**
101
+ * Detect the staged facts where the preferences in force say to look (B-qkek1), as the gate at filing reads its rows:
102
+ * locally, the newest preference file in the live layer; in CI, the newest in the commit's own record; in a hosted repo,
103
+ * only one that counts, so a commit can't make the gate see less by writing a file of its own (D138, D163). With none,
104
+ * the built-in paths. Warns when the config still has the keys the gate read before, unless asked not to (`verify` says
105
+ * so once, for HEAD).
106
+ */
107
+ export function detectStaged(store, snap, opts = {}) {
108
+ const force = store.inForce();
109
+ const found = detectFacts(snap, force.settings.paths);
110
+ const warnings = [
111
+ ...(force.problem ? [`${force.problem}, so the gate looks where it does by default`] : []),
112
+ ...(opts.configWarning === false ? [] : configWarnings(store.config())),
113
+ ...found.warnings,
114
+ ];
115
+ return { facts: found.facts, warnings };
116
+ }
117
+ /** The keys a config still has that the gate read before B-qkek1, and reads no longer. */
118
+ export function staleGateKeys(config) {
119
+ return ["gate_schema", "gate_infra", "gate_ignore", "gate_api"].filter((k) => config[k] !== undefined);
120
+ }
121
+ /** What to say when the config still has them: they're ignored, and what to do instead (B-qkek1). */
122
+ export function configWarnings(config) {
123
+ const keys = staleGateKeys(config);
124
+ if (!keys.length)
125
+ return [];
126
+ const them = keys.length === 1 ? keys[0] : `${keys.slice(0, -1).join(", ")} and ${keys.at(-1)}`;
127
+ return [
128
+ `${CONFIG_PATH} still has ${them}, which the gate no longer reads${cite("B-qkek1")}: what it looks for is in your preferences now, which only you change, in the client (Your preferences, then "What the gate looks for"). Set it there if the built-in lists don't cover it, then delete ${keys.length === 1 ? "it" : "them"} from the config.`,
129
+ ];
130
+ }
131
+ /** Verdicts on this branch's cards given since the branch's last commit (D30). */
132
+ export function verdictsSinceLastCommit(store) {
133
+ let since = "";
134
+ try {
135
+ since = execFileSync("git", ["log", "-1", "--format=%cI", "HEAD"], {
136
+ cwd: store.repo.toplevel,
137
+ encoding: "utf8",
138
+ stdio: ["ignore", "pipe", "ignore"],
139
+ }).trim();
140
+ since = new Date(since).toISOString();
141
+ }
142
+ catch {
143
+ // no commits yet: everything is new
144
+ }
145
+ return store
146
+ .branchCards()
147
+ .flatMap((c) => store.verdicts(c.meta.id))
148
+ .filter((v) => v.at > since)
149
+ .sort((a, b) => a.at.localeCompare(b.at));
150
+ }
151
+ /** IDs in a sentence: "B-a", "B-a and B-b", "B-a, B-b and B-c". */
152
+ export const named = (ids) => (ids.length < 2 ? ids.join("") : `${ids.slice(0, -1).join(", ")} and ${ids.at(-1)}`);
153
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
154
+ /** Why the gate blocks, in a clause: what isn't covered, the cards it can't build on, the trailers it can't read. */
155
+ export function blockedBecause(r) {
156
+ const parts = [];
157
+ if (r.uncovered.length)
158
+ parts.push(`${plural(r.uncovered.length, "decision")} in it ${r.uncovered.length === 1 ? "isn't" : "aren't"} covered by a card or an ack`);
159
+ if (r.problems.length)
160
+ parts.push(`it cites ${named(r.problems.map((p) => p.card))}, which it can't build on`);
161
+ if (r.malformed.length)
162
+ parts.push(`${plural(r.malformed.length, "trailer")} can't be read`);
163
+ return parts.join("; ");
164
+ }
165
+ /** What to run for a card a commit can't build on: wait on a hard one, revise a rejected one. */
166
+ function problemStep(p) {
167
+ if (p.problem === "hard-unresolved")
168
+ return [next(`baesian wait ${p.card}`, "until it's resolved")];
169
+ if (p.problem === "rejected")
170
+ return [next(`baesian revise ${p.card} card.md`, "or withdraw it")];
171
+ return [];
172
+ }
173
+ /**
174
+ * Why a commit is blocked, and how to cover what isn't: the body of the gate's report, and of each failing commit in
175
+ * `verify`. The trailers to paste are lines of their own, at the start of the line, never styled (B-eyrt5).
176
+ */
177
+ export function blockedBody(r, opts = {}) {
178
+ const out = [];
179
+ for (const m of r.malformed)
180
+ out.push(indent(2, text("unreadable trailer: ", m)));
181
+ for (const p of r.problems) {
182
+ out.push(...indent(2, p.lines.map((l) => text(l))));
183
+ if (opts.steps !== false)
184
+ out.push(...problemStep(p));
185
+ }
186
+ if (r.uncovered.length) {
187
+ out.push(...table(undefined, r.uncovered.map((f) => [f.id, dim(f.detail)])));
188
+ out.push(indent(2, text("Cover each one with a trailer at the end of the message, after a blank line: a card whose touches include it (", cmd("baesian file"), "),")));
189
+ out.push(trailer(`${CARD_TRAILER}: <id>`));
190
+ out.push(indent(2, text("or, for a two-way door, an ack with a reason:")));
191
+ for (const f of r.uncovered)
192
+ out.push(trailer(`${ACK_TRAILER}: ${f.id} <why this is fine>`));
193
+ }
194
+ return out;
195
+ }
196
+ /** The decisions a passing commit has, each with what covers it. */
197
+ function coveredRows(r) {
198
+ return table(undefined, r.covered.map(({ fact, cover }) => [fact.id, cover.by === "card" ? id(cover.card) : cover.by === "rule" ? ["rule ", id(cover.rule)] : "ack"]));
199
+ }
200
+ /**
201
+ * What the gate prints: at commit time (`for: "commit"`, from the hooks, which prefix each line), or as `baesian check`
202
+ * previews it. Agents read this, so it ends with exactly what to do. New verdicts since the branch's last commit come
203
+ * after, the reviewer's words quoted with their name (D169).
204
+ */
205
+ export function describeReport(r, opts = {}) {
206
+ const commit = opts.for !== "check";
207
+ const out = [];
208
+ if (!r.ok) {
209
+ out.push(failed(failure(commit ? "commit blocked" : "this commit would be blocked"), ": ", blockedBecause(r)));
210
+ out.push(...blockedBody(r));
211
+ if (commit)
212
+ out.push(indent(2, text(dim("To bypass it in an emergency: git commit --no-verify. CI runs the same check."))));
213
+ else if (!opts.message && r.uncovered.length)
214
+ out.push(next("baesian check --message <file>", "check a message's trailers before you commit"));
215
+ }
216
+ else if (r.covered.length) {
217
+ const n = r.covered.length;
218
+ const covered = n === 1 ? "its decision is covered" : `its ${n} decisions are covered`;
219
+ out.push(head("done", commit ? covered : `this commit would pass: ${covered}`));
220
+ out.push(...coveredRows(r));
221
+ }
222
+ else if (!commit)
223
+ out.push(head("done", "no decisions detected in the staged changes"));
224
+ for (const w of r.warnings)
225
+ out.push(warning(w));
226
+ for (const a of r.unusedAcks)
227
+ out.push(note(`${ACK_TRAILER} ${a} matches nothing in this commit`));
228
+ const news = opts.news ?? [];
229
+ if (news.length) {
230
+ out.push(head("attention", plural(news.length, "new verdict"), " since this branch's last commit"));
231
+ // What the reviewer wrote is quoted, with their name (D169).
232
+ for (const v of news)
233
+ out.push(indent(2, text(id(v.card), " ", answerLine(v))));
234
+ }
235
+ return out;
236
+ }
237
+ //# sourceMappingURL=gate.js.map
package/dist/git.js ADDED
@@ -0,0 +1,100 @@
1
+ import { execFileSync } from "node:child_process";
2
+ export function git(cwd, args) {
3
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
4
+ }
5
+ /** The directory isn't inside a git repository. */
6
+ export class NotARepo extends Error {
7
+ }
8
+ export function openRepo(cwd = process.cwd()) {
9
+ let toplevel;
10
+ try {
11
+ toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
12
+ }
13
+ catch {
14
+ throw new NotARepo(`not inside a git repository: ${cwd}`);
15
+ }
16
+ const commonDir = git(toplevel, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
17
+ return { toplevel, commonDir, branch: currentBranch(toplevel) };
18
+ }
19
+ function currentBranch(toplevel) {
20
+ try {
21
+ // Works on an unborn branch too (a repo with no commits yet).
22
+ return git(toplevel, ["symbolic-ref", "--short", "HEAD"]);
23
+ }
24
+ catch {
25
+ return `detached-${git(toplevel, ["rev-parse", "--short", "HEAD"])}`;
26
+ }
27
+ }
28
+ /**
29
+ * The commits HEAD's history starts from (B-5gnea): one, or several where unrelated histories were merged. Undefined
30
+ * before the first commit. A shallow clone's boundary looks like a root: ask `isShallow()` first.
31
+ */
32
+ export function rootCommits(toplevel) {
33
+ try {
34
+ return git(toplevel, ["rev-list", "--max-parents=0", "HEAD"]).split("\n").filter(Boolean);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /** Whether this clone is shallow, so its oldest commits aren't the history's first (B-5gnea). */
41
+ export function isShallow(toplevel) {
42
+ try {
43
+ return git(toplevel, ["rev-parse", "--is-shallow-repository"]) === "true";
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * The repo's default branch (B-jr8mk), the trunk the branches panel draws on top, read in this order, the first that
51
+ * names one:
52
+ * 1. **the remote's HEAD**: `refs/remotes/origin/HEAD`, which `git clone` sets (and `git fetch` since git 2.48, where it's
53
+ * missing), naming the branch the remote calls its default; with no `origin`, the one remote's HEAD, if there's one;
54
+ * 2. **`init.defaultBranch`**, as git's config sets it at any level, if a local branch of that name exists: a setting for
55
+ * new repos says nothing of an older one that never had the branch;
56
+ * 3. **the branch `git init` ran on**: the local branch whose reflog starts with the repo's first commit,
57
+ * `commit (initial)`; the oldest, if an orphan branch started another. Reflogs are local and expire (90 days, by
58
+ * default), so a clone or an old repo may have none, but those have a remote's HEAD.
59
+ * Undefined when none does: the panel then guesses as it did. A few git calls, none of them over the network.
60
+ */
61
+ export function defaultBranch(toplevel) {
62
+ const tryGit = (args) => {
63
+ try {
64
+ return git(toplevel, args);
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ };
70
+ const heads = (tryGit(["for-each-ref", "--format=%(refname) %(symref)", "refs/remotes/*/HEAD"]) ?? "")
71
+ .split("\n")
72
+ .flatMap((l) => {
73
+ const m = /^refs\/remotes\/([^/]+)\/HEAD refs\/remotes\/\1\/(.+)$/.exec(l);
74
+ return m ? [{ remote: m[1], branch: m[2] }] : [];
75
+ });
76
+ const remote = heads.find((h) => h.remote === "origin") ?? (heads.length === 1 ? heads[0] : undefined);
77
+ if (remote)
78
+ return { branch: remote.branch, from: "remote" };
79
+ const configured = tryGit(["config", "--get", "init.defaultBranch"]);
80
+ if (configured && tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${configured}`]) !== undefined)
81
+ return { branch: configured, from: "init.defaultBranch" };
82
+ // `--date=unix` names each entry by when it was written, which is when that first commit was made here.
83
+ const initial = (tryGit(["log", "-g", "--all", "--grep-reflog=commit (initial)", "--date=unix", "--format=%gD"]) ?? "")
84
+ .split("\n")
85
+ .flatMap((l) => {
86
+ const m = /^refs\/heads\/(.+)@\{(\d+)\}$/.exec(l);
87
+ return m ? [{ at: Number(m[2]), branch: m[1] }] : [];
88
+ })
89
+ // A deleted branch's reflog goes with it, so each of these still exists.
90
+ .sort((a, b) => a.at - b.at);
91
+ return initial[0] ? { branch: initial[0].branch, from: "first-commit" } : undefined;
92
+ }
93
+ /** `feat/run-store` → `feat-run-store`. */
94
+ export function slug(text) {
95
+ return (text
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, "-")
98
+ .replace(/^-+|-+$/g, "") || "x");
99
+ }
100
+ //# sourceMappingURL=git.js.map
package/dist/globs.js ADDED
@@ -0,0 +1,120 @@
1
+ // Path globs for the gate's detectors: `**` crosses directories, `*` and `?` don't. Also used for
2
+ // fact-ID patterns in rules (`dep:pytest-*`).
3
+ export function globToRegExp(glob) {
4
+ let re = "";
5
+ for (let i = 0; i < glob.length; i++) {
6
+ const c = glob[i];
7
+ if (c === "*" && glob[i + 1] === "*") {
8
+ const slash = glob[i + 2] === "/";
9
+ re += slash ? "(?:.*/)?" : ".*";
10
+ i += slash ? 2 : 1;
11
+ }
12
+ else if (c === "*")
13
+ re += "[^/]*";
14
+ else if (c === "?")
15
+ re += "[^/]";
16
+ else
17
+ re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
18
+ }
19
+ return new RegExp(`^${re}$`);
20
+ }
21
+ export function matcher(globs) {
22
+ const res = globs.map(globToRegExp);
23
+ return (path) => res.some((r) => r.test(path));
24
+ }
25
+ function automatonOf(glob) {
26
+ const trans = [[]];
27
+ const eps = [[]];
28
+ const state = () => {
29
+ eps.push([]);
30
+ return trans.push([]) - 1;
31
+ };
32
+ let at = 0;
33
+ for (let i = 0; i < glob.length; i++) {
34
+ const c = glob[i];
35
+ const next = state();
36
+ if (c === "*" && glob[i + 1] === "*") {
37
+ if (glob[i + 2] === "/") {
38
+ // `**/`: nothing, or anything that ends in a slash.
39
+ const any = state();
40
+ eps[at].push(next, any);
41
+ trans[any].push(["any", any], [{ lit: "/" }, next]);
42
+ i += 2;
43
+ }
44
+ else {
45
+ trans[at].push(["any", at]);
46
+ eps[at].push(next);
47
+ i += 1;
48
+ }
49
+ }
50
+ else if (c === "*") {
51
+ trans[at].push(["nonslash", at]);
52
+ eps[at].push(next);
53
+ }
54
+ else {
55
+ trans[at].push([c === "?" ? "nonslash" : { lit: c }, next]);
56
+ }
57
+ at = next;
58
+ }
59
+ return { trans, eps, accept: at };
60
+ }
61
+ function closure(a, from) {
62
+ const seen = new Set(from);
63
+ const stack = [...from];
64
+ while (stack.length) {
65
+ for (const t of a.eps[stack.pop()]) {
66
+ if (seen.has(t))
67
+ continue;
68
+ seen.add(t);
69
+ stack.push(t);
70
+ }
71
+ }
72
+ return [...seen].sort((x, y) => x - y);
73
+ }
74
+ /** "" stands for every character the globs don't name: not a slash, and no glob's literal. */
75
+ const fits = (l, ch) => l === "any" || (l === "nonslash" ? ch !== "/" : l.lit === ch);
76
+ function step(a, states, ch) {
77
+ const to = states.flatMap((s) => a.trans[s].filter(([l]) => fits(l, ch)).map(([, t]) => t));
78
+ return to.length ? closure(a, to) : [];
79
+ }
80
+ /** How far the walk goes before it gives up, and counts the glob as not covered. Real lists stay far below it. */
81
+ const BUDGET = 50_000;
82
+ /** Whether `by` matches every path `glob` matches: true, false, or undefined when it can't tell within its budget. */
83
+ function covers(by, glob) {
84
+ if (by.includes(glob))
85
+ return true;
86
+ const g = automatonOf(glob);
87
+ const hs = by.map(automatonOf);
88
+ const alphabet = ["/", "", ...new Set([...glob, ...by.join("")].filter((c) => c !== "/"))];
89
+ const start = [closure(g, [0]), ...hs.map((h) => closure(h, [0]))];
90
+ const seen = new Set([JSON.stringify(start)]);
91
+ const queue = [start];
92
+ for (let i = 0; i < queue.length; i++) {
93
+ const [gs, ...hss] = queue[i];
94
+ // A path the glob matches, and nothing in `by` does.
95
+ if (gs.includes(g.accept) && !hss.some((s, j) => s.includes(hs[j].accept)))
96
+ return false;
97
+ for (const ch of alphabet) {
98
+ const g2 = step(g, gs, ch);
99
+ if (!g2.length)
100
+ continue;
101
+ const next = [g2, ...hss.map((s, j) => (s.length ? step(hs[j], s, ch) : s))];
102
+ const key = JSON.stringify(next);
103
+ if (seen.has(key))
104
+ continue;
105
+ if (seen.size >= BUDGET)
106
+ return undefined;
107
+ seen.add(key);
108
+ queue.push(next);
109
+ }
110
+ }
111
+ return true;
112
+ }
113
+ /**
114
+ * The globs in `globs` that `by` doesn't cover: each that matches some path no glob in `by` matches, by `globToRegExp()`'s
115
+ * reading. One it can't tell is counted as not covered (B-qkek1).
116
+ */
117
+ export function uncovered(by, globs) {
118
+ return globs.filter((g) => covers(by, g) !== true);
119
+ }
120
+ //# sourceMappingURL=globs.js.map
@@ -0,0 +1,146 @@
1
+ import { createHash } from "node:crypto";
2
+ import { basename } from "node:path";
3
+ import { VERSION, compareReleases } from "./version.js";
4
+ // D54: Baesian's hooks are a marked block inside whatever hook files the repo already uses, placed right after the
5
+ // shebang, because livedocs' pre-commit ends in `exec`. The block's text is here, apart from the installer in
6
+ // src/hooks.ts, so the fact detector can tell Baesian's own block from any other change to a hook (B-g6ywj).
7
+ /**
8
+ * The hooks Baesian installs. `post-merge` and `post-rewrite` find merges (D181): git runs the first after a merge or a
9
+ * fast-forward (`git pull` too), never `post-commit`; the second after a rebase (`git pull --rebase` too). `pre-push`
10
+ * records a branch pushed to the remote's default branch as merged into it (B-h7rsv), and never stops a push.
11
+ */
12
+ export const HOOKS = ["pre-commit", "commit-msg", "post-commit", "post-merge", "post-rewrite", "pre-push"];
13
+ /** The hooks that can't stop anything, or mustn't (B-h7rsv): with Baesian missing, or failing, they carry on quietly. */
14
+ const NEVER_STOPS = ["post-commit", "post-merge", "post-rewrite", "pre-push"];
15
+ export const BEGIN = "# >>> baesian >>>";
16
+ export const END = "# <<< baesian <<<";
17
+ /** Per-clone git config key holding the command that runs Baesian when it isn't on PATH (D48). */
18
+ export const FALLBACK_KEY = "baesian.fallback";
19
+ /** What `writeHook` writes for a hook that didn't exist: a shebang, then the block. */
20
+ export const NEW_HOOK = (block) => `#!/bin/sh\n${block}\n`;
21
+ /** Text quoted for a double-quoted shell string. */
22
+ const dq = (s) => s.replace(/[\\"$`]/g, (c) => `\\${c}`);
23
+ /**
24
+ * The marked block for one hook. It's the same on every machine, because `.githooks/` is committed:
25
+ * the machine-specific fallback lives in the clone's own git config, never in the file.
26
+ *
27
+ * In personal mode (B-4rwyz) it's in the clone's own hooks instead, written by `baesian init --personal`, before the line
28
+ * that runs the repo's hook. There `post-rewrite`'s and `pre-push`'s standard input has been read into `$baesian_input`
29
+ * first, and Baesian gets a copy of it, as the repo's hook does; and a missing Baesian says how to stop using it in this
30
+ * clone, rather than to commit with `--no-verify`.
31
+ *
32
+ * `pre-push` (B-h7rsv) reads the refs being pushed from its standard input. In shared mode the block sits at the top of
33
+ * the repo's own hook, whose lines after it may read them too (Git LFS's do): the input is copied to a temporary file,
34
+ * which Baesian reads and which becomes the script's standard input again, whole. It never stops a push: whatever
35
+ * Baesian does, the hook carries on.
36
+ */
37
+ export function hookBlock(name, personal) {
38
+ // commit-msg gets the message file; post-merge whether it was a squash; post-rewrite what rewrote (D181); pre-push the
39
+ // remote's name. post-rewrite's standard input (the commits rewritten) is left for the repo's own lines after the
40
+ // block: Baesian doesn't read it.
41
+ const withArg = name === "commit-msg" || name === "post-merge" || name === "post-rewrite" || name === "pre-push";
42
+ const pushing = name === "pre-push" && !personal;
43
+ const stdin = name === "post-rewrite" && !personal ? " </dev/null" : pushing ? ` <"$baesian_refs"` : "";
44
+ const pipe = (name === "post-rewrite" || name === "pre-push") && personal ? `printf '%s' "$baesian_input" | ` : "";
45
+ const args = withArg ? ` ${name} "$1"${stdin}` : ` ${name}`;
46
+ const evalArgs = withArg ? ` ${name} \\"\\$1\\"${stdin.replace(/"/g, '\\"').replace(/\$/g, "\\$")}` : ` ${name}`;
47
+ const never = NEVER_STOPS.includes(name);
48
+ // The hooks after the fact can't stop anything: they record the ledger and merges, so a missing binary is skipped
49
+ // quietly. Nor does pre-push stop a push, whatever happens (B-h7rsv).
50
+ const missing = never
51
+ ? " :"
52
+ : personal
53
+ ? ` echo "baesian: not on PATH; this clone's hooks run Baesian's gate (personal mode). Install it, or stop using it here: ${dq(personal.restore)}" >&2\n exit 1`
54
+ : ` echo "baesian: not on PATH; install it (npm i -g baesian) or commit with --no-verify (CI runs the same check)." >&2\n exit 1`;
55
+ const after = name === "pre-push" ? " || :" : " || exit $?";
56
+ return [
57
+ BEGIN,
58
+ personal
59
+ ? "# Baesian's commit gate, for this clone only (B-4rwyz). Managed by `baesian init --personal`."
60
+ : "# Baesian's commit gate (D54). Managed by `baesian init`: edit outside the markers.",
61
+ ...(pushing ? ['if baesian_refs="$(mktemp 2>/dev/null)"; then cat >"$baesian_refs"; exec <"$baesian_refs"; else baesian_refs=/dev/null; fi'] : []),
62
+ "if command -v baesian >/dev/null 2>&1; then",
63
+ ` ${pipe}baesian hook${args}${after}`,
64
+ `elif baesian_fallback="$(git config --get ${FALLBACK_KEY})" && [ -n "$baesian_fallback" ]; then`,
65
+ ` ${pipe}eval "$baesian_fallback hook${evalArgs}"${after}`,
66
+ "else",
67
+ missing,
68
+ "fi",
69
+ ...(pushing ? ['[ "$baesian_refs" = /dev/null ] || rm -f "$baesian_refs"'] : []),
70
+ END,
71
+ ].join("\n");
72
+ }
73
+ /** A hook's text with Baesian's block, and the line break after it, taken out. */
74
+ function withoutBlock(text) {
75
+ const start = text.indexOf(BEGIN);
76
+ const end = text.indexOf(END);
77
+ if (start < 0 || end < start)
78
+ return text;
79
+ const after = end + END.length;
80
+ return text.slice(0, start) + text.slice(text[after] === "\n" ? after + 1 : after);
81
+ }
82
+ /**
83
+ * Every block a released Baesian's `baesian init` wrote in a repo's committed hooks (B-np6af), by hook: the SHA-256 of
84
+ * the block's text, from its first marker to its last, as `hookBlock()` returned it in that release. Personal mode's
85
+ * blocks (B-4rwyz) are never committed, so they're not here. Teammates and CI are often a release apart, so a block any
86
+ * release wrote is Baesian's own, as this one's is (B-g6ywj).
87
+ *
88
+ * A release that changes a block adds the one it replaces here, if a release wrote it, and in `CHANGED_BLOCKS`, so
89
+ * `baesian init` writes it for one more release (B-amxkp); test/releases.test.ts checks every release tag's blocks are
90
+ * listed. A block found flawed is taken out, and from then on counts as any change to a hook.
91
+ * A release can't know the blocks of the releases after it: a newer release's block, which this list lacks, counts as a
92
+ * change to the hook here, as before B-np6af.
93
+ */
94
+ export const RELEASED_BLOCKS = {
95
+ "pre-commit": ["24cb63901109e4b7868618c4c51a88e5ddcaf72426ee5d40c4680bfc8b3921e9"], // v0.1.0 to v0.1.10
96
+ "commit-msg": ["2f4a00b996aefc51e88e05c18709f7a3ef59e5444ff30229488d1d4bebce24a3"], // v0.1.0 to v0.1.10
97
+ "post-commit": ["cae9c1b0840004bdee362cbb9d43f6184f99c972351105c3895a1b574757d214"], // v0.1.0 to v0.1.10
98
+ "post-merge": ["8912aee3dee4dfde79bf04687553b3a4d9400bc9351cf3cfbe8f15b9a0803c39"], // v0.1.5 to v0.1.10
99
+ "post-rewrite": ["944bb30617d56c767f8229cdb76cdb76b42f4684b799bba436c563e0fc69e73c"], // v0.1.5 to v0.1.10
100
+ "pre-push": ["ad03a493a081320304d0b2a1040a6c676725244bb818561372ee508ad12b08dd"], // v0.1.8 to v0.1.10
101
+ };
102
+ /** A block's digest, as `RELEASED_BLOCKS` lists it. */
103
+ export const blockDigest = (block) => createHash("sha256").update(block).digest("hex");
104
+ /**
105
+ * The hooks whose block a release changed, by hook (B-amxkp, "The old block for a release"): when a release changes what
106
+ * `hookBlock()` returns for a committed hook, it puts the block before here, and adds the new one's digest to
107
+ * `RELEASED_BLOCKS` once a release has written it. `baesian init` writes the block before until a release after `since`
108
+ * (`writtenBlock()`), so CI pinned a release behind (B-c0e0d), and a teammate a release behind, recognise every block a
109
+ * commit adds, as formats roll out under B-4p43b. An entry may go once no release it names is supported. None today.
110
+ */
111
+ export const CHANGED_BLOCKS = {};
112
+ /**
113
+ * The block `baesian init` writes in a repo's hooks (B-amxkp): this release's, `hookBlock()`, or while a change to it is
114
+ * new, the block before (`CHANGED_BLOCKS`). Personal mode's blocks are never committed, so they're written new at once.
115
+ */
116
+ export function writtenBlock(name, release = VERSION) {
117
+ const changed = CHANGED_BLOCKS[name];
118
+ return changed && compareReleases(changed.since, release) >= 0 ? changed.before : hookBlock(name);
119
+ }
120
+ /** Whether a block is one Baesian wrote for this hook: this release's, or any released one's (B-np6af). */
121
+ export function isBaesianBlock(name, block) {
122
+ return block === hookBlock(name) || RELEASED_BLOCKS[name].includes(blockDigest(block));
123
+ }
124
+ /**
125
+ * Whether a change to a hook file only adds, updates or swaps Baesian's own block (B-g6ywj, B-np6af): the file is a hook
126
+ * Baesian installs, its new block is one a Baesian's `baesian init` wrote for that hook, this release's or any released
127
+ * one's (`isBaesianBlock()`), and nothing outside the block changed. Such a change isn't an infra fact: its text is
128
+ * Baesian's, so an ack on it would record nothing anyone decided. Any other change to a hook is one (D135), as is
129
+ * deleting a hook or taking the block out. The commit gate and `baesian verify` both judge hooks by it.
130
+ */
131
+ export function onlyBaesianBlock(path, before, after) {
132
+ const name = basename(path);
133
+ if (after === undefined || !HOOKS.includes(name))
134
+ return false;
135
+ const start = after.indexOf(BEGIN);
136
+ const end = after.indexOf(END);
137
+ if (start < 0 || end < start)
138
+ return false;
139
+ const block = after.slice(start, end + END.length);
140
+ if (!isBaesianBlock(name, block))
141
+ return false;
142
+ if (after.indexOf(BEGIN, start + 1) >= 0)
143
+ return false;
144
+ return before === undefined ? after === NEW_HOOK(block) : withoutBlock(before) === withoutBlock(after);
145
+ }
146
+ //# sourceMappingURL=hookblock.js.map