@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 +10 -0
- package/package.json +1 -1
- package/pipeline/lib/extract-conventions.sh +1 -1
- package/pipeline/multi-agent-refs/features/stack-skill-routing.md +9 -3
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +3 -1
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
- package/pipeline/preferences-template.json +1 -0
- package/pipeline/schemas/prefs.schema.json +5 -0
- package/pipeline/schemas/token-budget.json +1 -1
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +79 -9
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +21 -0
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +21 -0
- package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +120 -6
- package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +66 -4
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
|
+
"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",
|
|
@@ -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".
|
|
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
|
|
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
|
-
|
|
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":
|
|
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
|
+
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,
|
|
458
|
-
|
|
459
|
-
|
|
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
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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.
|
|
2
|
-
updated: 2026-08-
|
|
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.
|
|
353
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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({
|