ios-agent-mcp 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -7
- package/dist/analyzers/memory.d.ts +12 -0
- package/dist/analyzers/memory.js +81 -0
- package/dist/analyzers/memory.js.map +1 -0
- package/dist/analyzers/performance.d.ts +10 -0
- package/dist/analyzers/performance.js +106 -0
- package/dist/analyzers/performance.js.map +1 -0
- package/dist/analyzers/security.d.ts +2 -0
- package/dist/analyzers/security.js +82 -0
- package/dist/analyzers/security.js.map +1 -0
- package/dist/analyzers/skill.d.ts +34 -0
- package/dist/analyzers/skill.js +483 -0
- package/dist/analyzers/skill.js.map +1 -0
- package/dist/analyzers/testing.d.ts +16 -0
- package/dist/analyzers/testing.js +135 -0
- package/dist/analyzers/testing.js.map +1 -0
- package/dist/analyzers/types.d.ts +19 -1
- package/dist/analyzers/types.js +65 -3
- package/dist/analyzers/types.js.map +1 -1
- package/dist/index.js +159 -4
- package/dist/index.js.map +1 -1
- package/dist/report.d.ts +10 -0
- package/dist/report.js +40 -0
- package/dist/report.js.map +1 -1
- package/dist/resources.d.ts +67 -0
- package/dist/resources.js +222 -0
- package/dist/resources.js.map +1 -0
- package/dist/result.d.ts +203 -0
- package/dist/result.js +135 -0
- package/dist/result.js.map +1 -0
- package/dist/scan.d.ts +9 -0
- package/dist/scan.js +110 -0
- package/dist/scan.js.map +1 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/mcp.json +76 -11
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@ An MCP server that reviews Swift projects against the rules in
|
|
|
4
4
|
[ios-agent-skill](https://github.com/Nagarjuna2997/ios-agent-skill).
|
|
5
5
|
|
|
6
6
|
The skill teaches an agent how to *write* iOS code. This server lets an agent
|
|
7
|
-
*check* it —
|
|
8
|
-
a line, the consequence, and the fix
|
|
7
|
+
*check* it — ten tools that read a Swift project and report defects with a file,
|
|
8
|
+
a line, the consequence, and the fix, plus one that lints a skill repository's
|
|
9
|
+
own metadata.
|
|
9
10
|
|
|
10
11
|
```
|
|
11
12
|
You: Review my Swift project for concurrency problems.
|
|
@@ -80,6 +81,11 @@ npm install && npm run build
|
|
|
80
81
|
| `review_swiftui` | Fixed font sizes and heights, `AnyView`, `.cornerRadius`, literal spacing, materials over solid backgrounds, view state on models, `ObservableObject`, `@EnvironmentObject`, `try!` |
|
|
81
82
|
| `check_availability_guards` | Missing guards, **over-restrictive guards** (an iOS 26 API guarded at iOS 27 silently drops every iOS 26 device), Foundation Models without a runtime availability check |
|
|
82
83
|
| `audit_app_store_readiness` | Permission frameworks with no Info.plist purpose string, missing `PrivacyInfo.xcprivacy`, unlocalized strings, unlabeled icon buttons, `print()` |
|
|
84
|
+
| `review_swift_memory` | Repeating `Timer` and `NotificationCenter` blocks capturing self, Combine sinks, non-`weak` delegates, stored closures, `unowned self` |
|
|
85
|
+
| `review_swift_security` | Hardcoded secrets, credentials in `UserDefaults`, disabled ATS, cleartext HTTP, TLS trust accepted without evaluation, MD5/SHA-1, Keychain accessibility |
|
|
86
|
+
| `review_swift_testing` | **Test files only.** Sleeping, tests with no assertion, live `URLSession`, `await` in an `XCTAssert` autoclosure, order-dependent static state |
|
|
87
|
+
| `review_swift_performance` | Formatters and collection work inside `body`, `ForEach` over indices, eager stacks in a `ScrollView`, blocking I/O on the render path |
|
|
88
|
+
| `lint_skill` | **Skill metadata, not Swift.** `SKILL.md` frontmatter, subagent `name`/filename mismatches, misspelled tool names, **read-only agents granted `Edit` or `Write`**, mirror files drifted from `SKILL.md`, broken doc references |
|
|
83
89
|
|
|
84
90
|
Every tool takes one argument:
|
|
85
91
|
|
|
@@ -87,6 +93,47 @@ Every tool takes one argument:
|
|
|
87
93
|
{ "path": "/absolute/path/to/your/project" }
|
|
88
94
|
```
|
|
89
95
|
|
|
96
|
+
The first ten want a Swift project root. `lint_skill` wants an Agent Skill
|
|
97
|
+
repository root — the folder containing `SKILL.md`.
|
|
98
|
+
|
|
99
|
+
Every review tool also returns `structuredContent` — typed data with `summary`,
|
|
100
|
+
`score`, `counts`, `files_checked`, `issues`, and `suggestions` — alongside the
|
|
101
|
+
markdown, so a workflow can branch on a result without regexing prose.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Resources
|
|
106
|
+
|
|
107
|
+
Tools are verbs the model chooses to call. Resources are nouns a client can read
|
|
108
|
+
without being asked, so a project's shape can be attached to context up front.
|
|
109
|
+
|
|
110
|
+
```jsonc
|
|
111
|
+
{
|
|
112
|
+
"mcpServers": {
|
|
113
|
+
"ios-agent": {
|
|
114
|
+
"command": "npx",
|
|
115
|
+
"args": ["-y", "ios-agent-mcp", "--project", "/absolute/path/to/project"]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Resource | Contains |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `ios://project/info` | Counts, deployment target, UI framework, inferred architecture **with its evidence**, DI detection, frameworks |
|
|
124
|
+
| `ios://project/dependencies` | Third-party packages from `Package.swift` / `Package.resolved` / `Podfile`, plus Apple frameworks |
|
|
125
|
+
| `ios://project/issues` | Every finding across all nine categories, with counts by severity and category |
|
|
126
|
+
|
|
127
|
+
The root comes from `--project`, then `IOS_AGENT_PROJECT`, then the working
|
|
128
|
+
directory the client spawned the server in. **Every payload reports which root it
|
|
129
|
+
used**, so an empty project is never mistaken for a wrong path.
|
|
130
|
+
|
|
131
|
+
**There is deliberately no `ios://project/build-status`.** It would have to run
|
|
132
|
+
`xcodebuild`, which needs macOS and Xcode and breaks the
|
|
133
|
+
`filesystem: read, network: none` contract that lets this package install
|
|
134
|
+
anywhere in ~26 KB. Build and simulator state belong in the separate package that
|
|
135
|
+
already requires a full toolchain — see [ROADMAP.md](../ROADMAP.md).
|
|
136
|
+
|
|
90
137
|
---
|
|
91
138
|
|
|
92
139
|
## What it does and does not do
|
|
@@ -115,7 +162,7 @@ Test, mock, stub, and preview files are exempt from the app-code-only rules, and
|
|
|
115
162
|
```bash
|
|
116
163
|
npm install
|
|
117
164
|
npm run build # tsc
|
|
118
|
-
npm test #
|
|
165
|
+
npm test # 123 tests: unit + end-to-end over real MCP stdio
|
|
119
166
|
npm run typecheck
|
|
120
167
|
```
|
|
121
168
|
|
|
@@ -151,7 +198,7 @@ After publishing, verify:
|
|
|
151
198
|
|
|
152
199
|
```bash
|
|
153
200
|
npm view ios-agent-mcp version # registry has it
|
|
154
|
-
npx -y ios-agent-mcp --version # 1.0
|
|
201
|
+
npx -y ios-agent-mcp --version # 2.1.0
|
|
155
202
|
npx -y ios-agent-mcp --help # usage, tool list, setup commands
|
|
156
203
|
```
|
|
157
204
|
|
|
@@ -173,10 +220,20 @@ The npm package version and the repository version are **independent**:
|
|
|
173
220
|
|
|
174
221
|
| | Version | Why |
|
|
175
222
|
|---|---|---|
|
|
176
|
-
| `ios-agent-mcp` on npm | `1.0
|
|
177
|
-
| `ios-agent-skill` repo / `SKILL.md` | `2.
|
|
223
|
+
| `ios-agent-mcp` on npm | `2.1.0` | Generated from `package.json` — see below |
|
|
224
|
+
| `ios-agent-skill` repo / `SKILL.md` | `2.1.0` | Kept in lockstep since 2.1.0 |
|
|
225
|
+
|
|
226
|
+
**The version lives in `package.json` and nowhere else.** `mcp.json`,
|
|
227
|
+
`package-lock.json`, the CLI, and the MCP handshake are all generated from it by
|
|
228
|
+
`npm run sync-version`, which `build` and `typecheck` run automatically.
|
|
229
|
+
|
|
230
|
+
This exists because **2.0.1 shipped to npm with an `mcp.json` declaring
|
|
231
|
+
`1.0.0`** — the version was maintained by hand in four places, so one was always
|
|
232
|
+
wrong and nothing checked. CI runs `sync-version --check`, so a hand-edit fails
|
|
233
|
+
the build rather than reaching the registry.
|
|
178
234
|
|
|
179
|
-
|
|
235
|
+
Since 2.1.0 the skill and the server share a version. They were independent
|
|
236
|
+
before, which is exactly how 2.0.1 shipped with a manifest reading 1.0.0.
|
|
180
237
|
|
|
181
238
|
## License
|
|
182
239
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Finding, SourceFile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Retain cycles and lifetime defects.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately conservative. A closure capturing `self` is not a leak — most
|
|
6
|
+
* are fine, because most closures are consumed immediately. A leak needs the
|
|
7
|
+
* closure to be *stored* by something the object itself owns. So these rules
|
|
8
|
+
* fire on the specific storing APIs (Timer, NotificationCenter, Combine sinks,
|
|
9
|
+
* delegate assignment) rather than on `self.` inside any closure, which would
|
|
10
|
+
* bury the real findings under hundreds of false ones.
|
|
11
|
+
*/
|
|
12
|
+
export declare function analyzeMemory(file: SourceFile): Finding[];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { eachLine, isSupportFile } from "./types.js";
|
|
2
|
+
const CONCURRENCY_DOC = "docs/swift/swift-concurrency.md";
|
|
3
|
+
const MVVM_DOC = "patterns/mvvm.md";
|
|
4
|
+
/**
|
|
5
|
+
* Retain cycles and lifetime defects.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately conservative. A closure capturing `self` is not a leak — most
|
|
8
|
+
* are fine, because most closures are consumed immediately. A leak needs the
|
|
9
|
+
* closure to be *stored* by something the object itself owns. So these rules
|
|
10
|
+
* fire on the specific storing APIs (Timer, NotificationCenter, Combine sinks,
|
|
11
|
+
* delegate assignment) rather than on `self.` inside any closure, which would
|
|
12
|
+
* bury the real findings under hundreds of false ones.
|
|
13
|
+
*/
|
|
14
|
+
export function analyzeMemory(file) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
if (isSupportFile(file.path))
|
|
17
|
+
return findings;
|
|
18
|
+
const push = (line, excerpt, rule, severity, message, consequence, fix, doc = CONCURRENCY_DOC) => findings.push({
|
|
19
|
+
file: file.path,
|
|
20
|
+
line,
|
|
21
|
+
severity,
|
|
22
|
+
rule,
|
|
23
|
+
message,
|
|
24
|
+
consequence,
|
|
25
|
+
fix,
|
|
26
|
+
doc,
|
|
27
|
+
excerpt: excerpt.trim(),
|
|
28
|
+
});
|
|
29
|
+
const lines = file.content.split("\n");
|
|
30
|
+
/** Does the closure opening on this line declare a capture list within a few lines? */
|
|
31
|
+
const capturesWeakly = (index) => {
|
|
32
|
+
const window = lines.slice(index, index + 3).join(" ");
|
|
33
|
+
return /\[\s*(weak|unowned)\s+self/.test(window);
|
|
34
|
+
};
|
|
35
|
+
eachLine(file, (line, number) => {
|
|
36
|
+
const index = number - 1;
|
|
37
|
+
// Timer.scheduledTimer retains its block until invalidate(). A view model
|
|
38
|
+
// that owns the timer and is captured strongly by it never deallocates.
|
|
39
|
+
if (/Timer\.scheduledTimer\s*\(/.test(line) &&
|
|
40
|
+
/repeats:\s*true/.test(lines.slice(index, index + 4).join(" ")) &&
|
|
41
|
+
!capturesWeakly(index)) {
|
|
42
|
+
push(number, line, "timer-retain-cycle", "serious", "Repeating Timer captures self strongly.", "The run loop retains the timer, the timer retains the block, and the block retains self. The object never deallocates — the cycle is self -> Timer -> closure -> self.", "Capture `[weak self]`, and call `invalidate()` when the owner goes away. A repeating timer is never released by ARC alone.");
|
|
43
|
+
}
|
|
44
|
+
// NotificationCenter block observers must be removed AND captured weakly.
|
|
45
|
+
if (/NotificationCenter\.\w+\.addObserver\s*\(\s*forName/.test(line) &&
|
|
46
|
+
!capturesWeakly(index)) {
|
|
47
|
+
push(number, line, "notification-observer-retain", "serious", "Block-based notification observer captures self strongly.", "NotificationCenter holds the block for the lifetime of the returned token. Without a weak capture the observer keeps the object alive forever, and the handler keeps firing after the screen is gone.", "Capture `[weak self]` and store the returned token so it can be removed. In new code prefer `NotificationCenter.notifications(named:)` with a structured `for await`, which ends with the task.");
|
|
48
|
+
}
|
|
49
|
+
// Combine sinks are stored in cancellables owned by the same object.
|
|
50
|
+
if (/\.sink\s*(\(|\{)/.test(line) && !capturesWeakly(index)) {
|
|
51
|
+
const window = lines.slice(index, index + 6).join(" ");
|
|
52
|
+
if (/\bself\./.test(window)) {
|
|
53
|
+
push(number, line, "sink-retain-cycle", "serious", "Combine sink captures self strongly.", "The subscription is stored in a `cancellables` set owned by self, so self -> AnyCancellable -> closure -> self is a cycle that survives the view.", "Capture `[weak self]` in the sink closure.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// `unowned` on anything that can outlive its referent is a crash, not a leak.
|
|
57
|
+
if (/\[\s*unowned\s+self\s*\]/.test(line)) {
|
|
58
|
+
push(number, line, "unowned-self", "serious", "`unowned self` crashes if the closure outlives the object.", "Unlike `weak`, `unowned` does not nil out. Any escaping closure that runs after deallocation — a completion handler, a timer, a delayed task — traps instead of no-oping.", "Use `[weak self]` and `guard let self else { return }`. Reserve `unowned` for closures provably shorter-lived than self.");
|
|
59
|
+
}
|
|
60
|
+
// A strong delegate is the classic parent/child cycle.
|
|
61
|
+
if (/\bvar\s+delegate\s*:/.test(line) && !/\bweak\b/.test(line)) {
|
|
62
|
+
// AnyObject-constrained protocols only; a struct delegate cannot cycle.
|
|
63
|
+
push(number, line, "strong-delegate", "serious", "Delegate property is not `weak`.", "The delegate is almost always the owner, so owner -> child -> delegate -> owner is a cycle and neither side is ever freed.", "Declare it `weak var delegate: (any SomeDelegate)?` and constrain the protocol to `AnyObject`.");
|
|
64
|
+
}
|
|
65
|
+
// Escaping closure stored on the type, capturing self.
|
|
66
|
+
if (/\bvar\s+\w+\s*:\s*\(\s*.*\)\s*->\s*\w+\s*=\s*\{/.test(line) &&
|
|
67
|
+
!capturesWeakly(index) &&
|
|
68
|
+
/\bself\./.test(lines.slice(index, index + 4).join(" "))) {
|
|
69
|
+
push(number, line, "stored-closure-captures-self", "serious", "Stored closure property captures self strongly.", "The object owns the closure and the closure owns the object. Nothing releases either.", "Capture `[weak self]`, or restructure so the closure takes what it needs as a parameter.");
|
|
70
|
+
}
|
|
71
|
+
// Task { } stored on a type, capturing self, with no cancellation.
|
|
72
|
+
if (/\bTask\s*\{/.test(line) && !capturesWeakly(index)) {
|
|
73
|
+
const window = lines.slice(index, index + 8).join(" ");
|
|
74
|
+
if (/while\s+true|for\s+await/.test(window) && /\bself\./.test(window)) {
|
|
75
|
+
push(number, line, "long-lived-task-captures-self", "serious", "Long-running Task captures self strongly.", "An unstructured Task holds its captures until it finishes. A `for await` loop over an endless sequence never finishes, so the object is never released and keeps reacting after its screen is gone.", "Store the Task and cancel it on teardown, or move the loop into `.task {}` on the view so SwiftUI cancels it. Capture `[weak self]` if it must be unstructured.", MVVM_DOC);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.js","sourceRoot":"","sources":["../../src/analyzers/memory.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Finding, SourceFile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Runtime cost that shows up as dropped frames.
|
|
4
|
+
*
|
|
5
|
+
* The unifying rule: `body` can run many times per second, on the main actor,
|
|
6
|
+
* for reasons you do not control. Anything expensive inside it is multiplied by
|
|
7
|
+
* a number nobody measured. These rules look for work that does not belong on
|
|
8
|
+
* that path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function analyzePerformance(file: SourceFile): Finding[];
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { eachLine, isSupportFile } from "./types.js";
|
|
2
|
+
const PERF_DOC = "checklists/performance.md";
|
|
3
|
+
const SWIFTUI_DOC = "docs/swiftui/views-and-controls.md";
|
|
4
|
+
const CONCURRENCY_DOC = "docs/swift/swift-concurrency.md";
|
|
5
|
+
/**
|
|
6
|
+
* Runtime cost that shows up as dropped frames.
|
|
7
|
+
*
|
|
8
|
+
* The unifying rule: `body` can run many times per second, on the main actor,
|
|
9
|
+
* for reasons you do not control. Anything expensive inside it is multiplied by
|
|
10
|
+
* a number nobody measured. These rules look for work that does not belong on
|
|
11
|
+
* that path.
|
|
12
|
+
*/
|
|
13
|
+
export function analyzePerformance(file) {
|
|
14
|
+
const findings = [];
|
|
15
|
+
if (isSupportFile(file.path))
|
|
16
|
+
return findings;
|
|
17
|
+
const push = (line, excerpt, rule, severity, message, consequence, fix, doc = PERF_DOC) => findings.push({
|
|
18
|
+
file: file.path,
|
|
19
|
+
line,
|
|
20
|
+
severity,
|
|
21
|
+
rule,
|
|
22
|
+
message,
|
|
23
|
+
consequence,
|
|
24
|
+
fix,
|
|
25
|
+
doc,
|
|
26
|
+
excerpt: excerpt.trim(),
|
|
27
|
+
});
|
|
28
|
+
const lines = file.content.split("\n");
|
|
29
|
+
const isSwiftUI = /import\s+SwiftUI/.test(file.content);
|
|
30
|
+
/** Line ranges of `var body: some View { ... }`, so rules can scope to them. */
|
|
31
|
+
const bodyRanges = [];
|
|
32
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
33
|
+
if (!/\bvar\s+body\s*:\s*some\s+View\b/.test(lines[index]))
|
|
34
|
+
continue;
|
|
35
|
+
let depth = 0;
|
|
36
|
+
let started = false;
|
|
37
|
+
for (let cursor = index; cursor < lines.length; cursor += 1) {
|
|
38
|
+
depth += (lines[cursor].match(/\{/g) ?? []).length;
|
|
39
|
+
depth -= (lines[cursor].match(/\}/g) ?? []).length;
|
|
40
|
+
if (!started && depth > 0)
|
|
41
|
+
started = true;
|
|
42
|
+
if (started && depth <= 0) {
|
|
43
|
+
bodyRanges.push([index + 1, cursor + 1]);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const inBody = (line) => bodyRanges.some(([start, end]) => line >= start && line <= end);
|
|
49
|
+
eachLine(file, (line, number) => {
|
|
50
|
+
const index = number - 1;
|
|
51
|
+
// Formatters are famously expensive to construct — and this is inside body.
|
|
52
|
+
if (/\b(DateFormatter|NumberFormatter|ISO8601DateFormatter|DateComponentsFormatter|JSONDecoder|JSONEncoder)\s*\(\s*\)/.test(line)) {
|
|
53
|
+
const scoped = inBody(number);
|
|
54
|
+
push(number, line, scoped ? "formatter-allocated-in-body" : "formatter-allocated-repeatedly", scoped ? "serious" : "minor", `${scoped ? "Formatter allocated inside `body`" : "Formatter allocated inline"}.`, scoped
|
|
55
|
+
? "`body` re-runs on every state change, and constructing a DateFormatter is one of the most expensive routine operations on the platform. In a scrolling list this is the hitch."
|
|
56
|
+
: "Constructing a formatter costs orders of magnitude more than using one. Inside a loop or a row builder it dominates the work.", "Hoist it to a `static let`, or use `Date.FormatStyle` / `.formatted()`, which is cached by the system.", scoped ? SWIFTUI_DOC : PERF_DOC);
|
|
57
|
+
}
|
|
58
|
+
if (!inBody(number))
|
|
59
|
+
return;
|
|
60
|
+
// Sorting or filtering a collection on every render.
|
|
61
|
+
if (/\.(sorted|filter|map|reduce|compactMap|flatMap)\s*(\(|\{)/.test(line)) {
|
|
62
|
+
// Only flag when it is feeding a ForEach — that is the O(n) × every-frame case.
|
|
63
|
+
const window = lines.slice(Math.max(0, index - 2), index + 2).join(" ");
|
|
64
|
+
if (/ForEach\s*\(/.test(window)) {
|
|
65
|
+
push(number, line, "collection-work-in-body", "serious", "Collection transformed inside `body`.", "The sort or filter runs on every render of this view, on the main actor. With a list of any size this is visible as scroll stutter, and the cost scales with both list length and render frequency.", "Compute it once in the model — a `private(set)` property updated when the source changes, or a cached derived value. `body` should read, not compute.", SWIFTUI_DOC);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ForEach without stable identity re-creates every row.
|
|
69
|
+
if (/ForEach\s*\(\s*(?:0\s*\.\.[.<]|\w+\.indices)/.test(line)) {
|
|
70
|
+
push(number, line, "foreach-over-indices", "serious", "`ForEach` over indices rather than identity.", "Identity is positional, so any insertion or reorder invalidates every row after it. SwiftUI rebuilds and re-animates the whole list instead of the one row that changed.", "Iterate the elements and give them stable `Identifiable` conformance.", SWIFTUI_DOC);
|
|
71
|
+
}
|
|
72
|
+
// Eager stacks inside a ScrollView build every child up front.
|
|
73
|
+
if (/\bScrollView\s*(\(|\{)/.test(line)) {
|
|
74
|
+
const window = lines.slice(index, Math.min(index + 6, lines.length)).join(" ");
|
|
75
|
+
if (/\b(VStack|HStack)\s*(\(|\{)/.test(window) && !/Lazy/.test(window)) {
|
|
76
|
+
push(number, line, "eager-stack-in-scrollview", "serious", "`ScrollView` containing a non-lazy stack.", "VStack builds every child immediately, including the thousands below the fold. Launch time and memory scale with the whole collection rather than what is visible.", "Use `LazyVStack` / `LazyHStack`, or a `List`.", SWIFTUI_DOC);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// AsyncImage with no size is a layout thrash plus an unbounded decode.
|
|
80
|
+
if (/AsyncImage\s*\(/.test(line)) {
|
|
81
|
+
const window = lines.slice(index, Math.min(index + 8, lines.length)).join(" ");
|
|
82
|
+
if (!/\.frame\s*\(/.test(window)) {
|
|
83
|
+
push(number, line, "asyncimage-without-frame", "minor", "`AsyncImage` with no frame.", "The view resizes when the image arrives, which reflows everything around it — and a full-resolution remote image is decoded at its native size regardless of how small it renders.", "Give it a `.frame` and `.resizable().scaledToFill()`, so layout is stable before the load completes.", SWIFTUI_DOC);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// UIImage(named:) in body — repeated lookups, and it caches unboundedly.
|
|
87
|
+
if (/UIImage\s*\(\s*contentsOfFile:/.test(line)) {
|
|
88
|
+
push(number, line, "image-decode-in-body", "serious", "Image decoded from disk inside `body`.", "Disk I/O and decode happen on the main actor, every render. This blocks the frame outright.", "Load it in the model, off the main actor, and pass the decoded image in.", CONCURRENCY_DOC);
|
|
89
|
+
}
|
|
90
|
+
// Synchronous file or network access on the render path.
|
|
91
|
+
if (/\b(Data\s*\(\s*contentsOf:|String\s*\(\s*contentsOf:|FileManager\.\w+\.contents)/.test(line)) {
|
|
92
|
+
push(number, line, "blocking-io-in-body", "blocker", "Blocking I/O inside `body`.", "This runs synchronously on the main actor while the frame is being built. A slow disk or a remote URL freezes the UI outright — `Data(contentsOf:)` on an http URL is a network request with no timeout.", "Move it into the model behind `async`, and render from state.", CONCURRENCY_DOC);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
// A GeometryReader wrapping the whole body forces a layout pass per change.
|
|
96
|
+
if (isSwiftUI) {
|
|
97
|
+
for (const [start] of bodyRanges) {
|
|
98
|
+
const first = lines[start] ?? "";
|
|
99
|
+
if (/^\s*GeometryReader\s*\{/.test(first)) {
|
|
100
|
+
push(start + 1, first, "geometryreader-wraps-body", "minor", "`GeometryReader` wraps the entire body.", "It takes all available space and invalidates its content on every geometry change, so the whole subtree re-lays-out during any resize, rotation, or keyboard animation.", "Scope it to the subview that needs the measurement, or use `.containerRelativeFrame` / `onGeometryChange`.", SWIFTUI_DOC);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=performance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"performance.js","sourceRoot":"","sources":["../../src/analyzers/performance.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { eachLine, isSupportFile } from "./types.js";
|
|
2
|
+
const SECURITY_DOC = "checklists/security.md";
|
|
3
|
+
const CRYPTO_DOC = "docs/frameworks/cryptokit.md";
|
|
4
|
+
const AUTH_DOC = "docs/frameworks/local-authentication.md";
|
|
5
|
+
/** Names that look like secrets when assigned a string literal. */
|
|
6
|
+
const SECRET_NAME = /\b(api[_-]?key|apikey|secret|password|passwd|token|access[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|auth[_-]?token|bearer)\b/i;
|
|
7
|
+
/**
|
|
8
|
+
* Placeholders people legitimately commit. Flagging these trains readers to
|
|
9
|
+
* ignore the rule, which is worse than not having it.
|
|
10
|
+
*/
|
|
11
|
+
const PLACEHOLDER = /^(|<[^>]*>|your[_-]?\w*|xxx+|todo|tbd|changeme|placeholder|example|dummy|test|fake|sample|\{\{.*\}\}|\$\{.*\}|nil|null)$/i;
|
|
12
|
+
export function analyzeSecurity(file) {
|
|
13
|
+
const findings = [];
|
|
14
|
+
if (isSupportFile(file.path))
|
|
15
|
+
return findings;
|
|
16
|
+
const push = (line, excerpt, rule, severity, message, consequence, fix, doc = SECURITY_DOC) => findings.push({
|
|
17
|
+
file: file.path,
|
|
18
|
+
line,
|
|
19
|
+
severity,
|
|
20
|
+
rule,
|
|
21
|
+
message,
|
|
22
|
+
consequence,
|
|
23
|
+
fix,
|
|
24
|
+
doc,
|
|
25
|
+
excerpt: excerpt.trim(),
|
|
26
|
+
});
|
|
27
|
+
eachLine(file, (line, number) => {
|
|
28
|
+
// Hardcoded credential. Anything in the binary is extractable in minutes.
|
|
29
|
+
const assignment = /\b(?:let|var)\s+(\w+)\s*(?::\s*String\s*)?=\s*"([^"]*)"/.exec(line);
|
|
30
|
+
if (assignment) {
|
|
31
|
+
const [, name, value] = assignment;
|
|
32
|
+
if (SECRET_NAME.test(name) && !PLACEHOLDER.test(value.trim()) && value.length >= 8) {
|
|
33
|
+
push(number, line, "hardcoded-secret", "blocker", `\`${name}\` is a string literal in source.`, "Anything compiled into the app is extractable — `strings` on the binary takes seconds, and the value is in every copy ever shipped. Rotating it means shipping an update, and App Review does not remove it from installed builds.", "Move it server-side. If the client genuinely must hold a credential, fetch it at runtime after authentication and store it in the Keychain — never in the bundle, `Info.plist`, or source.");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// UserDefaults is a plist in the container: unencrypted, and in backups.
|
|
37
|
+
if (/UserDefaults\.\w+\.set\s*\(/.test(line) &&
|
|
38
|
+
SECRET_NAME.test(line)) {
|
|
39
|
+
push(number, line, "secret-in-userdefaults", "blocker", "Credential written to UserDefaults.", "UserDefaults is an unencrypted plist inside the app container. It is readable on a jailbroken device, and it is included in unencrypted iTunes/Finder backups — so the token leaves the device entirely.", "Use the Keychain with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. See `docs/frameworks/local-authentication.md` for access control.", AUTH_DOC);
|
|
40
|
+
}
|
|
41
|
+
// ATS disabled wholesale.
|
|
42
|
+
if (/NSAllowsArbitraryLoads/.test(line)) {
|
|
43
|
+
push(number, line, "ats-disabled", "blocker", "App Transport Security is disabled globally.", "Every request in the app may fall back to cleartext HTTP, so any network the user is on can read and modify traffic. App Review requires a written justification and rejects most of them.", "Remove the key. If one legacy host genuinely needs it, scope the exception with `NSExceptionDomains` for that host only.");
|
|
44
|
+
}
|
|
45
|
+
// http:// endpoints.
|
|
46
|
+
if (/"http:\/\/(?!localhost|127\.0\.0\.1|0\.0\.0\.0)/.test(line)) {
|
|
47
|
+
push(number, line, "cleartext-http", "serious", "Cleartext HTTP endpoint.", "Traffic is readable and modifiable by anyone on the path. ATS blocks it at runtime unless an exception was added, so this either fails in production or an exception is hiding elsewhere.", "Use `https://`.");
|
|
48
|
+
}
|
|
49
|
+
// Disabled TLS validation.
|
|
50
|
+
if (/\.useCredential\s*,\s*URLCredential\(trust:/.test(line) ||
|
|
51
|
+
/completionHandler\(\s*\.useCredential\s*,\s*URLCredential\(trust:/.test(line)) {
|
|
52
|
+
push(number, line, "tls-validation-bypassed", "blocker", "Server trust accepted without evaluation.", "Accepting the challenge's trust object unconditionally disables certificate validation, so any proxy can impersonate the server. Added for a dev proxy, this always ships.", "Evaluate with `SecTrustEvaluateWithError` before accepting. If you need pinning, pin the public key and keep a backup pin.", CRYPTO_DOC);
|
|
53
|
+
}
|
|
54
|
+
// Weak hashes for anything security-relevant.
|
|
55
|
+
if (/\b(Insecure\.)?(MD5|SHA1)\b/.test(line)) {
|
|
56
|
+
push(number, line, "weak-hash", "serious", "MD5 or SHA-1 used.", "Both are collision-broken. CryptoKit files them under `Insecure` for a reason. Fine for a cache key, unsafe for signatures, integrity checks, or password derivation.", "Use `SHA256`. For passwords use a KDF (PBKDF2, scrypt, Argon2) — a plain hash of any speed is the wrong primitive.", CRYPTO_DOC);
|
|
57
|
+
}
|
|
58
|
+
// Keychain items that sync or survive device migration.
|
|
59
|
+
if (/kSecAttrAccessibleAlways/.test(line)) {
|
|
60
|
+
push(number, line, "keychain-always-accessible", "blocker", "Keychain item readable while the device is locked.", "`kSecAttrAccessibleAlways` is deprecated and defeats the point of the Keychain — the item is available to any process even before first unlock.", "Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` for credentials.", AUTH_DOC);
|
|
61
|
+
}
|
|
62
|
+
if (/kSecAttrAccessibleWhenUnlocked\b(?!ThisDeviceOnly)/.test(line)) {
|
|
63
|
+
push(number, line, "keychain-migrates-to-new-device", "minor", "Keychain item without `ThisDeviceOnly` migrates through backups.", "A device-bound credential that restores onto a different device is no longer device-bound, which breaks any server-side assumption built on it.", "Use `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` unless the item is meant to travel.", AUTH_DOC);
|
|
64
|
+
}
|
|
65
|
+
// Randomness that is not cryptographic.
|
|
66
|
+
if (/\b(arc4random|Int\.random|Double\.random|UUID\(\))/.test(line) &&
|
|
67
|
+
/\b(nonce|salt|iv|token|secret|key)\b/i.test(line)) {
|
|
68
|
+
push(number, line, "non-cryptographic-randomness", "serious", "Security value generated from a non-cryptographic source.", "`Int.random` and `UUID()` are not designed to be unpredictable to an attacker. A guessable nonce or salt defeats the protocol that depends on it.", "Use `SecRandomCopyBytes` or `SymmetricKey(size:)` from CryptoKit.", CRYPTO_DOC);
|
|
69
|
+
}
|
|
70
|
+
// Secrets in logs.
|
|
71
|
+
if (/\b(print|NSLog|debugPrint)\s*\(/.test(line) &&
|
|
72
|
+
SECRET_NAME.test(line)) {
|
|
73
|
+
push(number, line, "secret-logged", "serious", "Credential written to the log.", "Device logs are readable via Console and are collected in sysdiagnose bundles, so the value leaves the app's sandbox. `print` is not stripped in release builds.", "Remove it. Use OSLog with `privacy: .private` for anything that must be logged at all.", "docs/frameworks/oslog.md");
|
|
74
|
+
}
|
|
75
|
+
// WebView JS bridges that trust page content.
|
|
76
|
+
if (/\.evaluateJavaScript\s*\(\s*"[^"]*\\\(/.test(line)) {
|
|
77
|
+
push(number, line, "javascript-string-interpolation", "serious", "String interpolation into evaluated JavaScript.", "Any value containing a quote or a script fragment escapes the expression and runs in the page's context — script injection through your own app.", "Pass values through `WKScriptMessage` / `callAsyncJavaScript(_:arguments:)` rather than building source text.");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=security.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security.js","sourceRoot":"","sources":["../../src/analyzers/security.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Severity } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A defect in a skill repository's own metadata, rather than in Swift source.
|
|
4
|
+
*
|
|
5
|
+
* Separate from `Finding` because the location is a markdown or YAML file, not
|
|
6
|
+
* a Swift file, and the excerpt must not be rendered in a `swift` code fence.
|
|
7
|
+
*/
|
|
8
|
+
export interface SkillFinding {
|
|
9
|
+
/** Repo-relative path. */
|
|
10
|
+
file: string;
|
|
11
|
+
/** 1-indexed line, or 0 when the finding is about the file as a whole. */
|
|
12
|
+
line: number;
|
|
13
|
+
severity: Severity;
|
|
14
|
+
/** Short kebab-case rule id, e.g. "agent-read-only-holds-write-tool". */
|
|
15
|
+
rule: string;
|
|
16
|
+
message: string;
|
|
17
|
+
consequence: string;
|
|
18
|
+
fix: string;
|
|
19
|
+
/** The offending line, trimmed. Empty when not line-anchored. */
|
|
20
|
+
excerpt: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SkillLintResult {
|
|
23
|
+
findings: SkillFinding[];
|
|
24
|
+
/** What the linter actually inspected, so an empty report is not ambiguous. */
|
|
25
|
+
checked: {
|
|
26
|
+
skillFile: string | null;
|
|
27
|
+
agentCount: number;
|
|
28
|
+
mirrorsCompared: number;
|
|
29
|
+
mirrorCheckSkipped: boolean;
|
|
30
|
+
referencedPaths: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Lint a skill repository's metadata: frontmatter, agents, mirrors, references. */
|
|
34
|
+
export declare function lintSkill(root: string): Promise<SkillLintResult>;
|