@bluewombat/slots 0.2.0 → 0.4.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/README.md CHANGED
@@ -3,37 +3,35 @@
3
3
  The Planner, Builder, and Gate commands that ship with mason. A Project points
4
4
  its config at one of these paths; nothing here is imported.
5
5
 
6
- ```json
7
- "planner": ["node", "./node_modules/@bluewombat/slots/planners/one-subtask.mjs"],
8
- "builder": {
9
- "producer": {
10
- "cmd": [
11
- "node", "./node_modules/@bluewombat/slots/builders/producer.mjs",
12
- "--prompt-file", "./build.md",
13
- "--",
14
- "node", "./node_modules/@bluewombat/slots/agents/claude.mjs"
15
- ],
16
- "timeoutMs": 600000
17
- },
18
- "repair": {
19
- "cmd": [
20
- "node", "./node_modules/@bluewombat/slots/builders/repair.mjs",
21
- "--prompt-file", "./repair.md",
22
- "--",
23
- "node", "./node_modules/@bluewombat/slots/agents/claude.mjs"
24
- ],
25
- "timeoutMs": 600000
26
- },
27
- "gates": {
28
- "defaultTimeoutMs": 120000,
29
- "gates": [
30
- {
31
- "id": "workspace-changed",
32
- "argv": ["node", "./node_modules/@bluewombat/slots/gates/workspace-changed.mjs"]
33
- }
34
- ]
35
- }
36
- }
6
+ ```yaml
7
+ planner:
8
+ cmd: [node, ./node_modules/@bluewombat/slots/planners/one-subtask.mjs]
9
+ builder:
10
+ producer:
11
+ cmd:
12
+ - node
13
+ - ./node_modules/@bluewombat/slots/builders/producer.mjs
14
+ - --prompt-file
15
+ - ./build.md
16
+ - --
17
+ - node
18
+ - ./node_modules/@bluewombat/slots/agents/claude.mjs
19
+ timeoutMs: 600000
20
+ repair:
21
+ cmd:
22
+ - node
23
+ - ./node_modules/@bluewombat/slots/builders/repair.mjs
24
+ - --prompt-file
25
+ - ./repair.md
26
+ - --
27
+ - node
28
+ - ./node_modules/@bluewombat/slots/agents/claude.mjs
29
+ timeoutMs: 600000
30
+ gates:
31
+ defaultTimeoutMs: 120000
32
+ gates:
33
+ - id: workspace-changed
34
+ argv: [node, ./node_modules/@bluewombat/slots/gates/workspace-changed.mjs]
37
35
  ```
38
36
 
39
37
  A slot is an opaque command: it reads argv and writes one JSON line on stdout.
@@ -69,6 +67,7 @@ these all use it. How Host wires a slot into a run:
69
67
  | `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
70
68
  | `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
71
69
  | `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
70
+ | `gates/gitignore-leak.mjs` | Gate | The feature's commits add nothing its own `.gitignore` excludes |
72
71
  | `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
73
72
  | `planners/one-subtask.mjs` | Planner | Bootstrap: one Subtask that is the FeatureStandard itself |
74
73
  | `publishers/git.mjs` | Publisher | Pushes the feature's branch to the work line's own remote |
@@ -85,10 +84,12 @@ and print the serialized run on stdout with `writeContract` (so a large
85
84
  stream-json transcript survives `process.exit`). They do not know `--intention`
86
85
  or `--report`. A role slot after `--` is what names one:
87
86
 
88
- ```json
89
- "--",
90
- "node", "./node_modules/@bluewombat/slots/agents/cursor.mjs",
91
- "--transcript-dir", "./.mason/transcripts"
87
+ ```yaml
88
+ - --
89
+ - node
90
+ - ./node_modules/@bluewombat/slots/agents/cursor.mjs
91
+ - --transcript-dir
92
+ - ./.mason/transcripts
92
93
  ```
93
94
 
94
95
  `cursor.mjs` needs `cursor-agent` on PATH (`cursor-agent login`); `claude.mjs`
@@ -156,18 +157,18 @@ in: it names no part of mason, because none of it is visible from there. What
156
157
  changes from one Project to the next is that text, not the agent, so it is a
157
158
  template file on the role:
158
159
 
