From f9b8e59c1a7d8977a013af1ab5353ab08d25e38e Mon Sep 17 00:00:00 2001 From: David Hunt Date: Wed, 2 Sep 2026 17:07:56 +0200 Subject: [PATCH 1/2] fix(fosmvvm-bootstrap): seeded memories state the settled signing shape The two seeded memory files carried an unratified evaluation that called disable-library-validation a required companion of ENABLE_DEBUG_DYLIB and floated retiring SPMLibraries. Both now state the shape the templates ship and fosmvvm-doctor audits: one SPMLibraries doorway, app-only embed with re-sign, hardened runtime off in Debug only, ENABLE_DEBUG_DYLIB unset, no entitlement. The index lines follow. --- CHANGELOG.md | 8 +++++ .../Templates/shared/memory/MEMORY.md | 4 +-- .../shared/memory/entitlement-is-a-symptom.md | 33 +++++-------------- .../memory/xcode16-dynamic-spm-packages.md | 29 +++------------- 4 files changed, 23 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3388f10..f12d406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Seeded memories state the settled signing shape** — the scaffold's + `entitlement-is-a-symptom.md` and `xcode16-dynamic-spm-packages.md` carried an + unratified 2026-08 evaluation that called `disable-library-validation` a + required companion of `ENABLE_DEBUG_DYLIB` and floated retiring `SPMLibraries`. + Both now state the shape the templates ship and `fosmvvm-doctor` audits: one + `SPMLibraries` doorway, app-only embed with re-sign, hardened runtime off in + Debug only, `ENABLE_DEBUG_DYLIB` unset, no entitlement. Surfaced by the first + customer doctor run, where the stale note read as a doctrine conflict. - **The scaffold's witnessed-tap helper retires** — the UI-test support templates drop their hand-rolled `tap(_:until:)`/`poll` pair, and the generated tests call FOSTestingUI's `tap(provenBy:)` directly: 0.15.0 ships the framework form, and diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md index 34a79a5..ae71a36 100644 --- a/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md @@ -1,7 +1,7 @@ # Memory Index - [SPMLibraries is settled doctrine](spm-libraries-settled.md) — one umbrella dynamic framework for all SPM products; do not link SPM packages directly into multiple targets -- [disable-library-validation entitlement](entitlement-is-a-symptom.md) — required with ENABLE_DEBUG_DYLIB + ENABLE_HARDENED_RUNTIME; was a shape-symptom pre-Xcode 16 -- [Xcode 16 dynamic SPM packages](xcode16-dynamic-spm-packages.md) — ENABLE_DEBUG_DYLIB forces SPM packages dynamic; SPMLibraries may be obsolete for Xcode 16+ projects +- [disable-library-validation entitlement](entitlement-is-a-symptom.md) — always a symptom of wrong embedding/signing shape; remove it and fix the cause (R12 hardened-runtime-in-Debug, embed vs link), never add it +- [Xcode 16 dynamic SPM packages](xcode16-dynamic-spm-packages.md) — leave ENABLE_DEBUG_DYLIB unset; the dynamic-package route was evaluated and not taken, SPMLibraries stands - [Stale build runbook](stale-build-runbook.md) — Clean Build Folder (not rm -rf DerivedData) for bogus undefined-symbol errors - [macOS build-for-testing FAQ](macos-build-for-testing-faq.md) — common macOS test build issues diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md index 8b8a539..3d3a59b 100644 --- a/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/entitlement-is-a-symptom.md @@ -1,34 +1,19 @@ --- name: entitlement-disable-library-validation -description: When disable-library-validation is required vs. a symptom of wrong shape — updated for Xcode 16+ dynamic SPM behavior +description: disable-library-validation is a symptom of the wrong embedding or signing shape, never a fix — the scaffold's shape does not need it metadata: type: feedback --- -## Xcode 16+ (ENABLE_DEBUG_DYLIB era) +`com.apple.security.cs.disable-library-validation` is **not** part of this project's shape. If it appears in the app's entitlements, something upstream is wrong. -`com.apple.security.cs.disable-library-validation` is **required** when: +**Why it is a symptom:** the entitlement tells the hardened runtime to accept frameworks signed by a different Team ID. The only frameworks that would trip that check are ad-hoc-signed SPM package frameworks loaded into a hardened-runtime process. The scaffold never puts the app in that position: -- `ENABLE_DEBUG_DYLIB = YES` on the app target, AND -- `ENABLE_HARDENED_RUNTIME = YES` on the app target +- Every SPM product enters through the single `SPMLibraries` umbrella framework (see [[spm-libraries-settled]]). +- The app alone embeds `SPMLibraries` and re-signs it with the developer's team (`embed: true`, `codeSign: true`). Nothing ad-hoc-signed ships inside the bundle. +- `ENABLE_HARDENED_RUNTIME` is `NO` in Debug and `YES` in Release. Debug keeps macOS UI testing alive; Release keeps notarization. +- `ENABLE_DEBUG_DYLIB` is left unset. Setting it forces SPM packages to build as separate dynamic `PackageFrameworks/`, which is exactly the ad-hoc-signed-framework situation above. -**Why:** `ENABLE_DEBUG_DYLIB` forces SPM `.automatic` packages to build as -dynamic frameworks in `PackageFrameworks/`. Those are linker-signed (ad-hoc, -TeamIdentifier=not set). Hardened runtime rejects loading them into a -developer-signed process (Team ID mismatch). The entitlement permits it. +**What to do when it is present:** remove the entitlement and fix the cause. `fosmvvm-doctor` (`swift package fosmvvm-doctor`, or `fosmvvm-bootstrap doctor --project `) names the cause: a framework embedding `SPMLibraries` instead of link-only, hardened runtime on in Debug, or signing off. -This is **not** a shape defect — it is the correct companion entitlement for -the dynamic-package build mode Xcode 16 introduced. - -**How to apply:** Add to the app target's `.entitlements` file alongside -`app-sandbox`. See [[xcode16-dynamic-spm-packages]]. - ---- - -## Pre-Xcode 16 (static SPM era) — historical - -Old guidance: you only need the entitlement when embeds ad-hoc PackageFrameworks -(wrong shape) or signing is off. Correct shape (static SPM → SPMLibraries → -signed) made the entitlement unnecessary. - -That guidance is **obsolete** for projects using `ENABLE_DEBUG_DYLIB = YES`. +**Do not** add the entitlement as a workaround for a dyld crash on launch ("different Team IDs"). That crash is the hardened-runtime-in-Debug finding; the answer is R12, not an entitlement. diff --git a/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md b/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md index 537e92e..b490db6 100644 --- a/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md +++ b/Sources/FOSMVVMBootstrap/Templates/shared/memory/xcode16-dynamic-spm-packages.md @@ -1,33 +1,12 @@ --- name: xcode16-dynamic-spm-packages -description: Xcode 16+ ENABLE_DEBUG_DYLIB forces SPM packages dynamic — implications for SPMLibraries and signing +description: ENABLE_DEBUG_DYLIB was evaluated as a replacement for SPMLibraries and is not the scaffold's shape — leave it unset; SPMLibraries stands metadata: type: project --- -When `ENABLE_DEBUG_DYLIB = YES` is set on an app target, Xcode 16+ builds SPM -`.automatic` packages as **dynamic frameworks** in `PackageFrameworks/` rather -than as static libs compiled into the consuming framework. +**Do not set `ENABLE_DEBUG_DYLIB` on the app target.** Leave it unset, as the scaffold ships it. -**Why:** The debug dylib feature splits the compiled app into a stub + -`.debug.dylib`. All dependencies must be separately loadable as dynamic -frameworks to support this split. +When `ENABLE_DEBUG_DYLIB = YES`, Xcode 16+ builds SPM `.automatic` packages as separate dynamic frameworks under `PackageFrameworks/` instead of compiling them into the consuming framework. Those frameworks are linker-signed ad-hoc, so a hardened-runtime process refuses to load them and the app dies in dyld before `main()`. Working around that requires the `disable-library-validation` entitlement, which is a symptom, not a shape (see [[entitlement-disable-library-validation]]). -**Implication for SPMLibraries:** The SPMLibraries umbrella was created to solve -the static-SPM type-identity problem (multiple targets → multiple static copies -→ `TypeA != TypeA`). With dynamic SPM packages, the OS dynamic linker loads each -package once per process — the type-identity problem is solved natively. This -means **SPMLibraries may be obsolete for Xcode 16+ projects**. - -**Known cons of SPMLibraries that go away with dynamic packages:** -- Xcode dependency scanner warnings: `'FOSMVVM' is missing a dependency on 'Yams'` -- Spurious stale-build failures (graph opacity) -- Shape confusion for tools and people reading the project - -**Signing side-effect:** Dynamic PackageFrameworks are linker-signed (ad-hoc). -With `ENABLE_HARDENED_RUNTIME = YES`, this requires the -`com.apple.security.cs.disable-library-validation` entitlement. -See [[entitlement-disable-library-validation]]. - -**Status (2026-08-08):** Under active evaluation. TestLocalOnly is the test bed. -SPMLibraries retirement is being considered for all Xcode 16+ FOS projects. +This route was evaluated (2026-08-08, on a local-only test bed) as a way to retire `SPMLibraries`, on the theory that dynamic packages give one copy per process and so solve type identity natively. The scaffold did not take it. `SPMLibraries` remains the one doorway for SPM products, the app alone embeds and re-signs it, and hardened runtime is off in Debug only. That shape needs no entitlement and no `ENABLE_DEBUG_DYLIB`, and it is the shape `fosmvvm-doctor` audits against. See [[spm-libraries-settled]]. From f1403b76d14161ac6d164f7906efd8cc0e3abb96 Mon Sep 17 00:00:00 2001 From: David Hunt Date: Wed, 2 Sep 2026 17:37:15 +0200 Subject: [PATCH 2/2] feat(fosmvvm-doctor): a finding names a rule the project may disable; fosmvvm-review honors it Finding gains an optional rule (DisableableRule, one case: app_sandbox) on the findings a project may hold on purpose, named in SwiftLint's vocabulary. The JSON carries the identifier on those findings only; the text report prints it under the finding. The review skill's .fosmvvm-review.yml gains doctor.disabled_rules (rule, target, reason): a matched finding reports at warning with the reason and no longer halts tier 2. Doctor's own verdict is unchanged. Design addendum, DocC, and plugin 2.64.0. --- .claude-plugin/plugin.json | 2 +- .claude/skills/fosmvvm-review/SKILL.md | 19 ++++++---- CHANGELOG.md | 12 +++++++ .../FOSMVVM/FOSMVVM.docc/CreatingAProject.md | 10 ++++++ Sources/FOSMVVMBootstrap/Doctor/Doctor.swift | 6 +++- Sources/FOSMVVMBootstrap/Doctor/Finding.swift | 36 ++++++++++++++++++- .../Doctor/ProjectRule+Shape.swift | 10 +++++- Tests/FOSMVVMBootstrapTests/DoctorTests.swift | 31 +++++++++++++++- docs/work/fosmvvm-doctor-design.md | 22 ++++++++++++ 9 files changed, 137 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5012dd3..f3cb07b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "fosmvvm-generators", "description": "FOSMVVM architecture generators for ViewModels, Fields, DataModels, ServerRequests, Leaf Views, and ViewModel Tests", - "version": "2.63.0", + "version": "2.64.0", "author": { "name": "FOS Computer Services" }, diff --git a/.claude/skills/fosmvvm-review/SKILL.md b/.claude/skills/fosmvvm-review/SKILL.md index 8d33e2f..bcfc134 100644 --- a/.claude/skills/fosmvvm-review/SKILL.md +++ b/.claude/skills/fosmvvm-review/SKILL.md @@ -52,6 +52,7 @@ Look for `.fosmvvm-review.yml` at the repo root. If present, parse: - `disabled_checks:` — list of check names to skip globally. - `severity_overrides:` — map of `check-name: severity` (blocker | warning | nit). - `excluded_paths:` — list of glob patterns; matching files are removed from scope. +- `doctor:` → `disabled_rules:` — list of `{rule, target, reason}` entries disabling one doctor rule for one target, the way SwiftLint's `disabled_rules` names rules (Step 2). `rule` is the identifier doctor prints on the finding (`app_sandbox`), `target` the Xcode target it names, `reason` one sentence. An entry without a reason is malformed. Nested under `doctor:` so it never reads as a sibling of `disabled_checks`, which governs tier 2. If the file is missing or any key is absent, use defaults. If the file is malformed (invalid YAML, unknown top-level keys), print a warning and continue with defaults. @@ -69,9 +70,11 @@ Before any triage, run the deterministic structural audit. `fosmvvm-doctor` chec Both routes exit non-zero when doctor finds errors — capture stdout regardless of exit status (append `|| true`). 3. Neither available (no macOS, no checkout): tier 1 is **unavailable**. Record it as such below and continue to Step 3 — the absence is stated in the report, never silent. Name the enabling route (the `CreatingAProject` DocC article, § Diagnosing an existing project). -**Parse the JSON:** `findings` (each carrying `severity` — `error` | `warning` — an optional `target`, `summary`, `remedy`), `unchecked`, and `hasErrors`. +**Parse the JSON:** `findings` (each carrying `severity` — `error` | `warning` — an optional `target`, `summary`, `remedy`, and on the few findings a project may disable a `rule` identifier), `unchecked`, and `hasErrors`. -**The gate (ruled 2026-08-25): structural errors halt tier 2.** When `hasErrors` is true, do not dispatch any area subagent — fix structure first, so that the area reviews find what they expect where they expect. Skip to Step 7 and emit the report now, with: +**Apply the project's disabled doctor rules (ruled 2026-09-02).** For each `doctor.disabled_rules` entry, find the doctor finding whose `rule` and `target` both match. A matched finding is reported at `warning` with `disabled: ` beside it, and it no longer counts toward the gate below. An entry that matches no finding, or names a rule doctor did not print on that target, is reported in the Configuration line as unmatched and otherwise ignored — it never silences anything. Only findings that carry a `rule` identifier can be disabled; every other doctor finding is a defect and stays as reported. Recompute the gate from the findings that remain at `error` — not from doctor's own `hasErrors`, which predates the config. + +**The gate (ruled 2026-08-25): structural errors halt tier 2.** When any doctor finding remains at `error` after the disabled rules are applied, do not dispatch any area subagent — fix structure first, so that the area reviews find what they expect where they expect. Skip to Step 7 and emit the report now, with: - the `structure` section carrying every doctor finding and the `unchecked` list, - `summary.by_area.structure` and `summary.total` counting them (severity mapping: doctor `error` → `blocker`, `warning` → `warning`), @@ -79,7 +82,7 @@ Both routes exit non-zero when doctor finds errors — capture stdout regardless When doctor reports only warnings, or nothing: record the results in the same `structure` section and continue to Step 3. -Doctor findings are deterministic facts, not review judgments: they are exempt from `.fosmvvm-review.yml` overrides and inline suppression, and they carry no check names — each finding's `remedy` is the action. +Doctor findings are deterministic facts, not review judgments: `disabled_checks`, `severity_overrides`, and inline suppression do not reach them, and they carry no check names — each finding's `remedy` is the action. The one door is `doctor.disabled_rules`, and it opens only on findings doctor itself marked with a `rule` identifier: the project is recording a choice, with a reason, not overriding a verdict. ### Step 3: Load Check Files @@ -166,11 +169,12 @@ If a subagent returned an error or timeout, record the area as `ERROR` with the **Tier 2:** (only when halted) halted — doctor reported structural errors; fix structure first, then re-run **Areas triaged:** {comma-separated areas, or "none — tier 2 halted"} **Fail-on threshold:** {threshold} -**Configuration applied:** (omit line if no config) disabled checks: {names}; severity overrides: {name=severity, ...}; excluded paths: {N} +**Configuration applied:** (omit line if no config) disabled checks: {names}; severity overrides: {name=severity, ...}; excluded paths: {N}; doctor disabled rules: {N} ({M} unmatched: {rule@target, ...}) ## Structure (doctor) (omit the section when tier 1 ran clean with nothing unchecked) - {❌ error | ⚠️ warning} {target or (project)}: {summary} → {remedy} +- ⚠️ {target}: {summary} — disabled ({rule}): {reason} (a disabled doctor rule, reported at warning) - Not checked: {each unchecked entry, one line} ## Findings by area @@ -218,7 +222,9 @@ mistaken for a clean one. "tier2": "ran", "structure": { "findings": [ - { "severity": "error", "target": "SPMLibraries", "summary": "...", "remedy": "..." } + { "severity": "error", "target": "SPMLibraries", "summary": "...", "remedy": "..." }, + { "severity": "warning", "target": "PalettePress", "summary": "...", "remedy": "...", + "rule": "app_sandbox", "disabled": "Talks to the local Docker socket." } ], "unchecked": ["entitlements match the project shape (needs --shape)"] }, @@ -227,7 +233,8 @@ mistaken for a clean one. "fail_on": "blocker", "disabled_checks": ["..."], "severity_overrides": { "": "" }, - "excluded_paths_count": 0 + "excluded_paths_count": 0, + "doctor_disabled_rules": { "applied": 1, "unmatched": [] } }, "summary": { "by_area": { "structure": { "blocker": 1, "warning": 0, "nit": 0 }, "viewmodel": { "blocker": 1, "warning": 2, "nit": 0 }, "...": {} }, diff --git a/CHANGELOG.md b/CHANGELOG.md index f12d406..fd4ae49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A doctor finding can name a rule the project may disable** — `Finding.rule` + (`DisableableRule`, one case: `app_sandbox`) marks the few findings a project + may hold on purpose, such as an ops app that runs unsandboxed to reach local + infrastructure. The JSON carries the identifier on those findings only, and the + text report prints it under the finding. `fosmvvm-review` gains + `doctor.disabled_rules` in `.fosmvvm-review.yml` (`rule`, `target`, `reason`), + SwiftLint's vocabulary: a matched finding reports at warning with the reason + beside it and no longer halts tier 2. Doctor's own verdict is unchanged — it + reports facts; the skill applies the project's word. Plugin 2.64.0. + ### Changed - **Seeded memories state the settled signing shape** — the scaffold's diff --git a/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md index c1df6cd..1484bcc 100644 --- a/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md +++ b/Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md @@ -85,6 +85,16 @@ The first run builds the scaffolder, so it takes a few minutes; later runs are i > Note: A standalone binary you could install once, without the checkout, is planned. Until it ships, the checkout is the supported route for Xcode-only projects. +A finding is a defect until you say otherwise, and for most there is no otherwise. The exception is a rule the generated shape always satisfies but an app can break on purpose, such as the sandbox on a tool that must reach local Docker. Doctor still reports it, and prints the rule identifier under the finding. Disable that rule for that target in `.fosmvvm-review.yml` at the repo root, the way you would in `.swiftlint.yml`, and `fosmvvm-review` reports it as a warning with your reason instead of halting: + +```yaml +doctor: + disabled_rules: + - rule: app_sandbox + target: MyApp + reason: Talks to the local Docker socket; sandboxing blocks it. +``` + Reach for it after adding a framework target by hand, or when adopting FOSUtilities in a project the scaffolder never created. The settings it checks are the ones that fail far from their cause: a second direct link to a FOS product (two non-identical copies of the same types, so `is` and `as?` fail across target boundaries at runtime), a misspelled `BUILD_LIBRARY_FOR_DISTRIBUTION` that Xcode silently ignores, a missing `DEVELOPMENT_TEAM` that surfaces as a dyld rejection at launch, a deployment target below the FOSUtilities floor, and a test plan pointing at target identifiers a regeneration re-minted. It also audits the shared-module doctrine: ViewModels declared outside a shared ViewModels module, and server imports (Vapor, Fluent) inside one. Every finding names the setting and the value to use, because fixing it is yours to do. diff --git a/Sources/FOSMVVMBootstrap/Doctor/Doctor.swift b/Sources/FOSMVVMBootstrap/Doctor/Doctor.swift index e62a633..33b679a 100644 --- a/Sources/FOSMVVMBootstrap/Doctor/Doctor.swift +++ b/Sources/FOSMVVMBootstrap/Doctor/Doctor.swift @@ -111,7 +111,8 @@ public extension Doctor { /// /// Both front ends print exactly this under `--json`. The shape is part /// of the contract: `findings` (each with `severity` — `error` or - /// `warning` — an optional `target`, `summary`, and `remedy`), + /// `warning` — an optional `target`, `summary`, `remedy`, and, on the + /// findings a project may disable, a `rule` — see ``DisableableRule``), /// `unchecked`, and `hasErrors`. Keys are sorted, so output is stable /// across runs for diffing and CI. public var json: String { @@ -147,6 +148,9 @@ public extension Doctor { lines.append(" \(finding.target ?? "(project)")") lines.append(" \(finding.summary)") lines.append(" → \(finding.remedy)") + if let rule = finding.rule { + lines.append(" ↳ Deliberate? Disable rule \(rule.rawValue) for this target in .fosmvvm-review.yml.") + } } } diff --git a/Sources/FOSMVVMBootstrap/Doctor/Finding.swift b/Sources/FOSMVVMBootstrap/Doctor/Finding.swift index 81e7b29..8778259 100644 --- a/Sources/FOSMVVMBootstrap/Doctor/Finding.swift +++ b/Sources/FOSMVVMBootstrap/Doctor/Finding.swift @@ -36,6 +36,35 @@ public enum Severity: String, Sendable, Equatable, CaseIterable, Codable { case warning } +/// A doctor rule a project may deliberately disable, named the way SwiftLint +/// names its rules so the identifier reads the same in a config file. +/// +/// Most findings are defects. A few report a rule the generated shape always +/// satisfies but an app can have reasons to break. An ops tool that must +/// reach local Docker or ssh runs unsandboxed on purpose. Such a finding still +/// reports, at its full severity, but it carries a `DisableableRule` so the +/// project can disable that rule for that target in `.fosmvvm-review.yml`: +/// +/// ```yaml +/// doctor: +/// disabled_rules: +/// - rule: app_sandbox +/// target: PalettePress +/// reason: Talks to the local Docker socket; sandboxing blocks it. +/// ``` +/// +/// `fosmvvm-review` then reports it as a warning with the reason beside it, +/// and its tier-2 review dispatches instead of halting. Findings without a +/// rule identifier cannot be disabled — a wrong embed, a missing test host, or +/// a deployment target below the floor is broken, not chosen. +/// +/// > Note: `Doctor` itself never reads the config. Its report is the facts; +/// > the review skill applies the project's word to them. +public enum DisableableRule: String, Sendable, Equatable, CaseIterable, Codable { + /// The app carries `com.apple.security.app-sandbox`. + case appSandbox = "app_sandbox" +} + /// One thing `Doctor` found wrong, and what to do about it. /// /// ```swift @@ -61,10 +90,15 @@ public struct Finding: Sendable, Equatable, Codable { /// What to do about it — the setting and value, or the action to take. public let remedy: String - public init(severity: Severity, target: String? = nil, summary: String, remedy: String) { + /// The rule this finding reports against, when the project may + /// deliberately disable it; nil for every finding that is simply wrong. + public let rule: DisableableRule? + + public init(severity: Severity, target: String? = nil, summary: String, remedy: String, rule: DisableableRule? = nil) { self.severity = severity self.target = target self.summary = summary self.remedy = remedy + self.rule = rule } } diff --git a/Sources/FOSMVVMBootstrap/Doctor/ProjectRule+Shape.swift b/Sources/FOSMVVMBootstrap/Doctor/ProjectRule+Shape.swift index 36acecd..19b74e1 100644 --- a/Sources/FOSMVVMBootstrap/Doctor/ProjectRule+Shape.swift +++ b/Sources/FOSMVVMBootstrap/Doctor/ProjectRule+Shape.swift @@ -54,7 +54,8 @@ extension ProjectRule { severity: .error, target: target.name, summary: "entitlements do not enable \(required).", - remedy: Self.entitlementRemedy(required, shape: shape) + remedy: Self.entitlementRemedy(required, shape: shape), + rule: Self.disableableRule(behind: required) ) ) } @@ -85,6 +86,13 @@ extension ProjectRule { } } + /// The sandbox is the one required entitlement an app can withhold on + /// purpose; `network.client` is not — without it a client-server app + /// cannot reach its own server. + private static func disableableRule(behind entitlement: String) -> DisableableRule? { + entitlement == "com.apple.security.app-sandbox" ? .appSandbox : nil + } + private static func entitlementRemedy(_ entitlement: String, shape: ProjectShape) -> String { switch entitlement { case "com.apple.security.network.client": diff --git a/Tests/FOSMVVMBootstrapTests/DoctorTests.swift b/Tests/FOSMVVMBootstrapTests/DoctorTests.swift index 0621351..0f42c35 100644 --- a/Tests/FOSMVVMBootstrapTests/DoctorTests.swift +++ b/Tests/FOSMVVMBootstrapTests/DoctorTests.swift @@ -350,6 +350,29 @@ struct DoctorStructureRuleTests { #expect(finding.severity == .error) } + @Test("R7 — a withheld sandbox is an error that names its disableable rule") + func withheldSandboxNamesItsRule() throws { + let report = try Fixture.clientServer( + mutatingEntitlements: { $0.replacingOccurrences(of: "com.apple.security.app-sandbox", with: "com.apple.security.unused") }, + shape: .clientServer + ) + let finding = try #require(report.findings.first { $0.summary.contains("app-sandbox") }) + #expect(finding.severity == .error) + #expect(finding.rule == .appSandbox) + #expect(report.hasErrors) + #expect(report.text.contains("rule app_sandbox")) + } + + @Test("R7 — a missing network.client is not disableable") + func missingNetworkClientIsNotDisableable() throws { + let report = try Fixture.clientServer( + mutatingEntitlements: { $0.replacingOccurrences(of: "com.apple.security.network.client", with: "com.apple.security.unused") }, + shape: .clientServer + ) + let finding = try #require(report.findings.first { $0.summary.contains("network.client") }) + #expect(finding.rule == nil) + } + @Test("R7 — disabling library validation is reported as the symptom it is") func disabledLibraryValidation() throws { let report = try Fixture.localOnly( @@ -493,7 +516,7 @@ struct DoctorReportTests { func jsonReportContract() throws { let report = Doctor.Report( findings: [ - Finding(severity: .error, target: "PalettePress", summary: "e", remedy: "fix e"), + Finding(severity: .error, target: "PalettePress", summary: "e", remedy: "fix e", rule: .appSandbox), Finding(severity: .warning, summary: "w", remedy: "fix w") ], unchecked: ["entitlements posture (needs --shape)"] @@ -508,6 +531,12 @@ struct DoctorReportTests { let findingsData = try JSONSerialization.data(withJSONObject: top["findings"] as Any) let decoded = try JSONDecoder().decode([Finding].self, from: findingsData) #expect(decoded == report.findings) + + // The rule identifier is what a project writes in .fosmvvm-review.yml, + // so the JSON must carry it verbatim. + let encoded = try #require(top["findings"] as? [[String: Any]]) + #expect(encoded.first?["rule"] as? String == "app_sandbox") + #expect(encoded.last?["rule"] == nil) } @Test("a clean report's JSON gates false") diff --git a/docs/work/fosmvvm-doctor-design.md b/docs/work/fosmvvm-doctor-design.md index 12c171a..adbd164 100644 --- a/docs/work/fosmvvm-doctor-design.md +++ b/docs/work/fosmvvm-doctor-design.md @@ -149,6 +149,28 @@ public enum Severity: Sendable { case error, warning } **`Finding` carries no rule identity.** The human reader wants prose, the generator skill wants the exit code, and the conformance test wants the list empty. Nobody needs to name a rule programmatically yet, so it is not on the surface. Add it when a caller exists. +### DisableableRule — the one thing a finding names (addendum, ruled 2026-09-02) + +The first customer run halted on a finding the customer holds on purpose: an ops app that runs unsandboxed because it must reach local infrastructure. Doctor findings are exempt from every review override, so that project could never reach tier 2. Ruled: the disablement lives in `.fosmvvm-review.yml`, it opens only on findings doctor itself marks, and the vocabulary is SwiftLint's — rules, snake_case rule identifiers, `disabled_rules` — so nothing has to be learned. + +The caller that exists needs to name a *disableable rule*, not a table row. A table-row identity would be the wrong grain — R7 emits three different findings for one target, only one of which is a choice — and a per-finding identity catalog would be thirty names nobody asked for. So `Finding` gains one optional field: + +```swift +public struct Finding { + public let rule: DisableableRule? // nil on every finding that is simply wrong +} + +public enum DisableableRule: String, CaseIterable, Codable { + case appSandbox = "app_sandbox" +} +``` + +`DisableableRule` is the closed set of rules the generated shape always satisfies but an app may break on purpose. It has one case. `network.client` is not one (without it a client-server app cannot reach its own server); `disable-library-validation` is not (it is a symptom); hardened runtime in Debug is not (it kills UI testing); linkage and embedding are never choices. New cases are ruled onto the enum one at a time, on field evidence, the way rules are ruled onto the table. + +The config nests under `doctor:` — `doctor.disabled_rules`, entries of `rule`, `target`, `reason` — so it never sits beside tier 2's `disabled_checks` as a near-twin key. The JSON carries the identifier verbatim on the findings that have one, and omits the key elsewhere, so parsers written against the earlier shape are unaffected. `Report.text` appends one line under such a finding naming the identifier, so the person reading the terminal knows what to write. Doctor's own verdict does not change: `hasErrors` stays true, the exit code stays non-zero, because doctor reports facts. The review skill applies the config — a matched finding reports at warning with the reason beside it, and the skill recomputes its gate from what remains — and an entry naming a rule doctor did not print is reported as unmatched, never honored. + +**The fuller alignment, not taken yet.** SwiftLint stamps every violation with its rule identifier. Giving all fourteen doctor rules identifiers would let `disabled_rules` and future reporters address any of them; it is fourteen names to arbitrate and no caller asks for it. It waits, like the rest, for a caller. + **`Report.text` renders once, in the library.** Both front ends print the same thing. This follows `HandoffChecklist.text(for:projectName:)`, which is already the repo's shape for this. **`unchecked` is display prose, not identity** — entries read like "entitlements posture (needs --shape)". It is a message, in the same category as `summary` and `remedy`, so it is not the stringly-typing the encapsulation rule forbids.