Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
19 changes: 13 additions & 6 deletions .claude/skills/fosmvvm-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -69,17 +70,19 @@ 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: <reason>` 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`),
- `"tier2": "halted"` in JSON; in Markdown, a `**Tier 2: halted**` line stating that doctor reported structural errors and area review runs after they are fixed.

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)"]
},
Expand All @@ -227,7 +233,8 @@ mistaken for a clean one.
"fail_on": "blocker",
"disabled_checks": ["..."],
"severity_overrides": { "<check>": "<severity>" },
"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 }, "...": {} },
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,28 @@ 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
`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
Expand Down
10 changes: 10 additions & 0 deletions Sources/FOSMVVM/FOSMVVM.docc/CreatingAProject.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion Sources/FOSMVVMBootstrap/Doctor/Doctor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.")
}
}
}

Expand Down
36 changes: 35 additions & 1 deletion Sources/FOSMVVMBootstrap/Doctor/Finding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
10 changes: 9 additions & 1 deletion Sources/FOSMVVMBootstrap/Doctor/ProjectRule+Shape.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
}
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 2 additions & 2 deletions Sources/FOSMVVMBootstrap/Templates/shared/memory/MEMORY.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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 <root>`) 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.
Loading
Loading