159
- ```json
160
- "builder": {
161
- "producer": {
162
- "cmd": [
163
- "node", "./node_modules/@bluewombat/slots/builders/producer.mjs",
164
- "--prompt-file", "./mason-prompt.md",
165
- "--",
166
- "node", "./node_modules/@bluewombat/slots/agents/cursor.mjs"
167
- ],
168
- "timeoutMs": 600000
169
- }
170
- }
160
+ ```yaml
161
+ builder:
162
+ producer:
163
+ cmd:
164
+ - node
165
+ - ./node_modules/@bluewombat/slots/builders/producer.mjs
166
+ - --prompt-file
167
+ - ./mason-prompt.md
168
+ - --
169
+ - node
170
+ - ./node_modules/@bluewombat/slots/agents/cursor.mjs
171
+ timeoutMs: 600000
171
172
  ```
172
173
 
173
174
  A path that names a file next to the config resolves against the config; any
@@ -253,18 +254,18 @@ them — `skills` and `usage` (`unknown`, `(none)`, or the values). Files are
253
254
  filed under the Feature the Task belongs to, named for the Subtask and the
254
255
  Attempt. It belongs on the **agent** command, after `--`.
255
256
 
256
- ```json
257
- "builder": {
258
- "producer": {
259
- "cmd": [
260
- "node", "./node_modules/@bluewombat/slots/builders/producer.mjs",
261
- "--",
262
- "node", "./node_modules/@bluewombat/slots/agents/cursor.mjs",
263
- "--transcript-dir", "./.mason/transcripts"
264
- ],
265
- "timeoutMs": 600000
266
- }
267
- }
257
+ ```yaml
258
+ builder:
259
+ producer:
260
+ cmd:
261
+ - node
262
+ - ./node_modules/@bluewombat/slots/builders/producer.mjs
263
+ - --
264
+ - node
265
+ - ./node_modules/@bluewombat/slots/agents/cursor.mjs
266
+ - --transcript-dir
267
+ - ./.mason/transcripts
268
+ timeoutMs: 600000
268
269
  ```
269
270
 
270
271
  ```
@@ -287,29 +288,21 @@ Every Gate answers with one JSON object on stdout — `{"verdict":"pass"}`, or
287
288
  in, and what mason appends to its argv, is in
288
289
  [`@bluewombat/runtime`](../../host/runtime/README.md).
289
290
 
290
- ```json
291
- "builder": { "gates": {
292
- "defaultTimeoutMs": 120000,
293
- "gates": [
294
- {
295
- "id": "workspace-changed",
296
- "argv": ["node", "./node_modules/@bluewombat/slots/gates/workspace-changed.mjs"]
297
- },
298
- {
299
- "id": "parent-clean",
300
- "argv": ["node", "./node_modules/@bluewombat/slots/gates/parent-clean.mjs"]
301
- },
302
- {
303
- "id": "sensitive-path",
304
- "argv": [
305
- "node",
306
- "./node_modules/@bluewombat/slots/gates/sensitive-path.mjs",
307
- "**/.env",
308
- "**/secrets/**"
309
- ]
310
- }
311
- ]
312
- } }
291
+ ```yaml
292
+ builder:
293
+ gates:
294
+ defaultTimeoutMs: 120000
295
+ gates:
296
+ - id: workspace-changed
297
+ argv: [node, ./node_modules/@bluewombat/slots/gates/workspace-changed.mjs]
298
+ - id: parent-clean
299
+ argv: [node, ./node_modules/@bluewombat/slots/gates/parent-clean.mjs]
300
+ - id: sensitive-path
301
+ argv:
302
+ - node
303
+ - ./node_modules/@bluewombat/slots/gates/sensitive-path.mjs
304
+ - "**/.env"
305
+ - "**/secrets/**"
313
306
  ```
314
307
 
315
308
  `workspace-changed` fail-retries when the workspace has no uncommitted change —
@@ -322,31 +315,52 @@ producer to send back.
322
315
  (`--parent DIR`, or the other git worktrees of this Child).
323
316
 
324
317
  `sensitive-path` fail-retries if the workspace changed a path matching a glob.
