create-cmp-cli 0.10.1 → 0.12.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/README.md +11 -9
- package/package.json +1 -1
- package/src/lib/package-name.mjs +72 -0
- package/src/lib/tabs.mjs +26 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/.claude/skills/add-feature/SKILL.md +20 -0
- package/template/.claude/skills/add-repository/SKILL.md +6 -0
- package/template/.claude/skills/add-screen/SKILL.md +6 -0
- package/template/CLAUDE.md +129 -8
- package/template/composeApp/build.gradle.kts +69 -0
- package/template/composeApp/proguard-rules.pro +12 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/approve.mjs +119 -11
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/approvals.mjs +602 -21
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/feature-brief.mjs +324 -0
- package/template/qa/lib/inputs-hash.mjs +43 -6
- package/template/qa/lib/reachability.mjs +211 -0
- package/template/qa/lib/spec-coverage.mjs +131 -0
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +776 -95
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
package/template/docs/TESTING.md
CHANGED
|
@@ -9,6 +9,7 @@ copy their shape.
|
|
|
9
9
|
| Conformance gates (ARCH clauses) | `composeApp/src/desktopTest/…/conformance` | same task |
|
|
10
10
|
| Screen behavior — Compose UI Test (spec-cited) | `composeApp/src/desktopTest/…/presentation` | same task |
|
|
11
11
|
| Golden trees (structure) | `qa/golden/` + `HomeGoldenTreeTest` | same task |
|
|
12
|
+
| Instrumented behavior (platform facts) | `composeApp/src/androidInstrumentedTest` | `./gradlew :composeApp:connectedDebugAndroidTest` (device attached) |
|
|
12
13
|
<!-- >>> cmp:feature e2e -->
|
|
13
14
|
| E2E smoke (few) | `qa/e2e/*.yaml` (Maestro) | `maestro test qa/e2e/smoke.yaml` |
|
|
14
15
|
<!-- <<< cmp:feature e2e -->
|
|
@@ -47,6 +48,82 @@ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
|
|
|
47
48
|
Never delete, weaken, or `@Ignore` a failing test to get green. Fix the behavior — or if the
|
|
48
49
|
test is genuinely wrong, change it and say so explicitly in your PR/summary.
|
|
49
50
|
|
|
51
|
+
## The instrumented tier — platform behavior
|
|
52
|
+
|
|
53
|
+
This seam exists because platform behavior escapes every desktop tier. Alarms,
|
|
54
|
+
notifications, lock-screen/full-screen intents, notification channels, PendingIntent
|
|
55
|
+
identity, audio routing, process death, and runtime permissions are OS facts:
|
|
56
|
+
`desktopTest` runs on a JVM, golden trees pin structure, the conformance suite is static,
|
|
57
|
+
and the E2E smoke taps UI without asserting anything about the shade or the alarm table.
|
|
58
|
+
A feature whose whole point is "the phone alerts" can ship fully green from every other
|
|
59
|
+
tier and never alert — that class of defect escaped to production repeatedly before this
|
|
60
|
+
tier existed. When your feature touches alarms, notifications, or locks, its behavior
|
|
61
|
+
test lives here.
|
|
62
|
+
|
|
63
|
+
**What belongs here:** claims only the OS can witness — a notification actually reached
|
|
64
|
+
the shade, a channel holds the importance the feature needs, N logical alarms occupy N
|
|
65
|
+
PendingIntent slots, the alert path plays with `USAGE_ALARM` on a silenced ringer, the
|
|
66
|
+
app survives process death, a permission-gated path degrades correctly.
|
|
67
|
+
|
|
68
|
+
**What does not:** logic (→ `commonTest`), rendered structure (→ golden trees),
|
|
69
|
+
architecture rules (→ conformance), UI journeys (→ E2E flows). If a JVM test can prove
|
|
70
|
+
it, a JVM test is where it goes — this tier is the most expensive seat in the house.
|
|
71
|
+
|
|
72
|
+
**Cost model:** needs a device/emulator, so it runs at checkpoint cadence — the lane's
|
|
73
|
+
`androidChecks` step (`connectedDebugAndroidTest`) SKIPs honestly when no device is
|
|
74
|
+
attached and runs in `local`/`ci` when one is, with the `release` profile adding the
|
|
75
|
+
release-APK smoke on top. It is not an inner-loop tier; don't reach for it per-edit.
|
|
76
|
+
|
|
77
|
+
**The helpers** (`androidInstrumentedTest/…/testing/`), small and composable — see
|
|
78
|
+
`PlatformBehaviorSeamTest` for the exemplar shape:
|
|
79
|
+
|
|
80
|
+
- `NotificationAsserts` — bounded-poll wait for a posted notification (id/tag or
|
|
81
|
+
predicate), channel-exists with an importance floor, full-screen-intent capability
|
|
82
|
+
(API-aware).
|
|
83
|
+
- `AlarmAsserts` — the OS alarm table (`dumpsys alarm`) parsed per-package;
|
|
84
|
+
alarm-registered and N-distinct-alarms assertions (the PendingIntent-identity
|
|
85
|
+
collision, caught mechanically).
|
|
86
|
+
- `SystemState` — snapshot/restore of ringer mode and DND so audio-routing claims are
|
|
87
|
+
testable; its header is honest about what instrumentation cannot control (OEM sound
|
|
88
|
+
policy, "a human heard it") — those stay a documented manual tier.
|
|
89
|
+
|
|
90
|
+
### Runtime state control — put the system into the state the claim is about
|
|
91
|
+
|
|
92
|
+
The observation helpers above answer "what did the OS do?"; this organ family answers
|
|
93
|
+
the prior question — "can the test even reach the state the claim is about?" Most
|
|
94
|
+
unprovable mobile claims are unprovable exactly because the state is hard to reach: you
|
|
95
|
+
would have to wait hours for Doze, ship to a user who denies the permission, drop the
|
|
96
|
+
network by hand, or hope the OS reclaims your process while you watch. Each organ
|
|
97
|
+
reaches one such state on demand — emulator-only (every entry point SKIPs on a real
|
|
98
|
+
device), root-free on stock user-build images (every shell command verified from the
|
|
99
|
+
shell uid), and bracket-shaped (`withX { }` — snapshot, act, restore in `finally`) so
|
|
100
|
+
organs compose:
|
|
101
|
+
|
|
102
|
+
- `TimeWarp` — the device clock and time zone ("the 08:00 alarm actually arrives", DST).
|
|
103
|
+
- `DozeControl` — forced light/deep idle; composed with TimeWarp it proves
|
|
104
|
+
`setExactAndAllowWhileIdle` does what its name promises (the flagship exemplar in
|
|
105
|
+
`RuntimeStateSeamTest`).
|
|
106
|
+
- `PermissionControl` — the denied state (the fresh-install default) as a test input;
|
|
107
|
+
one-way grants. Its header documents the trap that shapes it: revoking a held runtime
|
|
108
|
+
permission kills the holding process — this seam's own process.
|
|
109
|
+
- `ProcessControl` — real OS-driven activity destruction and saved-state rebuild
|
|
110
|
+
(don't-keep-activities — flipped live via the same binder call the Developer-options
|
|
111
|
+
toggle uses, because `settings put global always_finish_activities` alone is read only
|
|
112
|
+
at boot; verified on API 35); honest about why in-process "kill my own process" is
|
|
113
|
+
structurally impossible under instrumentation.
|
|
114
|
+
- `NetworkControl` — offline (airplane mode) and per-transport wifi/data brackets; the
|
|
115
|
+
path to proving `core/connectivity/NetworkMonitor` tracks the world.
|
|
116
|
+
- `ConfigControl` — dark mode, font scale, per-app locale (API 33+); configuration
|
|
117
|
+
change as the other state-loss lever.
|
|
118
|
+
|
|
119
|
+
What device-state control cannot reproduce, honestly: the path INTO a state (real Doze's
|
|
120
|
+
descent ladder and maintenance windows — forced idle teleports and holds), OEM policy
|
|
121
|
+
(battery managers, sound routing, skinned lock screens), app-standby buckets, real-network
|
|
122
|
+
character (bandwidth, captive portals, flaky RSSI — emulator transports are the host's
|
|
123
|
+
connection wearing costumes), true cold-process start under instrumentation, and anything
|
|
124
|
+
whose proof is a human's senses. Each organ's header states its own gaps; a green test is
|
|
125
|
+
evidence for exactly the state the organ produced, nothing wider.
|
|
126
|
+
|
|
50
127
|
<!-- >>> cmp:feature e2e -->
|
|
51
128
|
## E2E
|
|
52
129
|
|
|
@@ -70,3 +147,91 @@ asserts are for static post-navigation elements only.
|
|
|
70
147
|
attached. It writes the evidence receipt to `qa/evidence/latest.json`; **commit the receipt
|
|
71
148
|
with your change.** SKIPped steps are recorded honestly — green-with-gaps is visible, never
|
|
72
149
|
silent.
|
|
150
|
+
|
|
151
|
+
**`--fast` is the lane's inner loop.** `node qa/verify.mjs --fast` runs the resolved
|
|
152
|
+
profile minus the device/release tier (`releaseBuild`, `tokenDrift`, `e2eSmoke`,
|
|
153
|
+
`androidChecks`, `releaseSmoke`) — unconditionally, device attached or not. It exists
|
|
154
|
+
because device/release evidence is the scarce, slow tier (R8 compile, emulator, Maestro,
|
|
155
|
+
instrumented runner): batch it at the checkpoint, don't pay for it on every small edit.
|
|
156
|
+
The rest of the profile runs cheaply, three ways:
|
|
157
|
+
|
|
158
|
+
- **Unchanged pure-Node steps are reused, not re-run.** `specCoverage`, `approvals`,
|
|
159
|
+
`componentStories`, `reachability`, and `archDoc` are pure functions of files on disk;
|
|
160
|
+
a fast run reuses each one's last PASS when a content hash of its declared inputs is
|
|
161
|
+
unchanged, shown as `⚡ name: CACHED (unchanged since …)`. Only a PASS is ever reused —
|
|
162
|
+
a FAIL or SKIP always re-runs — and the cache
|
|
163
|
+
(`composeApp/build/.cmp-step-cache.json`) is a gitignored cache, never evidence: the
|
|
164
|
+
full lane never reads it (it always executes every step; it only writes entries so the
|
|
165
|
+
next fast run benefits).
|
|
166
|
+
- **Gradle's up-to-date checks stand.** The full lane forces test execution with
|
|
167
|
+
`--rerun` (evidence integrity — a receipt must attest tests that ran). A fast receipt
|
|
168
|
+
is already declared non-evidence, so fast mode omits the flag and unchanged test tasks
|
|
169
|
+
cost nothing.
|
|
170
|
+
- **Unit tests are scoped to the change.** Changed `.kt` files (git diff + untracked)
|
|
171
|
+
map to `--tests "*<feature>*"` filters, reported honestly in the step line and the
|
|
172
|
+
receipt. Broad-impact changes — build files, DI, theme/tokens, shared components,
|
|
173
|
+
`qa/` itself, anything outside `composeApp/src` — disable filtering and run the whole
|
|
174
|
+
suite, as does any uncertain case (no git, unmappable change): fail open, never fail
|
|
175
|
+
silent. A filtered fast run can miss a cross-feature regression by design; the full,
|
|
176
|
+
unfiltered suite at the checkpoint is what decides done.
|
|
177
|
+
|
|
178
|
+
A fast run is mechanically unable to claim done — its receipt records `"mode": "fast"`,
|
|
179
|
+
derives **no** evidence rung, and `qa/receipt-check.mjs` (the Stop hook, CI, pre-push)
|
|
180
|
+
refuses it by name. Iterate on `--fast`; run the full lane once, deliberately, when the
|
|
181
|
+
change is done.
|
|
182
|
+
|
|
183
|
+
**Watch mode makes the inner loop resident.** `node qa/watch.mjs` watches
|
|
184
|
+
`composeApp/src`, `specs/`, and `qa/` and re-runs `node qa/verify.mjs --fast` (as a
|
|
185
|
+
subprocess — it inherits every fast-mode economy above for free) on every save, debounced
|
|
186
|
+
so a multi-file save storm triggers one run and changes landing mid-run coalesce into
|
|
187
|
+
exactly one follow-up. Each run re-prints the step table with any failing step's reason
|
|
188
|
+
verbatim — the errors-on-save loop an IDE gives a human, for the agent. What it is **not**:
|
|
189
|
+
evidence. It runs the fast tier only, so nothing it produces can satisfy the done-gate,
|
|
190
|
+
and every run says so in a standing footer; the checkpoint remains one deliberate full
|
|
191
|
+
`node qa/verify.mjs` run. Coordination: it never launches while a verify lane
|
|
192
|
+
(`.cmp-lane-in-progress`) or a preview-daemon render (`.cmp-render-in-progress`) holds the
|
|
193
|
+
project — it waits and coalesces, so watch mode, the preview daemon, and the lane can all
|
|
194
|
+
be resident on one project without two Gradle invocations ever colliding. `--once` runs a
|
|
195
|
+
single coordinated pass for scripting; `--json` emits one JSON object per run for
|
|
196
|
+
programmatic consumption.
|
|
197
|
+
|
|
198
|
+
## One device, one driver
|
|
199
|
+
|
|
200
|
+
The machine typically has ONE Android device/emulator, and it is the scarcest, slowest,
|
|
201
|
+
most fragile resource the harness touches — two concurrent drivers produce wedged adbd,
|
|
202
|
+
`device offline` while `adb devices` looks fine, crossed app state, and false reds that
|
|
203
|
+
have nothing to do with the app. So device evidence is **batched, never an inner loop**:
|
|
204
|
+
the lane sequences its device steps (`tokenDrift` live tier, `e2eSmoke`, `androidChecks`,
|
|
205
|
+
`releaseSmoke`) once, last — don't hand-run `connectedDebugAndroidTest`, `maestro test`,
|
|
206
|
+
or `adb install` mid-task to "check something".
|
|
207
|
+
|
|
208
|
+
Mechanically, the first device step takes a **machine-global lease** on the device's adb
|
|
209
|
+
serial (`<os tmpdir>/create-cmp/device-leases/<serial>.json` — `qa/lib/device-lease.mjs`
|
|
210
|
+
documents the contract), held until the lane exits. It is machine-global on purpose: two
|
|
211
|
+
different projects (a scratch app in /tmp, the real one) share the same emulator, and
|
|
212
|
+
per-project markers cannot see each other.
|
|
213
|
+
|
|
214
|
+
**Contention is a SKIP, never a FAIL** — nothing is broken; another run legitimately holds
|
|
215
|
+
the device, and the reason names it: `held by "verify lane e2eSmoke" (pid 4711,
|
|
216
|
+
/tmp/scratch-x, 2m ago)`. A SKIPped device step does not buy its rung, so contention
|
|
217
|
+
visibly *lowers* the evidence level (L2 falls back to L1) instead of corrupting the run —
|
|
218
|
+
re-run when the holder finishes to earn the full rung. To see who holds a device:
|
|
219
|
+
`cat "${TMPDIR:-/tmp}/create-cmp/device-leases/<serial>.json"`. A crashed holder never
|
|
220
|
+
wedges the machine: a lease whose pid is dead, or older than 30 minutes, is silently
|
|
221
|
+
reclaimed by the next run. The live inspector tier (`connect_live`,
|
|
222
|
+
`navigate_and_inspect`) checks the same lease and refuses to drive a leased device by
|
|
223
|
+
naming the holder instead of failing with a mysterious transport error.
|
|
224
|
+
|
|
225
|
+
## The evidence ladder
|
|
226
|
+
|
|
227
|
+
Every PASS receipt names its rung (`evidenceLevel` in the receipt; derived by
|
|
228
|
+
`qa/lib/evidence-level.mjs` from which steps actually ran and PASSed — never declared, and
|
|
229
|
+
a SKIPped step never buys a rung: an unsigned-keystore `releaseSmoke` SKIP is not L3). The
|
|
230
|
+
rung is the coarse grade; the per-step list stays the fine print. A FAILed lane has no rung.
|
|
231
|
+
|
|
232
|
+
| Rung | What it proves | What it does NOT prove |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| **L0 scaffold** | Stamp-time green: the build compiles and the unit tests pass. | Nothing about conformance, rendered structure, a11y, or the release variant — and nothing on a device. |
|
|
235
|
+
| **L1 desktop** | Full static + JVM evidence: build, unit tests, conformance, golden trees, a11y, release COMPILE, and the pure-Node gates. | That the app runs on a device at all — no APK was installed or driven; platform behavior (alarms, notifications) is invisible from this rung. |
|
|
236
|
+
| **L2 device** | L1 plus executed on-device evidence: the debug APK installed and driven (`e2eSmoke`), instrumented platform assertions (`androidChecks`), and/or live token drift. | That the release variant behaves (R8 differs from debug — that is L3's job), nor that alarms/notifications actually land unless an instrumented behavior test asserts them. |
|
|
237
|
+
| **L3 release** | L2 plus `releaseSmoke` PASSed: the signed release APK installed and driven on a device. | Real-backend behavior (the emulator/dev backend is a documented tier boundary — see the instrumented-tier section) and store-review compliance. |
|
|
@@ -22,6 +22,14 @@ compose-hot-reload = "1.1.1"
|
|
|
22
22
|
coil = "3.1.0"
|
|
23
23
|
androidx-core = "1.15.0"
|
|
24
24
|
androidx-activity = "1.10.1"
|
|
25
|
+
# ── Android instrumentation tier (androidInstrumentedTest). The alarm table, the
|
|
26
|
+
# ── notification shade, channels, and audio routing are OS facts no JVM test can see —
|
|
27
|
+
# ── this tier is the only evidence that crosses the process boundary onto a real device.
|
|
28
|
+
androidx-test-runner = "1.6.2"
|
|
29
|
+
androidx-test-core = "1.6.1"
|
|
30
|
+
androidx-test-ext-junit = "1.2.1"
|
|
31
|
+
androidx-uiautomator = "2.3.0"
|
|
32
|
+
junit4 = "4.13.2"
|
|
25
33
|
|
|
26
34
|
[libraries]
|
|
27
35
|
# Compose Multiplatform
|
|
@@ -82,6 +90,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
|
|
|
82
90
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
|
83
91
|
android-desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version = "2.1.3" }
|
|
84
92
|
|
|
93
|
+
# Android instrumentation (androidInstrumentedTest — the on-device behavior tier)
|
|
94
|
+
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
|
|
95
|
+
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
|
|
96
|
+
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" }
|
|
97
|
+
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
|
|
98
|
+
junit4 = { module = "junit:junit", version.ref = "junit4" }
|
|
99
|
+
|
|
85
100
|
# Test
|
|
86
101
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
|
87
102
|
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
|
package/template/manifest.json
CHANGED
package/template/qa/approve.mjs
CHANGED
|
@@ -9,8 +9,25 @@
|
|
|
9
9
|
// node qa/approve.mjs --accept-defaults express lane (GENESIS-FLOW-DESIGN.md §2): approves
|
|
10
10
|
// every currently-resolvable, not-yet-approved
|
|
11
11
|
// artifact, each stamped "defaults-accepted"
|
|
12
|
-
// node qa/approve.mjs --reopen <artifact>
|
|
13
|
-
//
|
|
12
|
+
// node qa/approve.mjs --reopen <artifact> --reason "…"
|
|
13
|
+
// moves an approved artifact back to "reopened" for
|
|
14
|
+
// redesign (refuses anything not currently approved).
|
|
15
|
+
// --reason is REQUIRED — a reopen walks back a
|
|
16
|
+
// signature, and the signer must be able to read why
|
|
17
|
+
// from the ledger itself (2026-07-28 flow audit)
|
|
18
|
+
// node qa/approve.mjs --reopen-feature <name> --reason "…"
|
|
19
|
+
// one recorded change, not N reopen commands: reopens
|
|
20
|
+
// the brief + its spec + its design + every artifact
|
|
21
|
+
// the brief declares in `touches` (each only if
|
|
22
|
+
// currently approved), all under one reason
|
|
23
|
+
// node qa/approve.mjs --log the journal — every approve/reopen/accept with
|
|
24
|
+
// when, which surface, and why (newest last)
|
|
25
|
+
// node qa/approve.mjs --accept <name> the HUMAN's bookend on a feature brief
|
|
26
|
+
// (feature-brief:<name>) — refused until the feature
|
|
27
|
+
// is provenDone (every live clause cited + receipt
|
|
28
|
+
// PASS + receipt attests this tree). There is no
|
|
29
|
+
// --deliver: doneness is DERIVED, never claimed
|
|
30
|
+
// (CHANGE-FLOW-DESIGN.md §2).
|
|
14
31
|
//
|
|
15
32
|
// This file has NO logic of its own — every decision (the registry, hashing,
|
|
16
33
|
// state, the transitions) lives in qa/lib/approvals.mjs. That's deliberate: the
|
|
@@ -22,12 +39,16 @@ import path from "node:path";
|
|
|
22
39
|
import { fileURLToPath } from "node:url";
|
|
23
40
|
|
|
24
41
|
import {
|
|
42
|
+
acceptFeature,
|
|
25
43
|
approveAllDefaults,
|
|
26
44
|
approveArtifact,
|
|
27
45
|
getApprovalStatuses,
|
|
46
|
+
getFeatureBoard,
|
|
28
47
|
isPackageResolvable,
|
|
29
48
|
listGovernedArtifacts,
|
|
49
|
+
readJournal,
|
|
30
50
|
reopenArtifact,
|
|
51
|
+
reopenFeature,
|
|
31
52
|
} from "./lib/approvals.mjs";
|
|
32
53
|
|
|
33
54
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -52,22 +73,44 @@ function printStatus() {
|
|
|
52
73
|
// approvable — approval over an unresolved file set is refused.
|
|
53
74
|
const hashInfo =
|
|
54
75
|
s.status === "reopened"
|
|
55
|
-
? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})`
|
|
76
|
+
? `reopened at ${s.reopenedAt} (was approved ${shortHash(s.storedHash)})${s.reason ? ` — ${s.reason}` : ""}`
|
|
56
77
|
: s.status === "changed-since-approval"
|
|
57
78
|
? s.resolvable
|
|
58
79
|
? `approved ${shortHash(s.storedHash)} -> now ${shortHash(s.hash)}`
|
|
59
80
|
: `approved ${shortHash(s.storedHash)} -> unresolvable (${s.fileCount} of expected files resolved)`
|
|
60
81
|
: s.status === "approved"
|
|
61
|
-
?
|
|
82
|
+
? // storedHash is the signature; s.hash is the live recompute. They
|
|
83
|
+
// agree except on a legacy raw-bytes row, where printing the live
|
|
84
|
+
// value would name a hash nobody signed.
|
|
85
|
+
`${shortHash(s.storedHash) || shortHash(s.hash)}${
|
|
86
|
+
s.hashBasis === "raw-bytes" ? ", signed pre-strip — bytes unchanged since" : ""
|
|
87
|
+
}`
|
|
62
88
|
: s.resolvable
|
|
63
89
|
? `would approve at ${shortHash(s.hash)}`
|
|
64
90
|
: `unresolvable (${s.fileCount} of expected files resolved) — not approvable`;
|
|
65
91
|
const modeInfo = s.mode ? ` [${s.mode}]` : "";
|
|
66
|
-
|
|
92
|
+
// A feature brief's acceptance lives on the ledger row, never in the doc
|
|
93
|
+
// (the signed bytes must not move when the human accepts).
|
|
94
|
+
const lifecycle = s.accepted ? ` · accepted ${s.acceptedAt}` : "";
|
|
95
|
+
console.log(`${mark} ${s.id}: ${s.status} (${hashInfo})${modeInfo}${lifecycle} — ${s.label}`);
|
|
67
96
|
if (s.missing.length > 0) {
|
|
68
97
|
console.log(` missing: ${s.missing.join(", ")}`);
|
|
69
98
|
}
|
|
70
99
|
}
|
|
100
|
+
|
|
101
|
+
// Per-feature DERIVED doneness + next step — the same getFeatureBoard the
|
|
102
|
+
// console renders, so --status and the console never tell different
|
|
103
|
+
// stories. The next step names its owner: a signature HANDS OFF, it never
|
|
104
|
+
// commands — the agent drafts/builds/proves, the human signs/accepts.
|
|
105
|
+
const { features } = getFeatureBoard(ROOT);
|
|
106
|
+
if (features.length > 0) {
|
|
107
|
+
console.log("\nFeatures (doneness is derived, never claimed):");
|
|
108
|
+
for (const f of features) {
|
|
109
|
+
const mark = f.provenDone ? "✓" : "…";
|
|
110
|
+
console.log(` ${mark} ${f.name}: ${f.doneReason}${f.blockError ? ` — BLOCK ERROR: ${f.blockError}` : ""}`);
|
|
111
|
+
console.log(` next → ${f.nextStep.label}${f.nextStep.owner ? ` · ${f.nextStep.owner}` : ""}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
71
114
|
}
|
|
72
115
|
|
|
73
116
|
if (args.includes("--status")) {
|
|
@@ -102,27 +145,92 @@ if (args.includes("--accept-defaults")) {
|
|
|
102
145
|
process.exit(0);
|
|
103
146
|
}
|
|
104
147
|
|
|
148
|
+
// --reason is shared by --reopen and --reopen-feature: one plain sentence for
|
|
149
|
+
// the human who signed, recorded on the ledger row AND the journal. The library
|
|
150
|
+
// refuses without it; parsing it here just makes the usage line honest.
|
|
151
|
+
const reasonFlagIdx = args.indexOf("--reason");
|
|
152
|
+
const reason = reasonFlagIdx !== -1 ? args[reasonFlagIdx + 1] : undefined;
|
|
153
|
+
|
|
154
|
+
if (args.includes("--log")) {
|
|
155
|
+
const events = readJournal(ROOT);
|
|
156
|
+
if (events.length === 0) {
|
|
157
|
+
console.log("No journal yet (qa/approvals.log.jsonl) — it starts recording with the next approve/reopen/accept.");
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
console.log("Governance journal (oldest first):\n");
|
|
161
|
+
for (const e of events) {
|
|
162
|
+
const mark = e.verb === "approve" ? "✓" : e.verb === "reopen" ? "↺" : e.verb === "accept" ? "◆" : "·";
|
|
163
|
+
const bits = [e.via ? `via ${e.via}` : null, e.mode ? `[${e.mode}]` : null, e.feature ? `feature ${e.feature}` : null]
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.join(" · ");
|
|
166
|
+
console.log(`${mark} ${e.at} ${e.verb} ${e.artifact}${bits ? ` (${bits})` : ""}${e.reason ? ` — ${e.reason}` : ""}`);
|
|
167
|
+
}
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
105
171
|
const reopenFlagIdx = args.indexOf("--reopen");
|
|
106
172
|
if (reopenFlagIdx !== -1) {
|
|
107
173
|
refuseIfUnresolvable();
|
|
108
174
|
const artifactId = args[reopenFlagIdx + 1];
|
|
109
|
-
if (!artifactId) {
|
|
110
|
-
console.error(
|
|
175
|
+
if (!artifactId || artifactId === "--reason") {
|
|
176
|
+
console.error('usage: node qa/approve.mjs --reopen <artifact> --reason "why, in one sentence"');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const result = reopenArtifact(ROOT, artifactId, { reason, via: "cli" });
|
|
180
|
+
if (!result.ok) {
|
|
181
|
+
console.error(`error: ${result.reason}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
console.log(`↺ reopened ${result.artifact} for redesign — at ${result.reopenedAt}\n reason: ${reason.trim()}`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// One recorded change, not N reopen commands (2026-07-28 flow audit, fix 4):
|
|
189
|
+
// the brief is the container the human thinks in — reopen its whole declared
|
|
190
|
+
// set under one reason, each journal event grouped by the feature's name.
|
|
191
|
+
const reopenFeatureFlagIdx = args.indexOf("--reopen-feature");
|
|
192
|
+
if (reopenFeatureFlagIdx !== -1) {
|
|
193
|
+
refuseIfUnresolvable();
|
|
194
|
+
const name = args[reopenFeatureFlagIdx + 1];
|
|
195
|
+
if (!name || name === "--reason") {
|
|
196
|
+
console.error('usage: node qa/approve.mjs --reopen-feature <name> --reason "why, in one sentence"');
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const result = reopenFeature(ROOT, name, { reason, via: "cli" });
|
|
200
|
+
if (!result.ok) {
|
|
201
|
+
console.error(`error: ${result.reason}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
|
|
205
|
+
for (const id of result.reopened) console.log(` ↺ ${id}`);
|
|
206
|
+
for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
|
|
207
|
+
process.exit(0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The human's bookend: --accept takes the brief NAME, not the full artifact
|
|
211
|
+
// id — the docs/features/<name>.md filename is what a human knows. The library
|
|
212
|
+
// refuses until the feature is provenDone; there is no agent claim in between.
|
|
213
|
+
const acceptFlagIdx = args.indexOf("--accept");
|
|
214
|
+
if (acceptFlagIdx !== -1) {
|
|
215
|
+
refuseIfUnresolvable();
|
|
216
|
+
const name = args[acceptFlagIdx + 1];
|
|
217
|
+
if (!name) {
|
|
218
|
+
console.error("usage: node qa/approve.mjs --accept <name> (the brief's name — docs/features/<name>.md)");
|
|
111
219
|
process.exit(1);
|
|
112
220
|
}
|
|
113
|
-
const result =
|
|
221
|
+
const result = acceptFeature(ROOT, name, { via: "cli" });
|
|
114
222
|
if (!result.ok) {
|
|
115
223
|
console.error(`error: ${result.reason}`);
|
|
116
224
|
process.exit(1);
|
|
117
225
|
}
|
|
118
|
-
console.log(
|
|
226
|
+
console.log(`✓ accepted ${result.artifact} at ${result.acceptedAt} — the feature's card closes; the brief is its doc-of-record.`);
|
|
119
227
|
process.exit(0);
|
|
120
228
|
}
|
|
121
229
|
|
|
122
230
|
if (args.length === 0) {
|
|
123
231
|
const ids = listGovernedArtifacts(ROOT).map((a) => a.id);
|
|
124
232
|
console.error(
|
|
125
|
-
|
|
233
|
+
'usage: node qa/approve.mjs <artifact> | --status | --log | --accept-defaults | --reopen <artifact> --reason "…" | --reopen-feature <name> --reason "…" | --accept <name>\n' +
|
|
126
234
|
` valid artifacts: ${ids.length > 0 ? ids.join(", ") : "(none resolved in this project)"}`,
|
|
127
235
|
);
|
|
128
236
|
process.exit(1);
|
|
@@ -131,7 +239,7 @@ if (args.length === 0) {
|
|
|
131
239
|
refuseIfUnresolvable();
|
|
132
240
|
|
|
133
241
|
const artifactId = args[0];
|
|
134
|
-
const result = approveArtifact(ROOT, artifactId);
|
|
242
|
+
const result = approveArtifact(ROOT, artifactId, { via: "cli" });
|
|
135
243
|
if (!result.ok) {
|
|
136
244
|
console.error(`error: ${result.reason}`);
|
|
137
245
|
process.exit(1);
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
"required": ["schema", "profile", "verdict", "commit", "steps", "artifacts", "toolVersions", "generatedAt"],
|
|
7
7
|
"properties": {
|
|
8
8
|
"schema": { "const": "cmp-evidence/1" },
|
|
9
|
-
"profile": { "enum": ["scaffold", "local", "ci"] },
|
|
9
|
+
"profile": { "enum": ["scaffold", "local", "ci", "release"] },
|
|
10
|
+
"mode": {
|
|
11
|
+
"enum": ["full", "fast"],
|
|
12
|
+
"description": "How the lane was run. \"full\" is the done-gate. \"fast\" (verify --fast) excluded the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke): an inner-loop signal whose receipt derives no evidence rung and is REFUSED by qa/receipt-check.mjs — it can never satisfy done. Absent on receipts predating the flag — treated as full."
|
|
13
|
+
},
|
|
10
14
|
"verdict": { "enum": ["PASS", "FAIL"] },
|
|
11
15
|
"commit": {
|
|
12
16
|
"type": "object",
|
|
@@ -32,13 +36,27 @@
|
|
|
32
36
|
"required": ["name", "verdict", "durationMs"],
|
|
33
37
|
"properties": {
|
|
34
38
|
"name": { "type": "string" },
|
|
35
|
-
"verdict": {
|
|
39
|
+
"verdict": {
|
|
40
|
+
"enum": ["PASS", "FAIL", "SKIP", "CACHED"],
|
|
41
|
+
"description": "CACHED appears ONLY on mode:\"fast\" receipts: a pure-Node step's last PASS reused because its content-hashed input set is unchanged (qa/lib/step-cache.mjs). Counts as PASS for the lane verdict but stays visibly distinct — the full lane never consults the cache, so a full receipt never carries it."
|
|
42
|
+
},
|
|
36
43
|
"reason": { "type": "string" },
|
|
44
|
+
"note": { "type": "string", "description": "Honest fine print on a non-FAIL step (fast mode): which unit-test filter ran, or when a CACHED verdict was originally earned." },
|
|
37
45
|
"durationMs": { "type": "number" },
|
|
38
46
|
"details": { "type": "object" }
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
},
|
|
50
|
+
"evidenceLevel": {
|
|
51
|
+
"type": ["object", "null"],
|
|
52
|
+
"description": "The evidence ladder rung (qa/lib/evidence-level.mjs), DERIVED from which steps actually ran and PASSed — never declared. L0 scaffold / L1 desktop / L2 device / L3 release; a SKIPped step never upgrades a rung. null when the lane FAILed (a failed lane has no rung). Absent on receipts predating the ladder.",
|
|
53
|
+
"required": ["rung", "name", "satisfiedBy"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"rung": { "enum": ["L0", "L1", "L2", "L3"] },
|
|
56
|
+
"name": { "enum": ["scaffold", "desktop", "device", "release"] },
|
|
57
|
+
"satisfiedBy": { "type": "array", "items": { "type": "string" }, "description": "The PASSed step names the rung counts as its evidence, in lane order" }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
42
60
|
"artifacts": {
|
|
43
61
|
"type": "array",
|
|
44
62
|
"items": {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// affected-tests.mjs — FAST-MODE-ONLY scoping of the unit-test suite to the
|
|
2
|
+
// tests plausibly affected by the working-tree change.
|
|
3
|
+
//
|
|
4
|
+
// The full lane always runs the whole suite; this module exists so the inner
|
|
5
|
+
// loop (`verify --fast`) doesn't pay for every test on a one-file edit. Its
|
|
6
|
+
// honesty contract:
|
|
7
|
+
//
|
|
8
|
+
// - FALSE NEGATIVES ARE ACCEPTABLE HERE — AND ONLY HERE. A filtered fast
|
|
9
|
+
// run can miss a cross-feature regression; that is tolerable purely
|
|
10
|
+
// because the full, unfiltered suite runs at the checkpoint (the full
|
|
11
|
+
// lane), where done is actually decided. No other gate gets this license.
|
|
12
|
+
// - FAIL OPEN, NEVER FAIL SILENT. No git, a failed git command, an unmapped
|
|
13
|
+
// change, a broad-impact change — every uncertain case runs EVERYTHING,
|
|
14
|
+
// and the caller reports which case it was in the step's output and the
|
|
15
|
+
// receipt, so a filtered run can never be mistaken for the full suite.
|
|
16
|
+
// - The BLAST-RADIUS ESCAPE HATCH is mandatory: some paths fan out too
|
|
17
|
+
// widely to subset safely (build files rewire compilation, DI rewires
|
|
18
|
+
// object graphs, theme/tokens and shared components render into every
|
|
19
|
+
// screen, qa/ is the harness judging itself, and anything outside
|
|
20
|
+
// composeApp/src is by definition not a scoped source edit). Any one such
|
|
21
|
+
// change disables filtering for the run.
|
|
22
|
+
//
|
|
23
|
+
// Pure functions over path lists — git access is injected/separate so the
|
|
24
|
+
// engine suite can test every branch with no repo state.
|
|
25
|
+
|
|
26
|
+
import { execSync } from "node:child_process";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lane OUTPUTS, excluded from the changed-set before any classification.
|
|
31
|
+
* The receipt (qa/evidence/) and hashed artifacts (qa-artifacts/) change on
|
|
32
|
+
* every lane run by design; counting them as "changes" would make the qa/**
|
|
33
|
+
* escape hatch self-triggering forever — run N's receipt forcing run N+1 to
|
|
34
|
+
* the full suite, permanently. They cannot affect a test outcome (the same
|
|
35
|
+
* principle as inputs-hash.mjs's EXCLUDED_PREFIXES: lane outputs are not
|
|
36
|
+
* verdict inputs).
|
|
37
|
+
*/
|
|
38
|
+
export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts"];
|
|
39
|
+
|
|
40
|
+
function isLaneOutput(p) {
|
|
41
|
+
return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The mandatory blast-radius escape hatch: paths whose change fans out too
|
|
46
|
+
* widely to subset the suite safely. Returns the human-readable category when
|
|
47
|
+
* `p` is broad-impact, else null. Checked in order; the first match names the
|
|
48
|
+
* reason.
|
|
49
|
+
* @param {string} p POSIX relpath from the project root
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
export function broadImpactReason(p) {
|
|
53
|
+
if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
|
|
54
|
+
return "build files rewire compilation";
|
|
55
|
+
}
|
|
56
|
+
if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
|
|
57
|
+
if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
|
|
58
|
+
if (p.includes("presentation/components/")) return "shared components render into every screen";
|
|
59
|
+
if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
|
|
60
|
+
if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive the fast-mode unit-test filter from a list of changed paths.
|
|
66
|
+
*
|
|
67
|
+
* Mapping (deliberately simple and defensible): each changed `.kt` file under
|
|
68
|
+
* composeApp/src contributes its package's last segment — the parent
|
|
69
|
+
* directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
|
|
70
|
+
* template's package-mirrors-path conformance makes a package segment) — and
|
|
71
|
+
* the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
|
|
72
|
+
* class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
|
|
73
|
+
* the feature, which over-selects a little and under-maintains nothing.
|
|
74
|
+
*
|
|
75
|
+
* @param {string[]} changedPaths relpaths (either separator style) — tracked
|
|
76
|
+
* diffs plus untracked files, as from changedWorkingTreePaths()
|
|
77
|
+
* @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
|
|
78
|
+
* {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
|
|
79
|
+
* mode "all" ALWAYS carries the honest reason to report.
|
|
80
|
+
*/
|
|
81
|
+
export function deriveAffectedFilter(changedPaths) {
|
|
82
|
+
const paths = [...new Set((changedPaths ?? [])
|
|
83
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
84
|
+
.map((p) => p.split(path.sep).join("/")))]
|
|
85
|
+
.filter((p) => !isLaneOutput(p))
|
|
86
|
+
.sort();
|
|
87
|
+
|
|
88
|
+
if (paths.length === 0) {
|
|
89
|
+
return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const p of paths) {
|
|
93
|
+
const broad = broadImpactReason(p);
|
|
94
|
+
if (broad) {
|
|
95
|
+
return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Every remaining path is a scoped file under composeApp/src. Only .kt
|
|
100
|
+
// files map to test patterns; a change that maps to nothing (resources,
|
|
101
|
+
// manifests) falls open to the full suite below.
|
|
102
|
+
const ktPaths = paths.filter((p) => p.endsWith(".kt"));
|
|
103
|
+
const segments = new Set();
|
|
104
|
+
for (const p of ktPaths) {
|
|
105
|
+
const seg = path.posix.basename(path.posix.dirname(p));
|
|
106
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (segments.size === 0) {
|
|
110
|
+
return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
mode: "filtered",
|
|
115
|
+
patterns: [...segments].sort().map((s) => `*${s}*`),
|
|
116
|
+
sourcePaths: ktPaths,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function defaultRunGit(args, root) {
|
|
121
|
+
try {
|
|
122
|
+
return execSync(`git ${args}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The working-tree change: tracked files differing from HEAD (staged or not)
|
|
130
|
+
* plus untracked-but-not-ignored files — the same "what will this commit
|
|
131
|
+
* touch" surface inputs-hash.mjs hashes.
|
|
132
|
+
*
|
|
133
|
+
* Returns null when git is unavailable or either command fails — the caller
|
|
134
|
+
* MUST treat null as "run everything" (fail open) and say so (never fail
|
|
135
|
+
* silent).
|
|
136
|
+
*
|
|
137
|
+
* @param {string} root project root
|
|
138
|
+
* @param {(args: string, root: string) => string|null} [runGit] injectable for tests
|
|
139
|
+
* @returns {string[]|null}
|
|
140
|
+
*/
|
|
141
|
+
export function changedWorkingTreePaths(root, runGit = defaultRunGit) {
|
|
142
|
+
const diff = runGit("diff --name-only HEAD", root);
|
|
143
|
+
const untracked = runGit("ls-files --others --exclude-standard", root);
|
|
144
|
+
if (diff === null || untracked === null) return null;
|
|
145
|
+
const lines = (out) => out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
146
|
+
return [...new Set([...lines(diff), ...lines(untracked)])];
|
|
147
|
+
}
|