@dev-loops/core 1.0.0-rc.6 → 1.0.0-rc.7
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/package.json +7 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +448 -9
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +1 -27
- package/src/security/secret-scan.mjs +330 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./debt/signal": "./src/debt/debt-signal.mjs",
|
|
23
23
|
"./github/copilot-helpers": "./src/github/copilot-helpers.mjs",
|
|
24
24
|
"./github/comment-id-guard": "./src/github/comment-id-guard.mjs",
|
|
25
|
+
"./github/gh": "./src/github/gh.mjs",
|
|
25
26
|
"./github/issue-ops": "./src/github/issue-ops.mjs",
|
|
26
27
|
"./github/ownership-helpers": "./src/github/ownership-helpers.mjs",
|
|
27
28
|
"./github/repo-slug": "./src/github/repo-slug.mjs",
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
|
|
38
39
|
"./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
|
|
39
40
|
"./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
|
|
41
|
+
"./loop/markdown-sections": "./src/loop/markdown-sections.mjs",
|
|
40
42
|
"./loop/issue-refinement-artifact": "./src/loop/issue-refinement-artifact.mjs",
|
|
41
43
|
"./loop/phase-files": "./src/loop/phase-files.mjs",
|
|
42
44
|
"./loop/policy-constants": "./src/loop/policy-constants.mjs",
|
|
@@ -56,12 +58,14 @@
|
|
|
56
58
|
"./loop/queue-membership": "./src/loop/queue-membership.mjs",
|
|
57
59
|
"./loop/queue-parallel": "./src/loop/queue-parallel.mjs",
|
|
58
60
|
"./loop/queue-state": "./src/loop/queue-state.mjs",
|
|
61
|
+
"./loop/size-budget-merge-gate": "./src/loop/size-budget-merge-gate.mjs",
|
|
59
62
|
"./loop/cache-telemetry-evidence": "./src/loop/cache-telemetry-evidence.mjs",
|
|
60
63
|
"./loop/primer-evidence": "./src/loop/primer-evidence.mjs",
|
|
61
64
|
"./loop/review-dispatch-plan": "./src/loop/review-dispatch-plan.mjs",
|
|
62
65
|
"./loop/reviewer-loop-state": "./src/loop/reviewer-loop-state.mjs",
|
|
63
66
|
"./loop/run-context": "./src/loop/run-context.mjs",
|
|
64
67
|
"./loop/run-inspection": "./src/loop/run-inspection.mjs",
|
|
68
|
+
"./loop/run-post-merge-actions": "./src/loop/run-post-merge-actions.mjs",
|
|
65
69
|
"./loop/spike-exit-contract": "./src/loop/spike-exit-contract.mjs",
|
|
66
70
|
"./loop/spike-intake-contract": "./src/loop/spike-intake-contract.mjs",
|
|
67
71
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
@@ -73,6 +77,7 @@
|
|
|
73
77
|
"./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
|
|
74
78
|
"./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
|
|
75
79
|
"./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
|
|
80
|
+
"./security/secret-scan": "./src/security/secret-scan.mjs",
|
|
76
81
|
"./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
|
|
77
82
|
"./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
|
|
78
83
|
"./projects/resolve-project": "./src/projects/resolve-project.mjs",
|
|
@@ -80,6 +85,7 @@
|
|
|
80
85
|
"./tracker": "./src/tracker/index.mjs",
|
|
81
86
|
"./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
|
|
82
87
|
"./loop/main-checkout-ff": "./src/loop/main-checkout-ff.mjs",
|
|
88
|
+
"./loop/normalize": "./src/loop/normalize.mjs",
|
|
83
89
|
"./loop/tracker-first-loop-state": "./src/loop/tracker-first-loop-state.mjs"
|
|
84
90
|
},
|
|
85
91
|
"bin": {
|
|
@@ -22,6 +22,10 @@ export const ChangeCategory = Object.freeze({
|
|
|
22
22
|
// child_process/shell exec, untrusted network fetch, destructive filesystem
|
|
23
23
|
// ops / local-file upload). Triggers an up-front adversarial threat-model angle.
|
|
24
24
|
SECURITY_SENSITIVE_SEAM: "SECURITY_SENSITIVE_SEAM",
|
|
25
|
+
// #1442: a changed file is on the prose surface (docs/articles/**,
|
|
26
|
+
// docs/presentations/**, README*, narrative docs/*.md) — ADR 0041 prose half.
|
|
27
|
+
// Triggers the required fail-closed `deslop` gate angle.
|
|
28
|
+
PROSE_PRESENT: "PROSE_PRESENT",
|
|
25
29
|
});
|
|
26
30
|
|
|
27
31
|
// ---------------------------------------------------------------------------
|
|
@@ -69,6 +73,12 @@ export const CATEGORY_ANGLE_MAP = {
|
|
|
69
73
|
[ChangeCategory.SECURITY_SENSITIVE_SEAM]: [
|
|
70
74
|
"threat-model", "input-validation", "scope", "correctness",
|
|
71
75
|
],
|
|
76
|
+
// #1442 (ADR 0041 prose half): a prose-surface path is present → run the
|
|
77
|
+
// required `deslop` gate angle. skills/docs/** is deliberately NOT prose, so
|
|
78
|
+
// a normative-contract-only docs diff never triggers deslop.
|
|
79
|
+
[ChangeCategory.PROSE_PRESENT]: [
|
|
80
|
+
"deslop",
|
|
81
|
+
],
|
|
72
82
|
};
|
|
73
83
|
|
|
74
84
|
/**
|
|
@@ -21,6 +21,44 @@
|
|
|
21
21
|
// fails closed toward a full re-review, which is what a version bump needs.
|
|
22
22
|
const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
|
|
23
23
|
|
|
24
|
+
// #1442 (ADR 0041 prose half): the prose surface that triggers the required
|
|
25
|
+
// `deslop` gate angle. skills/docs/** is deliberately excluded — those are
|
|
26
|
+
// normative contracts (owned by the contract style guide + contradiction
|
|
27
|
+
// lens), not prose, and deslop's contrast-cutting must not fight RFC-2119
|
|
28
|
+
// modality.
|
|
29
|
+
const PROSE_PATH_RE = /^docs\/(articles|presentations)\//;
|
|
30
|
+
const NARRATIVE_DOC_RE = /^docs\/[^/]+\.(md|markdown)$/;
|
|
31
|
+
// #1442 review finding: the skills/docs/** exemption must hold for the README
|
|
32
|
+
// rule too. PROSE_PATH_RE / NARRATIVE_DOC_RE are anchored to `docs/` so
|
|
33
|
+
// skills/docs files never match them; the README basename rule is the ONLY
|
|
34
|
+
// path by which a skills/docs file could be classified prose (e.g. a future
|
|
35
|
+
// `skills/docs/README-*.md`). Carve the normative-contract subtree out
|
|
36
|
+
// explicitly so a README-named contract never arms deslop.
|
|
37
|
+
const SKILLS_DOCS_EXEMPT_RE = /^(skills\/docs|\x2eclaude\/skills\/docs)\//;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether a file path is on the prose surface (#1442): docs/articles/**,
|
|
41
|
+
* docs/presentations/**, README*, or a narrative direct-child docs/*.md.
|
|
42
|
+
* skills/docs/** (and .claude/skills/docs/**) is exempt (normative
|
|
43
|
+
* contracts, not prose) across ALL rules, including the README basename rule.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} filePath
|
|
46
|
+
* @returns {boolean}
|
|
47
|
+
*/
|
|
48
|
+
export function isProsePath(filePath) {
|
|
49
|
+
// #1442 review finding (input-validation): exported trust-boundary guard.
|
|
50
|
+
// Fail closed (false) on a non-string/empty argument rather than crashing in
|
|
51
|
+
// normalizeSep(filePath).replaceAll(...).
|
|
52
|
+
if (typeof filePath !== "string" || filePath.length === 0) return false;
|
|
53
|
+
const fp = normalizeSep(filePath);
|
|
54
|
+
if (SKILLS_DOCS_EXEMPT_RE.test(fp)) return false;
|
|
55
|
+
if (PROSE_PATH_RE.test(fp)) return true;
|
|
56
|
+
if (NARRATIVE_DOC_RE.test(fp)) return true;
|
|
57
|
+
const base = fp.split("/").pop() ?? "";
|
|
58
|
+
if (base.startsWith("README")) return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
24
62
|
/**
|
|
25
63
|
* @typedef {object} T0Result
|
|
26
64
|
* @property {string[]} files — flat file paths
|
|
@@ -56,6 +94,13 @@ export function analyzeT0(nameStatusOutput) {
|
|
|
56
94
|
const extensions = new Set();
|
|
57
95
|
const directories = new Set();
|
|
58
96
|
let renameCount = 0;
|
|
97
|
+
// #1442 review finding: prose arming must be scoped to content-carrying
|
|
98
|
+
// rows. A pure deletion (`D` name-status row) has no prose content for the
|
|
99
|
+
// deslop reviewer to strip — arming deslop on it over-selects and re-runs
|
|
100
|
+
// the fan-out over a content-free delta. Added/modified/renamed-dest rows
|
|
101
|
+
// (A / M / M? / R-dest) carry content and still arm prose. Rename rows emit
|
|
102
|
+
// `R<score>\told\tnew` (rawPath = parts[2], the new content-bearing path).
|
|
103
|
+
let prosePresent = false;
|
|
59
104
|
|
|
60
105
|
for (const line of lines) {
|
|
61
106
|
const parts = line.split("\t");
|
|
@@ -73,6 +118,16 @@ export function analyzeT0(nameStatusOutput) {
|
|
|
73
118
|
if (dir) directories.add(dir);
|
|
74
119
|
|
|
75
120
|
if (status.startsWith("R")) renameCount++;
|
|
121
|
+
// #1442: a content-carrying prose file arms PROSE_PRESENT → deslop. Pure
|
|
122
|
+
// deletions (`D`) are excluded (no prose content to strip, avoids noise);
|
|
123
|
+
// a pure `R100` rename (git's 100% similarity score — no content changed)
|
|
124
|
+
// is likewise content-free and must not arm deslop. Renames with a score
|
|
125
|
+
// below 100 (`R<score>\told\tnew`, rawPath = the new content-bearing path)
|
|
126
|
+
// carry content and still arm prose.
|
|
127
|
+
if (prosePresent) continue;
|
|
128
|
+
if (status === "D" || status.startsWith("D")) continue;
|
|
129
|
+
if (status.startsWith("R") && status === "R100") continue;
|
|
130
|
+
if (isProsePath(path)) prosePresent = true;
|
|
76
131
|
}
|
|
77
132
|
|
|
78
133
|
const renameOnly = lines.length > 0 && renameCount === lines.length;
|
|
@@ -87,6 +142,7 @@ export function analyzeT0(nameStatusOutput) {
|
|
|
87
142
|
directories: [...directories].sort(),
|
|
88
143
|
renameOnly,
|
|
89
144
|
allDocs,
|
|
145
|
+
prosePresent,
|
|
90
146
|
};
|
|
91
147
|
}
|
|
92
148
|
|
|
@@ -372,6 +428,7 @@ function t0FileCategories(t0) {
|
|
|
372
428
|
const categories = [];
|
|
373
429
|
if (t0.renameOnly) categories.push("RENAME_ONLY");
|
|
374
430
|
if (t0.allDocs) categories.push("DOCS_ONLY");
|
|
431
|
+
if (t0.prosePresent) categories.push("PROSE_PRESENT");
|
|
375
432
|
if (t0.files.every((f) => classifyFile(f) === "config")) categories.push("CONFIG_ONLY");
|
|
376
433
|
if (t0.files.every((f) => classifyFile(f) === "test")) categories.push("TEST_ONLY");
|
|
377
434
|
if (t0.files.every((f) => classifyFile(f) === "ci")) categories.push("CI_ONLY");
|
|
@@ -392,6 +449,7 @@ function t0PresentSurfaceCategories(t0) {
|
|
|
392
449
|
const categories = [];
|
|
393
450
|
const cats = new Set(t0.files.map(classifyFile));
|
|
394
451
|
if (cats.has("docs")) categories.push("DOCS_ONLY");
|
|
452
|
+
if (t0.prosePresent) categories.push("PROSE_PRESENT");
|
|
395
453
|
if (cats.has("config")) categories.push("CONFIG_ONLY");
|
|
396
454
|
if (cats.has("test")) categories.push("TEST_ONLY");
|
|
397
455
|
if (cats.has("ci")) categories.push("CI_ONLY");
|
|
@@ -445,8 +503,17 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
|
|
|
445
503
|
// When t1 is null (unambiguous diff), infer categories from t0
|
|
446
504
|
// so dynamic angle resolution can narrow for config-only / test-only etc.
|
|
447
505
|
if (!t1) {
|
|
506
|
+
// #1442 Copilot finding: a genuinely MIXED diff whose T1 never ran (no
|
|
507
|
+
// diffOutput) must NOT get a T0-only PROSE_PRESENT category. Non-empty
|
|
508
|
+
// categories set ambiguous=false, so a mixed code+prose diff would
|
|
509
|
+
// under-select to just deslop + always-include and drop the code-review
|
|
510
|
+
// core. T0-only inference is only safe for unambiguous diffs (docs-only /
|
|
511
|
+
// single surface); a mixed diff without hunk content is unclassifiable, so
|
|
512
|
+
// return empty categories and let resolveDynamicAngles fall back to the
|
|
513
|
+
// full angle set (fail closed).
|
|
514
|
+
const changeCategories = t0Ambiguous ? [] : inferCategoriesFromT0(t0);
|
|
448
515
|
t1 = {
|
|
449
|
-
changeCategories
|
|
516
|
+
changeCategories,
|
|
450
517
|
hunkCount: 0,
|
|
451
518
|
lineStats: { added: 0, deleted: 0 },
|
|
452
519
|
};
|
|
@@ -383,6 +383,23 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
|
|
|
383
383
|
*/
|
|
384
384
|
export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
|
|
385
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Env var that exempts an orchestrator-owned-commit dispatch from the SubagentStop
|
|
388
|
+
* uncommitted-work guard (#1786).
|
|
389
|
+
*
|
|
390
|
+
* A "LOCAL EDITS ONLY: no commit" dispatch (e.g. the `developer`/`quality`/`docs` delegation
|
|
391
|
+
* pattern in `skills/local-implementation/SKILL.md` "Delegation contract") tells the subagent to
|
|
392
|
+
* make local edits and report changed files, leaving commit + push to the dispatching
|
|
393
|
+
* orchestrator once it consolidates results. Without an exemption, that subagent's own
|
|
394
|
+
* SubagentStop event still sees the dirty worktree it was told not to commit and deadlocks. The
|
|
395
|
+
* dispatcher sets `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` for that dispatch to declare it owns the
|
|
396
|
+
* commit — same opt-in `DEVLOOPS_*` signal shape as `DEVLOOPS_COMMIT_AUTH_PENDING`, but distinct:
|
|
397
|
+
* this one exempts a non-interactive delegated dispatch whose commit responsibility sits with its
|
|
398
|
+
* caller, not an interactive session awaiting operator authorization. Left unset, an ordinary
|
|
399
|
+
* dispatch's commit-before-exit obligation stays enforced (fail closed by default).
|
|
400
|
+
*/
|
|
401
|
+
export const DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT_VAR = "DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT";
|
|
402
|
+
|
|
386
403
|
/**
|
|
387
404
|
* Decide whether a SubagentStop must be blocked because the subagent's worktree has
|
|
388
405
|
* uncommitted changes (#1619).
|
|
@@ -391,7 +408,8 @@ export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
|
|
|
391
408
|
* uncommitted changes in a worktree are destroyed with no warning. `LOCAL-COMMIT-BEFORE-EXIT`
|
|
392
409
|
* existed only as prose. This decider makes it mechanical: refuse the subagent stop when the
|
|
393
410
|
* cwd is under `tmp/worktrees/` and `git status --porcelain` is non-empty, unless the session
|
|
394
|
-
* is an interactive one awaiting commit authorization
|
|
411
|
+
* is an interactive one awaiting commit authorization, or the dispatch is an explicit
|
|
412
|
+
* orchestrator-owned-commit exemption (#1786) (either exempt). A clean worktree, a cwd
|
|
395
413
|
* outside `tmp/worktrees/`, and a git-error/empty-porcelain case all allow the stop.
|
|
396
414
|
*
|
|
397
415
|
* Pure and side-effect free. The hook script gathers `cwd` and the `git status --porcelain`
|
|
@@ -406,13 +424,16 @@ export const DEVLOOPS_COMMIT_AUTH_PENDING_VAR = "DEVLOOPS_COMMIT_AUTH_PENDING";
|
|
|
406
424
|
* @param {boolean} [params.pendingCommitAuthorization] - True when the interactive session is
|
|
407
425
|
* awaiting commit authorization (exempt) — derived by the hook script from the
|
|
408
426
|
* `DEVLOOPS_COMMIT_AUTH_PENDING=1` opt-in env signal.
|
|
427
|
+
* @param {boolean} [params.orchestratorOwnsCommit] - True when this dispatch is an explicit
|
|
428
|
+
* orchestrator-owned-commit exemption (exempt) — derived by the hook script from the
|
|
429
|
+
* `DEVLOOPS_ORCHESTRATOR_OWNS_COMMIT=1` opt-in env signal.
|
|
409
430
|
* @returns {HookDecision}
|
|
410
431
|
*/
|
|
411
|
-
export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false }) {
|
|
432
|
+
export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthorization = false, orchestratorOwnsCommit = false }) {
|
|
412
433
|
if (typeof cwd !== "string" || !isUnderWorktreePath(cwd)) {
|
|
413
434
|
return ALLOW;
|
|
414
435
|
}
|
|
415
|
-
if (pendingCommitAuthorization) {
|
|
436
|
+
if (pendingCommitAuthorization || orchestratorOwnsCommit) {
|
|
416
437
|
return ALLOW;
|
|
417
438
|
}
|
|
418
439
|
if (typeof porcelain !== "string" || porcelain.trim() === "") {
|
|
@@ -422,6 +443,17 @@ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthoriza
|
|
|
422
443
|
.split("\n")
|
|
423
444
|
.map((l) => l.trim())
|
|
424
445
|
.filter(Boolean);
|
|
446
|
+
// The reason is fed back to the stopping subagent as context, so the path
|
|
447
|
+
// enumeration is capped: an unbounded list on a very dirty worktree produces a
|
|
448
|
+
// multi-megabyte reason that consumers truncate or choke on, burying the one
|
|
449
|
+
// actionable line. The "Dirty paths (N):" header below always carries the full
|
|
450
|
+
// dirty count; the trailing "… and X more" line (when present) carries the
|
|
451
|
+
// remaining count past the cap, not the full total.
|
|
452
|
+
const MAX_LISTED_DIRTY_PATHS = 50;
|
|
453
|
+
const listed = dirty.slice(0, MAX_LISTED_DIRTY_PATHS).map((p) => " " + p);
|
|
454
|
+
if (dirty.length > MAX_LISTED_DIRTY_PATHS) {
|
|
455
|
+
listed.push(` … and ${dirty.length - MAX_LISTED_DIRTY_PATHS} more (run \`git status --porcelain\` for the full list)`);
|
|
456
|
+
}
|
|
425
457
|
return {
|
|
426
458
|
decision: "block",
|
|
427
459
|
reason:
|
|
@@ -429,6 +461,6 @@ export function decideSubagentStopGuard({ cwd, porcelain, pendingCommitAuthoriza
|
|
|
429
461
|
"to prevent silent data loss from post-merge worktree cleanup (cleanup-worktree.mjs runs " +
|
|
430
462
|
"`git worktree remove --force`). Commit your work before stopping. " +
|
|
431
463
|
`Dirty paths (${dirty.length}):\n` +
|
|
432
|
-
|
|
464
|
+
listed.join("\n"),
|
|
433
465
|
};
|
|
434
466
|
}
|
package/src/cli/primitives.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
2
4
|
import { parseArgs } from "node:util";
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -113,7 +115,7 @@ export function parsePositiveInteger(value, flag, parseError = null) {
|
|
|
113
115
|
|
|
114
116
|
export function parseNonNegativeInteger(value, flag, parseError = null) {
|
|
115
117
|
if (!/^\d+$/.test(value)) {
|
|
116
|
-
throw toCliError(`${flag} must be a non-negative integer`, parseError);
|
|
118
|
+
throw toCliError(`${flag} must be a non-negative integer, got "${value}"`, parseError);
|
|
117
119
|
}
|
|
118
120
|
return Number(value);
|
|
119
121
|
}
|
|
@@ -167,6 +169,33 @@ export function runChild(command, args, env = process.env, stdinText = undefined
|
|
|
167
169
|
});
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a `--body <text>` / `--body-file <path>` pair to the actual body
|
|
174
|
+
* string, the one file-reading step every `--body-file` CLI flag in this repo
|
|
175
|
+
* needs: read the file (or stdin, when `allowStdin` and `bodyFile === "-"`)
|
|
176
|
+
* and FAIL CLOSED (throw) on an empty/whitespace-only result, so a blank or
|
|
177
|
+
* unreadable `--body-file` can never silently clear a PR/comment body. When
|
|
178
|
+
* `bodyFile` is absent, `body` is returned unchanged (no emptiness check —
|
|
179
|
+
* callers that must reject an empty inline `--body` do that themselves,
|
|
180
|
+
* since not every caller enforces it).
|
|
181
|
+
*
|
|
182
|
+
* @param {object} input
|
|
183
|
+
* @param {string} [input.body] — inline body value, returned as-is when no bodyFile
|
|
184
|
+
* @param {string} [input.bodyFile] — path to read the body from ("-" = stdin, when allowStdin)
|
|
185
|
+
* @param {boolean} [input.allowStdin] — honor `bodyFile === "-"` as stdin (fd 0); default false
|
|
186
|
+
* @returns {Promise<string | undefined>}
|
|
187
|
+
*/
|
|
188
|
+
export async function resolveBodyOrFile({ body, bodyFile, allowStdin = false } = {}) {
|
|
189
|
+
if (bodyFile === undefined || bodyFile === null) return body;
|
|
190
|
+
// fs/promises readFile does not accept an integer fd, so stdin (fd 0) is
|
|
191
|
+
// read synchronously via readFileSync, which does accept one.
|
|
192
|
+
const content = allowStdin && bodyFile === "-" ? readFileSync(0, "utf8") : await readFile(bodyFile, "utf8");
|
|
193
|
+
if (content.trim().length === 0) {
|
|
194
|
+
throw new Error(`--body-file ${bodyFile} is empty`);
|
|
195
|
+
}
|
|
196
|
+
return content;
|
|
197
|
+
}
|
|
198
|
+
|
|
170
199
|
export function runCommand(command, args, { cwd = process.cwd(), env = process.env } = {}) {
|
|
171
200
|
return new Promise((resolve, reject) => {
|
|
172
201
|
const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|