@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.
- package/CHANGELOG.md +18 -0
- package/GLOSSARY.template.md +33 -0
- package/README.md +96 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/presets/js.d.ts +63 -0
- package/dist/presets/js.d.ts.map +1 -0
- package/dist/presets/js.js +112 -0
- package/dist/presets/js.js.map +1 -0
- package/dist/presets/kmp.d.ts +27 -0
- package/dist/presets/kmp.d.ts.map +1 -0
- package/dist/presets/kmp.js +253 -0
- package/dist/presets/kmp.js.map +1 -0
- package/dist/presets/kotlin.d.ts +40 -0
- package/dist/presets/kotlin.d.ts.map +1 -0
- package/dist/presets/kotlin.js +172 -0
- package/dist/presets/kotlin.js.map +1 -0
- package/dist/presets/swift.d.ts +10 -0
- package/dist/presets/swift.d.ts.map +1 -0
- package/dist/presets/swift.js +285 -0
- package/dist/presets/swift.js.map +1 -0
- package/dist/rules/acceptance-language.d.ts +95 -0
- package/dist/rules/acceptance-language.d.ts.map +1 -0
- package/dist/rules/acceptance-language.js +443 -0
- package/dist/rules/acceptance-language.js.map +1 -0
- package/dist/rules/gates.d.ts +125 -0
- package/dist/rules/gates.d.ts.map +1 -0
- package/dist/rules/gates.js +285 -0
- package/dist/rules/gates.js.map +1 -0
- package/dist/rules/kotlin.d.ts +323 -0
- package/dist/rules/kotlin.d.ts.map +1 -0
- package/dist/rules/kotlin.js +722 -0
- package/dist/rules/kotlin.js.map +1 -0
- package/dist/rules/ports-and-adapters.d.ts +86 -0
- package/dist/rules/ports-and-adapters.d.ts.map +1 -0
- package/dist/rules/ports-and-adapters.js +366 -0
- package/dist/rules/ports-and-adapters.js.map +1 -0
- package/dist/rules/scoping.d.ts +68 -0
- package/dist/rules/scoping.d.ts.map +1 -0
- package/dist/rules/scoping.js +93 -0
- package/dist/rules/scoping.js.map +1 -0
- package/dist/rules/spec-test-parity.d.ts +164 -0
- package/dist/rules/spec-test-parity.d.ts.map +1 -0
- package/dist/rules/spec-test-parity.js +456 -0
- package/dist/rules/spec-test-parity.js.map +1 -0
- package/dist/rules/swift.d.ts +50 -0
- package/dist/rules/swift.d.ts.map +1 -0
- package/dist/rules/swift.js +50 -0
- package/dist/rules/swift.js.map +1 -0
- package/dist/rules/ubiquitous-language.d.ts +36 -0
- package/dist/rules/ubiquitous-language.d.ts.map +1 -0
- package/dist/rules/ubiquitous-language.js +140 -0
- package/dist/rules/ubiquitous-language.js.map +1 -0
- package/dist/scripts/scope-report.d.ts +3 -0
- package/dist/scripts/scope-report.d.ts.map +1 -0
- package/dist/scripts/scope-report.js +184 -0
- package/dist/scripts/scope-report.js.map +1 -0
- package/kiro/README.md +20 -0
- package/kiro/kiro-agent.template.json +45 -0
- package/kiro/kiro-transcript-to-claude.py +183 -0
- package/kiro/probity-kiro-translate.py +132 -0
- package/kiro/probity-kiro.sh +87 -0
- package/kiro/skill-activation-forced-eval.sh +45 -0
- package/package.json +68 -0
- package/probity.config.kmp.ts +40 -0
- package/probity.config.kotlin.ts +37 -0
- package/probity.config.swift.ts +38 -0
- package/probity.config.ts +43 -0
- 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
|