@jjchill/probity-rules 0.1.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/GLOSSARY.template.md +33 -0
  3. package/README.md +96 -0
  4. package/dist/index.d.ts +20 -0
  5. package/dist/index.d.ts.map +1 -0
  6. package/dist/index.js +20 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/presets/js.d.ts +63 -0
  9. package/dist/presets/js.d.ts.map +1 -0
  10. package/dist/presets/js.js +112 -0
  11. package/dist/presets/js.js.map +1 -0
  12. package/dist/presets/kmp.d.ts +27 -0
  13. package/dist/presets/kmp.d.ts.map +1 -0
  14. package/dist/presets/kmp.js +253 -0
  15. package/dist/presets/kmp.js.map +1 -0
  16. package/dist/presets/kotlin.d.ts +40 -0
  17. package/dist/presets/kotlin.d.ts.map +1 -0
  18. package/dist/presets/kotlin.js +172 -0
  19. package/dist/presets/kotlin.js.map +1 -0
  20. package/dist/presets/swift.d.ts +10 -0
  21. package/dist/presets/swift.d.ts.map +1 -0
  22. package/dist/presets/swift.js +285 -0
  23. package/dist/presets/swift.js.map +1 -0
  24. package/dist/rules/acceptance-language.d.ts +95 -0
  25. package/dist/rules/acceptance-language.d.ts.map +1 -0
  26. package/dist/rules/acceptance-language.js +443 -0
  27. package/dist/rules/acceptance-language.js.map +1 -0
  28. package/dist/rules/gates.d.ts +125 -0
  29. package/dist/rules/gates.d.ts.map +1 -0
  30. package/dist/rules/gates.js +285 -0
  31. package/dist/rules/gates.js.map +1 -0
  32. package/dist/rules/kotlin.d.ts +323 -0
  33. package/dist/rules/kotlin.d.ts.map +1 -0
  34. package/dist/rules/kotlin.js +722 -0
  35. package/dist/rules/kotlin.js.map +1 -0
  36. package/dist/rules/ports-and-adapters.d.ts +86 -0
  37. package/dist/rules/ports-and-adapters.d.ts.map +1 -0
  38. package/dist/rules/ports-and-adapters.js +366 -0
  39. package/dist/rules/ports-and-adapters.js.map +1 -0
  40. package/dist/rules/scoping.d.ts +68 -0
  41. package/dist/rules/scoping.d.ts.map +1 -0
  42. package/dist/rules/scoping.js +93 -0
  43. package/dist/rules/scoping.js.map +1 -0
  44. package/dist/rules/spec-test-parity.d.ts +164 -0
  45. package/dist/rules/spec-test-parity.d.ts.map +1 -0
  46. package/dist/rules/spec-test-parity.js +456 -0
  47. package/dist/rules/spec-test-parity.js.map +1 -0
  48. package/dist/rules/swift.d.ts +50 -0
  49. package/dist/rules/swift.d.ts.map +1 -0
  50. package/dist/rules/swift.js +50 -0
  51. package/dist/rules/swift.js.map +1 -0
  52. package/dist/rules/ubiquitous-language.d.ts +36 -0
  53. package/dist/rules/ubiquitous-language.d.ts.map +1 -0
  54. package/dist/rules/ubiquitous-language.js +140 -0
  55. package/dist/rules/ubiquitous-language.js.map +1 -0
  56. package/dist/scripts/scope-report.d.ts +3 -0
  57. package/dist/scripts/scope-report.d.ts.map +1 -0
  58. package/dist/scripts/scope-report.js +184 -0
  59. package/dist/scripts/scope-report.js.map +1 -0
  60. package/kiro/README.md +20 -0
  61. package/kiro/kiro-agent.template.json +45 -0
  62. package/kiro/kiro-transcript-to-claude.py +183 -0
  63. package/kiro/probity-kiro-translate.py +132 -0
  64. package/kiro/probity-kiro.sh +87 -0
  65. package/kiro/skill-activation-forced-eval.sh +45 -0
  66. package/package.json +68 -0
  67. package/probity.config.kmp.ts +40 -0
  68. package/probity.config.kotlin.ts +37 -0
  69. package/probity.config.swift.ts +38 -0
  70. package/probity.config.ts +43 -0
  71. package/scripts/spec-parity.mjs +345 -0
