@@ -2,26 +2,95 @@ export function isTestPath(file: string): boolean {
22 return (
33 / ( ^ | \/ ) ( t e s t | t e s t s | s p e c | _ _ t e s t s _ _ ) \/ / i. test ( file ) ||
44 / ( ^ | \/ ) s r c \/ t e s t \/ / i. test ( file ) ||
5- / ( ^ | \/ ) [ ^ / ] + _ t e s t \. ( g o | p y | r b | d a r t ) $ / i. test ( file ) ||
6- / ( ^ | \/ ) t e s t _ [ ^ / ] * \. p y $ / i. test ( file ) ||
5+ / ( ^ | \/ ) [ ^ / ] + _ t e s t \. ( g o | p y | r b | d a r t ) $ / i. test ( file ) || // Dart/Flutter `foo_test.dart` co-located with source
6+ / ( ^ | \/ ) t e s t _ [ ^ / ] * \. p y $ / i. test ( file ) || // pytest's default `test_*.py` prefix convention (the suffix rule above only catches `*_test.py`)
77 / ( ^ | \/ ) [ ^ / ] + _ s p e c \. r b $ / i. test ( file ) ||
88 / \. ( t e s t | s p e c ) \. ( t s | t s x | m t s | c t s | j s | j s x | m j s | c j s | p y | r b | r s ) $ / i. test ( file ) ||
99 / ( ^ | \/ ) [ ^ / ] + \. ( c y | e 2 e ) \. ( t s | t s x | m t s | c t s | j s | j s x | m j s | c j s ) $ / i. test ( file ) ||
10+ // JVM / C# / Swift / PHP `SomethingTest(s)`/`SomethingSpec` class-suffix convention
11+ // (JUnit, Kotlin/ScalaTest, Spock, xUnit/NUnit, XCTest, PHPUnit/PHPSpec). Case-sensitive on the
12+ // PascalCase suffix so it can't false-positive on words that merely end in
13+ // "test"/"spec" (Latest.java, Contest.cs, manifest.scala, Latest.php).
1014 / ( ^ | \/ ) \w * ( T e s t s ? | S p e c ) \. ( j a v a | k t | k t s | s c a l a | c s | s w i f t | g r o o v y | p h p ) $ / . test ( file ) ||
1115 / ( ^ | \/ ) _ _ s n a p s h o t s _ _ \/ / i. test ( file )
1216 ) ;
1317}
1418
19+ // Canonical hand-authored-source extensions — the SOURCE-side sibling of isTestPath's class-suffix rule.
20+ // The two matchers MUST stay symmetric: isTestPath recognizes java/kt/kts/scala/cs/swift/groovy test files,
21+ // so this set lists those same languages. Otherwise a C#/Swift/Groovy/Kotlin-script SOURCE change is classified
22+ // as neither code nor test and silently escapes both the missing-tests gate signals and token scoring.
23+ const SOURCE_FILE_EXTENSION = / \. ( t s | t s x | m t s | c t s | j s | j s x | m j s | c j s | p y | r b | r s | k t | k t s | s c a l a | j a v a | c s | s w i f t | g r o o v y | g o | s q l ) $ / i;
24+
25+ /** True iff `file` is a hand-authored program-source file: a recognized source extension that is not itself a
26+ * test file. The single source of truth for every `isCodeFile` in the signals layer, so the source/test
27+ * classifiers can never drift (the same way isCodeFile's `isTestFile` wrappers all delegate to isTestPath). */
28+ export function isSourcePath ( file : string ) : boolean {
29+ return SOURCE_FILE_EXTENSION . test ( file ) && ! isTestPath ( file ) ;
30+ }
31+
32+ // Extensions recognized as code outside isSourcePath's core set (php, native, front-end frameworks, Dart).
33+ // isSourcePath owns the JVM/.NET/Swift/Groovy/Kotlin-script set symmetric with isTestPath.
34+ const EXTENDED_SOURCE_EXTENSION = / \. ( p h p | c p p | c c | c | h | h p p | m | v u e | s v e l t e | a s t r o | d a r t ) $ / i;
35+
36+ /** cs/swift/groovy/kts plus php, C/C++/Objective-C, vue/svelte/astro, and dart — see isSourcePath for the
37+ * canonical JVM/.NET/Swift/Groovy/Kotlin-script matcher kept symmetric with isTestPath. Generated Dart part
38+ * files (.g.dart/.freezed.dart/.gr.dart) stay non-code (#3724). The single source of truth both the Worker
39+ * (src/signals/path-matchers.ts) and the published @jsonbored/gittensory-mcp/gittensory-miner CLIs delegate
40+ * to, so the three previously-independent hand-ports can't silently drift from each other again. */
41+ export function isCodeFile ( file : string ) : boolean {
42+ if ( isSourcePath ( file ) ) return true ;
43+ return EXTENDED_SOURCE_EXTENSION . test ( file ) && ! isTestPath ( file ) && ! / \. ( g | f r e e z e d | g r ) \. d a r t $ / i. test ( file ) ;
44+ }
45+
46+ export function hasLocalTestEvidence ( input : { tests ?: string [ ] | undefined ; testFiles ?: string [ ] | undefined } ) : boolean {
47+ return ( input . tests ?? [ ] ) . length > 0 || ( input . testFiles ?? [ ] ) . some ( ( file ) => isTestPath ( file ) ) ;
48+ }
49+
50+ // A body can mention testing without having actually done it ("No tests run", "Tests not run", "Not
51+ // tested locally", "did not run any tests") -- the affirmative keyword match below would otherwise treat
52+ // that as passing evidence and let a configured manifest test expectation silently disappear. Rather than
53+ // enumerate ever more literal phrase templates (which a previous version of this function tried, and which
54+ // still missed "Not tested" because its test-noun list didn't include the verb form "tested"), detect
55+ // negation by PROXIMITY: a negation word within a few words of a test/validation stem, in either order,
56+ // with a shared stem definition so the "is this a test/validation mention at all" question is answered
57+ // exactly once. The filler between the negation word and the stem may not cross a clause/sentence boundary
58+ // (a comma, period, exclamation mark, or question mark), so an unrelated "not" earlier in the body (e.g.
59+ // "This is not a breaking change. Tested with npm run test:ci.") cannot suppress a later, unrelated
60+ // affirmative note. A colon, semicolon, or dash is deliberately NOT a hard boundary here -- see
61+ // LABEL_SEPARATOR_GAP below.
1562const TEST_STEM = "(?:test(?:ed|s|ing)?|validat(?:ion|ed)|verif(?:y|ied|ying)|manual check|smoke(?:\\s+tests?)?)" ;
1663const NEGATION_WORD = "(?:no|not|never|without|skip(?:ped)?|didn't|doesn't|isn't|wasn't|weren't|haven't|hasn't)" ;
1764const NEGATION_CONTINUATION = "(?:not|never|failed|failing|skipped|incomplete)" ;
1865const SAME_SENTENCE_FILLER_WORD = "[^\\s.,!?;]+" ;
66+ // A label-style status line often glues its separator directly onto the negation word or stem with no
67+ // surrounding whitespace ("Tests: not run.", "Validation; skipped.", "Tests - not run."). The plain
68+ // `\s+` gap below would never match across that punctuation, so the negation went undetected and the
69+ // bare "Tests"/"Validation" keyword fell through to the affirmative check instead (#3304, round 4).
70+ // Allow ONE label separator (colon, semicolon, or a hyphen/en-dash/em-dash) with any trailing
71+ // whitespace to stand in for the mandatory whitespace, but only at the junction touching the negation
72+ // word or stem itself -- every other gap between filler words stays pure whitespace, so a label
73+ // separator elsewhere in the sentence still cannot let a negation reach across unrelated content (the
74+ // filler-word bound below already exists for exactly this reason).
1975const LABEL_SEPARATOR_GAP = "(?:\\s+|[:;\\-\\u2013\\u2014]\\s*)" ;
76+
2077const NEGATES_BEFORE_TEST_STEM = new RegExp ( `\\b${ NEGATION_WORD } \\b${ LABEL_SEPARATOR_GAP } (?:${ SAME_SENTENCE_FILLER_WORD } \\s+){0,3}${ TEST_STEM } \\b` , "i" ) ;
2178const NEGATES_AFTER_TEST_STEM = new RegExp ( `\\b${ TEST_STEM } \\b${ LABEL_SEPARATOR_GAP } (?:${ SAME_SENTENCE_FILLER_WORD } \\s+){0,2}${ NEGATION_CONTINUATION } \\b` , "i" ) ;
79+ // A compound negated adjective with no separating whitespace at all ("untested", "unvalidated", "unverified").
2280const NEGATES_TEST_STEM_PREFIX = / \b u n (?: t e s t e d | v a l i d a t e d | v e r i f i e d ) \b / i;
81+
2382const AFFIRMATIVE_TEST_MENTION = / \b ( t e s t (?: e d | s | i n g ) ? | v a l i d a t i o n | v a l i d a t e d | v e r i f i e d | m a n u a l c h e c k | s m o k e | p y t e s t | v i t e s t | n p m t e s t | p n p m t e s t | c a r g o t e s t | g o t e s t ) \b / i;
2483
84+ // A body can contain BOTH a genuine negated clause ("No tests run locally.") and a separate, later clause
85+ // with real affirmative evidence ("Validated with npm run test:ci.") -- evaluating the negation checks
86+ // against the WHOLE body would let the first clause veto the second, discarding real evidence the manifest
87+ // gate is specifically trying to detect (#3304, round 3). Split on the same clause-boundary punctuation the
88+ // proximity checks already treat as a hard stop -- colon/semicolon/dash are excluded here on purpose
89+ // (#3304, round 4): they are typically a label separator glued directly onto the word on either side
90+ // ("Tests: not run."), and splitting on them would sever the stem from its own negation before the
91+ // proximity checks ever run, the same way the round-3 bug worked one level up. Require at least one
92+ // clause to be an affirmative, non-negated mention -- so an earlier honest "no tests" disclosure can no
93+ // longer suppress later evidence.
2594export function hasValidationNote ( value : string ) : boolean {
2695 return value
2796 . split ( / [ . , ! ? ] + / )
@@ -33,3 +102,84 @@ export function hasValidationNote(value: string): boolean {
33102 AFFIRMATIVE_TEST_MENTION . test ( clause ) ,
34103 ) ;
35104}
105+
106+ /**
107+ * Coarse classification of how much test coverage accompanies a set of changed paths.
108+ * Used by slop signals to weight diffs that touch source but include no tests differently
109+ * from those with proportionally strong test changes.
110+ */
111+ export type TestCoverageClassification = "strong" | "adequate" | "weak" | "absent" ;
112+
113+ export function classifyTestCoverage ( changedPaths : string [ ] ) : TestCoverageClassification {
114+ if ( changedPaths . length === 0 ) return "absent" ;
115+ const testCount = changedPaths . filter ( isTestPath ) . length ;
116+ if ( testCount === 0 ) return "absent" ;
117+ const ratio = testCount / changedPaths . length ;
118+ if ( ratio >= 0.4 ) return "strong" ;
119+ if ( ratio >= 0.2 ) return "adequate" ;
120+ return "weak" ;
121+ }
122+
123+ // #2187 (foundational slice of #1972 — boundary-safe test generation): a small, precise framework list, each
124+ // tied to an unambiguous marker file/pattern and an existing isTestPath naming family. Deliberately narrow —
125+ // a longer list of guessable-but-ambiguous frameworks would make detectTestConvention's output less trustworthy
126+ // as a test-gen input than returning null (see the "unknown => null" fail-safe below).
127+ export const TEST_FRAMEWORKS = [ "vitest" , "jest" , "pytest" , "go-test" , "rspec" , "cargo-test" ] as const ;
128+ export type TestFramework = ( typeof TEST_FRAMEWORKS ) [ number ] ;
129+
130+ /** Deterministic detection result: which framework, where tests live, and the file-naming convention to
131+ * follow when scaffolding a new one. `testDir` is `null` for a co-located convention (e.g. Go/Rust/Dart's
132+ * `_test`/`#[cfg(test)]` siblings), matching how those ecosystems actually lay out tests. */
133+ export type TestConvention = {
134+ framework : TestFramework ;
135+ testDir : string | null ;
136+ namingPattern : string ;
137+ } ;
138+
139+ // One marker file per framework, checked against the basename of each changed/known path. Ordered by
140+ // specificity where two frameworks could share an ecosystem (vitest before jest: a repo migrating from Jest to
141+ // Vitest keeps `jest.config.js` around far more often than the reverse, so vitest's own config marker — when
142+ // present — must win).
143+ const FRAMEWORK_MARKERS : ReadonlyArray < { framework : TestFramework ; pattern : RegExp ; testDir : string | null ; namingPattern : string } > = [
144+ { framework : "vitest" , pattern : / ( ^ | \/ ) v i t e s t \. c o n f i g \. ( t s | m t s | c t s | j s | m j s | c j s ) $ / i, testDir : "test/" , namingPattern : "*.test.ts" } ,
145+ { framework : "vitest" , pattern : / ( ^ | \/ ) v i t e s t \. w o r k s p a c e \. ( t s | m t s | c t s | j s | m j s | c j s ) $ / i, testDir : "test/" , namingPattern : "*.test.ts" } ,
146+ { framework : "jest" , pattern : / ( ^ | \/ ) j e s t \. c o n f i g \. ( t s | j s | m j s | c j s | j s o n ) $ / i, testDir : "__tests__/" , namingPattern : "*.test.js" } ,
147+ { framework : "pytest" , pattern : / ( ^ | \/ ) p y t e s t \. i n i $ / i, testDir : null , namingPattern : "test_*.py" } ,
148+ { framework : "pytest" , pattern : / ( ^ | \/ ) p y p r o j e c t \. t o m l $ / i, testDir : null , namingPattern : "test_*.py" } ,
149+ { framework : "go-test" , pattern : / ( ^ | \/ ) g o \. m o d $ / i, testDir : null , namingPattern : "*_test.go" } ,
150+ { framework : "rspec" , pattern : / ( ^ | \/ ) \. r s p e c $ / i, testDir : "spec/" , namingPattern : "*_spec.rb" } ,
151+ { framework : "cargo-test" , pattern : / ( ^ | \/ ) C a r g o \. t o m l $ / i, testDir : null , namingPattern : "#[cfg(test)] mod tests" } ,
152+ ] ;
153+
154+ // Fallback inference from an EXISTING test file's own naming, when no marker is present (e.g. a marker file
155+ // wasn't part of the changed/known set passed in, but the repo already has real test files to imitate).
156+ const CONVENTION_FROM_EXISTING_TEST : ReadonlyArray < { framework : TestFramework ; pattern : RegExp ; testDir : string | null ; namingPattern : string } > = [
157+ { framework : "vitest" , pattern : / \. ( t e s t | s p e c ) \. ( t s | t s x | m t s | c t s ) $ / i, testDir : "test/" , namingPattern : "*.test.ts" } ,
158+ { framework : "jest" , pattern : / \. ( t e s t | s p e c ) \. ( j s | j s x | m j s | c j s ) $ / i, testDir : "__tests__/" , namingPattern : "*.test.js" } ,
159+ { framework : "pytest" , pattern : / ( ^ | \/ ) t e s t _ [ ^ / ] * \. p y $ | [ ^ / ] + _ t e s t \. p y $ / i, testDir : null , namingPattern : "test_*.py" } ,
160+ { framework : "go-test" , pattern : / [ ^ / ] + _ t e s t \. g o $ / i, testDir : null , namingPattern : "*_test.go" } ,
161+ { framework : "rspec" , pattern : / [ ^ / ] + _ s p e c \. r b $ / i, testDir : "spec/" , namingPattern : "*_spec.rb" } ,
162+ ] ;
163+
164+ /**
165+ * Detect a repo's test framework + convention from a bounded set of changed paths and known marker filenames
166+ * (e.g. `package.json`, `pyproject.toml`, `go.mod` — paths the caller already has, never fetched by this
167+ * function). Deterministic and pure: markers win over inferring from existing test-file naming (a config file
168+ * is a stronger, unambiguous signal than a naming guess), and the marker list is checked in a fixed order so a
169+ * repo with more than one marker present always resolves to the same framework. Returns `null` when nothing in
170+ * `paths`/`markers` matches any known convention — an unrecognized layout is left alone (fail-safe) rather than
171+ * guessing, since a wrong framework guess would make the downstream test-gen spec actively misleading.
172+ */
173+ export function detectTestConvention ( paths : string [ ] , markers : string [ ] ) : TestConvention | null {
174+ for ( const marker of FRAMEWORK_MARKERS ) {
175+ if ( markers . some ( ( path ) => marker . pattern . test ( path ) ) || paths . some ( ( path ) => marker . pattern . test ( path ) ) ) {
176+ return { framework : marker . framework , testDir : marker . testDir , namingPattern : marker . namingPattern } ;
177+ }
178+ }
179+ for ( const convention of CONVENTION_FROM_EXISTING_TEST ) {
180+ if ( paths . some ( ( path ) => isTestPath ( path ) && convention . pattern . test ( path ) ) ) {
181+ return { framework : convention . framework , testDir : convention . testDir , namingPattern : convention . namingPattern } ;
182+ }
183+ }
184+ return null ;
185+ }
0 commit comments