@jjchill/probity-rules 0.1.0 → 0.2.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/CHANGELOG.md +53 -0
- package/README.md +58 -11
- package/dist/presets/kmp.d.ts.map +1 -1
- package/dist/presets/kmp.js +6 -7
- package/dist/presets/kmp.js.map +1 -1
- package/dist/presets/kotlin.d.ts.map +1 -1
- package/dist/presets/kotlin.js +9 -10
- package/dist/presets/kotlin.js.map +1 -1
- package/dist/rules/gates.d.ts +25 -8
- package/dist/rules/gates.d.ts.map +1 -1
- package/dist/rules/gates.js +33 -12
- package/dist/rules/gates.js.map +1 -1
- package/dist/rules/kotlin.d.ts +43 -32
- package/dist/rules/kotlin.d.ts.map +1 -1
- package/dist/rules/kotlin.js +521 -58
- package/dist/rules/kotlin.js.map +1 -1
- package/package.json +12 -13
- package/probity.config.kmp.ts +5 -5
- package/probity.config.kotlin.ts +1 -2
- package/probity.config.swift.ts +1 -2
- package/probity.config.ts +1 -4
package/dist/rules/kotlin.js
CHANGED
|
@@ -27,11 +27,18 @@ export const KOTLIN_INFRASTRUCTURE_IMPORTS = /import\s+(?:com\.amazonaws|aws\.sd
|
|
|
27
27
|
*/
|
|
28
28
|
export const MOCKING_LIBRARY_IMPORTS = /import\s+(?:io\.mockk|org\.mockito|org\.powermock)\./;
|
|
29
29
|
/**
|
|
30
|
-
* Matches Gradle
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* Matches a Gradle invocation of a verification task — `test`,
|
|
31
|
+
* flavored/module-qualified `test...Test` tasks (`./gradlew
|
|
32
|
+
* :mysudo:testDevDebugUnitTest`), `allTests`, `jvmTest`, `build`, or
|
|
33
|
+
* `check` (Gradle's `build` and `check` both run the test tasks they
|
|
34
|
+
* depend on, without the literal word "test") — for the commit-on-green
|
|
35
|
+
* `requireGreenTestRun` gate. Matches `gradlew`/`gradle` anywhere in a
|
|
36
|
+
* piped (`./gradlew build | grep ...`) or compound
|
|
37
|
+
* (`foo; ./gradlew build`) command string, not only at its start.
|
|
38
|
+
* Deliberately excludes unrelated tasks such as `assemble`, which
|
|
39
|
+
* builds outputs without running verification.
|
|
33
40
|
*/
|
|
34
|
-
export const GRADLE_TEST_COMMAND =
|
|
41
|
+
export const GRADLE_TEST_COMMAND = /\bgradlew?\b[^\n;&|]*(?:\b(?:build|check|test|allTests|jvmTest)\b|\btest[A-Za-z0-9]*Test\b)/;
|
|
35
42
|
const STATIC_MOCK_PATTERNS = [
|
|
36
43
|
{ label: 'Mockito.mockStatic()', pattern: /\bmockStatic\s*[(<]/g },
|
|
37
44
|
{ label: 'mockkStatic()', pattern: /\bmockkStatic\s*\(/g },
|
|
@@ -125,10 +132,17 @@ const KOTLIN_TEST_PATTERNS = [
|
|
|
125
132
|
{
|
|
126
133
|
rule: {
|
|
127
134
|
kind: 'function_declaration',
|
|
128
|
-
|
|
135
|
+
has: {
|
|
136
|
+
kind: 'modifiers',
|
|
137
|
+
has: {
|
|
138
|
+
kind: 'annotation',
|
|
139
|
+
regex: '^@(Test|ParameterizedTest|RepeatedTest|TestFactory)(?:\\b|\\s*\\()',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
129
142
|
},
|
|
130
143
|
},
|
|
131
144
|
];
|
|
145
|
+
const KOTLIN_TEST_FILE_PATTERN = /(?:^|\/)src\/(?:test|[A-Za-z0-9]+Test)\/(?:java|kotlin)\//;
|
|
132
146
|
let astGrep;
|
|
133
147
|
function loadKotlinAstGrep() {
|
|
134
148
|
if (astGrep !== undefined)
|
|
@@ -141,32 +155,272 @@ function loadKotlinAstGrep() {
|
|
|
141
155
|
astGrep = napi;
|
|
142
156
|
}
|
|
143
157
|
catch {
|
|
144
|
-
// Optional
|
|
158
|
+
// Optional dependencies unavailable on this install or platform.
|
|
145
159
|
astGrep = null;
|
|
146
160
|
}
|
|
147
161
|
return astGrep;
|
|
148
162
|
}
|
|
149
|
-
function
|
|
163
|
+
function findKotlinTestNodes(napi, code, patterns) {
|
|
150
164
|
const root = napi.parse('kotlin', code).root();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return
|
|
165
|
+
return patterns.flatMap((pattern) => root.findAll(pattern));
|
|
166
|
+
}
|
|
167
|
+
function kotlinTestNodes(napi, code, patterns) {
|
|
168
|
+
return findKotlinTestNodes(napi, code, patterns).map((node) => node.text());
|
|
169
|
+
}
|
|
170
|
+
function nodeIsInside(node, insertion) {
|
|
171
|
+
const range = node.range();
|
|
172
|
+
return (range.start.index >= insertion.startByte &&
|
|
173
|
+
range.end.index <= insertion.endByte);
|
|
174
|
+
}
|
|
175
|
+
function addedTestNode(beforeTests, afterTests, insertion) {
|
|
176
|
+
const unmatched = [...afterTests];
|
|
177
|
+
for (const beforeTest of beforeTests) {
|
|
178
|
+
const index = unmatched.findIndex((node) => node.text() === beforeTest);
|
|
179
|
+
if (index === -1)
|
|
180
|
+
return null;
|
|
181
|
+
unmatched.splice(index, 1);
|
|
182
|
+
}
|
|
183
|
+
if (unmatched.length !== 1)
|
|
184
|
+
return null;
|
|
185
|
+
const added = unmatched[0];
|
|
186
|
+
if (beforeTests.includes(added.text()))
|
|
187
|
+
return null;
|
|
188
|
+
return nodeIsInside(added, insertion) ? added : null;
|
|
189
|
+
}
|
|
190
|
+
function singleInsertion(before, after) {
|
|
191
|
+
if (after.length <= before.length)
|
|
192
|
+
return null;
|
|
193
|
+
let prefix = 0;
|
|
194
|
+
while (prefix < before.length && before[prefix] === after[prefix]) {
|
|
195
|
+
prefix += 1;
|
|
196
|
+
}
|
|
197
|
+
let beforeSuffix = before.length;
|
|
198
|
+
let afterSuffix = after.length;
|
|
199
|
+
while (beforeSuffix > prefix &&
|
|
200
|
+
before[beforeSuffix - 1] === after[afterSuffix - 1]) {
|
|
201
|
+
beforeSuffix -= 1;
|
|
202
|
+
afterSuffix -= 1;
|
|
203
|
+
}
|
|
204
|
+
if (beforeSuffix !== prefix)
|
|
205
|
+
return null;
|
|
206
|
+
const text = after.slice(prefix, afterSuffix);
|
|
207
|
+
const startByte = Buffer.byteLength(after.slice(0, prefix), 'utf8');
|
|
208
|
+
return {
|
|
209
|
+
text,
|
|
210
|
+
startByte,
|
|
211
|
+
endByte: startByte + Buffer.byteLength(text, 'utf8'),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function isRunnableTestNode(node) {
|
|
215
|
+
const ancestors = node.ancestors();
|
|
216
|
+
const owner = ancestors[1];
|
|
217
|
+
if (ancestors[0]?.kind() !== 'class_body' ||
|
|
218
|
+
owner?.kind() !== 'class_declaration' ||
|
|
219
|
+
ancestors[2]?.kind() !== 'source_file' ||
|
|
220
|
+
!owner.children().some((child) => child.kind() === 'class')) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const bodyStart = owner.text().indexOf('{');
|
|
224
|
+
const header = bodyStart === -1 ? owner.text() : owner.text().slice(0, bodyStart);
|
|
225
|
+
return countMatches(header, DISABLING_TEST_CONTROL) === 0;
|
|
226
|
+
}
|
|
227
|
+
function onlyCommentsAndWhitespace(code) {
|
|
228
|
+
return /^\s*$/.test(code.replace(/\/\/[^\r\n]*\r?\n|\/\*[\s\S]*?\*\//g, ''));
|
|
229
|
+
}
|
|
230
|
+
function identifiersIn(tests) {
|
|
231
|
+
return new Set(tests.flatMap((test) => [...test.matchAll(/`([^`]+)`|\b([A-Za-z_]\w*)\b/g)].map((match) => match[1] ?? match[2])));
|
|
232
|
+
}
|
|
233
|
+
function safeExistingFileInsertion(napi, before, after, addedTest, beforeTests, patterns) {
|
|
234
|
+
const insertion = singleInsertion(before, after);
|
|
235
|
+
if (!insertion || !nodeIsInside(addedTest, insertion))
|
|
236
|
+
return false;
|
|
237
|
+
const addedText = addedTest.text();
|
|
238
|
+
const addedAt = insertion.text.indexOf(addedText);
|
|
239
|
+
if (addedAt !== -1) {
|
|
240
|
+
const remainder = insertion.text.slice(0, addedAt) +
|
|
241
|
+
insertion.text.slice(addedAt + addedText.length);
|
|
242
|
+
if (onlyCommentsAndWhitespace(remainder))
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
const trimmed = insertion.text.trim();
|
|
246
|
+
const priorIdentifiers = identifiersIn(beforeTests);
|
|
247
|
+
const root = napi.parse('kotlin', after).root();
|
|
248
|
+
return root.findAll({ rule: { kind: 'class_declaration' } }).some((node) => {
|
|
249
|
+
if (node.text().trim() !== trimmed)
|
|
250
|
+
return false;
|
|
251
|
+
const name = nodeName(node);
|
|
252
|
+
if (name && priorIdentifiers.has(name))
|
|
253
|
+
return false;
|
|
254
|
+
const tests = patterns.flatMap((pattern) => node.findAll(pattern));
|
|
255
|
+
const functions = node.findAll({ rule: { kind: 'function_declaration' } });
|
|
256
|
+
return (tests.length === 1 &&
|
|
257
|
+
tests[0].range().start.index === addedTest.range().start.index &&
|
|
258
|
+
tests[0].range().end.index === addedTest.range().end.index &&
|
|
259
|
+
isRunnableTestNode(addedTest) &&
|
|
260
|
+
functions.length === 1);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function safeNewTestFile(napi, code, addedTest) {
|
|
264
|
+
if (!isRunnableTestNode(addedTest))
|
|
265
|
+
return false;
|
|
266
|
+
const root = napi.parse('kotlin', code).root();
|
|
267
|
+
return (root.findAll({ rule: { kind: 'class_declaration' } }).length === 1 &&
|
|
268
|
+
root.findAll({ rule: { kind: 'function_declaration' } }).length === 1);
|
|
269
|
+
}
|
|
270
|
+
const DISABLING_TEST_CONTROL = /@(?:[A-Za-z_]\w*\.)*(?:Ignore|Disabled)\b|\benabled\s*=\s*false\b/g;
|
|
271
|
+
function countMatches(code, pattern) {
|
|
272
|
+
pattern.lastIndex = 0;
|
|
273
|
+
return [...code.matchAll(pattern)].length;
|
|
274
|
+
}
|
|
275
|
+
function introducesDisablingControl(before, after) {
|
|
276
|
+
return (countMatches(after, DISABLING_TEST_CONTROL) >
|
|
277
|
+
countMatches(before, DISABLING_TEST_CONTROL));
|
|
278
|
+
}
|
|
279
|
+
function nodeName(node) {
|
|
280
|
+
const names = node
|
|
281
|
+
.children()
|
|
282
|
+
.filter((child) => child.kind() === 'simple_identifier' || child.kind() === 'type_identifier');
|
|
283
|
+
const direct = names.at(-1)?.text();
|
|
284
|
+
if (direct)
|
|
285
|
+
return direct.replace(/^`|`$/g, '');
|
|
286
|
+
const nested = node.findAll({ rule: { kind: 'simple_identifier' } })[0];
|
|
287
|
+
return nested ? nested.text().replace(/^`|`$/g, '') : null;
|
|
288
|
+
}
|
|
289
|
+
function enclosingScope(node) {
|
|
290
|
+
const container = node
|
|
291
|
+
.ancestors()
|
|
292
|
+
.find((ancestor) => ancestor.kind() === 'class_declaration' ||
|
|
293
|
+
ancestor.kind() === 'object_declaration');
|
|
294
|
+
return container ? (nodeName(container) ?? '<anonymous>') : '<top-level>';
|
|
295
|
+
}
|
|
296
|
+
function testScopes(napi, code, patterns) {
|
|
297
|
+
const root = napi.parse('kotlin', code).root();
|
|
298
|
+
const scopes = new Set();
|
|
299
|
+
for (const pattern of patterns) {
|
|
300
|
+
for (const node of root.findAll(pattern))
|
|
301
|
+
scopes.add(enclosingScope(node));
|
|
302
|
+
}
|
|
303
|
+
return scopes;
|
|
304
|
+
}
|
|
305
|
+
function containerHeaders(napi, code) {
|
|
306
|
+
const root = napi.parse('kotlin', code).root();
|
|
307
|
+
const headers = new Map();
|
|
308
|
+
for (const kind of ['class_declaration', 'object_declaration']) {
|
|
309
|
+
for (const node of root.findAll({ rule: { kind } })) {
|
|
310
|
+
const name = nodeName(node);
|
|
311
|
+
if (!name)
|
|
312
|
+
continue;
|
|
313
|
+
const bodyStart = node.text().indexOf('{');
|
|
314
|
+
headers.set(name, (bodyStart === -1 ? node.text() : node.text().slice(0, bodyStart)).trim());
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return headers;
|
|
318
|
+
}
|
|
319
|
+
function changesExistingTestContainer(napi, before, after, patterns) {
|
|
320
|
+
const scopes = testScopes(napi, before, patterns);
|
|
321
|
+
const beforeHeaders = containerHeaders(napi, before);
|
|
322
|
+
const afterHeaders = containerHeaders(napi, after);
|
|
323
|
+
for (const scope of scopes) {
|
|
324
|
+
if (scope === '<top-level>')
|
|
325
|
+
continue;
|
|
326
|
+
if (beforeHeaders.get(scope) !== afterHeaders.get(scope))
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
function declarations(napi, code) {
|
|
332
|
+
const root = napi.parse('kotlin', code).root();
|
|
333
|
+
const found = [];
|
|
334
|
+
for (const kind of [
|
|
335
|
+
'function_declaration',
|
|
336
|
+
'property_declaration',
|
|
337
|
+
'class_declaration',
|
|
338
|
+
'object_declaration',
|
|
339
|
+
]) {
|
|
340
|
+
for (const node of root.findAll({ rule: { kind } })) {
|
|
341
|
+
const name = nodeName(node);
|
|
342
|
+
if (name)
|
|
343
|
+
found.push({ name, scope: enclosingScope(node) });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
for (const node of root.findAll({ rule: { kind: 'import_header' } })) {
|
|
347
|
+
const alias = node.findAll({ rule: { kind: 'type_identifier' } }).at(-1);
|
|
348
|
+
const leaf = node.findAll({ rule: { kind: 'simple_identifier' } }).at(-1);
|
|
349
|
+
const name = (alias ?? leaf)?.text().replace(/^`|`$/g, '');
|
|
350
|
+
if (name)
|
|
351
|
+
found.push({ name, scope: '<top-level>' });
|
|
352
|
+
}
|
|
353
|
+
return found;
|
|
354
|
+
}
|
|
355
|
+
function introducedDeclarations(before, after) {
|
|
356
|
+
const existing = new Map();
|
|
357
|
+
for (const declaration of before) {
|
|
358
|
+
const key = `${declaration.scope}\u0000${declaration.name}`;
|
|
359
|
+
existing.set(key, (existing.get(key) ?? 0) + 1);
|
|
360
|
+
}
|
|
361
|
+
return after.filter((declaration) => {
|
|
362
|
+
const key = `${declaration.scope}\u0000${declaration.name}`;
|
|
363
|
+
const remaining = existing.get(key) ?? 0;
|
|
364
|
+
if (remaining === 0)
|
|
365
|
+
return true;
|
|
366
|
+
existing.set(key, remaining - 1);
|
|
367
|
+
return false;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function shadowsExistingTestIdentifier(napi, before, after, beforeTests, patterns) {
|
|
371
|
+
const identifiers = new Set(beforeTests.flatMap((test) => [...test.matchAll(/`([^`]+)`|\b([A-Za-z_]\w*)\b/g)].map((match) => match[1] ?? match[2])));
|
|
372
|
+
const scopes = testScopes(napi, before, patterns);
|
|
373
|
+
return introducedDeclarations(declarations(napi, before), declarations(napi, after)).some(({ name, scope }) => identifiers.has(name) &&
|
|
374
|
+
(scope === '<top-level>' || scopes.has(scope)));
|
|
375
|
+
}
|
|
376
|
+
function matchesTestPath(pattern, path) {
|
|
377
|
+
pattern.lastIndex = 0;
|
|
378
|
+
if (pattern !== KOTLIN_TEST_FILE_PATTERN)
|
|
379
|
+
return pattern.test(path);
|
|
380
|
+
const match = pattern.exec(path);
|
|
381
|
+
if (!match)
|
|
382
|
+
return false;
|
|
383
|
+
const prefix = path.slice(0, match.index);
|
|
384
|
+
return !/(?:^|\/)src\/(?:main|[A-Za-z0-9]+Main)\/(?:java|kotlin)\//.test(prefix);
|
|
385
|
+
}
|
|
386
|
+
async function delegateWithFastPathUnavailable(rule, action, ctx, detail) {
|
|
387
|
+
const result = await rule(action, ctx);
|
|
388
|
+
const diagnostic = `Kotlin fast path unavailable (${detail}); delegated to the wrapped rule.`;
|
|
389
|
+
if (result.kind === 'violation') {
|
|
390
|
+
return { kind: 'violation', reason: `${result.reason} ${diagnostic}` };
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
...result,
|
|
394
|
+
reason: result.reason ?? diagnostic,
|
|
395
|
+
notes: [
|
|
396
|
+
...(result.notes ?? []),
|
|
397
|
+
{ kind: 'kotlin-fast-path-unavailable' },
|
|
398
|
+
],
|
|
399
|
+
};
|
|
155
400
|
}
|
|
156
401
|
/**
|
|
157
402
|
* Kotlin equivalent of `enforceTdd({ fastPath: true })`, which Probity
|
|
158
403
|
* only implements for TS/JS/Python/C#/Ruby/PHP: wraps a rule so that a
|
|
159
404
|
* `.kt`/`.kts` write adding exactly one new test function passes
|
|
160
405
|
* deterministically — no AI call for the most common write in a TDD
|
|
161
|
-
* loop, adding the next red test.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
406
|
+
* loop, adding the next red test. The write must target a Kotlin test
|
|
407
|
+
* source set and add exactly one runnable test function. Existing files
|
|
408
|
+
* must preserve every byte and test function and make one contiguous
|
|
409
|
+
* insertion: the test (plus comments/whitespace), or a separate test
|
|
410
|
+
* class with no additional functions. Brand-new files may include
|
|
411
|
+
* imports and property fixtures but only the one test function.
|
|
412
|
+
* Edits that target production paths, introduce disabling controls,
|
|
413
|
+
* alter an existing test container's header, shadow an identifier used
|
|
414
|
+
* by an existing test, or delete, disable, or weaken tests delegate to
|
|
415
|
+
* the wrapped rule. Everything else (multi-test writes and non-Kotlin
|
|
416
|
+
* files) delegates unchanged.
|
|
164
417
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* — it can only ever skip work, never
|
|
418
|
+
* The parser packages `@ast-grep/napi` and `@ast-grep/lang-kotlin`
|
|
419
|
+
* ship as optional dependencies, so supported npm installs receive
|
|
420
|
+
* them automatically. If a platform cannot install them, or the
|
|
421
|
+
* current file is unavailable, or parsing fails, the wrapper falls
|
|
422
|
+
* through to the wrapped rule — it can only ever skip work, never
|
|
423
|
+
* block.
|
|
170
424
|
*
|
|
171
425
|
* Same trade-off as Probity's own fast-path: a deterministic pass on
|
|
172
426
|
* every single-test addition skips the green→red refactor-readiness
|
|
@@ -175,36 +429,62 @@ function countKotlinTests(napi, code, patterns) {
|
|
|
175
429
|
* @param rule — the rule to wrap, normally `enforceTdd()`.
|
|
176
430
|
* @param options.patterns — replaces the default ast-grep test-node
|
|
177
431
|
* patterns (e.g. to add a Kotest spec pattern).
|
|
432
|
+
* @param options.testFilePattern — limits deterministic passes to test
|
|
433
|
+
* source paths; defaults to classic and KMP `src/*Test/{java,kotlin}`.
|
|
178
434
|
*
|
|
179
435
|
* @example
|
|
180
436
|
* { files: ['**\/src\/main\/**', '**\/src\/test\/**'], rules: [withKotlinFastPath(enforceTdd())] }
|
|
181
437
|
*/
|
|
182
438
|
export function withKotlinFastPath(rule, options = {}) {
|
|
183
439
|
const patterns = options.patterns ?? KOTLIN_TEST_PATTERNS;
|
|
440
|
+
const testFilePattern = options.testFilePattern ?? KOTLIN_TEST_FILE_PATTERN;
|
|
184
441
|
const wrapped = async function kotlinFastPath(action, ctx) {
|
|
185
|
-
if (action.kind !== 'write' ||
|
|
442
|
+
if (action.kind !== 'write' ||
|
|
443
|
+
!/\.kts?$/.test(action.path) ||
|
|
444
|
+
!matchesTestPath(testFilePattern, action.path)) {
|
|
186
445
|
return rule(action, ctx);
|
|
187
446
|
}
|
|
188
447
|
const napi = loadKotlinAstGrep();
|
|
189
|
-
if (!napi)
|
|
190
|
-
return rule
|
|
448
|
+
if (!napi) {
|
|
449
|
+
return delegateWithFastPathUnavailable(rule, action, ctx, 'parser packages could not be loaded');
|
|
450
|
+
}
|
|
191
451
|
const before = await ctx?.readFile?.(action.path);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
return rule(action, ctx);
|
|
452
|
+
if (!before || before.kind === 'unknown') {
|
|
453
|
+
return delegateWithFastPathUnavailable(rule, action, ctx, 'current file content unavailable');
|
|
454
|
+
}
|
|
196
455
|
const beforeText = before.kind === 'present' ? before.content : '';
|
|
197
|
-
|
|
456
|
+
const insertion = beforeText.length === 0
|
|
457
|
+
? {
|
|
458
|
+
text: action.content,
|
|
459
|
+
startByte: 0,
|
|
460
|
+
endByte: Buffer.byteLength(action.content, 'utf8'),
|
|
461
|
+
}
|
|
462
|
+
: singleInsertion(beforeText, action.content);
|
|
463
|
+
if (!insertion)
|
|
464
|
+
return rule(action, ctx);
|
|
465
|
+
let fastPath = false;
|
|
198
466
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
467
|
+
const beforeTests = kotlinTestNodes(napi, beforeText, patterns);
|
|
468
|
+
const afterTestNodes = findKotlinTestNodes(napi, action.content, patterns);
|
|
469
|
+
const addedTest = addedTestNode(beforeTests, afterTestNodes, insertion);
|
|
470
|
+
if (addedTest) {
|
|
471
|
+
const unsafeInsertion = introducesDisablingControl(beforeText, action.content) ||
|
|
472
|
+
changesExistingTestContainer(napi, beforeText, action.content, patterns) ||
|
|
473
|
+
shadowsExistingTestIdentifier(napi, beforeText, action.content, beforeTests, patterns);
|
|
474
|
+
fastPath =
|
|
475
|
+
!unsafeInsertion &&
|
|
476
|
+
isRunnableTestNode(addedTest) &&
|
|
477
|
+
(beforeText.length === 0
|
|
478
|
+
? safeNewTestFile(napi, action.content, addedTest)
|
|
479
|
+
: safeExistingFileInsertion(napi, beforeText, action.content, addedTest, beforeTests, patterns));
|
|
480
|
+
}
|
|
202
481
|
}
|
|
203
482
|
catch {
|
|
204
|
-
return rule
|
|
483
|
+
return delegateWithFastPathUnavailable(rule, action, ctx, 'parsing failed');
|
|
205
484
|
}
|
|
206
|
-
if (
|
|
485
|
+
if (fastPath) {
|
|
207
486
|
return { kind: 'pass', notes: [{ kind: 'fast-path' }] };
|
|
487
|
+
}
|
|
208
488
|
return rule(action, ctx);
|
|
209
489
|
};
|
|
210
490
|
// Surface the wrapped rule in engine traces and block reports:
|
|
@@ -214,39 +494,222 @@ export function withKotlinFastPath(rule, options = {}) {
|
|
|
214
494
|
});
|
|
215
495
|
return wrapped;
|
|
216
496
|
}
|
|
497
|
+
// Kiro's tool-result envelope reports a command's outcome as JSON
|
|
498
|
+
// (`{"exit_status": "exit status: 0", ...}`) instead of the runner's
|
|
499
|
+
// own text, so a quiet/`-q` Gradle run with no "BUILD SUCCESSFUL" text
|
|
500
|
+
// can still be trusted via exit_status — but only when the Gradle
|
|
501
|
+
// invocation is provably the LAST simple command run (a pipe,
|
|
502
|
+
// `|| true`, or trailing command reports THAT command's status, not
|
|
503
|
+
// Gradle's). Kiro-specific: native Claude, or a caller supplying its
|
|
504
|
+
// own successPattern/failurePattern, gets no such allowance.
|
|
505
|
+
const KIRO_ZERO_EXIT_STATUS = 'exit status: 0';
|
|
506
|
+
/** Parses a Kiro envelope's `exit_status` via `JSON.parse` + a shape
|
|
507
|
+
* check, never a text regex — malformed/differently-shaped output is
|
|
508
|
+
* no evidence, never a parsed zero. */
|
|
509
|
+
function parseKiroExitStatus(output) {
|
|
510
|
+
const trimmed = output.trim();
|
|
511
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}'))
|
|
512
|
+
return null;
|
|
513
|
+
try {
|
|
514
|
+
const parsed = JSON.parse(trimmed);
|
|
515
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
const record = parsed;
|
|
519
|
+
const exitStatus = record['exit_status'];
|
|
520
|
+
return typeof exitStatus === 'string' &&
|
|
521
|
+
typeof record['stdout'] === 'string' &&
|
|
522
|
+
typeof record['stderr'] === 'string'
|
|
523
|
+
? exitStatus
|
|
524
|
+
: null;
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/** True when `output` mentions an `exit_status` field but fails
|
|
531
|
+
* {@link parseKiroExitStatus}'s canonical-shape check. Distinguishes
|
|
532
|
+
* "no Kiro evidence" from a broken/non-canonical envelope, which is
|
|
533
|
+
* red even if it contains banner-like text. */
|
|
534
|
+
function isMalformedKiroEnvelope(output) {
|
|
535
|
+
const trimmed = output.trim();
|
|
536
|
+
return ((trimmed.startsWith('{') || trimmed.startsWith('[')) &&
|
|
537
|
+
trimmed.includes('"exit_status"') &&
|
|
538
|
+
parseKiroExitStatus(output) === null);
|
|
539
|
+
}
|
|
540
|
+
/** Splits on top-level `;`, `&&`, `||`, `|`, background `&`, and
|
|
541
|
+
* newlines, quote-aware, without splitting a redirect's `&` (`2>&1`).
|
|
542
|
+
* Used only to find the LAST simple command for the Kiro check. */
|
|
543
|
+
function splitTopLevelCommands(command) {
|
|
544
|
+
const segments = [];
|
|
545
|
+
let current = '';
|
|
546
|
+
let quote = null;
|
|
547
|
+
for (let i = 0; i < command.length; i++) {
|
|
548
|
+
const ch = command[i];
|
|
549
|
+
if (quote) {
|
|
550
|
+
current += ch;
|
|
551
|
+
if (ch === quote && command[i - 1] !== '\\')
|
|
552
|
+
quote = null;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (ch === '"' || ch === "'") {
|
|
556
|
+
quote = ch;
|
|
557
|
+
current += ch;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const isRedirectAmpersand = ch === '&' &&
|
|
561
|
+
(command[i - 1] === '>' ||
|
|
562
|
+
command[i - 1] === '<' ||
|
|
563
|
+
command[i + 1] === '>');
|
|
564
|
+
if ('\n|;&'.includes(ch) && !isRedirectAmpersand) {
|
|
565
|
+
if ('|&'.includes(ch) && command[i + 1] === ch)
|
|
566
|
+
i++;
|
|
567
|
+
segments.push(current);
|
|
568
|
+
current = '';
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
current += ch;
|
|
572
|
+
}
|
|
573
|
+
segments.push(current);
|
|
574
|
+
return segments;
|
|
575
|
+
}
|
|
576
|
+
/** True when the LAST top-level simple command is itself a genuine
|
|
577
|
+
* Gradle verification invocation — not followed by a pipe/`;`/`&&`/
|
|
578
|
+
* `||`/other command, and not merely mentioning "gradlew" as an
|
|
579
|
+
* argument (`echo gradlew`). Piped/chained runs still pass via
|
|
580
|
+
* BUILD SUCCESSFUL; this only gates the exit-status shortcut. */
|
|
581
|
+
function gradleInvocationIsFinalCommand(command) {
|
|
582
|
+
const segments = splitTopLevelCommands(command);
|
|
583
|
+
let last = '';
|
|
584
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
585
|
+
if (segments[i].trim().length > 0) {
|
|
586
|
+
last = segments[i];
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return isGradleVerificationInvocation(last);
|
|
591
|
+
}
|
|
592
|
+
// Flags consuming the FOLLOWING word as a value (`--flag value`);
|
|
593
|
+
// boolean flags like `--no-daemon` take none.
|
|
594
|
+
const VALUE_TAKING_FLAGS = new Set([
|
|
595
|
+
'-P', '--project-prop', '-D', '--system-prop', '-I', '--init-script',
|
|
596
|
+
'-c', '--settings-file', '-b', '--build-file', '-p', '--project-dir',
|
|
597
|
+
'-g', '--gradle-user-home', '-x', '--exclude-task', '--args', '--task',
|
|
598
|
+
'--configuration-cache-problems', '--priority', '--max-workers',
|
|
599
|
+
'--console', '--warning-mode',
|
|
600
|
+
]);
|
|
601
|
+
const EXCLUDE_TASK_FLAG = /^(?:-x(?:=?.+)?|--exclude-task(?:=.+)?)$/;
|
|
602
|
+
const REDIRECT = /^(?:\d*|&)(?:>>?|<<?)/;
|
|
603
|
+
const REDIRECT_WITH_SEPARATE_TARGET = /^(?:\d*|&)(?:>>?|<<?)$/;
|
|
604
|
+
function isVerificationTask(word) {
|
|
605
|
+
const task = word.replace(/^:+/, '').split(':').pop() ?? '';
|
|
606
|
+
return (task === 'build' ||
|
|
607
|
+
task === 'check' ||
|
|
608
|
+
task === 'test' ||
|
|
609
|
+
task === 'allTests' ||
|
|
610
|
+
task === 'jvmTest' ||
|
|
611
|
+
/^test[A-Za-z0-9]*Test$/.test(task));
|
|
612
|
+
}
|
|
613
|
+
/** Accepts one simple shell command as a genuine Gradle verification
|
|
614
|
+
* invocation: first word `gradlew`/`./gradlew`/`gradle`; no
|
|
615
|
+
* `--dry-run` or `-x`/`--exclude-task`; and at least one positional
|
|
616
|
+
* task (not a flag, flag value, or redirect target) on the allowlist. */
|
|
617
|
+
function isGradleVerificationInvocation(simpleCommand) {
|
|
618
|
+
const words = simpleCommand.trim().split(/\s+/).filter(Boolean);
|
|
619
|
+
if (!/^(?:\.\/|[\w./-]*\/)?gradlew?$/.test(words[0] ?? ''))
|
|
620
|
+
return false;
|
|
621
|
+
if (words.some((word) => word === '--dry-run' || EXCLUDE_TASK_FLAG.test(word))) {
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
let skipNext = false;
|
|
625
|
+
for (let i = 1; i < words.length; i++) {
|
|
626
|
+
const word = words[i];
|
|
627
|
+
if (skipNext) {
|
|
628
|
+
skipNext = false;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
if (REDIRECT.test(word)) {
|
|
632
|
+
skipNext = REDIRECT_WITH_SEPARATE_TARGET.test(word);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (word.startsWith('-')) {
|
|
636
|
+
skipNext = !word.includes('=') && VALUE_TAKING_FLAGS.has(word);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (isVerificationTask(word))
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
/** Kotlin-default command predicate: true if ANY top-level simple
|
|
645
|
+
* command is a genuine Gradle verification invocation. */
|
|
646
|
+
function isKotlinDefaultGradleCommand(command) {
|
|
647
|
+
return splitTopLevelCommands(command).some(isGradleVerificationInvocation);
|
|
648
|
+
}
|
|
649
|
+
/** Kotlin-default extra-success predicate: trusts a parsed zero exit
|
|
650
|
+
* status only when the Gradle invocation is the last command run. */
|
|
651
|
+
function kotlinDefaultExtraSuccess(command, output) {
|
|
652
|
+
return (gradleInvocationIsFinalCommand(command) &&
|
|
653
|
+
parseKiroExitStatus(output) === KIRO_ZERO_EXIT_STATUS);
|
|
654
|
+
}
|
|
655
|
+
const DEFAULT_SUCCESS_PATTERN = /BUILD SUCCESSFUL/;
|
|
656
|
+
// A script/dependency-resolution failure can print `FAILURE:` without
|
|
657
|
+
// `BUILD FAILED`; a task failure names the task in `Execution failed
|
|
658
|
+
// for task ':...'`.
|
|
659
|
+
const DEFAULT_FAILURE_PATTERN = /FAILED|BUILD FAILED|^FAILURE: |Execution failed for task/m;
|
|
660
|
+
/** Kotlin-default extra-failure predicate: a parsed non-zero exit
|
|
661
|
+
* status, or output that looks like an ATTEMPTED but malformed/
|
|
662
|
+
* non-canonical Kiro envelope, is always red — even alongside a
|
|
663
|
+
* BUILD SUCCESSFUL banner (which may be echoed/stale text, or a
|
|
664
|
+
* substring inside broken JSON, not this run's own verdict). */
|
|
665
|
+
function kotlinDefaultExtraFailure(output) {
|
|
666
|
+
if (isMalformedKiroEnvelope(output))
|
|
667
|
+
return true;
|
|
668
|
+
const exitStatus = parseKiroExitStatus(output);
|
|
669
|
+
return exitStatus !== null && exitStatus !== KIRO_ZERO_EXIT_STATUS;
|
|
670
|
+
}
|
|
671
|
+
const DEFAULT_GRADLE_GREEN_REASON = 'Accepted Gradle verification tasks: test/test...Test, build, or check ' +
|
|
672
|
+
'(including module-qualified tasks and allTests/jvmTest; not --dry-run ' +
|
|
673
|
+
'or -x/--exclude-task). Under Kiro, a quiet run with no BUILD ' +
|
|
674
|
+
'SUCCESSFUL text is accepted only when the tool result is the canonical ' +
|
|
675
|
+
'{"exit_status": "exit status: 0", ...} envelope (a zero exit_status) ' +
|
|
676
|
+
'AND the Gradle invocation is the LAST command run (no trailing pipe, ' +
|
|
677
|
+
'`; next-command`, or `|| fallback`) — those still need the BUILD ' +
|
|
678
|
+
'SUCCESSFUL banner itself.';
|
|
217
679
|
/**
|
|
218
|
-
*
|
|
219
|
-
* `
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* no AI call.
|
|
232
|
-
*
|
|
233
|
-
* @param options.command — regex matching a test invocation (e.g.
|
|
234
|
-
* {@link GRADLE_TEST_COMMAND}).
|
|
235
|
-
* @param options.successPattern — output must match to count as green
|
|
236
|
-
* (default `/BUILD SUCCESSFUL/`).
|
|
237
|
-
* @param options.failurePattern — output matching this is red even if
|
|
238
|
-
* the success pattern also appears (default `/FAILED|BUILD FAILED/`).
|
|
680
|
+
* Gradle/Kiro-defaulted wrapper over `requireGreenTestRun` in
|
|
681
|
+
* `gates.ts` (see that export for the full option contract). The
|
|
682
|
+
* Kotlin defaults (command predicate, Kiro extra success/failure
|
|
683
|
+
* signal, default reason) apply ONLY when `options.command` is
|
|
684
|
+
* referentially the exported {@link GRADLE_TEST_COMMAND}; any other
|
|
685
|
+
* RegExp (a custom pattern, or a preset's own constant such as
|
|
686
|
+
* Swift's `XCODEBUILD_TEST_COMMAND`) falls through to the
|
|
687
|
+
* language-neutral behavior with the caller's own patterns/reason and
|
|
688
|
+
* no default appended. The Kiro exit-status extra-success/failure
|
|
689
|
+
* predicates apply only when the caller omits BOTH `successPattern`
|
|
690
|
+
* and `failurePattern` — supplying either fully suppresses the Kiro
|
|
691
|
+
* allowance, so a caller replacing the evidence contract gets exactly
|
|
692
|
+
* what they specified, nothing more.
|
|
239
693
|
*/
|
|
240
694
|
export function requireGreenTestRun(options) {
|
|
241
|
-
|
|
242
|
-
|
|
695
|
+
const isDefaultGradleCommand = options.command === GRADLE_TEST_COMMAND;
|
|
696
|
+
const allowKiroExtras = isDefaultGradleCommand && !options.successPattern && !options.failurePattern;
|
|
243
697
|
return requireGreenTestRunGeneric({
|
|
244
698
|
command: options.command,
|
|
245
|
-
|
|
246
|
-
|
|
699
|
+
commandPredicate: isDefaultGradleCommand
|
|
700
|
+
? isKotlinDefaultGradleCommand
|
|
701
|
+
: undefined,
|
|
702
|
+
successPattern: options.successPattern ?? DEFAULT_SUCCESS_PATTERN,
|
|
703
|
+
extraSuccessPredicate: allowKiroExtras ? kotlinDefaultExtraSuccess : undefined,
|
|
704
|
+
failurePattern: options.failurePattern ?? DEFAULT_FAILURE_PATTERN,
|
|
705
|
+
extraFailurePredicate: allowKiroExtras
|
|
706
|
+
? (_command, output) => kotlinDefaultExtraFailure(output)
|
|
707
|
+
: undefined,
|
|
247
708
|
enforceForPaths: options.enforceForPaths,
|
|
248
709
|
listCommitFiles: options.listCommitFiles,
|
|
249
|
-
reason:
|
|
710
|
+
reason: isDefaultGradleCommand
|
|
711
|
+
? options.reason ?? DEFAULT_GRADLE_GREEN_REASON
|
|
712
|
+
: options.reason,
|
|
250
713
|
});
|
|
251
714
|
}
|
|
252
715
|
/** Complete single-line telemetry calls — the only additions the
|