diff --git a/bin/gstack-retro-metrics b/bin/gstack-retro-metrics index 58c2c05180..739e3e41b7 100755 --- a/bin/gstack-retro-metrics +++ b/bin/gstack-retro-metrics @@ -103,11 +103,24 @@ _U="" # Emits COMMIT: lines (newest first, capped) plus every aggregate. Subjects may # contain '|', so fields are re-joined from index 6 on. Test-file detection is # the union of the historical patterns (dir-based test/|spec/|__tests__/ and -# suffix-based .test./.spec./_test./_spec.). +# suffix-based .test./.spec./_test./_spec.), plus CamelCase conventions: +# capitalized Tests/ dirs (SwiftPM) and FooTests.swift / FooTest.cs filenames. +# Without those, every Swift and C# project reports a 0% test ratio. git log "$REF" "$_S" ${_U:+"$_U"} --date=format-local:'%Y-%m-%d %H:%M' \ --format='C|%h|%aN|%at|%ad|%s' --numstat 2>/dev/null | awk ' + # Generated output is not authored code. Without this, a committed build + # directory dominates FOCUS_SCORE and its Test/ subdirs count as test LOC. + function is_generated(p) { + return (p ~ /(^|\/)(\.build|build|dist|out|target|vendor|node_modules|DerivedData|\.next|\.nuxt|__pycache__|coverage)\//) + } + # "spec" is overloaded: spec/ is RSpec, but docs/.../specs/*.md are written + # specifications. Prose is never test code, whatever directory it sits in. + function is_prose(p) { + return (p ~ /\.(md|mdx|markdown|txt|rst|adoc)$/) || (p ~ /(^|\/)(docs?|documentation)\//) + } function is_test(p) { - return (p ~ /(^|\/)(tests?|spec|__tests__)\//) || (p ~ /(\.(test|spec)\.|_test\.|_spec\.)/) + if (is_prose(p)) return 0 + return (p ~ /(^|\/)([Tt]ests?|[Ss]pecs?|__tests__)\//) || (p ~ /(\.(test|spec)\.|_test\.|_spec\.)/) || (p ~ /[A-Za-z0-9]([Tt]ests?|[Ss]pecs?)\.[A-Za-z0-9]+$/) } function type_of(s) { if (s ~ /^Merge /) return "merge" @@ -161,13 +174,14 @@ git log "$REF" "$_S" ${_U:+"$_U"} --date=format-local:'%Y-%m-%d %H:%M' \ next } /^[0-9-]+\t/ { + if (is_generated($3)) next if ($1 != "-") cins += $1; if ($2 != "-") cdel += $2 if ($1 != "-" && is_test($3)) ctins += $1 cfiles++ filecount[$3]++ d = ($3 ~ /\//) ? substr($3, 1, index($3, "/") - 1) "/" : "(root)" dircount[d]++; adir[author "|" d]++ - if ($3 ~ /(\.(test|spec)\.|_test\.|_spec\.)/) testfiles[$3] = 1 + if (is_test($3)) testfiles[$3] = 1 } END { flush() @@ -297,10 +311,11 @@ echo "PRS_REFERENCED: $(printf '%s' "$_PRS" | wc -w | tr -d ' ')" [ -n "$_PRS" ] && echo "PR_REFS: $_PRS" || true # ── Test health (repo-wide + window) ───────────────────────────────────────── -# Deliberately suffix-only (narrower than is_test's dir-based patterns): the +# Deliberately filename-only (narrower than is_test's dir-based patterns): the # repo-wide census counts conventional test FILES; is_test additionally counts -# dir-homed helpers toward test-insertion ratios. -_TF_TOTAL=$(git ls-files 2>/dev/null | grep -cE '(\.test\.|\.spec\.|_test\.|_spec\.)' || true) +# dir-homed helpers toward test-insertion ratios. Covers both the punctuated +# suffixes (.test./_spec.) and the CamelCase form (FooTests.swift). +_TF_TOTAL=$(git ls-files 2>/dev/null | grep -cE '(\.test\.|\.spec\.|_test\.|_spec\.|[A-Za-z0-9](Tests?|Specs?)\.[A-Za-z0-9]+$)' || true) case "$_TF_TOTAL" in ''|*[!0-9]*) _TF_TOTAL=0 ;; esac echo "TEST_FILES_TOTAL: $_TF_TOTAL" _REG=$(git log "$REF" "$_S" ${_U:+"$_U"} --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage" 2>/dev/null || true) diff --git a/test/gstack-retro-metrics.test.ts b/test/gstack-retro-metrics.test.ts index c1cf2450cf..39151fe925 100644 --- a/test/gstack-retro-metrics.test.ts +++ b/test/gstack-retro-metrics.test.ts @@ -306,6 +306,50 @@ describe('gstack-retro-metrics edges', () => { } }); + test('Swift/XCTest naming counts as tests; generated output and prose specs do not', () => { + const swiftRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rm-swift-')); + try { + git(swiftRepo, ['init', '-b', 'main']); + git(swiftRepo, ['config', 'user.email', 'dev@example.com']); + git(swiftRepo, ['config', 'user.name', 'Dev']); + + // Production source. + fs.mkdirSync(path.join(swiftRepo, 'App'), { recursive: true }); + write(swiftRepo, 'App/Store.swift', 'struct Store {}\n'); + + // XCTest convention: Tests/Tests.swift. Matches neither + // the lowercase test/ dir pattern nor the .test./_test. suffixes. + fs.mkdirSync(path.join(swiftRepo, 'AppTests'), { recursive: true }); + write(swiftRepo, 'AppTests/StoreTests.swift', 'import XCTest\nfinal class StoreTests: XCTestCase {}\n'); + + // Generated build output, including a Test/ subdirectory. + fs.mkdirSync(path.join(swiftRepo, '.build/Logs/Test'), { recursive: true }); + write(swiftRepo, '.build/Logs/Test/manifest.plist', 'generated\ngenerated\ngenerated\n'); + + // A written specification, not an RSpec test. + fs.mkdirSync(path.join(swiftRepo, 'docs/specs'), { recursive: true }); + write(swiftRepo, 'docs/specs/design.md', '# Design\nprose\nprose\n'); + + commit(swiftRepo, 'feat: swift app with tests', '2026-03-10T09:00:00'); + + const out = runMetrics(['--since', '2026-03-01T00:00:00'], swiftRepo); + + // The XCTest file counts. + expect(out).toMatch(/^TEST_FILES_TOTAL: 1$/m); + expect(out).toMatch(/^TEST_FILES_CHANGED: 1$/m); + expect(out).toMatch(/^TEST_INSERTIONS: 2$/m); + + // Generated output is excluded from insertions entirely, so it can + // neither inflate test LOC nor dominate the focus score. 6 = 1 prod + // + 2 test + 3 prose; the 3 generated lines are dropped. Prose is + // authored content, so it counts here even though it is not a test. + expect(out).not.toMatch(/^FOCUS_SCORE: \d+% \(\.build\/\)$/m); + expect(out).toMatch(/^INSERTIONS: 6$/m); + } finally { + fs.rmSync(swiftRepo, { recursive: true, force: true }); + } + }); + test('local reads only: no network git ops or curl anywhere in the script', () => { const script = fs.readFileSync(SCRIPT, 'utf-8'); expect(script).not.toMatch(/(^|[;|&`($!]|\s)git(\s+-C\s+\S+)?\s+(push|pull|fetch|clone|ls-remote)\b/m);