@@ -0,0 +1,722 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { join, relative } from 'node:path';
4
+ import { forbidNewAmbientEffects as forbidNewAmbientEffectsGeneric, introducedPatterns, requireGreenTestRun as requireGreenTestRunGeneric, } from './gates.js';
5
+ /**
6
+ * Kotlin/JVM/Android preset for the ports-and-adapters rules. The
7
+ * JS-ecosystem screens in `ports-and-adapters.ts` (ESM imports,
8
+ * jest/vi module mocks) never fire on Kotlin; these are their
9
+ * Kotlin-shaped counterparts.
10
+ *
11
+ * Brownfield note: both rules here judge the DELTA — they block only
12
+ * occurrences the pending write introduces, so files that already
13
+ * carry violations can be edited freely and migrated incrementally.
14
+ */
15
+ /**
16
+ * Known framework/vendor/infrastructure imports that never belong in
17
+ * core code under the Dependency Rule. Kotlin `import` syntax.
18
+ * Extend with your stack's packages; `enforcePortsBoundary` catches
19
+ * what this list misses.
20
+ */
21
+ export const KOTLIN_INFRASTRUCTURE_IMPORTS = /import\s+(?:com\.amazonaws|aws\.sdk\.kotlin|software\.amazon\.awssdk|com\.amplifyframework|com\.apollographql|com\.google\.firebase|com\.sudoplatform|com\.twilio|okhttp3|retrofit2|androidx\.room|androidx\.work|androidx\.datastore|java\.sql|javax\.sql|io\.ktor|org\.koin|org\.springframework|org\.jetbrains\.exposed|app\.cash\.sqldelight|com\.squareup\.sqldelight|net\.zetetic)\./;
22
+ /**
23
+ * Mocking-library imports, for projects whose convention is
24
+ * hand-written fakes at ports with no mocking library at all (pair
25
+ * with `forbidContentPattern`). Distinct from `forbidStaticMocks`,
26
+ * which permits the library but blocks its monkey-patching APIs.
27
+ */
28
+ export const MOCKING_LIBRARY_IMPORTS = /import\s+(?:io\.mockk|org\.mockito|org\.powermock)\./;
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.
33
+ */
34
+ export const GRADLE_TEST_COMMAND = /gradlew?\s+(?:[\w:./-]+\s+)*:?[\w:.-]*[tT]est\w*/;
35
+ const STATIC_MOCK_PATTERNS = [
36
+ { label: 'Mockito.mockStatic()', pattern: /\bmockStatic\s*[(<]/g },
37
+ { label: 'mockkStatic()', pattern: /\bmockkStatic\s*\(/g },
38
+ { label: 'mockkObject()', pattern: /\bmockkObject\s*\(/g },
39
+ { label: 'mockkConstructor()', pattern: /\bmockkConstructor\s*\(/g },
40
+ { label: 'PowerMock', pattern: /import\s+org\.powermock\b/g },
41
+ ];
42
+ const AMBIENT_EFFECT_PATTERNS = [
43
+ {
44
+ label: 'java.time .now()',
45
+ pattern: /\b(?:Instant|LocalDate|LocalDateTime|LocalTime|ZonedDateTime|OffsetDateTime)\.now\s*\(/g,
46
+ },
47
+ { label: 'System.currentTimeMillis()', pattern: /\bSystem\.currentTimeMillis\s*\(/g },
48
+ { label: 'Date()', pattern: /\bDate\s*\(\s*\)/g },
49
+ { label: 'UUID.randomUUID()', pattern: /\bUUID\.randomUUID\s*\(/g },
50
+ { label: 'Random()/Math.random()', pattern: /\bRandom\s*\(|\bMath\.random\s*\(/g },
51
+ { label: 'System.getenv', pattern: /\bSystem\.getenv\b/g },
52
+ ];
53
+ /**
54
+ * Kotlin counterpart of `forbidInternalModuleMocks`: blocks test
55
+ * writes that introduce static/object/constructor mocking —
56
+ * `Mockito.mockStatic`, MockK's `mockkStatic`/`mockkObject`/
57
+ * `mockkConstructor`, PowerMock — the JVM's monkey-patching
58
+ * equivalents. These always bypass the architecture ("Ports Are the
59
+ * Only Test Seam"): whatever they intercept should be reached through
60
+ * a port and replaced with a fake.
61
+ *
62
+ * Plain `mock<SomeInterface>()` is NOT blocked: whether the mocked
63
+ * type is a port (fine) or an internal class (violation) isn't
64
+ * decidable from the call site — that judgment belongs to
65
+ * `enforcePortsBoundary` or review.
66
+ *
67
+ * Delta-based: pre-existing static mocks in the file don't
68
+ * re-trigger on later edits. Applies to: write actions. No AI call.
69
+ *
70
+ * @example
71
+ * { files: ['**\/src\/test\/**', '**\/src\/androidTest\/**'], rules: [forbidStaticMocks()] }
72
+ */
73
+ export function forbidStaticMocks() {
74
+ return async function forbidStaticMocks(action, ctx) {
75
+ if (action.kind !== 'write')
76
+ return { kind: 'pass' };
77
+ const introduced = await introducedPatterns(action, ctx, STATIC_MOCK_PATTERNS);
78
+ if (introduced.length === 0)
79
+ return { kind: 'pass' };
80
+ return {
81
+ kind: 'violation',
82
+ reason: `This write introduces static/object mocking (${introduced.join(', ')}). Ports are the only test seam: put the intercepted ` +
83
+ 'dependency behind a port and substitute an in-memory fake ' +
84
+ 'there instead (see the ports-and-adapters skill).',
85
+ };
86
+ };
87
+ }
88
+ /**
89
+ * Blocks production writes that introduce direct ambient-effect calls
90
+ * — OS clock (`Instant.now()`, `System.currentTimeMillis()`,
91
+ * `Date()`), randomness (`UUID.randomUUID()`, `Random()`), and
92
+ * environment (`System.getenv`). Under ports-and-adapters these are
93
+ * unowned OS dependencies: clock, randomness, and config are ports.
94
+ *
95
+ * Delta-based: the ~hundreds of pre-existing call sites in a
96
+ * brownfield codebase don't block edits to their files; only net-new
97
+ * occurrences do. Scope to production sources — tests and adapter
98
+ * implementations (e.g. a `DefaultTimeProvider`) legitimately touch
99
+ * the real OS, so exclude adapter paths via globs or negations.
100
+ *
101
+ * @param options.seamHint — appended to the block message to point
102
+ * the agent at the project's canonical seam(s), e.g.
103
+ * "inject com.anonyome.sudocommons.core.common.TimeProvider".
104
+ * @param options.patterns — replaces the default pattern list; each
105
+ * RegExp needs the `g` flag.
106
+ *
107
+ * @example
108
+ * { files: ['**\/src\/main\/**', '!**\/adapters\/**'], rules: [forbidNewAmbientEffects()] }
109
+ */
110
+ export function forbidNewAmbientEffects(options = {}) {
111
+ // Kotlin-defaulted wrapper over the language-neutral rule in
112
+ // gates.ts (JS/TS configs use it directly with their own patterns).
113
+ return forbidNewAmbientEffectsGeneric({
114
+ patterns: options.patterns ?? AMBIENT_EFFECT_PATTERNS,
115
+ seamHint: options.seamHint,
116
+ });
117
+ }
118
+ /**
119
+ * ast-grep rules matching a JUnit-style test function. Kotest
120
+ * string-invocation specs (`"does a thing" { }`) would need an
121
+ * additional pattern; add one via `withKotlinFastPath`'s `patterns`
122
+ * option if your suite uses them.
123
+ */
124
+ const KOTLIN_TEST_PATTERNS = [
125
+ {
126
+ rule: {
127
+ kind: 'function_declaration',
128
+ regex: '@(Test|ParameterizedTest|RepeatedTest|TestFactory)\\b',
129
+ },
130
+ },
131
+ ];
132
+ let astGrep;
133
+ function loadKotlinAstGrep() {
134
+ if (astGrep !== undefined)
135
+ return astGrep;
136
+ try {
137
+ const require = createRequire(import.meta.url);
138
+ const napi = require('@ast-grep/napi');
139
+ const lang = require('@ast-grep/lang-kotlin');
140
+ napi.registerDynamicLanguage({ kotlin: lang.default ?? lang });
141
+ astGrep = napi;
142
+ }
143
+ catch {
144
+ // Optional peer deps not installed — the wrapper falls through.
145
+ astGrep = null;
146
+ }
147
+ return astGrep;
148
+ }
149
+ function countKotlinTests(napi, code, patterns) {
150
+ 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;
155
+ }
156
+ /**
157
+ * Kotlin equivalent of `enforceTdd({ fastPath: true })`, which Probity
158
+ * only implements for TS/JS/Python/C#/Ruby/PHP: wraps a rule so that a
159
+ * `.kt`/`.kts` write adding exactly one new test function passes
160
+ * 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.
164
+ *
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.
170
+ *
171
+ * Same trade-off as Probity's own fast-path: a deterministic pass on
172
+ * every single-test addition skips the green→red refactor-readiness
173
+ * check the AI would otherwise perform.
174
+ *
175
+ * @param rule — the rule to wrap, normally `enforceTdd()`.
176
+ * @param options.patterns — replaces the default ast-grep test-node
177
+ * patterns (e.g. to add a Kotest spec pattern).
178
+ *
179
+ * @example
180
+ * { files: ['**\/src\/main\/**', '**\/src\/test\/**'], rules: [withKotlinFastPath(enforceTdd())] }
181
+ */
182
+ export function withKotlinFastPath(rule, options = {}) {
183
+ const patterns = options.patterns ?? KOTLIN_TEST_PATTERNS;
184
+ const wrapped = async function kotlinFastPath(action, ctx) {
185
+ if (action.kind !== 'write' || !/\.kts?$/.test(action.path)) {
186
+ return rule(action, ctx);
187
+ }
188
+ const napi = loadKotlinAstGrep();
189
+ if (!napi)
190
+ return rule(action, ctx);
191
+ 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);
196
+ const beforeText = before.kind === 'present' ? before.content : '';
197
+ let delta;
198
+ try {
199
+ delta =
200
+ countKotlinTests(napi, action.content, patterns) -
201
+ countKotlinTests(napi, beforeText, patterns);
202
+ }
203
+ catch {
204
+ return rule(action, ctx);
205
+ }
206
+ if (delta === 1)
207
+ return { kind: 'pass', notes: [{ kind: 'fast-path' }] };
208
+ return rule(action, ctx);
209
+ };
210
+ // Surface the wrapped rule in engine traces and block reports:
211
+ // a block coming through the wrapper is the inner rule's verdict.
212
+ Object.defineProperty(wrapped, 'name', {
213
+ value: `kotlinFastPath(${rule.name || 'rule'})`,
214
+ });
215
+ return wrapped;
216
+ }
217
+ /**
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/`).
239
+ */
240
+ 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).
243
+ return requireGreenTestRunGeneric({
244
+ command: options.command,
245
+ successPattern: options.successPattern ?? /BUILD SUCCESSFUL/,
246
+ failurePattern: options.failurePattern ?? /FAILED|BUILD FAILED/,
247
+ enforceForPaths: options.enforceForPaths,
248
+ listCommitFiles: options.listCommitFiles,
249
+ reason: options.reason,
250
+ });
251
+ }
252
+ /** Complete single-line telemetry calls — the only additions the
253
+ * telemetry fast-path recognizes. Multi-line event calls fall
254
+ * through to the wrapped rule (conservative by design). */
255
+ const TELEMETRY_LINE_PATTERNS = [
256
+ /^[\w.]*logger\.event\(.*\)[,;]?$/i,
257
+ /^breadcrumbs\.(?:action|outcome)\(.*\)[,;]?$/,
258
+ ];
259
+ /**
260
+ * Deterministic fast-path for the write the observability rules
261
+ * encourage: adding telemetry to existing code. A `.kt`/`.kts` write
262
+ * whose entire delta is ADDED lines, every one a complete single-line
263
+ * telemetry call (`logger.event(...)`, `breadcrumbs.action/outcome`),
264
+ * passes without consulting the wrapped rule — no AI call, and no
265
+ * "over-implementation" friction from a TDD gate for instrumentation
266
+ * the adapter-observability rule demands anyway. Anything else — a
267
+ * removed/changed line, a multi-line event call, any non-telemetry
268
+ * addition — falls through unchanged.
269
+ *
270
+ * Wrap it around both sides of the tension: the TDD rule (so
271
+ * telemetry additions aren't judged as unasserted behavior) and
272
+ * `enforceAdapterObservability` (a telemetry-only addition trivially
273
+ * satisfies it).
274
+ */
275
+ export function withTelemetryFastPath(rule, options = {}) {
276
+ const patterns = options.patterns ?? TELEMETRY_LINE_PATTERNS;
277
+ // The extension guard is a parameter: a config passing Swift (or
278
+ // any non-Kotlin) telemetry patterns MUST also pass the matching
279
+ // filePattern, or the fast path silently never fires and every
280
+ // source write costs a model call (found live on an iOS trial).
281
+ const filePattern = options.filePattern ?? /\.kts?$/;
282
+ const wrapped = async function telemetryFastPath(action, ctx) {
283
+ if (action.kind !== 'write' || !filePattern.test(action.path)) {
284
+ return rule(action, ctx);
285
+ }
286
+ const before = await ctx?.readFile?.(action.path);
287
+ if (!before || before.kind !== 'present')
288
+ return rule(action, ctx);
289
+ const trim = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
290
+ const beforeLines = trim(before.content);
291
+ const afterLines = trim(action.content);
292
+ const beforeSet = new Set(beforeLines);
293
+ const afterSet = new Set(afterLines);
294
+ const removed = beforeLines.filter((line) => !afterSet.has(line));
295
+ const added = afterLines.filter((line) => !beforeSet.has(line));
296
+ if (removed.length > 0 || added.length === 0)
297
+ return rule(action, ctx);
298
+ const allTelemetry = added.every((line) => patterns.some((pattern) => pattern.test(line)));
299
+ if (!allTelemetry)
300
+ return rule(action, ctx);
301
+ return { kind: 'pass', notes: [{ kind: 'fast-path' }] };
302
+ };
303
+ Object.defineProperty(wrapped, 'name', {
304
+ value: `telemetryFastPath(${rule.name || 'rule'})`,
305
+ });
306
+ return wrapped;
307
+ }
308
+ /**
309
+ * Marker that declares a write a mutation probe: a deliberate,
310
+ * temporary break of production behavior made to prove a retrofitted
311
+ * test can fail (see acceptance-testing's mutation-check step). Put
312
+ * it in a comment on or near the mutated line:
313
+ *
314
+ * // probity: mutation-probe — proving RetrySpec bites; revert before commit
315
+ */
316
+ export const MUTATION_PROBE_MARKER = /probity:\s*mutation-probe/;
317
+ const PROBE_SKIP_DIRS = new Set([
318
+ 'node_modules',
319
+ '.git',
320
+ 'build',
321
+ '.gradle',
322
+ 'out',
323
+ 'dist',
324
+ '.idea',
325
+ ]);
326
+ const DEFAULT_PROBE_FILE_PATTERN = /\.(?:kt|kts|java)$/;
327
+ function walkFiles(dir, out = []) {
328
+ let entries;
329
+ try {
330
+ entries = readdirSync(dir, { withFileTypes: true });
331
+ }
332
+ catch {
333
+ return out;
334
+ }
335
+ for (const entry of entries) {
336
+ if (entry.isDirectory()) {
337
+ if (!PROBE_SKIP_DIRS.has(entry.name))
338
+ walkFiles(join(dir, entry.name), out);
339
+ }
340
+ else {
341
+ out.push(join(dir, entry.name));
342
+ }
343
+ }
344
+ return out;
345
+ }
346
+ /**
347
+ * Wraps a TDD rule so that a write carrying the
348
+ * {@link MUTATION_PROBE_MARKER} passes deterministically — no AI call,
349
+ * no red-before-green demand. Mutation checks (deliberately breaking
350
+ * production code to prove a retrofitted test fails) are *mandated* by
351
+ * the acceptance-testing skill, and an unwrapped `enforceTdd`
352
+ * correctly denies them: a deliberate regression has no failing test
353
+ * in front of it and never will. Without this wrapper the only way to
354
+ * run a mutation check is to override the gate — which trains agents
355
+ * and humans to ignore deny decisions.
356
+ *
357
+ * The bypass is not free: pair this with {@link enforceProbeReversion}
358
+ * so `git commit` is blocked while any probe marker is still on disk.
359
+ * The pair converts an override into an enforced round-trip: mark →
360
+ * watch the test fail → revert (the marker disappears with the
361
+ * mutation) → commit opens again. Removing just the marker while
362
+ * keeping the mutation is a fresh unmarked production write, judged by
363
+ * the wrapped TDD rule as usual.
364
+ *
365
+ * Only the TDD rule is bypassed. Deterministic screens (vendor
366
+ * imports, ambient effects) and the boundary validator still apply to
367
+ * probe writes — a probe has no business introducing those.
368
+ *
369
+ * @param rule — the rule to wrap, normally
370
+ * `withKotlinFastPath(enforceTdd())`.
371
+ */
372
+ export function withMutationProbe(rule) {
373
+ const wrapped = async function mutationProbe(action, ctx) {
374
+ if (action.kind === 'write' && MUTATION_PROBE_MARKER.test(action.content)) {
375
+ return { kind: 'pass', notes: [{ kind: 'mutation-probe' }] };
376
+ }
377
+ return rule(action, ctx);
378
+ };
379
+ Object.defineProperty(wrapped, 'name', {
380
+ value: `mutationProbe(${rule.name || 'rule'})`,
381
+ });
382
+ return wrapped;
383
+ }
384
+ // The inverse-scenario note only helps when the denial is about a
385
+ // missing red or when the write is removing control — appended
386
+ // anywhere else it reads as boilerplate (observed live on a
387
+ // minimal-green denial about an undefined symbol).
388
+ const MISSING_RED_REASON = /never (?:been )?observed|not (?:been )?observed failing|no (?:clean |assertion-level |prior )*red|passe[sd] (?:all|without)|without (?:a |any )?(?:failing|red)/i;
389
+ // The other recurring composition-root denial: a multi-part fixture
390
+ // (fake + fixture accessor + enum value) landed piecewise, and the
391
+ // first write is blocked for referencing a sibling it doesn't define.
392
+ const UNDEFINED_SIBLING_REASON = /not defined|no declaration|undefined|not declared|does not (?:add|define|declare)|isn't defined/i;
393
+ const ATOMIC_FIXTURE_HINT = 'Note: if this block cites a symbol the write references but does not ' +
394
+ 'define, the fake, its fixture key/accessor, and its enum value are ' +
395
+ 'ONE coherent unit — land them in a single atomic write instead of ' +
396
+ 'piecewise edits judged alone.';
397
+ const INVERSE_SCENARIO_GUIDANCE = 'Note for test-control infrastructure (fixtures, fakes registered in an ' +
398
+ 'acceptance composition root): if the scenario this control serves ' +
399
+ 'passes WITHOUT it — the environment already satisfies its Given (no ' +
400
+ 'network, no credentials, empty state) — do not resolve this block by ' +
401
+ 'deleting the control. That leaves the precondition owned by the ' +
402
+ 'environment and makes the opposite precondition unspecifiable. The ' +
403
+ 'legitimate red is the INVERSE scenario on the same port (e.g. the ' +
404
+ 'success path when only failure happens for free): write that scenario, ' +
405
+ 'watch it fail, and let it drive this fixture; then move the original ' +
406
+ 'scenario onto the explicit fixture as a refactor under green. If the ' +
407
+ 'seam itself is missing, mark the scenario `## Scenario (wip):` and ' +
408
+ 'surface the seam gap to the user instead of working around it.';
409
+ /**
410
+ * Wraps a TDD rule so that a violation on a write to the project's
411
+ * test-control layer — acceptance composition roots, fixture/fake
412
+ * registrations — carries the inverse-scenario escape route in its
413
+ * deny text.
414
+ *
415
+ * Why: on brownfield systems the environment often produces the sad
416
+ * path for free (a simulator with no backend fails every sign-in), so
417
+ * a sad-path scenario never goes red and an unwrapped TDD gate appears
418
+ * to "refuse" the control fixture. The observed failure mode is the
419
+ * agent resolving that tension in the wrong direction — deleting the
420
+ * control and letting the environment own the Given. The deny message
421
+ * is what the agent reads at that decision point, so the correct move
422
+ * (write the inverse scenario; its red drives the fixture) must be
423
+ * stated there, not only in the skill prose.
424
+ *
425
+ * Pass-through everywhere else: verdicts are unchanged, only the
426
+ * violation reason on matching paths gains a guidance paragraph — and
427
+ * only the paragraph that applies. The inverse-scenario note is
428
+ * appended when the denial is about a missing red (or the write
429
+ * removes existing content — the deletion temptation); a denial citing
430
+ * an undefined symbol gets the atomic-fixture hint instead (a
431
+ * multi-part fixture judged piecewise). Other denials pass through
432
+ * untouched, so the guidance never reads as boilerplate.
433
+ *
434
+ * @param rule — the TDD rule to wrap (already wrapped in fast-paths /
435
+ * mutation-probe as usual).
436
+ * @param options.filePattern — paths that hold test-control
437
+ * infrastructure (e.g. /App[/\\]Sources[/\\]Acceptance[/\\]/ or a
438
+ * fixtures directory).
439
+ */
440
+ export function withInverseScenarioGuidance(rule, options) {
441
+ const wrapped = async function inverseScenarioGuidance(action, ctx) {
442
+ const result = await rule(action, ctx);
443
+ if (result.kind !== 'violation' ||
444
+ action.kind !== 'write' ||
445
+ !options.filePattern.test(action.path)) {
446
+ return result;
447
+ }
448
+ const reason = result.reason ?? '';
449
+ let removesContent = false;
450
+ const before = await ctx?.readFile?.(action.path);
451
+ if (before?.kind === 'present') {
452
+ const after = new Set(action.content.split('\n').map((line) => line.trim()));
453
+ removesContent = before.content
454
+ .split('\n')
455
+ .map((line) => line.trim())
456
+ .some((line) => line.length > 0 && !after.has(line));
457
+ }
458
+ if (removesContent || MISSING_RED_REASON.test(reason)) {
459
+ return { ...result, reason: `${reason}\n\n${INVERSE_SCENARIO_GUIDANCE}` };
460
+ }
461
+ if (UNDEFINED_SIBLING_REASON.test(reason)) {
462
+ return { ...result, reason: `${reason}\n\n${ATOMIC_FIXTURE_HINT}` };
463
+ }
464
+ return result;
465
+ };
466
+ Object.defineProperty(wrapped, 'name', {
467
+ value: `inverseScenarioGuidance(${rule.name || 'rule'})`,
468
+ });
469
+ return wrapped;
470
+ }
471
+ /**
472
+ * The commit half of the mutation-probe round-trip (see
473
+ * {@link withMutationProbe}): blocks `git commit` while any source
474
+ * file under `roots` still contains the probe marker, listing the
475
+ * files. Reverting the mutation (e.g. `git checkout -- <file>`)
476
+ * removes the marker with it, so a clean tree needs no bookkeeping.
477
+ * Deterministic filesystem scan — no AI call.
478
+ *
479
+ * Applies to: command actions matching `git commit`.
480
+ *
481
+ * @param options.roots — absolute paths to scan (the repo root is
482
+ * fine; node_modules/build dirs are skipped).
483
+ * @param options.filePattern — which files can carry probes
484
+ * (default: `.kt`/`.kts`/`.java`).
485
+ */
486
+ export function enforceProbeReversion(options) {
487
+ const filePattern = options.filePattern ?? DEFAULT_PROBE_FILE_PATTERN;
488
+ return function enforceProbeReversion(action) {
489
+ if (action.kind !== 'command')
490
+ return { kind: 'pass' };
491
+ if (!/git commit/.test(action.command))
492
+ return { kind: 'pass' };
493
+ const outstanding = options.roots
494
+ .flatMap((root) => walkFiles(root)
495
+ .filter((file) => filePattern.test(file))
496
+ .filter((file) => {
497
+ try {
498
+ return MUTATION_PROBE_MARKER.test(readFileSync(file, 'utf8'));
499
+ }
500
+ catch {
501
+ return false;
502
+ }
503
+ })
504
+ .map((file) => relative(root, file)));
505
+ if (outstanding.length === 0)
506
+ return { kind: 'pass' };
507
+ return {
508
+ kind: 'violation',
509
+ reason: 'Mutation probe(s) still on disk — a deliberate break made to ' +
510
+ 'prove a test bites must be reverted before committing ' +
511
+ '(git checkout -- <file> restores the original and removes ' +
512
+ 'the marker):\n' +
513
+ outstanding.map((file) => ` ${file}`).join('\n'),
514
+ };
515
+ };
516
+ }
517
+ /**
518
+ * Marker that declares a test a characterization test: the first test
519
+ * for behavior that already exists in production, so no natural red
520
+ * can precede it (the test is born green). Put it in a comment on or
521
+ * directly above the test function:
522
+ *
523
+ * // probity: characterization
524
+ * func testSignedInUserWithoutAnEntitlementIsOfferedPlans() async { … }
525
+ */
526
+ export const CHARACTERIZATION_MARKER = /probity:\s*characterization\b/;
527
+ const FUNCTION_NAME = /(?:func|fun)\s+`?([A-Za-z_]\w*)/;
528
+ /** Test names whose characterization markers this write removes,
529
+ * paired with `null` when a marker can't be tied to a function. */
530
+ function removedMarkerTests(before, after) {
531
+ const names = (content) => {
532
+ const lines = content.split('\n');
533
+ const found = [];
534
+ lines.forEach((line, index) => {
535
+ if (!CHARACTERIZATION_MARKER.test(line))
536
+ return;
537
+ for (let scan = index; scan < Math.min(index + 6, lines.length); scan++) {
538
+ const match = lines[scan].match(FUNCTION_NAME);
539
+ if (match) {
540
+ found.push(match[1]);
541
+ return;
542
+ }
543
+ }
544
+ found.push(null);
545
+ });
546
+ return found;
547
+ };
548
+ const remaining = new Set(names(after).filter(Boolean));
549
+ return names(before).filter((name) => name === null || !remaining.has(name));
550
+ }
551
+ /**
552
+ * Wraps a TDD rule to sanction the **characterization round-trip** —
553
+ * the only honest way to add the FIRST test for behavior that predates
554
+ * it (common on brownfield systems). A test for existing behavior is
555
+ * born green, so no red keyed to it can be observed before it exists;
556
+ * an unwrapped TDD gate correctly denies it, and a mutation probe
557
+ * can't help yet because a probe only fails tests that already exist.
558
+ * Without this wrapper the only ways out are an override or leaving
559
+ * the behavior unspecified.
560
+ *
561
+ * The round-trip, each step enforced:
562
+ *
563
+ * 1. Write the test carrying {@link CHARACTERIZATION_MARKER} — this
564
+ * wrapper passes it deterministically (test-layer paths only).
565
+ * 2. Run the suite green, then mutation-probe the production path
566
+ * (`// probity: mutation-probe`) and observe the new test FAIL —
567
+ * the recorded red is the proof the test bites.
568
+ * 3. Revert the probe. Remove the characterization marker — this
569
+ * wrapper allows the removal only when the session transcript
570
+ * records a test run in which the marked test failed.
571
+ * 4. Commit — {@link enforceCharacterizationResolution} blocks while
572
+ * any marker is still on disk, so an unproven characterization
573
+ * test can't land.
574
+ *
575
+ * The bypass is confined: only writes to paths matching
576
+ * `options.filePattern` (the test layer) skip the wrapped rule, so a
577
+ * production write can't borrow the marker. Same inherent limit as
578
+ * every transcript gate: reds observed in another terminal or CI are
579
+ * invisible — run the probe in-session.
580
+ *
581
+ * @param rule — the TDD rule to wrap (fast-paths/probe wrappers
582
+ * included as usual).
583
+ * @param options.filePattern — paths that hold test code (e.g.
584
+ * /AcceptanceTests[/\\]/ or /src[/\\]\w+Test[/\\]/). Required: it is
585
+ * the boundary that keeps the marker useless in production files.
586
+ */
587
+ export function withCharacterizationTest(rule, options) {
588
+ const wrapped = async function characterizationTest(action, ctx) {
589
+ if (action.kind !== 'write' || !options.filePattern.test(action.path)) {
590
+ return rule(action, ctx);
591
+ }
592
+ const before = await ctx?.readFile?.(action.path);
593
+ const beforeText = before?.kind === 'present' ? before.content : '';
594
+ const markerRemains = CHARACTERIZATION_MARKER.test(action.content);
595
+ const removed = removedMarkerTests(beforeText, action.content);
596
+ if (removed.length > 0) {
597
+ if (removed.some((name) => name === null)) {
598
+ return {
599
+ kind: 'violation',
600
+ reason: 'A characterization marker is being removed but could not ' +
601
+ 'be tied to a test function — keep the marker in a comment ' +
602
+ 'directly above the test it declares, and remove them ' +
603
+ 'together with the proof in hand.',
604
+ };
605
+ }
606
+ const history = (await ctx?.history?.()) ?? [];
607
+ const unproven = removed.filter((name) => !history.some((event) => event.kind === 'command' &&
608
+ 'output' in event &&
609
+ typeof event.output === 'string' &&
610
+ event.output.includes(name) &&
611
+ /fail/i.test(event.output)));
612
+ if (unproven.length > 0) {
613
+ return {
614
+ kind: 'violation',
615
+ reason: 'Characterization marker removed without a recorded red: no ' +
616
+ 'test run in this session shows the marked test(s) failing ' +
617
+ `(${unproven.join(', ')}). Prove the test bites first — ` +
618
+ 'mutation-probe the production path it specifies ' +
619
+ '(// probity: mutation-probe), run the suite, watch this ' +
620
+ 'test fail on its concluding assertion, revert the probe — ' +
621
+ 'then remove the marker.',
622
+ };
623
+ }
624
+ return { kind: 'pass', notes: [{ kind: 'characterization-resolved' }] };
625
+ }
626
+ if (markerRemains) {
627
+ return { kind: 'pass', notes: [{ kind: 'characterization' }] };
628
+ }
629
+ return rule(action, ctx);
630
+ };
631
+ Object.defineProperty(wrapped, 'name', {
632
+ value: `characterizationTest(${rule.name || 'rule'})`,
633
+ });
634
+ return wrapped;
635
+ }
636
+ /**
637
+ * The commit half of the characterization round-trip (see
638
+ * {@link withCharacterizationTest}): blocks `git commit` while any
639
+ * test file under `roots` still carries the characterization marker —
640
+ * the marker only comes off through the proof-checked removal path,
641
+ * so a characterization test that has never been observed failing
642
+ * cannot land. Deterministic filesystem scan — no AI call.
643
+ *
644
+ * Applies to: command actions matching `git commit`.
645
+ *
646
+ * @param options.roots — absolute paths to scan.
647
+ * @param options.filePattern — which files can carry the marker
648
+ * (default: `.kt`/`.kts`/`.java`).
649
+ */
650
+ export function enforceCharacterizationResolution(options) {
651
+ const filePattern = options.filePattern ?? DEFAULT_PROBE_FILE_PATTERN;
652
+ return function enforceCharacterizationResolution(action) {
653
+ if (action.kind !== 'command')
654
+ return { kind: 'pass' };
655
+ if (!/git commit/.test(action.command))
656
+ return { kind: 'pass' };
657
+ const outstanding = options.roots.flatMap((root) => walkFiles(root)
658
+ .filter((file) => filePattern.test(file))
659
+ .filter((file) => {
660
+ try {
661
+ return CHARACTERIZATION_MARKER.test(readFileSync(file, 'utf8'));
662
+ }
663
+ catch {
664
+ return false;
665
+ }
666
+ })
667
+ .map((file) => relative(root, file)));
668
+ if (outstanding.length === 0)
669
+ return { kind: 'pass' };
670
+ return {
671
+ kind: 'violation',
672
+ reason: 'Characterization marker(s) still on disk — a first test for ' +
673
+ 'pre-existing behavior must be proven to bite before it lands: ' +
674
+ 'mutation-probe the production path, watch the marked test ' +
675
+ 'fail, revert the probe, then remove the marker (the removal ' +
676
+ 'is checked against the recorded red):\n' +
677
+ outstanding.map((file) => ` ${file}`).join('\n'),
678
+ };
679
+ };
680
+ }
681
+ /**
682
+ * Kotlin/Android addendum for `enforcePortsBoundary` — pass as
683
+ * `enforcePortsBoundary({ instructions: (d) => d + KOTLIN_BOUNDARY_ADDENDUM })`
684
+ * and extend the "Project layout" section with your module/package
685
+ * conventions.
686
+ */
687
+ export const KOTLIN_BOUNDARY_ADDENDUM = `
688
+
689
+ ### Kotlin/Android specifics
690
+
691
+ - Ambient OS access in core code is a violation: \`Instant.now()\`
692
+ and friends, \`System.currentTimeMillis()\`, \`Date()\`,
693
+ \`UUID.randomUUID()\`, \`Random()\`, \`System.getenv\` — clock,
694
+ randomness, and environment are ports.
695
+ - Vendor/infrastructure packages (AWS SDK, Amplify, Apollo,
696
+ Firebase, OkHttp, Retrofit, Room, WorkManager, JDBC) belong in
697
+ adapter modules only. In core code, their types in signatures are
698
+ leaked boundaries.
699
+ - Dagger/DI modules, \`@Component\` definitions, and \`…di\` packages
700
+ are composition roots: they import both core and adapters by
701
+ design — always allowed.
702
+ - In tests, \`mockStatic\`/\`mockkStatic\`/\`mockkObject\`/
703
+ \`mockkConstructor\`/PowerMock are always violations. A
704
+ mockito-kotlin \`mock<T>()\` where T is a port interface is an
705
+ acceptable seam (though a shared fake is preferred); \`mock<T>()\`
706
+ of a concrete internal class is a violation — the substitute
707
+ belongs at a port.
708
+ - Robolectric in a test signals Android-framework coupling; that is
709
+ an adapter concern, fine in adapter/UI tests, a smell in tests of
710
+ core logic.
711
+ - A port need not be an interface. A function-typed constructor
712
+ parameter injected at the composition root (e.g.
713
+ \`nowEpochMillis: () -> Long\`, \`randomIv: () -> ByteArray\`) is a
714
+ valid seam — but a default value that calls the real OS
715
+ (\`= { System.currentTimeMillis() }\`) inside core/common code
716
+ defeats it; real defaults belong in platform adapters or DI
717
+ wiring.
718
+ - Kotlin Multiplatform: \`commonMain\` core code is the inside of
719
+ the hexagon; \`expect\`/\`actual\` pairs and per-platform source
720
+ sets (\`androidMain\`, \`iosMain\`, \`desktopMain\`) implementing a
721
+ common declaration are adapters and may touch platform APIs.`;
722
+ //# sourceMappingURL=kotlin.js.map