Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions bin/gstack-retro-metrics
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions test/gstack-retro-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <Module>Tests/<Thing>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);
Expand Down