@gobing-ai/spur 0.4.7 → 0.4.9

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/pipeline-budgets.json +2 -2
  3. package/config/rules/boundary/config-loading-ownership.yaml +1 -0
  4. package/config/rules/boundary/sp-plugin-standalone.yaml +1 -1
  5. package/config/rules/boundary/sp-script-placement.yaml +1 -1
  6. package/config/rules/docs/sourced-api-claims.yaml +45 -7
  7. package/config/rules/strict/runtime-boundaries.yaml +40 -1
  8. package/config/rules/structure/protected-files.yaml +1 -1
  9. package/config/rules/surface/check-cli-surface.yaml +33 -0
  10. package/config/rules/typescript/no-debugger.yaml +3 -0
  11. package/config/workflow-candidates.json +2 -2
  12. package/config/workflows/feature-verification.yaml +6 -11
  13. package/config/workflows/history-anatomy.yaml +14 -28
  14. package/config/workflows/idea-pipeline.yaml +6 -19
  15. package/config/workflows/pr-review.yaml +9 -16
  16. package/config/workflows/task-pipeline.yaml +16 -23
  17. package/config/workflows/wrapup-pipeline.yaml +21 -63
  18. package/package.json +3 -3
  19. package/plugins/sp/README.md +70 -37
  20. package/plugins/sp/agents/super-planner.md +5 -5
  21. package/plugins/sp/agents/super-reviewer.md +2 -2
  22. package/plugins/sp/commands/dev-changelog.md +1 -1
  23. package/plugins/sp/commands/dev-daily.md +1 -1
  24. package/plugins/sp/commands/dev-pr-review.md +1 -1
  25. package/plugins/sp/commands/dev-review-session.md +1 -1
  26. package/plugins/sp/commands/dev-runall.md +4 -4
  27. package/plugins/sp/commands/dev-verify.md +4 -6
  28. package/plugins/sp/commands/dev-verifyall.md +4 -5
  29. package/plugins/sp/hooks/hooks.json +11 -1
  30. package/plugins/sp/lib/context-post-tool.ts +1 -1
  31. package/plugins/sp/lib/context-session-start.ts +1 -1
  32. package/plugins/sp/lib/context-session-stop.ts +1 -1
  33. package/plugins/sp/lib/destructive-policy.ts +65 -1
  34. package/plugins/sp/plugin.json +2 -5
  35. package/plugins/sp/{hooks → scripts/hooks}/agent-lifecycle.ts +1 -1
  36. package/plugins/sp/scripts/hooks/careful-guard.ts +94 -0
  37. package/plugins/sp/{hooks → scripts/hooks}/context-post-tool.ts +2 -2
  38. package/plugins/sp/{hooks → scripts/hooks}/context-session-start.ts +1 -1
  39. package/plugins/sp/{hooks → scripts/hooks}/context-session-stop.ts +1 -1
  40. package/plugins/sp/{hooks → scripts/hooks}/fleet-guest-stop.ts +6 -4
  41. package/plugins/sp/{hooks → scripts/hooks}/task-write-guard.ts +38 -21
  42. package/plugins/sp/skills/code-implementation/SKILL.md +1 -1
  43. package/plugins/sp/skills/code-verification/SKILL.md +8 -8
  44. package/plugins/sp/skills/code-verification/references/secu-review.md +4 -3
  45. package/plugins/sp/skills/code-verification/references/verdict-schema.md +9 -1
  46. package/plugins/sp/skills/daily-summary/SKILL.md +4 -5
  47. package/plugins/sp/skills/dogfood-testing/SKILL.md +9 -9
  48. package/plugins/sp/skills/dogfood-testing/references/report-template.md +1 -1
  49. package/plugins/sp/skills/history-anatomy/references/modes.md +2 -2
  50. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  51. package/plugins/sp/skills/parallel-execution/SKILL.md +1 -1
  52. package/plugins/sp/skills/pr-reviewing/SKILL.md +14 -15
  53. package/plugins/sp/skills/session-review/SKILL.md +3 -3
  54. package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
  55. package/plugins/sp/skills/spur-check/SKILL.md +14 -13
  56. package/plugins/sp/skills/spur-cli/SKILL.md +1 -1
  57. package/plugins/sp/skills/spur-cli/references/agent.md +12 -0
  58. package/plugins/sp/skills/spur-cli/references/builder.md +27 -6
  59. package/plugins/sp/skills/spur-cli/references/features.md +6 -1
  60. package/plugins/sp/skills/spur-cli/references/history.md +3 -0
  61. package/plugins/sp/skills/spur-cli/references/self.md +5 -3
  62. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +3 -0
  63. package/plugins/sp/skills/spur-cli/references/tasks.md +13 -3
  64. package/plugins/sp/skills/spur-cli/references/workflows.md +36 -1
  65. package/plugins/sp/skills/spur-dev/SKILL.md +1 -1
  66. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +1 -1
  67. package/plugins/sp/skills/spur-dev/references/dev-operations.md +12 -12
  68. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +10 -11
  69. package/plugins/sp/skills/spur-dev/references/execution-batch.md +16 -17
  70. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +1 -1
  71. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +8 -11
  72. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +6 -8
  73. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -1
  74. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +33 -40
  75. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +24 -24
  76. package/plugins/sp/skills/spur-doctor/SKILL.md +5 -4
  77. package/spur.js +41901 -30598
  78. package/config/plugin-scripts.json +0 -116
  79. package/config/script-placement-baseline.json +0 -33
  80. package/plugins/sp/hooks/careful-guard.ts +0 -74
  81. package/plugins/sp/hooks/pi/guard-extension.ts +0 -304
  82. package/plugins/sp/lib/dogfood-pipeline.ts +0 -265
  83. package/plugins/sp/lib/dogfood-report.ts +0 -278
  84. package/plugins/sp/lib/history-anatomy.generated.d.mts +0 -112
  85. package/plugins/sp/lib/history-anatomy.generated.mjs +0 -691
  86. package/plugins/sp/lib/idea-handoff.generated.d.mts +0 -17
  87. package/plugins/sp/lib/idea-handoff.generated.mjs +0 -1446
  88. package/plugins/sp/lib/inline-run-app.ts +0 -68
  89. package/plugins/sp/lib/inline-run.generated.d.mts +0 -37
  90. package/plugins/sp/lib/inline-run.generated.mjs +0 -3103
  91. package/plugins/sp/lib/plan-projection.generated.d.mts +0 -197
  92. package/plugins/sp/lib/plan-projection.generated.mjs +0 -982
  93. package/plugins/sp/lib/quality-gate.generated.d.mts +0 -139
  94. package/plugins/sp/lib/quality-gate.generated.mjs +0 -30510
  95. package/plugins/sp/lib/residual-scan.generated.d.mts +0 -93
  96. package/plugins/sp/lib/residual-scan.generated.mjs +0 -383
  97. package/plugins/sp/lib/run-summary-core.ts +0 -225
  98. package/plugins/sp/lib/spur-bin.ts +0 -36
  99. package/plugins/sp/lib/step-profile.generated.d.mts +0 -71
  100. package/plugins/sp/lib/step-profile.generated.mjs +0 -174
  101. package/plugins/sp/lib/transcript.ts +0 -112
  102. package/plugins/sp/scripts/batch-plan.mjs +0 -1096
  103. package/plugins/sp/scripts/batch-plan.ts +0 -145
  104. package/plugins/sp/scripts/batch-preflight.mjs +0 -423
  105. package/plugins/sp/scripts/batch-preflight.ts +0 -270
  106. package/plugins/sp/scripts/commit-guard.mjs +0 -226
  107. package/plugins/sp/scripts/commit-guard.ts +0 -248
  108. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +0 -626
  109. package/plugins/sp/scripts/daily-summary/daily-summary.ts +0 -855
  110. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +0 -211
  111. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +0 -110
  112. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +0 -282
  113. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +0 -98
  114. package/plugins/sp/scripts/feature-verification-steps.mjs +0 -82
  115. package/plugins/sp/scripts/feature-verification-steps.ts +0 -164
  116. package/plugins/sp/scripts/history-anatomy-cache.mjs +0 -931
  117. package/plugins/sp/scripts/history-anatomy-cache.ts +0 -247
  118. package/plugins/sp/scripts/idea-handoff.mjs +0 -32
  119. package/plugins/sp/scripts/idea-handoff.ts +0 -47
  120. package/plugins/sp/scripts/inline-run-dispatch.mjs +0 -131
  121. package/plugins/sp/scripts/inline-run-dispatch.ts +0 -115
  122. package/plugins/sp/scripts/inline-run-setup.mjs +0 -746
  123. package/plugins/sp/scripts/inline-run-setup.ts +0 -198
  124. package/plugins/sp/scripts/persist-out-check.mjs +0 -217
  125. package/plugins/sp/scripts/persist-out-check.ts +0 -249
  126. package/plugins/sp/scripts/pr-reviewing.mjs +0 -712
  127. package/plugins/sp/scripts/pr-reviewing.ts +0 -952
  128. package/plugins/sp/scripts/quality-gate.mjs +0 -30491
  129. package/plugins/sp/scripts/quality-gate.ts +0 -80
  130. package/plugins/sp/scripts/residual-scan.mjs +0 -634
  131. package/plugins/sp/scripts/residual-scan.ts +0 -248
  132. package/plugins/sp/scripts/run-summary.mjs +0 -758
  133. package/plugins/sp/scripts/run-summary.ts +0 -107
  134. package/plugins/sp/scripts/script-root.mjs +0 -129
  135. package/plugins/sp/scripts/script-root.ts +0 -199
  136. package/plugins/sp/scripts/session-timeline.mjs +0 -359
  137. package/plugins/sp/scripts/session-timeline.ts +0 -190
  138. package/plugins/sp/scripts/task-diffstat.mjs +0 -181
  139. package/plugins/sp/scripts/task-diffstat.ts +0 -219
  140. package/plugins/sp/scripts/workflow-step-profile.mjs +0 -338
  141. package/plugins/sp/scripts/workflow-step-profile.ts +0 -165
  142. package/plugins/sp/scripts/wrapup-drift-probe.mjs +0 -190
  143. package/plugins/sp/scripts/wrapup-drift-probe.ts +0 -261
  144. package/plugins/sp/scripts/wrapup-steps.mjs +0 -627
  145. package/plugins/sp/scripts/wrapup-steps.ts +0 -891
