@gobing-ai/spur 0.3.92 → 0.3.93
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/.claude-plugin/marketplace.json +1 -1
- package/config/pipeline-budgets.json +0 -7
- package/config/plugin-scripts.json +10 -0
- package/config/templates/AGENTS.md +4 -0
- package/config/templates/docs/02_ROADMAP.md +2 -0
- package/config/templates/docs/03_ARCHITECTURE.md +3 -1
- package/config/templates/docs/04_DESIGN.md +2 -0
- package/config/templates/docs/99_PROJECT_CONSTITUTION.md +10 -2
- package/config/workflow-candidates.json +72 -1
- package/config/workflows/feature-verification.yaml +1 -0
- package/config/workflows/history-anatomy.yaml +2 -0
- package/config/workflows/idea-pipeline.yaml +67 -18
- package/config/workflows/pr-review.yaml +8 -0
- package/config/workflows/task-pipeline.yaml +180 -39
- package/config/workflows/wayfinder-resolution.yaml +5 -0
- package/config/workflows/wrapup-pipeline.yaml +55 -14
- package/package.json +9 -9
- package/plugins/sp/README.md +7 -2
- package/plugins/sp/commands/dev-run.md +2 -2
- package/plugins/sp/commands/dev-runall.md +2 -2
- package/plugins/sp/lib/idea-handoff.generated.mjs +8 -4
- package/plugins/sp/lib/inline-run.generated.d.mts +1 -0
- package/plugins/sp/lib/inline-run.generated.mjs +24 -16
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/scripts/inline-pipeline-parity-check.ts +1 -1
- package/plugins/sp/scripts/inline-run-setup.mjs +75 -3
- package/plugins/sp/scripts/inline-run-setup.ts +133 -3
- package/plugins/sp/scripts/quality-gate.mjs +248 -6
- package/plugins/sp/scripts/quality-gate.ts +410 -9
- package/plugins/sp/scripts/residual-scan.mjs +12 -4
- package/plugins/sp/scripts/residual-scan.ts +32 -6
- package/plugins/sp/scripts/task-diffstat.mjs +156 -0
- package/plugins/sp/scripts/task-diffstat.ts +229 -0
- package/plugins/sp/scripts/wrapup-drift-probe.mjs +181 -0
- package/plugins/sp/scripts/wrapup-drift-probe.ts +258 -0
- package/plugins/sp/scripts/wrapup-steps.mjs +60 -1
- package/plugins/sp/scripts/wrapup-steps.ts +89 -4
- package/plugins/sp/skills/brainstorm/SKILL.md +2 -0
- package/plugins/sp/skills/brainstorm/references/workflows.md +17 -2
- package/plugins/sp/skills/code-verification/SKILL.md +2 -2
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -2
- package/plugins/sp/skills/spur-check/SKILL.md +112 -0
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -1
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +14 -2
- package/plugins/sp/skills/spur-dev/references/document-authoring.md +85 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +1 -1
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +8 -0
- package/plugins/sp/skills/spur-dev/references/gate-checklists.md +3 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +19 -1
- package/plugins/sp/skills/spur-dev/references/planning-workflow.md +5 -5
- package/plugins/sp/skills/spur-dev/templates/design.md +31 -0
- package/plugins/sp/skills/spur-dev/templates/plan.md +32 -0
- package/plugins/sp/skills/spur-doctor/SKILL.md +60 -14
- package/schemas/state-machine-workflow.schema.json +4 -0
- package/spur.js +1117 -472
- package/config/workflows/decision-routing-example.yaml +0 -134
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/task-diffstat.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { mkdirSync, readFileSync, 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/scripts/task-diffstat.ts
|
|
15
|
+
var SENSITIVE_PREFIXES = ["drizzle/", "packages/config/", ".github/", "plugins/sp/hooks/"];
|
|
16
|
+
var SENSITIVE_GLOBS = ["apps/server/src/**/auth*", "**/*secret*", "**/*.sql"];
|
|
17
|
+
function globToRegex(glob) {
|
|
18
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**/", "\x00").replaceAll("**", "\x01").replaceAll("*", "[^/]*").replaceAll("\x00", "(?:.*/)?").replaceAll("\x01", ".*");
|
|
19
|
+
return new RegExp(`^${escaped}$`);
|
|
20
|
+
}
|
|
21
|
+
var SENSITIVE_MATCHERS = [
|
|
22
|
+
...SENSITIVE_PREFIXES.map((prefix) => ({
|
|
23
|
+
label: prefix,
|
|
24
|
+
test: (path) => path.startsWith(prefix)
|
|
25
|
+
})),
|
|
26
|
+
...SENSITIVE_GLOBS.map((glob) => {
|
|
27
|
+
const regex = globToRegex(glob);
|
|
28
|
+
return { label: glob, test: (path) => regex.test(path) };
|
|
29
|
+
})
|
|
30
|
+
];
|
|
31
|
+
function sensitiveReasonForPath(path) {
|
|
32
|
+
const match = SENSITIVE_MATCHERS.find((entry) => entry.test(path));
|
|
33
|
+
return match ? `matches sensitive pattern ${match.label}` : null;
|
|
34
|
+
}
|
|
35
|
+
function defaultGitRunner(cwd) {
|
|
36
|
+
return (args) => {
|
|
37
|
+
const run = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
38
|
+
return { status: run.status ?? 1, stdout: run.stdout ?? "" };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function countNewlines(path, cwd) {
|
|
42
|
+
let content;
|
|
43
|
+
try {
|
|
44
|
+
content = readFileSync(cwd ? join(cwd, path) : path, "utf8");
|
|
45
|
+
} catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
let lines = 0;
|
|
49
|
+
for (const ch of content)
|
|
50
|
+
if (ch === `
|
|
51
|
+
`)
|
|
52
|
+
lines += 1;
|
|
53
|
+
return lines;
|
|
54
|
+
}
|
|
55
|
+
function expandDiffPath(raw) {
|
|
56
|
+
const arrow = raw.indexOf(" => ");
|
|
57
|
+
if (arrow === -1)
|
|
58
|
+
return [raw];
|
|
59
|
+
const open = raw.indexOf("{");
|
|
60
|
+
const close = raw.lastIndexOf("}");
|
|
61
|
+
if (open !== -1 && close > open && open < arrow && arrow < close) {
|
|
62
|
+
const inner = raw.slice(open + 1, close);
|
|
63
|
+
const innerArrow = inner.indexOf(" => ");
|
|
64
|
+
if (innerArrow !== -1) {
|
|
65
|
+
const head = raw.slice(0, open);
|
|
66
|
+
const tail = raw.slice(close + 1);
|
|
67
|
+
return [head + inner.slice(0, innerArrow) + tail, head + inner.slice(innerArrow + 4) + tail];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [raw.slice(0, arrow), raw.slice(arrow + 4)];
|
|
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 row2 = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
|
|
88
|
+
writeFileSync(abs(relResult), `${JSON.stringify(row2)}
|
|
89
|
+
`);
|
|
90
|
+
return { ...row2, resultFile: relResult, exitCode: 0 };
|
|
91
|
+
};
|
|
92
|
+
let base = "";
|
|
93
|
+
try {
|
|
94
|
+
base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), "utf8").trim();
|
|
95
|
+
} catch {}
|
|
96
|
+
if (!/^[0-9a-f]{7,40}$/i.test(base))
|
|
97
|
+
return failSafe("run base .spur/run/<wbs>-base.sha missing or malformed");
|
|
98
|
+
const numstat = git(["diff", "--numstat", base]);
|
|
99
|
+
if (numstat.status !== 0)
|
|
100
|
+
return failSafe(`git diff --numstat failed (status=${numstat.status})`);
|
|
101
|
+
const paths = new Set;
|
|
102
|
+
let insertions = 0;
|
|
103
|
+
let deletions = 0;
|
|
104
|
+
for (const line of numstat.stdout.split(`
|
|
105
|
+
`)) {
|
|
106
|
+
const row2 = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
107
|
+
if (!row2)
|
|
108
|
+
continue;
|
|
109
|
+
for (const p of expandDiffPath(row2[3] ?? ""))
|
|
110
|
+
paths.add(p);
|
|
111
|
+
if (row2[1] !== "-")
|
|
112
|
+
insertions += Number(row2[1]);
|
|
113
|
+
if (row2[2] !== "-")
|
|
114
|
+
deletions += Number(row2[2]);
|
|
115
|
+
}
|
|
116
|
+
const others = git(["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
117
|
+
if (others.status !== 0)
|
|
118
|
+
return failSafe(`git ls-files failed (status=${others.status})`);
|
|
119
|
+
for (const path of others.stdout.split("\x00")) {
|
|
120
|
+
if (path.length === 0 || path === ".spur" || path.startsWith(".spur/"))
|
|
121
|
+
continue;
|
|
122
|
+
paths.add(path);
|
|
123
|
+
insertions += countNewlines(path, cwd);
|
|
124
|
+
}
|
|
125
|
+
const all = [...paths].filter((p) => p.length > 0).sort();
|
|
126
|
+
const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
|
|
127
|
+
const row = { files: all.length, insertions, deletions, paths: all, sensitive };
|
|
128
|
+
writeFileSync(abs(relResult), `${JSON.stringify(row)}
|
|
129
|
+
`);
|
|
130
|
+
return { ...row, resultFile: relResult, exitCode: 0 };
|
|
131
|
+
}
|
|
132
|
+
var TASK_DIFFSTAT_USAGE = "usage: task-diffstat (env: wbs)";
|
|
133
|
+
function main(argv, env = getEnvVars(), options = {}) {
|
|
134
|
+
for (let i = 0;i < argv.length; i++) {
|
|
135
|
+
if (argv[i] === "--spur-bin") {
|
|
136
|
+
i++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
process.stderr.write(`${TASK_DIFFSTAT_USAGE}
|
|
140
|
+
`);
|
|
141
|
+
return 2;
|
|
142
|
+
}
|
|
143
|
+
return runDiffstat(env, options).exitCode;
|
|
144
|
+
}
|
|
145
|
+
{
|
|
146
|
+
process.exit(main(process.argv.slice(2)));
|
|
147
|
+
}
|
|
148
|
+
export {
|
|
149
|
+
sensitiveReasonForPath,
|
|
150
|
+
runDiffstat,
|
|
151
|
+
main,
|
|
152
|
+
expandDiffPath,
|
|
153
|
+
TASK_DIFFSTAT_USAGE,
|
|
154
|
+
SENSITIVE_PREFIXES,
|
|
155
|
+
SENSITIVE_GLOBS
|
|
156
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
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 <base>` (tracked changes; binary rows count the
|
|
11
|
+
* path but no lines) plus untracked files (`git ls-files --others --exclude-standard`,
|
|
12
|
+
* newline-counted — # ponytail: untracked line counts are a newline scan, numstat-grade
|
|
13
|
+
* binary detection is unnecessary for a lane heuristic; switch to `--numstat --no-index`
|
|
14
|
+
* if a miscount ever 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
|
+
* Node-builtin imports only; the git lookup is a spawnable `GitRunner` so tests can fake
|
|
23
|
+
* it; `main(argv, env, options)` is the same injection point the workflow wrapper and its
|
|
24
|
+
* node twin exercise. Only an empty `wbs` (mis-invocation) exits nonzero.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { getEnvVars } from '../lib/env';
|
|
31
|
+
|
|
32
|
+
export interface DiffstatEnv {
|
|
33
|
+
wbs?: string;
|
|
34
|
+
[key: string]: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DiffstatOptions {
|
|
38
|
+
/** Base directory for `.spur/run` and the git probes; defaults to the process cwd. */
|
|
39
|
+
cwd?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Minimal spawnable git surface — tests fake this to return canned probe output. */
|
|
43
|
+
export interface GitRunResult {
|
|
44
|
+
status: number;
|
|
45
|
+
stdout: string;
|
|
46
|
+
}
|
|
47
|
+
export type GitRunner = (args: string[]) => GitRunResult;
|
|
48
|
+
|
|
49
|
+
/** Diffstat row written to `.spur/run/<wbs>-diffstat.json` (0943 R2a, frozen shape). */
|
|
50
|
+
export interface Diffstat {
|
|
51
|
+
files: number;
|
|
52
|
+
insertions: number;
|
|
53
|
+
deletions: number;
|
|
54
|
+
paths: string[];
|
|
55
|
+
sensitive: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DiffstatResult extends Diffstat {
|
|
59
|
+
resultFile: string;
|
|
60
|
+
/** Exit code the workflow wrapper observes (only an empty wbs is a hard failure). */
|
|
61
|
+
exitCode: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sensitive paths (0943 R2c): any changed path under one of these forces the safety lane
|
|
66
|
+
* before the `task-triage` decide runs — sensitive work is never lane-negotiated with a
|
|
67
|
+
* model. Frozen list: `drizzle/`, `packages/config/`, auth-prefixed basenames anywhere
|
|
68
|
+
* under the server src tree, `*secret*` basenames, `.github/`, `plugins/sp/hooks/`, and
|
|
69
|
+
* any `*.sql` — spelled out in SENSITIVE_PREFIXES / SENSITIVE_GLOBS below (kept as data,
|
|
70
|
+
* not prose, so the `*` / `/` sequences survive the doc comment).
|
|
71
|
+
*/
|
|
72
|
+
export const SENSITIVE_PREFIXES = ['drizzle/', 'packages/config/', '.github/', 'plugins/sp/hooks/'] as const;
|
|
73
|
+
export const SENSITIVE_GLOBS = ['apps/server/src/**/auth*', '**/*secret*', '**/*.sql'] as const;
|
|
74
|
+
|
|
75
|
+
// Glob → RegExp: `**` + `/` matches any-or-no directories, bare `**` any characters, `*` within one segment.
|
|
76
|
+
function globToRegex(glob: string): RegExp {
|
|
77
|
+
const escaped = glob
|
|
78
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
79
|
+
.replaceAll('**/', '\u0000')
|
|
80
|
+
.replaceAll('**', '\u0001')
|
|
81
|
+
.replaceAll('*', '[^/]*')
|
|
82
|
+
.replaceAll('\u0000', '(?:.*/)?')
|
|
83
|
+
.replaceAll('\u0001', '.*');
|
|
84
|
+
return new RegExp(`^${escaped}$`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const SENSITIVE_MATCHERS: Array<{ label: string; test: (path: string) => boolean }> = [
|
|
88
|
+
...SENSITIVE_PREFIXES.map((prefix) => ({
|
|
89
|
+
label: prefix,
|
|
90
|
+
test: (path: string): boolean => path.startsWith(prefix),
|
|
91
|
+
})),
|
|
92
|
+
...SENSITIVE_GLOBS.map((glob) => {
|
|
93
|
+
const regex = globToRegex(glob);
|
|
94
|
+
return { label: glob, test: (path: string): boolean => regex.test(path) };
|
|
95
|
+
}),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
/** Sensitive-pattern match for one path, or null — mirrors wrapup-drift-probe's driftReasonForPath. */
|
|
99
|
+
export function sensitiveReasonForPath(path: string): string | null {
|
|
100
|
+
const match = SENSITIVE_MATCHERS.find((entry) => entry.test(path));
|
|
101
|
+
return match ? `matches sensitive pattern ${match.label}` : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function defaultGitRunner(cwd?: string): GitRunner {
|
|
105
|
+
return (args: string[]): GitRunResult => {
|
|
106
|
+
const run = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
107
|
+
return { status: run.status ?? 1, stdout: run.stdout ?? '' };
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Newline count (wc -l semantics) — untracked files have no numstat row. */
|
|
112
|
+
function countNewlines(path: string, cwd?: string): number {
|
|
113
|
+
let content: string;
|
|
114
|
+
try {
|
|
115
|
+
content = readFileSync(cwd ? join(cwd, path) : path, 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
let lines = 0;
|
|
120
|
+
for (const ch of content) if (ch === '\n') lines += 1;
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* git renders renames in --numstat paths as `old => new` (full) or `head{old => new}tail`
|
|
126
|
+
* (partial). The sensitive matchers anchor on single paths, so both rename halves must be
|
|
127
|
+
* surfaced — otherwise a rename INTO a sensitive path (review 0943 P3#1) classified clean.
|
|
128
|
+
*/
|
|
129
|
+
export function expandDiffPath(raw: string): string[] {
|
|
130
|
+
const arrow = raw.indexOf(' => ');
|
|
131
|
+
if (arrow === -1) return [raw];
|
|
132
|
+
const open = raw.indexOf('{');
|
|
133
|
+
const close = raw.lastIndexOf('}');
|
|
134
|
+
if (open !== -1 && close > open && open < arrow && arrow < close) {
|
|
135
|
+
const inner = raw.slice(open + 1, close);
|
|
136
|
+
const innerArrow = inner.indexOf(' => ');
|
|
137
|
+
if (innerArrow !== -1) {
|
|
138
|
+
const head = raw.slice(0, open);
|
|
139
|
+
const tail = raw.slice(close + 1);
|
|
140
|
+
return [head + inner.slice(0, innerArrow) + tail, head + inner.slice(innerArrow + 4) + tail];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [raw.slice(0, arrow), raw.slice(arrow + 4)];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `probe` — write `.spur/run/<wbs>-diffstat.json`. Always exits 0 after probing; every
|
|
148
|
+
* probe problem fails safe (empty diff, sensitive true), never to a fast lane.
|
|
149
|
+
*/
|
|
150
|
+
export function runDiffstat(
|
|
151
|
+
env: DiffstatEnv,
|
|
152
|
+
options: DiffstatOptions = {},
|
|
153
|
+
git: GitRunner = defaultGitRunner(options.cwd),
|
|
154
|
+
): DiffstatResult {
|
|
155
|
+
const cwd = options.cwd;
|
|
156
|
+
const wbs = env.wbs ?? '';
|
|
157
|
+
if (wbs.length === 0) {
|
|
158
|
+
process.stderr.write('task-diffstat: wbs is empty — refusing to guess the run artifact path\n');
|
|
159
|
+
return { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true, resultFile: '', exitCode: 1 };
|
|
160
|
+
}
|
|
161
|
+
const relRun = join('.spur', 'run');
|
|
162
|
+
const relResult = join(relRun, `${wbs}-diffstat.json`);
|
|
163
|
+
const abs = (p: string): string => (cwd ? join(cwd, p) : p);
|
|
164
|
+
mkdirSync(abs(relRun), { recursive: true });
|
|
165
|
+
|
|
166
|
+
const failSafe = (reason: string): DiffstatResult => {
|
|
167
|
+
process.stderr.write(`task-diffstat: ${reason} — failing safe (sensitive)\n`);
|
|
168
|
+
const row: Diffstat = { files: 0, insertions: 0, deletions: 0, paths: [], sensitive: true };
|
|
169
|
+
writeFileSync(abs(relResult), `${JSON.stringify(row)}\n`);
|
|
170
|
+
return { ...row, resultFile: relResult, exitCode: 0 };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
let base = '';
|
|
174
|
+
try {
|
|
175
|
+
base = readFileSync(abs(join(relRun, `${wbs}-base.sha`)), 'utf8').trim();
|
|
176
|
+
} catch {
|
|
177
|
+
// fall through to the shape check below
|
|
178
|
+
}
|
|
179
|
+
if (!/^[0-9a-f]{7,40}$/i.test(base)) return failSafe('run base .spur/run/<wbs>-base.sha missing or malformed');
|
|
180
|
+
|
|
181
|
+
const numstat = git(['diff', '--numstat', base]);
|
|
182
|
+
if (numstat.status !== 0) return failSafe(`git diff --numstat failed (status=${numstat.status})`);
|
|
183
|
+
const paths = new Set<string>();
|
|
184
|
+
let insertions = 0;
|
|
185
|
+
let deletions = 0;
|
|
186
|
+
for (const line of numstat.stdout.split('\n')) {
|
|
187
|
+
// Binary rows report `-` for both counts; the path still counts as changed.
|
|
188
|
+
const row = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
|
|
189
|
+
if (!row) continue;
|
|
190
|
+
for (const p of expandDiffPath(row[3] ?? '')) paths.add(p);
|
|
191
|
+
if (row[1] !== '-') insertions += Number(row[1]);
|
|
192
|
+
if (row[2] !== '-') deletions += Number(row[2]);
|
|
193
|
+
}
|
|
194
|
+
const others = git(['ls-files', '--others', '--exclude-standard', '-z']);
|
|
195
|
+
if (others.status !== 0) return failSafe(`git ls-files failed (status=${others.status})`);
|
|
196
|
+
for (const path of others.stdout.split('\u0000')) {
|
|
197
|
+
// Pipeline bookkeeping (the run's own .spur state) is not product work — skip it.
|
|
198
|
+
if (path.length === 0 || path === '.spur' || path.startsWith('.spur/')) continue;
|
|
199
|
+
paths.add(path);
|
|
200
|
+
insertions += countNewlines(path, cwd);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const all = [...paths].filter((p) => p.length > 0).sort();
|
|
204
|
+
const sensitive = all.some((p) => sensitiveReasonForPath(p) !== null);
|
|
205
|
+
const row: Diffstat = { files: all.length, insertions, deletions, paths: all, sensitive };
|
|
206
|
+
writeFileSync(abs(relResult), `${JSON.stringify(row)}\n`);
|
|
207
|
+
return { ...row, resultFile: relResult, exitCode: 0 };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const TASK_DIFFSTAT_USAGE = 'usage: task-diffstat (env: wbs)';
|
|
211
|
+
|
|
212
|
+
export function main(argv: string[], env: DiffstatEnv = getEnvVars(), options: DiffstatOptions = {}): number {
|
|
213
|
+
// 0482 R2: the workflow hands every plugin script `--spur-bin` for PATH-independence.
|
|
214
|
+
// task-diffstat shells only git (never spur), so the flag is accepted and unused;
|
|
215
|
+
// any other argv stays a usage error.
|
|
216
|
+
for (let i = 0; i < argv.length; i++) {
|
|
217
|
+
if (argv[i] === '--spur-bin') {
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
process.stderr.write(`${TASK_DIFFSTAT_USAGE}\n`);
|
|
222
|
+
return 2;
|
|
223
|
+
}
|
|
224
|
+
return runDiffstat(env, options).exitCode;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (import.meta.main) {
|
|
228
|
+
process.exit(main(process.argv.slice(2)));
|
|
229
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// plugins/sp/scripts/wrapup-drift-probe.ts
|
|
5
|
+
import { spawnSync } from "child_process";
|
|
6
|
+
import { mkdirSync, readFileSync, 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/scripts/wrapup-drift-probe.ts
|
|
15
|
+
var WORKFLOWS_GLOB = `${join("config", "workflows")}/**`;
|
|
16
|
+
var DOC_OWNED_SURFACES = [
|
|
17
|
+
"packages/contracts/**",
|
|
18
|
+
"apps/cli/src/commands/**",
|
|
19
|
+
"packages/config/src/**",
|
|
20
|
+
"drizzle/*.sql",
|
|
21
|
+
WORKFLOWS_GLOB,
|
|
22
|
+
"plugins/sp/commands/**",
|
|
23
|
+
"plugins/sp/skills/**",
|
|
24
|
+
"plugins/sp/hooks/**",
|
|
25
|
+
"package.json",
|
|
26
|
+
"docs/00_ADR.md",
|
|
27
|
+
"docs/03_ARCHITECTURE.md",
|
|
28
|
+
"docs/04_DESIGN.md",
|
|
29
|
+
"docs/design/**"
|
|
30
|
+
];
|
|
31
|
+
var KNOWN_TOP_LEVEL = new Set([
|
|
32
|
+
"apps",
|
|
33
|
+
"packages",
|
|
34
|
+
"plugins",
|
|
35
|
+
"config",
|
|
36
|
+
"docs",
|
|
37
|
+
"drizzle",
|
|
38
|
+
"scripts",
|
|
39
|
+
"vendors",
|
|
40
|
+
"package.json",
|
|
41
|
+
"bun.lock",
|
|
42
|
+
"bunfig.toml"
|
|
43
|
+
]);
|
|
44
|
+
var CORPUS_PREFIXES = ["docs/tasks", "docs/features/"];
|
|
45
|
+
function solutionSectionOf(content) {
|
|
46
|
+
const heading = /^#{2,4}\s+Solution\s*$/m.exec(content);
|
|
47
|
+
if (!heading)
|
|
48
|
+
return null;
|
|
49
|
+
const rest = content.slice(heading.index + heading[0].length);
|
|
50
|
+
const next = /^#{2,4}\s+\S/m.exec(rest);
|
|
51
|
+
return next ? rest.slice(0, next.index) : rest;
|
|
52
|
+
}
|
|
53
|
+
var CHANGE_ENTRY = /`([^`\r\n]+):(\d+)(?:-\d+)?`/g;
|
|
54
|
+
function changedPathsOf(section) {
|
|
55
|
+
if (section === null)
|
|
56
|
+
return [];
|
|
57
|
+
const paths = [];
|
|
58
|
+
for (const match of section.matchAll(CHANGE_ENTRY)) {
|
|
59
|
+
const path = match[1];
|
|
60
|
+
if (/\s/.test(path))
|
|
61
|
+
continue;
|
|
62
|
+
if (!path.includes("/") && !path.includes("."))
|
|
63
|
+
continue;
|
|
64
|
+
paths.push(path);
|
|
65
|
+
}
|
|
66
|
+
return paths;
|
|
67
|
+
}
|
|
68
|
+
function surfaceRegex(glob) {
|
|
69
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replaceAll("**", "\x00").replaceAll("*", "[^/]*").replaceAll("\x00", ".*");
|
|
70
|
+
return new RegExp(`^${escaped}$`);
|
|
71
|
+
}
|
|
72
|
+
var SURFACE_MATCHERS = DOC_OWNED_SURFACES.map((glob) => ({ glob, regex: surfaceRegex(glob) }));
|
|
73
|
+
function driftReasonForPath(path) {
|
|
74
|
+
if (CORPUS_PREFIXES.some((prefix) => path.startsWith(prefix)))
|
|
75
|
+
return null;
|
|
76
|
+
if (path.includes("/") && !KNOWN_TOP_LEVEL.has(path.split("/")[0])) {
|
|
77
|
+
return "new top-level workspace directory";
|
|
78
|
+
}
|
|
79
|
+
const match = SURFACE_MATCHERS.find((entry) => entry.regex.test(path));
|
|
80
|
+
return match ? `matches doc-owned surface ${match.glob}` : null;
|
|
81
|
+
}
|
|
82
|
+
function defaultSpurRunner(env, cwd) {
|
|
83
|
+
const parts = (env.spurBin ?? "spur").split(/\s+/).filter((part) => part.length > 0);
|
|
84
|
+
return (args) => {
|
|
85
|
+
const run = spawnSync(parts[0], [...parts.slice(1), ...args], {
|
|
86
|
+
cwd,
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
env: getEnvVars()
|
|
89
|
+
});
|
|
90
|
+
return { status: run.status ?? 1, stdout: run.stdout ?? "" };
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function runDriftProbe(env, options = {}, spur = defaultSpurRunner(env, options.cwd)) {
|
|
94
|
+
const cwd = options.cwd;
|
|
95
|
+
const runId = env.__runId ?? "";
|
|
96
|
+
if (runId.length === 0) {
|
|
97
|
+
process.stderr.write(`wrapup-drift-probe: __runId is empty \u2014 refusing the legacy fixed-path fallback
|
|
98
|
+
`);
|
|
99
|
+
return { clean: false, reasons: [], paths: [], probeFile: "", modeFile: "", exitCode: 1 };
|
|
100
|
+
}
|
|
101
|
+
const relProbeFile = join(".spur", "run", `${runId}-drift-probe.json`);
|
|
102
|
+
const relModeFile = join(".spur", "run", `${runId}-mode.txt`);
|
|
103
|
+
const relTasksFile = join(".spur", "run", `${runId}-wrapup-tasks.json`);
|
|
104
|
+
const abs = (p) => cwd ? join(cwd, p) : p;
|
|
105
|
+
mkdirSync(abs(join(".spur", "run")), { recursive: true });
|
|
106
|
+
writeFileSync(abs(relModeFile), `
|
|
107
|
+
`);
|
|
108
|
+
const finish = (clean, reasons2, paths) => {
|
|
109
|
+
writeFileSync(abs(relProbeFile), `${JSON.stringify({ clean, reasons: reasons2, paths })}
|
|
110
|
+
`);
|
|
111
|
+
writeFileSync(abs(relModeFile), clean ? `fast
|
|
112
|
+
` : `
|
|
113
|
+
`);
|
|
114
|
+
return { clean, reasons: reasons2, paths, probeFile: relProbeFile, modeFile: relModeFile, exitCode: 0 };
|
|
115
|
+
};
|
|
116
|
+
let tasks;
|
|
117
|
+
try {
|
|
118
|
+
tasks = JSON.parse(readFileSync(abs(relTasksFile), "utf8"));
|
|
119
|
+
} catch {
|
|
120
|
+
tasks = undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!Array.isArray(tasks) || !tasks.every((wbs) => typeof wbs === "string")) {
|
|
123
|
+
process.stderr.write(`wrapup-drift-probe: normalized task capture is missing or corrupted \u2014 failing safe (dirty)
|
|
124
|
+
`);
|
|
125
|
+
return finish(false, ["normalized task capture missing or corrupted"], []);
|
|
126
|
+
}
|
|
127
|
+
const reasons = [];
|
|
128
|
+
const allPaths = new Set;
|
|
129
|
+
for (const wbs of tasks) {
|
|
130
|
+
const shown = spur(["task", "show", wbs, "--json"]);
|
|
131
|
+
if (shown.status !== 0) {
|
|
132
|
+
reasons.push(`${wbs}: task show failed (status=${shown.status})`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
let content;
|
|
136
|
+
try {
|
|
137
|
+
content = JSON.parse(shown.stdout).content;
|
|
138
|
+
} catch {
|
|
139
|
+
content = undefined;
|
|
140
|
+
}
|
|
141
|
+
if (typeof content !== "string") {
|
|
142
|
+
reasons.push(`${wbs}: task show output unparseable`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const changedPaths = changedPathsOf(solutionSectionOf(content));
|
|
146
|
+
if (changedPaths.length === 0) {
|
|
147
|
+
reasons.push(`${wbs}: Solution empty or unparseable`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const path of changedPaths) {
|
|
151
|
+
if (CORPUS_PREFIXES.some((prefix) => path.startsWith(prefix)))
|
|
152
|
+
continue;
|
|
153
|
+
allPaths.add(path);
|
|
154
|
+
const drift = driftReasonForPath(path);
|
|
155
|
+
if (drift)
|
|
156
|
+
reasons.push(`${wbs}: ${path} ${drift}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return finish(reasons.length === 0, reasons, [...allPaths].sort());
|
|
160
|
+
}
|
|
161
|
+
var WRAPUP_DRIFT_PROBE_USAGE = "usage: wrapup-drift-probe (env: __runId, spurBin)";
|
|
162
|
+
function main(argv, env = getEnvVars(), options = {}) {
|
|
163
|
+
if (argv.length > 0) {
|
|
164
|
+
process.stderr.write(`${WRAPUP_DRIFT_PROBE_USAGE}
|
|
165
|
+
`);
|
|
166
|
+
return 2;
|
|
167
|
+
}
|
|
168
|
+
return runDriftProbe(env, options).exitCode;
|
|
169
|
+
}
|
|
170
|
+
{
|
|
171
|
+
process.exit(main(process.argv.slice(2)));
|
|
172
|
+
}
|
|
173
|
+
export {
|
|
174
|
+
solutionSectionOf,
|
|
175
|
+
runDriftProbe,
|
|
176
|
+
main,
|
|
177
|
+
driftReasonForPath,
|
|
178
|
+
changedPathsOf,
|
|
179
|
+
WRAPUP_DRIFT_PROBE_USAGE,
|
|
180
|
+
DOC_OWNED_SURFACES
|
|
181
|
+
};
|