fix: include-path contract + TUI change-operation parity - #62
Merged
Conversation
The skill reference and two guides all showed `include: [sql/tables]` alongside `paths.sql: ./sql`, which resolves to `./sql/sql/tables` and matches nothing. `docs/getting-started/first-build.md` already documented the rule correctly, so the examples were teaching the opposite of the docs. 24 entries corrected across three files, plus an explicit note on the failure mode: a non-matching include is not an error — the build reports success, runs zero files, and exits 0, so the misconfiguration is indistinguishable from a working build until something downstream fails on a missing table. Leading `./` and trailing `/` fail the same way.
`build.include` paths are relative to `paths.sql`, so the natural-looking `sql/01_tables` expands to `sql/sql/01_tables` and matches nothing. The build then ran zero files, reported `status: success`, and exited 0 — a misconfiguration indistinguishable from a clean build until something downstream failed on a missing table. `findUnmatchedIncludePatterns` reports the entries that matched nothing; the SDK attaches them to `BatchResult.unmatchedInclude` and `run build` warns, naming each entry and the paths.sql-relative rule. Warned rather than failed: matching nothing is a settings mistake, not an execution failure, and a build that legitimately matches nothing must still succeed.
createChangeManager built its ChangeContext without `dialect`, so every consumer fell back to `?? 'postgres'`. Dialect selects the tracking table names and whether a schema is applied, so on sqlite and mysql projects the TUI queried `noorm.change` instead of `__noorm_change__`. That read fails, history swallows the failure into an empty result, and the operation then reports success over zero changes — a rewind rendering "Reverted 0 change(s) successfully!" while nothing was reverted. The SDK has always passed it, which is why the CLI was unaffected. Also subscribes to the observer `error` event app-wide. Core reports failures it recovers from through that channel, and `noorm ui` routes both logger streams to a null stream, so those failures previously reached nothing but .noorm/state/noorm.log.
The TUI renders a DRY badge when dry-run is active, but ff/next/run/ revert/rewind all called the manager with no options — so a user who toggled dry-run watched the badge light up while the changes applied for real. Same defect as the `--dry-run` CLI bug, on the interactive surface. globalModes is in each callback's dependency list because exhaustive-deps is not enabled here; without it the callback closes over the toggle state from first render and flipping it has no effect.
The CLI names them; the TUI showed a bare "Run 0 SQL files on <config>?" confirm prompt with nothing explaining why the set was empty.
Seeds the sqlite-shaped `__noorm_change__` and asserts the manager built by createChangeManager reads it. With `dialect` removed the suite fails on `no such table: noorm.lock` and an empty history, which is the shape the bug presented as in the TUI.
`loadPrivateKey` throws on an unreadable key file or insecure permissions, and three vault screens called it bare from fire-and-forget keyboard handlers. The lifecycle manager's unhandledRejection handler caught the throw and tore the app down through `app:exit`, so a corrupted key file quit the TUI with no message rather than reporting the problem. useVaultSecretKeys separately treated every failure as "no vault secrets": a decrypt error, a permissions error, and an empty vault were indistinguishable. It now reports failures that threw while staying silent when there is genuinely no vault — a project without one must behave exactly as before — and the three secret screens render it.
Drives the real screen to its confirm step, toggles the global modes the way a user does, and asserts the options reaching ChangeManager. Fails on both halves of the fix: dropping the options argument fails all four cases, and dropping globalModes from the callback deps fails the three where a mode is toggled after mount.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two reported problems, plus what auditing them turned up.
1.
build.includepaths are relative topaths.sqlThe skill reference and two guides showed
include: [sql/tables]next topaths.sql: ./sql, which expands tosql/sql/tablesand matches nothing — whiledocs/getting-started/first-build.md:103already documented the rule correctly. 24 entries across three files were teaching the opposite of the docs.It costs real time because it fails silently. Measured:
include01_tablessql/01_tablesrun buildnow names the entries that matched nothing, on both CLI and TUI:Warned rather than failed: matching nothing is a settings mistake, not an execution failure, and a legitimately-empty build must still succeed.
BatchResultgains an optionalunmatchedInclude. Leading./and trailing/fail the same way and are now documented.2. TUI changes failing silently
createChangeManagernever setdialect, so consumers fell back to?? 'postgres'. Dialect selects tracking-table names and whether a schema is applied, so on sqlite and mysql the TUI queriednoorm.changeinstead of__noorm_change__. The failed read is swallowed into an empty history, and the operation then reports success over zero changes — a rewind rendering "Reverted 0 change(s) successfully!" while reverting nothing. The SDK always passed it, which is why the CLI was unaffected.The regression test proves it: with the line removed you get
SQLiteError: no such table: noorm.lockand an empty history.Also subscribes app-wide to the observer
errorevent. Core reports recovered failures through that channel, andnoorm uiroutes both logger streams to a null stream, so they previously reached nothing but.noorm/state/noorm.log.3. Dry-run was displayed but not applied (data safety)
Found while auditing the above, and the most serious item here. The TUI renders a bold yellow DRY badge when dry-run is active, but
ff/next/run/revert/rewindall called the manager with no options — so toggling dry-run showed the badge while changes applied for real against the database. Same defect class as the--dry-runCLI bug, on the interactive surface.The fix needed two parts.
exhaustive-depsis not enabled in this repo's eslint, andglobalModeswas missing from theuseCallbackdeps — without adding it the callback closes over first-render state and the toggle still does nothing. The test mutates both halves independently and fails on each.4. Vault key failures were invisible
loadPrivateKeythrows on an unreadable key file or insecure permissions, and three vault screens called it bare from fire-and-forget keyboard handlers. The lifecycleunhandledRejectionhandler caught it and tore the app down viaapp:exit— so a corrupted key file quit the TUI with no message. Separately,useVaultSecretKeystreated every failure as "no vault secrets", making a decrypt error indistinguishable from an empty vault. It now reports failures that threw while staying silent when there is genuinely no vault.Verified: lint, typecheck,
lint:docs, build all clean. All four CI groups run locally against live pg/mysql/mssql — 2537 / 122 / 476 / 631 = 3766 pass, 0 fail. Every new regression test was proven to fail with its fix reverted, then restored.Known gaps, deliberately not addressed here:
db transfer's truncate-first control is dead (safe defaultfalse, so a missing capability rather than a live bug — wiring it means extending the transfer/import options UI); the change factory still omitssecrets/globalSecrets, so TUI-run.sql.tmplchanges get an empty secrets context (fixing it makescreateChangeManagerasync, touching every call site);requiresConfirmationis unenforced stack-wide outside three CLI commands;vault cpanddb resethave no TUI screen;InitScreenreimplementsperformProjectInitrather than sharing it; a fatal error innoorm uiexits0; and onlyChangeFFScreenhas dry-run coverage — the other four screens use the same harness but each needs its own selection interaction.