jules-orchestrator-kit 0.60.0 → 0.64.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 +1 -1
- package/package.json +4 -2
- package/scripts/guard-reach-check.mjs +226 -0
- package/scripts/package-integrity-check.mjs +251 -0
- package/scripts/release.mjs +33 -0
- package/src/assertions.mjs +16 -0
- package/src/config.mjs +68 -0
- package/src/coverage.mjs +17 -2
- package/src/engine.mjs +33 -2
- package/src/evidence.mjs +38 -1
- package/src/guard-policy.mjs +481 -0
- package/src/ops/test-collection.mjs +149 -0
- package/src/security.mjs +216 -10
- package/src/stack-detector.mjs +113 -10
- package/src/task-optimizer.mjs +7 -3
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many tests the runner actually collected, read out of its own output.
|
|
3
|
+
*
|
|
4
|
+
* The gate's oracle is one number: the exit code of the verification command.
|
|
5
|
+
* That number cannot distinguish "every test passed" from "there were no
|
|
6
|
+
* tests". Several runners report the second case as success, by design:
|
|
7
|
+
*
|
|
8
|
+
* go test ./... → "? example.com/app [no test files]", exit 0
|
|
9
|
+
* jest --passWithNoTests → "No tests found, exiting with code 0"
|
|
10
|
+
* npm test --workspaces → exit 0 when the changed package has no suite
|
|
11
|
+
* pytest --exitfirst on a path that matches nothing, in some configurations
|
|
12
|
+
*
|
|
13
|
+
* So a repository could invert a function, add an untested one, and collect
|
|
14
|
+
* five green phases — verified against nothing. `verify.required: false` is
|
|
15
|
+
* the switch for a repository that genuinely has no oracle; silently passing
|
|
16
|
+
* is not.
|
|
17
|
+
*
|
|
18
|
+
* The parsing is deliberately one-sided. A count is only returned when the
|
|
19
|
+
* runner stated one in a form recognised here; an unrecognised runner yields
|
|
20
|
+
* `null`, and null is not a failure. Failing on "I could not tell" would break
|
|
21
|
+
* every runner not on this list, which is most of them.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Patterns that state a test count, per runner family.
|
|
26
|
+
*
|
|
27
|
+
* Each entry captures a single number. The first pattern that matches wins,
|
|
28
|
+
* so the more specific summaries come first.
|
|
29
|
+
*/
|
|
30
|
+
const COUNT_PATTERNS = [
|
|
31
|
+
// node:test — spec reporter ("ℹ tests 940") and tap ("# tests 940")
|
|
32
|
+
{ name: "node:test", re: /^[^\n]*?(?:ℹ|#)\s*tests\s+(\d+)\s*$/m },
|
|
33
|
+
// pytest — "collected 12 items", "12 passed", "no tests ran in 0.01s"
|
|
34
|
+
{ name: "pytest", re: /^\s*collected\s+(\d+)\s+items?/m },
|
|
35
|
+
{ name: "pytest", re: /=+\s*(\d+)\s+passed/m },
|
|
36
|
+
// cargo — "running 7 tests"
|
|
37
|
+
{ name: "cargo", re: /^\s*running\s+(\d+)\s+tests?\s*$/m },
|
|
38
|
+
// jest / vitest — "Tests: 12 passed, 12 total"
|
|
39
|
+
{ name: "jest", re: /^\s*Tests:\s+.*?(\d+)\s+total\s*$/m },
|
|
40
|
+
// mocha — "12 passing"
|
|
41
|
+
{ name: "mocha", re: /^\s*(\d+)\s+passing/m },
|
|
42
|
+
// Maven / Surefire — "Tests run: 12, Failures: 0"
|
|
43
|
+
{ name: "surefire", re: /\bTests run:\s*(\d+)/i },
|
|
44
|
+
// PHPUnit — "OK (12 tests, 30 assertions)"
|
|
45
|
+
{ name: "phpunit", re: /\bOK\s*\((\d+)\s+tests?/i },
|
|
46
|
+
// RSpec / ExUnit — "12 examples, 0 failures" / "12 tests, 0 failures"
|
|
47
|
+
{ name: "rspec", re: /^\s*(\d+)\s+examples?,\s*\d+\s+failures?/m },
|
|
48
|
+
{ name: "exunit", re: /^\s*(\d+)\s+tests?,\s*\d+\s+failures?/m },
|
|
49
|
+
// dotnet test — "Total tests: 12" / "Passed! - Failed: 0, Passed: 12"
|
|
50
|
+
{ name: "dotnet", re: /\bTotal(?:\s+tests)?:\s*(\d+)/i },
|
|
51
|
+
// swift test / XCTest — "Executed 12 tests"
|
|
52
|
+
{ name: "xctest", re: /\bExecuted\s+(\d+)\s+tests?/i },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Per-test lines, which `go test` only prints under -v. */
|
|
56
|
+
const GO_PER_TEST = /^\s*--- (?:PASS|FAIL|SKIP):/gm;
|
|
57
|
+
|
|
58
|
+
/** Phrases that state, in so many words, that nothing was collected. */
|
|
59
|
+
const EXPLICIT_ZERO = [
|
|
60
|
+
{ name: "pytest", re: /\bno tests ran\b/i },
|
|
61
|
+
{ name: "pytest", re: /^\s*collected\s+0\s+items?/m },
|
|
62
|
+
{ name: "jest", re: /\bNo tests found\b/i },
|
|
63
|
+
{ name: "vitest", re: /\bNo test files found\b/i },
|
|
64
|
+
{ name: "mocha", re: /^\s*0\s+passing/m },
|
|
65
|
+
{ name: "cargo", re: /^\s*running\s+0\s+tests?\s*$/m },
|
|
66
|
+
{ name: "phpunit", re: /\bNo tests executed!/i },
|
|
67
|
+
{ name: "gradle", re: /^>\s*Task\s+:\S*test\S*\s+NO-SOURCE\s*$/mi },
|
|
68
|
+
{ name: "ctest", re: /\bNo tests were found\b/i },
|
|
69
|
+
{ name: "flutter", re: /\bNo tests ran\.?/i },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** Go prints this per package that has no test files at all. */
|
|
73
|
+
const GO_NO_TEST_FILES = /\[no test files\]/;
|
|
74
|
+
/** Any sign that a Go package did run tests. */
|
|
75
|
+
const GO_RAN_SOMETHING = /^(?:ok|FAIL|---\s+(?:PASS|FAIL|SKIP)):?\s/m;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read a collected-test count out of a runner's output.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} [stdout]
|
|
81
|
+
* @param {string} [stderr]
|
|
82
|
+
* @returns {{ count: number|null, runner: string|null }}
|
|
83
|
+
* `count` is null when no recognised runner stated one — which is not a
|
|
84
|
+
* finding, only an absence of evidence.
|
|
85
|
+
*/
|
|
86
|
+
export function parseCollectedTests(stdout = "", stderr = "") {
|
|
87
|
+
const text = `${stdout || ""}\n${stderr || ""}`;
|
|
88
|
+
if (!text.trim()) return { count: null, runner: null };
|
|
89
|
+
|
|
90
|
+
for (const rule of EXPLICIT_ZERO) {
|
|
91
|
+
if (rule.re.test(text)) return { count: 0, runner: rule.name };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Go states absence per package rather than as a count, so it needs its own
|
|
95
|
+
// pass before the generic patterns.
|
|
96
|
+
if (GO_NO_TEST_FILES.test(text) || GO_RAN_SOMETHING.test(text)) {
|
|
97
|
+
// Only a run where *no* package did anything is a zero: a monorepo where
|
|
98
|
+
// one package has no tests and three do is a normal, healthy repository.
|
|
99
|
+
if (!GO_RAN_SOMETHING.test(text)) return { count: 0, runner: "go" };
|
|
100
|
+
// Something ran. `--- PASS:` lines are per-test but appear only under -v,
|
|
101
|
+
// so their absence means the count was not stated — not that it was zero.
|
|
102
|
+
// Reporting zero here would have failed every ordinary `go test ./...`.
|
|
103
|
+
const perTest = text.match(GO_PER_TEST);
|
|
104
|
+
return { count: perTest && perTest.length > 0 ? perTest.length : null, runner: "go" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const rule of COUNT_PATTERNS) {
|
|
108
|
+
const m = rule.re.exec(text);
|
|
109
|
+
if (!m) continue;
|
|
110
|
+
const n = Number(m[1]);
|
|
111
|
+
if (Number.isFinite(n)) return { count: n, runner: rule.name };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { count: null, runner: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Decide whether a passing verification command actually verified anything.
|
|
119
|
+
*
|
|
120
|
+
* @param {object} testResult - the test stage's result ({ ok, stdout, stderr, command })
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {number} [opts.minTests=1] - the floor, from `verify.minTests`.
|
|
123
|
+
* @returns {{ ok: boolean, count: number|null, runner: string|null, reason: string|null }}
|
|
124
|
+
*/
|
|
125
|
+
export function checkCollectionFloor(testResult, opts = {}) {
|
|
126
|
+
const minTests = Number.isFinite(opts.minTests) ? opts.minTests : 1;
|
|
127
|
+
if (minTests <= 0) return { ok: true, count: null, runner: null, reason: null };
|
|
128
|
+
// Only a *passing* command can lie about this. A failing one already fails.
|
|
129
|
+
if (!testResult || testResult.ok !== true) {
|
|
130
|
+
return { ok: true, count: null, runner: null, reason: null };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { count, runner } = parseCollectedTests(testResult.stdout, testResult.stderr);
|
|
134
|
+
if (count === null || count >= minTests) {
|
|
135
|
+
return { ok: true, count, runner, reason: null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
count,
|
|
141
|
+
runner,
|
|
142
|
+
reason:
|
|
143
|
+
`The verification command exited 0 without running any tests` +
|
|
144
|
+
(runner ? ` (${runner} reported ${count})` : "") +
|
|
145
|
+
`, so this change was approved against nothing. ` +
|
|
146
|
+
`Point verify.test at a suite that covers this repository, lower the floor with verify.minTests, ` +
|
|
147
|
+
`or — if this repository intentionally uses only the scope and secret phases — set verify.required: false.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/security.mjs
CHANGED
|
@@ -1155,14 +1155,42 @@ function locateFindingLine(lines, type, file = null) {
|
|
|
1155
1155
|
// (`expect(\n formatInvoice(bill)\n).toBe(…`) still recognises the chain.
|
|
1156
1156
|
// The bound is a guess: an argument list longer than 240 characters is
|
|
1157
1157
|
// rarer than a missed chain.
|
|
1158
|
+
// The dialect list is not decoration. `assertEqual` was recognised only
|
|
1159
|
+
// because `\.?` made the dot optional and the `i` flag let `Equal` match
|
|
1160
|
+
// `equal`; `assertEquals`, one letter longer, fell out of the pattern and
|
|
1161
|
+
// took JUnit, PHPUnit, Minitest, RSpec and XCTest with it. The weak forms
|
|
1162
|
+
// — assertTrue, assertNotNull, XCTAssertTrue — are deliberately absent:
|
|
1163
|
+
// they state no expected value, so their arrival in place of one of these
|
|
1164
|
+
// is a weakening, which is a finding of its own.
|
|
1158
1165
|
const SPECIFIC_ASSERTION = new RegExp(
|
|
1159
1166
|
[
|
|
1160
1167
|
"\\bassert(?:\\.strict)?\\.?(?:strictEqual|deepStrictEqual|deepEqual|notStrictEqual|notDeepStrictEqual|equal|notEqual|match|doesNotMatch|throws|rejects|doesNotThrow)\\s*\\(",
|
|
1161
1168
|
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.(?:toBe|toEqual|toStrictEqual|toMatch|toMatchObject|toContain|toHaveBeenCalledWith|toThrow|toHaveLength|toBeCloseTo)\\s*\\(",
|
|
1162
1169
|
"\\bassert\\.(?:equals|deepEquals|include|lengthOf)\\s*\\(",
|
|
1163
1170
|
"assert_eq!|assert_ne!",
|
|
1171
|
+
// The bare comparison form. `assert add(1, 2) == 3` is how pytest is
|
|
1172
|
+
// actually written, and Rust's `assert!(a == b)` and Elixir's
|
|
1173
|
+
// `assert f(x) == 3` follow it; none of them name a comparison
|
|
1174
|
+
// function, so a list of function names could never reach them.
|
|
1175
|
+
// Equality only. `assert!(x != 0)` names no expected value — it is the
|
|
1176
|
+
// weaker claim you arrive at by giving one up, and counting it as
|
|
1177
|
+
// specific would make the downgrade from `assert_eq!(x, 5)` invisible to
|
|
1178
|
+
// the weakening check.
|
|
1179
|
+
"\\bassert\\s+[^\\n]*(?:===|==)(?!=)",
|
|
1180
|
+
"\\bassert!\\s*\\([^\\n]*==(?!=)",
|
|
1164
1181
|
"\\bt\\.(?:Errorf|Fatalf)\\s*\\(",
|
|
1165
1182
|
"\\brequire\\.(?:Equal|NotEqual|Len|Contains|Error|NoError)\\s*\\(",
|
|
1183
|
+
// Python unittest, stated rather than inherited from the optional dot.
|
|
1184
|
+
"\\bassert(?:Equal|NotEqual|AlmostEqual|NotAlmostEqual|Regex|NotRegex|Raises|In|NotIn|Is|IsNot|ListEqual|DictEqual|SetEqual|TupleEqual|CountEqual|Greater|Less|GreaterEqual|LessEqual)\\s*\\(",
|
|
1185
|
+
// JUnit / TestNG / PHPUnit
|
|
1186
|
+
"\\bassert(?:Equals|NotEquals|Same|NotSame|ArrayEquals|IterableEquals|LinesMatch|Count|StringContainsString|StringEqualsFile|InstanceOf|Contains|Throws)\\s*\\(",
|
|
1187
|
+
"\\bassertThat\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.(?:isEqualTo|isSameAs|contains|containsExactly|hasSize|isCloseTo|matches)\\s*\\(",
|
|
1188
|
+
// Minitest
|
|
1189
|
+
"\\b(?:assert|refute)_(?:equal|includes|match|nil|same|in_delta|in_epsilon|raises|empty|operator|predicate)\\b",
|
|
1190
|
+
// RSpec
|
|
1191
|
+
"\\bexpect\\s*\\([\\s\\S]{0,240}?\\)\\s*\\.(?:to|not_to|to_not)\\s+(?:eq|eql|equal|be|be_within|match|include|contain_exactly|match_array|have_attributes|raise_error|start_with|end_with)\\b",
|
|
1192
|
+
// XCTest
|
|
1193
|
+
"\\bXCTAssert(?:Equal|NotEqual|EqualWithAccuracy|Identical|NotIdentical|GreaterThan|LessThan|GreaterThanOrEqual|LessThanOrEqual|ThrowsError|NoThrow)\\s*\\(",
|
|
1166
1194
|
].join("|"),
|
|
1167
1195
|
"i"
|
|
1168
1196
|
);
|
|
@@ -1203,6 +1231,15 @@ const TEST_LANG_BY_EXT = new Map([
|
|
|
1203
1231
|
[".py", "python"], [".pyi", "python"],
|
|
1204
1232
|
[".go", "go"],
|
|
1205
1233
|
[".rs", "rust"],
|
|
1234
|
+
// Approximations, chosen for comment and continuation syntax rather than
|
|
1235
|
+
// for kinship: the C-like family reads correctly under the `js` scanner,
|
|
1236
|
+
// and Ruby under the `python` one because both end a comment at `#` and a
|
|
1237
|
+
// statement at the newline. Naming them beats falling through to `js` by
|
|
1238
|
+
// default, which is how a `#` comment came to be read as code.
|
|
1239
|
+
[".java", "js"], [".kt", "js"], [".kts", "js"], [".scala", "js"], [".groovy", "js"],
|
|
1240
|
+
[".swift", "js"], [".cs", "js"], [".php", "js"], [".c", "js"], [".cc", "js"],
|
|
1241
|
+
[".cpp", "js"], [".h", "js"], [".hpp", "js"], [".m", "js"], [".sol", "js"],
|
|
1242
|
+
[".rb", "python"],
|
|
1206
1243
|
]);
|
|
1207
1244
|
|
|
1208
1245
|
function langForTestFile(file) {
|
|
@@ -1540,6 +1577,21 @@ function stripComments(text, lang) {
|
|
|
1540
1577
|
const CONTINUATION_START = /^[)\],.]/;
|
|
1541
1578
|
const CONTINUATION_OP_START = /^[+\-*/%<>=&|^:]/;
|
|
1542
1579
|
|
|
1580
|
+
// A comment is not a continuation, however much it looks like one.
|
|
1581
|
+
//
|
|
1582
|
+
// `//` begins with a division sign and `--` with a minus, so both matched
|
|
1583
|
+
// CONTINUATION_OP_START and folded the following comment line into the
|
|
1584
|
+
// statement above it. The cost was a false accusation on a virtuous act:
|
|
1585
|
+
// adding an assertion next to a `// ...` line made the new assertion absorb
|
|
1586
|
+
// the comment, stop matching its unchanged twin, and get reported as a
|
|
1587
|
+
// rewritten expectation. Python was unaffected only because `#` is not an
|
|
1588
|
+
// operator — which is why the same fixture passed in pytest and failed in
|
|
1589
|
+
// Jest, and why it survived every suite written against the pytest layout.
|
|
1590
|
+
//
|
|
1591
|
+
// A comment inside an open delimiter still joins: `cur.delta > 0` decides
|
|
1592
|
+
// that before this test is ever reached.
|
|
1593
|
+
const COMMENT_LINE_START = /^(?:\/\/|\/\*|#|--)/;
|
|
1594
|
+
|
|
1543
1595
|
// A scanner miscount (an unbalanced delimiter inside a regex literal is the
|
|
1544
1596
|
// usual cause) must not be able to merge a whole file into one statement,
|
|
1545
1597
|
// which would pair *any* literal change anywhere in the file.
|
|
@@ -1578,14 +1630,15 @@ function assembleStatements(sliceLines, lang) {
|
|
|
1578
1630
|
|
|
1579
1631
|
for (const L of sliceLines) {
|
|
1580
1632
|
const trimmed = L.text.replace(/^\s+/, "");
|
|
1633
|
+
const startsComment = COMMENT_LINE_START.test(trimmed);
|
|
1581
1634
|
const joins =
|
|
1582
1635
|
cur !== null &&
|
|
1583
1636
|
(cur.delta > 0 ||
|
|
1584
1637
|
cur.state.str !== null ||
|
|
1585
1638
|
cur.state.block > 0 ||
|
|
1586
1639
|
cur.trailingBackslash ||
|
|
1587
|
-
|
|
1588
|
-
|
|
1640
|
+
(!startsComment &&
|
|
1641
|
+
(CONTINUATION_START.test(trimmed) || CONTINUATION_OP_START.test(trimmed))));
|
|
1589
1642
|
|
|
1590
1643
|
if (
|
|
1591
1644
|
joins &&
|
|
@@ -1648,7 +1701,17 @@ function splitAssertionArgs(clean, lang) {
|
|
|
1648
1701
|
const m = SPECIFIC_ASSERTION.exec(clean);
|
|
1649
1702
|
if (!m) return null;
|
|
1650
1703
|
|
|
1651
|
-
|
|
1704
|
+
// Not every branch of SPECIFIC_ASSERTION ends at an opening paren:
|
|
1705
|
+
// `assert_eq!`, `assert_equal` and RSpec's `.to eq` all match a bare name.
|
|
1706
|
+
// Starting the walk one character early made every argument boundary wrong,
|
|
1707
|
+
// so a reworded message read as a rewritten value.
|
|
1708
|
+
let i = m.index + m[0].length;
|
|
1709
|
+
if (clean[i - 1] !== "(") {
|
|
1710
|
+
let j = i;
|
|
1711
|
+
while (j < clean.length && /\s/.test(clean[j])) j++;
|
|
1712
|
+
if (clean[j] !== "(") return null;
|
|
1713
|
+
i = j + 1;
|
|
1714
|
+
}
|
|
1652
1715
|
let depth = 1;
|
|
1653
1716
|
let quote = null;
|
|
1654
1717
|
let triple = false;
|
|
@@ -1740,7 +1803,45 @@ function messageArgIndices(args) {
|
|
|
1740
1803
|
* every time somebody improved the wording of a failure. Firing on that is
|
|
1741
1804
|
* how an operator learns to pass the override without reading it.
|
|
1742
1805
|
*/
|
|
1806
|
+
/**
|
|
1807
|
+
* Split a statement at a trailing `, "message"` written outside the call.
|
|
1808
|
+
*
|
|
1809
|
+
* RSpec puts the message there — `expect(x).to eq(3), "explain"` — and so do
|
|
1810
|
+
* Ruby and Elixir assertions generally. An argument-position check can never
|
|
1811
|
+
* see it, so rewording one read as a rewritten expectation.
|
|
1812
|
+
*/
|
|
1813
|
+
function splitTrailingMessage(clean) {
|
|
1814
|
+
let depth = 0;
|
|
1815
|
+
let quote = null;
|
|
1816
|
+
let lastComma = -1;
|
|
1817
|
+
for (let i = 0; i < clean.length; i++) {
|
|
1818
|
+
const c = clean[i];
|
|
1819
|
+
if (quote !== null) {
|
|
1820
|
+
if (c === "\\") { i += 1; continue; }
|
|
1821
|
+
if (c === quote) quote = null;
|
|
1822
|
+
continue;
|
|
1823
|
+
}
|
|
1824
|
+
if (c === '"' || c === "'" || c === "`") { quote = c; continue; }
|
|
1825
|
+
if (c === "(" || c === "[" || c === "{") depth++;
|
|
1826
|
+
else if (c === ")" || c === "]" || c === "}") depth--;
|
|
1827
|
+
else if (c === "," && depth === 0) lastComma = i;
|
|
1828
|
+
}
|
|
1829
|
+
if (lastComma === -1) return { head: clean, msg: null };
|
|
1830
|
+
const tail = clean.slice(lastComma + 1).trim();
|
|
1831
|
+
if (!isPureStringLiteral(tail)) return { head: clean, msg: null };
|
|
1832
|
+
return { head: clean.slice(0, lastComma), msg: tail };
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1743
1835
|
function differsOnlyInMessage(cleanRemoved, cleanAdded, lang) {
|
|
1836
|
+
const ta = splitTrailingMessage(cleanRemoved);
|
|
1837
|
+
const tb = splitTrailingMessage(cleanAdded);
|
|
1838
|
+
if (
|
|
1839
|
+
(ta.msg !== null || tb.msg !== null) &&
|
|
1840
|
+
ta.head.replace(/\s+/g, "") === tb.head.replace(/\s+/g, "")
|
|
1841
|
+
) {
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1744
1845
|
const a = splitAssertionArgs(cleanRemoved, lang);
|
|
1745
1846
|
const b = splitAssertionArgs(cleanAdded, lang);
|
|
1746
1847
|
if (!a || !b || a.length !== b.length || a.length === 0) return false;
|
|
@@ -1988,12 +2089,26 @@ export function resolveAllowedTamperKinds(options = {}) {
|
|
|
1988
2089
|
* @param {string} diffOrText - Unified git diff
|
|
1989
2090
|
* @param {Object} [options]
|
|
1990
2091
|
* @param {boolean} [options.allowTestModifications=false]
|
|
1991
|
-
* @returns {{ ok: boolean, violations: Array<
|
|
2092
|
+
* @returns {{ ok: boolean, violations: Array<object>, inputsSeen: number, filesSeen: number,
|
|
2093
|
+
* assertionsSeen: number, unreadable: Array<{file: string, count: number, samples: string[]}>,
|
|
2094
|
+
* status: "PASS"|"FAIL"|"UNREADABLE"|"NOT_APPLICABLE" }}
|
|
2095
|
+
* `status` distinguishes "checked and clean" from "nothing was checked";
|
|
2096
|
+
* `ok: true` alone cannot, and that ambiguity is the defect class this
|
|
2097
|
+
* field exists to make visible.
|
|
1992
2098
|
*/
|
|
1993
2099
|
export function checkTestTampering(diffOrText = "", options = {}) {
|
|
1994
|
-
if (!diffOrText || typeof diffOrText !== "string")
|
|
2100
|
+
if (!diffOrText || typeof diffOrText !== "string") {
|
|
2101
|
+
return { ok: true, violations: [], inputsSeen: 0, status: "NOT_APPLICABLE", reason: "empty diff" };
|
|
2102
|
+
}
|
|
1995
2103
|
const allowed = resolveAllowedTamperKinds(options);
|
|
1996
|
-
if (allowed.all)
|
|
2104
|
+
if (allowed.all) {
|
|
2105
|
+
return { ok: true, violations: [], inputsSeen: 0, status: "NOT_APPLICABLE", reason: "all kinds allowed" };
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// Which predicate decides what this guard even looks at. Injectable so the
|
|
2109
|
+
// meta-check can mutate it: a canary that still passes when the predicate is
|
|
2110
|
+
// replaced by `() => false` was never requiring this guard to activate.
|
|
2111
|
+
const isTestPath_ = typeof options.isTestPath === "function" ? options.isTestPath : isTestPath;
|
|
1997
2112
|
|
|
1998
2113
|
const violations = [];
|
|
1999
2114
|
const lines = diffOrText.split("\n");
|
|
@@ -2002,7 +2117,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2002
2117
|
let currentOldLineNo = null;
|
|
2003
2118
|
let currentNewLineNo = null;
|
|
2004
2119
|
|
|
2005
|
-
const isTestFile =
|
|
2120
|
+
const isTestFile = isTestPath_;
|
|
2006
2121
|
|
|
2007
2122
|
const SKIP_INJECTIONS = [
|
|
2008
2123
|
{ pattern: /\b(?:it|test|describe|context)\.skip\s*\(/i, desc: "Injected test skip (.skip())" },
|
|
@@ -2012,9 +2127,24 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2012
2127
|
{ pattern: /#\[ignore\]/i, desc: "Injected Rust test ignore attribute (#[ignore])" },
|
|
2013
2128
|
{ pattern: /\/\/\s*@ts-ignore/i, desc: "Injected TypeScript ignore comment (// @ts-ignore)" },
|
|
2014
2129
|
{ pattern: /\/\/\s*@ts-nocheck/i, desc: "Injected TypeScript nocheck directive (// @ts-nocheck)" },
|
|
2130
|
+
// A suite that never runs cannot fail, and `@Disabled` is exactly as
|
|
2131
|
+
// effective at arranging that as `it.skip` is.
|
|
2132
|
+
{ pattern: /@Disabled\b/, desc: "Injected JUnit 5 disable annotation (@Disabled)" },
|
|
2133
|
+
{ pattern: /@Ignore\b/, desc: "Injected JUnit 4 / TestNG ignore annotation (@Ignore)" },
|
|
2134
|
+
{ pattern: /@Test\s*\([^)]*enabled\s*=\s*false/i, desc: "Injected TestNG disabled test (enabled = false)" },
|
|
2135
|
+
{ pattern: /@unittest\.skip/i, desc: "Injected unittest skip decorator (@unittest.skip)" },
|
|
2136
|
+
{ pattern: /\bmarkTest(?:Skipped|Incomplete)\s*\(/i, desc: "Injected PHPUnit skip (markTestSkipped())" },
|
|
2137
|
+
{ pattern: /\bXCTSkip(?:If|Unless|IfNot)?\s*\(/, desc: "Injected XCTest skip (XCTSkip())" },
|
|
2138
|
+
{ pattern: /\b(?:xit|xdescribe|xcontext|xspecify)\b\s*["\x27]/i, desc: "Injected RSpec disabled example (xit)" },
|
|
2139
|
+
{ pattern: /,\s*skip:\s*(?:true|["\x27])/i, desc: "Injected RSpec skip metadata (skip:)" },
|
|
2140
|
+
{ pattern: /^\s*(?:skip|pending)\s*(?:["\x27(]|$)/i, desc: "Injected Minitest/RSpec skip statement" },
|
|
2015
2141
|
];
|
|
2016
2142
|
|
|
2017
|
-
|
|
2143
|
+
// `#` and `--` belong here for the same reason the dialects belong in
|
|
2144
|
+
// ASSERTION_PATTERN: a Ruby or Python assertion commented out is exactly
|
|
2145
|
+
// as gone as a JavaScript one, and was previously not looked for.
|
|
2146
|
+
const COMMENTED_ASSERTION =
|
|
2147
|
+
/^\+\s*(?:\/\/|\/\*|#|--)\s*(?:expect\s*\(|assert(?!ion|ing|ed\b|s\b)[a-zA-Z0-9_$]*\s*[.(]|assert\s|refute_|XCTAssert|t\.expect|t\.assert)/i;
|
|
2018
2148
|
|
|
2019
2149
|
const VACUOUS_ASSERTIONS = [
|
|
2020
2150
|
{ pattern: /\bassert(?:\.ok)?\s*\(\s*true\s*(?:,[^)]*)?\)/i, desc: "Vacuous truth assertion (assert.ok(true))" },
|
|
@@ -2025,11 +2155,44 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2025
2155
|
{ pattern: /\bexpect\s*\(\s*false\s*\)\s*\.toBeFalsy\s*\(/i, desc: "Vacuous falsity expectation (expect(false).toBeFalsy())" },
|
|
2026
2156
|
{ pattern: /\bassert\.(?:isTrue|isOk)\s*\(\s*true\s*(?:,[^)]*)?\)/i, desc: "Vacuous truth assertion (assert.isTrue(true))" },
|
|
2027
2157
|
{ pattern: /\bassert\.(?:isFalse|isNotOk)\s*\(\s*false\s*(?:,[^)]*)?\)/i, desc: "Vacuous falsity assertion (assert.isFalse(false))" },
|
|
2158
|
+
{ pattern: /\b(?:XCT)?assertTrue\s*\(\s*true\s*[,)]/i, desc: "Vacuous truth assertion (assertTrue(true))" },
|
|
2159
|
+
{ pattern: /\b(?:XCT)?assertFalse\s*\(\s*false\s*[,)]/i, desc: "Vacuous falsity assertion (assertFalse(false))" },
|
|
2160
|
+
{ pattern: /\b(?:assertEquals|assertSame|XCTAssertEqual)\s*\(\s*([^,]+?)\s*,\s*\1\s*[,)]/i, desc: "Vacuous identity assertion (assertEquals(X, X))" },
|
|
2161
|
+
{ pattern: /\bassert_equal\s*\(?\s*([^,]+?)\s*,\s*\1\s*\)?\s*$/i, desc: "Vacuous identity assertion (assert_equal X, X)" },
|
|
2162
|
+
{ pattern: /\bexpect\s*\(\s*true\s*\)\s*\.to\s+be(?:\s+true)?\b/i, desc: "Vacuous truth expectation (expect(true).to be true)" },
|
|
2028
2163
|
];
|
|
2029
2164
|
|
|
2030
|
-
|
|
2165
|
+
// Broad on purpose: this is the denominator, not the verdict. A word
|
|
2166
|
+
// boundary immediately after `assert` never falls in `assertEquals`,
|
|
2167
|
+
// `assert_equal` or `XCTAssertEqual`, so five ecosystems contributed no
|
|
2168
|
+
// assertions to count at all and a gutted JUnit suite was arithmetically
|
|
2169
|
+
// indistinguishable from an untouched one. The lookahead keeps prose and
|
|
2170
|
+
// identifiers — `assertion`, `asserts`, `asserted` — out of the count.
|
|
2171
|
+
const ASSERTION_PATTERN =
|
|
2172
|
+
/(?:\b(?:assert(?!ion|ing|ed\b|s\b)[a-zA-Z0-9_$]*(?:\.[a-zA-Z0-9_$]+)?|refute[a-zA-Z0-9_$]*|XCTAssert[a-zA-Z0-9_$]*|XCTFail|expect|t\.(?:assert|expect|is|equal|true|false|Errorf|Fatalf)|require\.[a-zA-Z0-9_$]+)\b|assert!|assert_eq!|assert_ne!)/i;
|
|
2173
|
+
// The loose net. Not a verdict and never a block — its only job is to
|
|
2174
|
+
// notice that a line was plainly an assertion in *some* dialect that
|
|
2175
|
+
// ASSERTION_PATTERN did not recognise. Without it, adding the seventh
|
|
2176
|
+
// ecosystem is indistinguishable from having covered it all along: the
|
|
2177
|
+
// guard returns the same clean PASS either way. This is the denominator
|
|
2178
|
+
// for the denominator.
|
|
2179
|
+
// Deliberately not call-shaped. Haskell's `x `shouldBe` 3` is an
|
|
2180
|
+
// assertion with no parentheses anywhere near it, and a net that only
|
|
2181
|
+
// catches `name(` reports the same confident PASS on it as on a clean
|
|
2182
|
+
// Node suite. `require` and `check` are absent on purpose: in a
|
|
2183
|
+
// CommonJS test file `require("./calc")` is an import, not a claim.
|
|
2184
|
+
const ASSERTION_SHAPED =
|
|
2185
|
+
/\b(?:assert(?!ion|ing|ed\b|s\b)|expect(?!ed\b|ation)|refute)[a-zA-Z0-9_$]*\b|`\s*should[a-zA-Z0-9_$]*\s*`|\b(?:should|must|verify|ensure|confirm)[a-zA-Z0-9_$]*\s*[(!]|\.\s*(?:should|to|to_not|not_to|must)\b|\bBOOST_[A-Z_]+\s*\(|\b[A-Z]+_(?:EQ|NE|TRUE|FALSE|THAT)\s*\(/;
|
|
2031
2186
|
const isCommentLine = (str) => /^\s*(?:\/\/|\/\*|\*|#|--|;)/.test(str);
|
|
2032
2187
|
|
|
2188
|
+
/** Book-keeping only: what this run looked at, before deciding anything. */
|
|
2189
|
+
const countExamined = (stats, text) => {
|
|
2190
|
+
if (!text.trim() || isCommentLine(text)) return;
|
|
2191
|
+
stats.examined++;
|
|
2192
|
+
if (ASSERTION_PATTERN.test(text)) stats.recognised++;
|
|
2193
|
+
else if (ASSERTION_SHAPED.test(text) && stats.unreadable.length < 5) stats.unreadable.push(text.trim().slice(0, 120));
|
|
2194
|
+
};
|
|
2195
|
+
|
|
2033
2196
|
const fileAssertions = new Map();
|
|
2034
2197
|
let pendingHunk = false;
|
|
2035
2198
|
|
|
@@ -2065,7 +2228,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2065
2228
|
}
|
|
2066
2229
|
|
|
2067
2230
|
if (!fileAssertions.has(currentFile)) {
|
|
2068
|
-
fileAssertions.set(currentFile, { removed: [], added: 0, addedTexts: [], removedSpecific: [], addedSpecific: 0, hunks: [] });
|
|
2231
|
+
fileAssertions.set(currentFile, { removed: [], added: 0, addedTexts: [], removedSpecific: [], addedSpecific: 0, hunks: [], examined: 0, recognised: 0, unreadable: [] });
|
|
2069
2232
|
}
|
|
2070
2233
|
const fileStats = fileAssertions.get(currentFile);
|
|
2071
2234
|
if (pendingHunk) {
|
|
@@ -2077,6 +2240,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2077
2240
|
if (line.startsWith("-") && !line.startsWith("---")) {
|
|
2078
2241
|
const deletedText = line.slice(1);
|
|
2079
2242
|
if (hunk) hunk.lines.push({ kind: "-", text: deletedText, oldNo: currentOldLineNo, newNo: null });
|
|
2243
|
+
countExamined(fileStats, deletedText);
|
|
2080
2244
|
if (!isCommentLine(deletedText) && ASSERTION_PATTERN.test(deletedText)) {
|
|
2081
2245
|
fileStats.removed.push({ line: currentOldLineNo, text: deletedText });
|
|
2082
2246
|
if (isSpecificAssertion(deletedText)) {
|
|
@@ -2087,6 +2251,7 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2087
2251
|
} else if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
2088
2252
|
const addedText = line.slice(1);
|
|
2089
2253
|
if (hunk) hunk.lines.push({ kind: "+", text: addedText, oldNo: null, newNo: currentNewLineNo });
|
|
2254
|
+
countExamined(fileStats, addedText);
|
|
2090
2255
|
let isVacuous = false;
|
|
2091
2256
|
|
|
2092
2257
|
// Check skip injections
|
|
@@ -2207,9 +2372,50 @@ export function checkTestTampering(diffOrText = "", options = {}) {
|
|
|
2207
2372
|
? violations
|
|
2208
2373
|
: violations.filter((v) => !allowed.kinds.has(TAMPER_KINDS.get(v.type)));
|
|
2209
2374
|
|
|
2375
|
+
// What was examined, not only what was found.
|
|
2376
|
+
//
|
|
2377
|
+
// `ok: true` from a guard that looked at nothing is byte-identical to
|
|
2378
|
+
// `ok: true` from a guard that looked at everything and approved it. That
|
|
2379
|
+
// ambiguity is how a substring bug in the file classifier switched this
|
|
2380
|
+
// entire guard off for the standard pytest, Rust and RSpec layouts while
|
|
2381
|
+
// every signal stayed green.
|
|
2382
|
+
//
|
|
2383
|
+
// Counting *files* was not enough. A JUnit diff that rewrote an expected
|
|
2384
|
+
// value produced `inputsSeen: 1` and a clean PASS while not one assertion
|
|
2385
|
+
// in it had been recognised — the same ambiguity, one level down, inside
|
|
2386
|
+
// the mechanism built to remove it. So the denominator is now the thing
|
|
2387
|
+
// the rules actually consume: lines examined, and of those, assertions
|
|
2388
|
+
// understood. `UNREADABLE` is the state that has no business being silent
|
|
2389
|
+
// — assertion-shaped lines were present and none of them parsed, which
|
|
2390
|
+
// means this repository speaks a dialect the guard does not.
|
|
2391
|
+
let examined = 0;
|
|
2392
|
+
let assertionsSeen = 0;
|
|
2393
|
+
const unreadable = [];
|
|
2394
|
+
for (const [file, stats] of fileAssertions.entries()) {
|
|
2395
|
+
examined += stats.examined;
|
|
2396
|
+
assertionsSeen += stats.recognised;
|
|
2397
|
+
if (stats.unreadable.length > 0) {
|
|
2398
|
+
unreadable.push({ file, count: stats.unreadable.length, samples: stats.unreadable.slice(0, 3) });
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const status =
|
|
2403
|
+
reported.length > 0
|
|
2404
|
+
? "FAIL"
|
|
2405
|
+
: assertionsSeen === 0 && unreadable.length > 0
|
|
2406
|
+
? "UNREADABLE"
|
|
2407
|
+
: examined > 0
|
|
2408
|
+
? "PASS"
|
|
2409
|
+
: "NOT_APPLICABLE";
|
|
2410
|
+
|
|
2210
2411
|
return {
|
|
2211
2412
|
ok: reported.length === 0,
|
|
2212
2413
|
violations: reported,
|
|
2414
|
+
inputsSeen: examined,
|
|
2415
|
+
filesSeen: fileAssertions.size,
|
|
2416
|
+
assertionsSeen,
|
|
2417
|
+
unreadable,
|
|
2418
|
+
status,
|
|
2213
2419
|
};
|
|
2214
2420
|
}
|
|
2215
2421
|
|
package/src/stack-detector.mjs
CHANGED
|
@@ -39,7 +39,65 @@ export function pytestCmd(env = process.env) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
42
|
+
* Does this Makefile declare a `test` target?
|
|
43
|
+
*
|
|
44
|
+
* Read rather than assumed: the presence of the file says nothing about
|
|
45
|
+
* whether `make test` will run.
|
|
46
|
+
*/
|
|
47
|
+
function makefileHasTestTarget(root) {
|
|
48
|
+
try {
|
|
49
|
+
const text = readFileSync(join(root, "Makefile"), "utf-8");
|
|
50
|
+
return /^\.PHONY:.*\btest\b/m.test(text) || /^test\s*:/m.test(text);
|
|
51
|
+
} catch (_) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A declared test script that runs no tests and exits 0.
|
|
58
|
+
*
|
|
59
|
+
* This is the single most dangerous input the gate can receive, because every
|
|
60
|
+
* downstream check reads "a command ran and passed". `bootstrapZeroTestRepo`
|
|
61
|
+
* called `"test": "echo 'no tests yet' && exit 0"` an
|
|
62
|
+
* EXISTING_VERIFICATION_ORACLE — it asked whether the field was set, never
|
|
63
|
+
* what was in it.
|
|
64
|
+
*
|
|
65
|
+
* npm's own default (`echo "Error: no test specified" && exit 1`) is not a
|
|
66
|
+
* placeholder by this definition, and correctly so: it exits non-zero, which
|
|
67
|
+
* fails loudly rather than certifying nothing.
|
|
68
|
+
*/
|
|
69
|
+
export function isPlaceholderTestScript(cmd) {
|
|
70
|
+
if (typeof cmd !== "string") return false;
|
|
71
|
+
const trimmed = cmd.trim();
|
|
72
|
+
if (!trimmed) return true;
|
|
73
|
+
// Drop the announcements; what matters is what the shell is left doing.
|
|
74
|
+
const remainder = trimmed
|
|
75
|
+
.split(/&&|;/)
|
|
76
|
+
.map((part) => part.trim())
|
|
77
|
+
.filter((part) => part && !/^(?:echo|printf|:)\b/.test(part));
|
|
78
|
+
if (remainder.length === 0) return true;
|
|
79
|
+
return remainder.every((part) => /^(?:exit\s+0|true|:)$/.test(part));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate the fallback verification oracle for a JS/generic repo with no tests.
|
|
84
|
+
*
|
|
85
|
+
* What this used to write could not fail:
|
|
86
|
+
*
|
|
87
|
+
* assert.ok(fs.existsSync(process.cwd()));
|
|
88
|
+
* assert.ok(fs.readdirSync(process.cwd()).length > 0);
|
|
89
|
+
*
|
|
90
|
+
* Both hold for every repository and every change, so the generated "oracle"
|
|
91
|
+
* was green against arbitrary broken code — and worse, it *silenced* the
|
|
92
|
+
* `missingOracle` guard in engine.mjs, which fires only when no command ran at
|
|
93
|
+
* all. A repository that honestly had no oracle was converted into one that
|
|
94
|
+
* claimed to have one. That is the tool writing its own blindness to disk.
|
|
95
|
+
*
|
|
96
|
+
* The other stacks already get a real static gate at this point — `tsc
|
|
97
|
+
* --noEmit`, `cargo check`, `go vet`, `compileall` — each of which fails on a
|
|
98
|
+
* real class of defect. This is the JavaScript equivalent: every source file
|
|
99
|
+
* must parse. It proves the code compiles, not that it works, and the caller
|
|
100
|
+
* says so; but a syntax error fails it, which is one more than before.
|
|
43
101
|
*/
|
|
44
102
|
export function generateSmokeTestScript(root = process.cwd()) {
|
|
45
103
|
const agentDir = join(root, ".agent");
|
|
@@ -48,15 +106,45 @@ export function generateSmokeTestScript(root = process.cwd()) {
|
|
|
48
106
|
} catch (_) {}
|
|
49
107
|
|
|
50
108
|
const smokePath = join(agentDir, "smoke.test.mjs");
|
|
51
|
-
const content = `// Auto-generated zero-dependency
|
|
109
|
+
const content = `// Auto-generated zero-dependency parse gate (.agent/smoke.test.mjs)
|
|
110
|
+
//
|
|
111
|
+
// Written by \`agentctl bootstrap\` for a repository that had no test suite.
|
|
112
|
+
// It proves that every source file still parses. It does NOT prove the code
|
|
113
|
+
// is correct — replace it with real tests as soon as there are any.
|
|
52
114
|
import { test } from "node:test";
|
|
53
115
|
import assert from "node:assert/strict";
|
|
54
|
-
import
|
|
116
|
+
import { readdirSync, statSync } from "node:fs";
|
|
117
|
+
import { join, extname } from "node:path";
|
|
118
|
+
import { spawnSync } from "node:child_process";
|
|
119
|
+
|
|
120
|
+
const SKIP = new Set([".git", "node_modules", "vendor", "dist", "build", "coverage", ".venv", "venv", ".next", ".agent"]);
|
|
121
|
+
const SOURCE = new Set([".js", ".mjs", ".cjs"]);
|
|
122
|
+
|
|
123
|
+
function sources(dir, acc = [], depth = 0) {
|
|
124
|
+
if (depth > 8) return acc;
|
|
125
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
126
|
+
if (entry.name.startsWith(".") && entry.name !== ".agent") continue;
|
|
127
|
+
if (SKIP.has(entry.name)) continue;
|
|
128
|
+
const full = join(dir, entry.name);
|
|
129
|
+
if (entry.isDirectory()) sources(full, acc, depth + 1);
|
|
130
|
+
else if (SOURCE.has(extname(entry.name))) acc.push(full);
|
|
131
|
+
}
|
|
132
|
+
return acc;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("every source file parses", () => {
|
|
136
|
+
const files = sources(process.cwd());
|
|
55
137
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
138
|
+
// A gate with nothing to check is not a passing gate. Reporting success
|
|
139
|
+
// over an empty file list is exactly the vacuous oracle this replaced.
|
|
140
|
+
assert.ok(files.length > 0, "No JavaScript sources found to verify — this is not an oracle. Set verify.test in .agent/config.yml.");
|
|
141
|
+
|
|
142
|
+
const broken = [];
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
const res = spawnSync(process.execPath, ["--check", file], { encoding: "utf-8" });
|
|
145
|
+
if (res.status !== 0) broken.push(\`\${file}: \${(res.stderr || "").trim().split("\\n")[0]}\`);
|
|
146
|
+
}
|
|
147
|
+
assert.deepEqual(broken, [], \`\${broken.length} file(s) failed to parse\`);
|
|
60
148
|
});
|
|
61
149
|
`;
|
|
62
150
|
writeFileSync(smokePath, content, "utf-8");
|
|
@@ -173,7 +261,15 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
173
261
|
if (existsSync(join(projectRoot, "Package.swift"))) {
|
|
174
262
|
return { ...container, stack: "swift", testCmd: "swift test", buildCmd: "swift build", triggerFile: "Package.swift" };
|
|
175
263
|
}
|
|
176
|
-
|
|
264
|
+
// `app.json` is not a manifest, it is an Expo/React Native *configuration*
|
|
265
|
+
// file, and the name is generic enough that unrelated projects use it. Its
|
|
266
|
+
// test command is `npm test`, so without a package.json beside it the
|
|
267
|
+
// detector was claiming a Node stack for a repository that has no Node in
|
|
268
|
+
// it: `Cargo.toml` + `app.json` was measured as `react-native` / `npm test`.
|
|
269
|
+
if (
|
|
270
|
+
(existsSync(join(projectRoot, "app.json")) && existsSync(join(projectRoot, "package.json"))) ||
|
|
271
|
+
existsSync(join(projectRoot, "react-native.config.js"))
|
|
272
|
+
) {
|
|
177
273
|
const triggerFile = existsSync(join(projectRoot, "app.json")) ? "app.json" : "react-native.config.js";
|
|
178
274
|
return { ...container, stack: "react-native", testCmd: "npm test", buildCmd: "npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output android/main.jsbundle", triggerFile };
|
|
179
275
|
}
|
|
@@ -188,7 +284,14 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
188
284
|
if (existsSync(join(projectRoot, "go.mod"))) {
|
|
189
285
|
return { ...container, stack: "go", testCmd: "go test ./...", buildCmd: "go build ./...", triggerFile: "go.mod" };
|
|
190
286
|
}
|
|
191
|
-
|
|
287
|
+
// A Makefile is only an oracle if it declares the target we are about to
|
|
288
|
+
// run. `make test` on a Makefile with only a `build:` target exits 2 with
|
|
289
|
+
// "No rule to make target 'test'" — measured on a repository whose
|
|
290
|
+
// package.json declared a perfectly good `vitest run`, because the Makefile
|
|
291
|
+
// was checked first and the presence of the *file* was the whole test. A
|
|
292
|
+
// hard red on day one is how a user learns the gate is broken and turns it
|
|
293
|
+
// off, so the file must earn the claim.
|
|
294
|
+
if (existsSync(join(projectRoot, "Makefile")) && makefileHasTestTarget(projectRoot)) {
|
|
192
295
|
return { ...container, stack: "make", testCmd: "make test", buildCmd: "make build", triggerFile: "Makefile" };
|
|
193
296
|
}
|
|
194
297
|
|
|
@@ -715,7 +818,7 @@ export function bootstrapZeroTestRepo(root = process.cwd(), options = {}) {
|
|
|
715
818
|
if (existsSync(join(root, "package.json"))) {
|
|
716
819
|
try {
|
|
717
820
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
|
|
718
|
-
hasPkgTest = Boolean(pkg?.scripts?.test);
|
|
821
|
+
hasPkgTest = Boolean(pkg?.scripts?.test) && !isPlaceholderTestScript(pkg.scripts.test);
|
|
719
822
|
} catch (_) {}
|
|
720
823
|
}
|
|
721
824
|
|