@jjchill/probity-rules 0.4.11 → 0.4.12

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.12
4
+
5
+ `probity-claude` now runs Probity in the worktree a session is working
6
+ in, so rules judge that tree's specs and files (#66).
7
+
8
+ - **What went wrong.** The documented hook is
9
+ `cd "$CLAUDE_PROJECT_DIR" && ./node_modules/.bin/probity-claude`.
10
+ After a session moves into a worktree (`EnterWorktree`, or an
11
+ `isolation: "worktree"` sub-agent), `CLAUDE_PROJECT_DIR` still names
12
+ the main checkout. Probity finds `probity.config.ts` by searching
13
+ upward from its working directory, so it loaded the main checkout's
14
+ config, and `ROOT`, `specsDir` and `glossaryPath` all pointed there.
15
+ In #66 the spec-first rule blocked an acceptance test whose
16
+ `Covers:` scenario existed only in the worktree's `specs/`
17
+ submodule, and named the main checkout's `specs/features`.
18
+ - **Now:** the wrapper finds the git worktree the action targets: the
19
+ edited file's location, or for a command its `git -C <dir>` or
20
+ leading `cd <dir> &&`, else the session's `cwd`. When that worktree
21
+ is nested inside the project and has its own `probity.config.*`, the
22
+ wrapper starts Probity there. Its config's imports resolve from the
23
+ project's `node_modules` (one level up the folder tree), so the
24
+ worktree needs no install. Work in the main checkout, worktrees
25
+ without a Probity config, and paths outside the project run as before.
26
+ - A relative `--debug <path>` is anchored to the project directory, so
27
+ the debug log stays in one place.
28
+ - Only hooks that run `probity-claude` get this. A worktree outside the
29
+ project folder still runs against the project.
30
+ - Consumers: no config change. The worktree's own `probity.config.ts`
31
+ (from its branch) is the one that runs.
32
+
3
33
  ## 0.4.11
4
34
 
5
35
  The commit-on-green gate now catches every form of `git commit`, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjchill/probity-rules",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "Versioned Probity rule presets and CLI tooling for the agent-skills catalog (test-driven-development, ports-and-adapters, acceptance-testing, spec-test parity, ubiquitous-language) — see hooks/PROBITY.md.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,17 @@
24
24
  * deletes the copy when Probity exits. The gates only need recent
25
25
  * events: the last test run, the red before a green.
26
26
  *
