Compare commits
19
Commits
6b4c4dcea4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b495dfed | ||
|
|
78272e2fd0 | ||
|
|
e1d7419a72 | ||
|
|
6e28e58e45 | ||
|
|
5c51a00e72 | ||
|
|
ec7a8ff3be | ||
|
|
f06ee3b460 | ||
|
|
279ef98ab8 | ||
|
|
4570c4e1ea | ||
|
|
3585cca5ea | ||
|
|
63d11402ae | ||
|
|
8ccecc4245 | ||
|
|
b5c848efcb | ||
|
|
6d4364666a | ||
|
|
48fc5e8202 | ||
|
|
9812463620 | ||
|
|
208da81108 | ||
|
|
e845a6ee72 | ||
|
|
c9d6660ba6 |
@@ -14,11 +14,16 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
# Branch pushes only — a tag push hits the same commit the branch push
|
||||
# already gated, so `branches: ['**']` drops the redundant tag-triggered
|
||||
# run (the release workflow owns tags). Pushing commits + a tag together
|
||||
# still gates the commits via the branch push.
|
||||
branches: ['**']
|
||||
# Only `main` (the post-merge gate + canonical branch). Feature branches
|
||||
# are gated via `pull_request` instead. Running both on a push to a PR'd
|
||||
# branch was pure duplication on Gitea: unlike GitHub it has no merge-
|
||||
# preview ref — its `pull_request` checks out `refs/pull/N/head`, the same
|
||||
# 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,
|
||||
# and the website subproject (it has its own workflow, website.yaml, that
|
||||
# builds + publishes it). A push touching crate code *and* these still runs
|
||||
|
||||
@@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### 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`,
|
||||
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
|
||||
|
||||
@@ -115,15 +115,23 @@ Current decisions at a glance (each backed by an ADR):
|
||||
a self-hosted
|
||||
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
||||
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
||||
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
|
||||
`cargo test` on every branch push / PR. **Release** on a `v*` tag
|
||||
prebuilt CI image. **Gate** (`ci.yaml`, every step run via `nix
|
||||
develop -c` so dev and CI share the pinned toolchain): `cargo fmt
|
||||
--check` + `clippy -D warnings` + `cargo test --no-fail-fast` on
|
||||
every branch push / PR. **Verify locally the CI way before pushing**
|
||||
— `nix develop -c cargo fmt --check`, `nix develop -c cargo clippy
|
||||
--all-targets -- -D warnings`, `nix develop -c cargo test` — and read
|
||||
the **exit code**, not piped output (a `cargo fmt --check | tail &&
|
||||
echo clean` masks the real result; a bare host `cargo` may also differ
|
||||
from the pinned toolchain). **Release** on a `v*` tag
|
||||
(`release.yaml`): the four non-macOS **D1** targets cross-built
|
||||
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
||||
`.exe`); the two macOS targets via the **dispatched**
|
||||
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
||||
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
||||
release with `.sha256`s. **`fmt` is intentionally not gated yet**
|
||||
(the tree isn't stock-`rustfmt`-clean). Now that this is on `main`,
|
||||
release with `.sha256`s. **`fmt` is gated** (`cargo fmt --check`,
|
||||
stock defaults — enabled once the tree was reformatted on `main`,
|
||||
ADR-ci-002 Amendment 1 / issue #35). Now that this is on `main`,
|
||||
`release-macos` is dispatchable (`workflow_dispatch` is
|
||||
Gitea-default-branch-only) — **dispatched and verified working**: the
|
||||
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
|
||||
@@ -264,6 +272,46 @@ Key invariants in the code:
|
||||
`git commit` is preceded by an explicit message proposal
|
||||
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`
|
||||
|
||||
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Unless you explicitly state otherwise, any contribution you intentionally
|
||||
|
||||
@@ -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
|
||||
renumbering the former to ADR-0044.) To prevent it:
|
||||
|
||||
**Assign an ADR's number at merge-to-`main`, not at creation.** While the
|
||||
work lives on a non-`main` branch, draft the ADR under a placeholder — an
|
||||
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that
|
||||
way from any plan or notes. Give it the next free number only when the
|
||||
branch merges to `main`, renaming the file and updating its references in
|
||||
the same step.
|
||||
**Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
|
||||
which superseded the earlier placeholder-until-merge default). The moment
|
||||
you know an ADR is needed — at branch start or mid-branch — run
|
||||
`scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
|
||||
free number against `main` (the remote ref is the registry; a `git push`
|
||||
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`,
|
||||
which is the single source of truth for the next free number — never
|
||||
compute "next" from a feature branch. A branch that genuinely needs a real
|
||||
number up front may instead reserve one by landing a stub index entry on
|
||||
`main` first, but placeholder-until-merge is the default.
|
||||
A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
|
||||
`main`; the script reads both to compute the next free number — never
|
||||
compute "next" by hand from a feature branch. The full rationale (why
|
||||
reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
|
||||
is in **ADR-0059**.
|
||||
|
||||
### Subproject ADR namespaces
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
0058 clause-concept-hints 2026-06-23 Clause-concept hints
|
||||
0059 dev-workflow 2026-06-23 Branch-and-PR working method
|
||||
@@ -7,15 +7,15 @@ Amendment 2), which grew out of a one-line copy-rule fix.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch `main`.** Commits this session so far (all on `main`, **not pushed**):
|
||||
`07575da` (#39 gate) · `e88fa79` (handoff-77 + changelog rule) · `3ad4aff`
|
||||
(#36 help) · `64818c0` (handoff-78) · `1a2002d` (**ADR-0005 Amendment 2** —
|
||||
the blob-drop decision record).
|
||||
**Branch `main`.** Commits this session (`1a2002d` ADR-0005 Am2 + `6b4c4dc`
|
||||
blob removal, plus the earlier `07575da` #39 · `e88fa79` handoff-77 · `3ad4aff`
|
||||
#36 · `64818c0` handoff-78) — **the user pushed these.** A follow-up
|
||||
**`style:` rustfmt commit + this CLAUDE.md/handoff doc commit** land on top
|
||||
(see §5) and are the only unpushed work at handoff time.
|
||||
|
||||
**Uncommitted: the blob-removal implementation** (39 files) — staged for review,
|
||||
commit proposed at session end (see §4). **Full suite green: 2520 passed / 0
|
||||
failed / 1 ignored** (1810 lib + 7 e2e_pty + 503 it + 200 typing).
|
||||
`clippy --all-targets -D warnings` + `fmt --check` clean.
|
||||
**Suite green: 2521 passed / 0 failed / 1 ignored** (1810 lib + 8 e2e_pty + 503
|
||||
it + 200 typing), `fmt --check` + `clippy -D warnings` clean — **verified via
|
||||
`nix develop -c`** (the CI-exact way; see §5's lesson).
|
||||
|
||||
**Closed this session:** #36, #39. **Open:** #37 (clause hints), #38
|
||||
(diagnostic route — needs a do/defer/close call), #40 (winget release notes).
|
||||
@@ -92,16 +92,34 @@ is deleted outright.
|
||||
## §4. Process pins / next
|
||||
|
||||
- Commits user-confirmed, no AI attribution, append-only, on `main`; **push is
|
||||
the user's step** (everything this session is unpushed).
|
||||
the user's step.**
|
||||
- **`/runda` was run on both the ADR (design) and the implementation.** The
|
||||
implementation pass found one real gap — the runtime's migrate-on-open glue
|
||||
was untested — closed with the new Tier-4 PTY test. It also verified the
|
||||
import path migrates (extract→migrate→rebuild) and the non-migration
|
||||
`parse_schema` callers fail-safe.
|
||||
- Proposed commit split for the implementation: (1) `feat(types)!` — code +
|
||||
tests + CHANGELOG; (2) `docs` — the CLAUDE.md/requirements/website/ADR-x-ref
|
||||
sweep; (3) `docs(handoff-79)`.
|
||||
- Consider a `cargo sweep` at this milestone.
|
||||
- **Next open issues:** #37 (clause hints, on-mission), #38 (escalate
|
||||
do/defer/close first), #40 (winget release notes). Plus: blob removal means a
|
||||
`cargo sweep`-worthy build and a good moment to push.
|
||||
do/defer/close first), #40 (winget release notes).
|
||||
|
||||
## §5. CI/fmt lesson (cost a red CI run — don't repeat)
|
||||
|
||||
The blob commit `6b4c4dc` **failed the CI `fmt` gate** (run 92, `ci.yaml`).
|
||||
Two compounding causes, both fixed here:
|
||||
|
||||
1. **`fmt` IS gated now** (`cargo fmt --check`, ADR-ci-002 Amendment 1 / issue
|
||||
#35) — but `CLAUDE.md` still said "fmt is intentionally not gated yet." That
|
||||
stale note bred complacency. **Corrected `CLAUDE.md`** (the gate is fmt +
|
||||
clippy + test, run via `nix develop -c`).
|
||||
2. **A broken local check masked the failure:** `cargo fmt --check 2>&1 | tail
|
||||
-1 && echo "fmt clean"` tests `tail`'s exit code (always 0), **not** fmt's —
|
||||
so "fmt clean" printed over real diffs. **Always read the exit code**, never
|
||||
a piped `… | tail && echo clean`.
|
||||
|
||||
**Going forward, verify the CI way:** `nix develop -c cargo fmt --check` /
|
||||
`nix develop -c cargo clippy --all-targets -- -D warnings` / `nix develop -c
|
||||
cargo test` — matching the pinned 1.95.0 toolchain *and* the devShell env, and
|
||||
checking exit codes. (A bare host `cargo` happened to be 1.95.0 this time, so
|
||||
the divergence was the masking pipe, not the toolchain — but `nix develop -c`
|
||||
guards both.) The `style:` rustfmt fix + this doc update land on top of
|
||||
`6b4c4dc`; CI re-runs green on the next push.
|
||||
|
||||
@@ -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).
|
||||
Executable
+141
@@ -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?"
|
||||
Executable
+82
@@ -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 ]
|
||||
Executable
+87
@@ -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 ]
|
||||
Executable
+60
@@ -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"
|
||||
Executable
+68
@@ -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
@@ -3205,8 +3205,24 @@ impl App {
|
||||
let probe = view.to_string();
|
||||
let mode = self.effective_mode().as_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;
|
||||
}
|
||||
// Tier-2 fallback: surface the ambient prose as a persistent
|
||||
@@ -3242,7 +3258,7 @@ impl App {
|
||||
/// (ADR-0053 D2/D5).
|
||||
fn note_hint_for_recent_error(&mut self) {
|
||||
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;
|
||||
}
|
||||
@@ -3258,28 +3274,51 @@ impl App {
|
||||
/// absent so the caller can fall back to tier 2. `what` is
|
||||
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
|
||||
/// 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 what_key = format!("{stem}.what");
|
||||
if cat.get(&what_key).is_none() {
|
||||
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
|
||||
// muted (`OutputStyleClass::Hint`); the rest are plain system.
|
||||
let labelled = |label: &str, value: &str| {
|
||||
// Pad `<Label>:` to a common width so the values align.
|
||||
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(
|
||||
&crate::t!("hint.block.what"),
|
||||
&crate::friendly::translate(&what_key, &[]),
|
||||
));
|
||||
if cat.get(&format!("{stem}.example")).is_some() {
|
||||
if cat.get(&example_key).is_some() {
|
||||
self.note_system(labelled(
|
||||
&crate::t!("hint.block.example"),
|
||||
&crate::friendly::translate(&format!("{stem}.example"), &[]),
|
||||
&crate::friendly::translate(&example_key, &[]),
|
||||
));
|
||||
}
|
||||
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]
|
||||
fn f1_on_add_column_does_not_render_the_relationship_block() {
|
||||
// Per-form disambiguation (ADR-0053 D3): `add column` resolves
|
||||
|
||||
+53
-6
@@ -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 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::Punct(':'),
|
||||
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")),
|
||||
AR_AS_NAME_OPT,
|
||||
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 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] = &[
|
||||
Node::Word(Word::keyword("default")),
|
||||
@@ -1066,7 +1086,13 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
|
||||
Node::Subgrammar(&super::expr::OR_EXPR),
|
||||
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] = &[
|
||||
NOT_NULL_CONSTRAINT,
|
||||
@@ -1158,11 +1184,20 @@ const SPEC_LIST: Node = Node::Repeated {
|
||||
};
|
||||
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("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_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_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::Punct(':'),
|
||||
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("from")),
|
||||
M2N_T1,
|
||||
|
||||
@@ -476,6 +476,26 @@ pub enum Node {
|
||||
mode: HintMode,
|
||||
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
|
||||
@@ -1087,6 +1107,173 @@ mod hint_key_tests {
|
||||
"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)]
|
||||
|
||||
@@ -133,12 +133,25 @@ pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
|
||||
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
|
||||
/// Validation of "specified twice" + max=2 lives in the
|
||||
/// command's AST builder.
|
||||
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
|
||||
const REFERENTIAL_CLAUSES_INNER: Node = Node::Repeated {
|
||||
inner: &ON_CLAUSE,
|
||||
separator: None,
|
||||
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)
|
||||
// =================================================================
|
||||
|
||||
@@ -204,16 +204,38 @@ static REFERENCES_NODES: &[Node] = &[
|
||||
shared::REFERENTIAL_CLAUSES,
|
||||
];
|
||||
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>` |
|
||||
// `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] = &[
|
||||
Node::Seq(NOT_NULL_NODES),
|
||||
Node::Word(Word::keyword("unique")),
|
||||
Node::Seq(PRIMARY_KEY_NODES),
|
||||
Node::Concept {
|
||||
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(CHECK_NODES),
|
||||
REFERENCES_CLAUSE,
|
||||
Node::Concept {
|
||||
topic: "check",
|
||||
inner: &Node::Seq(CHECK_NODES),
|
||||
},
|
||||
REFERENCES_CONCEPT,
|
||||
];
|
||||
const COL_CONSTRAINT: Node = Node::Choice(COL_CONSTRAINT_CHOICES);
|
||||
/// Zero-or-more column constraints after the type (`min: 0`).
|
||||
|
||||
@@ -134,6 +134,18 @@ pub struct WalkContext<'a> {
|
||||
/// resolver reads this directly instead of inferring the
|
||||
/// slot kind from the shape of the expected set.
|
||||
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
|
||||
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
||||
/// (cleared on the very next match — including the empty match
|
||||
@@ -227,6 +239,19 @@ pub struct PendingCteHarvest {
|
||||
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> {
|
||||
/// Schemaless walk context — the legacy default used by
|
||||
/// 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_column: None,
|
||||
pending_hint_mode: None,
|
||||
concept_spans: Vec::new(),
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
@@ -266,6 +292,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
concept_spans: Vec::new(),
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
|
||||
@@ -290,6 +290,33 @@ fn walk_node_inner(
|
||||
ctx.pending_hint_mode = Some(*mode);
|
||||
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::Repeated {
|
||||
inner,
|
||||
|
||||
+186
-1
@@ -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::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 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
|
||||
/// list — but only when the cursor sits at the *first* value
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,9 +237,39 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("hint.ambient_expected", &["expected"]),
|
||||
("hint.getting_started", &[]),
|
||||
("hint.block.heading", &[]),
|
||||
("hint.block.concept_heading", &[]),
|
||||
("hint.block.what", &[]),
|
||||
("hint.block.example", &[]),
|
||||
("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.
|
||||
("hint.cmd.insert.what", &[]),
|
||||
("hint.cmd.insert.example", &[]),
|
||||
|
||||
@@ -426,9 +426,58 @@ hint:
|
||||
# `what` / `example` / `concept` parts render under.
|
||||
block:
|
||||
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"
|
||||
example: "Example"
|
||||
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) ──────────────────────────
|
||||
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
||||
|
||||
@@ -394,8 +394,7 @@ mod tests {
|
||||
" - \"note <> 'type: blob'\"\n",
|
||||
"relationships: []\n",
|
||||
);
|
||||
let outcome =
|
||||
migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||
let outcome = migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, Some(1));
|
||||
assert!(outcome.body.contains("version: 2"));
|
||||
// The blob column became text.
|
||||
|
||||
+2
-1
@@ -180,7 +180,8 @@ pub async fn run(args: Args) -> Result<()> {
|
||||
// force a `.db` rebuild after the v1→v2 migration converts it to text
|
||||
// (the stale `.db` keeps a `STRICT … BLOB` engine column + `"blob"`
|
||||
// metadata that load otherwise uses as-is).
|
||||
let had_blob_column = std::fs::read_to_string(project.path().join(crate::project::PROJECT_YAML))
|
||||
let had_blob_column =
|
||||
std::fs::read_to_string(project.path().join(crate::project::PROJECT_YAML))
|
||||
.map(|b| crate::persistence::migrations::body_declares_blob_column(&b))
|
||||
.unwrap_or(false);
|
||||
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||
|
||||
@@ -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.
|
||||
+5
-1
@@ -437,7 +437,11 @@ fn opens_a_legacy_v1_blob_project_by_migrating_to_text() {
|
||||
),
|
||||
)
|
||||
.expect("write project.yaml");
|
||||
fs::write(proj.join("data").join("Files.csv"), "id,payload\n1,aGVsbG8=\n").expect("write csv");
|
||||
fs::write(
|
||||
proj.join("data").join("Files.csv"),
|
||||
"id,payload\n1,aGVsbG8=\n",
|
||||
)
|
||||
.expect("write csv");
|
||||
fs::write(root.join("last_project"), format!("{}\n", proj.display())).expect("write resume");
|
||||
|
||||
// Open via --resume: the runtime migrates blob→text and rebuilds, so
|
||||
|
||||
@@ -55,15 +55,24 @@ fn v1_blob_project_migrates_to_text_and_rebuilds() {
|
||||
assert_eq!(outcome.migrated_from, Some(1), "v1 project was migrated");
|
||||
|
||||
let migrated = fs::read_to_string(root.join("project.yaml")).expect("read migrated");
|
||||
assert!(migrated.contains("version: 2"), "version bumped: {migrated}");
|
||||
assert!(
|
||||
migrated.contains("version: 2"),
|
||||
"version bumped: {migrated}"
|
||||
);
|
||||
assert!(
|
||||
migrated.contains("{ name: data, type: text }"),
|
||||
"blob column rewritten to text: {migrated}"
|
||||
);
|
||||
assert!(!migrated.contains("type: blob"), "no blob type remains: {migrated}");
|
||||
assert!(
|
||||
!migrated.contains("type: blob"),
|
||||
"no blob type remains: {migrated}"
|
||||
);
|
||||
// The pre-migration original is preserved as a .bak.
|
||||
let bak = fs::read_to_string(root.join("project.yaml.v1.bak")).expect("read bak");
|
||||
assert!(bak.contains("type: blob"), "bak keeps the original blob column");
|
||||
assert!(
|
||||
bak.contains("type: blob"),
|
||||
"bak keeps the original blob column"
|
||||
);
|
||||
|
||||
// 2. Rebuild from the migrated text (as the runtime forces when a blob
|
||||
// column was converted): the column is now `text` and the row data
|
||||
|
||||
Reference in New Issue
Block a user