Skip to content

Commit a735f19

Browse files
tizz98claude
andauthored
feat: native swift profile (ships as 0.7.0) (#28)
* feat(profile): add native swift profile; release 0.7.0 Adds a first-class `swift` profile alongside rust/typescript/python/generic. - ProfileKind::Swift wired through parse/name/for_kind and every exhaustive match (detect, scaffold, guards, signals). - src/profile/swift.rs: SwiftPM defaults (swift build/test, coverage via --enable-code-coverage), .swift sources, language-agnostic signals with a Swift fragility pattern (try!/as!/fatalError/unsafelyUnwrapped), and the no-print-in-lib + deps-justified guards. - detect.rs: Package.swift -> SwiftPM (Sources/* domains); *.xcodeproj / *.xcworkspace -> Xcode-flavored with an xcodebuild note. - CI is now runner-aware: generated ci.yml runs on macos-latest for swift (Xcode/Swift preinstalled, no toolchain step); other profiles stay on ubuntu-latest. meta-check/arch stay on Linux (grep-only). - deps-justified understands SwiftPM: extracts .package(url:) names from Package.swift. - Tests across profile/detect/scaffold/guards + a swift init integration test. Bumps to 0.7.0 to ship the new profile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(swift): recognize SwiftPM test paths; harden swift_deps + fragility Addresses adversarial review findings on the new swift profile: - is_test_path now recognizes SwiftPM/XCTest conventions (capital-T `Tests/` dir, `<Name>Tests.swift`), so Swift test files are excluded from guards and arch signals — no more false no-print-in-lib failures under `check --strict` or bogus fragility tickets on `try!` in test setup. - swift_deps skips whole-line `//` comments so a commented-out `.package(url:)` isn't counted as a declared dependency. - Swift fragility signal also flags preconditionFailure( / assertionFailure(. Tests: is_test_path swift cases, a no-print-in-lib Tests/ exclusion test, and a commented-dep case in the swift deps test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 910f9e6 commit a735f19

13 files changed

Lines changed: 309 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "ai-meta"
3-
version = "0.6.0"
3+
version = "0.7.0"
44
edition = "2021"
55
rust-version = "1.80"
66
description = "The LLM's missing meta framework — one versioned CLI that scaffolds, lints, and syncs project tooling across repos."

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ then use `./meta <cmd>` in PowerShell or `.\meta <cmd>` in cmd.exe. The generate
4747
- **`meta init`** — scaffold `.meta/meta.toml`, the `./meta` shim, GitHub Actions
4848
workflows, `CLAUDE.md` (with a managed block), `.claude/skills/meta-*`, and
4949
`META.md`. Auto-detects the language **profile** (rust / typescript / python /
50-
generic) and infers build/test/lint commands + domains from the repo. Uses the
50+
swift / generic) and infers build/test/lint commands + domains from the repo. Uses the
5151
`claude` CLI to tailor wording when available (deterministic fallback otherwise).
5252
- **`meta check` / `meta arch`** — a data-driven rule engine (guards + advisory
5353
architecture signals) with per-profile defaults, tunable thresholds, and custom

src/assets/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77

88
jobs:
99
ci:
10-
runs-on: ubuntu-latest
10+
runs-on: {{runs_on}}
1111
steps:
1212
- uses: actions/checkout@v4
1313
{{toolchain_setup}}{{extra_steps}} - name: Local CI gate

src/detect.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ pub fn detect(root: &Path) -> Detection {
4343
det.kind = Some(ProfileKind::Rust);
4444
det.markers.push("Cargo.toml".into());
4545
infer_rust(root, &mut det);
46+
} else if let Some(marker) = swift_marker(root) {
47+
det.kind = Some(ProfileKind::Swift);
48+
det.markers.push(marker.clone());
49+
infer_swift(root, &marker, &mut det);
4650
} else if has("package.json") {
4751
det.kind = Some(ProfileKind::TypeScript);
4852
det.markers.push("package.json".into());
@@ -104,6 +108,37 @@ fn infer_rust(root: &Path, det: &mut Detection) {
104108
}
105109
}
106110

111+
/// The Swift marker present at `root`, if any: a SwiftPM `Package.swift` (checked
112+
/// first), else an Xcode `*.xcodeproj` / `*.xcworkspace` bundle.
113+
fn swift_marker(root: &Path) -> Option<String> {
114+
if root.join("Package.swift").exists() {
115+
return Some("Package.swift".into());
116+
}
117+
for e in std::fs::read_dir(root).ok()?.flatten() {
118+
let name = e.file_name().to_string_lossy().to_string();
119+
if name.ends_with(".xcodeproj") || name.ends_with(".xcworkspace") {
120+
return Some(name);
121+
}
122+
}
123+
None
124+
}
125+
126+
fn infer_swift(root: &Path, marker: &str, det: &mut Detection) {
127+
// SwiftPM's `swift build` / `swift test` are already the profile defaults, so
128+
// (like a Rust workspace) we don't restate them. An Xcode project needs a
129+
// custom `xcodebuild` invocation we can't infer — leave a note instead.
130+
if marker != "Package.swift" {
131+
det.notes.push(format!(
132+
"Xcode project ({marker}); set [commands] build/test to your xcodebuild invocation"
133+
));
134+
}
135+
// Swift targets live under Sources/ (SwiftPM); fall back to top-level dirs.
136+
det.domains = child_dirs(&root.join("Sources"), &[]);
137+
if det.domains.is_empty() {
138+
det.domains = child_dirs(root, &["Tests"]);
139+
}
140+
}
141+
107142
fn infer_typescript(root: &Path, det: &mut Detection) {
108143
let pkg = std::fs::read_to_string(root.join("package.json")).unwrap_or_default();
109144
let scripts = json_scripts(&pkg);
@@ -267,6 +302,33 @@ mod tests {
267302
assert_eq!(d.commands.test.as_deref(), Some("cargo test"));
268303
}
269304

305+
#[test]
306+
fn detects_swift_package() {
307+
let tmp = tempdir().unwrap();
308+
fs::write(
309+
tmp.path().join("Package.swift"),
310+
"// swift-tools-version:5.9\nimport PackageDescription\n",
311+
)
312+
.unwrap();
313+
fs::create_dir_all(tmp.path().join("Sources").join("App")).unwrap();
314+
fs::create_dir_all(tmp.path().join("Sources").join("Core")).unwrap();
315+
let d = detect(tmp.path());
316+
assert_eq!(d.kind, Some(ProfileKind::Swift));
317+
assert_eq!(d.markers, vec!["Package.swift".to_string()]);
318+
// SwiftPM commands equal the profile default, so they're not restated.
319+
assert!(d.commands.build.is_none());
320+
assert_eq!(d.domains, vec!["App", "Core"]);
321+
}
322+
323+
#[test]
324+
fn detects_swift_xcode_project() {
325+
let tmp = tempdir().unwrap();
326+
fs::create_dir_all(tmp.path().join("MyApp.xcodeproj")).unwrap();
327+
let d = detect(tmp.path());
328+
assert_eq!(d.kind, Some(ProfileKind::Swift));
329+
assert!(d.notes.iter().any(|n| n.contains("xcodebuild")));
330+
}
331+
270332
#[test]
271333
fn detects_typescript_and_infers_scripts() {
272334
let tmp = tempdir().unwrap();

src/profile/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
pub mod python;
88
pub mod rust;
9+
pub mod swift;
910
pub mod typescript;
1011

1112
use crate::error::{Error, Result};
@@ -18,6 +19,7 @@ pub enum ProfileKind {
1819
Rust,
1920
TypeScript,
2021
Python,
22+
Swift,
2123
Generic,
2224
}
2325

@@ -27,6 +29,7 @@ impl ProfileKind {
2729
ProfileKind::Rust => "rust",
2830
ProfileKind::TypeScript => "typescript",
2931
ProfileKind::Python => "python",
32+
ProfileKind::Swift => "swift",
3033
ProfileKind::Generic => "generic",
3134
}
3235
}
@@ -36,6 +39,7 @@ impl ProfileKind {
3639
"rust" => Ok(ProfileKind::Rust),
3740
"typescript" | "ts" | "javascript" | "js" => Ok(ProfileKind::TypeScript),
3841
"python" | "py" => Ok(ProfileKind::Python),
42+
"swift" | "swiftpm" => Ok(ProfileKind::Swift),
3943
"generic" => Ok(ProfileKind::Generic),
4044
other => Err(Error::UnknownProfile(other.to_string())),
4145
}
@@ -99,6 +103,7 @@ impl Profile {
99103
ProfileKind::Rust => rust::profile(),
100104
ProfileKind::TypeScript => typescript::profile(),
101105
ProfileKind::Python => python::profile(),
106+
ProfileKind::Swift => swift::profile(),
102107
ProfileKind::Generic => generic(),
103108
}
104109
}
@@ -173,6 +178,8 @@ mod tests {
173178
assert_eq!(ProfileKind::parse("ts").unwrap(), ProfileKind::TypeScript);
174179
assert_eq!(ProfileKind::parse("PY").unwrap(), ProfileKind::Python);
175180
assert_eq!(ProfileKind::parse("Rust").unwrap(), ProfileKind::Rust);
181+
assert_eq!(ProfileKind::parse("Swift").unwrap(), ProfileKind::Swift);
182+
assert_eq!(ProfileKind::parse("swiftpm").unwrap(), ProfileKind::Swift);
176183
assert!(ProfileKind::parse("cobol").is_err());
177184
}
178185

@@ -182,6 +189,7 @@ mod tests {
182189
ProfileKind::Rust,
183190
ProfileKind::TypeScript,
184191
ProfileKind::Python,
192+
ProfileKind::Swift,
185193
ProfileKind::Generic,
186194
] {
187195
let p = Profile::for_kind(k);

src/profile/swift.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//! The `swift` profile — SwiftPM defaults (`swift build`/`swift test`) for a
2+
//! Swift package or macOS/iOS app. Generated CI runs on a macOS runner (Xcode
3+
//! and the Swift toolchain are preinstalled there), so no toolchain-setup step
4+
//! is emitted. Xcode-project repos override the SwiftPM commands in meta.toml.
5+
6+
use super::{
7+
default_label_colors, default_statuses, default_types, Commands, CoverageTool, Profile,
8+
ProfileKind, VersionLocation,
9+
};
10+
use crate::rules::model::{GuardId, Severity, SignalId, Thresholds};
11+
12+
pub fn profile() -> Profile {
13+
Profile {
14+
kind: ProfileKind::Swift,
15+
commands: Commands {
16+
build: Some("swift build".into()),
17+
test: Some("swift test".into()),
18+
// `swift format` / SwiftLint aren't guaranteed to be installed, so
19+
// the profile leaves fmt/lint unset; a repo opts in via meta.toml.
20+
fmt: None,
21+
lint: None,
22+
typecheck: None,
23+
coverage: Some("swift test --enable-code-coverage".into()),
24+
},
25+
// No in-engine coverage parser for Swift yet; the command is still handy
26+
// for `meta test --coverage`, but coverage isn't gated.
27+
coverage_tool: CoverageTool::None,
28+
coverage_min: 0,
29+
coverage_summary: None,
30+
statuses: default_statuses(),
31+
types: default_types(),
32+
guards: vec![
33+
(GuardId::NoPrintInLib, Severity::Warn),
34+
(GuardId::DepsJustified, Severity::Warn),
35+
],
36+
signals: vec![
37+
SignalId::OversizedFiles,
38+
SignalId::Fragility,
39+
SignalId::DeepNesting,
40+
SignalId::DebtMarkers,
41+
],
42+
thresholds: Thresholds::default(),
43+
// Swift has no canonical in-repo version file; default to a plain VERSION
44+
// file (like the generic profile). Projects point `meta tag` elsewhere
45+
// (e.g. an Xcode MARKETING_VERSION) via `[[version.locations]]`.
46+
version_location: VersionLocation {
47+
path: "VERSION".into(),
48+
anchor: "^".into(),
49+
},
50+
source_exts: vec!["swift".into()],
51+
label_colors: default_label_colors(),
52+
}
53+
}

src/rules/grep.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,21 @@ impl Hit {
4141
/// Whether `path` (relative to a scan root) is a test/bench/declaration file
4242
/// that guards exclude. `exts` are the language source extensions.
4343
pub fn is_test_path(rel: &str, exts: &[String]) -> bool {
44-
if rel.contains("/tests/") || rel.starts_with("tests/") || rel.contains("/benches/") {
44+
if rel.contains("/tests/")
45+
|| rel.starts_with("tests/")
46+
|| rel.contains("/Tests/")
47+
|| rel.starts_with("Tests/")
48+
|| rel.contains("/benches/")
49+
{
4550
return true;
4651
}
4752
if rel.ends_with(".d.ts") {
4853
return true;
4954
}
50-
// `_test.<ext>` (rust/go style) and `.test.`/`.spec.` (js/ts) and python
51-
// `test_*.py` / `*_test.py`.
55+
// `_test.<ext>` (rust/go style), `<Name>Tests.<ext>` (SwiftPM/XCTest style),
56+
// `.test.`/`.spec.` (js/ts), and python `test_*.py` / `*_test.py`.
5257
for e in exts {
53-
if rel.ends_with(&format!("_test.{e}")) {
58+
if rel.ends_with(&format!("_test.{e}")) || rel.ends_with(&format!("Tests.{e}")) {
5459
return true;
5560
}
5661
}
@@ -151,15 +156,20 @@ mod tests {
151156

152157
#[test]
153158
fn test_path_detection() {
154-
let exts = vec!["rs".into(), "ts".into(), "py".into()];
159+
let exts = vec!["rs".into(), "ts".into(), "py".into(), "swift".into()];
155160
assert!(is_test_path("crate/tests/it.rs", &exts));
156161
assert!(is_test_path("src/foo_test.rs", &exts));
157162
assert!(is_test_path("src/foo.test.ts", &exts));
158163
assert!(is_test_path("src/foo.spec.ts", &exts));
159164
assert!(is_test_path("pkg/test_foo.py", &exts));
160165
assert!(is_test_path("types/x.d.ts", &exts));
166+
// SwiftPM/XCTest conventions: a capital-T `Tests/` dir and `<Name>Tests.swift`.
167+
assert!(is_test_path("Tests/AppTests/AppTests.swift", &exts));
168+
assert!(is_test_path("MyLibTests.swift", &exts));
161169
assert!(!is_test_path("src/foo.rs", &exts));
162170
assert!(!is_test_path("src/testing.rs", &exts));
171+
// Product Swift files that merely end in "…ests.swift" are not tests.
172+
assert!(!is_test_path("Sources/App/Requests.swift", &exts));
163173
}
164174

165175
#[test]

src/rules/guards.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ fn deps_justified(ctx: &GuardCtx) -> GuardResult {
261261
ProfileKind::Rust => rust_deps(ctx.root),
262262
ProfileKind::TypeScript => ts_deps(ctx.root),
263263
ProfileKind::Python => py_deps(ctx.root),
264+
ProfileKind::Swift => swift_deps(ctx.root),
264265
ProfileKind::Generic => return GuardResult::Skip("no dependency model for generic".into()),
265266
};
266267
let declared = match declared {
@@ -415,6 +416,42 @@ fn py_name(spec: &str) -> String {
415416
.to_string()
416417
}
417418

419+
/// Package dependencies from a SwiftPM `Package.swift`, each identified by the
420+
/// repo name in its `.package(url: "…")` entry (last path segment, sans `.git`).
421+
/// Local `.package(path: …)` entries have no URL and are treated as internal.
422+
fn swift_deps(root: &Path) -> Option<Vec<(String, String)>> {
423+
let path = root.join("Package.swift");
424+
if !path.is_file() {
425+
return None;
426+
}
427+
// Drop whole-line `//` comments so a commented-out dependency isn't counted
428+
// (line-by-line, like rust_deps — multi-line non-commented calls survive).
429+
let src = grep::read(&path);
430+
let text = src
431+
.lines()
432+
.filter(|l| !l.trim_start().starts_with("//"))
433+
.collect::<Vec<_>>()
434+
.join("\n");
435+
let re = compile(r#"\.package\(\s*(?:name:\s*"[^"]*"\s*,\s*)?url:\s*"([^"]+)""#);
436+
let mut out = Vec::new();
437+
for caps in re.captures_iter(&text) {
438+
let url = caps.get(1).map(|m| m.as_str()).unwrap_or("");
439+
let name = url
440+
.trim_end_matches('/')
441+
.rsplit('/')
442+
.next()
443+
.unwrap_or(url)
444+
.trim_end_matches(".git")
445+
.to_string();
446+
if !name.is_empty() {
447+
out.push(("Package.swift".to_string(), name));
448+
}
449+
}
450+
out.sort();
451+
out.dedup();
452+
Some(out)
453+
}
454+
418455
// --- shared helpers ----------------------------------------------------------
419456

420457
fn scan(

0 commit comments

Comments
 (0)