15 Commits
Author SHA1 Message Date
claude@clouddev1 44b495dfed docs: handoff-80 + docs direct-push carve-out (ADR-0059 Amendment 1)
Session handoff covering the clause-concept-hints feature (ADR-0058, #42) and
the working-method establishment (ADR-0059, #41; fixes #43/#44). Records the
docs direct-push carve-out: handoff/session docs and small docs amendments are
owner-direct-pushed to main, because a docs-only PR posts no ci/gate run
(paths-ignored) and stalls on the required check. Amends CLAUDE.md, ADR-0059
(Amendment 1), and its README index row.
2026-06-25 14:38:58 +00:00
oli 78272e2fd0 Merge pull request 'ci: gate feature branches via pull_request only (dedupe PR-push runs)' (#44) from ci/dedupe-pr-runs into main
ci / gate (push) Successful in 2m1s
ci / manifests (push) Successful in 4s
Reviewed-on: #44
2026-06-24 10:33:16 +01:00
claude@clouddev1 e1d7419a72 ci: gate feature branches via pull_request only (dedupe PR-push runs)
ci / gate (pull_request) Successful in 2m8s
ci / manifests (pull_request) Successful in 4s
A push to a branch with an open PR fired both the `push` and `pull_request`
triggers, running the gate (and manifests) twice. On Gitea those runs are
byte-identical: unlike GitHub it has no merge-preview ref, so its
`pull_request` checks out `refs/pull/N/head` — the same commit a branch push
would. Scope `push` to `main` and let `pull_request` gate feature branches:
halves CI on every PR push, loses no coverage, and is forward-compatible — if
Gitea ever adds the merge ref, these runs upgrade to testing the merged result
for free.
2026-06-24 09:28:54 +00:00
oli 6e28e58e45 Merge pull request 'feat(hint): clause-concept hints layered on F1 (issue #37)' (#42) from feat/clause-concept-hints into main
ci / gate (push) Successful in 2m9s
ci / manifests (push) Successful in 4s
Reviewed-on: #42
2026-06-23 23:24:29 +01:00
claude@clouddev1 5c51a00e72 Merge origin/feat into feat/clause-concept-hints (heal PR-branch divergence)
ci / gate (push) Successful in 2m4s
ci / manifests (push) Successful in 4s
ci / gate (pull_request) Successful in 2m4s
ci / manifests (pull_request) Successful in 3s
The Gitea "Update branch" button merged main into the PR branch server-side
(8ccecc4) — auto-resolving the README conflict by dropping the ADR-0059 index
row, and predating #43. The local merge (ec7a8ff) merged current main correctly
(both 0058 + 0059 rows; includes #43). This merge absorbs the stale remote
commit without changing content — the resulting tree is identical to the
correct local tip — so a normal (non-force) push now fast-forwards origin/feat.
2026-06-23 22:13:58 +00:00
claude@clouddev1 ec7a8ff3be Merge remote-tracking branch 'origin/main' into feat/clause-concept-hints 2026-06-23 22:01:39 +00:00
oli f06ee3b460 Merge pull request 'fix(workflow): explicit wt-rm + wt-new --no-track (worktree-helper corrections)' (#43) from fix/wt-rm-explicit-target into main
ci / gate (push) Successful in 2m8s
ci / manifests (push) Successful in 4s
Reviewed-on: #43
2026-06-23 22:42:01 +01:00
claude@clouddev1 279ef98ab8 fix(workflow): wt-new branches with --no-track (no inherited origin/main upstream)
ci / manifests (push) Successful in 4s
ci / gate (push) Successful in 2m6s
ci / gate (pull_request) Successful in 2m3s
ci / manifests (pull_request) Successful in 4s
Branching off origin/main set the new branch's upstream to origin/main, so a
bare `git push` failed with a name-mismatch and suggested the dangerous
`git push origin HEAD:main`. Add --no-track so the branch carries no upstream
until the first `git push -u origin <branch>` sets the right one — keeping the
fresh-from-origin/main base without the tracking side-effect.

Add a test-wt.sh assertion that a new branch has no upstream, and note the
behaviour + first-push form in ADR-0059 / CLAUDE.md.
2026-06-23 21:36:26 +00:00
claude@clouddev1 4570c4e1ea fix(workflow): wt-rm removes only the named worktree (was auto-sweeping merged ones)
The first wt-clean.sh removed every worktree whose branch was merged into
origin/main — but long-lived branches (website, the ci line) are merged too,
so it would have deleted their worktrees. Caught on review before any real use.

Replace the auto-sweep with an explicit `wt-rm <branch>` that removes only the
named worktree; safety comes from git's own refusals — it won't remove a dirty
worktree (no --force) and deletes the local branch only when it's merged into
origin/main, otherwise keeps it (and its unmerged commits).

Update test-wt.sh to 15 checks (incl. "unnamed worktree untouched", primary
refused, dirty refused, unmerged branch preserved) and the wt-clean references
in ADR-0059 / CLAUDE.md / the README index.
2026-06-23 21:27:41 +00:00
oli 3585cca5ea Merge pull request 'chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)' (#41) from chore/dev-workflow into main
ci / gate (push) Successful in 2m6s
ci / manifests (push) Successful in 3s
Reviewed-on: #41
2026-06-23 22:08:28 +01:00
oli 63d11402ae Merge branch 'main' into chore/dev-workflow
ci / gate (pull_request) Successful in 2m2s
ci / manifests (pull_request) Successful in 4s
2026-06-23 22:01:18 +01:00
oli 8ccecc4245 Merge branch 'main' into feat/clause-concept-hints
ci / gate (pull_request) Successful in 2m5s
ci / manifests (pull_request) Successful in 4s
2026-06-23 22:00:06 +01:00
claude@clouddev1 b5c848efcb chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)
ci / gate (push) Successful in 2m5s
ci / manifests (push) Successful in 4s
ci / gate (pull_request) Successful in 2m5s
ci / manifests (pull_request) Successful in 4s
Adopt a trackable working method now the repo is public:
- PRs onto a protected main; --no-ff merge commits; one worktree per branch.
- Reserve-first ADR numbering: scripts/adr-reserve.sh claims the next number
  atomically against main (push = compare-and-swap; ledger
  docs/adr/RESERVATIONS.log), so a number is stable from creation.
- Worktree helpers scripts/wt-new.sh + wt-clean.sh.
- Local-origin test harnesses (reserve 10/10, worktrees 7/7, shellcheck clean).

Record the decision in ADR-0059, supersede ADR-0000's placeholder-until-merge
numbering default, and add the lean CONTRIBUTING.md sections + CLAUDE.md
operational rules.
2026-06-23 20:41:42 +00:00
claude@clouddev1 6d4364666a docs(adr): finalize ADR-0058 number for clause-concept-hints
ci / gate (pull_request) Successful in 2m6s
ci / manifests (pull_request) Successful in 3s
Rename the placeholder draft to its reserved number (0058, allocated
reserve-first via the new dev-workflow flow), drop the ADR-XXXX
placeholders, and add the README index row.
2026-06-23 20:09:36 +00:00
claude@clouddev1 208da81108 feat(hint): clause-concept hints layered on F1 (issue #37)
Add a hint.concept.* tier-3 layer surfaced when the cursor sits inside a
recognized clause (referential actions, 1:n/m:n cardinality, primary key,
unique, check, foreign key), layered beneath the per-form block. New
Node::Concept grammar wrapper records clause byte-spans; concept_topic_at_cursor
resolves the innermost containing span. Examples are mode-keyed so they stay
syntax-correct in both simple and advanced mode. Draft ADR (number at merge).
2026-06-23 13:24:54 +00:00
25 changed files with 2157 additions and 36 deletions
+10 -5
View File
@@ -14,11 +14,16 @@
name: ci name: ci
on: on:
push: push:
# Branch pushes only — a tag push hits the same commit the branch push # Only `main` (the post-merge gate + canonical branch). Feature branches
# already gated, so `branches: ['**']` drops the redundant tag-triggered # are gated via `pull_request` instead. Running both on a push to a PR'd
# run (the release workflow owns tags). Pushing commits + a tag together # branch was pure duplication on Gitea: unlike GitHub it has no merge-
# still gates the commits via the branch push. # preview ref — its `pull_request` checks out `refs/pull/N/head`, the same
branches: ['**'] # commit a branch push would, so the two runs were byte-identical
# (docs.gitea.com/usage/actions/faq). Gating on `pull_request` is also
# forward-compatible: if Gitea ever adds the merge ref, these runs upgrade
# to testing the merged result for free. Tags stay unmatched (release.yaml
# owns them).
branches: [main]
# Skip the gate for changes that can't affect clippy/test — docs, markdown, # Skip the gate for changes that can't affect clippy/test — docs, markdown,
# and the website subproject (it has its own workflow, website.yaml, that # and the website subproject (it has its own workflow, website.yaml, that
# builds + publishes it). A push touching crate code *and* these still runs # builds + publishes it). A push touching crate code *and* these still runs
+5
View File
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- Pressing **F1** while the cursor sits inside a clause now adds a short
"About this clause" explanation beneath the command hint — covering
referential actions (`on delete`/`on update`), one-to-many and
many-to-many relationships, primary keys, unique and check constraints,
and foreign keys. The example shown matches whichever mode you're in.
- `help` now covers the advanced-mode SQL commands: `help select`, `help with`, - `help` now covers the advanced-mode SQL commands: `help select`, `help with`,
and the SQL forms of `insert` / `update` / `delete` / `explain` show their own and the SQL forms of `insert` / `update` / `delete` / `explain` show their own
syntax, and the full command list is grouped into "Simple-mode commands" and syntax, and the full command list is grouped into "Simple-mode commands" and
+40
View File
@@ -272,6 +272,46 @@ Key invariants in the code:
`git commit` is preceded by an explicit message proposal `git commit` is preceded by an explicit message proposal
and user approval. No AI attribution in commit messages. and user approval. No AI attribution in commit messages.
## Branch-and-PR working method (ADR-0059)
The trackable working method for the now-public repo. Full rationale +
forks in **ADR-0059**; the public-facing subset is `CONTRIBUTING.md`. The
operational rules an agent must follow:
- **Everything lands via a PR onto a protected `main`.** No direct pushes
to `main` (one exception: the ADR-reservation ledger line, D4/D5). One
logical change per branch; conventional prefixes (`feat/ fix/ docs/
chore/ refactor/ test/ ci/`) matching the commit type.
- **One worktree per branch — never switch the primary checkout.** Start a
branch with `scripts/wt-new.sh <branch>` (creates branch off `origin/main`
with `--no-track` + a sibling `<repo>-worktree-<segment>` worktree; first
push is `git push -u origin <branch>`); remove a finished one
with `scripts/wt-rm.sh <branch>` (removes only the *named* worktree — it
never scans or auto-removes, so long-lived branches like `website`/`ci`
are never at risk). This prevents the same-directory clobber hazard.
- **Reserve ADR numbers up front** the moment an ADR is known to be needed
(branch start *or* mid-branch): `scripts/adr-reserve.sh <slug> "<title>"`
atomically claims the next number against `main` (ledger
`docs/adr/RESERVATIONS.log`; push = compare-and-swap, retried). The
number is stable from creation — cite it freely in commits/cross-refs.
**The human runs this script** (its push must originate from them);
agents write/test it but don't run its pushing path. Then create
`docs/adr/<NNNN>-<slug>.md` + its README index row as normal branch work.
- **Merge with `--no-ff` merge commits; never rebase or squash** (append-
only history). Push and merge are **human steps** — agents prepare
branches, edits, PR text, and commit-message proposals, but never push
or merge.
- **Paste the `/runda` / DA review into the PR** so the public repo carries
the audit trail.
- **Handoff / session docs are owner-direct-pushed to `main`, not PR'd** (a
deliberate extension of the reservation-ledger carve-out). Reason: a
docs-only change is `paths-ignore`d by `ci.yaml`, so it posts no `ci / gate`
run — and branch protection *requires* `ci / gate*`, so a docs-only PR
stalls forever on a required check that never arrives. Direct-push (owner
is whitelisted, docs paths burn no CI) sidesteps that. The same applies to
small docs amendments like this one. If docs-via-PR is ever wanted, first
add a "skipped → success" gate shim (or drop the docs paths-ignore).
## Issue tracking — Gitea via `tea` ## Issue tracking — Gitea via `tea`
Extends (does not replace) the generic Gitea/`tea` safety rules in Extends (does not replace) the generic Gitea/`tea` safety rules in
+61
View File
@@ -7,6 +7,67 @@ open pull requests there. It's approaching its first public release, so
the most useful contributions right now are bug reports and rough edges the most useful contributions right now are bug reports and rough edges
you hit while learning. you hit while learning.
## Getting set up
The toolchain is pinned with a Nix flake, so dev and CI share one Rust
version. With [Nix](https://nixos.org/download) (flakes enabled):
```sh
nix develop # a shell with the pinned toolchain
```
Run everything through `nix develop -c …` so you match CI exactly.
## The checks your change must pass
CI runs these on every push and PR; run them locally first, and **check the
exit code** (a piped `… | tail` can hide a failure):
```sh
nix develop -c cargo fmt --check
nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
```
All three must be green, with no skipped tests. New behaviour needs tests —
the suite runs in tiers from unit up to a PTY-driven end-to-end harness.
## Branches and pull requests
- **`main` is protected** and always green; it isn't pushed to directly.
Every change lands through a pull request.
- **Branch off `main`**, one logical change per branch, named with a
conventional prefix: `feat/…`, `fix/…`, `docs/…`, `chore/…`,
`refactor/…`, `test/…`, `ci/…`.
- **Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/)
(`feat:`, `fix:`, `docs:` …) and reference the issue (`… (#123)` /
`Closes #123`).
- **Open a PR against `main`.** The CI gate must pass before it can merge;
PRs land as merge commits — history is append-only, so please don't
force-push or rebase shared branches.
- Keep one PR to one concern; don't fold unrelated changes together.
## User-facing text
Two rules bind anything a user can see (errors, help, notes):
- **Don't name the database engine** — say "the database" / "the engine".
- **Don't say "DSL"** — say "simple mode" / "advanced mode".
## Significant changes get a decision record
Architectural or otherwise consequential changes are recorded as ADRs in
[`docs/adr/`](docs/adr/) — start with [`docs/adr/README.md`](docs/adr/README.md).
If your change touches a decided area, read the relevant ADR first; if it
would change a decision, propose a new ADR rather than quietly diverging.
Opening an issue to discuss a substantial change before building it is
always welcome.
## Code style
Match the surrounding code — its naming, comment density, and idioms. The
Clippy nursery lints are enabled and must pass clean.
## License of contributions ## License of contributions
Unless you explicitly state otherwise, any contribution you intentionally Unless you explicitly state otherwise, any contribution you intentionally
+16 -11
View File
@@ -45,18 +45,23 @@ ADR numbers are a single global sequence, so two branches can each grab
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
renumbering the former to ADR-0044.) To prevent it: renumbering the former to ADR-0044.) To prevent it:
**Assign an ADR's number at merge-to-`main`, not at creation.** While the **Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
work lives on a non-`main` branch, draft the ADR under a placeholder — an which superseded the earlier placeholder-until-merge default). The moment
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that you know an ADR is needed — at branch start or mid-branch — run
way from any plan or notes. Give it the next free number only when the `scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
branch merges to `main`, renaming the file and updating its references in free number against `main` (the remote ref is the registry; a `git push`
the same step. is the compare-and-swap, retried on contention) and records it in the
append-only ledger `docs/adr/RESERVATIONS.log`. The number is then **stable
from creation**, so it is safe to cite in commit messages (immutable under
the no-rewrite rule), in other ADRs, and in the PR from the first commit.
Create the ADR as `docs/adr/<NNNN>-<slug>.md` and add its README index row
as part of the branch's normal work.
A number is "taken" only once it appears in `main`'s `docs/adr/README.md`, A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
which is the single source of truth for the next free number — never `main`; the script reads both to compute the next free number — never
compute "next" from a feature branch. A branch that genuinely needs a real compute "next" by hand from a feature branch. The full rationale (why
number up front may instead reserve one by landing a stub index entry on reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
`main` first, but placeholder-until-merge is the default. is in **ADR-0059**.
### Subproject ADR namespaces ### Subproject ADR namespaces
+462
View File
@@ -0,0 +1,462 @@
# ADR-0058: Clause-concept hints — a `hint.concept.*` layer surfaced by cursor position (issue #37)
## Status
Accepted — **implemented 2026-06-23** on branch `feat/clause-concept-hints`
(pending merge). Number **0058**, reserved up front via the reserve-first
flow (ADR-0059) rather than the older placeholder-until-merge default —
this ADR is the first to carry a number stable from creation. Extends
**ADR-0053** (contextual `hint`, D3/D4) additively; does not supersede it.
Closes the deferred extension tracked as Gitea issue **#37**.
Implementation: `Node::Concept { topic, inner }` (`dsl/grammar/mod.rs`) +
the span-recording driver arm (`dsl/walker/driver.rs`) +
`WalkContext::concept_spans` / `ConceptSpan` (`dsl/walker/context.rs`);
the `concept_topic_at_cursor` resolver (`dsl/walker/mod.rs`); 10
`Node::Concept` wrappers across `shared.rs` / `ddl.rs` /
`sql_create_table.rs` (one shared `REFERENTIAL_CLAUSES`, two extracted
cardinality sub-`Seq`s); the mode-keyed `emit_tier3_block` +
`note_hint_for_input` layering (`app.rs`); the seven `hint.concept.*`
blocks + `hint.block.concept_heading` (`strings/en-US.yaml`) with
`keys.rs` declarations; and the test set (14 resolver unit tests, 3
comprehensiveness gates incl. the recursive `Node::Concept` visitor, 3 F1
integration tests, the `hint_block_with_concept` snapshot). Suite green
across all tiers; `fmt`/`clippy` clean.
Revised after a `/runda` review (2026-06-22) that grounded the mechanism
against the code and caught two design gaps: the clause mapping (D4)
covered only the advanced-SQL constraint grammar and missed the
simple-mode constraint suffix (ADR-0029); and the single-`example`
content model collided with ADR-0053 D6's mode-correct-example rule for
topics reachable in both modes. Both are resolved here — D4 now maps both
grammars, and D5 adopts **mode-keyed examples** (user decision,
2026-06-22).
References ADR-0053 (the three-tier `hint` model + the `hint.cmd.*` /
`hint.err.*` corpus), ADR-0024 (the unified grammar/walker, `HintMode`-
per-node, `MatchedPath` spans), ADR-0022 (ambient tier-2 typing
assistance), ADR-0013/0045 (the relationship + `m:n` grammar), ADR-0035
(advanced-mode `CREATE TABLE` constraint grammar).
## Context
ADR-0053 delivers tier-3 teaching hints at **per-form** granularity: one
`hint.cmd.<form>` block (`what` / `example` / `concept`) per command form,
surfaced by F1 on the live input. Position-awareness *within* a form is
owned by tier-2 (the ambient candidate list / slot prose, ADR-0022).
During ADR-0053 Phase B we identified a third teaching point the per-form
model does not serve: **concepts that live inside a clause**, where a
learner may want teaching deeper than tier-2's candidate list but narrower
than the whole-form block. The canonical examples are the most
load-bearing relational concepts in the tool:
- the **referential actions** clause — `… on delete ⟨cascade | set null |
restrict | no action⟩` (and `on update`);
- the **cardinality** marker — `1:n` (one parent, many children) and
`m:n` (many-to-many, auto-junction per ADR-0045);
- the **primary-key** declaration — `with pk(…)` in simple mode and the
`primary key` constraint in `create table (…)`;
- the **column-constraint slots** in `create table (…)` — `unique`,
`check (…)`, `foreign key` / `references`.
A student parked in one of these clauses is, by definition, looking at the
exact relational concept that clause embodies. ADR-0053 explicitly
deferred this ("clause-concept hints", issue #37) and recorded that the
per-form keying does **not** lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
This ADR is that follow-up. It is **pedagogy-led, not metrics-led** — we
do not expect to accumulate usage statistics about which clauses learners
pause at, so the clause set is chosen by relational-teaching value, not
data.
### Two user decisions taken up front (2026-06-22)
The issue left two scope questions open. Both were decided by the user
before design:
1. **Detection precision: "anywhere inside the clause."** The hint fires
whenever the cursor sits *within* a recognized clause's span —
including parked inside already-typed text (e.g. `add 1:n
relation|ship …`) — not only at the slot boundary where the next token
is about to be typed.
2. **Clause scope: all four families** — referential actions, the
create-table constraint slots, `with pk`, and `1:n` / `m:n`
cardinality.
### Why the existing `HintMode`/`pending_hint_mode` mechanism is not enough
Tier-2 slot prose is driven by `Node::Hinted { mode, inner }`: entering
the node sets `ctx.pending_hint_mode`, and the resolver reads it at the
end of the cursor-trimmed walk (`hint_resolution_at_input_in_mode`,
`src/dsl/walker/mod.rs:107`). Crucially, the driver **clears
`pending_hint_mode` on every successful match** (`src/dsl/walker/
driver.rs:154`): it records only "the Hinted slot the cursor is *about to
fill*", and the moment the slot's content matches, the signal is gone.
That is exactly the *boundary* semantics tier-2 wants — and exactly the
opposite of "anywhere inside the clause". The instant the learner finishes
typing `cascade`, `pending_hint_mode` clears, so a cursor parked on the
finished `on delete cascade` would see nothing. Decision (1) above
therefore needs a **span-based** mechanism that survives the clause being
fully matched, not the single-slot `pending_hint_mode`.
## Decision
### D1 — A span-recording grammar wrapper, `Node::Concept`
Add a new grammar combinator, sibling to `Node::Hinted`:
```rust
Node::Concept { topic: &'static str, inner: &'static Node }
```
It is a pass-through for matching (it walks `inner` and returns `inner`'s
result verbatim — it never changes what parses), but as a side effect the
driver records the **byte span the node covered** into a new context
field:
```rust
// WalkContext
pub concept_spans: Vec<ConceptSpan>, // { topic: &'static str, start: usize, end: usize }
```
- `start` = the first non-whitespace byte the node began matching at
(post `skip_whitespace`, matching the driver's existing convention).
- `end` = the byte position the node's walk reached — the end of the
matched span on a full match, or the stop position on an incomplete /
failed inner. This makes the span the real extent of the clause as typed
so far.
Unlike `pending_hint_mode`, `concept_spans` is **append-only across the
whole walk** and is never cleared on match — so a fully-typed clause keeps
its span, satisfying decision (1).
**Precise push rule** (grounded in `NodeWalkResult`,
`src/dsl/walker/driver.rs:97`): walk `inner`, then push `{ topic, start,
end }` iff the inner result is one of `Matched { end }` (end = `end`),
`Incomplete { position }`, or `Failed { position }` (end = `position`) —
i.e. the node **committed** (consumed at least its first token, or ran out
mid-clause). On `NoMatch { .. }` (the node never engaged — e.g. a `Choice`
tried this branch and it didn't apply) push **nothing**. This is the
`NodeWalkResult`-level analogue of the driver's existing
`pending_hint_mode` clear-on-match leak-avoidance, and it means a failed
`Choice` branch leaves no stale span.
`Node::Concept` is **orthogonal to `Node::Hinted`** — a clause that wants
both a tier-2 slot prose *and* a tier-3 concept span nests them (`Concept{
… inner: Hinted{ … } }`). Neither changes the parse.
### D2 — Cursor → topic resolution: innermost containing span
A new resolver:
```rust
pub fn concept_topic_at_cursor(
input: &str, cursor: usize,
schema: Option<&SchemaCache>, mode: Mode,
) -> Option<&'static str>
```
walks the **full** input buffer with `WalkBound::EndOfInput` (the dormant
`WalkBound::Position` path is *not* used — see D3 rationale), collects
`ctx.concept_spans`, and returns the `topic` of the **innermost span
containing `cursor`**. "Innermost" = the containing span with the latest
`start` (equivalently, the narrowest), so when a `references` clause
(constraint slot) wraps a referential-actions clause, a cursor on `on
delete` resolves to the more specific `referential_actions`, while a
cursor on `references Parent` resolves to `foreign_key`.
Containment is inclusive of `start` and `end` (`start <= cursor <= end`),
so the boundary case — cursor *at* the point of entering the clause —
also resolves, meaning this subsumes the simpler boundary detection for
free.
The `cursor` passed in is the one **already computed by `feedback_view()`**
(`src/app.rs:3204` returns `(view, cursor, _off)`), which strips the `:`
one-shot sigil and adjusts the cursor offset to index into the stripped
`view`. The concept resolver walks that same `view`, so the `:`-strip is
handled with no extra offset arithmetic.
### D3 — Why full-buffer span-containment, not `WalkBound::Position`
`WalkBound::Position(cursor)` (defined, `#[allow(dead_code)]`,
`src/dsl/walker/outcome.rs:26`) slices the source at the cursor and walks
the prefix. Two reasons it is the wrong tool here:
- **It discards text after the cursor.** A cursor parked early in a
complete command (`add 1:n| relationship from … on delete cascade`)
would truncate to `add 1:` and lose the parsed structure that tells us
we are inside a well-formed relationship command. Full-buffer walking
keeps the whole parse; the cursor is used *only* for span containment.
- **It still rides `pending_hint_mode`'s clear-on-match**, which D1
already establishes is unsuitable for inside-clause detection.
Span-recording over a full `EndOfInput` walk needs no new walk-bound
behaviour and leaves the dormant `Position` path untouched.
### D4 — The clause set and their topics (both grammars)
**Seven** `hint.concept.<topic>` blocks, placed via `Node::Concept`
wrappers across **both** the simple-mode (`grammar/ddl.rs`, ADR-0029) and
advanced-mode (`grammar/sql_create_table.rs`, ADR-0035) grammars. The
codebase has **two distinct constraint grammars** — simple-mode `create
table` column constraints (`COLUMN_CONSTRAINT_SUFFIX`, `ddl.rs:1082`:
`not null` / `unique` / `default` / `check`; PK is `with pk`, FK is a
relationship) and the advanced-SQL constraint set (`COL_CONSTRAINT_CHOICES`,
`sql_create_table.rs`: adds `primary key` / `references`). A topic
reachable in both modes is wrapped in **both** grammars (or once, if the
node is physically shared — e.g. `REFERENTIAL_CLAUSES` in `shared.rs` is
reused by the relationship command *and* the advanced `references` clause).
| Topic | Modes | Grammar node(s) to wrap |
|---|---|---|
| `referential_actions` | both | `REFERENTIAL_CLAUSES` (`shared.rs` — one wrapper, reused in both) |
| `cardinality_one_to_many` | simple only | a new `CARDINALITY_1N` sub-`Seq` extracted from `ADD_RELATIONSHIP_NODES` (`ddl.rs:479`) |
| `cardinality_many_to_many` | simple only | a new `CARDINALITY_MN` sub-`Seq` extracted from `CREATE_M2N` head (`ddl.rs`) |
| `primary_key` | both | `WITH_PK` (`ddl.rs`, simple) + the `primary key` constraint node (`sql_create_table.rs`, advanced) |
| `unique` | both | the `unique` constraint node in `ddl.rs` (`UNIQUE_CONSTRAINT`, `:1051`) + in `sql_create_table.rs` |
| `check` | both | the `check (…)` constraint node in `ddl.rs` (`:1064`) + in `sql_create_table.rs` |
| `foreign_key` | advanced only | `REFERENCES_CLAUSE` (`sql_create_table.rs:206`) |
If a `unique` / `check` node turns out to be physically shared between the
two grammars (as `REFERENTIAL_CLAUSES` is), one wrapper suffices — the
mode-keyed example (D5) is selected by the **live mode** at F1 time, not by
which grammar node matched, so wrapper-sharing never affects correctness.
The exact wrapper count is pinned during implementation; the *topic* set is
the seven above.
**Nesting is intentional.** `foreign_key`'s `REFERENCES_CLAUSE` contains
`referential_actions`' `REFERENTIAL_CLAUSES`, so a cursor on `references
Parent` resolves to `foreign_key` and a cursor on the inner `on delete`
resolves to `referential_actions` — D2's innermost-wins handles it.
**Deliberate omissions** (faithful to issue #37's explicit list of
"primary key | unique | check | foreign key"): the **`not null`** and
**`default`** column constraints get **no** concept block in v1. They are
mechanical rather than the headline relational concepts #37 targets;
adding them later is purely additive (a wrapper + a block + a coverage
entry).
Cardinality is **split** (`_one_to_many` vs `_many_to_many`) rather than a
single "cardinality" block: the two are pedagogically distinct — `m:n`
auto-creates a junction table (ADR-0045) — the cursor is always
unambiguously in one or the other, and both are **simple-mode-only**
(`1:n`/`m:n` are app syntax, not SQL), so each carries a single plain
example with no mode conflict.
`referential_actions` is one block covering both `on delete` and `on
update` and all four actions (the action set is the concept; the block
explains the choice rather than each action in isolation).
### D5 — The content model: `what` / `example` / `concept`, with mode-keyed examples
Each `hint.concept.<topic>` block keeps the ADR-0053 D3 three-part shape —
`what` / `example` / `concept`. The one extension is the **example**: to
honour ADR-0053 D6's "mode-correct example, no cross-mode sharing" rule
for topics reachable in both modes (the `/runda` finding), a both-mode
topic carries **two** examples keyed by mode; a single-mode topic carries
one plain example.
```yaml
# both-mode topic — mode-keyed example
hint.concept.referential_actions:
what: "Decide what happens to child rows when the parent they point at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict). `cascade`
deletes the children with the parent; `set null` keeps them but clears
the link. The action encodes a real rule about your data."
# single-mode topic — one plain example
hint.concept.cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "‘1:n’ is one parent, many children. The child table holds the
foreign key column pointing back at the parent — that single shared
value is what groups a parent's children together."
```
`what`/`example` are always present; `concept` is the teaching payload
(it is what makes this tier-3). The both-mode topics are
`referential_actions`, `primary_key`, `unique`, `check`; the single-mode
(simple-only) topics are `cardinality_one_to_many`,
`cardinality_many_to_many`; `foreign_key` is single-mode (advanced-only).
The blocks live under the existing `hint:` namespace in
`src/friendly/strings/en-US.yaml`, alongside `hint.cmd.*` / `hint.err.*`,
and every leaf key is declared in
`src/friendly/keys.rs::KEYS_AND_PLACEHOLDERS` (the validator requires
exhaustive declaration — no prefix exemption for the `hint.*` families).
For both-mode topics that means `hint.concept.<topic>.example.simple` +
`.example.advanced`; for single-mode topics, `hint.concept.<topic>.example`.
**Authoring constraints** (project copy rules — see `CLAUDE.md`): concept
text is user-facing, so it must **never name the database engine** and
must **not say "DSL"** — use "simple mode" / "advanced mode". The `concept`
prose teaches the relational idea in plain language; examples are
copyable, mode-correct lines.
### D6 — Rendering: the concept block layers under the form block
This is an **additive** F1 surface. When F1 fires on live input
(`note_hint_for_input`, `src/app.rs:3200`):
1. Resolve and emit the per-form `hint.cmd.<form>` block exactly as today
(unchanged).
2. **Then** call `concept_topic_at_cursor(buffer, cursor, …)`. If it
yields a topic and `hint.concept.<topic>.what` exists, emit a second
tier-3 block beneath the form block.
The two blocks are visually distinguished by their **heading**: the form
block keeps the existing `hint.block.heading` ("Hint"); the concept block
carries a distinct sub-heading from a new `hint.block.concept_heading`
(e.g. "About this clause") so the learner sees the form-level help *and*
the clause-level concept without confusing the two.
`emit_tier3_block` gains **two** small parameters: the heading key, and an
optional mode used to resolve the example. Its example lookup becomes:
prefer `<stem>.example.<mode>` (where `<mode>` is `simple`/`advanced` from
the live `effective_mode`), else fall back to a plain `<stem>.example`.
This keeps a single renderer for all three tier-3 families — `hint.cmd.*`
and `hint.err.*` keep a plain `.example` (lookup falls straight through),
while `hint.concept.*` both-mode topics resolve the mode-keyed variant.
The mode-correct-example contract of ADR-0053 D6 is thereby honoured by
the *content keying* + this lookup, not by duplicating the renderer.
The concept block is **never shown alone** and **never replaces** the form
block — consistent with the issue ("layered on top of … does not
replace"). If the per-form resolution fails (rare; falls back to tier-2),
the concept layer is skipped too — F1 keeps a single coherent behaviour.
The submitted `hint` command (last-error route, D2 of ADR-0053) is
**unchanged** — it has no cursor, so concept hints do not apply there.
The rendered shape of a form-block-plus-concept-block is locked by a new
`insta` snapshot (`hint_block_with_concept`).
### D7 — Comprehensiveness, validation, fallback
- **Validation (build/test gate):** every `hint.concept.<topic>` leaf key
is declared in `keys.rs`; the existing `keys_validate_against_catalog`
test fails on a typo or an undeclared key, in either direction.
- **Comprehensiveness coverage test:** extend the ADR-0053
comprehensiveness test with a `CONCEPT_TOPICS` table — each entry a
`(topic, ModeCoverage)` where `ModeCoverage` is `Both` or
`Single` — asserting: (a) every topic resolves to `hint.concept.<topic>`
with a `.what` (and, per its `ModeCoverage`, either `.example.simple`
+`.example.advanced` for `Both`, or a plain `.example` for `Single`);
and (b) every `Node::Concept` wrapper reachable from the REGISTRY
carries a topic in the table. (b) needs a **recursive `Node`-tree
visitor** — `Node` is a recursive enum (`Seq(&[Node])` / `Choice` /
`Optional` / `Repeated` / `Hinted` / `Concept`), so the test walks each
REGISTRY shape collecting `Node::Concept` topics. A wrapper added
without content (or a topic with a wrapper missing) fails the test —
this is what makes the clause set enforceable rather than aspirational,
since `keys.rs` only checks that *referenced* keys resolve.
- **Graceful degrade:** `emit_tier3_block` already returns `false` when a
`.what` key is absent, so a missing concept block degrades to "form
block only" — never a blank or an error. The validation above means
that path is a safety net, not the plan.
## Forks
- **Detection precision = anywhere inside the clause** (user, 2026-06-22)
rather than slot-boundary-only. Drives the span-recording mechanism
(D1) over the simpler `pending_hint_mode` reuse.
- **Clause scope = all four families** (user, 2026-06-22) rather than the
issue's "start with referential actions + constraint slots" subset.
- **Mechanism = full-buffer span-containment, not `WalkBound::Position`**
(D3) — keeps the whole parse and leaves the dormant cursor-bound path
untouched. *Engineering choice; rationale in D3.*
- **Mode-keyed examples for both-mode topics** (user, 2026-06-22,
resolving the `/runda` finding) rather than splitting into
`_simple`/`_advanced` blocks — one concept block per topic, two examples
selected by live mode (D5/D6). Honours ADR-0053 D6's mode-correct-example
rule without duplicating concept prose.
- **Cardinality split into two blocks** (D4) rather than one — the `1:n`
and `m:n` concepts diverge (junction table). *Pedagogy choice.*
- **`primary_key` shared between `with pk` and the constraint slot** (D4)
— one concept, two syntaxes (a mode-keyed example per D5). *Content
choice.*
- **Distinct sub-heading for the concept block** (D6) rather than merging
into one block or an undifferentiated second "Hint" — keeps form-level
and clause-level guidance legible. *UX choice.*
## Consequences
- **A fourth contextual surface exists** within tier-3: clause-level
teaching, deeper than tier-2's candidate list, narrower than the
per-form block, surfaced by cursor position on F1.
- **One new grammar node** (`Node::Concept`), **one new `WalkContext`
field** (`concept_spans`), **one new resolver**
(`concept_topic_at_cursor`), **two new renderer parameters** (heading
key + example mode), and **~10 `Node::Concept` wrappers** across both
constraint grammars (exact count pinned at implementation per D4's
shared-node note), plus **two extracted cardinality sub-`Seq` nodes**.
No change to what parses; no change to tier-2; no change to the
submitted `hint` path.
- **A new content sub-namespace** — `hint.concept.*` (seven topics; the
four both-mode topics carry two examples each) — enters the catalogue
under `hint:`, validated by `keys.rs` and the comprehensiveness test.
Ongoing surface: a future clause worth teaching ships its
`Node::Concept` wrapper + block + coverage entry together (a checklist
item for feature ADRs that add clauses).
- **CHANGELOG:** a new `[Unreleased] → Added` bullet (user-facing F1
behaviour), phrased under the copy rules (no engine name; "simple
mode"/"advanced mode", not "DSL").
- **Testing:** Tier-1 unit tests for `concept_topic_at_cursor` across each
clause (cursor at boundary, cursor inside typed text, cursor outside →
`None`, nested `references`→`on delete` innermost-wins, both modes);
the F1 layering logic (form block always; concept block appended iff a
topic resolves; concept skipped when the form block falls back); the
`:` one-shot strip carried through (it must still resolve advanced
forms); Tier-2 `insta` snapshot `hint_block_with_concept`; Tier-3
integration tests (type a partial relationship command, move the cursor
into `on delete`, F1 → form block + referential-actions concept block,
**buffer / cursor / completion memo untouched**); the comprehensiveness
+ `keys.rs` validation tests (D7).
## Out of scope
- **Concept hints on the submitted `hint` command** — OOS: no cursor; the
last-error route is unchanged (D6).
- **A concept block for every clause in the grammar** — OOS for v1: the
eight wrappers cover the highest-value relational concepts (D4). More
can be added additively later, gated by the comprehensiveness test.
- **Surfacing concept text in the always-on tier-2 ambient panel** — OOS
(rejected): tier-2 stays terse by design (ADR-0022); the concept layer
is on-demand via F1, consistent with all of tier-3.
- **A `hint.concept.<topic>` reachable by an explicit argument** (e.g.
`hint cascade`) — OOS: ADR-0053 D1 keeps `hint` argument-free; `help
<topic>` owns explicit reference lookup.
- **Localisation beyond `en-US`** — OOS (deferred): the catalogue is i18n-
structured (ADR-0019) but English-only for v1 (requirements X2).
## Content inventory (implementation tracking)
Seven `hint.concept.<topic>` blocks, authored to the ADR-0053 D7 voice
(`what` / `concept` always; example(s) per mode coverage):
- [x] `referential_actions` *(both — 2 examples)* — on delete/update;
cascade / set null / restrict / no action.
- [x] `cardinality_one_to_many` *(simple — 1)* — one parent owns many
children; the FK lives on the child.
- [x] `cardinality_many_to_many` *(simple — 1)* — junction table; ADR-0045
auto-creation.
- [x] `primary_key` *(both — 2)* — uniquely identifies a row; shared by
`with pk` (simple) and the `create table` constraint (advanced).
- [x] `unique` *(both — 2)* — no two rows share the value; distinct from a
PK.
- [x] `check` *(both — 2)* — a per-row rule enforced on write.
- [x] `foreign_key` *(advanced — 1)* — a child column promising to point
at a real parent.
Implementation artifacts: the `Node::Concept` wrappers (D4 table, ~10
across both grammars + the two extracted cardinality sub-`Seq`s); the
`CONCEPT_TOPICS` coverage table (topic + `ModeCoverage`); the `keys.rs`
declarations (mode-keyed example leaves for the four both-mode topics);
the `hint_block_with_concept` snapshot; the CHANGELOG bullet.
+215
View File
@@ -0,0 +1,215 @@
# ADR-0059: Branch-and-PR working method — worktrees, merge commits, and reserve-first ADR numbering
## Status
Accepted — **2026-06-23**, on branch `chore/dev-workflow` (pending merge).
Number **0059**, the second ADR reserved via the reserve-first flow this
ADR itself defines (the first was ADR-0058). Updates the **Numbering
discipline** section of **ADR-0000** (supersedes its placeholder-until-merge
default with reserve-first). Does not affect the subproject ADR namespaces
(`docs/website/adr/`, `docs/ci/adr/`).
## Context
The repository is now public, and feature work is moving from
commit-straight-to-`main` toward branches. A public, multi-branch project
needs a written, trackable working method instead of ad-hoc habits.
The sharp edge that forced the issue is **ADR numbering**. ADR numbers are
a single global integer sequence; two branches that each grab "the next
number" collide on merge (this happened, and was the original motivation
for ADR-0000's numbering rules). The deeper problem is fundamental:
> A contiguous integer requires a single allocator. Plain git branches have
> **no shared allocator** until they reconverge at merge — so any number
> claimed independently on a branch is speculative and can collide.
Earlier work papered over this for two long-lived subprojects by giving
them their **own namespaces** (`ADR-website-NNN`, `ADR-ci-NNN`), which is
right for a nameable body of decisions but doesn't generalise to every
feature branch. ADR-0000 also wrote a **placeholder-until-merge** default
("assign the number at merge"), but it had never actually been exercised,
and exercising it revealed two fatal frictions:
1. **ADR numbers are referenced from the first commit onward** — in commit
messages (which are immutable under our no-rewrite rule, so a wrong
number there is wrong *forever*), in other ADRs, in the PR. A number
that only crystallises at merge leaves every earlier reference dangling.
2. ADRs are *not* low-collision in practice: they're born precisely on the
**long-lived feature branches most likely to run in parallel**, so
"rare collision" is not a safe assumption.
So the requirement is a number that is **stable from creation, contiguous,
and collision-free** — which is only possible by acquiring a lock *up
front*. The insight that unlocks it: **`main` is the registry and an atomic
`git push` is the lock** — git serialises ref updates, so a push is a
compare-and-swap. We just have to acquire it *before* referencing the
number, not at merge.
This is a **genuine-choice** area (reasonable practitioners run GitHub
Flow, Git Flow, trunk-based, etc.); the decisions below adapt mainstream
practice to this repo's constraints (append-only history, self-hosted
Gitea, solo-with-subagents development) rather than claiming a canonical
default.
## Decision
### D1 — Everything lands via a PR onto a protected `main`
`main` is always-green and protected; no direct pushes (one narrow
exception — D4). Every change is a branch + pull request onto `main`. One
logical change per branch/PR. Branch names carry a conventional prefix
(`feat/ fix/ docs/ chore/ refactor/ test/ ci/`) aligned with the commit
type. PRs reference their issue (`Closes #N`); the review we already
perform (the Devil's-Advocate / `/runda` pass) is pasted into the PR so the
public repo carries the audit trail.
### D2 — One worktree per branch (never switch the primary checkout)
Each branch is developed in its **own git worktree**, a sibling of the
primary checkout named `<repo>-worktree-<last-segment-of-branch>` (the
convention already in use for the `website` and `ci` worktrees). Switching
the primary checkout's branch while another shell sits in the same
directory is how work gets clobbered — worktrees make that impossible.
Helpers:
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
`origin/main` (with `--no-track`, so it doesn't inherit `origin/main` as
upstream — otherwise a bare `git push` errors on the name mismatch and
suggests the dangerous `HEAD:main`), and add the sibling worktree; prints
its path. First push is `git push -u origin <branch>`.
- **`scripts/wt-rm.sh <branch>`** — remove the worktree for the **named**
branch, and nothing else. It does **not** scan for "merged" worktrees to
auto-remove: long-lived branches (`website`, the `ci` line) are merged
into `main` too, so a merged-detection sweep would wrongly delete them
(caught immediately on review, before any real use — the initial design
was an auto-sweep `wt-clean`). Naming the target is the safety. Git's own
refusals do the rest: it won't remove a dirty worktree (no `--force`),
and it deletes the local branch only if it is merged into `origin/main`
(otherwise the worktree goes but the branch + its unmerged commits stay).
Stale bookkeeping for a hand-deleted directory is the built-in
`git worktree prune`'s job.
### D3 — Merge commits (`--no-ff`); never rebase or squash
Branches land with a **merge commit** (`--no-ff`). This is the only
strategy consistent with the project's append-only / no-history-rewrite
rule: squash and rebase-merge both rewrite history. Fast-forward-only is
out because it would require rebasing branches onto `main`. History gains
merge bubbles; that is the accepted cost of never rewriting.
### D4 — Reserve-first ADR numbering via `adr-reserve.sh`
ADR numbers are **reserved up front, the moment you know an ADR is
needed** — which may be mid-branch or late, so reservation is decoupled
from branch creation. `scripts/adr-reserve.sh <slug> [title]`:
1. reads an append-only ledger (`docs/adr/RESERVATIONS.log`) plus the
existing `NNNN-*.md` ADRs on `main`; next = highest + 1;
2. appends `<NNNN> <slug> <date> <title>`, commits, and **pushes to
`main`**;
3. if the push is rejected because `main` moved (someone reserved first),
it re-fetches, recomputes, and retries — the push is the compare-and-swap,
so allocations are unique by construction, not by luck.
It runs from any branch/worktree without touching the working tree (it does
the allocation in a throwaway worktree on a detached `origin/main`), and on
a *non-race* rejection (e.g. protection misconfigured) it **aborts loudly**
rather than spinning. The number is therefore **stable from the moment it
returns** — safe to cite in immutable commit messages and other ADRs from
the first commit, which is what the references-problem (Context) demands.
The ADR file is created as `docs/adr/NNNN-<slug>.md` in the working branch
and its README index row is added there as normal work; the ledger line on
`main` is the allocation record and never conflicts with feature work
(feature branches don't touch it). The ledger may be pruned later; it's a
lock record, not state. **Collisions** (two reservations in the same
instant) are resolved automatically by the retry; the README index edit is
a secondary tripwire if one ever slips through.
This **supersedes ADR-0000's placeholder-until-merge default.** Subproject
namespaces (`ADR-website-NNN`, `ADR-ci-NNN`) are unchanged — they solve a
different problem (a nameable long-lived body of decisions) and stay.
### D5 — Branch protection configuration
On `main` (Gitea → Settings → Branches):
- **Require pull requests** + **require the CI status check** (`ci / gate*`)
to pass before merge, and **block merge of out-of-date branches** (this
is what makes "`main` is the lock" airtight for the substantive merges).
- **Whitelist the repo owner for direct push.** This is the one narrow
carve-out that lets `adr-reserve.sh` push the append-only ledger line
directly; everything substantive still goes through a PR. Without it the
reserve script aborts with a clear "is the owner whitelisted?" message.
### D6 — Who does what
**Pushing and merging are human steps**; automated agents prepare branches,
edits, PR text, and commit-message proposals but never push or merge. The
reserve script is **run by the human** (so the ledger push originates from
the human's invocation, consistent with that rule) — agents may write and
test the script but not run its pushing path.
## Forks (all user-chosen, 2026-06-22/23)
- **Merge commits (`--no-ff`)** over squash or fast-forward-only — the only
option consistent with append-only history (D3).
- **Strict everything-via-PR** over allowing trivial direct commits — one
carve-out only, the reservation line (D1/D4/D5).
- **Branch protection enforced in Gitea** over convention-only — mechanical
guarantee for a public repo (D5).
- **Reserve-first contiguous integers** over: number-on-merge (rejected —
breaks immutable references), Gitea-issue-number ids (rejected — non-
contiguous, overlaps the legacy 0001–0057 range), or an allocator bot
(rejected — machinery + a PR-rule exemption). Reserve-first keeps the
short stable "ADR 0059" reference and needs no new infrastructure beyond
one script (D4).
- **Worktrees prescribed** over single-checkout branch switching — prevents
the same-directory clobber hazard (D2).
## Consequences
- A written, public-facing **`CONTRIBUTING.md`** (lean: setup, the CI gate,
branch/PR conventions, the user-facing copy rules, "significant changes
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
- Three scripts enter `scripts/`: `adr-reserve.sh`, `wt-new.sh`,
`wt-rm.sh`, each with a local-`origin` test harness
(`test-adr-reserve.sh`, `test-wt.sh`) — the reserve script's allocation,
idempotence, race-retry discriminator, and hard-abort paths are all
covered (the real-remote push is exercised by the human running it).
- `docs/adr/RESERVATIONS.log` appears on `main` as the allocation ledger.
- ADR-0000's Numbering-discipline section is updated to point here.
- `CLAUDE.md` gains the operational rules an agent must follow.
## Out of scope
- **Migrating the legacy `0001–0057` ADRs** to any new scheme — they keep
their numbers; reserve-first applies going forward (next is `0060`).
- **Automating PR creation/merge** — humans push and merge (D6).
- **A CONTRIBUTING-side description of the reserve lock** — it's maintainer
infrastructure, not contributor guidance (kept here instead).
## Amendment 1 (2026-06-25): docs direct-push carve-out
Extends the D5 owner-whitelist: **handoff / session docs (and small docs
amendments like this one) are owner-direct-pushed to `main`, not PR'd.**
Discovered while preparing the first handoff under this flow: a **docs-only
PR cannot merge** under the protection rules. `ci.yaml` `paths-ignore`s
`docs/**` and `**/*.md`, so a docs-only change posts **no `ci / gate` run**;
but D5 requires `ci / gate*` before merge — so the required check never
arrives and the PR stalls on "expected/waiting". Rather than weaken the gate
or add CI machinery, this trivial low-risk class (handoffs, session notes,
small docs fixes) rides the existing owner-direct-push carve-out: the owner
is already whitelisted, and a docs-only push to `main` is itself
`paths-ignore`d, so it burns no CI.
Substantive changes — code, and the ADRs/docs that accompany code — still go
through a PR as normal (their non-docs files trigger the gate). **If
docs-via-PR is ever wanted** (e.g. a docs-only ADR through review), the clean
fix is a "skipped → success" gate shim (a job that reports `ci / gate`
success when the real gate is path-skipped) or dropping the docs
`paths-ignore`; until then, direct-push is the sanctioned path for docs-only
changes.
File diff suppressed because one or more lines are too long
+144
View File
@@ -0,0 +1,144 @@
# Session handoff — 2026-06-25 (80)
A long session. It began as the next open-issue item — **#37, clause-concept
hints** — but the act of cutting a branch for it surfaced that this
now-public repo had **no written, trackable working method**. So the bulk of
the session became establishing one (**ADR-0059**) and hardening it through
real use, with two follow-up fixes the dogfooding turned up. Both arcs are
fully merged; the tree is clean.
## §1. State
**Branch `main` at `78272e2`.** Everything below is merged; **no open PRs**;
worktrees are just the primary (`main`) + the long-lived `website`. All
feature/fix worktrees were cleaned up with `wt-rm`.
**Suite green at #37's merge:** **1831 lib + 8 e2e_pty + 503 it + 200 typing
(2542 total), 0 failed, 1 ignored**; `fmt --check` + `clippy -D warnings`
clean (via `nix develop -c`). The new workflow scripts carry their own
local-`origin` harnesses: `test-adr-reserve.sh` **10/10**, `test-wt.sh`
**16/16**, all `shellcheck`-clean.
**Issues:** closed this session — **#37** (done, ADR-0058) and **#38**
(won't-do, see §4). Still open — **#40** (winget release notes, deferred to
next release; see §4). (#36/#39 were closed in earlier sessions.)
## §2. What shipped — #37 clause-concept hints (ADR-0058, merged #42)
The deferred ADR-0053 extension: a tier-3 **`hint.concept.*`** layer shown by
**F1** when the cursor sits **inside a recognized clause**, layered under the
per-form `hint.cmd.*` block. Seven topics — referential actions, `1:n`/`m:n`
cardinality, primary key, unique, check, foreign key.
- **Mechanism:** a new transparent `Node::Concept { topic, inner }` grammar
wrapper records the clause's byte span into `WalkContext::concept_spans`
(**append-only — survives the clause being fully matched**, unlike
`pending_hint_mode` which clears on match and so only knows the slot
boundary). `concept_topic_at_cursor` walks the full buffer and returns the
**innermost** containing span. No use of the dormant `WalkBound::Position`.
- **Two user forks:** detection = "anywhere inside the clause"; **all four
clause families** in v1. **Mode-keyed examples** (`example.simple` /
`example.advanced`) honour ADR-0053 D6 for topics reachable in both modes;
`emit_tier3_block` picks by live mode.
- **`/runda` caught two design gaps before any code** (clause map missed the
simple-mode constraint suffix; single-`example` model collided with D6) —
both fixed. **Comprehensiveness gate**: a recursive `Node::Concept` visitor
cross-checks grammar wrappers ↔ `CONCEPT_TOPICS` ↔ catalogue blocks.
- 21 new tests (14 resolver, 3 gates, 3 F1 integration, 1 snapshot).
## §3. The working method (ADR-0059, merged #41; fixes #43, #44)
The session's main artifact — the trackable flow for the public repo:
- **PRs onto a protected `main`** (always-green; one direct-push carve-out,
§4), one logical change per branch, conventional prefixes, the `/runda`/DA
review pasted into each PR. **Merge commits (`--no-ff`)**; never
rebase/squash (append-only).
- **One worktree per branch** (`<repo>-worktree-<segment>`) — never switch the
primary checkout. `scripts/wt-new.sh <branch>` (off `origin/main`,
`--no-track`) / `scripts/wt-rm.sh <branch>` (removes only the **named**
worktree).
- **Reserve-first ADR numbering** — `scripts/adr-reserve.sh <slug>`. The fix
for the real collision problem: a contiguous integer needs an allocator and
plain git branches have none, so **`main` is the registry and an atomic
`git push` is the lock** (compare-and-swap, retried on contention),
recorded in the append-only ledger **`docs/adr/RESERVATIONS.log`**. The
number is **stable from creation** (safe in immutable commit messages +
cross-refs). Supersedes ADR-0000's placeholder-until-merge default;
subproject namespaces (`ADR-website/ci-NNN`) unchanged.
- **Branch protection** (you applied in Gitea): require PR + the `ci / gate*`
check + up-to-date-before-merge; **owner whitelisted for direct push** (the
reservation ledger line, now also handoff/session docs — §4).
- **Fixes found by dogfooding:**
- **#43** — `wt-clean` (auto-sweep of all merged worktrees) → explicit
**`wt-rm <branch>`**. The auto-sweep would have deleted long-lived
branches (`website`, `ci`) since they're merged into `main` too. Also
**`wt-new --no-track`**: branching off `origin/main` had set the new
branch's upstream to `origin/main`, so a bare `git push` errored on a
name-mismatch and suggested the dangerous `HEAD:main`.
- **#44** — CI **dedupe**: `push: branches: [main]` (was `['**']`) so a
push to a PR'd branch no longer runs the gate twice. On **Gitea** the
`push` and `pull_request` runs were byte-identical — Gitea has **no
merge-preview ref**; its `pull_request` checks out `refs/pull/N/head`, the
same commit a branch push would (`docs.gitea.com/usage/actions/faq`).
Keeping the gate on `pull_request` is forward-compatible: if Gitea ever
adds the merge ref, the runs upgrade to testing the merged result for free.
## §4. Decisions
- **#38 closed — won't build** the pre-submit-diagnostic tier-3 route. The
ADR-0053 D6 deferral reasons still hold: `Diagnostic` carries no class key
(a class field would have to thread through every diagnostic-creation
site — broad change) for marginal value (tier-2 already surfaces
diagnostics live; many classes duplicate the runtime-error/clause-concept
tiers). Reconsider only if a diagnostic class has genuinely unique teaching
value. Rationale recorded on the issue.
- **#40 deferred** (open): wire `CHANGELOG` into komac's winget
`--release-notes-url`. Best coupled to the **next `v*` release + first real
winget *update* test** — the initial winget PR to `microsoft/winget-pkgs`
is still unconfirmed after several days, so end-to-end validation needs a
confirmed package first.
- **Docs direct-push carve-out** (this handoff is the first to use it):
handoff / session docs (and the reservation ledger) are **owner
direct-pushed to `main`**, not PR'd. See §5 for *why* a PR doesn't work.
## §5. Process lessons (now in ADR-0059 / CLAUDE.md)
- **Update a branch in ONE lane** — locally (`git fetch && git merge
origin/main`), **not** the Gitea "Update branch" button when a conflict is
possible. The button silently mis-resolved the README clash on #42
(dropped the `0059` index row) and, combined with a local merge, diverged
the PR branch; healed with an append-only merge (no force-push).
- **Primary checkout rests on `main`; feature work in worktrees.** Mid-session
the primary was stuck on `feat` (bootstrap artifact), which is what made
worktree cleanup awkward — there was no `main` checkout holding the
scripts. Resolved by switching the primary to `main` and moving `feat` to a
worktree.
- **Docs-only PRs are blocked by the required gate.** `paths-ignore`
(`docs/**`, `**/*.md`) means a docs-only change posts **no `ci / gate`
run**, and branch protection requires `ci / gate*` — so the required check
never arrives and the PR is stuck "expected/waiting." That is the concrete
reason handoff/session docs are direct-pushed (§4). If docs-via-PR is ever
wanted, add a "skipped → success" gate shim (or drop the paths-ignore).
- **Gitea CI specifics worth remembering:** no merge-preview ref (above); the
reserve script's CAS via atomic push; `--no-track` on branch creation to
avoid the inherited-`origin/main` upstream.
## §6. Next
- **Next session is free to pick a feature** — no specific item is queued;
consult `docs/requirements.md` for the remaining backlog (e.g. **TT4 /
Tier-4-in-CI** still not wired; **D3 packaging** — winget pending
confirmation; the larger deferred UX items: tutorial system, session-log +
markdown export V4, multi-line input I1, ER-diagram export V3).
- **#40** when the next release happens (with the first winget update test).
- **Use the new flow:** `wt-new` to start a branch, `adr-reserve` the moment
an ADR is needed, PR onto `main`, `wt-rm` when done. Push/merge stay your
steps; agents prepare but never push.
## §7. Process pins
- Commits user-confirmed, no AI attribution, append-only. **Push/merge are
the user's steps.** This handoff + the §4 carve-out doc amendments are
direct-pushed to `main` by the owner (docs-only, paths-ignored,
owner-whitelisted).
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# adr-reserve.sh — atomically reserve the next main-sequence ADR number.
#
# WHY THIS EXISTS
# A contiguous ADR integer needs a single allocator, but git branches have
# no shared allocator until they merge — which is why two parallel branches
# that each invent "the next number" collide. This script makes `main` the
# allocator and an atomic `git push` the lock:
#
# 1. read an append-only ledger (docs/adr/RESERVATIONS.log) + the existing
# NNNN-*.md ADRs on main; next = highest + 1;
# 2. append "<NNNN> <slug> <date> <title>" to the ledger, commit, PUSH;
# 3. if the push is rejected because main moved (someone reserved first),
# re-fetch, recompute, and retry.
#
# A push to a ref is a compare-and-swap, so the remote `main` ref is the
# mutex and step 3 is the retry-on-contention. The number is therefore
# stable from the moment this returns — safe to cite in commit messages
# (which are immutable) and in other ADRs from the very first commit.
# (Decision recorded in the dev-workflow ADR.)
#
# It runs from ANY branch / worktree / directory inside the repo and never
# touches your working tree: the allocation happens in a throwaway worktree
# on a detached origin/main.
#
# PREREQUISITE
# You must be allowed to push directly to `main` (the repo owner is
# whitelisted in the branch-protection rule for exactly this). Everything
# substantive still goes through a PR; this one append-only ledger line is
# the sole sanctioned direct push. If protection rejects the push, the
# script aborts with a clear message rather than spinning.
#
# USAGE
# scripts/adr-reserve.sh <slug> [title...]
# scripts/adr-reserve.sh clause-concept-hints "Clause-concept hints"
#
# OUTPUT
# The reserved zero-padded number on stdout (e.g. 0058); progress on stderr.
set -euo pipefail
log() { printf ' reserve-adr: %s\n' "$*" >&2; }
die() { printf 'reserve-adr: ERROR: %s\n' "$*" >&2; exit 1; }
REMOTE="origin"
BRANCH="main"
LEDGER="docs/adr/RESERVATIONS.log"
MAX_ATTEMPTS=20
[ $# -ge 1 ] || die "usage: adr-reserve.sh <slug> [title...]"
slug="$1"; shift
title="${*:-}"
# Slug must match the NNNN-<slug>.md filename convention.
[[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] \
|| die "slug must be lowercase-kebab-case (got: '$slug')"
root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repo"
log "allocating next ADR number for '$slug' against $REMOTE/$BRANCH"
git -C "$root" fetch --quiet "$REMOTE" "$BRANCH" || die "git fetch $REMOTE $BRANCH failed"
# Throwaway detached worktree on origin/main — isolated from your working tree.
tmp_base="$(mktemp -d)"
wt="$tmp_base/wt"
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
cleanup() {
git -C "$root" worktree remove --force "$wt" >/dev/null 2>&1 || true
rm -rf "$tmp_base"
}
trap cleanup EXIT
git -C "$root" worktree add --quiet --detach "$wt" "$REMOTE/$BRANCH" \
|| die "could not create temp worktree at $wt"
# Highest number across existing top-level ADR files and the ledger, + 1.
compute_next() {
local max=0 n base
shopt -s nullglob
for f in "$wt"/docs/adr/[0-9][0-9][0-9][0-9]-*.md; do
base="$(basename "$f")"; n=$((10#${base%%-*}))
(( n > max )) && max=$n
done
if [ -f "$wt/$LEDGER" ]; then
while read -r num _; do
[[ "$num" =~ ^[0-9]{4}$ ]] || continue
n=$((10#$num)); (( n > max )) && max=$n
done < "$wt/$LEDGER"
fi
printf '%04d' $(( max + 1 ))
}
# Idempotence: if this slug is already reserved, report it and stop.
if [ -f "$wt/$LEDGER" ]; then
existing="$(awk -v s="$slug" '$2==s {print $1; exit}' "$wt/$LEDGER" || true)"
if [ -n "${existing:-}" ]; then
log "'$slug' is already reserved as ADR $existing — nothing to do"
echo "$existing"
exit 0
fi
fi
attempt=0
while (( attempt < MAX_ATTEMPTS )); do
attempt=$(( attempt + 1 ))
git -C "$wt" fetch --quiet "$REMOTE" "$BRANCH"
git -C "$wt" reset --hard --quiet "$REMOTE/$BRANCH"
num="$(compute_next)"
# Dry run: report the number we WOULD reserve and stop (no commit/push).
# Useful for a preview and for testing the read/allocation path.
if [ -n "${ADR_RESERVE_DRY_RUN:-}" ]; then
log "DRY RUN: would reserve ADR $num for '$slug' (no push)"
echo "$num"
exit 0
fi
mkdir -p "$wt/docs/adr"
printf '%s %s %s %s\n' "$num" "$slug" "$(date +%F)" "$title" >> "$wt/$LEDGER"
git -C "$wt" add "$LEDGER"
git -C "$wt" commit --quiet -m "docs(adr): reserve $num for $slug"
log "attempt $attempt: claiming ADR $num …"
if push_out="$(git -C "$wt" push "$REMOTE" "HEAD:$BRANCH" 2>&1)"; then
log "reserved ADR $num for '$slug' (pushed to $REMOTE/$BRANCH)"
echo "$num"
exit 0
fi
# Distinguish a lost race (retryable) from a hard rejection (not).
if grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$push_out"; then
log "race lost (main moved) — re-fetching and retrying"
continue
fi
printf '%s\n' "$push_out" >&2
die "push to $BRANCH was rejected and this is not a race — likely branch \
protection (is the owner whitelisted for direct pushes to $BRANCH?). Aborting."
done
die "gave up after $MAX_ATTEMPTS contended attempts — main is changing very fast?"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Verifies adr-reserve.sh against a local file:// origin (no network/keys).
# Exercises: number computation, dry-run, real reserve+push, sequential
# allocation, idempotence, hard-rejection abort (vs infinite loop), and the
# race-vs-abort discriminator.
set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")" && pwd)/adr-reserve.sh"
PASS=0 FAIL=0
check() { # <desc> <expected> <actual>
if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi
}
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
# ---- seed a bare origin with ADRs up to 0057 -------------------------------
git init -q --bare "$T/origin.git"
git clone -q "$T/origin.git" "$T/seed"
git -C "$T/seed" config user.email t@t.io
git -C "$T/seed" config user.name tester
mkdir -p "$T/seed/docs/adr"
echo "# legacy" > "$T/seed/docs/adr/0056-alpha.md"
echo "# legacy" > "$T/seed/docs/adr/0057-beta.md"
git -C "$T/seed" add -A
git -C "$T/seed" commit -qm "seed: ADRs through 0057"
git -C "$T/seed" branch -M main
git -C "$T/seed" push -q origin main
# the repo the script runs inside (origin = our bare repo)
git clone -q "$T/origin.git" "$T/work"
git -C "$T/work" config user.email t@t.io
git -C "$T/work" config user.name tester
run() { ( cd "$T/work" && "$@" ); }
echo "--- Test 1: dry-run computes 0058 (no push) ---"
out="$(run env ADR_RESERVE_DRY_RUN=1 bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
check "dry-run number" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "dry-run pushed nothing" "no" "$( [ -f "$T/seed/docs/adr/RESERVATIONS.log" ] && echo yes || echo no )"
echo "--- Test 2: real reserve → 0058, pushed to origin ---"
out="$(run bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
check "first reserve number" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "ledger landed on origin" "0058 feature-one" "$(awk 'NR==1{print $1" "$2}' "$T/seed/docs/adr/RESERVATIONS.log")"
echo "--- Test 3: second slug → 0059 (sequential) ---"
out="$(run bash "$SCRIPT" feature-two 2>/dev/null)"
check "second reserve number" "0059" "$out"
echo "--- Test 4: idempotence — re-reserving feature-one returns 0058 ---"
out="$(run bash "$SCRIPT" feature-one 2>/dev/null)"
check "idempotent re-reserve" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "no duplicate ledger line" "1" "$(grep -c ' feature-one ' "$T/seed/docs/adr/RESERVATIONS.log")"
echo "--- Test 5: hard rejection (branch protection) aborts, does NOT loop ---"
cat > "$T/origin.git/hooks/pre-receive" <<'HOOK'
#!/bin/sh
echo "protected branch: push declined" >&2
exit 1
HOOK
chmod +x "$T/origin.git/hooks/pre-receive"
set +e
run bash "$SCRIPT" feature-three >/dev/null 2>"$T/err"; rc=$?
set -e
check "abort exit code nonzero" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "abort cites protection, not a loop" "yes" "$(grep -qi 'not a race\|protection' "$T/err" && echo yes || echo no)"
rm -f "$T/origin.git/hooks/pre-receive"
echo "--- Test 6: race discriminator matches a real non-ff message ---"
ff_msg=" ! [rejected] main -> main (non-fast-forward)
error: failed to push some refs"
check "non-ff treated as race" "yes" \
"$(grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$ff_msg" && echo yes || echo no)"
echo
echo "==== $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Smoke-tests wt-new.sh / wt-rm.sh against a local file:// origin.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
WT_NEW="$here/wt-new.sh"
WT_RM="$here/wt-rm.sh"
PASS=0 FAIL=0
check() { if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi; }
exists() { [ -d "$1" ] && echo yes || echo no; }
has_branch() { git -C "$T/primary" show-ref --verify --quiet "refs/heads/$1" && echo yes || echo no; }
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
git init -q --bare "$T/origin.git"
git clone -q "$T/origin.git" "$T/primary"
git -C "$T/primary" config user.email t@t.io
git -C "$T/primary" config user.name tester
echo seed > "$T/primary/README.md"
git -C "$T/primary" add -A
git -C "$T/primary" commit -qm seed
git -C "$T/primary" branch -M main
git -C "$T/primary" push -q origin main
run() { ( cd "$T/primary" && "$@" ); }
target1="$T/primary-worktree-thing-one"
target2="$T/primary-worktree-thing-two"
echo "--- wt-new rejects a non-conventional branch name ---"
set +e; run bash "$WT_NEW" my-thing >/dev/null 2>&1; rc=$?; set -e
check "bad-prefix rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-new creates branch + sibling worktree ---"
out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)"
check "prints target path" "$target1" "$out"
check "worktree dir exists" "yes" "$(exists "$target1")"
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target1" rev-parse --abbrev-ref HEAD)"
# It must NOT inherit origin/main as upstream (else a bare `git push` errors
# with a name-mismatch and suggests the dangerous `HEAD:main`). No upstream
# until the first `git push -u` sets the right one.
check "new branch has no upstream" "none" "$(git -C "$target1" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)"
echo "--- wt-new refuses to clobber an existing branch ---"
set +e; run bash "$WT_NEW" feat/thing-one >/dev/null 2>&1; rc=$?; set -e
check "duplicate rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
# Second worktree, and make the merge states explicit:
# thing-one → merged into origin/main (push its tip to main),
# thing-two → ahead of main (unmerged), clean.
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
git -C "$target1" commit -q --allow-empty -m "thing-one work"
git -C "$target1" push -q origin HEAD:main
git -C "$target2" commit -q --allow-empty -m "thing-two work (unmerged)"
git -C "$T/primary" fetch -q origin
echo "--- wt-rm removes ONLY the named worktree (the other is untouched) ---"
run bash "$WT_RM" feat/thing-one >/dev/null 2>&1
check "named worktree removed" "no" "$(exists "$target1")"
check "UNNAMED worktree untouched" "yes" "$(exists "$target2")"
check "merged branch deleted" "no" "$(has_branch feat/thing-one)"
echo "--- wt-rm errors on an unknown branch ---"
set +e; run bash "$WT_RM" feat/nope >/dev/null 2>&1; rc=$?; set -e
check "unknown branch rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-rm refuses the primary checkout ---"
set +e; run bash "$WT_RM" main >/dev/null 2>&1; rc=$?; set -e
check "primary refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "primary still present" "yes" "$(exists "$T/primary")"
echo "--- wt-rm refuses a dirty worktree ---"
touch "$target2/untracked-file"
set +e; run bash "$WT_RM" feat/thing-two >/dev/null 2>&1; rc=$?; set -e
check "dirty refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "dirty worktree kept" "yes" "$(exists "$target2")"
rm -f "$target2/untracked-file"
echo "--- wt-rm removes the worktree but KEEPS an unmerged branch ---"
run bash "$WT_RM" feat/thing-two >/dev/null 2>&1
check "unmerged worktree removed" "no" "$(exists "$target2")"
check "unmerged branch preserved" "yes" "$(has_branch feat/thing-two)"
echo
echo "==== $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
#
# wt-new.sh — start a branch in its own git worktree.
#
# WHY: every branch is developed in an isolated worktree, never by switching
# the primary checkout (two shells in one checkout is how you clobber work).
# This creates the branch off an up-to-date `main` and a sibling worktree
# named to match the repo's existing convention:
#
# <parent>/<repo>-worktree-<last-segment-of-branch>
# e.g. feat/clause-concept-hints -> ../rdbms-playground-worktree-clause-concept-hints
#
# USAGE
# scripts/wt-new.sh <branch>
# scripts/wt-new.sh feat/clause-concept-hints
#
# Prints the new worktree path; cd there to start working.
set -euo pipefail
log() { printf ' wt-new: %s\n' "$*" >&2; }
die() { printf 'wt-new: ERROR: %s\n' "$*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: wt-new.sh <branch> (e.g. feat/my-thing)"
branch="$1"
# Conventional prefixes keep branch names aligned with commit types.
case "$branch" in
feat/*|fix/*|docs/*|chore/*|refactor/*|test/*|ci/*) : ;;
*) die "branch should start with feat/ fix/ docs/ chore/ refactor/ test/ ci/ (got: $branch)" ;;
esac
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
# Sibling-of-the-primary-checkout location, matching existing worktrees.
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
parent="$(dirname "$main_wt")"
repo="$(basename "$main_wt")"
seg="${branch##*/}"
target="$parent/${repo}-worktree-${seg}"
[ -e "$target" ] && die "worktree path already exists: $target"
git show-ref --verify --quiet "refs/heads/$branch" \
&& die "branch already exists: $branch (use 'git worktree add' to attach a worktree to it)"
log "fetching origin/main"
git fetch --quiet origin main || die "git fetch origin main failed"
log "creating branch '$branch' + worktree at $target"
# `--no-track`: branch off origin/main for a fresh base, but DON'T inherit
# origin/main as the upstream. Without this, branching off a remote-tracking
# ref makes git set the new branch's upstream to origin/main, so a later bare
# `git push` fails with a name-mismatch and suggests the dangerous
# `git push origin HEAD:main`. With no upstream, the first `git push -u origin
# <branch>` sets the correct one.
git worktree add --quiet --no-track -b "$branch" "$target" origin/main \
|| die "git worktree add failed"
log "ready — cd into it:"
echo "$target"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# wt-rm.sh — remove the worktree for a named branch.
#
# EXPLICIT BY DESIGN: it removes ONLY the branch you name. There is no
# scanning and no "is it merged into main?" heuristic to decide *what* to
# remove — long-lived branches (e.g. `website`, the `ci` line) are merged
# into main too, so auto-detection would happily delete them. Naming the
# target is the safety.
#
# Safety still comes from git's own refusals, not from cleverness here:
# - the worktree won't be removed if it has uncommitted/untracked changes
# (no `--force`);
# - the local branch is deleted ONLY if it is fully merged into
# `origin/main` — otherwise the worktree goes but the branch (and its
# unmerged commits) is kept.
#
# USAGE
# scripts/wt-rm.sh <branch>
# scripts/wt-rm.sh feat/clause-concept-hints
#
# For a worktree whose directory you already deleted by hand, use the
# built-in `git worktree prune` (it only clears bookkeeping — never a
# directory or a branch).
set -euo pipefail
log() { printf ' wt-rm: %s\n' "$*" >&2; }
die() { printf 'wt-rm: ERROR: %s\n' "$*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: wt-rm.sh <branch>"
branch="$1"
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
# Resolve the named branch to its worktree path.
path=""; cur=""
while read -r key val; do
case "$key" in
worktree) cur="$val" ;;
branch) [ "${val#refs/heads/}" = "$branch" ] && path="$cur" ;;
esac
done < <(git worktree list --porcelain)
[ -n "$path" ] || die "no worktree is checked out on branch '$branch' (see: git worktree list)"
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
here="$(git rev-parse --show-toplevel)"
[ "$path" != "$main_wt" ] || die "refusing to remove the primary checkout ($path)"
[ "$path" != "$here" ] || die "refusing to remove the worktree you're standing in — run this from elsewhere"
log "removing worktree $path (branch $branch)"
git worktree remove "$path" \
|| die "could not remove $path — it likely has uncommitted or untracked changes; clean it (or remove by hand) first"
# Delete the local branch only if it is safely merged into origin/main;
# otherwise keep it so unmerged commits are never lost. (No auto-fetch — it
# judges against your current origin/main; run `git fetch` first if you want
# a freshly-merged branch to be eligible.)
if git rev-parse --verify --quiet origin/main >/dev/null \
&& git merge-base --is-ancestor "$branch" origin/main; then
git branch -D "$branch" >/dev/null
log "deleted local branch $branch (merged into origin/main)"
else
log "kept local branch $branch — not merged into origin/main; delete with 'git branch -D $branch' if you're sure"
fi
log "done"
+152 -7
View File
@@ -3205,8 +3205,24 @@ impl App {
let probe = view.to_string(); let probe = view.to_string();
let mode = self.effective_mode().as_mode(); let mode = self.effective_mode().as_mode();
if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode) if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
&& self.emit_tier3_block(&format!("hint.cmd.{id}")) && self.emit_tier3_block(&format!("hint.cmd.{id}"), "hint.block.heading")
{ {
// Clause-concept layer (issue #37 / clause-concept-hints D6):
// if the cursor sits inside a recognized clause, append its
// `hint.concept.<topic>` block beneath the per-form block.
// Only when the per-form block rendered — the concept layer
// never appears alone.
if let Some(topic) = crate::dsl::walker::concept_topic_at_cursor(
&probe,
cursor,
Some(&self.schema_cache),
mode,
) {
self.emit_tier3_block(
&format!("hint.concept.{topic}"),
"hint.block.concept_heading",
);
}
return; return;
} }
// Tier-2 fallback: surface the ambient prose as a persistent // Tier-2 fallback: surface the ambient prose as a persistent
@@ -3242,7 +3258,7 @@ impl App {
/// (ADR-0053 D2/D5). /// (ADR-0053 D2/D5).
fn note_hint_for_recent_error(&mut self) { fn note_hint_for_recent_error(&mut self) {
if let Some(class) = self.last_error_hint_key.clone() if let Some(class) = self.last_error_hint_key.clone()
&& self.emit_tier3_block(&format!("hint.err.{class}")) && self.emit_tier3_block(&format!("hint.err.{class}"), "hint.block.heading")
{ {
return; return;
} }
@@ -3258,28 +3274,51 @@ impl App {
/// absent so the caller can fall back to tier 2. `what` is /// absent so the caller can fall back to tier 2. `what` is
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
/// polish (the framed block) lands with the corpus. /// polish (the framed block) lands with the corpus.
fn emit_tier3_block(&mut self, stem: &str) -> bool { /// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
/// under `heading_key` (ADR-0053 D4; clause-concept-hints D6).
/// Returns `false` if the `what` part is absent so the caller can
/// fall back. `heading_key` lets the clause-concept block carry a
/// distinct sub-heading ("About this clause") beneath the per-form
/// `Hint` block. The example lookup is **mode-keyed**
/// (clause-concept-hints D5/D6): it prefers `<stem>.example.<mode>`
/// (`simple`/`advanced` from the live effective mode) and falls
/// back to a plain `<stem>.example` — so `hint.cmd.*` / `hint.err.*`
/// (plain example) are unaffected, while a both-mode
/// `hint.concept.*` topic shows the mode-correct example.
fn emit_tier3_block(&mut self, stem: &str, heading_key: &str) -> bool {
let cat = crate::friendly::catalog(); let cat = crate::friendly::catalog();
let what_key = format!("{stem}.what"); let what_key = format!("{stem}.what");
if cat.get(&what_key).is_none() { if cat.get(&what_key).is_none() {
return false; return false;
} }
// Labelled block (ADR-0053 D4): a `Hint` heading, then aligned // Labelled block (ADR-0053 D4): the heading, then aligned
// `What:` / `Example:` / `Concept:` lines. `concept` renders // `What:` / `Example:` / `Concept:` lines. `concept` renders
// muted (`OutputStyleClass::Hint`); the rest are plain system. // muted (`OutputStyleClass::Hint`); the rest are plain system.
let labelled = |label: &str, value: &str| { let labelled = |label: &str, value: &str| {
// Pad `<Label>:` to a common width so the values align. // Pad `<Label>:` to a common width so the values align.
format!(" {:<9}{value}", format!("{label}:")) format!(" {:<9}{value}", format!("{label}:"))
}; };
self.note_system(crate::t!("hint.block.heading")); let mode_str = match self.effective_mode().as_mode() {
crate::mode::Mode::Simple => "simple",
crate::mode::Mode::Advanced => "advanced",
};
let example_key = {
let keyed = format!("{stem}.example.{mode_str}");
if cat.get(&keyed).is_some() {
keyed
} else {
format!("{stem}.example")
}
};
self.note_system(crate::friendly::translate(heading_key, &[]));
self.note_system(labelled( self.note_system(labelled(
&crate::t!("hint.block.what"), &crate::t!("hint.block.what"),
&crate::friendly::translate(&what_key, &[]), &crate::friendly::translate(&what_key, &[]),
)); ));
if cat.get(&format!("{stem}.example")).is_some() { if cat.get(&example_key).is_some() {
self.note_system(labelled( self.note_system(labelled(
&crate::t!("hint.block.example"), &crate::t!("hint.block.example"),
&crate::friendly::translate(&format!("{stem}.example"), &[]), &crate::friendly::translate(&example_key, &[]),
)); ));
} }
if cat.get(&format!("{stem}.concept")).is_some() { if cat.get(&format!("{stem}.concept")).is_some() {
@@ -6150,6 +6189,112 @@ mod tests {
); );
} }
// ── Clause-concept hints (issue #37) ────────────────────────
/// Move the input cursor left `n` times (into already-typed text).
fn cursor_left_n(app: &mut App, n: usize) {
for _ in 0..n {
app.update(key(KeyCode::Left));
}
}
#[test]
fn f1_inside_on_delete_layers_the_referential_actions_concept() {
let mut app = App::new();
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cid on delete cascade",
);
// Park the cursor inside the already-typed `cascade` — the
// cursor-inside case (#37), not merely the slot boundary.
cursor_left_n(&mut app, 3);
let buffer_before = app.input.clone();
let cursor_before = app.input_cursor;
f1(&mut app);
// Per-form block still renders…
assert!(
output_contains(&app, "one parent, many children"),
"the per-form relationship block must still render",
);
// …layered with the clause-concept block under its sub-heading.
assert!(
output_contains(&app, "About this clause"),
"expected the clause-concept sub-heading",
);
assert!(
output_contains(&app, "forbids orphans"),
"expected the referential-actions concept block",
);
// F1 is a read-only overlay (ADR-0053 D1): buffer + cursor intact.
assert_eq!(app.input, buffer_before, "F1 must not alter the buffer");
assert_eq!(
app.input_cursor, cursor_before,
"F1 must not move the cursor"
);
}
#[test]
fn f1_concept_example_is_mode_correct() {
// Advanced mode: the referential-actions concept is reachable
// via `references … on delete`, and its example must be the
// advanced (SQL) one, never the simple-mode `add 1:n …` line
// (ADR-0053 D6 / clause-concept-hints D5).
let mut app = App::new();
app.mode = crate::mode::Mode::Advanced;
type_str(
&mut app,
"create table Orders (cid int references Customers(id) on delete cascade)",
);
cursor_left_n(&mut app, 3); // inside `cascade`
f1(&mut app);
assert!(
output_contains(&app, "references Customers(id) on delete cascade"),
"expected the advanced-mode example",
);
assert!(
!output_contains(&app, "add 1:n relationship"),
"must NOT show the simple-mode example in advanced mode",
);
}
#[test]
fn f1_outside_a_concept_clause_shows_no_concept_block() {
// Cursor at the end of a plain `insert` — not inside any
// recognized clause. The per-form block renders; no concept
// layer, no sub-heading.
let mut app = App::new();
type_str(&mut app, "insert into Customers ");
f1(&mut app);
assert!(
output_contains(&app, "Add one or more rows to a table"),
"the per-form block must render",
);
assert!(
!output_contains(&app, "About this clause"),
"no clause-concept block when the cursor is outside any clause",
);
}
/// Locks the rendered shape of the per-form block + the layered
/// clause-concept block (clause-concept-hints D6).
#[test]
fn hint_block_with_concept_renders_layered() {
let mut app = App::new();
type_str(
&mut app,
"add 1:n relationship from Customers.id to Orders.cid on delete cascade",
);
cursor_left_n(&mut app, 3);
f1(&mut app);
let block = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("hint_block_with_concept", block);
}
#[test] #[test]
fn f1_on_add_column_does_not_render_the_relationship_block() { fn f1_on_add_column_does_not_render_the_relationship_block() {
// Per-form disambiguation (ADR-0053 D3): `add column` resolves // Per-form disambiguation (ADR-0053 D3): `add column` resolves
+53 -6
View File
@@ -476,10 +476,23 @@ const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk")); const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
const ADD_RELATIONSHIP_NODES: &[Node] = &[ // The `1:n` cardinality marker, tagged as the
// `cardinality_one_to_many` clause-concept region (issue #37). A
// distinct sub-`Seq` so the concept span covers exactly `1:n`, not
// the whole relationship form (which the per-form `hint.cmd.*` block
// already serves).
const CARDINALITY_1N_NODES: &[Node] = &[
Node::Literal("1"), Node::Literal("1"),
Node::Punct(':'), Node::Punct(':'),
Node::Word(Word::keyword("n")), Node::Word(Word::keyword("n")),
];
const CARDINALITY_1N: Node = Node::Concept {
topic: "cardinality_one_to_many",
inner: &Node::Seq(CARDINALITY_1N_NODES),
};
const ADD_RELATIONSHIP_NODES: &[Node] = &[
CARDINALITY_1N,
Node::Word(Word::keyword("relationship")), Node::Word(Word::keyword("relationship")),
AR_AS_NAME_OPT, AR_AS_NAME_OPT,
Node::Word(Word::keyword("from")), Node::Word(Word::keyword("from")),
@@ -1048,7 +1061,14 @@ const NOT_NULL_NODES: &[Node] = &[
]; ];
const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES); const NOT_NULL_CONSTRAINT: Node = Node::Seq(NOT_NULL_NODES);
const UNIQUE_CONSTRAINT: Node = Node::Word(Word::keyword("unique")); // Tagged as the `unique` clause-concept region (issue #37). Shared
// across `create table … with pk`, `add constraint`, and (as a bare
// keyword) `drop constraint`, so the concept surfaces anywhere a
// unique constraint is being declared or removed.
const UNIQUE_CONSTRAINT: Node = Node::Concept {
topic: "unique",
inner: &Node::Word(Word::keyword("unique")),
};
const DEFAULT_CONSTRAINT_NODES: &[Node] = &[ const DEFAULT_CONSTRAINT_NODES: &[Node] = &[
Node::Word(Word::keyword("default")), Node::Word(Word::keyword("default")),
@@ -1066,7 +1086,13 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
Node::Subgrammar(&super::expr::OR_EXPR), Node::Subgrammar(&super::expr::OR_EXPR),
Node::Punct(')'), Node::Punct(')'),
]; ];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES); // Tagged as the `check` clause-concept region (issue #37). The span
// covers the whole `check ( <expr> )`, so a cursor inside the
// predicate still resolves the concept.
const CHECK_CONSTRAINT: Node = Node::Concept {
topic: "check",
inner: &Node::Seq(CHECK_CONSTRAINT_NODES),
};
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[ const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT, NOT_NULL_CONSTRAINT,
@@ -1158,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
}; };
const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST); const SPEC_LIST_OPT: Node = Node::Optional(&SPEC_LIST);
const WITH_PK_NODES: &[Node] = &[ // The `with pk` keywords, tagged as the `primary_key` clause-concept
// region (issue #37). Wrapping just the keywords (not the column
// `SPEC_LIST` that follows) keeps the concept crisp: a cursor on
// `with pk` teaches primary keys, while a cursor on a column's own
// `unique` / `check` constraint resolves to that narrower concept.
const WITH_PK_MARKER_NODES: &[Node] = &[
Node::Word(Word::keyword("with")), Node::Word(Word::keyword("with")),
Node::Word(Word::keyword("pk")), Node::Word(Word::keyword("pk")),
SPEC_LIST_OPT,
]; ];
const WITH_PK_MARKER: Node = Node::Concept {
topic: "primary_key",
inner: &Node::Seq(WITH_PK_MARKER_NODES),
};
const WITH_PK_NODES: &[Node] = &[WITH_PK_MARKER, SPEC_LIST_OPT];
const WITH_PK: Node = Node::Seq(WITH_PK_NODES); const WITH_PK: Node = Node::Seq(WITH_PK_NODES);
const WITH_PK_OPT: Node = Node::Optional(&WITH_PK); const WITH_PK_OPT: Node = Node::Optional(&WITH_PK);
@@ -1424,10 +1459,22 @@ const M2N_T2: Node = Node::Ident {
const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW]; const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW];
const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES)); const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES));
const CREATE_M2N_NODES: &[Node] = &[ // The `m:n` cardinality marker, tagged as the
// `cardinality_many_to_many` clause-concept region (issue #37).
// Mirrors `CARDINALITY_1N` — a distinct sub-`Seq` so the span covers
// exactly `m:n`.
const CARDINALITY_MN_NODES: &[Node] = &[
Node::Literal("m"), Node::Literal("m"),
Node::Punct(':'), Node::Punct(':'),
Node::Word(Word::keyword("n")), Node::Word(Word::keyword("n")),
];
const CARDINALITY_MN: Node = Node::Concept {
topic: "cardinality_many_to_many",
inner: &Node::Seq(CARDINALITY_MN_NODES),
};
const CREATE_M2N_NODES: &[Node] = &[
CARDINALITY_MN,
Node::Word(Word::keyword("relationship")), Node::Word(Word::keyword("relationship")),
Node::Word(Word::keyword("from")), Node::Word(Word::keyword("from")),
M2N_T1, M2N_T1,
+187
View File
@@ -476,6 +476,26 @@ pub enum Node {
mode: HintMode, mode: HintMode,
inner: &'static Self, inner: &'static Self,
}, },
/// Annotates `inner` as a recognized *clause-concept* region
/// (issue #37 / ADR clause-concept-hints, D1). Transparent to
/// matching, highlighting and the expected-set — it walks
/// `inner` and returns its result verbatim — but as a side
/// effect the walker records the byte span the clause covered
/// into `WalkContext::concept_spans`, tagged with `topic` (a
/// `hint.concept.<topic>` catalogue key). The F1 hint surface
/// reads those spans to layer a clause-level teaching block on
/// top of the per-form `hint.cmd.*` block when the cursor sits
/// inside the clause.
///
/// Sibling of `Hinted`, but where `Hinted` records a single
/// *pending* slot mode (cleared on the next match, so it marks
/// the slot the cursor is *about to fill*), `Concept` appends a
/// durable span that survives the clause being fully matched —
/// the "cursor anywhere inside the clause" semantics #37 wants.
Concept {
topic: &'static str,
inner: &'static Self,
},
} }
/// Which mode group a registered command belongs to (ADR-0030 /// Which mode group a registered command belongs to (ADR-0030
@@ -1087,6 +1107,173 @@ mod hint_key_tests {
"expected at least 49 hint.cmd.* examples, checked {checked}", "expected at least 49 hint.cmd.* examples, checked {checked}",
); );
} }
// ── Clause-concept comprehensiveness (issue #37 / clause-concept-
// hints D7) ────────────────────────────────────────────────────
/// A concept topic's mode reachability — drives both the example
/// shape (both-mode topics carry `example.simple`+`.advanced`;
/// single-mode topics a plain `example`) and the mode each example
/// must parse in.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ConceptModes {
Both,
SimpleOnly,
AdvancedOnly,
}
/// Every clause-concept topic + its mode coverage (D4). Source of
/// truth the gates below cross-check against the grammar wrappers
/// and the catalogue, so a wrapper or block can't drift out of sync.
const CONCEPT_TOPICS: &[(&str, ConceptModes)] = &[
("referential_actions", ConceptModes::Both),
("cardinality_one_to_many", ConceptModes::SimpleOnly),
("cardinality_many_to_many", ConceptModes::SimpleOnly),
("primary_key", ConceptModes::Both),
("unique", ConceptModes::Both),
("check", ConceptModes::Both),
("foreign_key", ConceptModes::AdvancedOnly),
];
/// Recursively collect every `Node::Concept` topic reachable from a
/// shape, following the static-child edges **and** `Subgrammar`/
/// `ScopedSubgrammar` references (the SQL `create table` body — and
/// thus the advanced `foreign_key` / constraint wrappers — hangs off
/// the command shape through a `Subgrammar`). Cycles in the
/// expression grammar are broken by a visited-by-address set; every
/// `Node` lives in static memory, so its address is a stable
/// identity. `DynamicSubgrammar` / `Lookahead` build nodes at walk
/// time and can't be traversed statically — no concept wrapper hides
/// behind one (asserted indirectly: every declared topic is reached).
fn collect_concept_topics(
node: &super::Node,
visited: &mut std::collections::BTreeSet<usize>,
out: &mut std::collections::BTreeSet<&'static str>,
) {
use super::Node;
let addr = std::ptr::from_ref(node) as usize;
if !visited.insert(addr) {
return;
}
match node {
Node::Concept { topic, inner } => {
out.insert(topic);
collect_concept_topics(inner, visited, out);
}
Node::Seq(children) | Node::Choice(children) => {
for c in *children {
collect_concept_topics(c, visited, out);
}
}
Node::Optional(inner)
| Node::Repeated { inner, .. }
| Node::Hinted { inner, .. }
| Node::TypedValueSlot { inner, .. }
| Node::Subgrammar(inner)
| Node::ScopedSubgrammar(inner) => {
collect_concept_topics(inner, visited, out);
}
_ => {}
}
}
/// Gate: the set of `Node::Concept` topics wired into the grammar
/// exactly matches `CONCEPT_TOPICS` — neither a wrapper without a
/// declared topic, nor a declared topic without a wrapper.
#[test]
fn concept_wrappers_match_declared_topics() {
let mut found = std::collections::BTreeSet::new();
let mut visited = std::collections::BTreeSet::new();
for (node, _category) in super::REGISTRY {
collect_concept_topics(&node.shape, &mut visited, &mut found);
}
let declared: std::collections::BTreeSet<&str> =
CONCEPT_TOPICS.iter().map(|(t, _)| *t).collect();
for t in &found {
assert!(
declared.contains(t),
"grammar has Node::Concept topic {t:?} missing from CONCEPT_TOPICS",
);
}
for (t, _) in CONCEPT_TOPICS {
assert!(
found.contains(t),
"CONCEPT_TOPICS lists {t:?} but no Node::Concept wrapper reaches it",
);
}
}
/// Gate: every topic resolves to a `hint.concept.<topic>` block with
/// `what` + `concept`, and the example shape matches its mode
/// coverage (mode-keyed for Both, plain for single-mode). `keys.rs`
/// checks referenced keys resolve; this checks every topic *has* a
/// block of the right shape.
#[test]
fn every_concept_topic_has_a_block() {
let cat = crate::friendly::catalog();
for (topic, modes) in CONCEPT_TOPICS {
for part in ["what", "concept"] {
assert!(
cat.get(&format!("hint.concept.{topic}.{part}")).is_some(),
"missing hint.concept.{topic}.{part}",
);
}
let simple = cat.get(&format!("hint.concept.{topic}.example.simple"));
let advanced = cat.get(&format!("hint.concept.{topic}.example.advanced"));
let plain = cat.get(&format!("hint.concept.{topic}.example"));
match modes {
ConceptModes::Both => {
assert!(
simple.is_some() && advanced.is_some() && plain.is_none(),
"{topic} is Both: needs example.simple + example.advanced, no plain example"
);
}
ConceptModes::SimpleOnly | ConceptModes::AdvancedOnly => {
assert!(
plain.is_some() && simple.is_none() && advanced.is_none(),
"{topic} is single-mode: needs a plain example, no mode-keyed variants"
);
}
}
}
}
/// Semantic guard (mirrors `every_cmd_hint_example_parses_in_its_mode`):
/// every clause-concept example parses in the mode it is taught for,
/// so an example can't drift out of the real grammar.
#[test]
fn every_concept_example_parses_in_its_mode() {
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
let cat = crate::friendly::catalog();
let parses = |key: &str, mode: Mode| {
let example = cat.get(key).unwrap_or_else(|| panic!("missing {key}"));
assert!(
parse_command_in_mode(example, mode).is_ok(),
"{key} does not parse in {mode:?} mode: {example:?}",
);
};
for (topic, modes) in CONCEPT_TOPICS {
match modes {
ConceptModes::Both => {
parses(
&format!("hint.concept.{topic}.example.simple"),
Mode::Simple,
);
parses(
&format!("hint.concept.{topic}.example.advanced"),
Mode::Advanced,
);
}
ConceptModes::SimpleOnly => {
parses(&format!("hint.concept.{topic}.example"), Mode::Simple);
}
ConceptModes::AdvancedOnly => {
parses(&format!("hint.concept.{topic}.example"), Mode::Advanced);
}
}
}
}
} }
#[cfg(test)] #[cfg(test)]
+14 -1
View File
@@ -133,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
/// Repeated `on <target> <action>` clauses (0..2 occurrences). /// Repeated `on <target> <action>` clauses (0..2 occurrences).
/// Validation of "specified twice" + max=2 lives in the /// Validation of "specified twice" + max=2 lives in the
/// command's AST builder. /// command's AST builder.
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated { const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
inner: &ON_CLAUSE, inner: &ON_CLAUSE,
separator: None, separator: None,
min: 0, min: 0,
}; };
/// As [`REFERENTIAL_CLAUSES_INNER`], tagged as the
/// `referential_actions` clause-concept region (issue #37).
///
/// Shared by the simple-mode `add 1:n relationship … on delete/update`
/// and the advanced-mode `references … on delete/update` clause, so the
/// one wrapper surfaces the concept in both modes (the mode-keyed
/// example is chosen by the live mode at F1 time, not by which grammar
/// matched).
pub const REFERENTIAL_CLAUSES: Node = Node::Concept {
topic: "referential_actions",
inner: &REFERENTIAL_CLAUSES_INNER,
};
// ================================================================= // =================================================================
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots) // Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
// ================================================================= // =================================================================
+27 -5
View File
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
shared::REFERENTIAL_CLAUSES, shared::REFERENTIAL_CLAUSES,
]; ];
const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES); const REFERENCES_CLAUSE: Node = Node::Seq(REFERENCES_NODES);
// The `foreign_key` clause-concept region (issue #37) — wraps the
// whole `references …` clause. It contains `shared::REFERENTIAL_CLAUSES`
// (the `referential_actions` concept), so a cursor on `references
// <parent>` resolves to `foreign_key` while a cursor on the inner `on
// delete …` resolves to the narrower `referential_actions` (D2
// innermost-wins).
const REFERENCES_CONCEPT: Node = Node::Concept {
topic: "foreign_key",
inner: &REFERENCES_CLAUSE,
};
// `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` | // `NOT NULL` | `UNIQUE` | `PRIMARY KEY` | `DEFAULT <expr>` |
// `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the // `CHECK (<expr>)`. Each branch starts on a distinct keyword, so the
// `Choice` never ambiguously commits. // `Choice` never ambiguously commits. The relational-concept branches
// (`unique`, `primary key`, `check`, `references`) carry a
// `Node::Concept` tag (issue #37); `not null` / `default` deliberately
// do not (D4 omission).
static COL_CONSTRAINT_CHOICES: &[Node] = &[ static COL_CONSTRAINT_CHOICES: &[Node] = &[
Node::Seq(NOT_NULL_NODES), Node::Seq(NOT_NULL_NODES),
Node::Word(Word::keyword("unique")), Node::Concept {
Node::Seq(PRIMARY_KEY_NODES), topic: "unique",
inner: &Node::Word(Word::keyword("unique")),
},
Node::Concept {
topic: "primary_key",
inner: &Node::Seq(PRIMARY_KEY_NODES),
},
Node::Seq(DEFAULT_NODES), Node::Seq(DEFAULT_NODES),
Node::Seq(CHECK_NODES), Node::Concept {
REFERENCES_CLAUSE, topic: "check",
inner: &Node::Seq(CHECK_NODES),
},
REFERENCES_CONCEPT,
]; ];
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES); const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
/// Zero-or-more column constraints after the type (`min: 0`). /// Zero-or-more column constraints after the type (`min: 0`).
+27
View File
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
/// resolver reads this directly instead of inferring the /// resolver reads this directly instead of inferring the
/// slot kind from the shape of the expected set. /// slot kind from the shape of the expected set.
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>, pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// Byte spans of the clause-concept regions the walk passed
/// through (issue #37 / ADR clause-concept-hints, D1).
///
/// A `Node::Concept { topic, inner }` wrapper pushes one entry
/// per *committed* inner walk (Matched / Incomplete / Failed —
/// never on NoMatch). Unlike `pending_hint_mode`, this is
/// **append-only and never cleared on match**, so a fully-typed
/// clause keeps its span — that is what lets the F1
/// clause-concept resolver report a concept for a cursor parked
/// *inside* already-typed clause text, not only at the slot
/// boundary.
pub concept_spans: Vec<ConceptSpan>,
/// An `IntroProse` hint captured from an *optional* slot that /// An `IntroProse` hint captured from an *optional* slot that
/// the walk skipped (issue #26). Unlike `pending_hint_mode` /// the walk skipped (issue #26). Unlike `pending_hint_mode`
/// (cleared on the very next match — including the empty match /// (cleared on the very next match — including the empty match
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
pub cte_name_span: (usize, usize), pub cte_name_span: (usize, usize),
} }
/// A clause-concept region recorded during a walk (issue #37).
///
/// `topic` is the `hint.concept.<topic>` catalogue key; `[start,
/// end]` is the inclusive byte range the clause covered as typed so
/// far (`end` is the matched end on a full match, or the stop
/// position on an incomplete / failed inner).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConceptSpan {
pub topic: &'static str,
pub start: usize,
pub end: usize,
}
impl<'a> WalkContext<'a> { impl<'a> WalkContext<'a> {
/// Schemaless walk context — the legacy default used by /// Schemaless walk context — the legacy default used by
/// pre-Phase-D callers and tests that don't care about /// pre-Phase-D callers and tests that don't care about
@@ -243,6 +268,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None, pending_value_type: None,
pending_value_column: None, pending_value_column: None,
pending_hint_mode: None, pending_hint_mode: None,
concept_spans: Vec::new(),
surviving_intro_hint: None, surviving_intro_hint: None,
user_listed_columns: None, user_listed_columns: None,
subgrammar_depth: 0, subgrammar_depth: 0,
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None, pending_value_type: None,
pending_value_column: None, pending_value_column: None,
pending_hint_mode: None, pending_hint_mode: None,
concept_spans: Vec::new(),
surviving_intro_hint: None, surviving_intro_hint: None,
user_listed_columns: None, user_listed_columns: None,
subgrammar_depth: 0, subgrammar_depth: 0,
+27
View File
@@ -290,6 +290,33 @@ fn walk_node_inner(
ctx.pending_hint_mode = Some(*mode); ctx.pending_hint_mode = Some(*mode);
walk_node(source, pos, inner, ctx, path, per_byte) walk_node(source, pos, inner, ctx, path, per_byte)
} }
Node::Concept { topic, inner } => {
// Issue #37 / ADR clause-concept-hints D1. Walk the
// inner clause, then record its covered byte span in
// `ctx.concept_spans` iff the inner *committed* — i.e.
// matched, ran out mid-clause (Incomplete), or hit a
// hard failure after engaging (Failed). On `NoMatch`
// the node never engaged (e.g. a `Choice` tried this
// branch and it didn't apply), so record nothing — no
// stale span. `start` is the post-whitespace position
// the walk wrapper already resolved.
let result = walk_node(source, pos, inner, ctx, path, per_byte);
let end = match &result {
NodeWalkResult::Matched { end, .. } => Some(*end),
NodeWalkResult::Incomplete { position, .. }
| NodeWalkResult::Failed { position, .. } => Some(*position),
NodeWalkResult::NoMatch { .. } => None,
};
if let Some(end) = end {
ctx.concept_spans
.push(crate::dsl::walker::context::ConceptSpan {
topic,
start: pos,
end,
});
}
result
}
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte), Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
Node::Repeated { Node::Repeated {
inner, inner,
+186 -1
View File
@@ -25,7 +25,7 @@ use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node};
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult}; use crate::dsl::walker::outcome::{Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult};
pub use context::ColumnInfo; pub use context::{ColumnInfo, ConceptSpan};
pub use highlight::{highlight_runs, highlight_runs_in_mode}; pub use highlight::{highlight_runs, highlight_runs_in_mode};
pub use outcome::{Diagnostic, Severity}; pub use outcome::{Diagnostic, Severity};
@@ -167,6 +167,46 @@ pub fn hint_resolution_at_input_in_mode(
} }
} }
/// Resolve the clause-concept topic the cursor sits inside, if any
/// (issue #37 / ADR clause-concept-hints, D2).
///
/// Walks the **full** `source` (not a cursor-trimmed prefix — the
/// dormant `WalkBound::Position` path is deliberately not used, so
/// the whole parse and every clause span is available), collects the
/// `Node::Concept` spans recorded in `ctx.concept_spans`, and returns
/// the `topic` of the **innermost** span containing `cursor`. The F1
/// hint surface layers `hint.concept.<topic>` on top of the per-form
/// block when this is `Some`.
///
/// "Innermost" = the containing span with the latest `start`, tie-
/// broken by the earliest `end` (narrowest): when a `references`
/// clause (`foreign_key`) wraps an `on delete` clause
/// (`referential_actions`), a cursor on `on delete` resolves to the
/// more specific inner concept. Containment is inclusive at both ends
/// so the boundary case (cursor exactly at the clause start) also
/// resolves.
#[must_use]
pub fn concept_topic_at_cursor(
source: &str,
cursor: usize,
schema: Option<&crate::completion::SchemaCache>,
mode: crate::mode::Mode,
) -> Option<&'static str> {
if source.trim().is_empty() {
return None;
}
let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
context::WalkContext::with_schema(s)
});
ctx.mode = mode;
let _ = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
ctx.concept_spans
.iter()
.filter(|s| s.start <= cursor && cursor <= s.end)
.max_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)))
.map(|s| s.topic)
}
/// Auto-generated columns a Form B insert skips from its value /// Auto-generated columns a Form B insert skips from its value
/// list — but only when the cursor sits at the *first* value /// list — but only when the cursor sits at the *first* value
/// slot, so the pedagogical note fires once per command rather /// slot, so the pedagogical note fires once per command rather
@@ -7028,3 +7068,148 @@ mod order_by_expected_set_tests {
); );
} }
} }
#[cfg(test)]
mod concept_hint_tests {
//! Issue #37 / ADR clause-concept-hints — `concept_topic_at_cursor`.
//!
//! The cursor is positioned by `find`-ing a substring so the
//! tests don't hard-code byte offsets. "inside" means the cursor
//! sits within already-typed clause text (the cursor-inside
//! semantics, not merely the slot boundary).
use super::concept_topic_at_cursor;
use crate::mode::Mode;
/// Cursor at the START of `needle` within `src`.
fn at(src: &str, needle: &str) -> usize {
src.find(needle).expect("needle present")
}
/// Cursor in the MIDDLE of `needle` within `src` (inside typed text).
fn inside(src: &str, needle: &str) -> usize {
at(src, needle) + needle.len() / 2
}
fn topic(src: &str, cursor: usize, mode: Mode) -> Option<&'static str> {
concept_topic_at_cursor(src, cursor, None, mode)
}
#[test]
fn referential_actions_at_on_delete_boundary() {
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
assert_eq!(
topic(s, at(s, "on delete"), Mode::Simple),
Some("referential_actions"),
);
}
#[test]
fn referential_actions_inside_already_typed_action() {
// Cursor parked inside the finished `cascade` — the
// cursor-inside case the slot-boundary mechanism can't serve.
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
assert_eq!(
topic(s, inside(s, "cascade"), Mode::Simple),
Some("referential_actions"),
);
}
#[test]
fn cardinality_one_to_many_on_marker() {
let s = "add 1:n relationship from Customers.id to Orders.cid";
assert_eq!(
topic(s, inside(s, "1:n"), Mode::Simple),
Some("cardinality_one_to_many"),
);
}
#[test]
fn cardinality_many_to_many_on_marker() {
let s = "create m:n relationship from Students to Courses";
assert_eq!(
topic(s, inside(s, "m:n"), Mode::Simple),
Some("cardinality_many_to_many"),
);
}
#[test]
fn primary_key_in_with_pk_simple() {
let s = "create table Orders with pk Code(text)";
assert_eq!(
topic(s, inside(s, "with pk"), Mode::Simple),
Some("primary_key"),
);
}
#[test]
fn primary_key_in_create_table_constraint_advanced() {
let s = "create table Orders (id int primary key)";
assert_eq!(
topic(s, inside(s, "primary key"), Mode::Advanced),
Some("primary_key"),
);
}
#[test]
fn unique_constraint_simple() {
let s = "create table Ages with pk age(int) unique";
assert_eq!(topic(s, inside(s, "unique"), Mode::Simple), Some("unique"));
}
#[test]
fn unique_constraint_advanced() {
let s = "create table Orders (Code text unique)";
assert_eq!(
topic(s, inside(s, "unique"), Mode::Advanced),
Some("unique")
);
}
#[test]
fn check_constraint_simple() {
let s = "create table Ages with pk age(int) check (age > 0)";
assert_eq!(topic(s, inside(s, "check"), Mode::Simple), Some("check"));
}
#[test]
fn check_constraint_advanced() {
let s = "create table Orders (Qty int check (Qty > 0))";
assert_eq!(topic(s, inside(s, "check"), Mode::Advanced), Some("check"));
}
#[test]
fn foreign_key_references_advanced() {
let s = "create table Orders (cid int references Customers(id))";
assert_eq!(
topic(s, inside(s, "references"), Mode::Advanced),
Some("foreign_key"),
);
}
#[test]
fn nested_on_delete_inside_references_resolves_innermost() {
// `references … on delete cascade` nests referential_actions
// inside foreign_key; cursor on `on delete` → inner concept.
let s = "create table Orders (cid int references Customers(id) on delete cascade)";
assert_eq!(
topic(s, at(s, "on delete"), Mode::Advanced),
Some("referential_actions"),
);
// …but a cursor on `references` → the outer foreign_key.
assert_eq!(
topic(s, inside(s, "references"), Mode::Advanced),
Some("foreign_key"),
);
}
#[test]
fn cursor_outside_any_clause_is_none() {
let s = "add 1:n relationship from Customers.id to Orders.cid on delete cascade";
// On the entry word `add` — no concept clause there.
assert_eq!(topic(s, at(s, "add"), Mode::Simple), None);
}
#[test]
fn empty_input_is_none() {
assert_eq!(topic("", 0, Mode::Simple), None);
}
}
+30
View File
@@ -237,9 +237,39 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.ambient_expected", &["expected"]), ("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]), ("hint.getting_started", &[]),
("hint.block.heading", &[]), ("hint.block.heading", &[]),
("hint.block.concept_heading", &[]),
("hint.block.what", &[]), ("hint.block.what", &[]),
("hint.block.example", &[]), ("hint.block.example", &[]),
("hint.block.concept", &[]), ("hint.block.concept", &[]),
// Clause-concept blocks (issue #37 / clause-concept-hints D5).
// Both-mode topics carry a mode-keyed `example` (simple + advanced);
// single-mode topics carry a plain `example`. The comprehensiveness
// test (CONCEPT_TOPICS) enforces this shape per topic.
("hint.concept.referential_actions.what", &[]),
("hint.concept.referential_actions.example.simple", &[]),
("hint.concept.referential_actions.example.advanced", &[]),
("hint.concept.referential_actions.concept", &[]),
("hint.concept.cardinality_one_to_many.what", &[]),
("hint.concept.cardinality_one_to_many.example", &[]),
("hint.concept.cardinality_one_to_many.concept", &[]),
("hint.concept.cardinality_many_to_many.what", &[]),
("hint.concept.cardinality_many_to_many.example", &[]),
("hint.concept.cardinality_many_to_many.concept", &[]),
("hint.concept.primary_key.what", &[]),
("hint.concept.primary_key.example.simple", &[]),
("hint.concept.primary_key.example.advanced", &[]),
("hint.concept.primary_key.concept", &[]),
("hint.concept.unique.what", &[]),
("hint.concept.unique.example.simple", &[]),
("hint.concept.unique.example.advanced", &[]),
("hint.concept.unique.concept", &[]),
("hint.concept.check.what", &[]),
("hint.concept.check.example.simple", &[]),
("hint.concept.check.example.advanced", &[]),
("hint.concept.check.concept", &[]),
("hint.concept.foreign_key.what", &[]),
("hint.concept.foreign_key.example", &[]),
("hint.concept.foreign_key.concept", &[]),
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars. // Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
("hint.cmd.insert.what", &[]), ("hint.cmd.insert.what", &[]),
("hint.cmd.insert.example", &[]), ("hint.cmd.insert.example", &[]),
+49
View File
@@ -426,9 +426,58 @@ hint:
# `what` / `example` / `concept` parts render under. # `what` / `example` / `concept` parts render under.
block: block:
heading: "Hint" heading: "Hint"
# Sub-heading for the clause-concept block layered beneath the
# per-form block when the cursor sits inside a recognized clause
# (issue #37 / clause-concept-hints D6).
concept_heading: "About this clause"
what: "What" what: "What"
example: "Example" example: "Example"
concept: "Concept" concept: "Concept"
# ── Tier-3 clause-concept blocks (issue #37 / clause-concept-hints) ─
# Surfaced by F1 when the cursor sits inside a recognized clause,
# layered on top of the per-form `hint.cmd.*` block. Same
# `what`/`example`/`concept` shape; topics reachable in BOTH modes
# carry a mode-keyed `example` (`simple` + `advanced`) so the example
# is syntax-correct for the mode the user is actually in (ADR-0053 D6).
# Single-mode topics carry one plain `example`. Copy rules: no engine
# name; "simple mode"/"advanced mode", never "DSL".
concept:
referential_actions:
what: "Decide what happens to a child row when the parent it points at is deleted, or its key changes."
example:
simple: "add 1:n relationship from Customers.id to Orders.customer_id on delete cascade"
advanced: "create table Orders (id int primary key, customer_id int references Customers(id) on delete cascade)"
concept: "A foreign key forbids orphans by default (restrict — the delete is refused). `cascade` deletes the children along with the parent; `set null` keeps the children but clears their link. The action you pick encodes a real rule about your data."
cardinality_one_to_many:
what: "Link two tables so one parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "\"1:n\" is one parent, many children — one customer, many orders. The child table holds a foreign key column pointing back at the parent; that shared value is what groups a parent's children together."
cardinality_many_to_many:
what: "Link two tables when rows on each side can relate to many rows on the other."
example: "create m:n relationship from Students to Courses"
concept: "\"m:n\" — many students take many courses — can't be stored as one foreign key. A junction table sits between them, holding one row per pairing; it is created for you, turning the m:n into two 1:n links."
primary_key:
what: "Mark the column (or columns) that uniquely identify each row."
example:
simple: "create table Customers with pk id(serial), Name(text)"
advanced: "create table Customers (id int primary key, Name text)"
concept: "A primary key is the row's identity: every row must have one, and no two rows may share it. Other tables point at this key to reference the row. List several columns for a compound key when one column isn't unique on its own."
unique:
what: "Require that no two rows share the same value in this column."
example:
simple: "create table Customers with pk id(serial), Email(text) unique"
advanced: "create table Customers (id int primary key, Email text unique)"
concept: "Like a primary key, `unique` forbids duplicates — but a table has one primary key (its identity) and may have many `unique` columns (e.g. email, username). Unlike the primary key, a unique column may usually be left empty."
check:
what: "Attach a rule each row must satisfy before it can be saved."
example:
simple: "create table Products with pk id(serial), Price(decimal) check (Price >= 0)"
advanced: "create table Products (id int primary key, Price decimal check (Price >= 0))"
concept: "A check guards the data at write time: any insert or update that breaks the rule is refused, so invalid rows never reach the table. Use it to enforce real-world constraints — a non-negative price, a rating between 1 and 5."
foreign_key:
what: "Make this column point at a row in another table."
example: "create table Orders (id int primary key, customer_id int references Customers(id))"
concept: "A foreign key is a promise that every value here matches a real row in the parent table — no orphaned references. The parent must exist before the child can point at it; an `on delete`/`on update` action decides what happens when that parent later changes."
# ── Tier-3 teaching blocks (ADR-0053 D3) ────────────────────────── # ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
# Per-form command hints (`hint.cmd.<form>`) and per-class error # Per-form command hints (`hint.cmd.<form>`) and per-class error
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example` # hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
@@ -0,0 +1,12 @@
---
source: src/app.rs
expression: block
---
Hint
What: Link two tables so a parent row can own many child rows.
Example: add 1:n relationship from Customers.id to Orders.customer_id
Concept: The "1:n" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet.
About this clause
What: Decide what happens to a child row when the parent it points at is deleted, or its key changes.
Example: add 1:n relationship from Customers.id to Orders.customer_id on delete cascade
Concept: A foreign key forbids orphans by default (restrict — the delete is refused). `cascade` deletes the children along with the parent; `set null` keeps the children but clears their link. The action you pick encodes a real rule about your data.