@mmerterden/multi-agent-pipeline 15.3.2 → 15.4.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/CHANGELOG.md CHANGED
@@ -16,6 +16,16 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [15.4.0] - 2026-08-18
20
+
21
+ ### Added
22
+ - **Per-project `testPolicy`** (`tdd` | `tests-after` | `none`): Phase 0 resolves it after project selection for every input type, asking once via a native picker when absent (autopilot defaults to `tdd` and notes it). Phase 3 gates the TDD cycle on it; Phase 5 skips the gap scan under `none` and runs only pre-existing targets. Prefs schema + template carry the field.
23
+ - **Ordered stack-toolkit routing candidates**: the ios/android rows resolve the corporate `-engineering-` variant before its public derivation, and a full probe miss records every candidate tried. The conventions census learns a `CrossDomains` root; screen-creation tasks bind the routed toolkit's `create-screen` workflow.
24
+ - **ios-module-structure 0.3.0 / ios-coding-standard 1.2.0** (authoring source): the settled architecture becomes the standard - UnitDialect slot, unit vocabulary roles, STRUCT-18..21 with checker support, UNIT-01..03 / SAFE-03 / MOD-08; the pre-conversion spellings are named residue.
25
+
26
+ ### Fixed
27
+ - The test-policy step resolves for Jira-ID and URL inputs, not only free-text; STRUCT-18 reports an unbound contracts root as disabled coverage instead of passing silently.
28
+
19
29
  ## [15.3.2] - 2026-08-17
20
30
 
21
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "15.3.2",
3
+ "version": "15.4.0",
4
4
  "description": "8-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -86,7 +86,7 @@ scan_roots() {
86
86
  local roots=()
87
87
  case "$PLATFORM" in
88
88
  ios)
89
- for d in Domains Common Core App Features Modules; do
89
+ for d in Domains Common Core CrossDomains App Features Modules; do
90
90
  [ -d "$REPO_PATH/$d" ] && roots+=("$REPO_PATH/$d")
91
91
  done
92
92
  ;;
@@ -20,12 +20,18 @@ Phase 3 pre-flight, before any code is written, for **every** `taskType`. Compon
20
20
 
21
21
  Platform comes from the same mapping component dispatch uses, so the two cannot disagree:
22
22
 
23
- | `state.platform` / detected stack | Toolkit |
23
+ | `state.platform` / detected stack | Toolkit candidates, first enabled wins |
24
24
  |---|---|
25
- | ios, swift | `ai-ios-toolkit` |
26
- | android, kotlin | `ai-android-toolkit` |
25
+ | ios, swift | `ai-ios-engineering-toolkit`, then `ai-ios-toolkit` |
26
+ | android, kotlin | `ai-android-engineering-toolkit`, then `ai-android-toolkit` |
27
27
  | anything else | no toolkit - step is a recorded no-op |
28
28
 
29
+ A row is an ORDERED candidate list, not one name: the corporate toolkit (where one exists)
30
+ carries the project-specific doctrine and outranks its public derivation, so it resolves first.
31
+ Probe the candidates in order against the enabled plugins and take the first hit. When none is
32
+ enabled, the recorded no-op names every candidate probed - a repo that enabled a corporate
33
+ toolkit the table did not know about must show up as a probe miss, never as silence.
34
+
29
35
  The toolkit is enabled per repo (`.claude/settings.local.json` / `~/.claude/settings.json` `enabledPlugins`). **Not enabled is not an error here**, unlike component dispatch: a backend or web repo legitimately has no toolkit, and halting would make the pipeline unusable outside mobile. Record the no-op and continue.
30
36
 
31
37
  Two marketplaces may ship the same toolkit name (a public one and a corporate one). Resolve whichever is enabled and record its **name and version** in the ledger entry, because the routing table and the skill set differ between versions - a finding that cites a skill has to be traceable to the version that defined it.
