jules-orchestrator-kit 0.63.0 → 0.65.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 +6 -3
- package/bin/agentctl.mjs +5 -0
- package/package.json +3 -2
- package/scripts/guard-reach-check.mjs +68 -6
- package/scripts/package-integrity-check.mjs +251 -0
- package/scripts/release.mjs +16 -0
- package/src/assertions.mjs +16 -0
- package/src/engine.mjs +49 -1
- package/src/guard-policy.mjs +512 -0
- package/src/ops/test-collection.mjs +26 -9
- package/src/security.mjs +194 -14
- package/src/stack-detector.mjs +50 -0
- package/src/wizard-init.mjs +61 -11
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The policy this kit claims to enforce, written by hand.
|
|
3
|
+
*
|
|
4
|
+
* Every entry here is derived from what the tool *advertises* — the stacks
|
|
5
|
+
* `detectStack` declares, the layouts each ecosystem actually uses — and never
|
|
6
|
+
* from the regexes, path lists or registries that implement the checks. That
|
|
7
|
+
* separation is the whole point: a contract generated from the implementation
|
|
8
|
+
* makes the implementation its own oracle, and an implementation that is its
|
|
9
|
+
* own oracle cannot be wrong.
|
|
10
|
+
*
|
|
11
|
+
* This is what the substring bug in the file classifier cost. `isTestFile`
|
|
12
|
+
* matched `/test/`, which does not occur in `tests/test_calc.py`, so the entire
|
|
13
|
+
* tamper guard was off for the standard pytest, Rust and RSpec layouts — and
|
|
14
|
+
* every mechanism that should have caught it (a large suite, a doc-sync gate,
|
|
15
|
+
* a nine-way CI matrix, two cold reviews, a blocking release) was sampling the
|
|
16
|
+
* same distribution the implementation was written from. Nine runs of
|
|
17
|
+
* `test/foo.test.js` do not explore `tests/test_calc.py`.
|
|
18
|
+
*
|
|
19
|
+
* Adding a stack to `detectStack` is not finished until it has a row here.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Paths the policy says are test files, and near-misses it says are not.
|
|
24
|
+
*
|
|
25
|
+
* The near-misses matter as much as the hits: a predicate that answers "yes"
|
|
26
|
+
* to everything also has no denominator.
|
|
27
|
+
*/
|
|
28
|
+
export const TEST_PATH_CASES = [
|
|
29
|
+
// Node / JavaScript
|
|
30
|
+
{ path: "test/calc.test.js", expected: true, why: "node, conventional" },
|
|
31
|
+
{ path: "src/calc.spec.ts", expected: true, why: "co-located spec" },
|
|
32
|
+
{ path: "src/__tests__/calc.js", expected: true, why: "jest convention" },
|
|
33
|
+
// Python
|
|
34
|
+
{ path: "tests/test_calc.py", expected: true, why: "pytest, repository root — the reported gap" },
|
|
35
|
+
{ path: "test/test_calc.py", expected: true, why: "pytest, singular directory" },
|
|
36
|
+
{ path: "backend/tests/test_api.py", expected: true, why: "pytest, nested" },
|
|
37
|
+
// Go
|
|
38
|
+
{ path: "internal/calc/calc_test.go", expected: true, why: "go, co-located with the code" },
|
|
39
|
+
{ path: "cmd/api/main_test.go", expected: true, why: "go, command package" },
|
|
40
|
+
// Rust
|
|
41
|
+
{ path: "tests/integration.rs", expected: true, why: "rust integration tests" },
|
|
42
|
+
// Ruby
|
|
43
|
+
{ path: "spec/models/user_spec.rb", expected: true, why: "rspec" },
|
|
44
|
+
{ path: "test/user_test.rb", expected: true, why: "minitest" },
|
|
45
|
+
// JVM / .NET / PHP
|
|
46
|
+
{ path: "src/test/java/com/x/CalcTest.java", expected: true, why: "maven layout" },
|
|
47
|
+
{ path: "tests/Unit/CalcTest.php", expected: true, why: "phpunit" },
|
|
48
|
+
// Solidity
|
|
49
|
+
{ path: "test/Token.t.sol", expected: true, why: "foundry" },
|
|
50
|
+
// Monorepo position
|
|
51
|
+
{ path: "packages/api/test/handler.test.js", expected: true, why: "monorepo package" },
|
|
52
|
+
|
|
53
|
+
// Near-misses: segment match, not substring
|
|
54
|
+
{ path: "latest/build.js", expected: false, why: "`latest` is not `test`" },
|
|
55
|
+
{ path: "contest/result.js", expected: false, why: "`contest` is not `test`" },
|
|
56
|
+
{ path: "lib/myspec/render.js", expected: false, why: "`myspec` is not `spec`" },
|
|
57
|
+
{ path: "src/attestation.js", expected: false, why: "contains 'test', is not one" },
|
|
58
|
+
{ path: "src/index.js", expected: false, why: "ordinary source" },
|
|
59
|
+
{ path: "docs/testing-guide.md", expected: false, why: "documentation about testing" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Known-bad diffs, one per guard rule, each with the finding it MUST produce.
|
|
64
|
+
*
|
|
65
|
+
* A canary that comes back clean is not a passing test — it is proof that the
|
|
66
|
+
* rule it names has stopped being reachable. `layout` is deliberately varied
|
|
67
|
+
* across ecosystems so that a canary set cannot be green by only ever handing
|
|
68
|
+
* the guard the layout the implementation was written from.
|
|
69
|
+
*/
|
|
70
|
+
export const TAMPER_CANARIES = [
|
|
71
|
+
{
|
|
72
|
+
id: "skip-injection/node",
|
|
73
|
+
file: "test/calc.test.js",
|
|
74
|
+
removed: [],
|
|
75
|
+
added: ['it.skip("adds", () => { assert.equal(add(1,2), 3); });'],
|
|
76
|
+
expect: "TEST_SKIP_INJECTION",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "vacuous/node",
|
|
80
|
+
file: "test/calc.test.js",
|
|
81
|
+
removed: ["assert.equal(add(1, 2), 3);"],
|
|
82
|
+
added: ["assert.ok(true);"],
|
|
83
|
+
expect: "VACUOUS_ASSERTION",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "commented/node",
|
|
87
|
+
file: "test/calc.test.js",
|
|
88
|
+
removed: ["assert.equal(add(1, 2), 3);"],
|
|
89
|
+
added: ["// assert.equal(add(1, 2), 3);"],
|
|
90
|
+
expect: "COMMENTED_ASSERTION",
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: "removal/node",
|
|
94
|
+
file: "test/calc.test.js",
|
|
95
|
+
removed: ["assert.equal(add(1, 2), 3);", "assert.equal(add(2, 2), 4);"],
|
|
96
|
+
added: [],
|
|
97
|
+
expect: "ASSERTION_REMOVAL",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
id: "weakening/node",
|
|
101
|
+
file: "test/calc.test.js",
|
|
102
|
+
removed: ["assert.strictEqual(add(1, 2), 3);"],
|
|
103
|
+
added: ["assert.ok(add(1, 2) !== undefined);"],
|
|
104
|
+
expect: "ASSERTION_WEAKENED",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: "expectation/node",
|
|
108
|
+
file: "test/calc.test.js",
|
|
109
|
+
removed: ["assert.equal(add(1, 2), 3);"],
|
|
110
|
+
added: ["assert.equal(add(1, 2), -1);"],
|
|
111
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
112
|
+
},
|
|
113
|
+
// The same attacks, in the layouts the classifier used to be blind to.
|
|
114
|
+
// These are the canaries that were silent when the classifier matched a
|
|
115
|
+
// substring instead of a path segment.
|
|
116
|
+
{
|
|
117
|
+
id: "expectation/pytest-root",
|
|
118
|
+
file: "tests/test_calc.py",
|
|
119
|
+
removed: [" self.assertEqual(add(1, 2), 3)"],
|
|
120
|
+
added: [" self.assertEqual(add(1, 2), -1)"],
|
|
121
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "skip-injection/pytest-root",
|
|
125
|
+
file: "tests/test_calc.py",
|
|
126
|
+
removed: [],
|
|
127
|
+
added: ["@pytest.mark.skip", "def test_add():"],
|
|
128
|
+
expect: "TEST_SKIP_INJECTION",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
id: "expectation/rust",
|
|
132
|
+
file: "tests/integration.rs",
|
|
133
|
+
removed: [" assert_eq!(add(1, 2), 3);"],
|
|
134
|
+
added: [" assert_eq!(add(1, 2), -1);"],
|
|
135
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
id: "expectation/go-colocated",
|
|
139
|
+
file: "internal/calc/calc_test.go",
|
|
140
|
+
removed: ['\t\tt.Errorf("got %d want %d", got, 3)'],
|
|
141
|
+
added: ['\t\tt.Errorf("got %d want %d", got, 999)'],
|
|
142
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
id: "removal/monorepo",
|
|
146
|
+
file: "packages/api/test/handler.test.js",
|
|
147
|
+
removed: ["expect(handler(req)).toBe(200);", "expect(handler(bad)).toBe(400);"],
|
|
148
|
+
added: [],
|
|
149
|
+
expect: "ASSERTION_REMOVAL",
|
|
150
|
+
},
|
|
151
|
+
// Dialects the guard was measured silent on. `assertEqual` matched only
|
|
152
|
+
// because the pattern's optional dot and case-insensitive flag happened to
|
|
153
|
+
// line up; `assertEquals` — one letter longer — did not, and neither did
|
|
154
|
+
// RSpec's `.to eq(`, PHPUnit's `$this->assertSame`, Minitest's
|
|
155
|
+
// `assert_equal` or XCTest's `XCTAssertEqual`. Every one of them returned
|
|
156
|
+
// PASS with a non-zero denominator, which is the exact shape this file
|
|
157
|
+
// exists to reject, one level down inside the mechanism built to catch it.
|
|
158
|
+
{
|
|
159
|
+
id: "expectation/junit",
|
|
160
|
+
file: "src/test/java/com/x/CalcTest.java",
|
|
161
|
+
removed: [" assertEquals(3, calc.add(1, 2));"],
|
|
162
|
+
added: [" assertEquals(-1, calc.add(1, 2));"],
|
|
163
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: "expectation/rspec",
|
|
167
|
+
file: "spec/models/user_spec.rb",
|
|
168
|
+
removed: [" expect(user.age).to eq(30)"],
|
|
169
|
+
added: [" expect(user.age).to eq(-1)"],
|
|
170
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "expectation/phpunit",
|
|
174
|
+
file: "tests/Unit/CalcTest.php",
|
|
175
|
+
removed: [" $this->assertSame(3, $c->add(1, 2));"],
|
|
176
|
+
added: [" $this->assertSame(-1, $c->add(1, 2));"],
|
|
177
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "expectation/minitest",
|
|
181
|
+
file: "test/user_test.rb",
|
|
182
|
+
removed: [" assert_equal(3, add(1, 2))"],
|
|
183
|
+
added: [" assert_equal(-1, add(1, 2))"],
|
|
184
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: "expectation/xctest",
|
|
188
|
+
file: "Tests/CalcTests/CalcTests.swift",
|
|
189
|
+
removed: [" XCTAssertEqual(add(1, 2), 3)"],
|
|
190
|
+
added: [" XCTAssertEqual(add(1, 2), -1)"],
|
|
191
|
+
expect: "ASSERTION_EXPECTATION_CHANGED",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "weakening/junit",
|
|
195
|
+
file: "src/test/java/com/x/CalcTest.java",
|
|
196
|
+
removed: [" assertEquals(3, calc.add(1, 2));"],
|
|
197
|
+
added: [" assertTrue(calc.add(1, 2) != null);"],
|
|
198
|
+
expect: "ASSERTION_WEAKENED",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "removal/phpunit",
|
|
202
|
+
file: "tests/Unit/CalcTest.php",
|
|
203
|
+
removed: [" $this->assertSame(3, $c->add(1, 2));", " $this->assertSame(4, $c->add(2, 2));"],
|
|
204
|
+
added: [],
|
|
205
|
+
expect: "ASSERTION_REMOVAL",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "vacuous/xctest",
|
|
209
|
+
file: "Tests/CalcTests/CalcTests.swift",
|
|
210
|
+
removed: [" XCTAssertEqual(add(1, 2), 3)"],
|
|
211
|
+
added: [" XCTAssertTrue(true)"],
|
|
212
|
+
expect: "VACUOUS_ASSERTION",
|
|
213
|
+
},
|
|
214
|
+
// Skip injection is the same blindness in the other five ecosystems: a
|
|
215
|
+
// suite that never runs cannot fail, and `@Disabled` is as effective as
|
|
216
|
+
// `it.skip` at making that happen.
|
|
217
|
+
{
|
|
218
|
+
id: "skip-injection/junit",
|
|
219
|
+
file: "src/test/java/com/x/CalcTest.java",
|
|
220
|
+
removed: [],
|
|
221
|
+
added: [" @Disabled(\"flaky\")", " void addsTwoNumbers() {"],
|
|
222
|
+
expect: "TEST_SKIP_INJECTION",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
id: "skip-injection/rspec",
|
|
226
|
+
file: "spec/models/user_spec.rb",
|
|
227
|
+
removed: [],
|
|
228
|
+
added: [" xit \"computes the age\" do"],
|
|
229
|
+
expect: "TEST_SKIP_INJECTION",
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
id: "skip-injection/phpunit",
|
|
233
|
+
file: "tests/Unit/CalcTest.php",
|
|
234
|
+
removed: [],
|
|
235
|
+
added: [" $this->markTestSkipped(\"later\");"],
|
|
236
|
+
expect: "TEST_SKIP_INJECTION",
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "skip-injection/xctest",
|
|
240
|
+
file: "Tests/CalcTests/CalcTests.swift",
|
|
241
|
+
removed: [],
|
|
242
|
+
added: [" throw XCTSkip(\"not now\")"],
|
|
243
|
+
expect: "TEST_SKIP_INJECTION",
|
|
244
|
+
},
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Mutants of the applicability predicate.
|
|
250
|
+
*
|
|
251
|
+
* Each one must kill at least one canary. A mutant that survives means no
|
|
252
|
+
* canary ever required the guard to *activate* — the suite would stay green if
|
|
253
|
+
* the guard silently stopped looking, which is precisely the defect.
|
|
254
|
+
*
|
|
255
|
+
* These are hand-written rather than generated: the original bug was not an
|
|
256
|
+
* untested branch, it was a branch nobody wrote, and no mutation operator
|
|
257
|
+
* invents the case the code never handled.
|
|
258
|
+
*/
|
|
259
|
+
export const PREDICATE_MUTANTS = [
|
|
260
|
+
{ id: "alwaysFalse", fn: () => false, why: "the guard looks at nothing" },
|
|
261
|
+
{ id: "rootBlind", fn: (p) => String(p).includes("/test/"), why: "the original substring bug" },
|
|
262
|
+
{ id: "nodeOnly", fn: (p) => /\.(test|spec)\.[jt]sx?$/.test(String(p)), why: "only the layout the code was written from" },
|
|
263
|
+
{ id: "caseSensitive", fn: (p) => String(p) === String(p).toLowerCase() && /(^|\/)tests?\//.test(String(p)), why: "case and separator drift" },
|
|
264
|
+
];
|
|
265
|
+
|
|
266
|
+
/** Runner outputs that state zero collected tests, per ecosystem. */
|
|
267
|
+
export const EMPTY_RUN_CANARIES = [
|
|
268
|
+
{ id: "pytest", output: "collected 0 items\n\nno tests ran in 0.01s" },
|
|
269
|
+
{ id: "jest", output: "No tests found, exiting with code 0" },
|
|
270
|
+
{ id: "vitest", output: "No test files found, exiting with code 0" },
|
|
271
|
+
{ id: "cargo", output: "running 0 tests\ntest result: ok. 0 passed" },
|
|
272
|
+
{ id: "mocha", output: " 0 passing (1ms)" },
|
|
273
|
+
{ id: "go", output: "? example.com/app\t[no test files]" },
|
|
274
|
+
{ id: "surefire", output: "Tests run: 0, Failures: 0, Errors: 0, Skipped: 0" },
|
|
275
|
+
{ id: "gradle", output: "> Task :test NO-SOURCE" },
|
|
276
|
+
{ id: "phpunit", output: "No tests executed!" },
|
|
277
|
+
{ id: "rspec", output: "0 examples, 0 failures" },
|
|
278
|
+
{ id: "dotnet", output: "Total tests: 0" },
|
|
279
|
+
{ id: "xctest", output: "Executed 0 tests" },
|
|
280
|
+
{ id: "ctest", output: "No tests were found!!!" },
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
/** Paths the policy says an agent must never modify without an override. */
|
|
284
|
+
export const SCOPE_CANARIES = [
|
|
285
|
+
{ path: ".github/workflows/ci.yml", rule: "deny", why: "runs with repo credentials" },
|
|
286
|
+
{ path: ".gitlab-ci.yml", rule: "deny", why: "same, another forge" },
|
|
287
|
+
{ path: "Jenkinsfile", rule: "deny", why: "same, another forge" },
|
|
288
|
+
{ path: ".envrc", rule: "deny", why: "direnv executes it on cd" },
|
|
289
|
+
{ path: "package-lock.json", rule: "protect", why: "decides which code installs" },
|
|
290
|
+
{ path: "Cargo.lock", rule: "protect", why: "same, another ecosystem" },
|
|
291
|
+
{ path: "conftest.py", rule: "protect", why: "runs before every pytest collection" },
|
|
292
|
+
{ path: "jest.config.js", rule: "protect", why: "decides which tests run" },
|
|
293
|
+
{ path: "CODEOWNERS", rule: "protect", why: "decides who must approve" },
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Edits that must produce no finding at all.
|
|
298
|
+
*
|
|
299
|
+
* A guard that answers "yes" to everything has no more discrimination than
|
|
300
|
+
* one that answers "no" to everything, and it is worse in practice: the
|
|
301
|
+
* operator learns to pass the override without reading it, and the day it
|
|
302
|
+
* reports something real, nobody looks. Every entry here is an edit an
|
|
303
|
+
* honest agent makes constantly.
|
|
304
|
+
*
|
|
305
|
+
* The first two are not hypothetical. `//` begins with a division sign, so a
|
|
306
|
+
* comment line matched the operator-continuation test and folded itself into
|
|
307
|
+
* the assertion above it — which meant *adding* an assertion next to a
|
|
308
|
+
* comment was reported as rewriting an expectation. Python was immune,
|
|
309
|
+
* because `#` is not an operator, so the fixtures this project was written
|
|
310
|
+
* from never showed it.
|
|
311
|
+
*/
|
|
312
|
+
export const INNOCENT_EDITS = [
|
|
313
|
+
{
|
|
314
|
+
id: "add-assertion-beside-comment/node",
|
|
315
|
+
file: "test/calc.test.js",
|
|
316
|
+
context: "// arithmetic",
|
|
317
|
+
removed: [" assert.equal(add(1, 2), 3);"],
|
|
318
|
+
added: [" assert.equal(add(1, 2), 3);", " assert.equal(add(2, 2), 4);"],
|
|
319
|
+
why: "adding a test is the behaviour the gate exists to encourage",
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: "add-assertion-beside-comment/rust",
|
|
323
|
+
file: "tests/integration.rs",
|
|
324
|
+
context: "// arithmetic",
|
|
325
|
+
removed: [" assert!(add(1, 2) == 3);"],
|
|
326
|
+
added: [" assert!(add(1, 2) == 3);", " assert!(add(2, 2) == 4);"],
|
|
327
|
+
why: "same edit, same comment syntax, another ecosystem",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
id: "reorder-assertions/pytest",
|
|
331
|
+
file: "tests/test_calc.py",
|
|
332
|
+
context: "# arithmetic",
|
|
333
|
+
removed: [" assert a() == 1", " assert b() == 2"],
|
|
334
|
+
added: [" assert b() == 2", " assert a() == 1"],
|
|
335
|
+
why: "moving an assertion changes nothing it checks",
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: "reindent/pytest",
|
|
339
|
+
file: "tests/test_calc.py",
|
|
340
|
+
context: "# arithmetic",
|
|
341
|
+
removed: [" assert add(1, 2) == 3"],
|
|
342
|
+
added: [" assert add(1, 2) == 3"],
|
|
343
|
+
why: "a formatter run must not read as tampering",
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: "reword-message/rspec",
|
|
347
|
+
file: "spec/models/user_spec.rb",
|
|
348
|
+
context: "# age",
|
|
349
|
+
removed: [' expect(u.age).to eq(30), "wrong"'],
|
|
350
|
+
added: [' expect(u.age).to eq(30), "unexpected age"'],
|
|
351
|
+
why: "RSpec writes the message outside the call, where an argument check cannot see it",
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: "reword-message/minitest",
|
|
355
|
+
file: "test/user_test.rb",
|
|
356
|
+
context: "# age",
|
|
357
|
+
removed: [' assert_equal 3, add(1, 2), "wrong"'],
|
|
358
|
+
added: [' assert_equal 3, add(1, 2), "unexpected sum"'],
|
|
359
|
+
why: "same, with the message in argument position and no parentheses",
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: "reword-message/node",
|
|
363
|
+
file: "test/calc.test.js",
|
|
364
|
+
context: "// arithmetic",
|
|
365
|
+
removed: [' assert.equal(add(1, 2), 3, "wrong");'],
|
|
366
|
+
added: [' assert.equal(add(1, 2), 3, "unexpected sum");'],
|
|
367
|
+
why: "rewording a failure message says nothing about what is checked",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
id: "rename-test/junit",
|
|
371
|
+
file: "src/test/java/com/x/CalcTest.java",
|
|
372
|
+
context: "// arithmetic",
|
|
373
|
+
removed: [" void addsNumbers() {"],
|
|
374
|
+
added: [" void addsTwoNumbers() {"],
|
|
375
|
+
why: "a test name is not an expectation",
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: "change-import/node",
|
|
379
|
+
file: "test/calc.test.js",
|
|
380
|
+
context: "// setup",
|
|
381
|
+
removed: ['const calc = require("./calc");'],
|
|
382
|
+
added: ['const calc = require("../src/calc");'],
|
|
383
|
+
why: "`require(` is an import, not a claim — the loose net must not read it as one",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
id: "rename-helper/node",
|
|
387
|
+
file: "test/calc.test.js",
|
|
388
|
+
context: "// setup",
|
|
389
|
+
removed: [" const shouldRetry = false;"],
|
|
390
|
+
added: [" const shouldRetry = true;"],
|
|
391
|
+
why: "`shouldRetry` and `expected` are identifiers; reading them as assertions makes the dialect warning worthless",
|
|
392
|
+
},
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Dialects the guard genuinely cannot parse, which it must say out loud.
|
|
397
|
+
*
|
|
398
|
+
* This is the case the whole denominator exists for. A JUnit diff used to
|
|
399
|
+
* return `PASS` with `inputsSeen: 1` while not one assertion in it had been
|
|
400
|
+
* recognised — a verdict indistinguishable from a clean Node suite. Coverage
|
|
401
|
+
* will always end somewhere; what must never happen again is that the edge
|
|
402
|
+
* is silent.
|
|
403
|
+
*/
|
|
404
|
+
export const UNREADABLE_DIALECTS = [
|
|
405
|
+
{
|
|
406
|
+
id: "hspec",
|
|
407
|
+
file: "tests/CalcSpec.hs",
|
|
408
|
+
context: "-- arithmetic",
|
|
409
|
+
removed: [" calc `shouldBe` 3"],
|
|
410
|
+
added: [" calc `shouldBe` (-1)"],
|
|
411
|
+
why: "an infix assertion with no parentheses anywhere near it",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: "googletest",
|
|
415
|
+
file: "tests/calc_test.cc",
|
|
416
|
+
context: "// arithmetic",
|
|
417
|
+
removed: [" EXPECT_EQ(add(1, 2), 3);"],
|
|
418
|
+
added: [" EXPECT_EQ(add(1, 2), -1);"],
|
|
419
|
+
why: "a macro dialect the pattern list does not cover",
|
|
420
|
+
},
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Import forms the package-integrity extractor must find.
|
|
425
|
+
*
|
|
426
|
+
* The first pass of that check reported "every relative import resolves" on
|
|
427
|
+
* a package whose newest script could not start: its matcher was written as
|
|
428
|
+
* `[^;\n]*?from`, and the import it needed to see spanned several lines. The
|
|
429
|
+
* check was confidently green about a file that threw ERR_MODULE_NOT_FOUND
|
|
430
|
+
* on load — the same failure the tool exists to prevent, committed by the
|
|
431
|
+
* tool's own integrity check.
|
|
432
|
+
*
|
|
433
|
+
* Every entry is a source fragment and the specifiers it must yield.
|
|
434
|
+
*/
|
|
435
|
+
export const IMPORT_EXTRACTION_CASES = [
|
|
436
|
+
{ id: "single-line named", src: 'import { a, b } from "./one.mjs";', expect: ["./one.mjs"] },
|
|
437
|
+
{
|
|
438
|
+
id: "multi-line named",
|
|
439
|
+
src: 'import {\n a,\n b,\n} from "./two.mjs";',
|
|
440
|
+
expect: ["./two.mjs"],
|
|
441
|
+
why: "the form the first version of the check could not see",
|
|
442
|
+
},
|
|
443
|
+
{ id: "default", src: 'import three from "./three.mjs";', expect: ["./three.mjs"] },
|
|
444
|
+
{ id: "namespace", src: 'import * as four from "./four.mjs";', expect: ["./four.mjs"] },
|
|
445
|
+
{ id: "side-effect only", src: 'import "./five.mjs";', expect: ["./five.mjs"] },
|
|
446
|
+
{ id: "re-export", src: 'export { six } from "./six.mjs";', expect: ["./six.mjs"] },
|
|
447
|
+
{ id: "re-export all", src: 'export * from "./seven.mjs";', expect: ["./seven.mjs"] },
|
|
448
|
+
{ id: "dynamic", src: 'const m = await import("./eight.mjs");', expect: ["./eight.mjs"] },
|
|
449
|
+
{ id: "require", src: 'const nine = require("./nine.js");', expect: ["./nine.js"] },
|
|
450
|
+
{ id: "single quotes", src: "import ten from './ten.mjs';", expect: ["./ten.mjs"] },
|
|
451
|
+
{
|
|
452
|
+
id: "bare specifiers are not files",
|
|
453
|
+
src: 'import { readFileSync } from "node:fs";\nimport x from "some-package";',
|
|
454
|
+
expect: ["node:fs", "some-package"],
|
|
455
|
+
why: "found, then ignored by the resolver — never resolved against the tarball",
|
|
456
|
+
},
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
// Cases that exercise the mask rather than the matcher. Appended separately
|
|
460
|
+
// because each one is a fixture *about* fixtures: the check has to tell a
|
|
461
|
+
// module reference from a picture of one.
|
|
462
|
+
IMPORT_EXTRACTION_CASES.push(
|
|
463
|
+
{
|
|
464
|
+
id: "regex holding a quote, then a real import",
|
|
465
|
+
src: 'const q = /["\']/;\nimport x from "./after-regex.mjs";',
|
|
466
|
+
expect: ["./after-regex.mjs"],
|
|
467
|
+
why: "the phantom string this file's own subject matter opens",
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: "an import quoted inside a string",
|
|
471
|
+
src: 'const example = \'import a from "./not-real.mjs";\';',
|
|
472
|
+
expect: [],
|
|
473
|
+
why: "a picture of an import is not an import",
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: "an import written in a comment",
|
|
477
|
+
src: '// import a from "./commented.mjs";\nconst x = 1;',
|
|
478
|
+
expect: [],
|
|
479
|
+
why: "same, in the other place examples live",
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Runs that stated a count, which must never be read as empty.
|
|
485
|
+
*
|
|
486
|
+
* The floor was written to be one-sided — only a *stated* zero fails — and
|
|
487
|
+
* then a phrase was allowed to outrank a statement. A healthy 190-test TAP
|
|
488
|
+
* suite whose one skipped fixture printed `# SKIP no tests found` was
|
|
489
|
+
* rejected as empty and attributed to Jest, in a repository that does not
|
|
490
|
+
* use Jest. A false red on a correct repository is how a user learns the
|
|
491
|
+
* gate is broken and turns it off.
|
|
492
|
+
*/
|
|
493
|
+
export const COUNTED_RUN_CANARIES = [
|
|
494
|
+
{
|
|
495
|
+
id: "tap with a skip message",
|
|
496
|
+
output: "TAP version 13\n# Subtest: performance\n # SKIP no tests found\nok 1 - performance # SKIP\n1..191\n# tests 191\n# pass 190\n# skip 1",
|
|
497
|
+
atLeast: 1,
|
|
498
|
+
why: "`no tests found` inside a skip comment is not a statement about the run",
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
id: "pytest mentioning an empty module",
|
|
502
|
+
output: "collected 12 items\n\ntests/test_a.py ............\n\n12 passed in 0.3s",
|
|
503
|
+
atLeast: 1,
|
|
504
|
+
why: "a stated count is present and must win",
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
id: "go, verbose, two tests",
|
|
508
|
+
output: "--- PASS: TestAdd (0.00s)\n--- PASS: TestSub (0.00s)\nok \texample.com/lib\t0.004s",
|
|
509
|
+
atLeast: 1,
|
|
510
|
+
why: "Go's own `ok <package>` line, which a bare `^ok\\s` confused with TAP's `ok 1 - name`",
|
|
511
|
+
},
|
|
512
|
+
];
|
|
@@ -71,8 +71,15 @@ const EXPLICIT_ZERO = [
|
|
|
71
71
|
|
|
72
72
|
/** Go prints this per package that has no test files at all. */
|
|
73
73
|
const GO_NO_TEST_FILES = /\[no test files\]/;
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Any sign that a Go package did run tests.
|
|
76
|
+
*
|
|
77
|
+
* The negative lookahead is what separates Go from TAP. `ok 1 - performance`
|
|
78
|
+
* is a TAP result line and `ok example.com/lib 0.004s` is a Go package
|
|
79
|
+
* summary, and a bare `^ok\s` matched both — so a 190-test TAP suite was
|
|
80
|
+
* classified as Go and reported as having stated no count at all.
|
|
81
|
+
*/
|
|
82
|
+
const GO_RAN_SOMETHING = /^(?:(?:ok|FAIL)\s+(?!\d+\s)\S+|---\s+(?:PASS|FAIL|SKIP):?\s)/m;
|
|
76
83
|
|
|
77
84
|
/**
|
|
78
85
|
* Read a collected-test count out of a runner's output.
|
|
@@ -87,8 +94,20 @@ export function parseCollectedTests(stdout = "", stderr = "") {
|
|
|
87
94
|
const text = `${stdout || ""}\n${stderr || ""}`;
|
|
88
95
|
if (!text.trim()) return { count: null, runner: null };
|
|
89
96
|
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
// A stated count wins over a phrase that merely resembles one.
|
|
98
|
+
//
|
|
99
|
+
// `EXPLICIT_ZERO` used to be consulted first, so any output containing the
|
|
100
|
+
// words "no tests found" was read as a zero — including a healthy TAP run
|
|
101
|
+
// of 190 passing tests whose one skipped fixture printed
|
|
102
|
+
// `# SKIP no tests found`. The gate rejected the suite as empty and named
|
|
103
|
+
// Jest as the runner, in a repository that does not use Jest. A phrase
|
|
104
|
+
// appears anywhere in a stream; a count is stated deliberately, so the
|
|
105
|
+
// count is the better witness and has to be asked first.
|
|
106
|
+
for (const rule of COUNT_PATTERNS) {
|
|
107
|
+
const m = rule.re.exec(text);
|
|
108
|
+
if (!m) continue;
|
|
109
|
+
const n = Number(m[1]);
|
|
110
|
+
if (Number.isFinite(n)) return { count: n, runner: rule.name };
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
// Go states absence per package rather than as a count, so it needs its own
|
|
@@ -104,11 +123,9 @@ export function parseCollectedTests(stdout = "", stderr = "") {
|
|
|
104
123
|
return { count: perTest && perTest.length > 0 ? perTest.length : null, runner: "go" };
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
const n = Number(m[1]);
|
|
111
|
-
if (Number.isFinite(n)) return { count: n, runner: rule.name };
|
|
126
|
+
// Only now: no runner stated a number, so a declared absence is all there is.
|
|
127
|
+
for (const rule of EXPLICIT_ZERO) {
|
|
128
|
+
if (rule.re.test(text)) return { count: 0, runner: rule.name };
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
return { count: null, runner: null };
|