325
- `*` is one segment, `**` any depth.
318
+ `*` is one segment, `**` any depth. It looks at what a fold can carry out of
319
+ the workspace — tracked paths, and new ones `.gitignore` does not exclude — so
320
+ what an install left under an ignored directory (a dependency's own `.github/`)
321
+ is not a change to guard.
322
+
323
+ `gitignore-leak --base REF` fail-retries if the feature's commits add a path the
324
+ workspace's own `.gitignore` excludes — a Builder's `node_modules/`, a build's
325
+ `dist/`, a generated file. `REF` is the work line the feature is offered to
326
+ (`main`): only what the feature adds since it left that line is judged, and
327
+ only what is committed, so a path tracked on purpose before the feature began
328
+ is not blamed on it, and a removal staged but not committed does not pass. The
329
+ report is written for `assembly.fix`: untrack the paths, or change `.gitignore`
330
+ if tracking one is the point. It passes at `--stage unit`, where no commit
331
+ holds a Subtask's work yet.
332
+
333
+ It is a second line, not the first: the `isolation-git` fold already keeps an
334
+ ignored path out of the commits it makes. It catches a leak that came some
335
+ other way — another isolation strategy, an agent that committed. Like every
336
+ assembly Gate, with an Authority it judges what was published, so it stops the
337
+ fold into the work line, not the push.
338
+
339
+ ```yaml
340
+ assembly:
341
+ gates:
342
+ defaultTimeoutMs: 900000
343
+ gates:
344
+ - id: gitignore-leak
345
+ argv: [node, ./node_modules/@bluewombat/slots/gates/gitignore-leak.mjs, --base, main]
346
+ - id: ci-green
347
+ argv:
348
+ - node
349
+ - ./node_modules/@bluewombat/slots/gates/ci-green.mjs
350
+ - --token-env
351
+ - MASON_GITHUB_TOKEN
352
+ - --require-checks
353
+ ```
326
354
 
327
355
  `ci-green` reads the work line's own checks on what this workspace published,
328
- and answers on them. It reads and nothing else — it does not push, does not open
329
- a pull request, does not merge. Whoever put the work in front of the checks did
330
- that before the Gate ran; a workspace with nothing published is `fail-blocking`,
331
- because no Attempt of that Task can change it.
332
-
333
- ```json
334
- "assembly": { "gates": {
335
- "defaultTimeoutMs": 900000,
336
- "gates": [
337
- {
338
- "id": "ci-green",
339
- "argv": [
340
- "node",
341
- "./node_modules/@bluewombat/slots/gates/ci-green.mjs",
342
- "--token-env",
343
- "MASON_GITHUB_TOKEN",
344
- "--require-checks"
345
- ]
346
- }
347
- ]
348
- } }
349
- ```
356
+ and answers on them: GitHub Actions' check runs and the commit statuses a CI
357
+ outside Actions reports (Vercel, Netlify, Jenkins…) — the two lists GitHub
358
+ shows on a pull request. A pending status is still running; a failed one's
359
+ report is its context, what it said, and its link. It reads and nothing else —
360
+ it does not push, does not open a pull request, does not merge. Whoever put
361
+ the work in front of the checks did that before the Gate ran; a workspace
362
+ with nothing published is `fail-blocking`, because no Attempt of that Task
363
+ can change it.
350
364
 
351
365
  It waits while checks are running, so give it a ceiling of its own. On a red
352
366
  check the report carries the failing job's log, cut at the error the runner
@@ -358,11 +372,11 @@ the variable holding a token that can read the checks — the same one the
358
372
  manager's `tokenEnv` names. It has no default on purpose: `GITHUB_TOKEN` is
359
373
  what `gh` reads ahead of its own login, so an operator must not export it,
360
374
  and a Gate that fell back to it refused only at the first assembly of a real
361
- run. `--remote` and `--poll-ms` are there too. It talks to GitHub, and knows
375
+ run. `--remote`, `--poll-ms` and `--api-base` (GitHub Enterprise) are there too. It talks to GitHub, and knows
362
376
  nothing about `@bluewombat/manager-github`: a Project may run either one
363
377
  without the other.
364
378
 
365
- `sensitive-path`, `workspace-changed` and `ci-green` need a git workspace, which
379
+ `sensitive-path`, `workspace-changed`, `gitignore-leak` and `ci-green` need a git workspace, which
366
380
  is what Isolator makes when WorkLineStable is a git tree. They fail-block on a
367
381
  workspace Isolator had to copy.
368
382
 
@@ -376,16 +390,17 @@ it takes no option.
376
390
  before answering and writes nothing into it. `--prompt-file` and `--read` belong
377
391
  on the role; the vendor is named after `--`, the same way a Builder names it.
378
392
 
379
- ```json
380
- "planner": {
381
- "cmd": [
382
- "node", "./node_modules/@bluewombat/slots/planners/producer.mjs",
383
- "--read", "./work-line-stable",
384
- "--",
385
- "node", "./node_modules/@bluewombat/slots/agents/cursor.mjs"
386
- ],
387
- "timeoutMs": 600000
388
- }
393
+ ```yaml
394
+ planner:
395
+ cmd:
396
+ - node
397
+ - ./node_modules/@bluewombat/slots/planners/producer.mjs
398
+ - --read
399
+ - ./work-line-stable
400
+ - --
401
+ - node
402
+ - ./node_modules/@bluewombat/slots/agents/cursor.mjs
403
+ timeoutMs: 600000
389
404
  ```
390
405
 
391
406
  | Option | Meaning |
@@ -430,6 +445,9 @@ that places its work some other way writes its own Publisher and changes nothing
430
445
  else. A refusal stops the Submission and travels back to the tracker; a slot that
431
446
  could not run at all exits non-zero and says why on stderr.
432
447
 
448
+ `git.mjs` runs git with `LC_ALL=C`: the refusal quotes git, and it is posted on a
449
+ tracker a team reads, whatever language the machine that ran it speaks.
450
+
433
451
  ## Refreshers
434
452
 
435
453
  The other half of the Publisher's seam: what the Authority accepted has moved the
@@ -450,7 +468,8 @@ else, so it answers `ok` with nothing to do.
450
468
  A refusal stops the pass: nothing starts, and the next one tries again. The
451
469
  reason is written for the person who reads `mason run`'s trace: the copy is the
452
470
  system's, nothing in it is worth keeping, and the reason says the one command
453
- that puts it back.
471
+ that puts it back. Git runs with `LC_ALL=C` here too, for the same reason as the
472
+ Publisher.
454
473
 
455
474
  ## Stack
456
475
 
@@ -2,13 +2,16 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
4
4
 
5
- // The work line's own checks, on the work this workspace published.
5
+ // The work line's own checks, on the work this workspace published: GitHub
6
+ // Actions' check runs, and the commit statuses CI outside Actions reports
7
+ // (Vercel, Netlify, Jenkins…), the two lists GitHub itself shows on a PR.
6
8
  //
7
9
  // It reads and nothing else: it does not push, does not open anything, does not
8
10
  // merge. Whoever put the work in front of the checks did that before this Gate
9
11
  // ran; this Gate only says whether the answer came back green.
10
12
  //
11
13
  // --remote NAME default: origin
14
+ // --api-base URL default: https://api.github.com (GitHub Enterprise)
12
15
  // --poll-ms N how often to ask again while checks are running
13
16
  // --token-env NAME required: the variable holding a token that can read
14
17
  // the checks. No default on purpose — GITHUB_TOKEN is the
@@ -18,7 +21,6 @@ import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
18
21
  // --require-checks wait for a check to appear; without it, a commit with no
19
22
  // check at all passes
20
23
 
21
- const API = "https://api.github.com";
22
24
  // What the runner writes at the top of every step it runs.
23
25
  const STEP_OPEN = "##[group]Run ";
24
26
  const COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
@@ -29,6 +31,7 @@ if (!options.ok) {
29
31
  emitVerdict("fail-blocking", options.reason);
30
32
  process.exit(0);
31
33
  }
34
+ const apiBase = options.apiBase;
32
35
 
33
36
  if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
34
37
  emitVerdict("fail-blocking", "ci-green needs a git workspace.");
@@ -88,10 +91,11 @@ for (;;) {
88
91
 
89
92
  /**
90
93
  * @param {readonly string[]} tokens
91
- * @returns {{ ok: true; remote: string; pollMs: number; tokenEnv: string; requireChecks: boolean } | { ok: false; reason: string }}
94
+ * @returns {{ ok: true; remote: string; apiBase: string; pollMs: number; tokenEnv: string; requireChecks: boolean } | { ok: false; reason: string }}
92
95
  */
93
96
  function parseOwnArgv(tokens) {
94
97
  let remote = "origin";
98
+ let apiBase = "https://api.github.com";
95
99
  let pollMs = 15_000;
96
100
  let tokenEnv;
97
101
  let requireChecks = false;
@@ -108,6 +112,8 @@ function parseOwnArgv(tokens) {
108
112
  i += 1;
109
113
  if (token === "--remote") {
110
114
  remote = value;
115
+ } else if (token === "--api-base") {
116
+ apiBase = value.replace(/\/+$/, "");
111
117
  } else if (token === "--token-env") {
112
118
  tokenEnv = value;
113
119
  } else if (token === "--poll-ms") {
@@ -127,7 +133,7 @@ function parseOwnArgv(tokens) {
127
133
  "ci-green needs --token-env NAME: the environment variable holding a token that can read the checks (the same one @bluewombat/manager-github's tokenEnv names, never GITHUB_TOKEN).",
128
134
  };
129
135
  }
130
- return { ok: true, remote, pollMs, tokenEnv, requireChecks };
136
+ return { ok: true, remote, apiBase, pollMs, tokenEnv, requireChecks };
131
137
  }
132
138
 
133
139
  /**
@@ -187,6 +193,12 @@ async function readChecks(sha) {
187
193
  if (!Array.isArray(checkRuns)) {
188
194
  return undefined;
189
195
  }
196
+ // A token that may read check runs but not statuses reads as no status at
197
+ // all: what this Gate did before it read them.
198
+ const combined = record(
199
+ await api(`/repos/${repo.owner}/${repo.name}/commits/${sha}/status?per_page=100`),
200
+ );
201
+ const statuses = Array.isArray(combined?.statuses) ? combined.statuses : [];
190
202
  /** @type {string[]} */
191
203
  const failed = [];
192
204
  // One workflow can run the same job for two events (push, pull_request)
@@ -213,7 +225,28 @@ async function readChecks(sha) {
213
225
  seen.add(heading);
214
226
  failed.push(`${heading}${typeof run.id === "number" ? await logOf(run.id) : ""}`);
215
227
  }
216
- return { total: checkRuns.length, running, failed };
228
+ // The latest status per context. No log to cut here: what the reporter said
229
+ // and where it said more is all there is.
230
+ for (const entry of statuses) {
231
+ const status = record(entry) ?? {};
232
+ if (status.state === "pending") {
233
+ running += 1;
234
+ continue;
235
+ }
236
+ if (status.state === "success") {
237
+ continue;
238
+ }
239
+ const heading = `${status.context}: ${status.state ?? "failed"}`;
240
+ if (seen.has(heading)) {
241
+ continue;
242
+ }
243
+ seen.add(heading);
244
+ const more = [status.description, status.target_url].filter(
245
+ (text) => typeof text === "string" && text.length > 0,
246
+ );
247
+ failed.push([heading, ...more].join("\n"));
248
+ }
249
+ return { total: checkRuns.length + statuses.length, running, failed };
217
250
  }
218
251
 
219
252
  /**
@@ -279,7 +312,7 @@ async function logOf(jobId) {
279
312
  */
280
313
  async function api(path, as = "json") {
281
314
  try {
282
- const response = await fetch(`${API}${path}`, {
315
+ const response = await fetch(`${apiBase}${path}`, {
283
316
  headers: {
284
317
  authorization: `Bearer ${token}`,
285
318
  accept: "application/vnd.github+json",
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { emitVerdict, GATE_FLAGS, ownArgv, stageOf } from "@bluewombat/slot-kit";
4
+
5
+ // Fail-retryable when the feature's commits add a path the workspace's own
6
+ // .gitignore excludes — a Builder's node_modules/, a build's dist/, a generated
7
+ // file — so it would ship in the Submission.
8
+ //
9
+ // --base REF (required): the work line the feature is offered to. Only what the
10
+ // feature adds since it left that line is judged, in what is committed: a path
11
+ // tracked on purpose before the feature began is not this feature's leak, and
12
+ // a removal staged but not committed has not removed anything yet.
13
+ //
14
+ // A unit only: nothing. A Subtask's work is uncommitted until a fold records it,
15
+ // so there is no commit of its own here to judge.
16
+
17
+ const cwd = process.cwd();
18
+ const tokens = ownArgv(process.argv, GATE_FLAGS);
19
+
20
+ let base;
21
+ for (let i = 0; i < tokens.length; i++) {
22
+ const token = tokens[i];
23
+ if (token === "--base") {
24
+ const value = tokens[i + 1];
25
+ if (value === undefined || value.startsWith("-")) {
26
+ emitVerdict("fail-blocking", "gitignore-leak needs a ref after --base.");
27
+ process.exit(0);
28
+ }
29
+ base = value;
30
+ i += 1;
31
+ continue;
32
+ }
33
+ emitVerdict("fail-blocking", `gitignore-leak does not take "${token}". Use --base <ref>.`);
34
+ process.exit(0);
35
+ }
36
+
37
+ if (base === undefined) {
38
+ emitVerdict(
39
+ "fail-blocking",
40
+ "gitignore-leak needs --base <ref>: the work line the feature is offered to.",
41
+ );
42
+ process.exit(0);
43
+ }
44
+
45
+ if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
46
+ emitVerdict("fail-blocking", "gitignore-leak needs a git workspace.");
47
+ process.exit(0);
48
+ }
49
+
50
+ if (stageOf(process.argv) !== "assembly") {
51
+ emitVerdict("pass");
52
+ process.exit(0);
53
+ }
54
+
55
+ if (git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).status !== 0) {
56
+ emitVerdict("fail-blocking", `gitignore-leak cannot find --base "${base}" in this workspace.`);
57
+ process.exit(0);
58
+ }
59
+
60
+ // --no-renames: a path moved under an ignored directory shows as added there.
61
+ const added = git([
62
+ "diff",
63
+ "--name-only",
64
+ "--no-renames",
65
+ "--diff-filter=A",
66
+ "-z",
67
+ `${base}...HEAD`,
68
+ ]);
69
+ if (added.status !== 0) {
70
+ emitVerdict(
71
+ "fail-blocking",
72
+ `gitignore-leak cannot diff HEAD against "${base}": ${added.stderr.trim()}`,
73
+ );
74
+ process.exit(0);
75
+ }
76
+ if (added.stdout.length === 0) {
77
+ emitVerdict("pass");
78
+ process.exit(0);
79
+ }
80
+
81
+ // --no-index: these paths are tracked, and the question is whether the
82
+ // patterns match them, not whether git would still pick them up.
83
+ const ignored = spawnSync("git", ["-C", cwd, "check-ignore", "--no-index", "--stdin", "-z"], {
84
+ input: added.stdout,
85
+ encoding: "utf8",
86
+ });
87
+ // 0: some match; 1: none does; anything else is git failing.
88
+ if (ignored.status === 1) {
89
+ emitVerdict("pass");
90
+ process.exit(0);
91
+ }
92
+ if (ignored.status !== 0) {
93
+ emitVerdict(
94
+ "fail-blocking",
95
+ `gitignore-leak cannot run git check-ignore: ${ignored.stderr.trim()}`,
96
+ );
97
+ process.exit(0);
98
+ }
99
+
100
+ const leaked = ignored.stdout.split("\0").filter(Boolean);
101
+ const shown = leaked.slice(0, 50);
102
+ const rest = leaked.length - shown.length;
103
+ emitVerdict(
104
+ "fail-retryable",
105
+ [
106
+ `These paths are committed on top of ${base}, and this workspace's .gitignore excludes them.`,
107
+ "Take them out of the commit with git rm -r --cached <path> and keep the files. If the",
108
+ "feature does mean to track one, change .gitignore so it no longer excludes it:",
109
+ ...shown,
110
+ ...(rest > 0 ? [`… and ${rest} more`] : []),
111
+ ].join("\n"),
112
+ );
113
+
114
+ /** @param {string[]} args */
115
+ function git(args) {
116
+ return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
117
+ }
@@ -1,11 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { readdirSync, readFileSync } from "node:fs";
4
- import { join, relative } from "node:path";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
5
  import { emitVerdict, GATE_FLAGS, ownArgv, pathMatchesGlob } from "@bluewombat/slot-kit";
