@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.104.1",
4
+ "version": "0.105.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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 experiments: write a fixture a spike needs 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`.
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.
@@ -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` |
@@ -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.
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.104.1",
4
+ "version": "0.105.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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
+ }
@@ -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
- git_dir=$(git rev-parse --git-dir 2>/dev/null) || {
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 "$(git rev-parse --show-toplevel)")
69
+ name=$(basename "$toplevel")
33
70
  echo $(($(printf '%s' "$name" | cksum | cut -d' ' -f1) % band + 1))
34
71
  }
35
72
 
36
- echo $((base + $(offset)))
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/"]
@@ -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.