27
+ * And it runs Probity in the right tree (issue #66). The hook `cd`s to
28
+ * $CLAUDE_PROJECT_DIR, which still names the main checkout after a
29
+ * session moves into a worktree (EnterWorktree, or an `isolation:
30
+ * "worktree"` sub-agent). Probity finds its config by searching upward
31
+ * from its working directory, so every rule judged the main checkout's
32
+ * specs and files. When the edited file (or, for a command, the
33
+ * session's cwd) is inside a git worktree nested in the project that
34
+ * has its own probity.config.*, the wrapper starts Probity there. That
35
+ * config's imports still resolve from the project's node_modules, one
36
+ * directory level up the tree.
37
+ *
27
38
  * Use it in place of the probity bin in `.claude/settings.json`:
28
39
  *
29
40
  * "command": "cd \"$CLAUDE_PROJECT_DIR\" && ./node_modules/.bin/probity-claude"
@@ -47,7 +58,7 @@ import {
47
58
  } from 'node:fs'
48
59
  import { createRequire } from 'node:module'
49
60
  import { tmpdir } from 'node:os'
50
- import { basename, dirname, join } from 'node:path'
61
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
51
62
  import { fileURLToPath } from 'node:url'
52
63
 
53
64
  /** The sub-agent transcript for a payload, or null to keep the original. */
@@ -134,6 +145,75 @@ export function preparePayload(raw, limits = {}) {
134
145
  }
135
146
  }
136
147
 
148
+ const CONFIG_NAMES = ['ts', 'mts', 'js', 'mjs'].map((ext) => `probity.config.${ext}`)
149
+
150
+ function canonical(path) {
151
+ // Resolve symlinks on the longest existing prefix (a file being
152
+ // created does not exist yet), so paths compare like the project dir.
153
+ let head = resolve(path)
154
+ const tail = []
155
+ while (!existsSync(head) && dirname(head) !== head) {
156
+ tail.unshift(basename(head))
157
+ head = dirname(head)
158
+ }
159
+ try {
160
+ head = realpathSync(head)
161
+ } catch {
162
+ // keep the resolved path
163
+ }
164
+ return join(head, ...tail)
165
+ }
166
+
167
+ function isBelow(parent, child) {
168
+ const path = relative(parent, child)
169
+ return path !== '' && path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)
170
+ }
171
+
172
+ // The directory a command explicitly acts in: `git -C <dir>`, else a
173
+ // leading `cd <dir> &&`; null when it names none. Mirrors the commit
174
+ // gates' commitDirectory (rules/commit-target.ts), so a worktree session
175
+ // that commits in the main checkout is judged there.
176
+ function commandDirectory(command) {
177
+ const unquote = (value) => value.replace(/^(['"])(.*)\1$/, '$2')
178
+ const gitDashC = command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
179
+ const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|[^\s;&|]+)\s*(?:&&|;)/)
180
+ const target = gitDashC?.[1] ?? cd?.[1]
181
+ return target ? unquote(target) : null
182
+ }
183
+
184
+ /**
185
+ * The git worktree to run Probity in for this payload, or null to stay
186
+ * in `projectDir`: the nearest directory holding `.git` above the edited
187
+ * file (for a command, the session's cwd), when it lies below
188
+ * `projectDir` and has its own Probity config. A command's directory is
189
+ * its `git -C <dir>` or leading `cd <dir> &&`, else the session's cwd.
190
+ */
191
+ export function probityDirectory(payload, projectDir) {
192
+ const input = payload?.tool_input ?? {}
193
+ const cwd = typeof payload?.cwd === 'string' ? payload.cwd : null
194
+ const file = [input.file_path, input.notebook_path].find((value) => typeof value === 'string')
195
+ const base = cwd ?? projectDir
196
+ let start
197
+ if (file) start = dirname(isAbsolute(file) ? file : resolve(base, file))
198
+ else if (typeof input.command === 'string') start = resolve(base, commandDirectory(input.command) ?? '.')
199
+ else if (cwd) start = cwd
200
+ else return null
201
+ const project = canonical(projectDir)
202
+ for (let dir = canonical(start); isBelow(project, dir); dir = dirname(dir)) {
203
+ if (existsSync(join(dir, '.git'))) {
204
+ return CONFIG_NAMES.some((name) => existsSync(join(dir, name))) ? dir : null
205
+ }
206
+ }
207
+ return null
208
+ }
209
+
210
+ /** `args` with a relative `--debug <path>` anchored to `projectDir`, so the log stays put when Probity runs elsewhere. */
211
+ export function debugArgs(args, projectDir) {
212
+ return args.map((arg, i) =>
213
+ args[i - 1] === '--debug' && !isAbsolute(arg) ? join(projectDir, arg) : arg,
214
+ )
215
+ }
216
+
137
217
  function probityBin() {
138
218
  // @nizos/probity doesn't export its package.json, so resolve the main
139
219
  // entry and walk up to the package root for the bin path.
@@ -156,9 +236,18 @@ function probityBin() {
156
236
  async function main() {
157
237
  const chunks = []
158
238
  for await (const chunk of process.stdin) chunks.push(chunk)
159
- const { input, cleanup } = preparePayload(Buffer.concat(chunks).toString('utf8'))
239
+ const raw = Buffer.concat(chunks).toString('utf8')
240
+ const { input, cleanup } = preparePayload(raw)
241
+ let payload
242
+ try {
243
+ payload = JSON.parse(raw)
244
+ } catch {
245
+ payload = null
246
+ }
247
+ const projectDir = process.cwd()
248
+ const workDir = probityDirectory(payload, projectDir) ?? projectDir
160
249
 
161
- const args = process.argv.slice(2)
250
+ const args = debugArgs(process.argv.slice(2), projectDir)
162
251
  if (!args.includes('--agent')) args.unshift('--agent', 'claude-code')
163
252
 
164
253
  let bin
@@ -169,7 +258,10 @@ async function main() {
169
258
  cleanup()
170
259
  process.exit(2)
171
260
  }
172
- const child = spawn(process.execPath, [bin, ...args], { stdio: ['pipe', 'inherit', 'inherit'] })
261
+ const child = spawn(process.execPath, [bin, ...args], {
262
+ cwd: workDir,
263
+ stdio: ['pipe', 'inherit', 'inherit'],
264
+ })
173
265
  child.stdin.end(input)
174
266
  child.on('exit', (code, signal) => {
175
267
  cleanup()