@@ -35,7 +35,7 @@ Read preferences: `PREFS_FILE="$HOME/.claude/multi-agent-preferences.json"` (if
35
35
  OUTPUT_LANG=$(jq -r '.global.outputLanguage // "en"' "$PREFS_FILE" 2>/dev/null || echo en)
36
36
  ```
37
37
 
38
- From this point on, everything the user reads renders in `$OUTPUT_LANG`: conversational lines, `AskUserQuestion` `question`/`description`, and external payload bodies (PR/Jira/Confluence). English stays only on `label`/`header`, commit messages, branch names, PR titles, identifiers. Full matrix: `rules.md` "Language Application". Skipping this step is why a Turkish session gets an English wall of text - or an English picker.
38
+ From this point on, everything the user reads renders in `$OUTPUT_LANG`: conversational lines, `AskUserQuestion` `question`/`description`, and external payload bodies (PR/Jira/Confluence). English stays only on `label`/`header`, commit messages, branch names, PR titles, identifiers. Full matrix: `rules.md` "Language Application".
39
39
 
40
40
  **Model fallback date gate** (same step, once per run): read `prefs.global.modelFallback`. If `premiumTierUntil` is set and in the past, apply the date-gate trigger from `$HOME/.claude/multi-agent-refs/features/model-fallback.md` - `preferredModel` personas dispatch on `fallbackModel` for this run, with the one-line WARN. Dispatch-error and budget triggers in that contract apply per-dispatch later; nothing else to do here.
41
41
 
@@ -238,6 +238,8 @@ Scan `$HOME` (maxdepth 2) for project markers (`.xcodeproj`, `Package.swift`, `b
238
238
  - After selection, if the chosen set matches an existing `recentGroups` entry, bump `count` + `lastUsed`. If it's a new combination of ≥2 repos, ask with a native `AskUserQuestion` picker (`question`: "Save this repo combo as a reusable multi-repo group?" in `outputLanguage`; `header`: "Save"; `options`: `{ label: "Save", ... }`, `{ label: "Skip", ... }`) - on **Save**, prepend to `recentGroups` (LRU cap 10).
239
239
  - Single-repo selection (1 repo) → legacy single-repo path; `state.projects[]` is omitted, scalars are populated as before.
240
240
 
241
+ **Test policy (once per project).** Resolve `prefs.projects[{slug}].testPolicy` → `state.testPolicy`; missing → native picker "Should development write tests here?" (`header`: "Tests"): `tdd` (recommended) / `tests-after` / `none`; persist. Autopilot without a record: `tdd`, noted.
242
+
241
243
  #### Step 3 - Remote Detection + Branch Selection
242
244
 
243
245
  1. **Check preferences first**: If `prefs.projects[{project}].remoteType` exists, use cached value.
@@ -38,7 +38,7 @@ Pre-flight steps (run in order, abort on failure).
38
38
 
39
39
  `targetFiles` is required - without it a skill applied to the wrong files still reads as "applied". Append at the moment of consultation, not at the end of the phase. Phase 4 Step 1.78 treats this as self-report only and resolves criteria independently; it is the one signal separating "applied to the wrong files" from "never opened".
40
40
 
41
- 9. **Stack skill routing (every `taskType`, when a stack toolkit plugin is enabled)**: ask the enabled `ai-<platform>-toolkit`'s own `index` skill which skills govern this task, load them BEFORE writing code, and record each into `state.telemetry.skillCalls[]` with `routedBy: "<toolkit>:index@<version>"`. The routing table stays in the plugin - a copy here would be the stale one. No toolkit, or none enabled, is a recorded no-op, not a halt. Contract: [`features/stack-skill-routing.md`]($HOME/.claude/multi-agent-refs/features/stack-skill-routing.md).
41
+ 9. **Stack skill routing (every `taskType`, when a stack toolkit plugin is enabled)**: ask the routed stack toolkit's own `index` skill which skills govern this task, load them BEFORE writing code, and record each into `state.telemetry.skillCalls[]` with `routedBy: "<toolkit>:index@<version>"`. The routing table stays in the plugin - a copy here would be the stale one. A screen-creation task loads the routed toolkit's `workflow/create-screen` when one exists. No toolkit, or none enabled, is a recorded no-op, not a halt. Contract: [`features/stack-skill-routing.md`]($HOME/.claude/multi-agent-refs/features/stack-skill-routing.md).
42
42
 
43
43
  The analysis document is the SOLE design source in Phase 3. Variant choices, padding values, color tokens, copy strings, accessibility identifiers, and test method names all come from the rendered Pass B cells. If something is missing in the analysis doc, the fix is to re-run `/multi-agent:analysis`, not to fetch from Figma.
44
44
 
@@ -112,7 +112,7 @@ For each task (respecting dependency order):
112
112
  - New types/tokens in the **common** submodule
113
113
  - The component itself in the **uicomponents** submodule
114
114
  - Work in BOTH submodules in the same task - this is normal and expected
115
- 3. **TDD cycle** (Launch Agent with `model: "sonnet"`):
115
+ 3. **TDD cycle** (Launch Agent with `model: "sonnet"`) - gated by `state.testPolicy`: `tdd` = the loop below; `tests-after` = skip RED, author the same tests once green; `none` = author no tests (existing ones never weakened), report "tests not written - project policy" rather than a gap.
116
116
 
117
117
  **RED - Write ONE failing test first:**
118
118
  - **Rework re-entry**: if `state.reviewIterations[-1].verifyByTest.redTests[]` exists (Phase 4 Step 3.7 ran), those failing repro tests ARE the RED step for their findings - make them green, do not write a duplicate failing test and do not delete or weaken them. See `$HOME/.claude/multi-agent-refs/features/verify-by-test.md`.
@@ -7,7 +7,7 @@ Progress emission per `$HOME/.claude/multi-agent-refs/progress-contract.md` -
7
7
 
8
8
  #### Step 0 - Test Gap Report (advisory)
9
9
 
10
- Before the local-checkout prompt, run the static test-gap detector. Heuristic, deterministic, no LLM, sub-second. The report ends up in `agent-log.md` under "Test Scenarios" and surfaces public symbols added in this branch that have no paired test.
10
+ `state.testPolicy: none` → skip the gap scan (the gap IS the recorded policy) and run only pre-existing test targets; none → recorded no-op. Otherwise, before the local-checkout prompt, run the static test-gap detector. Heuristic, deterministic, no LLM, sub-second. The report ends up in `agent-log.md` under "Test Scenarios" and surfaces public symbols added in this branch that have no paired test.
11
11
 
12
12
  ```bash
13
13
  STACK=$(jq -r '.analysis.stack.primary // "unknown"' "$STATE_FILE")
@@ -94,6 +94,7 @@
94
94
  }
95
95
  },
96
96
  "projects": {},
97
+ "_projectsComment": "Per-project overrides by slug. Notable: testPolicy (tdd | tests-after | none) - how the dev phase authors tests; asked once by Phase 0 when absent.",
97
98
  "_figmaConfigTemplate": {
98
99
  "_comment": "Copy this to ~/.claude/projects/{slug}/figma-config.json and fill in values. See pipeline/schemas/figma-project-config.schema.json for full schema.",
99
100
  "schemaVersion": "1.0.0",
@@ -1545,6 +1545,11 @@
1545
1545
  "type": "object",
1546
1546
  "additionalProperties": false,
1547
1547
  "properties": {
1548
+ "testPolicy": {
1549
+ "type": "string",
1550
+ "enum": ["tdd", "tests-after", "none"],
1551
+ "description": "How Phase 3 authors tests in this project: tdd (default; failing test first), tests-after (implementation first, tests authored at the end), none (no unit/UI test authoring; existing tests are kept and run). Asked once by Phase 0 when absent; autopilot defaults to tdd and notes it."
1552
+ },
1548
1553
  "defaultReviewers": {
1549
1554
  "type": "array",
1550
1555
  "items": {
@@ -36,6 +36,6 @@
36
36
  "warn_tokens": 5600
37
37
  }
38
38
  },
39
- "total_max_tokens": 53100,
39
+ "total_max_tokens": 53350,
40
40
  "note": "Token estimate = ceil(chars / 4). Per-phase budget rule: warn = current+10% (rounded to nearest 50), max = current+25%. Gives ~6 edit cycles of headroom before warn trips - intentionally quiet under normal maintenance, loud when a phase grows unusually. Only the active phase is loaded (lazy). Recalibrated at v10.0.0 after the validator/consistency/simplifier/lesson gate contracts landed in phases 1-4. Recalibrated again at v10.9.0 after the verify-by-test (Phase 4 Step 3.7), update-check (Phase 0 Step 0.6), immutable-test (Phase 3 GREEN) and redTests re-entry contracts landed - Step 3.7 prose was compressed to a pointer into refs/features/verify-by-test.md before the recalibration. Total bumped 50000 -> 51000 at v12.5.0 after the worktree residue/traversal-prune contract (Phase 0 + Phase 5 heal) and the Reflexion causal-diagnosis contract (Phase 4 lesson memory) landed; the prose was compressed first (161 tokens reclaimed) and every per-phase max still passes - only the aggregate needed room. Recalibrated again at v13.6.0 after the install-relative path correction: an instruction that names `pipeline/scripts/x` resolves only from a repo checkout, and a run happens in the user's worktree, so 157 references across these docs moved to `$HOME/.claude/...` at +5 bytes each - 196 tokens of pure correctness cost. Same discipline as before: prose was compressed FIRST (149 tokens reclaimed, by pointing Phase 1's Figma tier table at the Phase 0 probe that already resolved it and Phase 4's Codex constraints at the always-loaded AGENTS.md block), and only then were the budgets moved. Five warn lines had been permanently amber, which makes the amber tier useless as a signal, so every warn was reset to the documented current+10% and the four maxes that the new warn would have collided with were reset to current+25%. Aggregate 51000 -> 51500. Total bumped 51500 -> 52200 at v14.0.0 after Phase 4 Review entered the four --dev mode phase sets and the criteria-resolution contract (Step 1.78) landed. Same discipline as every prior bump: prose was compressed FIRST, 820 tokens reclaimed, before the number moved. Two of those compressions are structural rather than cosmetic - the hardcoded SwiftUI interaction list in Step 1.5 and the SwiftUI convention paragraph in Step 2.8 were transcriptions of rules that now live in a scoped registry, so keeping them here would have re-created the drift this release exists to remove, and the third moved the Step 1.78 full contract into refs/features/skill-conformance.md leaving a pointer. What remains is contract text that cannot be inferred: the manifest's four consumer-visible parts, the conformance checklist the reviewers must return, and the fail-closed semantics. Every per-phase max still passes (phase-4 12405/14750); only the aggregate needed room. Total bumped 52200 -> 52700 at v14.1.0 after two more contracts landed: stack skill routing (Phase 3 pre-flight step 9) and worktree finalize (Phase 6 step 9). Compression came first, as always, and twice: 224 tokens out of Phase 3 by pointing its criteria-ledger and routing steps at their feature files instead of restating them, and 190 out of Phase 6 by moving the finalize contract into refs/features/worktree-finalize.md and leaving the invocation plus the exit-3 semantics. Both new contracts follow the pattern the earlier ones set: the phase doc carries the call and the decision, the feature file carries the reasoning, and the feature files are outside this budget because it loops only the eight phase-N-* keys. Every per-phase max still passes (phase-3 7677/8950, phase-6 5223/6150 and both under warn); only the aggregate needed room. Total bumped 52700 -> 52750 for the Phase 0 Step 3 branch-persistence correction: the step wrote the legacy `projects[].branches` while the TTL filter two sections below read `global.recentBranches`, and both spots named a `{name, lastUsed}` shape the schema rejects (`branch` required, `additionalProperties: false`), so the recent-branch picker option could never populate and a literal implementation would have failed prefs validation. Naming the right target, the right key and the legacy field to avoid costs 41 tokens over the one line it replaces. Compression came first and was applied three times to the replacement text itself, from 120 tokens down to 66, by moving the rationale out of the phase doc entirely: the reasoning now lives where it is enforced, in the migrate-prefs carry-forward comment and the smoke-pref-migration f7 block, leaving the phase doc with only the instruction. 50 was the smallest step that clears it; phase-0-init sits at 10893/12400, far under its own max, so this is purely an aggregate ceiling. v15.0.0: total 52750 -> 53100, the stack-skill tables in phase-1/2/4 now carry plugin-namespaced names (ai-<stack>-toolkit:<skill>) - functional prefixes, ~170 tokens."
41
41
  }
@@ -1,4 +1,4 @@
1
- version: 1.1.0
1
+ version: 1.2.0
2
2
  updated: 2026-07-29
3
3
  owner: iOS platform
4
4
  description: >
@@ -454,9 +454,11 @@ rules:
454
454
  enforcement: lint
455
455
  rationale: readability
456
456
  check: >
457
- Scene, ViewModel, LocalizedText, CoordinatorEvent, AnalyticsTracking, UseCase, Repository
458
- (+protocol +mock), Mapper + models - each present when its responsibility exists. Report a
459
- missing file whose responsibility leaked elsewhere AND a ceremonial empty file.
457
+ Scene, ViewModel, AnalyticsTracking, UseCase, Repository (+protocol +mock), Mapper +
458
+ models - each present when its responsibility exists. In a converted module the screen's
459
+ Output enum lives contract-side, not here, and a module-local CoordinatorEvent or
460
+ LocalizedText aggregator is pre-conversion residue reported as debt. Report a missing file
461
+ whose responsibility leaked elsewhere AND a ceremonial empty file.
460
462
 
461
463
  - id: STRUCT-03
462
464
  title: Every screen sits at the same depth with the same internal grouping
@@ -509,11 +511,12 @@ rules:
509
511
  not a defect.
510
512
  check: >
511
513
  What this rule protects is that a reader finds a screen's exits under ONE name across the
512
- module, not that a particular name wins. Both shapes in use are legitimate: a typed
513
- `CoordinatorEvent` enum with a handler alias, or an `Output` enum with a closure. Each
514
- enumerates every navigation exit in one place, which is the property that matters.
515
- The finding is a module that uses both, or a screen whose exits are spread across an enum
516
- and loose ad-hoc callbacks. Report the spelling per screen and the count of each.
514
+ module. The settled shape is an `Output` enum consumed through a plain closure, with the
515
+ enum living in the flow-contracts target. A `CoordinatorEvent` enum with a handler alias
516
+ is the pre-conversion spelling: legitimate only in a module that has not converted yet, a
517
+ debt finding in one that has, and never a choice for new code. The hard finding is a module
518
+ that uses both, or a screen whose exits are spread across an enum and loose ad-hoc
519
+ callbacks. Report the spelling per screen and the count of each.
517
520
 
518
521
  - id: NAME-02
519
522
  title: Our models use RequestModel/ResponseModel; transport suffixes stop at the data layer
@@ -1815,3 +1818,70 @@ rules:
1815
1818
  accept_from_vocabulary: IndexLabel
1816
1819
  rationale: readability
1817
1820
  check: One spelling for "at this position" across the module; two make the call sites read as two concepts.
1821
+
1822
+ # ── UNIT - the settled screen dialect ────────────────────────────────────
1823
+ - id: UNIT-01
1824
+ title: A new screen's view model is the unit type, never the deprecated scene base
1825
+ severity: blocking
1826
+ enforcement: scan
1827
+ mechanism: >
1828
+ grep the diff's ADDED files for a subclass of the deprecated pre-unit scene base; grep the
1829
+ declaration of the unit generic (ViewModel<) for the four type parameters.
1830
+ rationale: flexibility
1831
+ check: >
1832
+ The unit dialect - ViewModel<State, ViewAction, Action, Output> with explicit conformer
1833
+ typealiases - is the only legal shape for new screen code. The pre-unit base class is
1834
+ deprecated at its declaration; existing screens convert on touch, and that migration is its
1835
+ own work. The finding is a NEW file building on the deprecated base, or a unit view model
1836
+ missing its typealias block (the all-compiler-version spelling).
1837
+
1838
+ - id: UNIT-02
1839
+ title: Unit state is written only inside the reducer
1840
+ severity: important
1841
+ enforcement: scan
1842
+ mechanism: 'custom regex: assignments to the unit state outside next(_:on:) in ViewModel files'
1843
+ rationale: readability
1844
+ check: >
1845
+ The unit's state moves in one place - the reducer - so a reader replays a screen's
1846
+ behaviour from a single function. A state write from an action handler, a task body or a
1847
+ view callback bypasses that replay and is the finding, wherever it compiles.
1848
+
1849
+ - id: UNIT-03
1850
+ title: Dependencies are observation-ignored, defaulted parameters
1851
+ severity: important
1852
+ enforcement: lint
1853
+ mechanism: 'custom regex: stored service/use-case/analytics properties in view models'
1854
+ rationale: flexibility
1855
+ check: >
1856
+ Every dependency is an @ObservationIgnored stored property injected as an init parameter
1857
+ whose default resolves from the container. Tests then inject doubles with no container
1858
+ setup, and observation never tracks a service handle. A dependency resolved inline at the
1859
+ call site, or a stored property observation can see, is the finding.
1860
+
1861
+ - id: SAFE-03
1862
+ title: A field reset that must erase the value writes the value before clear()
1863
+ severity: important
1864
+ enforcement: scan
1865
+ mechanism: 'custom regex: .clear() on a form field with no value write in the surrounding statement group'
1866
+ rationale: flexibility
1867
+ check: >
1868
+ The shared form field's clear() resets STATE only - the value survives it. Every reset
1869
+ path that must also erase what was typed (switching an entry method, finishing a secure
1870
+ flow, leaving a screen that held credentials) writes value = "" (a picker: deselect())
1871
+ first, then clear(), in that order - the value write may fire a validation policy and the
1872
+ trailing clear() leaves the field pristine. A bare clear() on such a path has shipped
1873
+ stale identifiers and surviving passwords before; treat it as the finding unless the reset
1874
+ demonstrably must keep the value.
1875
+
1876
+ - id: MOD-08
1877
+ title: Screens bind the shared formatters and rule facades; a hand-rolled one is a finding
1878
+ severity: important
1879
+ enforcement: scan
1880
+ mechanism: 'custom regex: DateFormatter(/NumberFormatter( construction and digit/dial-code string surgery inside Screens/**'
1881
+ rationale: flexibility
1882
+ check: >
1883
+ Dates, money, grouped amounts, dial-code spellings and validation bounds each have one
1884
+ shared home - the core formatter families and the module's form-rules facade. A screen
1885
+ that constructs its own formatter or re-derives a bound duplicates a wire contract that
1886
+ already has an owner, and the copies drift apart silently. The finding is the construction
1887
+ site; the fix is binding, not re-deriving.
@@ -73,3 +73,24 @@ surface has not chosen a different dialect, it is missing the surface, so that s
73
73
 
74
74
  The test when you are tempted to add one: can you write the second value's paragraph without it
75
75
  reading as an excuse? If not, it is a rule.
76
+
77
+ ## Multi-target packages
78
+
79
+ A domain that outgrows one target splits into one target per flow inside a single package, and
80
+ the tree rules then apply PER TARGET. What the shape adds, and what the overlay binds for it:
81
+
82
+ - `screen.root` binds with a leading `Sources/*/` so every target's `Screens/` is walked; a
83
+ target with no screens simply matches nothing.
84
+ - Each screen-bearing target carries its own dependency configurator - registration, validation
85
+ pins, and the store entry the composition root aggregates - bound as `target.configurator`
86
+ and proven by STRUCT-21. Registration lives with the target, never pooled in a sibling.
87
+ - The flow-contracts side mirrors the split: one contracts sub-folder per target, named
88
+ `<Module><Target>FlowContracts`-style, under the module's contracts target
89
+ (`contracts.root` + STRUCT-18 prove each screen's pair).
90
+ - A scaffold target - empty configurator, a route enum with an empty destination switch, no
91
+ screens yet - is a legitimate recorded state, not a finding: it reserves the flow's seams so
92
+ the first screen lands as a fill-in, not a redesign.
93
+ - Tests mirror per target (`Tests/<Target>Tests/`), so the source/test mirror is a set of
94
+ prefix pairs; a single-swap `source.root`/`test.root` binding cannot express it and stays
95
+ unbound until the checker learns per-target templates.
96
+
@@ -79,6 +79,13 @@ dialect:
79
79
  # Leave a role empty when the module genuinely has no such thing - the rules that read it are then
80
80
  # reported as lost coverage, which is the honest answer.
81
81
  roles:
82
+ # Unit-dialect vocabulary (bind when UnitDialect is adopted):
83
+ # screen.viewstate: "Presentation/Models/*ViewState.swift"
84
+ # screen.viewaction: "Presentation/Models/*ViewAction.swift"
85
+ # screen.viewmodelaction: "Presentation/Models/*ViewModelAction.swift"
86
+ # screen.viewmodelstate: "Presentation/Models/*ViewModelState.swift"
87
+ # Multi-target packages (STRUCT-21):
88
+ # target.configurator: "Sources/*/Configuration/*DependencyConfigurator.swift"
82
89
  screen.root: # e.g. "Sources/*/Screens/*"
83
90
  screen.entry:
84
91
  screen.viewmodel:
@@ -110,6 +117,20 @@ vocabulary:
110
117
  BareVerbs: [] # verbs a presentation type may not use alone
111
118
  ForbiddenTypeSuffixes: [] # last words that describe no role
112
119
 
120
+ # UnitDialect: adopted | pre-unit
121
+ # Count view models subclassing the unit generic vs the deprecated pre-unit base.
122
+ # pre-unit is a RECORDED migration state, reported as debt - a new module binds adopted.
123
+
124
+ # --- Contracts ---------------------------------------------------------
125
+ # Only for a CONVERTED module whose screens are reached through a flow-contracts target.
126
+ # root is relative to the module root; leave the whole block out for a pre-conversion module
127
+ # and STRUCT-18 reports lost coverage instead of guessing. Bind the two contract roles under
128
+ # roles: with {screen}-substituted patterns, e.g.
129
+ # contracts.output: "{screen}Output.swift"
130
+ # contracts.providing: "{screen}ScreenProviding.swift"
131
+ contracts: {}
132
+ # root: ../../CrossDomains/Contracts/Sources/<Module>FlowContracts
133
+
113
134
  # --- Limits ------------------------------------------------------------
114
135
  limits:
115
136
  source_target:
@@ -1,5 +1,5 @@
1
- version: 0.1.0
2
- updated: 2026-08-13
1
+ version: 0.3.0
2
+ updated: 2026-08-18
3
3
  owner: iOS platform
4
4
  description: >
5
5
  The ios-module-structure rule registry. Where a declaration lives, what its file is called, and what
@@ -61,10 +61,30 @@ roles:
61
61
  description: the type holding the screen's behaviour and service calls.
62
62
  screen.state:
63
63
  description: the type holding the screen's UI state, when the module separates it from the view model.
64
+ screen.viewstate:
65
+ description: the unit dialect's view-facing state value, one declaration per file.
66
+ screen.viewaction:
67
+ description: the unit dialect's view-action vocabulary, one declaration per file.
68
+ screen.viewmodelaction:
69
+ description: the unit dialect's own-action vocabulary, one declaration per file.
70
+ screen.viewmodelstate:
71
+ description: the unit dialect's reducer-owned state, one declaration per file.
72
+ target.configurator:
73
+ description: >
74
+ a screen-bearing target's dependency configurator - the registration + validation home a
75
+ multi-target package carries once per target.
64
76
  screen.analytics:
65
77
  description: the screen's analytics surface.
66
78
  screen.factory:
67
79
  description: the seam another module calls to build the screen.
80
+ contracts.output:
81
+ description: >
82
+ the screen's Output enum in the module's flow-contracts target - every navigation exit,
83
+ nothing else. Lives OUTSIDE the module tree; the overlay's contracts.root names where.
84
+ contracts.providing:
85
+ description: >
86
+ the screen's vending seam (ScreenProviding protocol) in the flow-contracts target,
87
+ beside its Output. Lives OUTSIDE the module tree; the overlay's contracts.root names where.
68
88
  screen.mapper:
69
89
  description: the wire-to-domain translation for the screen.
70
90
  service.dir:
@@ -128,7 +148,8 @@ module_overlay_slots:
128
148
  governs: [STRUCT-05]
129
149
  values:
130
150
  per-screen-factory: each screen ships its own construction seam.
131
- shared-factory: one factory per module builds every screen.
151
+ shared-factory: one factory per module builds every screen - pre-conversion residue;
152
+ a converted module binds per-screen-factory, and a NEW module may not choose this.
132
153
  note: >
133
154
  Both put construction behind a seam, which is the property STRUCT-05 protects. Counting
134
155
  factories against screens tells you which one the module chose.
@@ -136,7 +157,8 @@ module_overlay_slots:
136
157
  governs: [STRUCT-06]
137
158
  values:
138
159
  separate-state-type: UI state lives in its own type beside the view model.
139
- view-model-owned: the view model holds UI state directly.
160
+ view-model-owned: the view model holds UI state directly - pre-conversion residue for
161
+ form screens; the settled shape is the separate state type.
140
162
  note: >
141
163
  Unbound, STRUCT-06 is disabled - "the view model holds a form field" is only a finding in a
142
164
  module that decided it should not.
@@ -177,6 +199,16 @@ module_overlay_slots:
177
199
  copy-layer: a per-screen type owns every string the screen shows.
178
200
  note: Both keep copy findable; mixing them is what costs.
179
201
 
202
+ - id: UnitDialect
203
+ governs: [STRUCT-19, STRUCT-20]
204
+ question: which view-model generation do this module's screens ride?
205
+ values:
206
+ adopted: screens ride the unit view model (the State/ViewAction/Action/Output generic)
207
+ with the one-declaration-per-file vocabulary under the presentation models folder.
208
+ pre-unit: screens still ride the deprecated pre-unit scene base. Legitimate only as a
209
+ recorded migration state - the run reports it as debt, and a NEW module may not
210
+ choose it.
211
+
180
212
  # ---------------------------------------------------------------------------
181
213
  # Rules
182
214
  # ---------------------------------------------------------------------------
@@ -349,8 +381,11 @@ rules:
349
381
  check: >
350
382
  The entry file holds its properties, its init, one body and its previews. A second view
351
383
  member on it is a fragment that either belongs in the body's composition or belongs beside
352
- the screen as an extracted view. The exception is a fragment the type system pins to the call
353
- site - an alert's content, a modifier-constrained builder - which cannot be moved.
384
+ the screen as an extracted view. Moving those members into a view-composition extension
385
+ file (a "+ViewBuilder" / "+Subviews" companion) is the same finding relocated, not a fix -
386
+ that pattern is retired; the extracted subview pair is its replacement. The exception is a
387
+ fragment the type system pins to the call site - an alert's content, a modifier-constrained
388
+ builder - which cannot be moved.
354
389
 
355
390
  - id: STRUCT-11
356
391
  title: A cross-screen folder holds no type named after one screen
@@ -557,3 +592,82 @@ rules:
557
592
  A scripted implementation that also carries its own payload literals mixes the decision of
558
593
  which scenario to answer with the content of the answer. The tool can surface the size ratio;
559
594
  the split itself is a judgement.
595
+
596
+ - id: STRUCT-18
597
+ title: A converted screen ships its contract pair in the flow-contracts target
598
+ severity: important
599
+ enforcement: scan
600
+ predicate: contract_pair_required
601
+ params:
602
+ output_role: contracts.output
603
+ providing_role: contracts.providing
604
+ applies_when: >
605
+ the overlay binds contracts.root (the path of the module's flow-contracts target, relative
606
+ to the module root) AND both contracts.output and contracts.providing. Unbound, DISABLED -
607
+ a pre-conversion module has no contract side, and the run reports the lost coverage instead
608
+ of guessing one.
609
+ rationale: module boundaries
610
+ check: >
611
+ A converted flow's screen is reachable only through its contract seam: an Output enum naming
612
+ every navigation exit and a providing protocol vending the built screen. Both live in the
613
+ flow-contracts target, not in the module, so the module tree alone cannot prove a screen is
614
+ actually wired. Each screen directory must have a matching directory under contracts.root
615
+ holding both files; a screen without one is invisible to every consumer of the seam.
616
+
617
+ - id: STRUCT-19
618
+ title: A unit screen carries the four-file vocabulary, one declaration per file
619
+ severity: important
620
+ enforcement: lint
621
+ predicate: file_required_in_dir
622
+ params:
623
+ slot: UnitDialect
624
+ slot_value: adopted
625
+ role: screen.viewmodelstate
626
+ companion_roles: [screen.viewstate, screen.viewaction, screen.viewmodelaction]
627
+ applies_when: >
628
+ the module binds UnitDialect to adopted AND the four unit vocabulary roles. Unbound or
629
+ pre-unit, DISABLED - and the pre-unit binding itself is reported as recorded debt.
630
+ rationale: readability
631
+ check: >
632
+ The unit dialect spells a screen's contract as four value files beside the presentation
633
+ models - view state, view action, own action, reducer state - each a single declaration in
634
+ a file of its own name. A vocabulary member folded into the view model file or sharing a
635
+ file with a sibling hides the screen's surface from the reader who navigates by name.
636
+
637
+ - id: STRUCT-20
638
+ title: An adopted module's view models ride the unit generic, never the deprecated base
639
+ severity: blocking
640
+ enforcement: scan
641
+ predicate: forbidden_pattern
642
+ params:
643
+ slot: UnitDialect
644
+ slot_value: adopted
645
+ subject_role: screen.viewmodel
646
+ pattern: ':\s*BaseViewModel\b'
647
+ applies_when: >
648
+ the module binds UnitDialect to adopted. In a pre-unit module this rule is DISABLED - the
649
+ migration converts screens on touch and drowning it in findings would hide the ones that
650
+ matter - but the binding is reported as debt every run.
651
+ rationale: flexibility
652
+ check: >
653
+ The pre-unit scene base is deprecated at its declaration. In a module that declared itself
654
+ adopted, any view model still subclassing it is a regression, not a leftover - the overlay
655
+ said this module is done.
656
+
657
+ - id: STRUCT-21
658
+ title: Every screen-bearing target carries its dependency configurator
659
+ severity: important
660
+ enforcement: lint
661
+ predicate: target_configurator_required
662
+ params:
663
+ role: target.configurator
664
+ applies_when: >
665
+ the overlay binds target.configurator. Unbound, DISABLED and reported - a single-target
666
+ module may register at its screens instead, and that is a dialect, not a defect.
667
+ rationale: module boundaries
668
+ check: >
669
+ In a multi-target package each target that ships screens owns one registration home: the
670
+ configurator that registers its repositories, use cases and screen seams, validates them,
671
+ and exposes the store entry the composition root aggregates. A screen-bearing target
672
+ without one leaves its seams registered from another target - reachable, but owned by
673
+ nobody the reader can find.
@@ -68,6 +68,7 @@ class Bindings:
68
68
 
69
69
  def __init__(self, overlay, registry):
70
70
  self.roles = dict(overlay.get("roles") or {})
71
+ self.contracts = dict(overlay.get("contracts") or {})
71
72
  self.dialect = {k: v for k, v in (overlay.get("dialect") or {}).items()
72
73
  if v and not k.endswith("_evidence")}
73
74
  self.vocabulary = dict(overlay.get("vocabulary") or {})
@@ -223,10 +224,57 @@ def p_file_required_in_dir(screen, root, rule, params, b):
223
224
  if not any(trigger_re.search(read(p)) for p in iter_glob(screen, trigger_pattern)):
224
225
  return []
225
226
 
226
- wanted = expand(pattern, screen=screen.name)
227
- if iter_glob(screen, wanted):
227
+ findings = []
228
+ for role_name in [params["role"]] + list(params.get("companion_roles") or []):
229
+ role_pattern = b.role(role_name)
230
+ if not role_pattern:
231
+ return []
232
+ wanted = expand(role_pattern, screen=screen.name)
233
+ if not iter_glob(screen, wanted):
234
+ findings.append({"path": str(screen), "line": 0, "detail": wanted})
235
+ return findings
236
+
237
+
238
+ def module_target_configurator(root, rule, params, b, screens):
239
+ pattern = b.role(params["role"])
240
+ if not pattern:
241
+ return []
242
+ screen_units = {}
243
+ for screen in screens:
244
+ rel = screen.relative_to(root).parts
245
+ unit = rel[1] if rel[0] == "Sources" and len(rel) > 2 else rel[0]
246
+ screen_units.setdefault(unit, screen)
247
+ hits = iter_glob(root, pattern)
248
+ covered = set()
249
+ for hit in hits:
250
+ rel = hit.relative_to(root).parts
251
+ covered.add(rel[1] if rel[0] == "Sources" and len(rel) > 2 else rel[0])
252
+ return [{"path": str(screen_units[u]), "line": 0,
253
+ "detail": f"no configurator matched {pattern} for target {u}"}
254
+ for u in sorted(screen_units) if u not in covered]
255
+
256
+
257
+ def p_contract_pair_required(screen, root, rule, params, b):
258
+ croot = b.contracts.get("root")
259
+ if not croot:
228
260
  return []
229
- return [{"path": str(screen), "line": 0, "detail": wanted}]
261
+ base = (root / croot).resolve()
262
+ if not base.is_dir():
263
+ return [{"path": str(screen), "line": 0,
264
+ "detail": f"contracts.root does not resolve: {croot}"}]
265
+ matches = [d for d in base.rglob(screen.name) if d.is_dir()]
266
+ if not matches:
267
+ return [{"path": str(screen), "line": 0,
268
+ "detail": f"no contract directory named {screen.name} under {croot}"}]
269
+ findings = []
270
+ for role_key in (params["output_role"], params["providing_role"]):
271
+ pattern = b.role(role_key)
272
+ if not pattern:
273
+ return []
274
+ wanted = expand(pattern, screen=screen.name)
275
+ if not any(iter_glob(d, wanted) for d in matches):
276
+ findings.append({"path": str(screen), "line": 0, "detail": wanted})
277
+ return findings
230
278
 
231
279
 
232
280
  def p_prefix_collision(screen, root, rule, params, b):
@@ -524,6 +572,8 @@ PREDICATES = {
524
572
  "prefix_collision": p_prefix_collision,
525
573
  "dir_required_in_dir": p_dir_required_in_dir,
526
574
  "file_required_in_dir": p_file_required_in_dir,
575
+ "contract_pair_required": p_contract_pair_required,
576
+ "target_configurator_required": p_none, # module-level; dispatched before the per-screen loop
527
577
  "pair_required_in_dir": p_pair_required_in_dir,
528
578
  "sibling_required": p_sibling_required,
529
579
  "forbidden_pattern": p_forbidden_pattern,
@@ -553,10 +603,13 @@ def rule_status(rule, bindings):
553
603
  wanted = params.get("slot_value")
554
604
  if wanted and bound != wanted:
555
605
  return False, f"slot {slot} is {bound}"
556
- for key in ("role", "subject_role", "sibling_role", "container_role", "left_role", "right_role"):
606
+ for key in ("role", "subject_role", "sibling_role", "container_role", "left_role", "right_role",
607
+ "output_role", "providing_role"):
557
608
  name = params.get(key)
558
609
  if name and not bindings.role(name):
559
610
  return False, f"role {name} unbound"
611
+ if rule.get("predicate") == "contract_pair_required" and not bindings.contracts.get("root"):
612
+ return False, "contracts.root unbound"
560
613
  if params.get("vocabulary_key") and not bindings.vocabulary.get(params["vocabulary_key"]):
561
614
  return False, f"vocabulary {params['vocabulary_key']} unbound"
562
615
  if params.get("accept_from_vocabulary") and not bindings.vocabulary.get(params["accept_from_vocabulary"]):
@@ -608,6 +661,15 @@ def run(root, registry, bindings, only_rule=None, only_screen=None):
608
661
  })
609
662
  continue
610
663
 
664
+ if rule.get("predicate") == "target_configurator_required":
665
+ for hit in module_target_configurator(root, rule, params, bindings, screens):
666
+ findings.append({
667
+ "rule_id": rule["id"], "severity": rule["severity"], "screen": "(targets)",
668
+ "path": str(Path(hit["path"]).relative_to(root)), "line": 0,
669
+ "message": rule["title"], "detail": hit["detail"],
670
+ })
671
+ continue
672
+
611
673
  if rule.get("predicate") == "prefix_collision":
612
674
  for hit in module_prefix_collision(root, rule, params, bindings, screens):
613
675
  findings.append({