@erclx/aitk 0.104.1 → 0.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-groundwork/SKILL.md +3 -2
- package/claude/skills/claude-worktree/SKILL.md +6 -1
- package/docs/agents/commands.md +2 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/test-order.md +56 -0
- package/docs/ai-workflow.md +2 -0
- package/governance/rules/core/070-planning.md +1 -0
- package/package.json +1 -1
- package/src/commands/gov.ts +135 -0
- package/src/gov/test-order.ts +383 -0
- package/standards/groundwork.md +4 -0
- package/tooling/astro/manifest.toml +5 -5
- package/tooling/vite-react/manifest.toml +4 -4
- package/tooling/web/configs/scripts/worktree-port.sh +44 -6
- package/tooling/web/manifest.toml +3 -3
- package/tooling/web/reference.md +4 -1
|
@@ -21,7 +21,8 @@ Read `.claude/standards/groundwork.md` from the project root before writing any
|
|
|
21
21
|
|
|
22
22
|
- Write only inside `.claude/groundwork/<slug>/`. A feature plan, source changes, a standard, a rule, and a reference doc all live outside that folder, so this one rule forbids every one of them.
|
|
23
23
|
- One exception, at close only: write one task file recording what the track concluded.
|
|
24
|
-
- A second exception, for
|
|
24
|
+
- A second exception, for what a spike reads: write an input under `.claude/.tmp/groundwork-fixtures/<slug>/`. Keep it out of `.claude/groundwork/` so mode detection never matches a fixture as a track. A fixture a headless run is pointed at goes outside the repository instead, per the rule in `## Running a spike`.
|
|
25
|
+
- What a spike produces stays inside the track rather than joining the two exceptions above: write evidence a spike file cites under `.claude/groundwork/<slug>/evidence/`, which the first rule already permits. Mode detection matches entries at the top level of the tracks directory, so the sibling the fixtures rule guards against cannot be a folder nested inside a track, and the scratch path holds only what can be deleted without loss, which a recording a finding rests on is not.
|
|
25
26
|
- Reading is not restricted. External research is in scope, so read documentation, comparable projects, and papers whenever a live question needs them.
|
|
26
27
|
- Every claim about a source outside the project carries a link to it. A source found and not read is listed as a lead and is never cited.
|
|
27
28
|
- Treat the folder as gitignored and unbacked. It dies with the machine, so `07-next-session.md` repeats what it needs instead of pointing at its siblings.
|
|
@@ -36,7 +37,7 @@ A track may run an experiment to settle an open question without stopping to ask
|
|
|
36
37
|
|
|
37
38
|
A fixture a headless run is pointed at goes outside the repository, under `mktemp -d`, and never under `.claude/.tmp/`. A session started anywhere beneath the project root loads that project's `CLAUDE.md`, `.claude/rules/`, and `.claude/standards/` through the ancestor chain, so an in-repo fixture measures the repository instead of the arm. Record the fixture as contaminated and re-run it outside if this is discovered after the fact, because the result reads as evidence either way.
|
|
38
39
|
|
|
39
|
-
Record method, result, measured cost, and caveats in `08-spikes.md`. Reach for a test harness the project already carries before building one, and copy how it isolates its fixture rather than only what it asserts.
|
|
40
|
+
Record method, result, measured cost, and caveats in `08-spikes.md`. Put whatever the run produced that the record cites in `evidence/` beside it, so a reader checking a claim opens the artifact rather than taking the prose for it. Reach for a test harness the project already carries before building one, and copy how it isolates its fixture rather than only what it asserts.
|
|
40
41
|
|
|
41
42
|
## Step 1: detect the mode
|
|
42
43
|
|
|
@@ -113,9 +113,14 @@ The last line is what keeps the step honest on a stack this skill cannot read. E
|
|
|
113
113
|
|
|
114
114
|
Then report the port this worktree derives, on a second line:
|
|
115
115
|
|
|
116
|
-
- `scripts/worktree-port.sh` present: run `bash scripts/worktree-port.sh` and emit `Port offset <n>. Every served port adds it to the stack default.`
|
|
116
|
+
- `scripts/worktree-port.sh` present and exiting zero: run `bash scripts/worktree-port.sh` and emit `Port offset <n>. Every served port adds it to the stack default.`
|
|
117
|
+
- Present and exiting non-zero: emit `The port helper refuses this directory, so no server here has a port. <its stderr>`
|
|
117
118
|
- Absent: `No port derivation installed, so every served port is the stack default.`
|
|
118
119
|
|
|
120
|
+
Branch on the exit rather than on the output, since the helper prints nothing to stdout when it refuses and reading that as a number reports an offset of zero, which is the main checkout's.
|
|
121
|
+
|
|
122
|
+
The helper refuses a folder left behind after its worktree was removed, which Step 4 cannot land on, since it registers whatever it creates. The branch is here so a refusal is never read back as an offset of zero, which is the main checkout's port and the collision the helper exists to prevent.
|
|
123
|
+
|
|
119
124
|
The offset is what `claude-orchestrate` sends a reader here to read rather than assign, and what an operator overrides through `WORKTREE_PORT_OFFSET` when two worktrees derive the same value. Deriving it correctly and printing it nowhere leaves both instructions naming a number no surface emits.
|
|
120
125
|
|
|
121
126
|
Do not invoke `ExitWorktree` from this skill. Exit is the user's call.
|
package/docs/agents/commands.md
CHANGED
|
@@ -45,6 +45,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
|
|
|
45
45
|
| `aitk markdown audit` | Fail any markdown path on a banned character, word, or spelling, and report the structural checkpoints |
|
|
46
46
|
| `aitk claude skills audit` | Report both skill corpora against the mechanical rules in `standards/skill.md` |
|
|
47
47
|
| `aitk claude skills drift` | Name the shipped skill bodies rewritten between a given ref and `HEAD` (`--json`) |
|
|
48
|
+
| `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
|
|
48
49
|
| `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
|
|
49
50
|
|
|
50
51
|
## Domain commands
|
|
@@ -56,7 +57,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
|
|
|
56
57
|
| `tooling` | `list`, `sync`, `ref`, `create`, `verify`, `inject`, `prune-gitignore` |
|
|
57
58
|
| `snippets` | `list`, `install`, `sync`, `create` |
|
|
58
59
|
| `standards` | `list`, `install`, `sync`, `<name>` |
|
|
59
|
-
| `gov` | `list`, `install`, `sync`, `build`
|
|
60
|
+
| `gov` | `list`, `install`, `sync`, `build`, `regen`, `test-order` |
|
|
60
61
|
| `claude` | `init`, `sync`, `seeds list`, `skills list`, `skills audit`, `skills drift`, `setup [dest]` |
|
|
61
62
|
| `wiki` | `init` |
|
|
62
63
|
| `design` | `render` |
|
package/docs/agents/index.md
CHANGED
|
@@ -27,3 +27,4 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
27
27
|
- [Skill audit](skills-audit.md): Measuring both skill corpora against standards/skill.md, the checks it reads, the requirement gate that is the only failing one, and the drift verb that names bodies rewritten since a ref
|
|
28
28
|
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
29
29
|
- [Teach](teach.md): Listing learning workspaces and the ordinal a new one takes, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, the refusal reasons, and why every write here runs through a verb
|
|
30
|
+
- [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Test order
|
|
3
|
+
description: Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Test order
|
|
7
|
+
|
|
8
|
+
`aitk gov test-order` reports where an implementation reached a commit ahead of the test covering the same behavior. It answers the rule in `.claude/rules/core/070-planning.md` that asks for the test first, which until this verb existed was prose loaded on a glob match with nothing measuring it.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
aitk gov test-order
|
|
12
|
+
aitk gov test-order --base origin/main --json
|
|
13
|
+
aitk gov test-order --root ../my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Option | Behavior |
|
|
17
|
+
| --------------- | ----------------------------------------------------------------- |
|
|
18
|
+
| `--base <ref>` | Far side of the range, defaulting to the merge base against trunk |
|
|
19
|
+
| `--root <path>` | Repository to read, defaulting to the current directory |
|
|
20
|
+
| `--json` | Add a machine-readable record on stdout, keeping the frame |
|
|
21
|
+
|
|
22
|
+
Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode. A consumer reading stdout sees pure data, and an operator reading the terminal sees why a run refused rather than a command that appeared to do nothing.
|
|
23
|
+
|
|
24
|
+
## The range it reads
|
|
25
|
+
|
|
26
|
+
The default range is the branch against the trunk, resolved as the merge base against `origin/main` and then local `main`. A repository carrying neither falls back to the root commit, so a fresh checkout still answers rather than refusing.
|
|
27
|
+
|
|
28
|
+
Reading all history on every run measures work nobody is reviewing and buries the finding that matters under the ones already merged. `--base` widens or narrows it when a reader wants a different window.
|
|
29
|
+
|
|
30
|
+
Only history is read. A file sitting in the working tree and in no commit is invisible here, which is the point: the verification run sees a tree at one moment, and the ordering exists nowhere but history.
|
|
31
|
+
|
|
32
|
+
## How a pair is decided
|
|
33
|
+
|
|
34
|
+
A test is paired to an implementation by the module path with the test suffix removed, so `src/parser.test.ts` covers `src/parser.ts`. Every record is keyed on the implementation path, which is what a reader goes looking for.
|
|
35
|
+
|
|
36
|
+
Pairing is the whole difficulty. A test and its implementation are related by behavior rather than by filename, so a behavior split across two modules pairs wrongly or not at all. That is what the unclassified verdict exists to catch rather than hide.
|
|
37
|
+
|
|
38
|
+
## The three verdicts
|
|
39
|
+
|
|
40
|
+
- **Satisfied.** The implementation was added in the range and its test reached history no later. A test that predates the range counts, and so does one commit carrying both sides, since the rule asks that the test not come after and a single commit is the shape a small change takes.
|
|
41
|
+
- **Implementation-first.** The implementation was added in the range and its test was added in a later commit of the same range. This is the only verdict that moves the exit code.
|
|
42
|
+
- **Unclassified.** Everything the pairing cannot read, each carrying its own reason. A module the range modified rather than added lands here, because a refactor and a new behavior cannot be told apart from history. So does an implementation no test names, and a test whose implementation the range never introduced.
|
|
43
|
+
|
|
44
|
+
## What it reads past
|
|
45
|
+
|
|
46
|
+
Only `.ts` and `.tsx` are paired, since a test sits beside its subject under one name across that corpus and a bash script has no such partner. Declaration files carry no behavior to test. Every other changed path is counted under `Read past` rather than dropped silently.
|
|
47
|
+
|
|
48
|
+
Coverage is narrower than the rule the check answers to. The rule speaks to every behavior, and the verb speaks to the behaviors it can identify, so the report states its own scope on every run rather than implying it looked at everything.
|
|
49
|
+
|
|
50
|
+
## Exit codes
|
|
51
|
+
|
|
52
|
+
Exit codes are `0` when nothing reached history ahead of its test, `1` for a refusal, and `2` for at least one implementation-first finding. Unclassified rows move no exit code.
|
|
53
|
+
|
|
54
|
+
Nothing wires this into `bun run check` or into a hook. The unclassified class is large and known, and gating a measure carrying a known false-positive class is what teaches contributors to route around the stage. `aitk tasks validate` is the precedent: it exits `2` on findings and is called where the claim is made rather than on every push.
|
|
55
|
+
|
|
56
|
+
An exit code says nothing about a call made from a session, since a shell profile may wrap the binary in a function taking its status from a later command. Read the JSON record's `findings` array rather than the exit when a skill consumes this.
|
package/docs/ai-workflow.md
CHANGED
|
@@ -55,6 +55,8 @@ The routing test is whether the repository can answer an item today. A session g
|
|
|
55
55
|
|
|
56
56
|
A groundwork track may run experiments to settle a question, writing a fixture it reads itself under `.claude/.tmp/groundwork-fixtures/<slug>/` and spawning up to three billed headless runs before it asks. A fixture a headless run is pointed at sits outside the repository, since a session started under the project root inherits that project's `CLAUDE.md` and rules and would measure them instead of the arm.
|
|
57
57
|
|
|
58
|
+
What a spike produces goes somewhere else again. An input the run reads is re-runnable and cited by nothing, so the scratch path above is the right lifetime for it, while a recording or a render the track cites as evidence for a finding is what a later reader opens to check the claim. Evidence therefore lives in `evidence/` inside the track beside the file citing it, since the scratch tree holds only what can be deleted without loss.
|
|
59
|
+
|
|
58
60
|
#### Session 2
|
|
59
61
|
|
|
60
62
|
Start a fresh Claude Code session. The diff is sufficient context for both review and ship.
|
|
@@ -13,4 +13,5 @@ description: Enforce planning standards before implementation
|
|
|
13
13
|
- Propose the simplest solution that satisfies the requirement before implementing complex patterns.
|
|
14
14
|
- Write or update tests as part of every implementation plan.
|
|
15
15
|
- Write the test for a behavior before the code that implements it. Confirm visual output after implementing it, not before.
|
|
16
|
+
- Run `aitk gov test-order` before shipping a branch. Fix what it names as reaching history ahead of its test.
|
|
16
17
|
- Do not modify code without a confirmed plan.
|
package/package.json
CHANGED
package/src/commands/gov.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
mergeExtraRules,
|
|
15
15
|
resolveRules,
|
|
16
16
|
} from '@/gov/stacks'
|
|
17
|
+
import {
|
|
18
|
+
type PairRecord,
|
|
19
|
+
readTestOrder,
|
|
20
|
+
type TestOrderReport,
|
|
21
|
+
} from '@/gov/test-order'
|
|
17
22
|
import { recordStamp, runDomainSync } from '@/sync/engine'
|
|
18
23
|
import { resolveTarget } from '@/target'
|
|
19
24
|
import {
|
|
@@ -48,6 +53,12 @@ interface ListOptions {
|
|
|
48
53
|
readonly json?: boolean
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
interface TestOrderOptions {
|
|
57
|
+
readonly base?: string
|
|
58
|
+
readonly root?: string
|
|
59
|
+
readonly json?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
export function register(program: Command): void {
|
|
52
63
|
const gov = program
|
|
53
64
|
.command('gov')
|
|
@@ -133,6 +144,130 @@ export function register(program: Command): void {
|
|
|
133
144
|
.action((opts: ListOptions) => {
|
|
134
145
|
process.exitCode = runList(opts)
|
|
135
146
|
})
|
|
147
|
+
|
|
148
|
+
gov
|
|
149
|
+
.command('test-order')
|
|
150
|
+
.description(
|
|
151
|
+
'Report where an implementation reached history before its test',
|
|
152
|
+
)
|
|
153
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
154
|
+
.option('--base <ref>', 'Far side of the range, defaulting to the trunk')
|
|
155
|
+
.option('--root <path>', 'Repository to read, defaulting to the cwd')
|
|
156
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
157
|
+
.addHelpText(
|
|
158
|
+
'after',
|
|
159
|
+
[
|
|
160
|
+
'',
|
|
161
|
+
'Measures the rule in .claude/rules/core/070-planning.md that asks for',
|
|
162
|
+
'the test before the code. It reports and never gates, because pairing',
|
|
163
|
+
'a test to an implementation is a judgment.',
|
|
164
|
+
'',
|
|
165
|
+
'Coverage:',
|
|
166
|
+
' a test sits beside its subject under the same name, minus .test',
|
|
167
|
+
' only .ts and .tsx are paired, and every other path is named as read past',
|
|
168
|
+
' a module the range modified rather than added is unclassified, since',
|
|
169
|
+
' a refactor and a new behavior cannot be told apart from history',
|
|
170
|
+
'',
|
|
171
|
+
'Exit codes:',
|
|
172
|
+
' 0 no implementation reached history ahead of its test',
|
|
173
|
+
' 1 refused, with the reason on stderr or in the JSON record',
|
|
174
|
+
' 2 at least one implementation reached history ahead of its test',
|
|
175
|
+
'',
|
|
176
|
+
'Examples:',
|
|
177
|
+
' aitk gov test-order',
|
|
178
|
+
' aitk gov test-order --base origin/main --json',
|
|
179
|
+
'',
|
|
180
|
+
].join('\n'),
|
|
181
|
+
)
|
|
182
|
+
.action((opts: TestOrderOptions) => {
|
|
183
|
+
process.exitCode = runTestOrder(opts)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reports and never gates, so the finding count moves the exit code without
|
|
189
|
+
* anything wiring it into a push. `aitk tasks validate` set that shape: a
|
|
190
|
+
* measure carrying a known false-positive class is what forces contributors to
|
|
191
|
+
* route around a stage, and the unclassified bucket here is that class.
|
|
192
|
+
*/
|
|
193
|
+
function runTestOrder(opts: TestOrderOptions): number {
|
|
194
|
+
const root = resolve(opts.root ?? process.cwd())
|
|
195
|
+
const report = readTestOrder(root, { base: opts.base })
|
|
196
|
+
const emitJson = opts.json ?? false
|
|
197
|
+
|
|
198
|
+
// The frame renders on stderr in both modes and the record goes to stdout
|
|
199
|
+
// alone, which is the split `docs/agents/output-shape.md` fixes. A consumer
|
|
200
|
+
// reading stdout sees pure data either way, and an operator reading the
|
|
201
|
+
// terminal sees the refusal rather than a command that appeared to do nothing.
|
|
202
|
+
if (report.kind === 'unreadable') {
|
|
203
|
+
intro('aitk gov test-order')
|
|
204
|
+
logStep('Refused')
|
|
205
|
+
logError(report.reason)
|
|
206
|
+
outro()
|
|
207
|
+
|
|
208
|
+
if (emitJson) {
|
|
209
|
+
process.stdout.write(
|
|
210
|
+
`${JSON.stringify({ ok: false, reason: report.reason })}\n`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return 1
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
reportTestOrder(report, root)
|
|
218
|
+
|
|
219
|
+
if (emitJson) {
|
|
220
|
+
process.stdout.write(`${JSON.stringify({ ok: true, root, ...report })}\n`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return report.findings.length > 0 ? 2 : 0
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function describePair(record: PairRecord): string {
|
|
227
|
+
const test = record.test === null ? 'no test' : record.test
|
|
228
|
+
return `${record.subject} → ${test}: ${record.reason}`
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function reportTestOrder(
|
|
232
|
+
report: Extract<TestOrderReport, { kind: 'measured' }>,
|
|
233
|
+
root: string,
|
|
234
|
+
): void {
|
|
235
|
+
intro('aitk gov test-order')
|
|
236
|
+
|
|
237
|
+
logStep('Range')
|
|
238
|
+
logInfo(`${report.base.slice(0, 8)}..${report.head.slice(0, 8)} in ${root}`)
|
|
239
|
+
|
|
240
|
+
logStep(report.findings.length === 0 ? 'Clean' : 'Findings')
|
|
241
|
+
if (report.findings.length === 0) {
|
|
242
|
+
logInfo('no implementation reached history ahead of the test covering it')
|
|
243
|
+
} else {
|
|
244
|
+
for (const finding of report.findings) logWarn(describePair(finding))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
logStep('Satisfied')
|
|
248
|
+
logInfo(`${report.satisfied.length} pair(s) whose test came first`)
|
|
249
|
+
|
|
250
|
+
// The unclassified rows carry the warn glyph and move no exit code. A pass
|
|
251
|
+
// over a change the pairing could not read is the claim this check exists to
|
|
252
|
+
// avoid making, so the rows are named rather than counted into the clean line.
|
|
253
|
+
logStep('Unclassified')
|
|
254
|
+
if (report.unclassified.length === 0) {
|
|
255
|
+
logInfo('every changed module paired')
|
|
256
|
+
} else {
|
|
257
|
+
logWarn(
|
|
258
|
+
`${report.unclassified.length} change(s) the pairing could not read`,
|
|
259
|
+
)
|
|
260
|
+
for (const record of report.unclassified) logWarn(describePair(record))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Coverage is narrower than the rule, and a report that did not say so would
|
|
264
|
+
// read as a verdict over every behavior in the range.
|
|
265
|
+
logStep('Read past')
|
|
266
|
+
logInfo(
|
|
267
|
+
`${report.ignored.length} path(s) outside ${report.scope.extensions.join(', ')}`,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
outro()
|
|
136
271
|
}
|
|
137
272
|
|
|
138
273
|
/**
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { execaSync } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Preferred first, matching `src/tasks/trunk.ts`. A clone with no remote still
|
|
6
|
+
* answers off its local trunk, and a local `main` trailing the remote widens
|
|
7
|
+
* the range rather than narrowing it, which over-reports instead of hiding.
|
|
8
|
+
*/
|
|
9
|
+
const TRUNK_REFS = ['origin/main', 'main'] as const
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The extensions this check can pair. A test sits beside its subject under one
|
|
13
|
+
* name throughout this corpus, so the weakest assumption available is also the
|
|
14
|
+
* one the corpus already honors. Every other extension is read past and named,
|
|
15
|
+
* since a bash script and its test are related by nothing a filename carries.
|
|
16
|
+
*/
|
|
17
|
+
export const SOURCE_EXTENSIONS = ['.ts', '.tsx'] as const
|
|
18
|
+
|
|
19
|
+
/** The suffix removed to derive the subject a test covers. */
|
|
20
|
+
export const TEST_SUFFIXES = ['.test.ts', '.test.tsx'] as const
|
|
21
|
+
|
|
22
|
+
/** A `%H` line, which is 40 hex characters under sha1 and 64 under sha256. */
|
|
23
|
+
const COMMIT = /^[0-9a-f]{40,64}$/
|
|
24
|
+
|
|
25
|
+
/** A `--name-status` line: a status letter, a tab, then one path. */
|
|
26
|
+
const NAME_STATUS = /^([A-Z])\d*\t(.+)$/
|
|
27
|
+
|
|
28
|
+
export type Verdict = 'satisfied' | 'implementation-first' | 'unclassified'
|
|
29
|
+
|
|
30
|
+
export interface PairRecord {
|
|
31
|
+
/** The implementation path, which is what a subject is named by. */
|
|
32
|
+
readonly subject: string
|
|
33
|
+
/** The test paired to it, or `null` when the pairing found no partner. */
|
|
34
|
+
readonly test: string | null
|
|
35
|
+
readonly verdict: Verdict
|
|
36
|
+
readonly implementationCommit: string | null
|
|
37
|
+
readonly testCommit: string | null
|
|
38
|
+
/** Why the verdict reads the way it does, in the report's own words. */
|
|
39
|
+
readonly reason: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Scope {
|
|
43
|
+
readonly extensions: readonly string[]
|
|
44
|
+
readonly testSuffixes: readonly string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TestOrderReport =
|
|
48
|
+
| {
|
|
49
|
+
readonly kind: 'measured'
|
|
50
|
+
readonly base: string
|
|
51
|
+
readonly head: string
|
|
52
|
+
readonly scope: Scope
|
|
53
|
+
readonly satisfied: readonly PairRecord[]
|
|
54
|
+
readonly findings: readonly PairRecord[]
|
|
55
|
+
readonly unclassified: readonly PairRecord[]
|
|
56
|
+
/** Changed paths outside the pairing's reach, named rather than counted. */
|
|
57
|
+
readonly ignored: readonly string[]
|
|
58
|
+
}
|
|
59
|
+
| { readonly kind: 'unreadable'; readonly reason: string }
|
|
60
|
+
|
|
61
|
+
export interface TestOrderOptions {
|
|
62
|
+
/** The far side of the range, defaulting to the merge base against the trunk. */
|
|
63
|
+
readonly base?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One path as one commit in the range touched it. */
|
|
67
|
+
interface Change {
|
|
68
|
+
readonly path: string
|
|
69
|
+
readonly status: string
|
|
70
|
+
readonly commit: string
|
|
71
|
+
/** Position in the range, oldest first, which is what orders a pair. */
|
|
72
|
+
readonly order: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads `--name-status` log output, oldest commit first, into one entry per
|
|
77
|
+
* path per commit.
|
|
78
|
+
*
|
|
79
|
+
* The order field rather than the commit is what a comparison reads, since two
|
|
80
|
+
* commits carry no ordering a caller can derive from their hashes alone.
|
|
81
|
+
*/
|
|
82
|
+
function parseChanges(output: string): Change[] {
|
|
83
|
+
const changes: Change[] = []
|
|
84
|
+
let commit = ''
|
|
85
|
+
let order = -1
|
|
86
|
+
|
|
87
|
+
for (const line of output.split('\n')) {
|
|
88
|
+
const trimmed = line.trimEnd()
|
|
89
|
+
if (trimmed === '') continue
|
|
90
|
+
|
|
91
|
+
if (COMMIT.test(trimmed)) {
|
|
92
|
+
commit = trimmed
|
|
93
|
+
order += 1
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const match = NAME_STATUS.exec(trimmed)
|
|
98
|
+
if (match === null || commit === '') continue
|
|
99
|
+
|
|
100
|
+
changes.push({ path: match[2], status: match[1], commit, order })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return changes
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a path is a test this check can derive a subject from. */
|
|
107
|
+
function testSuffix(path: string): string | undefined {
|
|
108
|
+
return TEST_SUFFIXES.find((suffix) => path.endsWith(suffix))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The implementation a test covers, derived by removing the test suffix.
|
|
113
|
+
*
|
|
114
|
+
* A behavior split across two modules pairs wrongly or not at all under this,
|
|
115
|
+
* which is what the unclassified bucket exists to catch rather than hide.
|
|
116
|
+
*/
|
|
117
|
+
function subjectOf(path: string): string {
|
|
118
|
+
const suffix = testSuffix(path)
|
|
119
|
+
if (suffix === undefined) return path
|
|
120
|
+
return `${path.slice(0, -suffix.length)}${suffix.replace('.test', '')}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The one test path that would cover `subject` under the beside convention. */
|
|
124
|
+
function testPathFor(subject: string): string {
|
|
125
|
+
return subject.replace(/\.(tsx?)$/, '.test.$1')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether a path is an implementation this check can pair. A declaration file
|
|
130
|
+
* carries no behavior to test, so it is read past rather than reported as a
|
|
131
|
+
* module nothing covers.
|
|
132
|
+
*/
|
|
133
|
+
function isImplementation(path: string): boolean {
|
|
134
|
+
if (path.endsWith('.d.ts')) return false
|
|
135
|
+
if (testSuffix(path) !== undefined) return false
|
|
136
|
+
return SOURCE_EXTENSIONS.some((extension) => path.endsWith(extension))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The first commit in the range that added `path`, or `undefined`. */
|
|
140
|
+
function introduction(
|
|
141
|
+
changes: readonly Change[],
|
|
142
|
+
path: string,
|
|
143
|
+
): Change | undefined {
|
|
144
|
+
return changes.find((change) => change.path === path && change.status === 'A')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function classify(
|
|
148
|
+
changes: readonly Change[],
|
|
149
|
+
atBase: ReadonlySet<string>,
|
|
150
|
+
): {
|
|
151
|
+
satisfied: PairRecord[]
|
|
152
|
+
findings: PairRecord[]
|
|
153
|
+
unclassified: PairRecord[]
|
|
154
|
+
} {
|
|
155
|
+
const satisfied: PairRecord[] = []
|
|
156
|
+
const findings: PairRecord[] = []
|
|
157
|
+
const unclassified: PairRecord[] = []
|
|
158
|
+
|
|
159
|
+
// Keyed by subject so a module whose implementation and test both moved is
|
|
160
|
+
// one record rather than two, and so a test with no partner still reports
|
|
161
|
+
// under the implementation path a reader would go looking for.
|
|
162
|
+
const subjects = new Set<string>()
|
|
163
|
+
for (const change of changes) {
|
|
164
|
+
if (isImplementation(change.path)) subjects.add(change.path)
|
|
165
|
+
else if (testSuffix(change.path) !== undefined) {
|
|
166
|
+
subjects.add(subjectOf(change.path))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const subject of [...subjects].sort()) {
|
|
171
|
+
const candidate = testPathFor(subject)
|
|
172
|
+
const testChange = introduction(changes, candidate)
|
|
173
|
+
const testAtBase = atBase.has(candidate)
|
|
174
|
+
const test = testAtBase || testChange !== undefined ? candidate : undefined
|
|
175
|
+
|
|
176
|
+
const implementation = introduction(changes, subject)
|
|
177
|
+
|
|
178
|
+
if (implementation === undefined) {
|
|
179
|
+
unclassified.push({
|
|
180
|
+
subject,
|
|
181
|
+
test: test ?? null,
|
|
182
|
+
verdict: 'unclassified',
|
|
183
|
+
implementationCommit: null,
|
|
184
|
+
testCommit: null,
|
|
185
|
+
reason: atBase.has(subject)
|
|
186
|
+
? 'the implementation predates the range, so a change to it cannot be separated from a refactor'
|
|
187
|
+
: 'no implementation reached the range beside this test',
|
|
188
|
+
})
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (test === undefined) {
|
|
193
|
+
unclassified.push({
|
|
194
|
+
subject,
|
|
195
|
+
test: null,
|
|
196
|
+
verdict: 'unclassified',
|
|
197
|
+
implementationCommit: implementation.commit,
|
|
198
|
+
testCommit: null,
|
|
199
|
+
reason: 'no test names this module, so the ordering has no second side',
|
|
200
|
+
})
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A paired test the range never added is one that already sat at the base
|
|
205
|
+
// commit, since those are the only two ways `test` gets a value at all.
|
|
206
|
+
if (testChange === undefined) {
|
|
207
|
+
satisfied.push({
|
|
208
|
+
subject,
|
|
209
|
+
test,
|
|
210
|
+
verdict: 'satisfied',
|
|
211
|
+
implementationCommit: implementation.commit,
|
|
212
|
+
testCommit: null,
|
|
213
|
+
reason: 'the test predates the range',
|
|
214
|
+
})
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// One commit carrying both sides counts as satisfied. The rule asks that
|
|
219
|
+
// the test not come after, and a single commit is the shape a small change
|
|
220
|
+
// takes here, so reporting it would flag most of the corpus.
|
|
221
|
+
const record: PairRecord = {
|
|
222
|
+
subject,
|
|
223
|
+
test,
|
|
224
|
+
verdict:
|
|
225
|
+
testChange.order <= implementation.order
|
|
226
|
+
? 'satisfied'
|
|
227
|
+
: 'implementation-first',
|
|
228
|
+
implementationCommit: implementation.commit,
|
|
229
|
+
testCommit: testChange.commit,
|
|
230
|
+
reason:
|
|
231
|
+
testChange.order <= implementation.order
|
|
232
|
+
? 'the test reached history no later than the implementation'
|
|
233
|
+
: 'the implementation reached history before the test covering it',
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (record.verdict === 'satisfied') satisfied.push(record)
|
|
237
|
+
else findings.push(record)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { satisfied, findings, unclassified }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Where an implementation reached a commit ahead of the test covering it,
|
|
245
|
+
* between `base` and the current `HEAD` of the tree at `root`.
|
|
246
|
+
*
|
|
247
|
+
* This reports and never gates. Pairing a test to an implementation is a
|
|
248
|
+
* judgment, so a change the pairing cannot read lands in `unclassified` with
|
|
249
|
+
* its reason stated rather than being counted as a pass. Coverage is narrower
|
|
250
|
+
* than the rule the check answers to, and `scope` and `ignored` are what say so
|
|
251
|
+
* on every run.
|
|
252
|
+
*/
|
|
253
|
+
export function readTestOrder(
|
|
254
|
+
root: string,
|
|
255
|
+
options: TestOrderOptions = {},
|
|
256
|
+
): TestOrderReport {
|
|
257
|
+
const head = revParse(root, 'HEAD')
|
|
258
|
+
if (head === undefined) {
|
|
259
|
+
return {
|
|
260
|
+
kind: 'unreadable',
|
|
261
|
+
reason: `No git history under ${root}. History is the only surface carrying the ordering, so there is nothing to read.`,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const base = resolveBase(root, options.base, head)
|
|
266
|
+
if (typeof base !== 'string') return base
|
|
267
|
+
|
|
268
|
+
const log = git(root, [
|
|
269
|
+
'log',
|
|
270
|
+
'--reverse',
|
|
271
|
+
'--name-status',
|
|
272
|
+
'--no-renames',
|
|
273
|
+
'--format=%H',
|
|
274
|
+
`${base}..${head}`,
|
|
275
|
+
])
|
|
276
|
+
|
|
277
|
+
if (log === undefined) {
|
|
278
|
+
return {
|
|
279
|
+
kind: 'unreadable',
|
|
280
|
+
reason: `Reading history between ${base} and HEAD failed under ${root}.`,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const tree = git(root, ['ls-tree', '-r', '--name-only', base])
|
|
285
|
+
if (tree === undefined) {
|
|
286
|
+
return {
|
|
287
|
+
kind: 'unreadable',
|
|
288
|
+
reason: `Reading the tree at ${base} failed under ${root}. Without it a test written before the range reads as absent.`,
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const changes = parseChanges(log)
|
|
293
|
+
const atBase = new Set(tree.split('\n').filter((line) => line !== ''))
|
|
294
|
+
|
|
295
|
+
const ignored = [
|
|
296
|
+
...new Set(
|
|
297
|
+
changes
|
|
298
|
+
.map((change) => change.path)
|
|
299
|
+
.filter(
|
|
300
|
+
(path) => !isImplementation(path) && testSuffix(path) === undefined,
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
].sort()
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
kind: 'measured',
|
|
307
|
+
base,
|
|
308
|
+
head,
|
|
309
|
+
scope: { extensions: SOURCE_EXTENSIONS, testSuffixes: TEST_SUFFIXES },
|
|
310
|
+
...classify(changes, atBase),
|
|
311
|
+
ignored,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The far side of the range. A ref the caller named has to resolve, since
|
|
317
|
+
* falling back to the trunk there would measure a range nobody asked for. With
|
|
318
|
+
* no ref named, the merge base against the trunk scopes the run to the branch,
|
|
319
|
+
* and a repository carrying no trunk falls back to the root commit rather than
|
|
320
|
+
* refusing.
|
|
321
|
+
*/
|
|
322
|
+
function resolveBase(
|
|
323
|
+
root: string,
|
|
324
|
+
ref: string | undefined,
|
|
325
|
+
head: string,
|
|
326
|
+
): string | { kind: 'unreadable'; reason: string } {
|
|
327
|
+
if (ref !== undefined) {
|
|
328
|
+
const resolved = revParse(root, ref)
|
|
329
|
+
if (resolved === undefined) {
|
|
330
|
+
return {
|
|
331
|
+
kind: 'unreadable',
|
|
332
|
+
reason: `Ref ${ref} resolves to no commit in ${root}. Pass a commit this tree carries.`,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return resolved
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
for (const trunk of TRUNK_REFS) {
|
|
339
|
+
if (revParse(root, trunk) === undefined) continue
|
|
340
|
+
const merged = git(root, ['merge-base', head, trunk])
|
|
341
|
+
if (merged !== undefined && merged !== '') return merged
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const rootCommits = git(root, ['rev-list', '--max-parents=0', head])
|
|
345
|
+
if (rootCommits === undefined || rootCommits === '') {
|
|
346
|
+
return {
|
|
347
|
+
kind: 'unreadable',
|
|
348
|
+
reason: `No base resolves against ${root}. Fetch origin or pass --base.`,
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return rootCommits.split('\n')[0]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `execaSync` with git's repository-resolution variables stripped, so `-C`
|
|
357
|
+
* resolves against `root` and not against whatever repository a hook exported.
|
|
358
|
+
* `undefined` is the refusal, which every caller turns into its own reason.
|
|
359
|
+
*/
|
|
360
|
+
function git(root: string, args: readonly string[]): string | undefined {
|
|
361
|
+
const result = execaSync('git', ['-C', root, ...args], {
|
|
362
|
+
reject: false,
|
|
363
|
+
env: gitEnv(),
|
|
364
|
+
extendEnv: false,
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
return result.exitCode === 0 ? result.stdout.trimEnd() : undefined
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The commit a ref names. `^{commit}` is what turns a tag or a tree into the
|
|
372
|
+
* commit behind it, so a caller never compares a ref against another type.
|
|
373
|
+
*/
|
|
374
|
+
function revParse(root: string, ref: string): string | undefined {
|
|
375
|
+
const resolved = git(root, [
|
|
376
|
+
'rev-parse',
|
|
377
|
+
'--verify',
|
|
378
|
+
'--quiet',
|
|
379
|
+
`${ref}^{commit}`,
|
|
380
|
+
])
|
|
381
|
+
|
|
382
|
+
return resolved === undefined || resolved === '' ? undefined : resolved.trim()
|
|
383
|
+
}
|
package/standards/groundwork.md
CHANGED
|
@@ -122,6 +122,10 @@ Each spike carries four things:
|
|
|
122
122
|
|
|
123
123
|
Cost is a report rather than a limit, and it is what makes the next spike estimable before anyone commits to it. Record it even when it comes to a single read.
|
|
124
124
|
|
|
125
|
+
A spike also leaves files behind, and they split on whether the track cites them rather than on whether they are markdown. An input the run reads, being a fixture page, an arm script, or a copied asset, is re-runnable and cited by nothing, so it stays outside the track wherever the surface driving the spike puts it. Evidence the record cites, being a recording, a render, or a frame pulled from one, is what a later reader opens to check a claim, so it lives in `evidence/` inside the track beside the file citing it. Name the split rather than the file types, which is what keeps this from going stale on the next kind of artifact a spike produces.
|
|
126
|
+
|
|
127
|
+
`evidence/` takes no number, since numbering is the read order over the files a reader opens in sequence and an artifact is reached from the claim that cites it instead.
|
|
128
|
+
|
|
125
129
|
Reach for a test harness the project already carries before building one. A track needing an experiment no existing harness can express has found a finding, and it belongs in the folder rather than in a new abstraction.
|
|
126
130
|
|
|
127
131
|
One method error is worth naming, because it is made rather than imagined. Counting matches in a transcript overstates whether a file was read, since an instruction naming a path puts that path in the transcript whether or not anything opened it. The check is the tool call.
|
|
@@ -15,17 +15,17 @@ packages = [
|
|
|
15
15
|
]
|
|
16
16
|
|
|
17
17
|
[scripts]
|
|
18
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro dev"
|
|
18
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro dev"
|
|
19
19
|
"build" = "astro check && astro build"
|
|
20
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro preview"
|
|
20
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro preview"
|
|
21
21
|
"astro" = "astro"
|
|
22
22
|
"typecheck" = "astro check"
|
|
23
23
|
"setup" = "./scripts/setup.sh"
|
|
24
24
|
|
|
25
25
|
[scripts.override]
|
|
26
|
-
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) bash scripts/screenshot.sh"
|
|
27
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro dev"
|
|
28
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) astro preview"
|
|
26
|
+
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4321) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
27
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro dev"
|
|
28
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && astro preview"
|
|
29
29
|
|
|
30
30
|
[gitignore]
|
|
31
31
|
"# Astro" = [".astro/"]
|
|
@@ -11,13 +11,13 @@ packages = [
|
|
|
11
11
|
]
|
|
12
12
|
|
|
13
13
|
[scripts]
|
|
14
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite"
|
|
14
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite"
|
|
15
15
|
"build" = "tsc --noEmit && vite build"
|
|
16
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite preview"
|
|
16
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite preview"
|
|
17
17
|
"typecheck" = "tsc --noEmit"
|
|
18
18
|
"setup" = "./scripts/setup.sh"
|
|
19
19
|
|
|
20
20
|
[scripts.override]
|
|
21
21
|
"build" = "tsc --noEmit && vite build"
|
|
22
|
-
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite"
|
|
23
|
-
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) vite preview"
|
|
22
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite"
|
|
23
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && vite preview"
|
|
@@ -3,10 +3,18 @@ set -euo pipefail
|
|
|
3
3
|
|
|
4
4
|
# Prints a port for this working directory: the base itself in a normal
|
|
5
5
|
# checkout, and the base plus a per-worktree offset in a linked git worktree,
|
|
6
|
-
# so two worktrees of one repository never serve on one port.
|
|
6
|
+
# so two worktrees of one repository never serve on one port. A folder left
|
|
7
|
+
# behind after its worktree was removed is refused rather than served, since
|
|
8
|
+
# every port it derives lands on the one the main checkout wanted.
|
|
7
9
|
|
|
8
10
|
base="${1:-0}"
|
|
9
11
|
band=50
|
|
12
|
+
worktrees_dir=".claude/worktrees"
|
|
13
|
+
|
|
14
|
+
refuse() {
|
|
15
|
+
echo "worktree-port: $1 is a leftover worktree folder, because $2." >&2
|
|
16
|
+
echo "worktree-port: a port derived here collides with the main checkout, so remove the folder, or set WORKTREE_PORT_OFFSET to serve from it anyway." >&2
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
offset() {
|
|
12
20
|
if [[ -n "${WORKTREE_PORT_OFFSET:-}" ]]; then
|
|
@@ -14,23 +22,53 @@ offset() {
|
|
|
14
22
|
return
|
|
15
23
|
fi
|
|
16
24
|
|
|
17
|
-
local git_dir common_dir name
|
|
18
|
-
|
|
25
|
+
local here git_dir common_dir toplevel dir name
|
|
26
|
+
here=$(pwd -P)
|
|
27
|
+
|
|
28
|
+
if ! git_dir=$(git rev-parse --git-dir 2>/dev/null); then
|
|
29
|
+
# Git refuses outright when a `.git` file names an administrative directory
|
|
30
|
+
# that is gone, which is what removing a worktree by hand leaves behind.
|
|
31
|
+
# Outside a repository there is no pointer at all and the base is correct.
|
|
32
|
+
dir=$here
|
|
33
|
+
while [[ "$dir" != / ]]; do
|
|
34
|
+
if [[ -f "$dir/.git" ]]; then
|
|
35
|
+
refuse "$dir" "its .git file names an administrative directory that is gone"
|
|
36
|
+
return 1
|
|
37
|
+
fi
|
|
38
|
+
[[ -d "$dir/.git" ]] && break
|
|
39
|
+
dir=$(dirname "$dir")
|
|
40
|
+
done
|
|
19
41
|
echo 0
|
|
20
42
|
return
|
|
21
|
-
|
|
43
|
+
fi
|
|
44
|
+
|
|
22
45
|
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || {
|
|
23
46
|
echo 0
|
|
24
47
|
return
|
|
25
48
|
}
|
|
49
|
+
toplevel=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
50
|
+
echo 0
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
toplevel=$(cd "$toplevel" && pwd -P)
|
|
26
54
|
|
|
27
55
|
if [[ "$(cd "$git_dir" && pwd -P)" == "$(cd "$common_dir" && pwd -P)" ]]; then
|
|
56
|
+
# The main checkout answers here, and so does every directory under it,
|
|
57
|
+
# including a folder whose `.git` was deleted along with its worktree,
|
|
58
|
+
# since git then walks upward and reports the parent repository. Location
|
|
59
|
+
# is the only signal separating the two, so a directory sitting under the
|
|
60
|
+
# worktrees folder is refused rather than handed the base port.
|
|
61
|
+
if [[ "$here/" == "$toplevel/$worktrees_dir/"?* ]]; then
|
|
62
|
+
refuse "$here" "no worktree is registered for it"
|
|
63
|
+
return 1
|
|
64
|
+
fi
|
|
28
65
|
echo 0
|
|
29
66
|
return
|
|
30
67
|
fi
|
|
31
68
|
|
|
32
|
-
name=$(basename "$
|
|
69
|
+
name=$(basename "$toplevel")
|
|
33
70
|
echo $(($(printf '%s' "$name" | cksum | cut -d' ' -f1) % band + 1))
|
|
34
71
|
}
|
|
35
72
|
|
|
36
|
-
|
|
73
|
+
value=$(offset) || exit 1
|
|
74
|
+
echo $((base + value))
|
|
@@ -42,13 +42,13 @@ packages = [
|
|
|
42
42
|
"test:run" = "vitest run --reporter=verbose"
|
|
43
43
|
"test:ui" = "vitest --ui"
|
|
44
44
|
"test:coverage" = "vitest run --coverage"
|
|
45
|
-
"test:e2e" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) playwright test"
|
|
46
|
-
"test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) playwright test --ui"
|
|
45
|
+
"test:e2e" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test"
|
|
46
|
+
"test:e2e:ui" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && playwright test --ui"
|
|
47
47
|
"test:e2e:report" = "playwright show-report"
|
|
48
48
|
"check:full" = "./scripts/verify.sh && bun run test:e2e"
|
|
49
49
|
|
|
50
50
|
[scripts.override]
|
|
51
|
-
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) bash scripts/screenshot.sh"
|
|
51
|
+
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 4173) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
52
52
|
|
|
53
53
|
[gitignore]
|
|
54
54
|
"# Build" = ["dist/"]
|
package/tooling/web/reference.md
CHANGED
|
@@ -16,7 +16,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
|
|
|
16
16
|
- `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
|
|
17
17
|
- `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
|
|
18
18
|
- `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
|
|
19
|
-
- `scripts/worktree-port.sh`: prints a base port plus this working directory's offset. Called with no argument it prints the offset alone.
|
|
19
|
+
- `scripts/worktree-port.sh`: prints a base port plus this working directory's offset. Called with no argument it prints the offset alone. It refuses a folder left under the worktrees directory after its worktree was removed, rather than printing a port for it.
|
|
20
20
|
|
|
21
21
|
## What stays in per-stack adapters
|
|
22
22
|
|
|
@@ -40,6 +40,9 @@ Two worktrees of one repository run the same stack, so a fixed port makes the se
|
|
|
40
40
|
- Derive every served port from `scripts/worktree-port.sh`. Never write a port literal into a script string.
|
|
41
41
|
- Read `WORKTREE_PORT_OFFSET` in a config and add it to the stack's default port. Unset yields the default, so a plain clone keeps the port it has always served on.
|
|
42
42
|
- Draw the offset from a band of 50, hashed from the worktree folder name. Two worktrees can hash to one offset, so set `WORKTREE_PORT_OFFSET` by hand to break a tie.
|
|
43
|
+
- Expect a non-zero exit and no port from a folder left under `.claude/worktrees/` once its worktree is gone. Git reports the parent repository from inside one, so the helper cannot read it as a worktree and every base it serves would land on the main checkout's port. Both shapes refuse, and they reach differently. A folder whose own `.git` was deleted refuses only under that directory, since location is the only thing separating it from an ordinary subdirectory the base port is correct for. A folder whose `.git` names a pruned administrative directory refuses wherever it sits, because a pointer to nothing is broken regardless of where the folder is.
|
|
44
|
+
- Call it as `VAR=$(bash scripts/worktree-port.sh) && export VAR && <server>`, never as the shorter `VAR=$(bash scripts/worktree-port.sh) <server>`. An assignment prefix discards the exit status of its own substitution, so the shorter form starts the server with `VAR` set to the empty string, every config reads that back as an offset of zero, and the refusal lands on the port it was raised to protect. The assignment alone carries the status, which is what the `&&` reads.
|
|
45
|
+
- Set `WORKTREE_PORT_OFFSET` by hand to serve from such a folder anyway. That is the one override, and it is checked before any directory test.
|
|
43
46
|
- Force-replace `dev` and `preview` through `[scripts.override]`. Both stacks' scaffolds define those keys, and a plain `[scripts]` entry never replaces a key the scaffold already wrote.
|
|
44
47
|
- Set `strictPort` on every dev and preview server. A server that walks to the next free port serves where nothing is looking for it.
|
|
45
48
|
- Set Playwright `reuseExistingServer: false`. Reuse attaches to whatever answers on the port, which reports a pass against another branch's code and prints nothing to say so.
|