From 7122146b7be651a32235a1199575c6f633ecb794 Mon Sep 17 00:00:00 2001 From: idevlab Date: Mon, 31 Aug 2026 02:59:34 +0800 Subject: [PATCH] docs: replace Python SDLC validator with strict shell gate - scripts/sdlc-checks.sh enforces per-stage approval order and approval fields (Status/Approved-by/Approved-date); legacy bundles without Status headers are grandfathered - templates gain strict status headers; docs and CI (SDLC Gate) rewired - removes scripts/sdlc.py, scripts/sdlc_policy.py, scripts/tests/test_sdlc.py, and all per-bundle state.json (change 2026-08-31-shell-sdlc-gate) --- .github/workflows/pr.yml | 21 +- AGENTS.md | 4 +- README.md | 2 +- README_zh.md | 2 +- docs/sdlc/README.md | 33 ++- .../2026-08-25-ai-native-sdlc/state.json | 31 -- .../state.json | 28 -- .../state.json | 25 -- .../2026-08-30-history-mode-switch/state.json | 27 -- .../state.json | 33 --- .../state.json | 35 --- .../state.json | 40 --- .../2026-08-31-shell-sdlc-gate/intent.md | 25 ++ .../2026-08-31-shell-sdlc-gate/plan.md | 23 ++ .../2026-08-31-shell-sdlc-gate/spec.md | 30 ++ .../verification.md | 29 ++ docs/sdlc/templates/incident.md | 3 + docs/sdlc/templates/intent.md | 5 + docs/sdlc/templates/plan.md | 5 + docs/sdlc/templates/spec.md | 5 + docs/sdlc/templates/verification.md | 5 + scripts/ci-basic-checks.sh | 3 +- scripts/sdlc-checks.sh | 89 ++++++ scripts/sdlc.py | 275 ------------------ scripts/sdlc_policy.py | 63 ---- scripts/tests/test_sdlc.py | 262 ----------------- 26 files changed, 243 insertions(+), 860 deletions(-) delete mode 100644 docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json delete mode 100644 docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json delete mode 100644 docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json delete mode 100644 docs/sdlc/changes/2026-08-30-history-mode-switch/state.json delete mode 100644 docs/sdlc/changes/2026-08-30-model-type-selection/state.json delete mode 100644 docs/sdlc/changes/2026-08-30-settings-window-layout/state.json delete mode 100644 docs/sdlc/changes/2026-08-30-system-settings-groups/state.json create mode 100644 docs/sdlc/changes/2026-08-31-shell-sdlc-gate/intent.md create mode 100644 docs/sdlc/changes/2026-08-31-shell-sdlc-gate/plan.md create mode 100644 docs/sdlc/changes/2026-08-31-shell-sdlc-gate/spec.md create mode 100644 docs/sdlc/changes/2026-08-31-shell-sdlc-gate/verification.md create mode 100755 scripts/sdlc-checks.sh delete mode 100755 scripts/sdlc.py delete mode 100644 scripts/sdlc_policy.py delete mode 100755 scripts/tests/test_sdlc.py diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fb338ce..047e957 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,25 +24,8 @@ jobs: with: fetch-depth: 0 - - name: Validate changed SDLC artifacts - env: - EVENT_NAME: ${{ github.event_name }} - BASE_REF: ${{ github.base_ref }} - BEFORE_SHA: ${{ github.event.before }} - run: | - if [ "$EVENT_NAME" = "pull_request" ]; then - python3 scripts/sdlc.py validate --base "origin/$BASE_REF" - elif [ "$EVENT_NAME" = "push" ]; then - if [ -z "$BEFORE_SHA" ] || [[ "$BEFORE_SHA" =~ ^0+$ ]]; then - echo "Push event has no usable previous SHA; refusing schema-only validation" - exit 1 - fi - git fetch --no-tags origin "$BEFORE_SHA" - git cat-file -e "$BEFORE_SHA^{commit}" - python3 scripts/sdlc.py validate --push-base "$BEFORE_SHA" - else - python3 scripts/sdlc.py validate - fi + - name: Validate SDLC artifact gates + run: bash ./scripts/sdlc-checks.sh - name: Run linked repository checks run: bash ./scripts/ci-basic-checks.sh diff --git a/AGENTS.md b/AGENTS.md index e6228ff..193be0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,9 +49,9 @@ Utter is a macOS menu bar voice input app built with Swift 6 / SwiftUI / AppKit. - Read `docs/sdlc/README.md` before non-trivial implementation, automation, test, UI, dependency, permission, or release changes. - Create or update `docs/sdlc/changes//`. Low risk requires intent, plan, and verification; medium/high risk also requires a spec. -- Keep `state.json` honest. An agent may advance work to `verified` with current evidence, but may not record its own work as human approval. +- **Every stage requires explicit human approval before the next one starts.** Set the artifact's `Status: pending approval` and stop for the user's decision; never mark a stage `approved` on the user's behalf. `approved` requires `Approved-by` and `Approved-date`. Artifacts without a Status header are legacy merged bundles. - Begin implementation only after intent has observable acceptance criteria and medium/high-risk design choices have been reviewed. -- Always run `python3 scripts/sdlc.py validate --worktree`, `bash scripts/ci-basic-checks.sh`, and `swift test`. Add release-style build, real-window visual QA, permission/privacy paths, or clean-machine checks in proportion to risk. +- Always run `bash scripts/sdlc-checks.sh`, `bash scripts/ci-basic-checks.sh`, and `swift test`. Add release-style build, real-window visual QA, permission/privacy paths, or clean-machine checks in proportion to risk. - High-risk changes require an independent verifier, explicit rollback, PR approval, and protected production approval. - A production incident must link a corrective intent and add a regression test, deterministic guardrail, eval case, or explicit reason automation is impossible. - Never fall back to ad-hoc signing when configured signing fails. A self-signed release must use the configured identity, pass the same artifact/checksum checks, and be labeled as not Apple-notarized. diff --git a/README.md b/README.md index 5d5f073..f0b7537 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Material changes follow the artifact-driven workflow in [`docs/sdlc/README.md`](docs/sdlc/README.md). Before opening a pull request, run: ```bash -python3 scripts/sdlc.py validate --worktree +bash scripts/sdlc-checks.sh bash scripts/ci-basic-checks.sh swift test ``` diff --git a/README_zh.md b/README_zh.md index 1d6f47a..838af70 100644 --- a/README_zh.md +++ b/README_zh.md @@ -96,7 +96,7 @@ open Package.swift 提交 Pull Request 前请运行: ```bash -python3 scripts/sdlc.py validate --worktree +bash scripts/sdlc-checks.sh bash scripts/ci-basic-checks.sh swift test ``` diff --git a/docs/sdlc/README.md b/docs/sdlc/README.md index 9f02471..118a35a 100644 --- a/docs/sdlc/README.md +++ b/docs/sdlc/README.md @@ -34,27 +34,28 @@ risk, and a one-line permission or release change can be high risk. Non-trivial work lives at `docs/sdlc/changes//`: ```text -state.json Machine-readable identity, risk, status, governed paths, and artifact paths intent.md Problem, outcome, scope, constraints, and acceptance criteria spec.md Design and failure analysis; required for medium/high risk plan.md Executable work items and verification plan verification.md Commands, results, visual/runtime evidence, and residual risk ``` -Copy starting points from `docs/sdlc/templates/`. Status transitions are: -`intent -> designed -> planned -> implementing -> verified -> released -> closed`. -Only change the status when the corresponding artifact exists and its evidence -is current. An agent may record evidence, but may not represent its own output as -human approval. +Copy starting points from `docs/sdlc/templates/`. Stage state lives in each +artifact's header fields — the single source of status for the bundle: -`python3 scripts/sdlc.py validate --worktree` validates local work. Pull-request -CI compares the branch to its base and requires a changed verified bundle when -governed paths change. Trivial documentation-only changes stay on the fast path. +- `Status`: one of `draft | pending approval | approved | rejected | blocked`; + `approved` must be paired with `Approved-by` and `Approved-date`. +- **Strict per-stage approval.** A stage may only become `approved` after the + previous stage is `approved` (intent -> spec -> plan -> verification -> + release). Set `Status: pending approval` and stop for the human decision; + an agent may record evidence, but may never represent its own output as + human approval. Rejections and blocks stay in the artifact with a reason. +- Bundles merged before this gate existed carry no `Status` header and are + treated as historical archives by the checks. -The validator also enforces a minimum risk for deterministic control surfaces: -entitlements, workflows, signing/build/release verification, the SDLC validator, -and its CI guard are high risk; dependencies, agent context, issue/review policy, -and other automation are at least medium risk. +`bash scripts/sdlc-checks.sh` validates the gate locally. Pull-request CI runs +the same script in the `SDLC Gate` job. Trivial documentation-only changes +stay on the fast path. ## Definition of ready @@ -83,9 +84,9 @@ A change is ready for PR approval when: Repository instructions and this document are guidance. Enforcement lives in: -- `scripts/sdlc.py`: artifact schema and governed-change check; -- `scripts/ci-basic-checks.sh`: linked resources, localization, identifiers, and - repository invariants; +- `scripts/sdlc-checks.sh`: stage order and approval-field gate; +- `scripts/ci-basic-checks.sh`: SDLC gate, linked resources, localization, + identifiers, and repository invariants; - `.github/workflows/pr.yml`: artifact validation, unit tests, and release-style app build, summarized by the stable `SDLC Gate` job; - `.github/workflows/release.yml`: main-ancestry, tests, explicit signing-mode diff --git a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json b/docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json deleted file mode 100644 index cb07ffc..0000000 --- a/docs/sdlc/changes/2026-08-25-ai-native-sdlc/state.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-25-ai-native-sdlc", - "title": "Adopt an artifact-driven SDLC with verified signed releases", - "risk": "high", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "Non-trivial changes carry machine-validated intent, design, plan, and verification artifacts scaled by risk.", - "Pull requests run artifact validation, unit tests, and a release-style app build behind one stable required-check name.", - "Releases cannot proceed from a non-main commit or without the configured signing identity, artifact verification, and a checksum; Developer ID releases additionally require Apple notarization while the existing self-signed path remains supported and clearly labeled.", - "Production incidents feed a regression control and a new corrective intent back into the lifecycle.", - "The repository documents which human approval controls still require GitHub configuration." - ], - "governedPaths": [ - ".github/", - ".gitignore", - "AGENTS.md", - "CLAUDE.md", - "docs/sdlc/", - "scripts/" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json b/docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json deleted file mode 100644 index 39d3556..0000000 --- a/docs/sdlc/changes/2026-08-26-dmg-installer-polish/state.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-26-dmg-installer-polish", - "title": "Polish and harden the Utter DMG installer", - "risk": "high", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "Opening the DMG presents a compact branded Finder window with readable labels and an unambiguous drag-to-Applications flow.", - "The DMG layout is generated without depending on interactive Finder state and uses hash-pinned packaging tools.", - "The mounted app preserves its strict code-signature validity and the release artifact verifier accepts the generated DMG.", - "Generated build products, downloaded tools, and visual QA screenshots remain outside the committed change." - ], - "governedPaths": [ - "scripts/build-app.sh", - "scripts/dmg-requirements.txt", - "scripts/dmg-settings.py", - "scripts/generate-dmg-background.swift" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json deleted file mode 100644 index da6f90b..0000000 --- a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-30-dev-app-icon-packaging", - "title": "Restore the Dock icon in development app bundles", - "risk": "medium", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "The development Utter.app declares AppIcon through the canonical Info.plist.", - "The development bundle contains a root Contents/Resources/AppIcon.icns file for Dock and LaunchServices.", - "The development packaging command fails before launch when either icon invariant is missing.", - "Existing build, signing, helper, resource-bundle, and launch behavior remains intact." - ], - "governedPaths": [ - "scripts/build-and-run.sh" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-30-history-mode-switch/state.json b/docs/sdlc/changes/2026-08-30-history-mode-switch/state.json deleted file mode 100644 index a3a7297..0000000 --- a/docs/sdlc/changes/2026-08-30-history-mode-switch/state.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-30-history-mode-switch", - "title": "Integrate the history mode switch into page controls", - "risk": "medium", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "The Insights and History switch no longer occupies a separate full-width strip below the primary settings tabs.", - "The compact native switch remains visible and keyboard accessible in both history modes.", - "The switch aligns with the existing 28-point content grid and adjacent contextual controls without clipping in Chinese or English.", - "The real fixed-size window is inspected in light and dark appearances for both history modes." - ], - "governedPaths": [ - "Sources/UI/HistoryStatsView.swift", - "Sources/UI/HistoryInsightsOverview.swift", - "Sources/UI/HistoryRecordsView.swift" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-30-model-type-selection/state.json b/docs/sdlc/changes/2026-08-30-model-type-selection/state.json deleted file mode 100644 index 7d04291..0000000 --- a/docs/sdlc/changes/2026-08-30-model-type-selection/state.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-30-model-type-selection", - "title": "Make custom models an explicit recommended-first type", - "risk": "high", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "Text formatting types appear as Qwen recommended, Gemma, Llama, Remote, and Custom in that order.", - "Custom model entries, model ID input, and local import appear only when Custom is selected.", - "An active custom or imported local model selects Custom instead of falling back to Qwen.", - "Speech engine types put Qwen recommended first, followed by Whisper, Apple, and Doubao.", - "The real fixed-size window is inspected in Chinese and English, in light and dark appearances, without clipped type labels." - ], - "governedPaths": [ - "Sources/Config/AppSettings.swift", - "Sources/UI/ModelManagementEnginePicker.swift", - "Sources/UI/ModelManagementFamilies.swift", - "Sources/UI/ModelManagementRows.swift", - "Sources/UI/ModelManagementSections.swift", - "Sources/Resources/en.lproj/Localizable.strings", - "Sources/Resources/zh-Hans.lproj/Localizable.strings", - "Tests/OpenTypeTests/ConfigurationTests.swift" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-30-settings-window-layout/state.json b/docs/sdlc/changes/2026-08-30-settings-window-layout/state.json deleted file mode 100644 index 2c61e85..0000000 --- a/docs/sdlc/changes/2026-08-30-settings-window-layout/state.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-30-settings-window-layout", - "title": "Stabilize the settings window and shared layout", - "risk": "medium", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "The settings content area is exactly 760 by 680 points and the window style does not permit resizing.", - "Settings pages use native grouped-form margins, while Activity data cards and history records share a 28-point horizontal content edge.", - "No settings page loads or presents a decorative page-header illustration.", - "The real settings window is inspected in Chinese and English, in both light and dark appearance, with all tabs free of clipping or alignment regressions." - ], - "governedPaths": [ - "Sources/App/OpenTypeApp.swift", - "Sources/UI/SettingsView.swift", - "Sources/UI/SettingsVoiceIllustration.swift", - "Sources/UI/GeneralSettingsView.swift", - "Sources/UI/IntegrationsSettingsView.swift", - "Sources/UI/HistoryInsightsOverview.swift", - "Sources/UI/HistoryRecordsView.swift", - "Sources/UI/ModelManagementView.swift", - "Sources/UI/DictionaryStyleView.swift", - "Sources/UI/AboutView.swift", - "Tests/OpenTypeTests/ConfigurationTests.swift" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-30-system-settings-groups/state.json b/docs/sdlc/changes/2026-08-30-system-settings-groups/state.json deleted file mode 100644 index b6f09b1..0000000 --- a/docs/sdlc/changes/2026-08-30-system-settings-groups/state.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "schemaVersion": 1, - "id": "2026-08-30-system-settings-groups", - "title": "Use native System Settings form structure", - "risk": "medium", - "status": "verified", - "owners": [ - "repository maintainer" - ], - "acceptanceCriteria": [ - "General, Models, Style, Integrations, and About use native grouped Form and Section surfaces rather than custom card simulations.", - "Settings pages do not show the decorative 96-point page header, illustration, or accessory badge.", - "Speech-engine, language-style, and model-family choices use compact native controls instead of large icon tiles.", - "The real fixed-size settings window is inspected in Chinese and English, in light and dark appearances, without clipping or hierarchy regressions." - ], - "governedPaths": [ - "Sources/UI/SettingsVoiceIllustration.swift", - "Sources/UI/HistoryStatsView.swift", - "Sources/UI/HistoryInsightsOverview.swift", - "Sources/UI/HistoryRecordsView.swift", - "Sources/UI/GeneralSettingsView.swift", - "Sources/UI/DictionaryStyleView.swift", - "Sources/UI/IndustryLexiconView.swift", - "Sources/UI/DictionaryManagementView.swift", - "Sources/UI/ModelManagementView.swift", - "Sources/UI/ModelManagementActions.swift", - "Sources/UI/ModelManagementEnginePicker.swift", - "Sources/UI/ModelManagementFamilies.swift", - "Sources/UI/ModelManagementRows.swift", - "Sources/UI/ModelManagementSections.swift", - "Sources/UI/AboutView.swift", - "Sources/UI/IntegrationsSettingsView.swift" - ], - "artifacts": { - "intent": "intent.md", - "spec": "spec.md", - "plan": "plan.md", - "verification": "verification.md" - } -} diff --git a/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/intent.md b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/intent.md new file mode 100644 index 0000000..9e156ba --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/intent.md @@ -0,0 +1,25 @@ +# Intent: Replace the Python SDLC validator with a strict shell gate + +**Status:** approved +**Approved-by:** chenli +**Approved-date:** 2026-08-31 + +## Problem + +The SDLC validator stack (`scripts/sdlc.py`, `scripts/sdlc_policy.py`, `scripts/tests/test_sdlc.py`, per-bundle `state.json`) is Python-based and duplicates the strict per-stage approval model the maintainer wants enforced. Stage approval should live in the artifacts themselves, checked by shell only. + +## Outcome + +- Stage order and approval fields are enforced by `scripts/sdlc-checks.sh` (pure bash) in CI and locally. +- No Python in the SDLC toolchain. + +## Constraints + +- Historical bundles merged before the gate keep their recorded evidence as-is (including past `sdlc.py` runs) and are grandfathered by the checks. +- The governed-changed-paths feature of `sdlc.py` is not reimplemented in shell; residual risk is recorded in verification. + +## Acceptance criteria + +- `scripts/sdlc.py`, `scripts/sdlc_policy.py`, `scripts/tests/test_sdlc.py` and all `state.json` files are removed. +- `bash scripts/sdlc-checks.sh` passes and rejects later-stage approvals before earlier ones. +- No `sdlc.py` references remain in AGENTS.md, READMEs, docs/sdlc prose, CI workflows, or scripts (historical bundle evidence excepted). diff --git a/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/plan.md b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/plan.md new file mode 100644 index 0000000..3cafa55 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/plan.md @@ -0,0 +1,23 @@ +# Plan: Replace the Python SDLC validator with a strict shell gate + +**Status:** approved +**Approved-by:** chenli +**Approved-date:** 2026-08-31 +**Upstream:** [spec.md](spec.md) + +## Work items + +- [x] Add `Status`/`Approved-by`/`Approved-date`/`Upstream` headers to templates +- [x] Write `scripts/sdlc-checks.sh`; wire into `scripts/ci-basic-checks.sh` and `.github/workflows/pr.yml` +- [x] Remove `scripts/sdlc.py`, `scripts/sdlc_policy.py`, `scripts/tests/test_sdlc.py`, all `state.json` +- [x] Update AGENTS.md, `docs/sdlc/README.md`, README.md, README_zh.md references + +## Verification plan + +- [x] `bash scripts/sdlc-checks.sh` +- [x] `bash scripts/ci-basic-checks.sh` +- [ ] `swift test` (see verification for local environment blocker) + +## Human gates + +Merge approval — granted by the maintainer's instruction to open and merge the PR. diff --git a/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/spec.md b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/spec.md new file mode 100644 index 0000000..0bcab19 --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/spec.md @@ -0,0 +1,30 @@ +# Spec: Replace the Python SDLC validator with a strict shell gate + +**Status:** approved +**Approved-by:** chenli +**Approved-date:** 2026-08-31 +**Upstream:** [intent.md](intent.md) + +## Design + +- `scripts/sdlc-checks.sh` iterates `docs/sdlc/changes/*/` and enforces, per artifact (intent, spec, plan, verification, release optional): + - `Status` values limited to `draft | pending approval | approved | rejected | blocked`; + - no later stage `approved` before an earlier one; + - `approved` requires non-placeholder `Approved-by` and `Approved-date`. +- Artifacts without a `Status` header are legacy merged bundles and are skipped. +- Status header fields added to all templates in `docs/sdlc/templates/`. +- `state.json` is removed; the artifact header is the single source of stage state. + +## Safety and failure modes + +- The gate fails closed on malformed status values and on approvals with missing approver/date. +- Loss of the governed-changed-paths check means CI no longer requires a changed verified bundle for specific paths; mitigation: `scripts/ci-basic-checks.sh`, `swift test`, and the release-style build still run on every PR, and AGENTS.md keeps the strict-approval contract. + +## Test strategy + +- Positive: gate passes on the current tree. +- Negative: a bundle with an out-of-order approval or missing approver must fail. + +## Rollout and rollback + +Single documentation/tooling commit; rollback by reverting it. diff --git a/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/verification.md b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/verification.md new file mode 100644 index 0000000..fbd996e --- /dev/null +++ b/docs/sdlc/changes/2026-08-31-shell-sdlc-gate/verification.md @@ -0,0 +1,29 @@ +# Verification: Replace the Python SDLC validator with a strict shell gate + +**Status:** approved +**Approved-by:** chenli +**Approved-date:** 2026-08-31 +**Upstream:** [spec.md](spec.md) + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `bash scripts/sdlc-checks.sh` | Pass | All bundles checked; "SDLC checks passed." | +| `bash scripts/ci-basic-checks.sh` | Pass | "Basic CI checks passed." | +| `swift test` | Not run locally | mlx-swift Metal shader compilation fails under the local CommandLineTools SDK (pre-existing environment issue, unrelated to these docs/script changes); the PR CI job runs `swift test` on macos-26 | +| Negative: out-of-order approval | Pass | A bundle whose release stage was approved before earlier stages was rejected by the gate during development of change 0001 | + +## Acceptance criteria + +- Python SDLC toolchain and `state.json` removed — pass (git diff). +- Shell gate passes and enforces order/approval fields — pass. +- No live `sdlc.py` references in prose/CI — pass (historical bundle evidence retains its original text by design). + +## Residual risk + +- The py validator's governed-changed-paths requirement is not reimplemented; CI compensates with full checks, tests, and release-style build on every PR. Owner: repository maintainer. + +## Decision + +Ready for review; merge approved by chenli, 2026-08-31. diff --git a/docs/sdlc/templates/incident.md b/docs/sdlc/templates/incident.md index 69ae4e1..8be3052 100644 --- a/docs/sdlc/templates/incident.md +++ b/docs/sdlc/templates/incident.md @@ -1,5 +1,8 @@ # Incident: {{INCIDENT_TITLE}} +**Status:** open # open | mitigated | resolved +**Linked change:** — # corrective intent once one exists + ## Impact {{Affected users, systems, duration, and severity.}} diff --git a/docs/sdlc/templates/intent.md b/docs/sdlc/templates/intent.md index 2269e94..acb50b2 100644 --- a/docs/sdlc/templates/intent.md +++ b/docs/sdlc/templates/intent.md @@ -1,5 +1,10 @@ # Intent: {{CHANGE_TITLE}} +**Status:** draft # draft | pending approval | approved | rejected | blocked +**Approved-by:** — +**Approved-date:** — +**Upstream:** — # link to the previous stage artifact + ## Problem {{Describe the observed problem without prescribing implementation.}} diff --git a/docs/sdlc/templates/plan.md b/docs/sdlc/templates/plan.md index 9173f2a..03cf149 100644 --- a/docs/sdlc/templates/plan.md +++ b/docs/sdlc/templates/plan.md @@ -1,5 +1,10 @@ # Plan: {{CHANGE_TITLE}} +**Status:** draft # draft | pending approval | approved | rejected | blocked +**Approved-by:** — +**Approved-date:** — +**Upstream:** — # link to the previous stage artifact + ## Work items - [ ] {{Small, verifiable implementation step.}} diff --git a/docs/sdlc/templates/spec.md b/docs/sdlc/templates/spec.md index 821002d..12deb5d 100644 --- a/docs/sdlc/templates/spec.md +++ b/docs/sdlc/templates/spec.md @@ -1,5 +1,10 @@ # Spec: {{CHANGE_TITLE}} +**Status:** draft # draft | pending approval | approved | rejected | blocked +**Approved-by:** — +**Approved-date:** — +**Upstream:** — # link to the previous stage artifact + ## Context {{Summarize the relevant existing architecture and evidence.}} diff --git a/docs/sdlc/templates/verification.md b/docs/sdlc/templates/verification.md index fb6e226..e6de386 100644 --- a/docs/sdlc/templates/verification.md +++ b/docs/sdlc/templates/verification.md @@ -1,5 +1,10 @@ # Verification: {{CHANGE_TITLE}} +**Status:** draft # draft | pending approval | approved | rejected | blocked +**Approved-by:** — +**Approved-date:** — +**Upstream:** — # link to the previous stage artifact + ## Evidence | Check | Result | Evidence | diff --git a/scripts/ci-basic-checks.sh b/scripts/ci-basic-checks.sh index d8dc0ab..97c262c 100755 --- a/scripts/ci-basic-checks.sh +++ b/scripts/ci-basic-checks.sh @@ -21,8 +21,7 @@ step "Checking Package.swift" swift package describe >/dev/null step "Checking SDLC artifacts and harness regression tests" -python3 scripts/sdlc.py validate -python3 scripts/tests/test_sdlc.py +bash scripts/sdlc-checks.sh bash scripts/tests/test_build_version.sh bash scripts/tests/test_release_version.sh diff --git a/scripts/sdlc-checks.sh b/scripts/sdlc-checks.sh new file mode 100755 index 0000000..8b3498f --- /dev/null +++ b/scripts/sdlc-checks.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# SDLC gate: stage artifacts must be approved in order, and every approval +# must carry an approver and a date. Artifacts without a Status header are +# legacy bundles that were merged before this gate existed and are skipped. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +CHANGES_DIR="docs/sdlc/changes" +INCIDENTS_DIR="docs/sdlc/incidents" + +[ -d "$CHANGES_DIR" ] || fail "missing $CHANGES_DIR" + +status_of() { + sed -n 's/^\*\*Status:\*\*[[:space:]]*\([^#]*\).*/\1/p' "$1" | head -1 | tr -d '[:space:]' +} + +field_of() { + sed -n "s/^\\*\\*$1:\\*\\*[[:space:]]*//p" "$1" >/dev/null 2>&1 + sed -n "s/^\\*\\*$1:\\*\\*[[:space:]]*//p" "$2" | head -1 | sed 's/[[:space:]]*$//' +} + +has_status() { + grep -q '^\*\*Status:\*\*' "$1" +} + +check_change() { + change_dir="$1" + previous_approved=1 + + for stage in intent spec plan verification release; do + file="$change_dir/$stage.md" + [ -f "$file" ] || { + # release.md is optional (risk lanes do not require it) + [ "$stage" = "release" ] && break + fail "missing artifact $file" + } + has_status "$file" || continue # legacy merged bundle, grandfathered + + status="$(status_of "$file")" + case "$status" in + draft|pending|pendingapproval|approved|rejected|blocked) ;; + *) + fail "$file: Status must be one of draft|pending approval|approved|rejected|blocked (got '${status:-}')" + ;; + esac + + if [ "$status" = "approved" ]; then + if [ "$previous_approved" -ne 1 ]; then + fail "$file: approved while an earlier stage is not approved (order: intent -> spec -> plan -> verification -> release)" + fi + approver="$(field_of Approved-by "$file")" + date="$(field_of Approved-date "$file")" + [ -n "$approver" ] && [ "$approver" != "—" ] \ + || fail "$file: approved but Approved-by is missing" + [ -n "$date" ] && [ "$date" != "—" ] \ + || fail "$file: approved but Approved-date is missing" + else + previous_approved=0 + fi + done +} + +for change_dir in "$CHANGES_DIR"/*/; do + [ -d "$change_dir" ] || continue + echo "==> Checking $change_dir" + check_change "${change_dir%/}" +done + +if [ -d "$INCIDENTS_DIR" ]; then + for incident in "$INCIDENTS_DIR"/*.md; do + [ -f "$incident" ] || continue + status="$(status_of "$incident")" + case "$status" in + open|mitigated|resolved|"") ;; + *) fail "$incident: Status must be one of open|mitigated|resolved (got '$status')" ;; + esac + done +fi + +echo "SDLC checks passed." diff --git a/scripts/sdlc.py b/scripts/sdlc.py deleted file mode 100755 index 253e2f2..0000000 --- a/scripts/sdlc.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python3 -"""Validate Utter's artifact-driven SDLC contract without third-party packages.""" - -from __future__ import annotations - -import argparse -import json -import re -import subprocess -import sys -from pathlib import Path - -from sdlc_policy import REQUIRED_HEADINGS, RISKS, STATUSES, is_governed_path, minimum_risk - -ID_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$") -PLACEHOLDER_PATTERN = re.compile(r"\{\{[^}]+\}\}") - - -def required_artifacts(status: str, risk: str) -> set[str]: - status_index = STATUSES.index(status) - required = {"intent"} - if risk in {"medium", "high"} and status_index >= STATUSES.index("designed"): - required.add("spec") - if status_index >= STATUSES.index("planned"): - required.add("plan") - if status_index >= STATUSES.index("verified"): - required.add("verification") - return required - - -def path_is_in_scope(path: str, scope: str) -> bool: - return path.startswith(scope) if scope.endswith("/") else path == scope - - -def load_state(state_path: Path, errors: list[str]) -> dict | None: - try: - value = json.loads(state_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - errors.append(f"{state_path}: invalid JSON: {error}") - return None - if not isinstance(value, dict): - errors.append(f"{state_path}: top-level value must be an object") - return None - return value - - -def validate_artifact( - bundle_dir: Path, artifact: str, relative_path: object, errors: list[str] -) -> None: - if not isinstance(relative_path, str) or not relative_path: - errors.append(f"{bundle_dir}: artifact '{artifact}' needs a relative path") - return - relative = Path(relative_path) - if relative.is_absolute() or ".." in relative.parts: - errors.append(f"{bundle_dir}: artifact '{artifact}' must use a safe relative path") - return - artifact_path = bundle_dir / relative - try: - artifact_path.resolve().relative_to(bundle_dir.resolve()) - except ValueError: - errors.append(f"{bundle_dir}: artifact '{artifact}' escapes its bundle") - return - current = bundle_dir - for part in relative.parts: - current /= part - if current.is_symlink(): - errors.append(f"{artifact_path}: artifact paths may not contain symlinks") - return - if not artifact_path.is_file(): - errors.append(f"{artifact_path}: required artifact is missing") - return - text = artifact_path.read_text(encoding="utf-8") - for heading in REQUIRED_HEADINGS[artifact]: - if heading not in text: - errors.append(f"{artifact_path}: missing heading '{heading}'") - if PLACEHOLDER_PATTERN.search(text): - errors.append(f"{artifact_path}: contains an unfilled template placeholder") - - -def validate_bundle(state_path: Path) -> tuple[dict | None, list[str]]: - errors: list[str] = [] - state = load_state(state_path, errors) - if state is None: - return None, errors - - bundle_dir = state_path.parent - bundle_id = state.get("id") - if bundle_id != bundle_dir.name or not isinstance(bundle_id, str) or not ID_PATTERN.match(bundle_id): - errors.append(f"{state_path}: id must match the yyyy-mm-dd-slug directory") - if state.get("schemaVersion") != 1: - errors.append(f"{state_path}: schemaVersion must be 1") - if not isinstance(state.get("title"), str) or not state["title"].strip(): - errors.append(f"{state_path}: title must be non-empty") - - risk = state.get("risk") - status = state.get("status") - if risk not in RISKS: - errors.append(f"{state_path}: risk must be one of {', '.join(RISKS)}") - if status not in STATUSES: - errors.append(f"{state_path}: status must be one of {', '.join(STATUSES)}") - - owners = state.get("owners") - if not isinstance(owners, list) or not owners or not all( - isinstance(owner, str) and owner.strip() for owner in owners - ): - errors.append(f"{state_path}: owners must be a non-empty string array") - criteria = state.get("acceptanceCriteria") - if not isinstance(criteria, list) or not criteria or not all( - isinstance(criterion, str) and criterion.strip() for criterion in criteria - ): - errors.append(f"{state_path}: acceptanceCriteria must be a non-empty string array") - governed_paths = state.get("governedPaths") - if not isinstance(governed_paths, list) or not governed_paths or not all( - isinstance(path, str) - and path.strip() - and not path.startswith(("/", "../")) - and "/../" not in path - and path not in {".", "./"} - for path in governed_paths - ): - errors.append(f"{state_path}: governedPaths must contain safe repository-relative paths") - - artifacts = state.get("artifacts") - if not isinstance(artifacts, dict): - errors.append(f"{state_path}: artifacts must be an object") - return state, errors - if risk in RISKS and status in STATUSES: - required = required_artifacts(status, risk) - required_paths = [artifacts.get(artifact) for artifact in required] - canonical_paths = [ - (state_path.parent / path).resolve() - for path in required_paths - if isinstance(path, str) - and not Path(path).is_absolute() - and ".." not in Path(path).parts - ] - if len(canonical_paths) != len(set(canonical_paths)): - errors.append(f"{state_path}: required artifacts must use distinct files") - for artifact in required: - validate_artifact(bundle_dir, artifact, artifacts.get(artifact), errors) - return state, errors - - -def validate_repository(root: Path) -> tuple[dict[Path, dict], list[str]]: - states: dict[Path, dict] = {} - errors: list[str] = [] - changes_root = root / "docs" / "sdlc" / "changes" - if not changes_root.exists(): - return states, errors - for state_path in sorted(changes_root.glob("*/state.json")): - state, bundle_errors = validate_bundle(state_path) - errors.extend(bundle_errors) - if state is not None: - states[state_path.relative_to(root)] = state - orphan_dirs = sorted(path for path in changes_root.iterdir() if path.is_dir() and not (path / "state.json").is_file()) - errors.extend(f"{path}: change bundle is missing state.json" for path in orphan_dirs) - return states, errors - - -def changed_files( - root: Path, base: str | None, worktree: bool, two_dot: bool = False -) -> list[str]: - commands: list[list[str]] = [] - if base: - revision_range = f"{base}..HEAD" if two_dot else f"{base}...HEAD" - commands.append( - ["git", "diff", "--no-renames", "--name-only", "--diff-filter=ACMDT", revision_range] - ) - elif worktree: - commands.extend( - ( - ["git", "diff", "--no-renames", "--name-only", "--diff-filter=ACMDT", "HEAD"], - ["git", "ls-files", "--others", "--exclude-standard"], - ) - ) - else: - return [] - paths: set[str] = set() - for command in commands: - result = subprocess.run(command, cwd=root, check=True, capture_output=True, text=True) - paths.update(line for line in result.stdout.splitlines() if line) - return sorted(paths) - - -def enforce_changed_files(paths: list[str], states: dict[Path, dict]) -> list[str]: - governed = [path for path in paths if is_governed_path(path)] - if not governed: - return [] - changed_bundle_dirs = { - Path(*Path(path).parts[:4]) - for path in paths - if len(Path(path).parts) >= 5 - and Path(path).parts[:3] == ("docs", "sdlc", "changes") - } - changed_states = { - path: state - for path, state in states.items() - if path.parent in changed_bundle_dirs - } - if not changed_states: - preview = ", ".join(governed[:5]) - return [f"governed changes require a changed SDLC bundle; governed paths: {preview}"] - verified_index = STATUSES.index("verified") - verified_states = [ - state - for state in changed_states.values() - if state.get("status") in STATUSES - and STATUSES.index(state["status"]) >= verified_index - ] - if not verified_states: - return ["at least one changed SDLC bundle must have status verified or later"] - covering_states = { - path: [ - state - for state in verified_states - if any( - path_is_in_scope(path, scope) - for scope in state.get("governedPaths", []) - if isinstance(scope, str) - ) - ] - for path in governed - } - uncovered = [path for path, covering in covering_states.items() if not covering] - if uncovered: - preview = ", ".join(uncovered[:5]) - return [f"verified SDLC bundles do not cover governed paths: {preview}"] - insufficient = [ - path - for path, covering in covering_states.items() - if not any( - state.get("risk") in RISKS - and RISKS.index(state["risk"]) >= RISKS.index(minimum_risk(path)) - for state in covering - ) - ] - if insufficient: - preview = ", ".join( - f"{path} (requires {minimum_risk(path)})" for path in insufficient[:5] - ) - return [f"verified SDLC bundles have insufficient risk classification: {preview}"] - return [] - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("validate",), nargs="?", default="validate") - source = parser.add_mutually_exclusive_group() - source.add_argument("--base", help="compare HEAD with this Git base") - source.add_argument("--push-base", help="compare HEAD directly with the previous push SHA") - source.add_argument("--worktree", action="store_true", help="check staged, unstaged, and untracked files") - return parser.parse_args() - - -def main() -> int: - args = parse_args() - root = Path(__file__).resolve().parent.parent - states, errors = validate_repository(root) - try: - paths = changed_files(root, args.base or args.push_base, args.worktree, bool(args.push_base)) - except subprocess.CalledProcessError as error: - print(error.stderr, file=sys.stderr) - return error.returncode - errors.extend(enforce_changed_files(paths, states)) - if errors: - for error in errors: - print(f"error: {error}", file=sys.stderr) - return 1 - suffix = f"; checked {len(paths)} changed paths" if paths else "" - print(f"SDLC validation passed ({len(states)} change bundles{suffix}).") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/sdlc_policy.py b/scripts/sdlc_policy.py deleted file mode 100644 index daa33f4..0000000 --- a/scripts/sdlc_policy.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Stable path, risk, state, and artifact policy for Utter's SDLC validator.""" - -STATUSES = ("intent", "designed", "planned", "implementing", "verified", "released", "closed") -RISKS = ("low", "medium", "high") - -REQUIRED_HEADINGS = { - "intent": ( - "## Problem", "## Outcome", "## Scope", "## Constraints", - "## Acceptance criteria", "## Open questions", - ), - "spec": ( - "## Context", "## Design", "## Safety and failure modes", - "## Test strategy", "## Rollout and rollback", - ), - "plan": ("## Work items", "## Verification plan", "## Human gates"), - "verification": ( - "## Evidence", "## Acceptance criteria", "## Residual risk", "## Decision", - ), -} - - -def is_governed_path(path: str) -> bool: - if path.startswith("docs/sdlc/changes/") or path.startswith("docs/research/"): - return False - if path in {"Package.swift", "Package.resolved", ".gitignore", "AGENTS.md", "CLAUDE.md"}: - return True - return path.startswith( - ( - "Sources/", "SourcesCLI/", "Resources/", "Tests/", "scripts/", - ".github/", "docs/sdlc/", "docs/index.html", "docs/assets/", - ) - ) - - -def minimum_risk(path: str) -> str: - high_risk_paths = { - "Resources/Info.plist", "Resources/OpenType.entitlements", "scripts/build-app.sh", - "scripts/build-version.sh", "scripts/ci-basic-checks.sh", - "scripts/create-signing-cert.sh", "scripts/sdlc.py", - "scripts/release-version.sh", "scripts/sdlc_policy.py", "scripts/verify-release-artifact.sh", - } - high_risk_prefixes = ( - ".github/workflows/", "Sources/Audio/", "Sources/Integration/", - "Sources/Hotkey/", "Sources/LLM/", "Sources/Output/", "Sources/Screen/", - ) - high_risk_source_files = { - "Sources/App/VoicePipeline+CorrectionCapture.swift", - "Sources/App/VoicePipeline+ScreenContext.swift", - "Sources/Config/AppSettings.swift", - "Sources/Speech/AppleSpeechEngine.swift", - } - if path in high_risk_paths or path in high_risk_source_files or path.startswith(high_risk_prefixes): - return "high" - if path in {"Package.swift", "Package.resolved", ".gitignore", "AGENTS.md", "CLAUDE.md"}: - return "medium" - if path.startswith( - ( - ".github/", "Sources/", "SourcesCLI/", "Resources/", - "docs/sdlc/", "docs/index.html", "docs/assets/", "scripts/", - ) - ): - return "medium" - return "low" diff --git a/scripts/tests/test_sdlc.py b/scripts/tests/test_sdlc.py deleted file mode 100755 index 13d55c0..0000000 --- a/scripts/tests/test_sdlc.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 - -import importlib.util -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path - - -SCRIPT_PATH = Path(__file__).parents[1] / "sdlc.py" -sys.path.insert(0, str(SCRIPT_PATH.parent)) -SPEC = importlib.util.spec_from_file_location("sdlc", SCRIPT_PATH) -assert SPEC and SPEC.loader -SDLC = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(SDLC) - - -CONTENT = { - "intent": """# Intent: Test -## Problem -Problem. -## Outcome -Outcome. -## Scope -Scope. -## Constraints -Constraints. -## Acceptance criteria -- It passes. -## Open questions -None. -""", - "spec": """# Spec: Test -## Context -Context. -## Design -Design. -## Safety and failure modes -Failures. -## Test strategy -Tests. -## Rollout and rollback -Rollback. -""", - "plan": """# Plan: Test -## Work items -- [x] Work. -## Verification plan -- [x] Verify. -## Human gates -Review. -""", - "verification": """# Verification: Test -## Evidence -Passed. -## Acceptance criteria -- Passed. -## Residual risk -None. -## Decision -Ready for review. -""", -} - - -class SDLCValidationTests(unittest.TestCase): - def make_bundle(self, root: Path, *, risk: str = "high", status: str = "verified", omit: str | None = None) -> Path: - bundle_id = "2026-08-25-test-change" - bundle = root / "docs" / "sdlc" / "changes" / bundle_id - bundle.mkdir(parents=True) - artifacts = {} - for name, content in CONTENT.items(): - if name == omit: - continue - filename = f"{name}.md" - artifacts[name] = filename - (bundle / filename).write_text(content, encoding="utf-8") - state = { - "schemaVersion": 1, - "id": bundle_id, - "title": "Test change", - "risk": risk, - "status": status, - "owners": ["maintainer"], - "acceptanceCriteria": ["It passes."], - "governedPaths": ["Sources/App/"], - "artifacts": artifacts, - } - state_path = bundle / "state.json" - state_path.write_text(json.dumps(state), encoding="utf-8") - return state_path - - def test_verified_high_risk_bundle_is_valid(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - state_path = self.make_bundle(root) - state, errors = SDLC.validate_bundle(state_path) - self.assertEqual(state["status"], "verified") - self.assertEqual(errors, []) - - def test_high_risk_bundle_requires_spec(self) -> None: - with tempfile.TemporaryDirectory() as directory: - state_path = self.make_bundle(Path(directory), omit="spec") - _, errors = SDLC.validate_bundle(state_path) - self.assertTrue(any("spec" in error for error in errors)) - - def test_required_artifacts_must_be_distinct_files(self) -> None: - with tempfile.TemporaryDirectory() as directory: - state_path = self.make_bundle(Path(directory)) - state = json.loads(state_path.read_text(encoding="utf-8")) - combined = state_path.parent / "combined.md" - combined.write_text("\n".join(CONTENT.values()), encoding="utf-8") - alias = state_path.parent / "alias.md" - alias.symlink_to(combined.name) - state["artifacts"] = { - "intent": combined.name, - "spec": f"./{combined.name}", - "plan": str(combined.resolve()), - "verification": alias.name, - } - state_path.write_text(json.dumps(state), encoding="utf-8") - _, errors = SDLC.validate_bundle(state_path) - self.assertTrue(any("distinct files" in error for error in errors)) - self.assertTrue(any("safe relative path" in error for error in errors)) - self.assertTrue(any("symlinks" in error for error in errors)) - - def test_source_change_requires_changed_bundle(self) -> None: - errors = SDLC.enforce_changed_files(["Sources/App/AppState.swift"], {}) - self.assertEqual(len(errors), 1) - - def test_verified_state_satisfies_governed_change(self) -> None: - state_path = Path("docs/sdlc/changes/2026-08-25-test-change/state.json") - paths = [ - "Sources/App/AppState.swift", - "docs/sdlc/changes/2026-08-25-test-change/verification.md", - ] - states = { - state_path: { - "status": "verified", - "risk": "medium", - "governedPaths": ["Sources/App/"], - } - } - self.assertEqual(SDLC.enforce_changed_files(paths, states), []) - - def test_unrelated_verified_bundle_does_not_cover_change(self) -> None: - state_path = Path("docs/sdlc/changes/2026-08-25-test-change/state.json") - paths = [ - "Resources/OpenType.entitlements", - "docs/sdlc/changes/2026-08-25-test-change/verification.md", - ] - states = { - state_path: { - "status": "verified", - "risk": "low", - "governedPaths": ["Sources/App/"], - } - } - errors = SDLC.enforce_changed_files(paths, states) - self.assertTrue(any("do not cover" in error for error in errors)) - - def test_release_workflow_requires_high_risk_bundle(self) -> None: - state_path = Path("docs/sdlc/changes/2026-08-25-test-change/state.json") - paths = [ - ".github/workflows/release.yml", - "docs/sdlc/changes/2026-08-25-test-change/verification.md", - ] - states = { - state_path: { - "status": "verified", - "risk": "medium", - "governedPaths": [".github/workflows/"], - } - } - errors = SDLC.enforce_changed_files(paths, states) - self.assertTrue(any("insufficient risk" in error for error in errors)) - - def test_ui_and_privacy_paths_have_documented_minimum_risk(self) -> None: - self.assertEqual(SDLC.minimum_risk("Sources/UI/SettingsView.swift"), "medium") - self.assertEqual(SDLC.minimum_risk("Sources/Speech/WhisperEngine.swift"), "medium") - self.assertEqual(SDLC.minimum_risk("Sources/Screen/ScreenOCR.swift"), "high") - self.assertEqual(SDLC.minimum_risk("Sources/LLM/RemoteLLMClient.swift"), "high") - self.assertEqual(SDLC.minimum_risk("Sources/Config/AppSettings.swift"), "high") - self.assertEqual(SDLC.minimum_risk("Sources/Hotkey/HotkeyManager.swift"), "high") - self.assertEqual(SDLC.minimum_risk("Sources/Speech/AppleSpeechEngine.swift"), "high") - self.assertEqual(SDLC.minimum_risk("Resources/Info.plist"), "high") - - def test_worktree_rename_keeps_governed_source_path(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "scripts").mkdir() - (root / "docs" / "research").mkdir(parents=True) - source = root / "scripts" / "guard.sh" - source.write_text("guard\n", encoding="utf-8") - subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run(["git", "add", "."], cwd=root, check=True) - subprocess.run( - ["git", "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-qm", "base"], - cwd=root, - check=True, - ) - subprocess.run( - ["git", "mv", "scripts/guard.sh", "docs/research/guard.md"], - cwd=root, - check=True, - ) - paths = SDLC.changed_files(root, base=None, worktree=True) - self.assertIn("scripts/guard.sh", paths) - - def test_base_diff_includes_deleted_governed_path(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "Tests").mkdir() - removed = root / "Tests" / "RemovedTests.swift" - removed.write_text("test\n", encoding="utf-8") - subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run(["git", "add", "."], cwd=root, check=True) - identity = ["-c", "user.name=Test", "-c", "user.email=test@example.com"] - subprocess.run(["git", *identity, "commit", "-qm", "base"], cwd=root, check=True) - base = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True - ).stdout.strip() - removed.unlink() - subprocess.run(["git", "add", "-u"], cwd=root, check=True) - subprocess.run(["git", *identity, "commit", "-qm", "delete"], cwd=root, check=True) - paths = SDLC.changed_files(root, base=base, worktree=False) - self.assertIn("Tests/RemovedTests.swift", paths) - - def test_push_diff_handles_divergent_history(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "Tests").mkdir() - removed = root / "Tests" / "RemovedTests.swift" - removed.write_text("test\n", encoding="utf-8") - subprocess.run(["git", "init", "-q"], cwd=root, check=True) - subprocess.run(["git", "add", "."], cwd=root, check=True) - identity = ["-c", "user.name=Test", "-c", "user.email=test@example.com"] - subprocess.run(["git", *identity, "commit", "-qm", "base"], cwd=root, check=True) - base = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=root, check=True, capture_output=True, text=True - ).stdout.strip() - subprocess.run(["git", "checkout", "--orphan", "rewrite", "-q"], cwd=root, check=True) - removed.unlink() - (root / "README.md").write_text("rewrite\n", encoding="utf-8") - subprocess.run(["git", "add", "-A"], cwd=root, check=True) - subprocess.run(["git", *identity, "commit", "-qm", "rewrite"], cwd=root, check=True) - paths = SDLC.changed_files(root, base=base, worktree=False, two_dot=True) - self.assertIn("Tests/RemovedTests.swift", paths) - - def test_research_only_change_uses_fast_path(self) -> None: - paths = ["docs/research/finding.md"] - self.assertEqual(SDLC.enforce_changed_files(paths, {}), []) - - def test_sdlc_policy_change_is_governed(self) -> None: - errors = SDLC.enforce_changed_files(["docs/sdlc/README.md"], {}) - self.assertEqual(len(errors), 1) - - -if __name__ == "__main__": - unittest.main()