@ticket-gate/engine 0.0.0-stage → 0.1.0-rc.1
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/LICENSE +202 -0
- package/README.md +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- package/tree.mjs +211 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load-time code. A module's top-level statement either only declares something
|
|
3
|
+
* (inert: a function, a literal table, an import) or does work when the module is evaluated
|
|
4
|
+
* (a call, a `new`, a template with a substitution). Evaluating a module happens on every
|
|
5
|
+
* page that imports it, so a changed load-time statement can break any spec.
|
|
6
|
+
*
|
|
7
|
+
* When in doubt a statement is load-time code.
|
|
8
|
+
* Dependency-free Node ESM plus `typescript` (a root devDependency).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { lazyTs } from './typescript.mjs';
|
|
12
|
+
|
|
13
|
+
const ts = lazyTs();
|
|
14
|
+
const K = new Proxy({}, { get: (_, key) => ts.SyntaxKind[key] });
|
|
15
|
+
|
|
16
|
+
/** @typedef {{ text: string, loadTime: boolean }} Unit */
|
|
17
|
+
|
|
18
|
+
let printerInstance = null;
|
|
19
|
+
const printer = {
|
|
20
|
+
printNode: (...a) =>
|
|
21
|
+
(printerInstance ??= ts.createPrinter({ removeComments: true })).printNode(
|
|
22
|
+
...a,
|
|
23
|
+
),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const scriptKind = (fileName) =>
|
|
27
|
+
fileName.endsWith('.tsx')
|
|
28
|
+
? ts.ScriptKind.TSX
|
|
29
|
+
: fileName.endsWith('.ts') || fileName.endsWith('.mts')
|
|
30
|
+
? ts.ScriptKind.TS
|
|
31
|
+
: ts.ScriptKind.JS;
|
|
32
|
+
|
|
33
|
+
const parse = (source, fileName) =>
|
|
34
|
+
ts.createSourceFile(
|
|
35
|
+
fileName,
|
|
36
|
+
source,
|
|
37
|
+
ts.ScriptTarget.Latest,
|
|
38
|
+
true,
|
|
39
|
+
scriptKind(fileName),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const hasModifier = (node, kind) =>
|
|
43
|
+
ts.canHaveModifiers(node) &&
|
|
44
|
+
(ts.getModifiers(node) ?? []).some((m) => m.kind === kind);
|
|
45
|
+
|
|
46
|
+
/** interface, type alias, `declare ...`, `import type`, `export type ... from`: no runtime form. */
|
|
47
|
+
function isTypeOnlyStatement(st) {
|
|
48
|
+
if (ts.isInterfaceDeclaration(st) || ts.isTypeAliasDeclaration(st))
|
|
49
|
+
return true;
|
|
50
|
+
if (hasModifier(st, K.DeclareKeyword)) return true;
|
|
51
|
+
if (ts.isImportDeclaration(st)) return st.importClause?.isTypeOnly === true;
|
|
52
|
+
if (ts.isExportDeclaration(st)) return st.isTypeOnly;
|
|
53
|
+
if (ts.isImportEqualsDeclaration(st)) return st.isTypeOnly;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const unwrap = (e) => {
|
|
58
|
+
let x = e;
|
|
59
|
+
while (
|
|
60
|
+
ts.isAsExpression(x) ||
|
|
61
|
+
ts.isSatisfiesExpression(x) ||
|
|
62
|
+
ts.isNonNullExpression(x) ||
|
|
63
|
+
ts.isTypeAssertionExpression(x) ||
|
|
64
|
+
ts.isParenthesizedExpression(x)
|
|
65
|
+
)
|
|
66
|
+
x = x.expression;
|
|
67
|
+
return x;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const staticName = (name) =>
|
|
71
|
+
name !== undefined && !ts.isComputedPropertyName(name);
|
|
72
|
+
|
|
73
|
+
/** Every identifier a binding name (identifier or nested pattern) declares. */
|
|
74
|
+
function bindingNames(name, names) {
|
|
75
|
+
if (ts.isIdentifier(name)) names.add(name.text);
|
|
76
|
+
else
|
|
77
|
+
for (const el of name.elements)
|
|
78
|
+
if (ts.isBindingElement(el)) bindingNames(el.name, names);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Names the module declares itself at the top level (not imports): `extends` of one is load-time. */
|
|
82
|
+
function localNames(sf) {
|
|
83
|
+
const names = new Set();
|
|
84
|
+
for (const st of sf.statements) {
|
|
85
|
+
if (ts.isVariableStatement(st)) {
|
|
86
|
+
for (const d of st.declarationList.declarations) {
|
|
87
|
+
bindingNames(d.name, names);
|
|
88
|
+
}
|
|
89
|
+
} else if (
|
|
90
|
+
(ts.isClassDeclaration(st) ||
|
|
91
|
+
ts.isFunctionDeclaration(st) ||
|
|
92
|
+
ts.isEnumDeclaration(st)) &&
|
|
93
|
+
st.name
|
|
94
|
+
)
|
|
95
|
+
names.add(st.name.text);
|
|
96
|
+
}
|
|
97
|
+
return names;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function classInert(cls, locals) {
|
|
101
|
+
if (ts.canHaveDecorators(cls) && (ts.getDecorators(cls) ?? []).length > 0)
|
|
102
|
+
return false;
|
|
103
|
+
for (const h of cls.heritageClauses ?? []) {
|
|
104
|
+
if (h.token !== K.ExtendsKeyword) continue;
|
|
105
|
+
for (const t of h.types) {
|
|
106
|
+
const e = t.expression;
|
|
107
|
+
if (!ts.isIdentifier(e) || locals.has(e.text)) return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const m of cls.members) {
|
|
111
|
+
if (ts.canHaveDecorators(m) && (ts.getDecorators(m) ?? []).length > 0)
|
|
112
|
+
return false;
|
|
113
|
+
if (ts.isClassStaticBlockDeclaration(m)) return false;
|
|
114
|
+
if (m.name && ts.isComputedPropertyName(m.name)) return false;
|
|
115
|
+
if (
|
|
116
|
+
ts.isPropertyDeclaration(m) &&
|
|
117
|
+
hasModifier(m, K.StaticKeyword) &&
|
|
118
|
+
m.initializer
|
|
119
|
+
)
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isNumericOperand = (e) => ts.isNumericLiteral(e) || ts.isBigIntLiteral(e);
|
|
126
|
+
|
|
127
|
+
/** true = proved inert (evaluating it does no work and reads no other module's state). */
|
|
128
|
+
function exprInert(node, locals) {
|
|
129
|
+
const e = unwrap(node);
|
|
130
|
+
switch (e.kind) {
|
|
131
|
+
case K.StringLiteral:
|
|
132
|
+
case K.NumericLiteral:
|
|
133
|
+
case K.BigIntLiteral:
|
|
134
|
+
case K.NoSubstitutionTemplateLiteral:
|
|
135
|
+
case K.RegularExpressionLiteral:
|
|
136
|
+
case K.TrueKeyword:
|
|
137
|
+
case K.FalseKeyword:
|
|
138
|
+
case K.NullKeyword:
|
|
139
|
+
case K.ArrowFunction:
|
|
140
|
+
case K.FunctionExpression:
|
|
141
|
+
return true;
|
|
142
|
+
case K.PrefixUnaryExpression:
|
|
143
|
+
return (
|
|
144
|
+
(e.operator === K.MinusToken || e.operator === K.PlusToken) &&
|
|
145
|
+
isNumericOperand(e.operand)
|
|
146
|
+
);
|
|
147
|
+
case K.ClassExpression:
|
|
148
|
+
return classInert(e, locals);
|
|
149
|
+
case K.ArrayLiteralExpression:
|
|
150
|
+
return e.elements.every(
|
|
151
|
+
(x) => ts.isOmittedExpression(x) || exprInert(x, locals),
|
|
152
|
+
);
|
|
153
|
+
case K.ObjectLiteralExpression:
|
|
154
|
+
return e.properties.every((p) => {
|
|
155
|
+
if (ts.isPropertyAssignment(p))
|
|
156
|
+
return staticName(p.name) && exprInert(p.initializer, locals);
|
|
157
|
+
if (
|
|
158
|
+
ts.isMethodDeclaration(p) ||
|
|
159
|
+
ts.isGetAccessorDeclaration(p) ||
|
|
160
|
+
ts.isSetAccessorDeclaration(p)
|
|
161
|
+
)
|
|
162
|
+
return staticName(p.name);
|
|
163
|
+
return false;
|
|
164
|
+
});
|
|
165
|
+
case K.CallExpression: {
|
|
166
|
+
const c = e.expression;
|
|
167
|
+
return (
|
|
168
|
+
ts.isPropertyAccessExpression(c) &&
|
|
169
|
+
ts.isIdentifier(c.expression) &&
|
|
170
|
+
c.expression.text === 'Object' &&
|
|
171
|
+
c.name.text === 'freeze' &&
|
|
172
|
+
e.arguments.every((a) => exprInert(a, locals))
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
default:
|
|
176
|
+
return false; // fact 2: an expression not proved inert
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function enumInert(en, locals) {
|
|
181
|
+
return en.members.every(
|
|
182
|
+
(m) =>
|
|
183
|
+
!m.initializer ||
|
|
184
|
+
(exprInert(m.initializer, locals) &&
|
|
185
|
+
!ts.isArrowFunction(unwrap(m.initializer)) &&
|
|
186
|
+
!ts.isFunctionExpression(unwrap(m.initializer))),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** true = load-time code. */
|
|
191
|
+
function statementLoadTime(st, locals) {
|
|
192
|
+
if (
|
|
193
|
+
ts.isImportDeclaration(st) ||
|
|
194
|
+
ts.isExportDeclaration(st) ||
|
|
195
|
+
ts.isImportEqualsDeclaration(st) ||
|
|
196
|
+
ts.isFunctionDeclaration(st) ||
|
|
197
|
+
ts.isEmptyStatement(st)
|
|
198
|
+
)
|
|
199
|
+
return false;
|
|
200
|
+
if (ts.isClassDeclaration(st)) return !classInert(st, locals);
|
|
201
|
+
if (ts.isEnumDeclaration(st)) return !enumInert(st, locals);
|
|
202
|
+
if (ts.isExpressionStatement(st)) return !ts.isStringLiteral(st.expression); // a directive is inert
|
|
203
|
+
if (ts.isVariableStatement(st)) {
|
|
204
|
+
const list = st.declarationList;
|
|
205
|
+
// `await using` and `using` share the Const bit; only a plain `const` can be inert
|
|
206
|
+
if ((list.flags & ts.NodeFlags.BlockScoped) !== ts.NodeFlags.Const)
|
|
207
|
+
return true;
|
|
208
|
+
return !list.declarations.every(
|
|
209
|
+
(d) =>
|
|
210
|
+
ts.isIdentifier(d.name) &&
|
|
211
|
+
d.initializer !== undefined &&
|
|
212
|
+
exprInert(d.initializer, locals),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return true; // fact 2: a statement not proved inert is load-time code
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Statement units in order. Text is the comment-free printed statement, so comment-only and
|
|
220
|
+
* whitespace-only edits are no change. Type-only statements yield no unit.
|
|
221
|
+
* @returns {Unit[]}
|
|
222
|
+
*/
|
|
223
|
+
export function loadTimeUnits(source, fileName = 'x.ts') {
|
|
224
|
+
const sf = parse(source, fileName);
|
|
225
|
+
const locals = localNames(sf);
|
|
226
|
+
const units = [];
|
|
227
|
+
for (const st of sf.statements.filter((x) => !isTypeOnlyStatement(x))) {
|
|
228
|
+
units.push({
|
|
229
|
+
text: printer.printNode(ts.EmitHint.Unspecified, st, sf),
|
|
230
|
+
loadTime: statementLoadTime(st, locals),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return units;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function hasLoadTimeCode(source, fileName = 'x.ts') {
|
|
237
|
+
return loadTimeUnits(source, fileName).some((u) => u.loadTime);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Texts keyed with their occurrence index, so duplicates keep their identity. */
|
|
241
|
+
function keyed(units) {
|
|
242
|
+
const seen = new Map();
|
|
243
|
+
return units.map((u) => {
|
|
244
|
+
const n = seen.get(u.text) ?? 0;
|
|
245
|
+
seen.set(u.text, n + 1);
|
|
246
|
+
return `${n}\0${u.text}`;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The units both sides hold, in base order, differ in order from head order. */
|
|
251
|
+
function orderChanged(b, h) {
|
|
252
|
+
const bk = keyed(b);
|
|
253
|
+
const hk = keyed(h);
|
|
254
|
+
const inHead = new Set(hk);
|
|
255
|
+
const inBase = new Set(bk);
|
|
256
|
+
const commonB = bk.filter((k) => inHead.has(k));
|
|
257
|
+
const commonH = hk.filter((k) => inBase.has(k));
|
|
258
|
+
return commonB.some((k, i) => k !== commonH[i]);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* R4: did a load-time statement get added, removed, edited, or reordered?
|
|
263
|
+
* base === null: the file is new, every load-time unit is added. head === null: removed,
|
|
264
|
+
* which is not a hit here (F3 names edits and added imports only).
|
|
265
|
+
*/
|
|
266
|
+
export function loadTimeChanged(base, head, fileName = 'x.ts') {
|
|
267
|
+
if (head === null) return false;
|
|
268
|
+
const b = base === null ? [] : loadTimeUnits(base, fileName);
|
|
269
|
+
const h = loadTimeUnits(head, fileName);
|
|
270
|
+
const bl = b.filter((u) => u.loadTime).map((u) => u.text);
|
|
271
|
+
const hl = h.filter((u) => u.loadTime).map((u) => u.text);
|
|
272
|
+
if (bl.length !== hl.length || bl.some((t, i) => t !== hl[i])) return true;
|
|
273
|
+
const anyLoadTime = hl.length > 0;
|
|
274
|
+
if (anyLoadTime && orderChanged(b, h)) return true;
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Module specifiers evaluated when the file loads (type-only statements add none). */
|
|
279
|
+
export function evaluationSpecs(source, fileName = 'x.ts') {
|
|
280
|
+
const sf = parse(source, fileName);
|
|
281
|
+
const out = new Set();
|
|
282
|
+
const calls = (node) => {
|
|
283
|
+
if (
|
|
284
|
+
ts.isCallExpression(node) &&
|
|
285
|
+
(node.expression.kind === K.ImportKeyword ||
|
|
286
|
+
(ts.isIdentifier(node.expression) &&
|
|
287
|
+
node.expression.text === 'require')) &&
|
|
288
|
+
node.arguments.length > 0 &&
|
|
289
|
+
(ts.isStringLiteral(node.arguments[0]) ||
|
|
290
|
+
ts.isNoSubstitutionTemplateLiteral(node.arguments[0]))
|
|
291
|
+
)
|
|
292
|
+
out.add(node.arguments[0].text);
|
|
293
|
+
ts.forEachChild(node, calls);
|
|
294
|
+
};
|
|
295
|
+
for (const st of sf.statements) {
|
|
296
|
+
if (isTypeOnlyStatement(st)) continue;
|
|
297
|
+
if (ts.isImportDeclaration(st) || ts.isExportDeclaration(st)) {
|
|
298
|
+
if (st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier))
|
|
299
|
+
out.add(st.moduleSpecifier.text);
|
|
300
|
+
} else if (
|
|
301
|
+
ts.isImportEqualsDeclaration(st) &&
|
|
302
|
+
ts.isExternalModuleReference(st.moduleReference) &&
|
|
303
|
+
ts.isStringLiteral(st.moduleReference.expression)
|
|
304
|
+
)
|
|
305
|
+
out.add(st.moduleReference.expression.text);
|
|
306
|
+
calls(st);
|
|
307
|
+
}
|
|
308
|
+
return [...out];
|
|
309
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `typescript` is the project's, resolved at call time from `from` (the project
|
|
3
|
+
* root), never imported statically by the engine. Dependency-free Node ESM.
|
|
4
|
+
*/
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { SelectionConfigError } from '../selection/define.mjs';
|
|
9
|
+
|
|
10
|
+
const cache = new Map();
|
|
11
|
+
|
|
12
|
+
export function typescript(from = process.cwd()) {
|
|
13
|
+
if (cache.has(from)) return cache.get(from);
|
|
14
|
+
let mod;
|
|
15
|
+
try {
|
|
16
|
+
mod = createRequire(join(from, 'package.json'))('typescript');
|
|
17
|
+
} catch {
|
|
18
|
+
throw new SelectionConfigError(
|
|
19
|
+
`typescript is not resolvable from ${from}: install it there as a devDependency`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
cache.set(from, mod);
|
|
23
|
+
return mod;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A module-level `ts` that resolves on first property read, so nothing loads at import time. */
|
|
27
|
+
export const lazyTs = () =>
|
|
28
|
+
new Proxy({}, { get: (_, key) => typescript()[key] });
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse hook for Claude Code: agents may push a feature branch and open
|
|
3
|
+
// or edit its PR, but never rewrite shared history or land code. Denied:
|
|
4
|
+
// - `git push` with a force flag (-f, --force, --force-with-lease,
|
|
5
|
+
// --force-if-includes), a `+refspec`, --mirror, --all, --prune or a delete
|
|
6
|
+
// (-d, --delete, `:branch`);
|
|
7
|
+
// - `git push` whose refspec targets main or master, or a bare `git push`
|
|
8
|
+
// while main or master is checked out;
|
|
9
|
+
// - `gh pr merge|close`, `gh repo sync`, and a writing `gh api` call;
|
|
10
|
+
// - MCP git_push (it bypasses the refspec checks; push from Bash instead).
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { isMain } from '../is-main.mjs';
|
|
15
|
+
|
|
16
|
+
// Start of a command: line start or a shell separator, then optional sudo / command.
|
|
17
|
+
const PREFIX =
|
|
18
|
+
String.raw`(?:^|&&|\|\||;|\||` +
|
|
19
|
+
'`' +
|
|
20
|
+
String.raw`|\$\(|\n)\s*(?:sudo\s+)?(?:command\s+)?`;
|
|
21
|
+
const SEPARATOR = /&&|\|\||;|\||\n/;
|
|
22
|
+
|
|
23
|
+
const GIT_PUSH = new RegExp(
|
|
24
|
+
PREFIX + String.raw`git(?:\s+-[^\s]+(?:\s+[^\s-][^\s]*)?)*\s+push\b`,
|
|
25
|
+
'gi',
|
|
26
|
+
);
|
|
27
|
+
const GH_WRITE = new RegExp(
|
|
28
|
+
PREFIX +
|
|
29
|
+
String.raw`gh(?:\s+-[^\s]+(?:\s+[^\s-][^\s]*)?)*\s+(?:pr\s+(?:merge|close)|repo\s+sync)\b`,
|
|
30
|
+
'i',
|
|
31
|
+
);
|
|
32
|
+
const GH_API = new RegExp(
|
|
33
|
+
PREFIX + String.raw`gh(?:\s+-[^\s]+(?:\s+[^\s-][^\s]*)?)*\s+api\b`,
|
|
34
|
+
'gi',
|
|
35
|
+
);
|
|
36
|
+
// An explicit write method, or field / input flags, which make `gh api` default to POST.
|
|
37
|
+
const GH_API_WRITE =
|
|
38
|
+
/(?:-X|--method)[\s=]*['"]?(?:POST|PUT|PATCH|DELETE)\b|\s(?:-f|-F|--field|--raw-field|--input)(?=[\s=]|$)/i;
|
|
39
|
+
const MCP_PUSH_TOOLS = new Set(['git_push', 'git push']);
|
|
40
|
+
|
|
41
|
+
const PUSH_DENY_FLAG =
|
|
42
|
+
/^(?:-f|--force|--force-with-lease(?:=.*)?|--force-if-includes|--mirror|--all|--prune|-d|--delete)$/;
|
|
43
|
+
// Short-flag clusters such as -uf or -fu carry a force too.
|
|
44
|
+
const SHORT_CLUSTER_FORCE = /^-[a-z]*[fd][a-z]*$/i;
|
|
45
|
+
|
|
46
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
47
|
+
|
|
48
|
+
/** Matches a protected branch name, literally, with or without refs/heads/. */
|
|
49
|
+
export function protectedPattern(names) {
|
|
50
|
+
return new RegExp(
|
|
51
|
+
String.raw`^(?:refs\/heads\/)?(?:${names.map(escapeRegExp).join('|')})$`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only for callers that pass no guard (unit tests); the hook always loads the config.
|
|
56
|
+
const DEFAULT_GUARD = { protected: protectedPattern(['main', 'master']) };
|
|
57
|
+
|
|
58
|
+
// A broken gate.config.mjs must deny every push and gh pr/repo/api call, never allow.
|
|
59
|
+
const FAIL_CLOSED = [
|
|
60
|
+
new RegExp(
|
|
61
|
+
PREFIX + String.raw`git(?:\s+-[^\s]+(?:\s+[^\s-][^\s]*)?)*\s+push\b`,
|
|
62
|
+
'i',
|
|
63
|
+
),
|
|
64
|
+
new RegExp(
|
|
65
|
+
PREFIX +
|
|
66
|
+
String.raw`gh(?:\s+-[^\s]+(?:\s+[^\s-][^\s]*)?)*\s+(?:pr|repo|api)\b`,
|
|
67
|
+
'i',
|
|
68
|
+
),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Why a push / gh command is denied because the config could not be read, or null. */
|
|
72
|
+
export function failClosedDenial(command, error) {
|
|
73
|
+
if (!FAIL_CLOSED.some((re) => re.test(command))) return null;
|
|
74
|
+
return `gate.config.mjs is unusable (${error}); fix the key it names, or print the command for the user to run`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The push guard from gate.config.mjs: { protected } or { error }. Never throws. */
|
|
78
|
+
export async function loadPushGuard() {
|
|
79
|
+
try {
|
|
80
|
+
const { loadGateConfig } = await import('../config.mjs');
|
|
81
|
+
const config = await loadGateConfig();
|
|
82
|
+
return { protected: protectedPattern(config.pushGuard.protected) };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { error: String(err?.message ?? err) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const allow = () => process.exit(0);
|
|
89
|
+
const deny = (why) => {
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
hookSpecificOutput: {
|
|
93
|
+
hookEventName: 'PreToolUse',
|
|
94
|
+
permissionDecision: 'deny',
|
|
95
|
+
permissionDecisionReason: `Blocked by the ticket-gate push guard: ${why}. Agents may push a feature branch and open or edit its PR; they never force-push, delete a remote branch, push to main or master, merge or close a PR, sync a repo or make a writing gh api call. Do not retry another way; print the exact command for the user to run.`,
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** This command's own arguments after the match: stop at the next separator. */
|
|
103
|
+
const argsAfter = (command, end) =>
|
|
104
|
+
command.slice(end).split(SEPARATOR)[0].trim().split(/\s+/).filter(Boolean);
|
|
105
|
+
|
|
106
|
+
/** The branch checked out in the hook's working directory, or '' when unknown. */
|
|
107
|
+
function currentBranch() {
|
|
108
|
+
try {
|
|
109
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
112
|
+
}).trim();
|
|
113
|
+
} catch {
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Why this `git push` argument list is denied, or null when it is allowed. */
|
|
119
|
+
export function pushDenial(args, branch, guard = DEFAULT_GUARD) {
|
|
120
|
+
const positional = [];
|
|
121
|
+
for (const a of args) {
|
|
122
|
+
if (
|
|
123
|
+
PUSH_DENY_FLAG.test(a) ||
|
|
124
|
+
(!a.startsWith('--') && SHORT_CLUSTER_FORCE.test(a))
|
|
125
|
+
)
|
|
126
|
+
return `git push ${a}`;
|
|
127
|
+
if (a.startsWith('-')) continue;
|
|
128
|
+
positional.push(a.replace(/^['"]|['"]$/g, ''));
|
|
129
|
+
}
|
|
130
|
+
// positional[0] is the remote; the rest are refspecs.
|
|
131
|
+
const refspecs = positional.slice(1);
|
|
132
|
+
for (const r of refspecs) {
|
|
133
|
+
if (r.startsWith('+')) return `force refspec ${r}`;
|
|
134
|
+
if (r.startsWith(':')) return `delete refspec ${r}`;
|
|
135
|
+
const dst = r.includes(':') ? r.slice(r.indexOf(':') + 1) : r;
|
|
136
|
+
if (
|
|
137
|
+
guard.protected.test(dst) ||
|
|
138
|
+
(dst === 'HEAD' && guard.protected.test(branch))
|
|
139
|
+
)
|
|
140
|
+
return `push to ${dst === 'HEAD' ? branch : dst}`;
|
|
141
|
+
}
|
|
142
|
+
if (refspecs.length === 0 && guard.protected.test(branch))
|
|
143
|
+
return `bare git push while ${branch} is checked out`;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Why `command` is denied, or null when it is allowed. */
|
|
148
|
+
export function commandDenial(
|
|
149
|
+
command,
|
|
150
|
+
branch = currentBranch(),
|
|
151
|
+
guard = DEFAULT_GUARD,
|
|
152
|
+
) {
|
|
153
|
+
if (guard.error) return failClosedDenial(command, guard.error);
|
|
154
|
+
for (const m of command.matchAll(GIT_PUSH)) {
|
|
155
|
+
const why = pushDenial(
|
|
156
|
+
argsAfter(command, m.index + m[0].length),
|
|
157
|
+
branch,
|
|
158
|
+
guard,
|
|
159
|
+
);
|
|
160
|
+
if (why) return why;
|
|
161
|
+
}
|
|
162
|
+
if (GH_WRITE.test(command)) return 'gh pr merge/close or gh repo sync';
|
|
163
|
+
for (const m of command.matchAll(GH_API)) {
|
|
164
|
+
const rest = command.slice(m.index + m[0].length).split(SEPARATOR)[0];
|
|
165
|
+
if (GH_API_WRITE.test(rest)) return 'a writing gh api call';
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function main() {
|
|
171
|
+
let raw = '';
|
|
172
|
+
try {
|
|
173
|
+
raw = readFileSync(0, 'utf8');
|
|
174
|
+
} catch {
|
|
175
|
+
allow();
|
|
176
|
+
}
|
|
177
|
+
if (!raw.trim()) allow();
|
|
178
|
+
|
|
179
|
+
let payload;
|
|
180
|
+
try {
|
|
181
|
+
payload = JSON.parse(raw);
|
|
182
|
+
} catch {
|
|
183
|
+
deny('unreadable hook payload');
|
|
184
|
+
}
|
|
185
|
+
if (!payload || typeof payload !== 'object') deny('unreadable hook payload');
|
|
186
|
+
|
|
187
|
+
const tool = String(
|
|
188
|
+
payload.tool_name ?? payload.toolName ?? payload.tool ?? '',
|
|
189
|
+
).toLowerCase();
|
|
190
|
+
if (
|
|
191
|
+
MCP_PUSH_TOOLS.has(tool) ||
|
|
192
|
+
tool.endsWith('/git_push') ||
|
|
193
|
+
tool.endsWith(':git_push') ||
|
|
194
|
+
tool.endsWith('__git_push')
|
|
195
|
+
)
|
|
196
|
+
deny('MCP git_push (push from Bash so the refspec checks apply)');
|
|
197
|
+
|
|
198
|
+
const input = payload.tool_input;
|
|
199
|
+
let command = '';
|
|
200
|
+
if (input && typeof input === 'object') {
|
|
201
|
+
for (const key of ['command', 'cmd', 'script']) {
|
|
202
|
+
if (typeof input[key] === 'string') {
|
|
203
|
+
command = input[key];
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const why = commandDenial(command, currentBranch(), await loadPushGuard());
|
|
209
|
+
if (why) deny(why);
|
|
210
|
+
allow();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isMain(import.meta.url)) await main();
|