vouchington-tooling 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/gh-api-shell-quoting/index.d.mts +5 -0
- package/dist/gh-api-shell-quoting/index.mjs +17 -0
- package/dist/gh-api-shell-quoting/scan.d.mts +7 -0
- package/dist/gh-api-shell-quoting/scan.mjs +181 -0
- package/dist/gh-api-shell-quoting/yaml.d.mts +6 -0
- package/dist/gh-api-shell-quoting/yaml.mjs +95 -0
- package/dist/gh-cli/index.d.mts +1 -1
- package/dist/gh-cli/index.mjs +1 -1
- package/dist/gh-cli/pr-create.d.mts +17 -3
- package/dist/gh-cli/pr-create.mjs +27 -5
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +6 -1
- package/skills/github-issue/SKILL.md +12 -5
package/README.md
CHANGED
|
@@ -208,8 +208,21 @@ import { runRetrospectiveTranscript } from 'vouchington-tooling/retrospective-tr
|
|
|
208
208
|
import { appendJournal, probeBlackboard } from 'vouchington-tooling/agent-blackboard'
|
|
209
209
|
import { buildSessionFrictionReport, recordFriction } from 'vouchington-tooling/session-friction'
|
|
210
210
|
import { createPullRequest, getDiffAgainstBase, runGh, runGit } from 'vouchington-tooling/gh-cli'
|
|
211
|
+
import {
|
|
212
|
+
shellScriptViolations,
|
|
213
|
+
workflowYamlViolations,
|
|
214
|
+
} from 'vouchington-tooling/gh-api-shell-quoting'
|
|
211
215
|
```
|
|
212
216
|
|
|
217
|
+
`shellScriptViolations`/`workflowYamlViolations` flag a `gh api` call whose argument carries an
|
|
218
|
+
unquoted `?` or `&`: an unquoted `&` silently backgrounds the command and truncates the query
|
|
219
|
+
(the call still exits 0), and an unquoted `?` fails loudly under zsh glob-nomatch but passes
|
|
220
|
+
through unexpanded under bash. `workflowYamlViolations` decodes quoted and folded YAML `run:`
|
|
221
|
+
scalars before scanning, so a hazard hidden by YAML's own quote-stripping is still caught; it
|
|
222
|
+
throws on a `run:` value that is a YAML alias or a multiline PLAIN scalar, shapes it cannot yet
|
|
223
|
+
scan safely. Both functions scan already-in-scope source text — deciding which files count as a
|
|
224
|
+
shell script or a workflow/action YAML file is left to the caller.
|
|
225
|
+
|
|
213
226
|
`checkWorkspaceGatesPolicy` rejects tracked test assertions that hard-code the exact version of a
|
|
214
227
|
dependency declared by a non-fixture package manifest. Assert dependency membership or placement,
|
|
215
228
|
or derive a configuration or documentation package spec from that manifest instead.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ShellQuotingViolation } from './yaml.mts';
|
|
2
|
+
export type { ShellQuotingViolation } from './yaml.mts';
|
|
3
|
+
export { workflowYamlViolations } from './yaml.mts';
|
|
4
|
+
/** For a plain shell script: `source` is the whole file, so hit offsets are already absolute. */
|
|
5
|
+
export declare function shellScriptViolations(source: string): ShellQuotingViolation[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Detects `gh api <arg>` invocations, in a shell script or a workflow/composite-action `run:`
|
|
2
|
+
// block, where the argument carries an unquoted `?` or `&`. An unquoted `&` backgrounds the
|
|
3
|
+
// command and drops every argument after it — the truncated call still exits 0, so CI can report
|
|
4
|
+
// success on a silently short-circuited query. An unquoted `?` fails loudly under zsh
|
|
5
|
+
// glob-nomatch but passes through unexpanded under bash.
|
|
6
|
+
//
|
|
7
|
+
// This module only scans already-in-scope source text; deciding which files count as a shell
|
|
8
|
+
// script or a workflow/action YAML file is repo-layout policy left to the caller.
|
|
9
|
+
import { ghApiShellQuotingHits, lineNumberAt } from './scan.mjs';
|
|
10
|
+
export { workflowYamlViolations } from './yaml.mjs';
|
|
11
|
+
/** For a plain shell script: `source` is the whole file, so hit offsets are already absolute. */
|
|
12
|
+
export function shellScriptViolations(source) {
|
|
13
|
+
return ghApiShellQuotingHits(source).map((hit) => ({
|
|
14
|
+
line: lineNumberAt(source, hit.offset),
|
|
15
|
+
excerpt: hit.excerpt,
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type ShellQuotingHit = {
|
|
2
|
+
offset: number;
|
|
3
|
+
excerpt: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function lineNumberAt(source: string, offset: number): number;
|
|
6
|
+
/** Hits in `text` with offsets relative to `text` itself, not any enclosing document. */
|
|
7
|
+
export declare function ghApiShellQuotingHits(text: string): ShellQuotingHit[];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Pure `gh api` shell-argument scanner: detects an unquoted `?` or `&` in a `gh api` call's
|
|
2
|
+
// argument list. An unquoted `&` backgrounds the command and drops every argument after it — the
|
|
3
|
+
// truncated call still exits 0, so CI can report success on a silently short-circuited query. An
|
|
4
|
+
// unquoted `?` fails loudly under zsh glob-nomatch but passes through unexpanded under bash. See
|
|
5
|
+
// ./yaml.mts and ./index.mts for what feeds this and how it maps hits back to source locations.
|
|
6
|
+
//
|
|
7
|
+
// This is a small heuristic scanner, not a shell parser: it tracks quote state, backslash
|
|
8
|
+
// escapes, and `$(...)` command-substitution nesting per logical line (physical lines joined
|
|
9
|
+
// across a trailing, unescaped `\`). It stops once it finds the first unsafe character in a
|
|
10
|
+
// call's argument list. A general-purpose shell tokenizer whose "expandable" notion conflates
|
|
11
|
+
// glob metacharacters with `$`/backtick expansion would false-positive on a quoted
|
|
12
|
+
// `"…${VAR}…?per_page=1"`, so this scanner tracks its own narrower quote-state machine instead.
|
|
13
|
+
const GH_API_BOUNDARY = /[\s;|&(){}]/;
|
|
14
|
+
const GH_API_HEAD = /^gh[ \t]+api\b/;
|
|
15
|
+
function matchGhApiHead(text, index) {
|
|
16
|
+
if (index > 0 && !GH_API_BOUNDARY.test(text[index - 1]))
|
|
17
|
+
return 0;
|
|
18
|
+
const match = GH_API_HEAD.exec(text.slice(index));
|
|
19
|
+
return match ? match[0].length : 0;
|
|
20
|
+
}
|
|
21
|
+
function excerptAround(text, index) {
|
|
22
|
+
return text.slice(Math.max(0, index - 24), Math.min(text.length, index + 12)).trim();
|
|
23
|
+
}
|
|
24
|
+
function newFrame() {
|
|
25
|
+
return { inSingleQuote: false, inDoubleQuote: false, scanningArgs: false, parenDepth: 0 };
|
|
26
|
+
}
|
|
27
|
+
function pushSubstitutionFrame(stack) {
|
|
28
|
+
const frame = newFrame();
|
|
29
|
+
frame.parenDepth = 1;
|
|
30
|
+
stack.push(frame);
|
|
31
|
+
}
|
|
32
|
+
// Scans one already-joined logical line for `gh api` calls and returns at most one hit per call —
|
|
33
|
+
// the first unquoted `?` or `&`, whichever comes first. `offsets[k]` maps character k of `text`
|
|
34
|
+
// back to its absolute offset in the original source, so callers can report real line numbers.
|
|
35
|
+
function scanLogicalLine(text, offsets) {
|
|
36
|
+
const hits = [];
|
|
37
|
+
const stack = [newFrame()];
|
|
38
|
+
let i = 0;
|
|
39
|
+
while (i < text.length) {
|
|
40
|
+
const frame = stack[stack.length - 1];
|
|
41
|
+
const char = text[i];
|
|
42
|
+
if (frame.inSingleQuote) {
|
|
43
|
+
if (char === "'")
|
|
44
|
+
frame.inSingleQuote = false;
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === '\\') {
|
|
49
|
+
i += 2;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (frame.inDoubleQuote) {
|
|
53
|
+
if (char === '"') {
|
|
54
|
+
frame.inDoubleQuote = false;
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === '$' && text[i + 1] === '(') {
|
|
59
|
+
pushSubstitutionFrame(stack);
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
i += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === '#' && (i === 0 || /\s/.test(text[i - 1])))
|
|
67
|
+
break;
|
|
68
|
+
if (char === "'") {
|
|
69
|
+
frame.inSingleQuote = true;
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (char === '"') {
|
|
74
|
+
frame.inDoubleQuote = true;
|
|
75
|
+
i += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (char === '$' && text[i + 1] === '(') {
|
|
79
|
+
pushSubstitutionFrame(stack);
|
|
80
|
+
i += 2;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (stack.length > 1 && (char === '(' || char === ')')) {
|
|
84
|
+
frame.parenDepth += char === '(' ? 1 : -1;
|
|
85
|
+
i += 1;
|
|
86
|
+
if (frame.parenDepth === 0)
|
|
87
|
+
stack.pop();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (frame.scanningArgs) {
|
|
91
|
+
if (char === ';' || char === '|') {
|
|
92
|
+
frame.scanningArgs = false;
|
|
93
|
+
i += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (char === '&') {
|
|
97
|
+
if (text[i + 1] === '&') {
|
|
98
|
+
frame.scanningArgs = false;
|
|
99
|
+
i += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// `2>&1`/`>&2`/`<&3` fd-duplication redirects don't end the argument list; whitespace-
|
|
103
|
+
// preceded `&` is Bash's background operator and does. Only an `&` embedded in an
|
|
104
|
+
// unquoted argument is unsafe.
|
|
105
|
+
if (i > 0 && /[><]/.test(text[i - 1])) {
|
|
106
|
+
i += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (!(i > 0 && /[ \t]/.test(text[i - 1])))
|
|
110
|
+
hits.push({ offset: offsets[i], excerpt: excerptAround(text, i) });
|
|
111
|
+
frame.scanningArgs = false;
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (char === '?') {
|
|
116
|
+
hits.push({ offset: offsets[i], excerpt: excerptAround(text, i) });
|
|
117
|
+
frame.scanningArgs = false;
|
|
118
|
+
i += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const headLength = frame.scanningArgs ? 0 : matchGhApiHead(text, i);
|
|
123
|
+
if (headLength > 0) {
|
|
124
|
+
frame.scanningArgs = true;
|
|
125
|
+
i += headLength;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
i += 1;
|
|
129
|
+
}
|
|
130
|
+
return hits;
|
|
131
|
+
}
|
|
132
|
+
// A logical line joins physical lines across a trailing, unescaped `\` — bash line continuation —
|
|
133
|
+
// so a `gh api \` / URL-on-next-line split (common in multi-line `run:` steps) is scanned as one
|
|
134
|
+
// command instead of two truncated fragments.
|
|
135
|
+
function endsWithLineContinuation(line) {
|
|
136
|
+
let backslashRun = 0;
|
|
137
|
+
for (let i = line.length - 1; i >= 0 && line[i] === '\\'; i -= 1)
|
|
138
|
+
backslashRun += 1;
|
|
139
|
+
return backslashRun % 2 === 1;
|
|
140
|
+
}
|
|
141
|
+
function joinLogicalLines(source) {
|
|
142
|
+
const lines = [];
|
|
143
|
+
let position = 0;
|
|
144
|
+
while (position <= source.length) {
|
|
145
|
+
let text = '';
|
|
146
|
+
const offsets = [];
|
|
147
|
+
for (;;) {
|
|
148
|
+
const newlineIndex = source.indexOf('\n', position);
|
|
149
|
+
const lineEnd = newlineIndex === -1 ? source.length : newlineIndex;
|
|
150
|
+
const physical = source.slice(position, lineEnd);
|
|
151
|
+
const continues = endsWithLineContinuation(physical);
|
|
152
|
+
const contentEnd = continues ? physical.length - 1 : physical.length;
|
|
153
|
+
for (let i = 0; i < contentEnd; i += 1) {
|
|
154
|
+
text += physical[i];
|
|
155
|
+
offsets.push(position + i);
|
|
156
|
+
}
|
|
157
|
+
position = lineEnd + 1;
|
|
158
|
+
if (!continues || newlineIndex === -1)
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
lines.push({ text, offsets });
|
|
162
|
+
if (position > source.length)
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
export function lineNumberAt(source, offset) {
|
|
168
|
+
let line = 1;
|
|
169
|
+
for (let i = 0; i < offset; i += 1)
|
|
170
|
+
if (source[i] === '\n')
|
|
171
|
+
line += 1;
|
|
172
|
+
return line;
|
|
173
|
+
}
|
|
174
|
+
/** Hits in `text` with offsets relative to `text` itself, not any enclosing document. */
|
|
175
|
+
export function ghApiShellQuotingHits(text) {
|
|
176
|
+
const hits = [];
|
|
177
|
+
for (const logicalLine of joinLogicalLines(text)) {
|
|
178
|
+
hits.push(...scanLogicalLine(logicalLine.text, logicalLine.offsets));
|
|
179
|
+
}
|
|
180
|
+
return hits;
|
|
181
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Extracts and scans `run:` step bodies from a GitHub Actions workflow or composite-action YAML
|
|
2
|
+
// document for the same unquoted `?`/`&` hazard `./scan.mts` detects in a plain shell script. See
|
|
3
|
+
// ./index.mts for the exported entry points.
|
|
4
|
+
import { isAlias, isScalar, parseDocument, visit } from 'yaml';
|
|
5
|
+
import { ghApiShellQuotingHits, lineNumberAt } from './scan.mjs';
|
|
6
|
+
// Any `run:` scalar anywhere in the document is a shell step body — workflow job steps and
|
|
7
|
+
// composite-action steps both use the same key. This module does not resolve YAML aliases: a
|
|
8
|
+
// `run:` value has no reason to be shared via an anchor, so instead of silently skipping an
|
|
9
|
+
// aliased `run:` (which would let an unsafe invocation bypass detection undetected), `runBlocks`
|
|
10
|
+
// throws when it finds one — see below.
|
|
11
|
+
//
|
|
12
|
+
// A folded scalar (`run: >-`) joins its source lines with spaces before the shell ever sees it, so
|
|
13
|
+
// `gh api` on one physical line and an unquoted `repos/x/y?a=1&b=2` on the next form one unsafe
|
|
14
|
+
// command at runtime even though they are two lines in the file. A quoted flow scalar
|
|
15
|
+
// (`run: 'gh api …?a=1&b=2'` or `run: "gh api …?a=1&b=2"`) has its outer quotes stripped by YAML
|
|
16
|
+
// before the shell ever sees it too, so scanning the raw source slice — which still includes those
|
|
17
|
+
// quotes — makes the shell scanner mistake them for argument quoting and miss a real violation.
|
|
18
|
+
// `text` is the already-decoded string for these shapes (`pair.value.value`, not the raw source
|
|
19
|
+
// slice, which still carries the block header or quote delimiters) so the scanner sees exactly
|
|
20
|
+
// what the shell would; `decoded` tells the caller that per-hit offsets inside `text` no longer
|
|
21
|
+
// correspond 1:1 to source byte offsets, since decoding discards those delimiters (and, for folded
|
|
22
|
+
// scalars, the original line breaks). `BLOCK_LITERAL` (`run: |`) and single-line `PLAIN` scalars
|
|
23
|
+
// keep their raw source slice: both scan identically decoded or not, so precise per-hit offsets
|
|
24
|
+
// stay correct and existing per-line assertions are unaffected. A *multiline* `PLAIN` scalar folds
|
|
25
|
+
// its line breaks the same way a folded scalar does, so its raw slice is not equivalent to what
|
|
26
|
+
// the shell sees either — worse, the raw slice's un-joined `\n` (with no trailing backslash) makes
|
|
27
|
+
// `ghApiShellQuotingHits` treat the continuation as an unrelated logical line, silently missing a
|
|
28
|
+
// `gh api` call split across it (see below). `runBlocks` throws on this shape below rather than
|
|
29
|
+
// decode it, mirroring the alias handling above.
|
|
30
|
+
const DECODED_SCALAR_TYPES = new Set([
|
|
31
|
+
'BLOCK_FOLDED',
|
|
32
|
+
'QUOTE_SINGLE',
|
|
33
|
+
'QUOTE_DOUBLE',
|
|
34
|
+
]);
|
|
35
|
+
function runBlocks(source) {
|
|
36
|
+
const document = parseDocument(source);
|
|
37
|
+
if (document.errors.length > 0)
|
|
38
|
+
throw document.errors[0];
|
|
39
|
+
const blocks = [];
|
|
40
|
+
visit(document, {
|
|
41
|
+
Pair(_key, pair) {
|
|
42
|
+
if (!isScalar(pair.key) || pair.key.value !== 'run')
|
|
43
|
+
return;
|
|
44
|
+
if (isAlias(pair.value)) {
|
|
45
|
+
const resolved = pair.value.resolve(document);
|
|
46
|
+
if (isScalar(resolved) && typeof resolved.value === 'string') {
|
|
47
|
+
throw new Error('run: value is a YAML alias to a string scalar — this guard does not resolve ' +
|
|
48
|
+
'aliases, so an aliased shell body could bypass it undetected. Write the run: ' +
|
|
49
|
+
'value inline, or extend runBlocks() to resolve aliases before adding one.');
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!isScalar(pair.value) || typeof pair.value.value !== 'string')
|
|
54
|
+
return;
|
|
55
|
+
/* v8 ignore next -- parseDocument always assigns a range to a scalar it parsed from source;
|
|
56
|
+
this only guards the type narrowing above, not a reachable runtime state. */
|
|
57
|
+
if (!pair.value.range)
|
|
58
|
+
throw new Error('run: scalar has no source range');
|
|
59
|
+
const raw = source.slice(pair.value.range[0], pair.value.range[1]);
|
|
60
|
+
if (pair.value.type === 'PLAIN' && raw.includes('\n')) {
|
|
61
|
+
throw new Error('run: value is a multiline PLAIN scalar — its raw source slice does not match the ' +
|
|
62
|
+
'decoded shell text (YAML folds a PLAIN line break into a single space), and this ' +
|
|
63
|
+
'guard only decodes BLOCK_FOLDED/QUOTE_SINGLE/QUOTE_DOUBLE scalars, so scanning the ' +
|
|
64
|
+
'raw slice can silently miss a call split across the fold. Rewrite the run: value as ' +
|
|
65
|
+
'a quoted or block scalar, or extend DECODED_SCALAR_TYPES and runBlocks() to decode ' +
|
|
66
|
+
'multiline PLAIN scalars before adding one.');
|
|
67
|
+
}
|
|
68
|
+
const decoded = pair.value.type !== undefined && DECODED_SCALAR_TYPES.has(pair.value.type);
|
|
69
|
+
blocks.push({
|
|
70
|
+
offset: pair.value.range[0],
|
|
71
|
+
text: decoded ? pair.value.value : raw,
|
|
72
|
+
decoded,
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return blocks;
|
|
77
|
+
}
|
|
78
|
+
/** For a workflow/composite-action YAML file: scans every `run:` block's effective shell text. */
|
|
79
|
+
export function workflowYamlViolations(source) {
|
|
80
|
+
const violations = [];
|
|
81
|
+
for (const block of runBlocks(source)) {
|
|
82
|
+
const blockStartLine = lineNumberAt(source, block.offset);
|
|
83
|
+
for (const hit of ghApiShellQuotingHits(block.text)) {
|
|
84
|
+
// A decoded block's delimiters (and, for folded blocks, its line breaks) are gone by the
|
|
85
|
+
// time the shell runs it, so there is no single source line the hit "belongs" to — report
|
|
86
|
+
// the block's start line instead of computing a byte offset into decoded text that no
|
|
87
|
+
// longer lines up with `source`.
|
|
88
|
+
violations.push({
|
|
89
|
+
line: block.decoded ? blockStartLine : lineNumberAt(source, block.offset + hit.offset),
|
|
90
|
+
excerpt: hit.excerpt,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return violations;
|
|
95
|
+
}
|
package/dist/gh-cli/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createCommandRunner, runGh, runGit } from './exec.mts';
|
|
2
2
|
export type { ExecFileText, RunTextCommand } from './exec.mts';
|
|
3
3
|
export { getDiffAgainstBase } from './diff.mts';
|
|
4
|
-
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, resolveHeadBranch, } from './pr-create.mts';
|
|
4
|
+
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from './pr-create.mts';
|
|
5
5
|
export type { AssertHeadPushedOptions, BuildGhPrCreateArgsOptions, CreatePullRequestExecutors, CreatePullRequestOptions, } from './pr-create.mts';
|
package/dist/gh-cli/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { createCommandRunner, runGh, runGit } from './exec.mjs';
|
|
2
2
|
export { getDiffAgainstBase } from './diff.mjs';
|
|
3
|
-
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, resolveHeadBranch, } from './pr-create.mjs';
|
|
3
|
+
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from './pr-create.mjs';
|
|
@@ -7,6 +7,13 @@ export declare class DetachedHeadError extends Error {
|
|
|
7
7
|
export declare class HeadNotPushedError extends Error {
|
|
8
8
|
constructor(branch: string, remote: string);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Thrown by {@link assertHeadPushed} when `branch` exists on `remote` but at a different commit
|
|
12
|
+
* than the local branch — creating the pull request now would silently use the stale remote tip.
|
|
13
|
+
*/
|
|
14
|
+
export declare class HeadOutOfDateError extends Error {
|
|
15
|
+
constructor(branch: string, remote: string);
|
|
16
|
+
}
|
|
10
17
|
/** Resolves the current branch via `git branch --show-current`, trimmed. */
|
|
11
18
|
export declare function resolveHeadBranch(runGit: RunTextCommand): Promise<string>;
|
|
12
19
|
export type AssertHeadPushedOptions = {
|
|
@@ -14,9 +21,16 @@ export type AssertHeadPushedOptions = {
|
|
|
14
21
|
remote?: string;
|
|
15
22
|
};
|
|
16
23
|
/**
|
|
17
|
-
* Confirms `branch` has a matching ref on `remote` (default `origin`) via
|
|
18
|
-
* `git ls-remote --heads
|
|
19
|
-
*
|
|
24
|
+
* Confirms `branch` has a matching, up-to-date ref on `remote` (default `origin`) via
|
|
25
|
+
* `git ls-remote --heads` and `git rev-parse`. Both use the full `refs/heads/<branch>` path
|
|
26
|
+
* rather than the bare branch name: `ls-remote`'s pattern otherwise matches any ref whose name
|
|
27
|
+
* ends with `branch` (not just an exact `refs/heads/<branch>`), and bare `rev-parse <branch>`
|
|
28
|
+
* resolves `refs/tags/<branch>` before `refs/heads/<branch>` when a same-named tag exists, either
|
|
29
|
+
* of which could compare the wrong commit. `ls-remote --heads` exits `0` with empty stdout when
|
|
30
|
+
* there is no match, so a missing branch is checked via stdout rather than the exit code. A
|
|
31
|
+
* remote ref that exists but points at a different commit than the local branch means local HEAD
|
|
32
|
+
* has commits the remote does not — that fails with {@link HeadOutOfDateError} rather than
|
|
33
|
+
* silently creating the pull request from the stale remote tip.
|
|
20
34
|
*/
|
|
21
35
|
export declare function assertHeadPushed(runGit: RunTextCommand, { branch, remote }: AssertHeadPushedOptions): Promise<void>;
|
|
22
36
|
export type BuildGhPrCreateArgsOptions = {
|
|
@@ -12,6 +12,16 @@ export class HeadNotPushedError extends Error {
|
|
|
12
12
|
this.name = 'HeadNotPushedError';
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by {@link assertHeadPushed} when `branch` exists on `remote` but at a different commit
|
|
17
|
+
* than the local branch — creating the pull request now would silently use the stale remote tip.
|
|
18
|
+
*/
|
|
19
|
+
export class HeadOutOfDateError extends Error {
|
|
20
|
+
constructor(branch, remote) {
|
|
21
|
+
super(`branch "${branch}" on remote "${remote}" does not match the local branch — push the latest commits before creating the pull request`);
|
|
22
|
+
this.name = 'HeadOutOfDateError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
/** Resolves the current branch via `git branch --show-current`, trimmed. */
|
|
16
26
|
export async function resolveHeadBranch(runGit) {
|
|
17
27
|
const branch = (await runGit(['branch', '--show-current'])).trim();
|
|
@@ -20,14 +30,26 @@ export async function resolveHeadBranch(runGit) {
|
|
|
20
30
|
return branch;
|
|
21
31
|
}
|
|
22
32
|
/**
|
|
23
|
-
* Confirms `branch` has a matching ref on `remote` (default `origin`) via
|
|
24
|
-
* `git ls-remote --heads
|
|
25
|
-
*
|
|
33
|
+
* Confirms `branch` has a matching, up-to-date ref on `remote` (default `origin`) via
|
|
34
|
+
* `git ls-remote --heads` and `git rev-parse`. Both use the full `refs/heads/<branch>` path
|
|
35
|
+
* rather than the bare branch name: `ls-remote`'s pattern otherwise matches any ref whose name
|
|
36
|
+
* ends with `branch` (not just an exact `refs/heads/<branch>`), and bare `rev-parse <branch>`
|
|
37
|
+
* resolves `refs/tags/<branch>` before `refs/heads/<branch>` when a same-named tag exists, either
|
|
38
|
+
* of which could compare the wrong commit. `ls-remote --heads` exits `0` with empty stdout when
|
|
39
|
+
* there is no match, so a missing branch is checked via stdout rather than the exit code. A
|
|
40
|
+
* remote ref that exists but points at a different commit than the local branch means local HEAD
|
|
41
|
+
* has commits the remote does not — that fails with {@link HeadOutOfDateError} rather than
|
|
42
|
+
* silently creating the pull request from the stale remote tip.
|
|
26
43
|
*/
|
|
27
44
|
export async function assertHeadPushed(runGit, { branch, remote = 'origin' }) {
|
|
28
|
-
const
|
|
29
|
-
|
|
45
|
+
const ref = `refs/heads/${branch}`;
|
|
46
|
+
const remoteLine = (await runGit(['ls-remote', '--heads', remote, ref])).trim();
|
|
47
|
+
if (remoteLine === '')
|
|
30
48
|
throw new HeadNotPushedError(branch, remote);
|
|
49
|
+
const [remoteSha] = remoteLine.split(/\s+/);
|
|
50
|
+
const localSha = (await runGit(['rev-parse', ref])).trim();
|
|
51
|
+
if (localSha !== remoteSha)
|
|
52
|
+
throw new HeadOutOfDateError(branch, remote);
|
|
31
53
|
}
|
|
32
54
|
/** Pure argv builder for `gh pr create`. `head` is always passed explicitly (never omitted). */
|
|
33
55
|
export function buildGhPrCreateArgs(options) {
|
package/dist/index.d.mts
CHANGED
|
@@ -87,5 +87,5 @@ export type { DiagnosticReportLimitOptions, DiagnosticReportSummary, } from './v
|
|
|
87
87
|
export { createCommandRunner, runGh, runGit } from './gh-cli/index.mts';
|
|
88
88
|
export type { ExecFileText, RunTextCommand } from './gh-cli/index.mts';
|
|
89
89
|
export { getDiffAgainstBase } from './gh-cli/index.mts';
|
|
90
|
-
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, resolveHeadBranch, } from './gh-cli/index.mts';
|
|
90
|
+
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from './gh-cli/index.mts';
|
|
91
91
|
export type { AssertHeadPushedOptions, BuildGhPrCreateArgsOptions, CreatePullRequestExecutors, CreatePullRequestOptions, } from './gh-cli/index.mts';
|
package/dist/index.mjs
CHANGED
|
@@ -49,4 +49,4 @@ export { validateResolvedPinDelta } from './swift-resolved-pin-delta/index.mjs';
|
|
|
49
49
|
export { DEFAULT_MAX_DIAGNOSTIC_REPORTS, DEFAULT_MAX_FORMATTED_DIAGNOSTIC_REPORTS, formatDiagnosticReportSummaries, HARD_MAX_DIAGNOSTIC_REPORTS, readDiagnosticReportSummaries, summarizeDiagnosticReport, } from './vitest-diagnostics/index.mjs';
|
|
50
50
|
export { createCommandRunner, runGh, runGit } from './gh-cli/index.mjs';
|
|
51
51
|
export { getDiffAgainstBase } from './gh-cli/index.mjs';
|
|
52
|
-
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, resolveHeadBranch, } from './gh-cli/index.mjs';
|
|
52
|
+
export { assertHeadPushed, buildGhPrCreateArgs, createPullRequest, DetachedHeadError, HeadNotPushedError, HeadOutOfDateError, resolveHeadBranch, } from './gh-cli/index.mjs';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Vouchington CLI and extractable tooling libraries.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -267,6 +267,11 @@
|
|
|
267
267
|
"import": "./dist/gh-cli/index.mjs",
|
|
268
268
|
"default": "./dist/gh-cli/index.mjs"
|
|
269
269
|
},
|
|
270
|
+
"./gh-api-shell-quoting": {
|
|
271
|
+
"types": "./dist/gh-api-shell-quoting/index.d.mts",
|
|
272
|
+
"import": "./dist/gh-api-shell-quoting/index.mjs",
|
|
273
|
+
"default": "./dist/gh-api-shell-quoting/index.mjs"
|
|
274
|
+
},
|
|
270
275
|
"./package.json": "./package.json"
|
|
271
276
|
},
|
|
272
277
|
"publishConfig": {
|
|
@@ -25,11 +25,18 @@ When an external creation target is denied, never write there. Search for and cr
|
|
|
25
25
|
tracking issue in the current repository, or a consumer-selected tracker. Immediately before that
|
|
26
26
|
write, refetch the destination repository and apply the issue-operation gate above. Include the
|
|
27
27
|
intended upstream repository and a copy-ready report so a human can decide whether to file it. Before
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
naming that upstream repository anywhere in the report, resolve it and compare the returned canonical
|
|
29
|
+
name against the name being written — a renamed repository's redirect resolves successfully under the
|
|
30
|
+
stale name, so existence alone proves nothing. This comparison serves report accuracy, not the
|
|
31
|
+
mutation-authority gate above: a rename is not the hard-deny mismatch that gate defines, so report the
|
|
32
|
+
canonical name in its place; when the name does not resolve at all, route the follow-up to the current
|
|
33
|
+
repository instead of naming an unreachable target. Before copying details to a less-restricted
|
|
34
|
+
destination, remove private repository identity, paths, links, code, and findings; if redaction would
|
|
35
|
+
make the report unusable, require explicit destination approval or return the draft without mutation.
|
|
36
|
+
Authorization to file the external issue includes this tracking fallback unless the caller opts out;
|
|
37
|
+
report the reroute explicitly. If no tracker passes, return the draft without mutation. Never fall back
|
|
38
|
+
silently or to an unverified
|
|
39
|
+
repository.
|
|
33
40
|
|
|
34
41
|
## Issue workflow
|
|
35
42
|
|