Merge pull request #600 from thejesh23/feat/understandignore-swift-test-patterns

feat(core): broaden .understandignore starter — Swift XCTest/Quick patterns
This commit is contained in:
Yuxiang Lin
2026-07-30 10:24:26 +08:00
committed by GitHub
2 changed files with 141 additions and 8 deletions
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import ignore from "ignore";
import { generateStarterIgnoreFile } from "../ignore-generator";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -284,12 +285,94 @@ describe("generateStarterIgnoreFile", () => {
expect(content).toContain("# **/rails_helper.rb");
});
it("scopes Swift XCTest patterns to exactly-named test directories", () => {
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# Swift");
// Exact name, not a *Tests suffix — Contests/ must not be matchable.
expect(content).toContain("# **/Tests/**/*.swift");
});
it("includes Swift Quick/Nimble BDD spec directories", () => {
const content = generateStarterIgnoreFile(testDir);
// Quick is the Swift RSpec-equivalent — dominant in codebases that
// adopted BDD styling before Swift Testing shipped.
expect(content).toContain("# **/Specs/**/*.swift");
});
it("suggests Xcode target dirs by real name, only when they exist", () => {
mkdirSync(join(testDir, "MyAppTests"), { recursive: true });
mkdirSync(join(testDir, "MyAppUITests"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# MyAppTests/");
expect(content).toContain("# MyAppUITests/");
// Not speculatively globbed for projects that have no such dir.
expect(generateStarterIgnoreFile(join(testDir, "nope"))).not.toContain(
"MyAppTests/",
);
});
it("surfaces a production *tests dir under its real name, not silently", () => {
// Contests/ does match the unanchored suffix rule — that is acceptable
// only because the user sees this exact line and can leave it commented.
mkdirSync(join(testDir, "Contests"), { recursive: true });
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# Contests/");
});
it("keeps production Swift source when the Swift group is uncommented", () => {
// These starter lines exist to be uncommented, so assert on real
// matcher behaviour rather than on the emitted text alone.
const content = generateStarterIgnoreFile(testDir);
const swiftPatterns = content
.split("\n")
.map((l) => l.replace(/^#\s*/, "").trim())
.filter((l) => l.endsWith(".swift"));
expect(swiftPatterns.length).toBeGreaterThan(0);
const ig = ignore().add(swiftPatterns);
for (const kept of [
"Sources/App/Contest.swift",
"Sources/App/Latest.swift",
"Sources/App/Backtest.swift",
"Sources/App/Protest.swift",
"Sources/App/Contests.swift",
"Sources/App/Inspec.swift",
"Sources/Requests/LoginRequest.swift",
"Sources/Interests/InterestPicker.swift",
// Directory-level collisions the exact-name globs must also avoid.
"Sources/Contests/ContestList.swift",
"Sources/Protests/ProtestFeed.swift",
]) {
expect(ig.ignores(kept), `${kept} must not be ignored`).toBe(false);
}
for (const dropped of [
"Tests/AppTests/AppTests.swift",
"Tests/AppTests/Helpers.swift",
"Tests/File.swift",
"Modules/Feature/Tests/A.swift",
"SignalServiceKit/tests/CryptoTest.swift",
"Specs/LoginSpec.swift",
]) {
expect(ig.ignores(dropped), `${dropped} must be ignored`).toBe(true);
}
});
it("does not emit bare Swift file-suffix globs", () => {
const content = generateStarterIgnoreFile(testDir);
// The runtime matcher is case-insensitive (`ignore` defaults to
// ignorecase: true), so these would silently drop production files
// named Contest.swift / Latest.swift / Backtest.swift / Inspec.swift.
expect(content).not.toContain("# **/*Test.swift");
expect(content).not.toContain("# **/*Tests.swift");
expect(content).not.toContain("# **/*Spec.swift");
});
it("groups patterns under the JS / TS sub-header", () => {
const content = generateStarterIgnoreFile(testDir);
expect(content).toContain("# JS / TS");
});
it("emits language groups in stable order: JS, C#, Java, Go, C++, Python, Rust, Ruby", () => {
it("emits language groups in stable order: JS, C#, Java, Go, C++, Python, Rust, Ruby, Swift", () => {
const content = generateStarterIgnoreFile(testDir);
const jsIdx = content.indexOf("# JS / TS");
const csIdx = content.indexOf("# C# / .NET");
@@ -299,6 +382,7 @@ describe("generateStarterIgnoreFile", () => {
const pyIdx = content.indexOf("# Python");
const rustIdx = content.indexOf("# Rust");
const rubyIdx = content.indexOf("# Ruby");
const swiftIdx = content.indexOf("# Swift");
expect(jsIdx).toBeGreaterThan(-1);
expect(csIdx).toBeGreaterThan(jsIdx);
expect(javaIdx).toBeGreaterThan(csIdx);
@@ -307,6 +391,7 @@ describe("generateStarterIgnoreFile", () => {
expect(pyIdx).toBeGreaterThan(cppIdx);
expect(rustIdx).toBeGreaterThan(pyIdx);
expect(rubyIdx).toBeGreaterThan(rustIdx);
expect(swiftIdx).toBeGreaterThan(rubyIdx);
});
it("keeps all suggestions commented even with no detected dirs and no .gitignore", () => {
@@ -39,14 +39,22 @@ const EXACT_DIR_NAMES = [
];
// Directory-name suffixes matched case-insensitively via String.endsWith.
// Primarily intended for C# / .NET project-suffix conventions like Foo.Tests,
// Foo.UnitTests, Foo.IntegrationTests, but note the match is unanchored —
// e.g. a hypothetical `.storybook.tests` would also match. Suggestions stay
// commented-out so the user reviews before activating.
// Covers C# / .NET project-suffix conventions (Foo.Tests, Foo.UnitTests,
// Foo.IntegrationTests) and Xcode target conventions (MyAppTests,
// MyAppUITests) in one rule — the dotted C# forms all end in "tests", so
// they need no separate entries.
//
// The match is deliberately unanchored, which means a production directory
// ending in "tests"/"specs" (Contests/, Protests/) also matches. That is
// safe *here* in a way the equivalent file-glob is not: this list only ever
// runs against directories that actually exist on disk (see
// detectDirectories), and the result is emitted commented-out under the
// directory's real name — so a repo with a genuine Contests/ dir sees a
// literal `# Contests/` line it can decline to uncomment. A speculative
// `**/*Tests/**` glob offers the user no such signal.
const SUFFIX_DIR_GLOBS = [
".tests",
".unittests",
".integrationtests",
"tests",
"specs",
];
// Test file patterns grouped by language. Emitted as commented suggestions
@@ -156,6 +164,46 @@ const TEST_PATTERN_GROUPS: Array<{ label: string; patterns: string[] }> = [
"**/rails_helper.rb",
],
},
{
// Swift patterns are scoped to test *directories*, not file
// suffixes, because the runtime matcher (the `ignore` package in
// ignore-filter.ts) runs with its default `ignorecase: true`. Under
// case-insensitive matching a bare `**/*Test.swift` also swallows
// production names like Contest.swift, Latest.swift, Backtest.swift
// and Protest.swift; `**/*Tests.swift` catches Contests.swift, and
// `**/*Spec.swift` catches Inspec.swift. That is not recoverable by
// writing the glob more carefully — `ignore` compiles patterns to a
// RegExp with the `i` flag, so a `[Tt]` character class buys nothing.
// Since these starter lines are meant to be uncommented by users,
// silently dropping real source is the worst failure mode available,
// so the file-suffix form was abandoned.
//
// The globs below use *exact* directory names rather than a `*Tests`
// suffix, so they carry no false-positive risk at all: Contests/ and
// Protests/ do not match. They cover SPM's `Tests/` and Quick's
// `Specs/` at any depth, including nested module layouts like
// Modules/Feature/Tests/ that `detectDirectories()` cannot see (it
// enumerates only direct children of projectRoot).
//
// Xcode's target-suffix convention (`MyAppTests/`, `MyAppUITests/`)
// is deliberately NOT handled here. It is handled by the "tests"
// entry in SUFFIX_DIR_GLOBS instead, which only fires for directories
// observed on disk and emits them under their real name — see the
// note there for why that is the safe place for suffix matching.
// The residual gap is a *nested* Xcode-style dir (Modules/Feature/
// FeatureTests/), which neither rule reaches. That is an accepted
// miss: under-matching costs tokens, over-matching silently drops
// source, and only one of those is a correctness bug.
//
// NOTE: token-savings measurement still to be redone for this
// directory-scoped shape — the earlier file-suffix figures do not
// carry over and have been removed rather than restated.
label: "Swift",
patterns: [
"**/Tests/**/*.swift",
"**/Specs/**/*.swift",
],
},
];
/**