6
6
 
7
7
  // Fail-retryable when the workspace changed a path matching a glob.
8
8
  // Remaining argv after Implementer's flags are the globs.
9
+ //
10
+ // Only paths a fold can carry out of the workspace: tracked ones, and new ones
11
+ // .gitignore does not exclude. What a Builder's own install left under an
12
+ // ignored directory (a dependency's own .github/) stays in the workspace, so
13
+ // changing it is not a change to guard.
9
14
 
10
15
  const cwd = process.cwd();
11
16
  const globs = ownArgv(process.argv, GATE_FLAGS).filter((token) => token.length > 0);
@@ -22,17 +27,23 @@ if (git(cwd, ["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
22
27
 
23
28
  const hit = new Set();
24
29
 
25
- walkFiles(cwd, (file) => {
26
- const posix = relative(cwd, file).replaceAll("\\", "/");
27
- if (!matchesAny(posix, globs)) {
28
- return;
30
+ const listed = git(cwd, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
31
+ if (listed.status !== 0) {
32
+ emitVerdict("fail-blocking", `sensitive-path cannot list the workspace: ${listed.stderr.trim()}`);
33
+ process.exit(0);
34
+ }
35
+ for (const posix of new Set(listed.stdout.split("\0").filter(Boolean))) {
36
+ const file = join(cwd, posix);
37
+ // A tracked path gone from disk is a deletion, read below.
38
+ if (!matchesAny(posix, globs) || !existsSync(file)) {
39
+ continue;
29
40
  }
30
41
  const onDisk = readFileSync(file);
31
42
  const atHead = blobAtHead(cwd, posix);
32
43
  if (atHead === null || Buffer.compare(onDisk, atHead) !== 0) {
33
44
  hit.add(posix);
34
45
  }
35
- });
46
+ }
36
47
 
37
48
  const deleted = git(cwd, ["diff", "--name-only", "--diff-filter=D", "HEAD"]);
38
49
  if (deleted.status === 0) {
@@ -70,32 +81,6 @@ function blobAtHead(workspace, path) {
70
81
  return result.stdout;
71
82
  }
72
83
 
73
- /**
74
- * @param {string} dir
75
- * @param {(file: string) => void} visit
76
- */
77
- function walkFiles(dir, visit) {
78
- let entries;
79
- try {
80
- entries = readdirSync(dir, { withFileTypes: true });
81
- } catch {
82
- return;
83
- }
84
- for (const entry of entries) {
85
- if (entry.name === ".git") {
86
- continue;
87
- }
88
- const full = join(dir, entry.name);
89
- if (entry.isDirectory()) {
90
- walkFiles(full, visit);
91
- continue;
92
- }
93
- if (entry.isFile()) {
94
- visit(full);
95
- }
96
- }
97
- }
98
-
99
84
  /**
100
85
  * @param {string} workspace
101
86
  * @param {string[]} args
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluewombat/slots",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
6
6
  "keywords": [
@@ -53,6 +53,6 @@
53
53
  "test": "tsx --test $(find test -name '*.test.ts')"
54
54
  },
55
55
  "dependencies": {
56
- "@bluewombat/slot-kit": "^0.2.0"
56
+ "@bluewombat/slot-kit": "^0.4.0"
57
57
  }
58
58
  }
@@ -46,8 +46,11 @@ if (!existsSync(join(cwd, ".git"))) {
46
46
  process.exit(0);
47
47
  }
48
48
 
49
+ // C locale: git's words become the refusal a team reads on the tracker, and a
50
+ // machine's own language would put French or German in an English thread.
49
51
  const pushed = spawnSync("git", ["-C", cwd, "push", remote, `${ref}:${ref}`], {
50
52
  encoding: "utf8",
53
+ env: { ...process.env, LC_ALL: "C" },
51
54
  });
52
55
  if (pushed.error !== undefined) {
53
56
  writeDiagnostic(`git publisher cannot run git: ${pushed.error.message}\n`);
@@ -69,7 +69,13 @@ if (merged.status !== 0) {
69
69
 
70
70
  emitRefreshed();
71
71
 
72
- /** @param {string[]} args */
72
+ /**
73
+ * C locale, as in the Publisher: what git says here ends up in a report.
74
+ * @param {string[]} args
75
+ */
73
76
  function git(args) {
74
- return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
77
+ return spawnSync("git", ["-C", cwd, ...args], {
78
+ encoding: "utf8",
79
+ env: { ...process.env, LC_ALL: "C" },
80
+ });
75
81
  }