dev-loops 0.2.7 → 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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/developer.md +1 -0
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/review.md +30 -0
- package/.claude/hooks/_run-context.mjs +9 -16
- package/.claude/skills/copilot-pr-followup/SKILL.md +62 -6
- package/.claude/skills/dev-loop/SKILL.md +6 -6
- package/.claude/skills/docs/anti-patterns.md +2 -0
- package/.claude/skills/docs/copilot-loop-operations.md +3 -3
- package/.claude/skills/docs/issue-intake-procedure.md +4 -0
- package/.claude/skills/docs/merge-preconditions.md +65 -1
- package/.claude/skills/docs/stop-conditions.md +1 -0
- package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
- package/.claude/skills/local-implementation/SKILL.md +25 -5
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +69 -0
- package/README.md +8 -2
- package/agents/developer.agent.md +1 -0
- package/agents/fixer.agent.md +1 -0
- package/agents/review.agent.md +30 -0
- package/cli/index.mjs +60 -8
- package/extension/README.md +1 -1
- package/package.json +3 -3
- package/scripts/README.md +8 -7
- package/scripts/_core-helpers.mjs +1 -0
- package/scripts/claude/headless-dev-loop.mjs +53 -13
- package/scripts/claude/headless-info-smoke.mjs +45 -11
- package/scripts/github/build-adjacent-bundle.mjs +448 -0
- package/scripts/github/{create-draft-pr.mjs → create-pr.mjs} +28 -12
- package/scripts/github/detect-checkpoint-evidence.mjs +95 -4
- package/scripts/github/offer-human-handoff.mjs +147 -0
- package/scripts/github/post-gate-findings.mjs +392 -0
- package/scripts/github/probe-ci-status.mjs +468 -0
- package/scripts/github/reconcile-draft-gate.mjs +2 -2
- package/scripts/github/request-copilot-review.mjs +72 -11
- package/scripts/github/resolve-handoff-candidates.mjs +412 -0
- package/scripts/github/upsert-checkpoint-verdict.mjs +599 -17
- package/scripts/github/verify-fresh-review-context.mjs +12 -1
- package/scripts/github/write-gate-context.mjs +634 -0
- package/scripts/github/write-gate-findings-log.mjs +1 -1
- package/scripts/loop/_stale-runner-detection.mjs +1 -1
- package/scripts/loop/_worktree-path.mjs +27 -0
- package/scripts/loop/cleanup-worktree.mjs +175 -0
- package/scripts/loop/copilot-pr-handoff.mjs +1 -1
- package/scripts/loop/detect-change-scope.mjs +36 -11
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +30 -18
- package/scripts/loop/detect-stale-runner.mjs +3 -4
- package/scripts/loop/detect-tracker-first-loop-state.mjs +38 -11
- package/scripts/loop/ensure-worktree.mjs +219 -0
- package/scripts/loop/outer-loop.mjs +1 -1
- package/scripts/loop/pr-runner-coordination.mjs +1 -1
- package/scripts/loop/pre-flight-gate.mjs +10 -7
- package/scripts/loop/pre-push-main-guard.mjs +4 -4
- package/scripts/loop/provision-worktree.mjs +243 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +5 -5
- package/scripts/loop/run-queue.mjs +112 -16
- package/scripts/loop/run-watch-cycle.mjs +75 -22
- package/scripts/projects/add-queue-item.mjs +80 -48
- package/scripts/projects/archive-done-items.mjs +136 -39
- package/scripts/projects/ensure-queue-board.mjs +67 -65
- package/scripts/projects/list-queue-items.mjs +59 -57
- package/scripts/projects/move-queue-item.mjs +125 -125
- package/scripts/projects/reorder-queue-item.mjs +67 -48
- package/scripts/projects/sync-item-status.mjs +199 -0
- package/skills/copilot-pr-followup/SKILL.md +62 -6
- package/skills/dev-loop/SKILL.md +2 -2
- package/skills/dev-loop/scripts/log-bash-exit-1.mjs +2 -2
- package/skills/dev-loop/scripts/phase-files.mjs +2 -2
- package/skills/docs/anti-patterns.md +2 -0
- package/skills/docs/copilot-loop-operations.md +3 -3
- package/skills/docs/issue-intake-procedure.md +4 -0
- package/skills/docs/merge-preconditions.md +65 -1
- package/skills/docs/stop-conditions.md +1 -0
- package/skills/docs/tracker-first-loop-state.md +6 -6
- package/skills/local-implementation/SKILL.md +25 -5
|
@@ -18,21 +18,55 @@
|
|
|
18
18
|
import { spawnSync } from "node:child_process";
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { parseArgs } from "node:util";
|
|
21
22
|
|
|
22
|
-
function
|
|
23
|
+
function parseCliArgs(argv) {
|
|
23
24
|
const opts = { loopInfo: false };
|
|
24
|
-
const requireValue = (name,
|
|
25
|
-
const v =
|
|
26
|
-
if (v ===
|
|
25
|
+
const requireValue = (name, token) => {
|
|
26
|
+
const v = token.value;
|
|
27
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("--")) {
|
|
28
|
+
throw new Error(`${name} requires a value`);
|
|
29
|
+
}
|
|
27
30
|
return v;
|
|
28
31
|
};
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
|
|
33
|
+
const { tokens } = parseArgs({
|
|
34
|
+
args: [...argv],
|
|
35
|
+
options: {
|
|
36
|
+
"loop-info": { type: "boolean" },
|
|
37
|
+
issue: { type: "string" },
|
|
38
|
+
pr: { type: "string" },
|
|
39
|
+
},
|
|
40
|
+
allowPositionals: true,
|
|
41
|
+
strict: false,
|
|
42
|
+
tokens: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
for (const token of tokens) {
|
|
46
|
+
if (token.kind === "positional") {
|
|
47
|
+
throw new Error(`unknown argument: ${token.value}`);
|
|
48
|
+
}
|
|
49
|
+
if (token.kind !== "option") {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
switch (token.name) {
|
|
53
|
+
case "loop-info":
|
|
54
|
+
if (token.value !== undefined) {
|
|
55
|
+
throw new Error(`unknown argument: ${token.rawName}=${token.value}`);
|
|
56
|
+
}
|
|
57
|
+
opts.loopInfo = true;
|
|
58
|
+
break;
|
|
59
|
+
case "issue":
|
|
60
|
+
opts.issue = requireValue("--issue", token);
|
|
61
|
+
break;
|
|
62
|
+
case "pr":
|
|
63
|
+
opts.pr = requireValue("--pr", token);
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
throw new Error(`unknown argument: ${token.rawName}`);
|
|
67
|
+
}
|
|
35
68
|
}
|
|
69
|
+
|
|
36
70
|
if (opts.issue != null && opts.pr != null) {
|
|
37
71
|
throw new Error("--issue and --pr are mutually exclusive");
|
|
38
72
|
}
|
|
@@ -46,7 +80,7 @@ function runCli(cliEntry, repoRoot, args) {
|
|
|
46
80
|
function main(argv) {
|
|
47
81
|
let opts;
|
|
48
82
|
try {
|
|
49
|
-
opts =
|
|
83
|
+
opts = parseCliArgs(argv);
|
|
50
84
|
} catch (error) {
|
|
51
85
|
process.stderr.write(JSON.stringify({ ok: false, error: error.message }) + "\n");
|
|
52
86
|
return 1;
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* build-adjacent-bundle.mjs — deterministic, neutral adjacent-code bundle builder.
|
|
4
|
+
*
|
|
5
|
+
* Part of issue #895 (build-once neutral context bundle for the gate fan-out).
|
|
6
|
+
* The gate context-builder runs ONCE and emits a generous, NEUTRAL bundle that
|
|
7
|
+
* every independent reviewer is seeded with verbatim — instead of every reviewer
|
|
8
|
+
* re-deriving the diff + adjacent code from scratch (the actual N× waste this
|
|
9
|
+
* fixes). The primary win is WORK-DEDUP (build once vs. N× re-derivation);
|
|
10
|
+
* prompt-cache of the shared prefix is an opportunistic bonus.
|
|
11
|
+
*
|
|
12
|
+
* Neutrality + determinism are guaranteed because this is a SCRIPT, not an
|
|
13
|
+
* agent: it cannot editorialize, and identical (head + changed files) input
|
|
14
|
+
* always produces an identical bundle (sorted file list, stable JSON shape).
|
|
15
|
+
*
|
|
16
|
+
* Bundle contents: for each changed JS/.mjs/.cjs file we include
|
|
17
|
+
* - the files it imports (1-hop out-edges), and
|
|
18
|
+
* - the files that import it (1-hop in-edges, via a repo-wide import scan)
|
|
19
|
+
* resolved against the repo (deterministic 1-hop import/require graph).
|
|
20
|
+
*
|
|
21
|
+
* Size guards (record, do not silently drop):
|
|
22
|
+
* - skip lockfiles (package-lock.json / *-lock.yaml), generated trees
|
|
23
|
+
* (.claude/, dist/, lib/, node_modules/, coverage/), binary, and minified
|
|
24
|
+
* files (*.min.*) — recorded in `stripped` with a reason.
|
|
25
|
+
* - cap per-file bytes (default ~48KB) and truncate the long tail —
|
|
26
|
+
* recorded in `truncated` with original/included byte counts.
|
|
27
|
+
*
|
|
28
|
+
* The output is purely structural adjacency; it does NOT carry any opinion,
|
|
29
|
+
* verdict, or main-agent state, so seeding a reviewer with it is the INTENDED
|
|
30
|
+
* neutral seed (RFC-2), not contamination.
|
|
31
|
+
*/
|
|
32
|
+
import { open, readFile, readdir, stat } from "node:fs/promises";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
|
|
35
|
+
/** Default per-file byte cap before truncation. */
|
|
36
|
+
export const DEFAULT_MAX_FILE_BYTES = 48 * 1024;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Binary-sniff window: the NUL-byte heuristic inspects this many leading bytes.
|
|
40
|
+
* The guarded read must cover at least this window even when the per-file cap is
|
|
41
|
+
* configured smaller, so a small cap can't cause a binary (whose first NUL is
|
|
42
|
+
* past the cap) to be misclassified as text.
|
|
43
|
+
*/
|
|
44
|
+
const BINARY_SNIFF_BYTES = 8 * 1024;
|
|
45
|
+
|
|
46
|
+
/** Source extensions we resolve import edges for (JS family, this repo). */
|
|
47
|
+
const SOURCE_EXTENSIONS = [".mjs", ".js", ".cjs"];
|
|
48
|
+
|
|
49
|
+
/** Directory prefixes treated as generated / vendored and skipped. */
|
|
50
|
+
const GENERATED_DIR_PREFIXES = [
|
|
51
|
+
".claude/",
|
|
52
|
+
"dist/",
|
|
53
|
+
"lib/",
|
|
54
|
+
"node_modules/",
|
|
55
|
+
"coverage/",
|
|
56
|
+
".git/",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify why a file is skipped from the bundle, or null when it is includable.
|
|
61
|
+
* Deterministic, path-shape based. Exported for tests.
|
|
62
|
+
* @param {string} relPath — repo-relative POSIX path
|
|
63
|
+
* @returns {"lockfile"|"generated"|"binary"|"minified"|null}
|
|
64
|
+
*/
|
|
65
|
+
export function classifyStripReason(relPath) {
|
|
66
|
+
const posix = String(relPath).replace(/\\/g, "/");
|
|
67
|
+
const base = posix.split("/").pop() ?? posix;
|
|
68
|
+
|
|
69
|
+
if (base === "package-lock.json" || base === "npm-shrinkwrap.json" || /-lock\.ya?ml$/.test(base) || base === "yarn.lock" || base === "pnpm-lock.yaml") {
|
|
70
|
+
return "lockfile";
|
|
71
|
+
}
|
|
72
|
+
for (const prefix of GENERATED_DIR_PREFIXES) {
|
|
73
|
+
if (posix === prefix.slice(0, -1) || posix.startsWith(prefix) || posix.includes(`/${prefix}`)) {
|
|
74
|
+
return "generated";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (/\.min\.[a-z0-9]+$/i.test(base)) return "minified";
|
|
78
|
+
if (isBinaryPath(base)) return "binary";
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const BINARY_EXTENSIONS = new Set([
|
|
83
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".pdf",
|
|
84
|
+
".zip", ".gz", ".tgz", ".tar", ".bz2", ".7z", ".rar",
|
|
85
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
86
|
+
".mp3", ".mp4", ".mov", ".avi", ".wav", ".ogg", ".webm",
|
|
87
|
+
".so", ".dylib", ".dll", ".exe", ".bin", ".wasm", ".class", ".o", ".a",
|
|
88
|
+
".node", ".lockb",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
function isBinaryPath(base) {
|
|
92
|
+
const dot = base.lastIndexOf(".");
|
|
93
|
+
if (dot < 0) return false;
|
|
94
|
+
return BINARY_EXTENSIONS.has(base.slice(dot).toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Heuristic binary-content sniff: a NUL byte within the first 8KB marks the
|
|
99
|
+
* file as binary regardless of extension (covers extension-less binaries).
|
|
100
|
+
* @param {Buffer} buf
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
function looksBinaryContent(buf) {
|
|
104
|
+
const limit = Math.min(buf.length, BINARY_SNIFF_BYTES);
|
|
105
|
+
for (let i = 0; i < limit; i++) {
|
|
106
|
+
if (buf[i] === 0) return true;
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract the static import/require specifiers from JS source text.
|
|
113
|
+
* Deterministic regex scan (no execution). Captures:
|
|
114
|
+
* - import ... from "x" / export ... from "x"
|
|
115
|
+
* - import("x") (dynamic import)
|
|
116
|
+
* - require("x")
|
|
117
|
+
* Returns specifiers in a deterministic, grouped-by-kind order — each kind is
|
|
118
|
+
* scanned in its own pass (export/import-from → bare side-effect import →
|
|
119
|
+
* dynamic import() → require()), so the output is grouped by form rather than
|
|
120
|
+
* by position in the source. De-duplicated (first occurrence wins).
|
|
121
|
+
* @param {string} source
|
|
122
|
+
* @returns {string[]}
|
|
123
|
+
*/
|
|
124
|
+
export function extractImportSpecifiers(source) {
|
|
125
|
+
if (typeof source !== "string" || source.length === 0) return [];
|
|
126
|
+
const specs = [];
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const push = (s) => {
|
|
129
|
+
if (typeof s === "string" && s.length > 0 && !seen.has(s)) {
|
|
130
|
+
seen.add(s);
|
|
131
|
+
specs.push(s);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
// import ... from "x" | export ... from "x"
|
|
135
|
+
const fromRe = /\b(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]/g;
|
|
136
|
+
// bare side-effect import "x"
|
|
137
|
+
const bareRe = /\bimport\s+['"]([^'"]+)['"]/g;
|
|
138
|
+
// dynamic import("x")
|
|
139
|
+
const dynRe = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
140
|
+
// require("x")
|
|
141
|
+
const reqRe = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
142
|
+
for (const re of [fromRe, bareRe, dynRe, reqRe]) {
|
|
143
|
+
let m;
|
|
144
|
+
while ((m = re.exec(source)) !== null) push(m[1]);
|
|
145
|
+
}
|
|
146
|
+
return specs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve a RELATIVE import specifier (./ or ../) from a source file to a
|
|
151
|
+
* repo-relative POSIX path, trying common extensions and index files.
|
|
152
|
+
* Bare/package specifiers (no leading dot) are NOT resolved (out of repo).
|
|
153
|
+
* Pure path math against the provided existing-files set — deterministic.
|
|
154
|
+
* @param {string} fromRelPath — repo-relative path of the importing file
|
|
155
|
+
* @param {string} specifier
|
|
156
|
+
* @param {Set<string>} existing — set of repo-relative POSIX paths that exist
|
|
157
|
+
* @returns {string|null}
|
|
158
|
+
*/
|
|
159
|
+
export function resolveRelativeImport(fromRelPath, specifier, existing) {
|
|
160
|
+
if (typeof specifier !== "string" || !specifier.startsWith(".")) return null;
|
|
161
|
+
const fromDir = path.posix.dirname(String(fromRelPath).replace(/\\/g, "/"));
|
|
162
|
+
const base = path.posix.normalize(path.posix.join(fromDir, specifier));
|
|
163
|
+
if (base.startsWith("..")) return null; // escaped the repo root
|
|
164
|
+
const candidates = [base];
|
|
165
|
+
for (const ext of SOURCE_EXTENSIONS) candidates.push(base + ext);
|
|
166
|
+
for (const ext of SOURCE_EXTENSIONS) candidates.push(path.posix.join(base, "index" + ext));
|
|
167
|
+
for (const c of candidates) {
|
|
168
|
+
if (existing.has(c)) return c;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isSourceFile(relPath) {
|
|
174
|
+
const ext = path.posix.extname(String(relPath).replace(/\\/g, "/")).toLowerCase();
|
|
175
|
+
return SOURCE_EXTENSIONS.includes(ext);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Recursively list repo-relative POSIX paths under repoRoot, skipping
|
|
180
|
+
* generated/vendored dirs and the tmp/ scratch tree for determinism+speed.
|
|
181
|
+
* @param {string} repoRoot
|
|
182
|
+
* @returns {Promise<string[]>} sorted repo-relative POSIX paths
|
|
183
|
+
*/
|
|
184
|
+
export async function listRepoFiles(repoRoot) {
|
|
185
|
+
const out = [];
|
|
186
|
+
const skipDir = new Set([".git", "node_modules", "dist", "lib", ".claude", "coverage", "tmp"]);
|
|
187
|
+
async function walk(absDir, relDir) {
|
|
188
|
+
let entries;
|
|
189
|
+
try {
|
|
190
|
+
entries = await readdir(absDir, { withFileTypes: true });
|
|
191
|
+
} catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const ent of entries) {
|
|
195
|
+
if (ent.isSymbolicLink()) continue;
|
|
196
|
+
const relPath = relDir ? `${relDir}/${ent.name}` : ent.name;
|
|
197
|
+
if (ent.isDirectory()) {
|
|
198
|
+
if (skipDir.has(ent.name)) continue;
|
|
199
|
+
await walk(path.join(absDir, ent.name), relPath);
|
|
200
|
+
} else if (ent.isFile()) {
|
|
201
|
+
out.push(relPath);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
await walk(repoRoot, "");
|
|
206
|
+
out.sort();
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build the reverse-import (importers) index: for each source file, which other
|
|
212
|
+
* source files import it (1-hop in-edges). Deterministic given the repo files.
|
|
213
|
+
* @param {string[]} repoFiles — repo-relative POSIX paths
|
|
214
|
+
* @param {string} repoRoot
|
|
215
|
+
* @returns {Promise<Map<string, string[]>>} target → sorted importers
|
|
216
|
+
*/
|
|
217
|
+
export async function buildImporterIndex(repoFiles, repoRoot) {
|
|
218
|
+
const existing = new Set(repoFiles);
|
|
219
|
+
const importers = new Map();
|
|
220
|
+
for (const file of repoFiles) {
|
|
221
|
+
if (!isSourceFile(file)) continue;
|
|
222
|
+
// Skip minified artifacts: they are stripped from the final bundle anyway and
|
|
223
|
+
// are not meaningful import sources, so reading them here is wasted I/O/memory
|
|
224
|
+
// on repos with large minified files.
|
|
225
|
+
if (/\.min\.[a-z0-9]+$/i.test(file)) continue;
|
|
226
|
+
let source;
|
|
227
|
+
try {
|
|
228
|
+
source = await readFile(path.resolve(repoRoot, file), "utf8");
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
for (const spec of extractImportSpecifiers(source)) {
|
|
233
|
+
const target = resolveRelativeImport(file, spec, existing);
|
|
234
|
+
if (!target) continue;
|
|
235
|
+
if (!importers.has(target)) importers.set(target, []);
|
|
236
|
+
const list = importers.get(target);
|
|
237
|
+
if (!list.includes(file)) list.push(file);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const list of importers.values()) list.sort();
|
|
241
|
+
return importers;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Decide whether a repo-relative path is safe to resolve+read under repoRoot.
|
|
246
|
+
* Fails closed: rejects absolute paths, any `..` segment, and any resolved
|
|
247
|
+
* absolute path that escapes repoRoot. Deterministic, pure path math.
|
|
248
|
+
* @param {string} repoRoot
|
|
249
|
+
* @param {string} relPath — repo-relative POSIX path
|
|
250
|
+
* @returns {{ ok: true, abs: string } | { ok: false }}
|
|
251
|
+
*/
|
|
252
|
+
export function resolveSafeRepoPath(repoRoot, relPath) {
|
|
253
|
+
const posix = String(relPath).replace(/\\/g, "/");
|
|
254
|
+
if (posix.length === 0) return { ok: false };
|
|
255
|
+
if (path.posix.isAbsolute(posix) || path.win32.isAbsolute(posix)) return { ok: false };
|
|
256
|
+
if (posix.split("/").includes("..")) return { ok: false };
|
|
257
|
+
const root = path.resolve(repoRoot);
|
|
258
|
+
const abs = path.resolve(root, posix);
|
|
259
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return { ok: false };
|
|
260
|
+
return { ok: true, abs };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Read a file with the size guards applied. Returns the (possibly truncated)
|
|
265
|
+
* content plus guard metadata. Never throws on a missing/unreadable file.
|
|
266
|
+
*
|
|
267
|
+
* Path safety: unsafe relPaths (absolute, `..` segments, or escaping repoRoot)
|
|
268
|
+
* are NOT read — they are recorded with strip reason "unsafe-path".
|
|
269
|
+
*
|
|
270
|
+
* Bounded read: when the file is over the cap it is truncated, so we read at
|
|
271
|
+
* most maxFileBytes+1 bytes (the +1 lets us also sniff binary content past the
|
|
272
|
+
* cap without loading the whole file). The full original size is read from
|
|
273
|
+
* stat() for the manifest.
|
|
274
|
+
* @param {string} repoRoot
|
|
275
|
+
* @param {string} relPath
|
|
276
|
+
* @param {number} maxFileBytes
|
|
277
|
+
* @returns {Promise<{ relPath: string, content: string|null, bytes: number, includedBytes: number, truncated: boolean, missing: boolean, strip: string|null }>}
|
|
278
|
+
*/
|
|
279
|
+
async function readGuardedFile(repoRoot, relPath, maxFileBytes) {
|
|
280
|
+
const strip = classifyStripReason(relPath);
|
|
281
|
+
if (strip) {
|
|
282
|
+
return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: false, strip };
|
|
283
|
+
}
|
|
284
|
+
const safe = resolveSafeRepoPath(repoRoot, relPath);
|
|
285
|
+
if (!safe.ok) {
|
|
286
|
+
return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: false, strip: "unsafe-path" };
|
|
287
|
+
}
|
|
288
|
+
const abs = safe.abs;
|
|
289
|
+
let handle;
|
|
290
|
+
try {
|
|
291
|
+
const info = await stat(abs);
|
|
292
|
+
if (!info.isFile()) {
|
|
293
|
+
return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: true, strip: null };
|
|
294
|
+
}
|
|
295
|
+
const bytes = info.size;
|
|
296
|
+
// Read enough to (a) detect over-cap and truncate, and (b) always cover the
|
|
297
|
+
// binary-sniff window — so a cap configured below BINARY_SNIFF_BYTES can't let
|
|
298
|
+
// a binary slip through as text. Still bounded (never the whole large file).
|
|
299
|
+
const readCap = Math.min(bytes, Math.max(maxFileBytes + 1, BINARY_SNIFF_BYTES));
|
|
300
|
+
const buf = Buffer.allocUnsafe(readCap);
|
|
301
|
+
handle = await open(abs, "r");
|
|
302
|
+
const { bytesRead } = await handle.read(buf, 0, readCap, 0);
|
|
303
|
+
const data = buf.subarray(0, bytesRead);
|
|
304
|
+
if (looksBinaryContent(data)) {
|
|
305
|
+
return { relPath, content: null, bytes, includedBytes: 0, truncated: false, missing: false, strip: "binary" };
|
|
306
|
+
}
|
|
307
|
+
let truncated = false;
|
|
308
|
+
let slice = data;
|
|
309
|
+
if (bytes > maxFileBytes) {
|
|
310
|
+
slice = data.subarray(0, maxFileBytes);
|
|
311
|
+
truncated = true;
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
relPath,
|
|
315
|
+
content: slice.toString("utf8"),
|
|
316
|
+
bytes,
|
|
317
|
+
includedBytes: slice.length,
|
|
318
|
+
truncated,
|
|
319
|
+
missing: false,
|
|
320
|
+
strip: null,
|
|
321
|
+
};
|
|
322
|
+
} catch {
|
|
323
|
+
return { relPath, content: null, bytes: 0, includedBytes: 0, truncated: false, missing: true, strip: null };
|
|
324
|
+
} finally {
|
|
325
|
+
if (handle) await handle.close().catch(() => {});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Build the deterministic, neutral adjacent-code bundle for a set of changed
|
|
331
|
+
* files. For each changed source file, collect its 1-hop import out-edges
|
|
332
|
+
* (resolved relative imports) and in-edges (files that import it) and include
|
|
333
|
+
* each adjacent file's (guarded) content.
|
|
334
|
+
*
|
|
335
|
+
* Output shape (added to the gate-context artifact under `adjacentCode`):
|
|
336
|
+
* {
|
|
337
|
+
* maxFileBytes,
|
|
338
|
+
* files: [{ path, role: "changed"|"imports"|"importedBy", relatedTo: [..],
|
|
339
|
+
* bytes, includedBytes, truncated, content }], // sorted by path
|
|
340
|
+
* stripped: [{ path, reason, role: "changed"|"imports"|"importedBy",
|
|
341
|
+
* relatedTo: [..] }], // sorted by path
|
|
342
|
+
* truncated: [{ path, bytes, includedBytes }], // sorted by path
|
|
343
|
+
* missing: [string], // sorted
|
|
344
|
+
* }
|
|
345
|
+
*
|
|
346
|
+
* Deterministic: same (repoRoot contents + changedFiles) → identical bundle.
|
|
347
|
+
*
|
|
348
|
+
* @param {object} input
|
|
349
|
+
* @param {string[]} input.changedFiles — repo-relative paths from the diff
|
|
350
|
+
* @param {string} [input.repoRoot]
|
|
351
|
+
* @param {number} [input.maxFileBytes]
|
|
352
|
+
* @returns {Promise<object>}
|
|
353
|
+
*/
|
|
354
|
+
export async function buildAdjacentBundle({ changedFiles, repoRoot = process.cwd(), maxFileBytes = DEFAULT_MAX_FILE_BYTES }) {
|
|
355
|
+
const changed = Array.from(
|
|
356
|
+
new Set((Array.isArray(changedFiles) ? changedFiles : []).map((f) => String(f).replace(/\\/g, "/")).filter((f) => f.length > 0)),
|
|
357
|
+
).sort();
|
|
358
|
+
|
|
359
|
+
// No changed files → empty bundle. Return early so the empty case is O(1) and
|
|
360
|
+
// skips the whole-repo scan + importer-index build entirely.
|
|
361
|
+
if (changed.length === 0) {
|
|
362
|
+
return { maxFileBytes, files: [], stripped: [], truncated: [], missing: [] };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const repoFiles = await listRepoFiles(repoRoot);
|
|
366
|
+
const existing = new Set(repoFiles);
|
|
367
|
+
const importerIndex = await buildImporterIndex(repoFiles, repoRoot);
|
|
368
|
+
|
|
369
|
+
// role per path: changed > imports/importedBy. relatedTo accumulates the
|
|
370
|
+
// changed files an adjacent file is adjacent to (sorted, deduped).
|
|
371
|
+
/** @type {Map<string, { role: string, relatedTo: Set<string> }>} */
|
|
372
|
+
const selected = new Map();
|
|
373
|
+
const note = (relPath, role, relatedTo) => {
|
|
374
|
+
if (!relPath) return;
|
|
375
|
+
let entry = selected.get(relPath);
|
|
376
|
+
if (!entry) {
|
|
377
|
+
entry = { role, relatedTo: new Set() };
|
|
378
|
+
selected.set(relPath, entry);
|
|
379
|
+
} else if (entry.role !== "changed" && role === "changed") {
|
|
380
|
+
entry.role = "changed";
|
|
381
|
+
}
|
|
382
|
+
if (relatedTo) entry.relatedTo.add(relatedTo);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
for (const file of changed) {
|
|
386
|
+
note(file, "changed", null);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const file of changed) {
|
|
390
|
+
// Out-edges: imports declared by the changed file (source files only).
|
|
391
|
+
if (isSourceFile(file) && existing.has(file)) {
|
|
392
|
+
try {
|
|
393
|
+
const source = await readFile(path.resolve(repoRoot, file), "utf8");
|
|
394
|
+
for (const spec of extractImportSpecifiers(source)) {
|
|
395
|
+
const target = resolveRelativeImport(file, spec, existing);
|
|
396
|
+
if (target && !changed.includes(target)) note(target, "imports", file);
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// unreadable changed file — diff still carries it; skip out-edges
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// In-edges: files that import the changed file.
|
|
403
|
+
const importers = importerIndex.get(file) ?? [];
|
|
404
|
+
for (const imp of importers) {
|
|
405
|
+
if (!changed.includes(imp)) note(imp, "importedBy", file);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const files = [];
|
|
410
|
+
const stripped = [];
|
|
411
|
+
const truncatedList = [];
|
|
412
|
+
const missing = [];
|
|
413
|
+
|
|
414
|
+
for (const relPath of Array.from(selected.keys()).sort()) {
|
|
415
|
+
const entry = selected.get(relPath);
|
|
416
|
+
const relatedTo = Array.from(entry.relatedTo).sort();
|
|
417
|
+
const guarded = await readGuardedFile(repoRoot, relPath, maxFileBytes);
|
|
418
|
+
if (guarded.strip) {
|
|
419
|
+
stripped.push({ path: relPath, reason: guarded.strip, role: entry.role, relatedTo });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (guarded.missing) {
|
|
423
|
+
// A changed file deleted by the diff legitimately won't exist on disk.
|
|
424
|
+
missing.push(relPath);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (guarded.truncated) {
|
|
428
|
+
truncatedList.push({ path: relPath, bytes: guarded.bytes, includedBytes: guarded.includedBytes });
|
|
429
|
+
}
|
|
430
|
+
files.push({
|
|
431
|
+
path: relPath,
|
|
432
|
+
role: entry.role,
|
|
433
|
+
relatedTo,
|
|
434
|
+
bytes: guarded.bytes,
|
|
435
|
+
includedBytes: guarded.includedBytes,
|
|
436
|
+
truncated: guarded.truncated,
|
|
437
|
+
content: guarded.content,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
maxFileBytes,
|
|
443
|
+
files,
|
|
444
|
+
stripped,
|
|
445
|
+
truncated: truncatedList,
|
|
446
|
+
missing: missing.sort(),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { buildParseError, formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
5
|
-
const USAGE = `Usage: create-
|
|
6
|
-
|
|
5
|
+
const USAGE = `Usage: create-pr.mjs [gh pr create args...]
|
|
6
|
+
Canonical PR-creation wrapper around \`gh pr create\`. Every PR opened through this
|
|
7
|
+
tool is ALWAYS a draft and is self-assigned by default. Never call raw \`gh pr create\`.
|
|
7
8
|
Behavior:
|
|
8
|
-
- injects exactly one \`--draft\` when absent
|
|
9
|
+
- injects exactly one \`--draft\` when absent (draft is the only mode)
|
|
10
|
+
- defaults \`--assignee @me\` when no assignee is given (self-assigned by default)
|
|
11
|
+
- honors an explicit \`--assignee <login>\` / \`-a <login>\` when supplied (no default injected)
|
|
9
12
|
- rejects \`--ready\` before invoking \`gh\`
|
|
10
13
|
- detects missing \`Closes #N\` / \`Fixes #N\` in \`--body\` or \`--body-file\` content (non-fatal stderr warning)
|
|
11
14
|
- forwards every other argument to \`gh pr create\` unchanged
|
|
12
15
|
- preserves the underlying \`gh pr create\` stdout, stderr, and exit code
|
|
13
16
|
Examples:
|
|
14
|
-
node scripts/github/create-
|
|
15
|
-
node <resolved-skill-scripts>/github/create-
|
|
17
|
+
node scripts/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
|
|
18
|
+
node <resolved-skill-scripts>/github/create-pr.mjs --repo owner/repo --base main --head feature --title "..." --body-file pr.md
|
|
16
19
|
Notes:
|
|
17
20
|
- Use \`gh pr ready\` later to leave draft state; this wrapper never opens a ready PR.
|
|
18
21
|
- Wrapper-owned validation is limited to \`--ready\`; all other argument validation is left to \`gh pr create\`.
|
|
@@ -25,6 +28,12 @@ const parseError = buildParseError(USAGE);
|
|
|
25
28
|
const READY_FLAG_PATTERN = /^--ready(?:$|=)/u;
|
|
26
29
|
const DRAFT_FLAG_PATTERN = /^--draft(?:=(.*))?$/iu;
|
|
27
30
|
const DRAFT_TRUE_VALUE_PATTERN = /^(?:true|1)$/iu;
|
|
31
|
+
// Detect both the long `--assignee`/`--assignee=<login>` forms and the `-a`
|
|
32
|
+
// short flag that `gh pr create` documents, so an explicit assignee in either
|
|
33
|
+
// form suppresses the `--assignee @me` default (otherwise a caller passing
|
|
34
|
+
// `-a <login>` would get a conflicting `--assignee @me` injected). (#894)
|
|
35
|
+
const ASSIGNEE_FLAG_PATTERN = /^(?:--assignee(?:$|=)|-a$)/u;
|
|
36
|
+
const DEFAULT_ASSIGNEE = "@me";
|
|
28
37
|
const CLOSING_KEYWORD_PATTERN = /Closes\s+#\d+|Fixes\s+#\d+/i;
|
|
29
38
|
const MAX_BODY_SCAN_BYTES = 16 * 1024;
|
|
30
39
|
export function detectClosingKeyword(body) {
|
|
@@ -52,12 +61,12 @@ async function warnMissingClosingKeyword(args) {
|
|
|
52
61
|
if (body === null) return; // no --body or --body-file, skip
|
|
53
62
|
if (!detectClosingKeyword(body)) {
|
|
54
63
|
process.stderr.write(
|
|
55
|
-
"[create-
|
|
64
|
+
"[create-pr] Warning: PR body missing `Closes #N` or `Fixes #N`. " +
|
|
56
65
|
"GitHub will not auto-close the linked issue on merge.\n",
|
|
57
66
|
);
|
|
58
67
|
}
|
|
59
68
|
}
|
|
60
|
-
export function
|
|
69
|
+
export function buildCreatePrArgs(argv) {
|
|
61
70
|
const args = [...argv];
|
|
62
71
|
if (args.includes("--help") || args.includes("-h")) {
|
|
63
72
|
return {
|
|
@@ -66,17 +75,24 @@ export function buildCreateDraftPrArgs(argv) {
|
|
|
66
75
|
};
|
|
67
76
|
}
|
|
68
77
|
if (args.some((token) => READY_FLAG_PATTERN.test(token))) {
|
|
69
|
-
throw parseError("create-
|
|
78
|
+
throw parseError("create-pr rejects --ready; open the PR as draft first, then run `gh pr ready` after the draft gate is satisfied");
|
|
70
79
|
}
|
|
71
80
|
const draftTokens = args.filter((token) => DRAFT_FLAG_PATTERN.test(token));
|
|
72
81
|
const lastDraftToken = draftTokens.length > 0 ? draftTokens.at(-1) : null;
|
|
73
82
|
const lastDraftSuppliesDraft = lastDraftToken === "--draft" || (typeof lastDraftToken === "string" && DRAFT_TRUE_VALUE_PATTERN.test(lastDraftToken.slice("--draft=".length)));
|
|
83
|
+
const hasAssignee = args.some((token) => ASSIGNEE_FLAG_PATTERN.test(token));
|
|
74
84
|
return {
|
|
75
85
|
help: false,
|
|
76
|
-
ghArgs: [
|
|
86
|
+
ghArgs: [
|
|
87
|
+
"pr",
|
|
88
|
+
"create",
|
|
89
|
+
...args,
|
|
90
|
+
...(hasAssignee ? [] : ["--assignee", DEFAULT_ASSIGNEE]),
|
|
91
|
+
...(lastDraftSuppliesDraft ? [] : ["--draft"]),
|
|
92
|
+
],
|
|
77
93
|
};
|
|
78
94
|
}
|
|
79
|
-
export function
|
|
95
|
+
export function spawnCreatePr(ghArgs, { ghCommand = "gh", env = process.env } = {}) {
|
|
80
96
|
return new Promise((resolve, reject) => {
|
|
81
97
|
const child = spawn(ghCommand, ghArgs, {
|
|
82
98
|
env,
|
|
@@ -89,13 +105,13 @@ export function spawnCreateDraftPr(ghArgs, { ghCommand = "gh", env = process.env
|
|
|
89
105
|
});
|
|
90
106
|
}
|
|
91
107
|
export async function main(argv = process.argv.slice(2), runtime = {}) {
|
|
92
|
-
const { help, ghArgs } =
|
|
108
|
+
const { help, ghArgs } = buildCreatePrArgs(argv);
|
|
93
109
|
if (help) {
|
|
94
110
|
process.stdout.write(`${USAGE}\n`);
|
|
95
111
|
return 0;
|
|
96
112
|
}
|
|
97
113
|
await warnMissingClosingKeyword(argv);
|
|
98
|
-
return
|
|
114
|
+
return spawnCreatePr(ghArgs, runtime);
|
|
99
115
|
}
|
|
100
116
|
if (isDirectCliRun(import.meta.url)) {
|
|
101
117
|
try {
|