@@ -1,181 +0,0 @@
1
- #!/usr/bin/env node
2
- // @bun
3
-
4
- // plugins/sp/scripts/task-diffstat.ts
5
- import { spawnSync } from "child_process";
6
- import { fstatSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "fs";
7
- import { join } from "path";
8
-
9
- // plugins/sp/lib/env.ts
10
- function getEnvVars() {
11
- return process.env;
12
- }
13
-
14
- // plugins/sp/lib/plan-projection.generated.mjs
15
- var NON_ABORTING_PREFLIGHT_CODES = new Set([
16
- "L4.scenario-unverified",
17
- "L4.verifying-incomplete-tasks"
18
- ]);
19
- var OPERATOR_TOOLS = new Set(["AskUserQuestion", "ask_user_question"]);
20
- var SHELL_TOOLS = new Set(["bash", "shell", "exec", "exec_command", "run_command", "command"]);
21
- var SUBAGENT_TOOLS = new Set(["agent", "task", "invoke_subagent", "subagent", "dispatch_agent"]);
22
- var LOWERCASE_OPERATOR_TOOLS = new Set(["askuserquestion", "ask_user_question"]);
23
- var SENSITIVE_PREFIXES = ["drizzle/", "packages/config/", ".github/", "plugins/sp/hooks/"];
24
- var SENSITIVE_GLOBS = ["apps/server/src/**/auth*", "**/*secret*", "**/*.sql"];
25
- function globToRegex(glob) {
26
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*");
27
- return new RegExp(`^${escaped}$`, "s");
28
- }
29
- var SENSITIVE_MATCHERS = [
30
- ...SENSITIVE_PREFIXES.map((prefix) => ({
31
- label: prefix,
32
- test: (path) => path.startsWith(prefix)
33
- })),
34
- ...SENSITIVE_GLOBS.map((glob) => {
35
- const regex = globToRegex(glob);
36
- return { label: glob, test: (path) => regex.test(path) };
37
- })
38
- ];
39
- function sensitiveReasonForPath(path) {
40
- const match = SENSITIVE_MATCHERS.find((entry) => entry.test(path));
41
- return match ? `matches sensitive pattern ${match.label}` : null;
42
- }
43
-
44
- // plugins/sp/scripts/task-diffstat.ts
45
- function defaultGitRunner(cwd) {
46
- return (args) => {
47
- const run = spawnSync("git", args, { cwd, encoding: "utf8" });
48
- return { status: run.status ?? 1, stdout: run.stdout ?? "" };
49
- };
50
- }
51
- function countNewlines(path, cwd) {
52
- let content;
53
- try {
54
- content = readFileSync(cwd ? join(cwd, path) : path, "utf8");
55
- } catch {
56
- return 0;
57
- }
58
- let lines = 0;
59
- for (const ch of content)
60
- if (ch === `
61
- `)
62
- lines += 1;
63
- return lines;
64
- }
65
- function addCounts(add, del) {
66
- for (const count of [add, del])
67
- if (count !== "-" && !/^\d+$/.test(count))
68
- return null;
69
- const [a, d] = [add === "-" ? 0 : Number(add), del === "-" ? 0 : Number(del)];
70
- return Number.isSafeInteger(a) && Number.isSafeInteger(d) ? [a, d] : null;
71
- }
72
- function runDiffstat(env, options = {}, git = defaultGitRunner(options.cwd)) {
73
- const cwd = options.cwd;
74
- const wbs = env.wbs ?? "";
75
- if (wbs.length === 0) {
76
- process.stderr.write(`task-diffstat: wbs is empty \u2014 refusing to guess the run artifact path
77
- `);
78
- return { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true, resultFile: "", exitCode: 1 };
79
- }
80
- const relRun = join(".spur", "run");
81
- const relResult = join(relRun, `${wbs}-diffstat.json`);
82
- const abs = (p) => cwd ? join(cwd, p) : p;
83
- mkdirSync(abs(relRun), { recursive: true });
84
- const failSafe = (reason) => {
85
- process.stderr.write(`task-diffstat: ${reason} \u2014 failing safe (sensitive)
86
- `);
87
- const row = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
88
- writeArtifact(abs(relResult), row);
89
- return { ...row, resultFile: relResult, exitCode: 0 };
90
- };
91
- let stdoutIsArtifact = false;
92
- try {
93
- const [out, art] = [fstatSync(1), statSync(abs(relResult))];
94
- stdoutIsArtifact = out.dev === art.dev && out.ino === art.ino;
95
- } catch {}
96
- let base = "";
97
- try {
98
- base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), "utf8").trim();
99
- } catch {}
100
- if (!/^[0-9a-f]{7,40}$/i.test(base))
101
- return failSafe("run base .spur/run/<wbs>-base.sha missing or malformed");
102
- const numstat = git(["diff", "--numstat", "-z", base]);
103
- if (numstat.status !== 0)
104
- return failSafe(`git diff --numstat failed (status=${numstat.status})`);
105
- if (numstat.stdout.length > 0 && !numstat.stdout.endsWith("\x00"))
106
- return failSafe("git diff --numstat -z output is not NUL-terminated (malformed framing)");
107
- const paths = new Set;
108
- let insertions = 0;
109
- let deletions = 0;
110
- const records = numstat.stdout.split("\x00").slice(0, -1);
111
- for (let i = 0;i < records.length; i++) {
112
- const header = records[i] ?? "";
113
- const first = header.indexOf("\t");
114
- const second = header.indexOf("\t", first + 1);
115
- if (first < 1 || second < first + 2)
116
- return failSafe("git diff --numstat -z record is missing counts or path");
117
- const counts = addCounts(header.slice(0, first), header.slice(first + 1, second));
118
- if (counts === null)
119
- return failSafe("git diff --numstat -z record has a malformed count");
120
- insertions += counts[0];
121
- deletions += counts[1];
122
- const path = header.slice(second + 1);
123
- if (path !== "") {
124
- paths.add(path);
125
- continue;
126
- }
127
- const oldPath = records[++i];
128
- const newPath = records[++i];
129
- if (!oldPath || !newPath)
130
- return failSafe("git diff --numstat -z rename record has incomplete paths");
131
- paths.add(oldPath);
132
- paths.add(newPath);
133
- }
134
- const others = git(["ls-files", "--others", "--exclude-standard", "-z"]);
135
- if (others.status !== 0)
136
- return failSafe(`git ls-files failed (status=${others.status})`);
137
- for (const path of others.stdout.split("\x00")) {
138
- if (path.length === 0 || path === ".spur" || path.startsWith(".spur/"))
139
- continue;
140
- paths.add(path);
141
- insertions += countNewlines(path, cwd);
142
- }
143
- const all = [...paths].filter((p) => p.length > 0).sort();
144
- const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
145
- const row = { files: all.length, insertions, deletions, paths: all, sensitive };
146
- writeArtifact(abs(relResult), row);
147
- if (stdoutIsArtifact)
148
- process.stderr.write(`task-diffstat: stdout redirected onto the artifact \u2014 kept; redirect stdout elsewhere
149
- `);
150
- return { ...row, resultFile: relResult, exitCode: stdoutIsArtifact ? 1 : 0 };
151
- }
152
- function writeArtifact(absPath, row) {
153
- const tmp = `${absPath}.tmp`;
154
- writeFileSync(tmp, `${JSON.stringify(row)}
155
- `);
156
- renameSync(tmp, absPath);
157
- }
158
- var TASK_DIFFSTAT_USAGE = "usage: task-diffstat (env: wbs)";
159
- function main(argv, env = getEnvVars(), options = {}) {
160
- for (let i = 0;i < argv.length; i++) {
161
- if (argv[i] === "--spur-bin") {
162
- i++;
163
- continue;
164
- }
165
- process.stderr.write(`${TASK_DIFFSTAT_USAGE}
166
- `);
167
- return 2;
168
- }
169
- return runDiffstat(env, options).exitCode;
170
- }
171
- {
172
- process.exit(main(process.argv.slice(2)));
173
- }
174
- export {
175
- SENSITIVE_GLOBS,
176
- SENSITIVE_PREFIXES,
177
- TASK_DIFFSTAT_USAGE,
178
- main,
179
- runDiffstat,
180
- sensitiveReasonForPath
181
- };
@@ -1,219 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * task-diffstat — deterministic diff-evidence producer behind the task-pipeline `triage`
4
- * onEnter (task 0943, feature D64, ADR-125 lane routing).
5
- *
6
- * Reads the run's anchored base commit `.spur/run/<wbs>-base.sha` (written once by
7
- * `precheck`, F96 R1) and the current working tree, then writes:
8
- * `.spur/run/<wbs>-diffstat.json` `{ files, insertions, deletions, paths[], sensitive }`
9
- *
10
- * Counts come from `git diff --numstat -z <base>` (tracked changes; binary rows count the path
11
- * but no lines; NUL records carry RAW paths, so a non-ASCII or newline-bearing path reaches the
12
- * sensitive matchers intact) plus untracked files (`git ls-files --others --exclude-standard -z`,
13
- * newline-counted — # ponytail: untracked line counts are a newline scan, numstat-grade binary
14
- * detection is unnecessary here; switch to `--numstat --no-index` if a miscount flips a lane).
15
- *
16
- * `sensitive` is true when ANY changed path matches the exported SENSITIVE list (0943 R2c);
17
- * the workflow's pre-decide guard maps sensitive (or >400 changed lines) to the safety lane
18
- * without a model call. Fail safe: a missing/malformed base, a failing git call, or any
19
- * other probe problem writes `sensitive: true` (and exits 0) so the lane degrades to
20
- * safety, never to fast.
21
- *
22
- * The artifact is written atomically (tmp + rename), so redirecting stdout onto the artifact path
23
- * cannot corrupt it — the rename swaps in the real JSON after the shell's redirect truncates the
24
- * old inode (1067 R3); that redirect is still refused with a nonzero exit. Normal output is silent.
25
- *
26
- * Node-builtin imports only; the git lookup is a spawnable `GitRunner` so tests can fake
27
- * it; `main(argv, env, options)` is the same injection point the workflow wrapper and its
28
- * node twin exercise. Only an empty `wbs` (mis-invocation) exits nonzero.
29
- */
30
-
31
- import { spawnSync } from 'node:child_process';
32
- import { fstatSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
33
- import { join } from 'node:path';
34
- import { getEnvVars } from '../lib/env';
35
-
36
- export interface DiffstatEnv {
37
- wbs?: string;
38
- [key: string]: string | undefined;
39
- }
40
-
41
- export interface DiffstatOptions {
42
- /** Base directory for `.spur/run` and the git probes; defaults to the process cwd. */
43
- cwd?: string;
44
- }
45
-
46
- /** Minimal spawnable git surface — tests fake this to return canned probe output. */
47
- export interface GitRunResult {
48
- status: number;
49
- stdout: string;
50
- }
51
- export type GitRunner = (args: string[]) => GitRunResult;
52
-
53
- /** Diffstat row written to `.spur/run/<wbs>-diffstat.json` (0943 R2a, frozen shape). */
54
- export interface Diffstat {
55
- files: number;
56
- insertions: number;
57
- deletions: number;
58
- paths: string[];
59
- sensitive: boolean;
60
- }
61
-
62
- export interface DiffstatResult extends Diffstat {
63
- resultFile: string;
64
- /** Exit code the workflow wrapper observes (only an empty wbs is a hard failure). */
65
- exitCode: number;
66
- }
67
-
68
- // Task 1176: the sensitive-path policy (0943 R2c) moved to the workflow/task owner —
69
- // `packages/app/src/workflow/task-triage.ts`, reached through the generated pure bundle. That policy
70
- // still forces the safety lane for any changed path under the frozen sensitive list before the
71
- // `task-triage` decide runs, so sensitive work is never lane-negotiated with a model; this entry
72
- // keeps git capture and the output artifact, and re-exports the policy for existing callers.
73
- import { SENSITIVE_GLOBS, SENSITIVE_PREFIXES, sensitiveReasonForPath } from '../lib/plan-projection.generated.mjs';
74
-
75
- export { SENSITIVE_GLOBS, SENSITIVE_PREFIXES, sensitiveReasonForPath };
76
-
77
- function defaultGitRunner(cwd?: string): GitRunner {
78
- return (args: string[]): GitRunResult => {
79
- const run = spawnSync('git', args, { cwd, encoding: 'utf8' });
80
- return { status: run.status ?? 1, stdout: run.stdout ?? '' };
81
- };
82
- }
83
-
84
- /** Newline count (wc -l semantics) — untracked files have no numstat row. */
85
- function countNewlines(path: string, cwd?: string): number {
86
- let content: string;
87
- try {
88
- content = readFileSync(cwd ? join(cwd, path) : path, 'utf8');
89
- } catch {
90
- return 0;
91
- }
92
- let lines = 0;
93
- for (const ch of content) if (ch === '\n') lines += 1;
94
- return lines;
95
- }
96
-
97
- function addCounts(add: string, del: string): [number, number] | null {
98
- for (const count of [add, del]) if (count !== '-' && !/^\d+$/.test(count)) return null;
99
- const [a, d] = [add === '-' ? 0 : Number(add), del === '-' ? 0 : Number(del)];
100
- return Number.isSafeInteger(a) && Number.isSafeInteger(d) ? [a, d] : null;
101
- }
102
-
103
- /**
104
- * `probe` — write `.spur/run/<wbs>-diffstat.json`. Always exits 0 after probing; every
105
- * probe problem fails safe (empty diff, sensitive true), never to a fast lane.
106
- */
107
- export function runDiffstat(
108
- env: DiffstatEnv,
109
- options: DiffstatOptions = {},
110
- git: GitRunner = defaultGitRunner(options.cwd),
111
- ): DiffstatResult {
112
- const cwd = options.cwd;
113
- const wbs = env.wbs ?? '';
114
- if (wbs.length === 0) {
115
- process.stderr.write('task-diffstat: wbs is empty — refusing to guess the run artifact path\n');
116
- return { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true, resultFile: '', exitCode: 1 };
117
- }
118
- const relRun = join('.spur', 'run');
119
- const relResult = join(relRun, `${wbs}-diffstat.json`);
120
- const abs = (p: string): string => (cwd ? join(cwd, p) : p);
121
- mkdirSync(abs(relRun), { recursive: true });
122
-
123
- const failSafe = (reason: string): DiffstatResult => {
124
- process.stderr.write(`task-diffstat: ${reason} — failing safe (sensitive)\n`);
125
- const row: Diffstat = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
126
- writeArtifact(abs(relResult), row);
127
- return { ...row, resultFile: relResult, exitCode: 0 };
128
- };
129
-
130
- let stdoutIsArtifact = false; // 1067 R3: stdout redirected onto the artifact itself clobbered it once
131
- try {
132
- const [out, art] = [fstatSync(1), statSync(abs(relResult))];
133
- stdoutIsArtifact = out.dev === art.dev && out.ino === art.ino;
134
- } catch {
135
- /* artifact absent or stdout not a regular file — nothing to compare */
136
- }
137
-
138
- let base = '';
139
- try {
140
- base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), 'utf8').trim();
141
- } catch {
142
- // fall through to the shape check below
143
- }
144
- if (!/^[0-9a-f]{7,40}$/i.test(base)) return failSafe('run base .spur/run/<wbs>-base.sha missing or malformed');
145
-
146
- const numstat = git(['diff', '--numstat', '-z', base]);
147
- if (numstat.status !== 0) return failSafe(`git diff --numstat failed (status=${numstat.status})`);
148
- if (numstat.stdout.length > 0 && !numstat.stdout.endsWith('\u0000'))
149
- return failSafe('git diff --numstat -z output is not NUL-terminated (malformed framing)');
150
- const paths = new Set<string>();
151
- let insertions = 0;
152
- let deletions = 0;
153
- const records = numstat.stdout.split('\u0000').slice(0, -1); // drop the terminating NUL tail
154
- for (let i = 0; i < records.length; i++) {
155
- const header = records[i] ?? '';
156
- const first = header.indexOf('\t'); // first two tabs only
157
- const second = header.indexOf('\t', first + 1); // a raw path may itself hold more tabs
158
- if (first < 1 || second < first + 2) return failSafe('git diff --numstat -z record is missing counts or path');
159
- const counts = addCounts(header.slice(0, first), header.slice(first + 1, second));
160
- if (counts === null) return failSafe('git diff --numstat -z record has a malformed count');
161
- insertions += counts[0];
162
- deletions += counts[1]; // once per count header, never once per rename half
163
- const path = header.slice(second + 1);
164
- if (path !== '') {
165
- paths.add(path);
166
- continue;
167
- }
168
- // An empty path field is Git's rename/copy form: the next two records are old and new.
169
- const oldPath = records[++i];
170
- const newPath = records[++i];
171
- if (!oldPath || !newPath) return failSafe('git diff --numstat -z rename record has incomplete paths');
172
- paths.add(oldPath);
173
- paths.add(newPath);
174
- }
175
- const others = git(['ls-files', '--others', '--exclude-standard', '-z']);
176
- if (others.status !== 0) return failSafe(`git ls-files failed (status=${others.status})`);
177
- for (const path of others.stdout.split('\u0000')) {
178
- // Pipeline bookkeeping (the run's own .spur state) is not product work — skip it.
179
- if (path.length === 0 || path === '.spur' || path.startsWith('.spur/')) continue;
180
- paths.add(path);
181
- insertions += countNewlines(path, cwd);
182
- }
183
-
184
- const all = [...paths].filter((p) => p.length > 0).sort();
185
- const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
186
- const row: Diffstat = { files: all.length, insertions, deletions, paths: all, sensitive };
187
- writeArtifact(abs(relResult), row);
188
- if (stdoutIsArtifact)
189
- process.stderr.write('task-diffstat: stdout redirected onto the artifact — kept; redirect stdout elsewhere\n');
190
- return { ...row, resultFile: relResult, exitCode: stdoutIsArtifact ? 1 : 0 };
191
- }
192
-
193
- /** Atomic artifact write (tmp + rename): a stdout redirect onto the path cannot interleave (1067 R3). */
194
- function writeArtifact(absPath: string, row: Diffstat): void {
195
- const tmp = `${absPath}.tmp`;
196
- writeFileSync(tmp, `${JSON.stringify(row)}\n`);
197
- renameSync(tmp, absPath);
198
- }
199
-
200
- export const TASK_DIFFSTAT_USAGE = 'usage: task-diffstat (env: wbs)';
201
-
202
- export function main(argv: string[], env: DiffstatEnv = getEnvVars(), options: DiffstatOptions = {}): number {
203
- // 0482 R2: the workflow hands every plugin script `--spur-bin` for PATH-independence.
204
- // task-diffstat shells only git (never spur), so the flag is accepted and unused;
205
- // any other argv stays a usage error.
206
- for (let i = 0; i < argv.length; i++) {
207
- if (argv[i] === '--spur-bin') {
208
- i++;
209
- continue;
210
- }
211
- process.stderr.write(`${TASK_DIFFSTAT_USAGE}\n`);
212
- return 2;
213
- }
214
- return runDiffstat(env, options).exitCode;
215
- }
216
-
217
- if (import.meta.main) {
218
- process.exit(main(process.argv.slice(2)));
219
- }