@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.
@@ -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 test invocations (`./gradlew test`,
31
- * `./gradlew :mysudo:testDevDebugUnitTest`, flavored Android unit-test
32
- * tasks) for the commit-on-green `requireCommand` gate.
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 = /gradlew?\s+(?:[\w:./-]+\s+)*:?[\w:.-]*[tT]est\w*/;
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
- regex: '@(Test|ParameterizedTest|RepeatedTest|TestFactory)\\b',
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 peer deps not installed — the wrapper falls through.
158
+ // Optional dependencies unavailable on this install or platform.
145
159
  astGrep = null;
146
160
  }
147
161
  return astGrep;
148
162
  }
149
- function countKotlinTests(napi, code, patterns) {
163
+ function findKotlinTestNodes(napi, code, patterns) {
150
164
  const root = napi.parse('kotlin', code).root();
151
- let count = 0;
152
- for (const pattern of patterns)
153
- count += root.findAll(pattern).length;
154
- return count;
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. Everything else (production writes,
162
- * multi-test writes, non-Kotlin files) delegates to the wrapped rule
163
- * unchanged.
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
- * Requires the optional packages `@ast-grep/napi` and
166
- * `@ast-grep/lang-kotlin` (`npm install -D` both). When they're
167
- * missing, or the current file content is unavailable, or parsing
168
- * fails, the wrapper transparently falls through to the wrapped rule
169
- * — it can only ever skip work, never block.
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' || !/\.kts?$/.test(action.path)) {
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(action, ctx);
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
- // An unknowable before-count makes any delta unverifiable; fall
193
- // through to the wrapped rule rather than risk a false pass.
194
- if (!before || before.kind === 'unknown')
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
- let delta;
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
- delta =
200
- countKotlinTests(napi, action.content, patterns) -
201
- countKotlinTests(napi, beforeText, patterns);
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(action, ctx);
483
+ return delegateWithFastPathUnavailable(rule, action, ctx, 'parsing failed');
205
484
  }
206
- if (delta === 1)
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
- * Commit-on-GREEN gate — the stricter sibling of Probity's built-in
219
- * `requireCommand`, which only checks that a matching test command was
220
- * *recorded* after the last write and would happily pass a transcript
221
- * whose latest run FAILED. This rule additionally judges the recorded
222
- * run's output: the last matching test command after the last write
223
- * must look green (`successPattern` present, `failurePattern` absent).
224
- *
225
- * Inherent limit (unchanged from requireCommand): the gate sees only
226
- * the session transcript. A green run in another terminal, CI, or a
227
- * wrapper script is invisible — rerun the suite in-session, and keep
228
- * the CI mirror for human commits.
229
- *
230
- * Applies to: command actions matching `git commit`. Deterministic —
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
- // Gradle-defaulted wrapper over the language-neutral rule in
242
- // gates.ts (JS/TS configs use it directly with their own patterns).
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
- successPattern: options.successPattern ?? /BUILD SUCCESSFUL/,
246
- failurePattern: options.failurePattern ?? /FAILED|BUILD FAILED/,
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: options.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