pi-helper-core 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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Rank candidate test files against changed paths.
3
+ *
4
+ * Every ecosystem answers "what is a test file" and "what module does this path
5
+ * become" differently, so those questions are injected. The ranking itself —
6
+ * pytest conventions first, fuzzy token overlap second, ubiquitous signals
7
+ * discarded — has proven stable across ecosystems and lives here.
8
+ */
9
+
10
+ export interface TestSelection {
11
+ path: string;
12
+ score: number;
13
+ reason: string;
14
+ }
15
+
16
+ export interface SelectionResult {
17
+ selected: TestSelection[];
18
+ /** True when no changed file could be mapped and every test file is returned. */
19
+ fellBackToAll: boolean;
20
+ /**
21
+ * False when the selection covers every considered test file, so the
22
+ * candidate list was not narrowed at all. Reported so a caller does not read
23
+ * "30 of 30 selected" as a focused run.
24
+ */
25
+ narrowed: boolean;
26
+ changedSourceFiles: string[];
27
+ changedTestFiles: string[];
28
+ consideredTestFiles: string[];
29
+ /** Files under a test directory that the runner does not collect tests from. */
30
+ supportFiles: string[];
31
+ /**
32
+ * True when at least one candidate was matched by an actual import of a
33
+ * changed module, which is the strongest available signal. False means the
34
+ * selection rests on naming conventions alone.
35
+ */
36
+ importEvidenceUsed: boolean;
37
+ }
38
+
39
+ /**
40
+ * Dotted module names imported by each test file, keyed by test path.
41
+ *
42
+ * Supplied by the project model because a test named `test_db_session.py` gives
43
+ * no naming hint that it covers `db/database.py`; its imports do.
44
+ */
45
+ export type TestImportMap = Record<string, string[]>;
46
+
47
+ export interface SelectionSignals {
48
+ isSourceFile(path: string): boolean;
49
+ isTestFile(path: string): boolean;
50
+ /** True when the runner collects tests from this file at all. */
51
+ isRunnableTestFile(path: string): boolean;
52
+ /** Lowercase words a path contributes, used for fuzzy matching. */
53
+ pathTokens(path: string): string[];
54
+ /** Dotted module names a source path can be imported as. */
55
+ moduleNamesForFile(path: string): string[];
56
+ /** First package segment of a path, used to detect a package-rooted test tree. */
57
+ packageName(path: string): string;
58
+ /**
59
+ * Basenames that affect every test and so are always in scope once tests run
60
+ * (a shared fixture file, a test module root).
61
+ */
62
+ supportFileNames?: ReadonlySet<string>;
63
+ /** Affixes stripped from a test file name before stems are compared. */
64
+ testNameAffixes?: { prefixes: string[]; suffixes: string[] };
65
+ /** Share of candidates a value must reach before it stops being informative. */
66
+ ubiquitousShare?: number;
67
+ }
68
+
69
+ export interface SelectionOptions {
70
+ testImports?: TestImportMap;
71
+ }
72
+
73
+ export const DEFAULT_SCORE = {
74
+ changedTestItself: 100,
75
+ /** Importing the changed module is stronger than any name coincidence. */
76
+ importsChangedModule: 90,
77
+ sameStemSameDir: 80,
78
+ sameStem: 60,
79
+ sameDirectory: 40,
80
+ sharedToken: 20,
81
+ sharedModule: 30,
82
+ } as const;
83
+
84
+ function toPosix(path: string): string {
85
+ return path.replace(/\\/g, '/');
86
+ }
87
+
88
+ function parentDir(path: string): string {
89
+ const posix = toPosix(path);
90
+ const index = posix.lastIndexOf('/');
91
+ return index === -1 ? '' : posix.slice(0, index);
92
+ }
93
+
94
+ function baseName(path: string): string {
95
+ const posix = toPosix(path);
96
+ const index = posix.lastIndexOf('/');
97
+ return index === -1 ? posix : posix.slice(index + 1);
98
+ }
99
+
100
+ function stem(path: string): string {
101
+ return baseName(path).replace(/\.[A-Za-z0-9]+$/, '');
102
+ }
103
+
104
+ /** Strip the ecosystem's test affixes so `test_parser` and `parser` compare equal. */
105
+ export function normalizedStem(path: string, signals: SelectionSignals): string {
106
+ const affixes = signals.testNameAffixes ?? { prefixes: [], suffixes: [] };
107
+ let name = stem(path);
108
+ for (const prefix of affixes.prefixes) {
109
+ if (name.startsWith(prefix)) name = name.slice(prefix.length);
110
+ }
111
+ for (const suffix of affixes.suffixes) {
112
+ if (name.endsWith(suffix)) name = name.slice(0, -suffix.length);
113
+ }
114
+ return name.toLowerCase();
115
+ }
116
+
117
+ /** True when a test imports the module, or a parent package of it. */
118
+ function importsModule(imported: string[], modules: string[]): string | undefined {
119
+ let best: string | undefined;
120
+ for (const candidate of modules) {
121
+ for (const entry of imported) {
122
+ const matches =
123
+ entry === candidate ||
124
+ entry.startsWith(`${candidate}.`) ||
125
+ candidate.startsWith(`${entry}.`);
126
+ if (!matches) continue;
127
+ // Report the most specific import: naming `pkg` when the file actually
128
+ // imports `pkg.routes.admin` overstates how broadly the test is coupled.
129
+ if (!best || entry.length > best.length) best = entry;
130
+ }
131
+ }
132
+ return best;
133
+ }
134
+
135
+ /**
136
+ * Values that appear in at least this share of the candidates carry no
137
+ * information about *which* candidate to run: in a project whose tests all live
138
+ * inside the package under test, the package name matches every file.
139
+ */
140
+ const DEFAULT_UBIQUITOUS_SHARE = 0.5;
141
+
142
+ function ubiquitousValues(documents: string[][], share: number): Set<string> {
143
+ const counts = new Map<string, number>();
144
+ for (const values of documents) {
145
+ for (const value of new Set(values)) {
146
+ if (value.length === 0) continue;
147
+ counts.set(value, (counts.get(value) ?? 0) + 1);
148
+ }
149
+ }
150
+ const threshold = Math.max(2, documents.length * share);
151
+ const ubiquitous = new Set<string>();
152
+ for (const [value, count] of counts) {
153
+ if (count >= threshold) ubiquitous.add(value);
154
+ }
155
+ return ubiquitous;
156
+ }
157
+
158
+ /**
159
+ * Rank test files against changed paths.
160
+ *
161
+ * Signals shared by every candidate are discarded rather than scored. Without
162
+ * that step a package-rooted test tree matches its own package on every file and
163
+ * the selection degenerates into the full suite while appearing focused.
164
+ */
165
+ export function selectTests(
166
+ changedPaths: string[],
167
+ testFiles: string[],
168
+ signals: SelectionSignals,
169
+ options: SelectionOptions = {},
170
+ ): SelectionResult {
171
+ const share = signals.ubiquitousShare ?? DEFAULT_UBIQUITOUS_SHARE;
172
+ const changed = changedPaths
173
+ .map(toPosix)
174
+ .filter((path) => signals.isSourceFile(path) || signals.isTestFile(path));
175
+ const changedSourceFiles = changed.filter((path) => !signals.isTestFile(path));
176
+ const changedTestFiles = changed.filter((path) => signals.isTestFile(path));
177
+ const considered = [...new Set(testFiles.map(toPosix))]
178
+ .filter((path) => signals.isSourceFile(path) || signals.isTestFile(path))
179
+ .sort();
180
+ const supportFiles = considered.filter((path) => !signals.isRunnableTestFile(path));
181
+ const testImports = options.testImports ?? {};
182
+
183
+ if (!changed.length) {
184
+ return {
185
+ selected: [],
186
+ fellBackToAll: false,
187
+ narrowed: false,
188
+ changedSourceFiles,
189
+ changedTestFiles,
190
+ consideredTestFiles: considered,
191
+ supportFiles,
192
+ importEvidenceUsed: false,
193
+ };
194
+ }
195
+
196
+ const sourceTokens = new Set(changedSourceFiles.flatMap((path) => signals.pathTokens(path)));
197
+ const sourceModules = new Set(changedSourceFiles.map((path) => signals.packageName(path)));
198
+ const sourceStems = new Set(changedSourceFiles.map((path) => normalizedStem(path, signals)));
199
+ const sourceModulePaths = changedSourceFiles.map((path) => signals.moduleNamesForFile(path));
200
+
201
+ const ubiquitousTokens = ubiquitousValues(
202
+ considered.map((path) => signals.pathTokens(path)),
203
+ share,
204
+ );
205
+ const ubiquitousModules = ubiquitousValues(
206
+ considered.map((path) => [signals.packageName(path)]),
207
+ share,
208
+ );
209
+ // A source directory that contains every test file (the package root) cannot
210
+ // distinguish candidates, so it does not score.
211
+ const sourceDirs = new Set(
212
+ [...new Set(changedSourceFiles.map(parentDir))].filter(
213
+ (directory) =>
214
+ directory === '' || !considered.every((path) => path.startsWith(`${directory}/`)),
215
+ ),
216
+ );
217
+
218
+ let importEvidenceUsed = false;
219
+ const selections: TestSelection[] = [];
220
+ for (const testFile of considered) {
221
+ // Test infrastructure is not a target, but it can still affect the run, so
222
+ // it is reported separately instead of being scored as a test file.
223
+ if (!signals.isRunnableTestFile(testFile)) continue;
224
+
225
+ const reasons: string[] = [];
226
+ let score = 0;
227
+
228
+ if (changedTestFiles.includes(testFile)) {
229
+ score += DEFAULT_SCORE.changedTestItself;
230
+ reasons.push('the test file itself changed');
231
+ }
232
+
233
+ const imported = testImports[testFile];
234
+ if (imported && imported.length > 0) {
235
+ const matched = sourceModulePaths
236
+ .map((modules) => importsModule(imported, modules))
237
+ .find((value) => value !== undefined);
238
+ if (matched) {
239
+ score += DEFAULT_SCORE.importsChangedModule;
240
+ importEvidenceUsed = true;
241
+ reasons.push(`imports the changed module "${matched}"`);
242
+ }
243
+ }
244
+
245
+ const testStem = normalizedStem(testFile, signals);
246
+ const testDir = parentDir(testFile);
247
+ if (sourceStems.has(testStem)) {
248
+ score += DEFAULT_SCORE.sameStem;
249
+ reasons.push(`module name matches "${testStem}"`);
250
+ if (sourceDirs.has(testDir)) {
251
+ score += DEFAULT_SCORE.sameStemSameDir - DEFAULT_SCORE.sameStem;
252
+ reasons.push('same directory as the changed module');
253
+ }
254
+ } else if (sourceDirs.has(testDir)) {
255
+ score += DEFAULT_SCORE.sameDirectory;
256
+ reasons.push('same directory as a changed module');
257
+ } else if (sourceDirs.has(parentDir(testDir))) {
258
+ score += DEFAULT_SCORE.sameDirectory - 10;
259
+ reasons.push('nested under a changed directory');
260
+ }
261
+
262
+ const module = signals.packageName(testFile);
263
+ if (module && sourceModules.has(module) && !ubiquitousModules.has(module)) {
264
+ score += DEFAULT_SCORE.sharedModule;
265
+ reasons.push(`covers module "${module}"`);
266
+ }
267
+
268
+ const tokens = signals.pathTokens(testFile);
269
+ const shared = tokens.filter(
270
+ (token) => sourceTokens.has(token) && !ubiquitousTokens.has(token),
271
+ );
272
+ if (shared.length) {
273
+ score += DEFAULT_SCORE.sharedToken * Math.min(shared.length, 2);
274
+ reasons.push(`shares token(s): ${shared.slice(0, 4).join(', ')}`);
275
+ }
276
+
277
+ if (score > 0) {
278
+ selections.push({ path: testFile, score, reason: reasons.join('; ') });
279
+ }
280
+ }
281
+
282
+ selections.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
283
+
284
+ // A shared fixture can affect every test, so it is always in scope once tests run.
285
+ const supportNames = signals.supportFileNames ?? new Set<string>();
286
+ for (const testFile of considered) {
287
+ if (supportNames.has(baseName(testFile)) && !selections.some((s) => s.path === testFile)) {
288
+ selections.push({ path: testFile, score: 1, reason: 'shared test fixture scope' });
289
+ }
290
+ }
291
+
292
+ const runnableConsidered = considered.filter((path) => signals.isRunnableTestFile(path));
293
+ const selectedRunnable = selections.filter((entry) => signals.isRunnableTestFile(entry.path));
294
+ const fellBackToAll = selections.length === 0 && considered.length > 0;
295
+ if (fellBackToAll) {
296
+ return {
297
+ selected: considered.map((path) => ({
298
+ path,
299
+ score: 0,
300
+ reason: 'no match; running the full suite',
301
+ })),
302
+ fellBackToAll: true,
303
+ narrowed: false,
304
+ changedSourceFiles,
305
+ changedTestFiles,
306
+ consideredTestFiles: considered,
307
+ supportFiles,
308
+ importEvidenceUsed,
309
+ };
310
+ }
311
+
312
+ return {
313
+ selected: selections,
314
+ fellBackToAll: false,
315
+ narrowed: selectedRunnable.length < runnableConsidered.length,
316
+ changedSourceFiles,
317
+ changedTestFiles,
318
+ consideredTestFiles: considered,
319
+ supportFiles,
320
+ importEvidenceUsed,
321
+ };
322
+ }
@@ -0,0 +1,131 @@
1
+ export interface ValidationStep {
2
+ /** Step label, used for the quality checks (`ruff`, `clippy`). */
3
+ name?: string;
4
+ executed: boolean;
5
+ ok: boolean;
6
+ exitCode?: number | null;
7
+ failures?: number;
8
+ /**
9
+ * True when the runner reported success without executing any test. A run
10
+ * that tested nothing is the most common false green, so it is never evidence.
11
+ */
12
+ noTestsRan?: boolean;
13
+ /**
14
+ * Why a step was not executed. A step that was skipped deliberately (the
15
+ * environment is missing the tool it needs) must say so, otherwise the run
16
+ * looks like an unexplained test failure.
17
+ */
18
+ skippedReason?: string;
19
+ }
20
+
21
+ /**
22
+ * Concrete wording for the reasons. The adapter supplies the command names it
23
+ * actually runs so the message reads naturally without the core knowing them.
24
+ */
25
+ export interface ValidationLabels {
26
+ lock: string;
27
+ preparation: string;
28
+ stale: string;
29
+ }
30
+
31
+ export interface ValidationSummary {
32
+ ok: boolean;
33
+ reason: string;
34
+ checks: {
35
+ lock: boolean;
36
+ preparation: boolean;
37
+ test: boolean;
38
+ conformance: boolean;
39
+ /**
40
+ * True when every configured quality command passed. Vacuously true when the
41
+ * project declares none, so a project without lint/type tooling is not
42
+ * penalised.
43
+ */
44
+ quality: boolean;
45
+ staleArtifacts: boolean;
46
+ };
47
+ }
48
+
49
+ const DEFAULT_LABELS: ValidationLabels = {
50
+ lock: 'the lockfile check',
51
+ preparation: 'the preparation step',
52
+ stale: 'a stale artifact',
53
+ };
54
+
55
+ const PREVIEW_REASON = 'Set execute=true to run the validation bundle.';
56
+
57
+ /**
58
+ * Summarise a verification sequence as a single gate.
59
+ *
60
+ * Conformance must be proven, not merely not-failed: a test run that passed
61
+ * against versions the lockfile does not describe is not evidence, so an
62
+ * `unverifiable` verdict fails the gate exactly like drift does.
63
+ *
64
+ * A test step that was never executed is also not evidence, and when the reason
65
+ * is known (the environment no longer provides the test runner) that reason is
66
+ * reported instead of a generic failure.
67
+ */
68
+ export function summarizeValidation(input: {
69
+ lock: ValidationStep;
70
+ preparation: ValidationStep;
71
+ test: ValidationStep;
72
+ /** Declared lint/type commands; omitted when the project declares none. */
73
+ quality?: ValidationStep[];
74
+ conformance: 'consistent' | 'drifted' | 'unverifiable';
75
+ stale: boolean;
76
+ /** True when nothing was executed because the caller only asked for a preview. */
77
+ preview?: boolean;
78
+ labels?: Partial<ValidationLabels>;
79
+ }): ValidationSummary {
80
+ const labels = { ...DEFAULT_LABELS, ...input.labels };
81
+ const lock = input.lock.executed && input.lock.ok;
82
+ const preparation = input.preparation.executed && input.preparation.ok;
83
+ const test =
84
+ input.test.executed &&
85
+ input.test.ok &&
86
+ (input.test.failures ?? 0) === 0 &&
87
+ input.test.noTestsRan !== true;
88
+ const qualitySteps = input.quality ?? [];
89
+ const quality = qualitySteps.every((step) => step.executed && step.ok);
90
+ const failedQuality = qualitySteps.filter((step) => !step.executed || !step.ok);
91
+ const conformance = input.conformance === 'consistent';
92
+ const staleArtifacts = input.stale;
93
+
94
+ let reason = `${labels.lock} passed, ${labels.preparation} passed, the tests passed, and the installed versions agree with the lockfile.`;
95
+ if (input.preview) {
96
+ reason = PREVIEW_REASON;
97
+ } else if (!input.lock.executed || !input.preparation.executed) {
98
+ reason = !input.lock.executed
99
+ ? `${labels.lock} was not executed, so lockfile agreement is unproven.`
100
+ : `${labels.preparation} was not executed, so the environment the tests ran in is unknown.`;
101
+ } else if (!lock) {
102
+ reason = `The lockfile is out of date; refresh it before trusting any test result.`;
103
+ } else if (!preparation) {
104
+ reason = `${labels.preparation} could not be completed from the locked state.`;
105
+ } else if (!input.test.executed) {
106
+ reason =
107
+ input.test.skippedReason ?? 'Tests were not executed, so no test result exists to report.';
108
+ } else if (input.test.noTestsRan) {
109
+ reason = 'The test run completed without executing any test, so it proves nothing.';
110
+ } else if (!test) {
111
+ reason = 'Tests failed; inspect the first failing case and its project frame.';
112
+ } else if (input.conformance === 'drifted') {
113
+ reason =
114
+ 'Tests passed, but the installed versions do not match the lockfile, so the run does not describe the locked environment.';
115
+ } else if (input.conformance === 'unverifiable') {
116
+ reason =
117
+ 'The installed environment could not be compared with the lockfile, so the passing test run is not proven to be on the locked versions.';
118
+ } else if (failedQuality.length > 0) {
119
+ const names = failedQuality.map((step) => step.name ?? 'quality check').join(', ');
120
+ reason = `Tests passed, but the declared quality check(s) failed: ${names}.`;
121
+ } else if (staleArtifacts) {
122
+ reason = `Tests passed, but ${labels.stale} was detected; refresh it and rerun.`;
123
+ }
124
+ return {
125
+ // A preview is never a passing validation: nothing was executed, so nothing
126
+ // is proven, regardless of how the placeholder steps were filled in.
127
+ ok: !input.preview && lock && preparation && test && conformance && quality && !staleArtifacts,
128
+ reason,
129
+ checks: { lock, preparation, test, conformance, quality, staleArtifacts },
130
+ };
131
+ }
@@ -0,0 +1,48 @@
1
+ export interface PreparationStage {
2
+ /** Stable identifier, for example `environment` or `build`. */
3
+ name: string;
4
+ /**
5
+ * Human label used in blocker messages. The adapter supplies the concrete
6
+ * wording (`uv sync`, `cargo build`) so the core stays ecosystem-neutral.
7
+ */
8
+ label: string;
9
+ executed: boolean;
10
+ ok: boolean;
11
+ }
12
+
13
+ export interface CompletionEvidenceInput {
14
+ preparation: PreparationStage;
15
+ testExecuted: boolean;
16
+ testOk: boolean;
17
+ stale: boolean;
18
+ changedPaths: string[];
19
+ }
20
+
21
+ export interface CompletionEvidence {
22
+ ok: boolean;
23
+ blockers: string[];
24
+ changedPaths: string[];
25
+ }
26
+
27
+ /**
28
+ * Completion is only proven when the preparation stage ran and the tests
29
+ * actually ran. Declaring completion on a partial run is a blocker, never a
30
+ * warning, because a partial run is exactly how a false "done" is reported.
31
+ */
32
+ export function buildCompletionEvidence(input: CompletionEvidenceInput): CompletionEvidence {
33
+ const blockers: string[] = [];
34
+ const { preparation } = input;
35
+ if (!preparation.executed) {
36
+ blockers.push(`The ${preparation.label} was not executed.`);
37
+ } else if (!preparation.ok) {
38
+ blockers.push(`The ${preparation.label} did not pass.`);
39
+ }
40
+ if (!input.testExecuted) blockers.push('Tests were not executed.');
41
+ else if (!input.testOk) blockers.push('Tests did not pass.');
42
+ if (input.stale) {
43
+ blockers.push(
44
+ 'Stale artifacts were detected, so the test result does not describe the current sources.',
45
+ );
46
+ }
47
+ return { ok: blockers.length === 0, blockers, changedPaths: input.changedPaths };
48
+ }
@@ -0,0 +1,152 @@
1
+ /** A production file and a test file that were matched to each other. */
2
+ export interface TddAssociation {
3
+ source: string;
4
+ test: string;
5
+ /** Tokens both paths share, so the caller can judge the match. */
6
+ sharedTokens: string[];
7
+ /**
8
+ * `module` when the match goes beyond the path prefix every file shares,
9
+ * `package` when only the common package prefix matched. A package-level match
10
+ * still passes the checkpoint, but it is weak evidence and is disclosed.
11
+ */
12
+ strength: 'module' | 'package';
13
+ }
14
+
15
+ export interface TddCheckpoint {
16
+ ok: boolean;
17
+ reasons: string[];
18
+ sourceChanges: string[];
19
+ testChanges: string[];
20
+ associations: TddAssociation[];
21
+ /** True when a match rested only on the shared package prefix. */
22
+ weakAssociation: boolean;
23
+ }
24
+
25
+ /**
26
+ * Which files count as production code or tests, and which words in a path
27
+ * carry no information. Every ecosystem answers this differently: the core must
28
+ * not assume a `.py` suffix or a `test_` prefix.
29
+ */
30
+ export interface TddSignals {
31
+ isSourceFile(path: string): boolean;
32
+ isTestFile(path: string): boolean;
33
+ /**
34
+ * Tokens that appear in the prefix of most paths and so cannot distinguish
35
+ * two modules (`tests`, `src`, `lib`).
36
+ */
37
+ prefixTokens: ReadonlySet<string>;
38
+ /** Tokens shorter than this cannot distinguish two module names. */
39
+ minTokenLength?: number;
40
+ }
41
+
42
+ const DEFAULT_MIN_TOKEN_LENGTH = 4;
43
+
44
+ function tokens(path: string, signals: TddSignals): string[] {
45
+ const minLength = signals.minTokenLength ?? DEFAULT_MIN_TOKEN_LENGTH;
46
+ return path
47
+ .replace(/\.[A-Za-z0-9]+$/, '')
48
+ .split(/[^A-Za-z0-9]+/)
49
+ .map((token) => token.toLowerCase())
50
+ .filter((token) => token.length >= minLength && !signals.prefixTokens.has(token));
51
+ }
52
+
53
+ function sharedTokens(source: string, test: string, signals: TddSignals): string[] {
54
+ const testTokens = tokens(test, signals);
55
+ const shared: string[] = [];
56
+ for (const sourceToken of new Set(tokens(source, signals))) {
57
+ if (
58
+ testTokens.some(
59
+ (testToken) =>
60
+ sourceToken === testToken ||
61
+ sourceToken.startsWith(testToken) ||
62
+ testToken.startsWith(sourceToken),
63
+ )
64
+ ) {
65
+ shared.push(sourceToken);
66
+ }
67
+ }
68
+ return shared;
69
+ }
70
+
71
+ /**
72
+ * Tokens contributed by the directory prefix every changed path shares.
73
+ *
74
+ * In a project whose tests live inside the package under test, every path
75
+ * starts with the package name, so those tokens say nothing about whether a
76
+ * specific test covers a specific module. A common prefix of nothing (no shared
77
+ * directory) yields no exclusions, so an exact name match is never downgraded.
78
+ */
79
+ function commonPrefixTokens(paths: string[], signals: TddSignals): Set<string> {
80
+ if (paths.length < 2) return new Set();
81
+ const directories = paths.map((path) => path.replace(/\\/g, '/').split('/').slice(0, -1));
82
+ const [first, ...rest] = directories;
83
+ const common: string[] = [];
84
+ for (let index = 0; index < first.length; index += 1) {
85
+ const segment = first[index];
86
+ if (rest.every((entry) => entry[index] === segment)) common.push(segment);
87
+ else break;
88
+ }
89
+ return new Set(common.flatMap((segment) => tokens(segment, signals)));
90
+ }
91
+
92
+ /**
93
+ * Check that production changes are accompanied by a plausibly related test
94
+ * change.
95
+ *
96
+ * Matching is name-based on purpose: it is cheap, deterministic, and only used
97
+ * to decide whether to run the heavier verification bundle. Because it is only
98
+ * name-based, it also reports *why* each pair matched and downgrades a match
99
+ * that rests solely on the package prefix instead of silently counting it as
100
+ * strong evidence.
101
+ */
102
+ export function checkTdd(
103
+ changedPaths: string[],
104
+ testChangedPaths: string[],
105
+ signals: TddSignals,
106
+ ): TddCheckpoint {
107
+ const all = [...new Set([...changedPaths, ...testChangedPaths])].map((path) =>
108
+ path.replace(/\\/g, '/'),
109
+ );
110
+ const sourceChanges = all.filter(
111
+ (path) => signals.isSourceFile(path) && !signals.isTestFile(path),
112
+ );
113
+ const testChanges = all.filter((path) => signals.isTestFile(path));
114
+
115
+ // `pkg/db/database.py` and `pkg/tests/unit/test_db.py` share `pkg` with every
116
+ // other file in the project, so those tokens are not evidence of a relation.
117
+ const ubiquitous = commonPrefixTokens([...sourceChanges, ...testChanges], signals);
118
+
119
+ const associations: TddAssociation[] = [];
120
+ for (const source of sourceChanges) {
121
+ for (const test of testChanges) {
122
+ const shared = sharedTokens(source, test, signals);
123
+ if (shared.length === 0) continue;
124
+ const discriminating = shared.filter((token) => !ubiquitous.has(token));
125
+ associations.push({
126
+ source,
127
+ test,
128
+ sharedTokens: shared,
129
+ strength: discriminating.length > 0 ? 'module' : 'package',
130
+ });
131
+ }
132
+ }
133
+
134
+ const strong = associations.some((entry) => entry.strength === 'module');
135
+ const weakAssociation = associations.length > 0 && !strong;
136
+
137
+ const reasons: string[] = [];
138
+ if (sourceChanges.length > 0 && testChanges.length === 0) {
139
+ reasons.push('Production source files changed without any test file change.');
140
+ } else if (sourceChanges.length > 0 && associations.length === 0) {
141
+ reasons.push('Changed test files do not appear related to the changed production modules.');
142
+ }
143
+
144
+ return {
145
+ ok: reasons.length === 0,
146
+ reasons,
147
+ sourceChanges,
148
+ testChanges,
149
+ associations,
150
+ weakAssociation,
151
+ };
152
+ }