@descent-vtt/spec-harness 0.7.0 → 0.9.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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +54 -0
  4. package/README.md +5 -3
  5. package/dist/answers.d.ts +54 -0
  6. package/dist/answers.js +139 -0
  7. package/dist/answers.js.map +1 -0
  8. package/dist/branch.d.ts +2 -2
  9. package/dist/branch.js +3 -1
  10. package/dist/branch.js.map +1 -1
  11. package/dist/cli.js +28 -17
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands.js +7 -2
  14. package/dist/commands.js.map +1 -1
  15. package/dist/configure.d.ts +13 -1
  16. package/dist/configure.js +46 -3
  17. package/dist/configure.js.map +1 -1
  18. package/dist/context.d.ts +16 -4
  19. package/dist/context.js +27 -12
  20. package/dist/context.js.map +1 -1
  21. package/dist/document.d.ts +15 -3
  22. package/dist/document.js.map +1 -1
  23. package/dist/fs.js +8 -5
  24. package/dist/fs.js.map +1 -1
  25. package/dist/git.d.ts +12 -6
  26. package/dist/git.js +25 -11
  27. package/dist/git.js.map +1 -1
  28. package/dist/host.d.ts +4 -3
  29. package/dist/host.js +74 -13
  30. package/dist/host.js.map +1 -1
  31. package/dist/plugin.js +10 -17
  32. package/dist/plugin.js.map +1 -1
  33. package/dist/premises.js +16 -12
  34. package/dist/premises.js.map +1 -1
  35. package/dist/reader.js +27 -10
  36. package/dist/reader.js.map +1 -1
  37. package/dist/round.d.ts +1 -1
  38. package/dist/round.js +61 -55
  39. package/dist/round.js.map +1 -1
  40. package/dist/server.js +17 -6
  41. package/dist/server.js.map +1 -1
  42. package/dist/setup.js +20 -15
  43. package/dist/setup.js.map +1 -1
  44. package/dist/siblings.d.ts +2 -0
  45. package/dist/siblings.js +12 -4
  46. package/dist/siblings.js.map +1 -1
  47. package/dist/signers.d.ts +8 -5
  48. package/dist/signers.js +126 -51
  49. package/dist/signers.js.map +1 -1
  50. package/dist/versions.d.ts +16 -3
  51. package/dist/versions.js +12 -2
  52. package/dist/versions.js.map +1 -1
  53. package/dist/workspace.d.ts +16 -3
  54. package/dist/workspace.js +12 -2
  55. package/dist/workspace.js.map +1 -1
  56. package/package.json +1 -1
package/dist/fs.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets the disk: the configuration, the state\n * directory outside the work tree, and paths as the filesystem spells them.\n */\n\nimport { existsSync, realpathSync } from 'node:fs';\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';\n\nimport { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig, type HarnessConfig } from './config.js';\n\n/** Reads `.spec-harness.json` at the root, or the defaults when there is none. */\nexport async function loadConfig(root: string): Promise<{ config: HarnessConfig; file: string | null }> {\n const file = join(root, CONFIG_FILE);\n if (!existsSync(file)) return { config: DEFAULT_CONFIG, file: null };\n let raw: unknown;\n try {\n raw = JSON.parse(await readFile(file, 'utf8'));\n } catch (error) {\n throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${(error as Error).message}`);\n }\n return { config: parseConfig(raw), file };\n}\n\n/**\n * The real spelling of a path: links resolved and, on a filesystem that\n * ignores case, the case the directory entries have. A path that does not\n * exist yet is resolved through its nearest existing ancestor.\n *\n * This is what stops `SRC/db/schema.ts` from walking past a protection on\n * `src/db/schema.ts` on Windows or macOS, where both name one file.\n */\nexport function realSpelling(path: string): string {\n const missing: string[] = [];\n let current = resolve(path);\n for (;;) {\n try {\n const real = realpathSync.native(current);\n return missing.length === 0 ? real : join(real, ...missing.reverse());\n } catch {\n const parent = dirname(current);\n if (parent === current) return resolve(path);\n missing.push(current.slice(parent.length).replace(/^[\\\\/]+/, ''));\n current = parent;\n }\n }\n}\n\n/**\n * A path as the repository names it - relative to the root, POSIX-separated,\n * real spelling - or `null` when it lies outside the root.\n */\nexport function repositoryPath(given: string, root: string, cwd: string): string | null {\n const absolute = isAbsolute(given) ? given : resolve(cwd, given);\n const real = realSpelling(absolute);\n const realRoot = realSpelling(root);\n const rel = relative(realRoot, real);\n if (rel === '') return '';\n // `..env` at the root is inside it; only a `..` segment climbs out.\n if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n return rel.split(sep).join('/');\n}\n\n/** The harness's own directory under the git common directory, created on demand (ADR-0003). */\nexport async function stateDirectory(commonDir: string, ...parts: string[]): Promise<string> {\n const directory = join(commonDir, 'spec-harness', ...parts);\n await mkdir(directory, { recursive: true });\n return directory;\n}\n\n/** Writes a file whole or not at all: a temporary file renamed over the target. */\nexport async function writeAtomic(file: string, content: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true });\n const temporary = `${file}.${process.pid}.tmp`;\n await writeFile(temporary, content, 'utf8');\n await rename(temporary, file);\n}\n\n/** A JSON object from a file: `null` when there is no file, `'unreadable'` when it holds anything else. */\nexport async function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'> {\n if (!existsSync(file)) return null;\n try {\n const value = JSON.parse(await readFile(file, 'utf8')) as unknown;\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 'unreadable';\n } catch {\n return 'unreadable';\n }\n}\n\nexport async function readText(file: string): Promise<string | null> {\n try {\n return await readFile(file, 'utf8');\n } catch {\n return null;\n }\n}\n"]}
1
+ {"version":3,"file":"fs.js","sourceRoot":"","sources":["../src/fs.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets the disk: the configuration, the state\n * directory outside the work tree, and paths as the filesystem spells them.\n */\n\nimport { existsSync, realpathSync } from 'node:fs';\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises';\nimport { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';\n\nimport { ConfigError, CONFIG_FILE, DEFAULT_CONFIG, parseConfig, type HarnessConfig } from './config.js';\n\n/** Reads `.spec-harness.json` at the root, or the defaults when there is none. */\nexport async function loadConfig(root: string): Promise<{ config: HarnessConfig; file: string | null }> {\n const file = join(root, CONFIG_FILE);\n if (!existsSync(file)) return { config: DEFAULT_CONFIG, file: null };\n let raw: unknown;\n try {\n // A Buffer parses as its text, so the encoding is equivalent to its mutant.\n raw = JSON.parse(await readFile(file, 'utf8'));\n } catch (error) {\n throw new ConfigError(`${CONFIG_FILE} is not valid JSON: ${(error as Error).message}`);\n }\n return { config: parseConfig(raw), file };\n}\n\n/**\n * The real spelling of a path: links resolved and, on a filesystem that\n * ignores case, the case the directory entries have. A path that does not\n * exist yet is resolved through its nearest existing ancestor.\n *\n * This is what stops `SRC/db/schema.ts` from walking past a protection on\n * `src/db/schema.ts` on Windows or macOS, where both name one file.\n */\nexport function realSpelling(path: string): string {\n const missing: string[] = [];\n let current = resolve(path);\n for (;;) {\n try {\n // join leaves a real path as it is, and reads a segment's leading\n // separator as the one between it and the last.\n return join(realpathSync.native(current), ...missing.reverse());\n } catch {\n const parent = dirname(current);\n // Only a root that does not exist fails at the top, a drive Windows\n // does not have; everywhere else the loop ends in the try.\n if (parent === current) return resolve(path);\n missing.push(current.slice(parent.length));\n current = parent;\n }\n }\n}\n\n/**\n * A path as the repository names it - relative to the root, POSIX-separated,\n * real spelling - or `null` when it lies outside the root.\n */\nexport function repositoryPath(given: string, root: string, cwd: string): string | null {\n const absolute = isAbsolute(given) ? given : resolve(cwd, given);\n const real = realSpelling(absolute);\n const realRoot = realSpelling(root);\n const rel = relative(realRoot, real);\n // `..env` at the root is inside it; only a `..` segment climbs out.\n if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;\n return rel.split(sep).join('/');\n}\n\n/** The harness's own directory under the git common directory, created on demand (ADR-0003). */\nexport async function stateDirectory(commonDir: string, ...parts: string[]): Promise<string> {\n const directory = join(commonDir, 'spec-harness', ...parts);\n await mkdir(directory, { recursive: true });\n return directory;\n}\n\n/** Writes a file whole or not at all: a temporary file renamed over the target. */\nexport async function writeAtomic(file: string, content: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true });\n const temporary = `${file}.${process.pid}.tmp`;\n // utf8 is writeFile's own default, so naming it is equivalent to its mutant.\n await writeFile(temporary, content, 'utf8');\n await rename(temporary, file);\n}\n\n/** A JSON object from a file: `null` when there is no file, `'unreadable'` when it holds anything else. */\nexport async function readJsonObject(file: string): Promise<Record<string, unknown> | null | 'unreadable'> {\n if (!existsSync(file)) return null;\n try {\n const value = JSON.parse(await readFile(file, 'utf8')) as unknown;\n return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : 'unreadable';\n } catch {\n return 'unreadable';\n }\n}\n\nexport async function readText(file: string): Promise<string | null> {\n try {\n return await readFile(file, 'utf8');\n } catch {\n return null;\n }\n}\n"]}
package/dist/git.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface Run {
13
13
  readonly stderr: string;
14
14
  }
15
15
  /** Runs git and answers whatever it answered; a missing git is a code of -1. */
16
- export declare function git(args: readonly string[], cwd: string, input?: string): Promise<Run>;
16
+ export declare function git(args: readonly string[], cwd: string): Promise<Run>;
17
17
  export declare class GitError extends Error {
18
18
  constructor(message: string);
19
19
  }
@@ -43,18 +43,24 @@ export declare function changes(from: string, to: string, cwd: string): Promise<
43
43
  export declare function stagedChanges(cwd: string): Promise<FileChange[]>;
44
44
  /** A file's content at a revision, or `null` when it does not exist there. */
45
45
  export declare function show(ref: string, path: string, cwd: string): Promise<string | null>;
46
+ /** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */
47
+ export declare function isUncommitted(sha: string): boolean;
46
48
  /**
47
49
  * The commit that last changed one line of a file, as of a revision, or of
48
50
  * the working tree when `ref` is `null` - where a line nobody committed yet
49
51
  * belongs to the all-zero commit.
50
52
  */
51
53
  export declare function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null>;
52
- export interface Signature {
53
- readonly good: boolean;
54
- /** The allowed signer's principal, for a good signature. */
55
- readonly principal: string | null;
54
+ /** A good signature names its allowed signer's principal; any other names none. */
55
+ export type Signature = {
56
+ readonly good: true;
57
+ readonly principal: string;
56
58
  readonly detail: string;
57
- }
59
+ } | {
60
+ readonly good: false;
61
+ readonly principal: null;
62
+ readonly detail: string;
63
+ };
58
64
  /**
59
65
  * Whether a commit carries a good SSH signature by a principal in the given
60
66
  * allowed-signers file. The file is passed in, never read from the commit's
package/dist/git.js CHANGED
@@ -8,14 +8,16 @@
8
8
  */
9
9
  import { execFile } from 'node:child_process';
10
10
  /** Runs git and answers whatever it answered; a missing git is a code of -1. */
11
- export function git(args, cwd, input) {
11
+ export function git(args, cwd) {
12
12
  return new Promise((resolve) => {
13
- const child = execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
14
- const code = error === null ? 0 : typeof error.code === 'number' ? (error.code) : -1;
13
+ // windowsHide only keeps a console window from flashing up on Windows,
14
+ // and the sweep runs on Linux, where its mutant is equivalent.
15
+ execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {
16
+ // Every caller tells 0 from any other code, never one failure from
17
+ // another, so which code a failure gets is equivalent to its mutants.
18
+ const code = error === null ? 0 : typeof error.code === 'number' ? error.code : -1;
15
19
  resolve({ code, stdout, stderr });
16
20
  });
17
- if (input !== undefined)
18
- child.stdin?.end(input);
19
21
  });
20
22
  }
21
23
  async function value(args, cwd) {
@@ -55,7 +57,9 @@ export async function remoteDefault(cwd) {
55
57
  /** Every local branch, by its short name. */
56
58
  export async function localBranches(cwd) {
57
59
  const listed = await value(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd);
58
- return listed === null || listed === '' ? [] : listed.split('\n').map((name) => name.trim());
60
+ // for-each-ref fails only where git itself does, outside a repository,
61
+ // where init never runs: the null check is there for the type.
62
+ return listed === null || listed === '' ? [] : listed.split('\n');
59
63
  }
60
64
  export async function revision(ref, cwd) {
61
65
  return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);
@@ -68,7 +72,7 @@ export function parseNameStatus(output) {
68
72
  const fields = output.split('\0');
69
73
  const out = [];
70
74
  for (let i = 0; i < fields.length;) {
71
- const code = fields[i] ?? '';
75
+ const code = fields[i];
72
76
  if (code === '') {
73
77
  i += 1;
74
78
  continue;
@@ -104,6 +108,10 @@ export async function show(ref, path, cwd) {
104
108
  const run = await git(['show', `${ref}:${path}`], cwd);
105
109
  return run.code === 0 ? run.stdout : null;
106
110
  }
111
+ /** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */
112
+ export function isUncommitted(sha) {
113
+ return /^0+$/.test(sha);
114
+ }
107
115
  /**
108
116
  * The commit that last changed one line of a file, as of a revision, or of
109
117
  * the working tree when `ref` is `null` - where a line nobody committed yet
@@ -111,8 +119,8 @@ export async function show(ref, path, cwd) {
111
119
  */
112
120
  export async function blameLine(ref, path, line, cwd) {
113
121
  const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);
114
- if (run.code !== 0)
115
- return null;
122
+ // A blame that fails prints nothing on stdout, which names no commit. Its
123
+ // output opens with the commit, so the anchor is equivalent to its mutant.
116
124
  const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];
117
125
  return sha ?? null;
118
126
  }
@@ -124,8 +132,14 @@ export async function blameLine(ref, path, line, cwd) {
124
132
  export async function verifyCommit(sha, allowedSignersFile, cwd) {
125
133
  const run = await git(['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha], cwd);
126
134
  const text = `${run.stderr}\n${run.stdout}`;
127
- const principal = /Good "git" signature for (\S+)/.exec(text)?.[1] ?? null;
128
- return { good: run.code === 0 && principal !== null, principal, detail: text.trim().split('\n')[0] ?? '' };
135
+ const principal = /Good "git" signature for (\S+)/.exec(text)?.[1];
136
+ // git writes the check to stderr, so the trim changes the first line only
137
+ // for output on stdout alone, which verify-commit does not print; and split
138
+ // always answers a first part.
139
+ const detail = text.trim().split('\n')[0];
140
+ // git exits 0 only for a signature by a listed principal, which it names:
141
+ // either condition alone is equivalent to both, and both trust neither.
142
+ return run.code === 0 && principal !== undefined ? { good: true, principal, detail } : { good: false, principal: null, detail };
129
143
  }
130
144
  export async function addWorktree(path, rev, cwd) {
131
145
  const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);
package/dist/git.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets git.\n *\n * Reads, except for two things ADR-0003 allows and bounds: a temporary\n * worktree, added and always removed by the sandbox, and nothing else. Every\n * call is an argument vector, never a shell string, so a path or a ref cannot\n * become a command.\n */\n\nimport { execFile } from 'node:child_process';\n\nimport type { FileChange } from './types.js';\n\nexport interface Run {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\n/** Runs git and answers whatever it answered; a missing git is a code of -1. */\nexport function git(args: readonly string[], cwd: string, input?: string): Promise<Run> {\n return new Promise((resolve) => {\n const child = execFile(\n 'git',\n [...args],\n { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true },\n (error, stdout, stderr) => {\n const code = error === null ? 0 : typeof (error as { code?: unknown }).code === 'number' ? ((error as { code: number }).code) : -1;\n resolve({ code, stdout, stderr });\n },\n );\n if (input !== undefined) child.stdin?.end(input);\n });\n}\n\nasync function value(args: readonly string[], cwd: string): Promise<string | null> {\n const run = await git(args, cwd);\n return run.code === 0 ? run.stdout.trim() : null;\n}\n\nexport class GitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GitError';\n }\n}\n\n/** The top of the work tree holding `cwd`, POSIX-separated, or `null` outside one. */\nexport async function workTreeRoot(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--show-toplevel'], cwd);\n}\n\n/** The git directory every worktree of this repository shares. */\nexport async function commonDirectory(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);\n}\n\n/**\n * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git\n * reads it - relative to the work tree, absolute, or under `~` - and a linked\n * worktree's shared hooks. `null` when git cannot say, before 2.31.\n */\nexport async function hookPath(name: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);\n}\n\n/** The checked-out branch's short name, or `null` on a detached head. */\nexport async function currentBranch(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);\n}\n\n/** The remote's default branch as a local ref, such as `origin/main`, or `null`. */\nexport async function remoteDefault(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);\n}\n\n/** Every local branch, by its short name. */\nexport async function localBranches(cwd: string): Promise<string[]> {\n const listed = await value(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd);\n return listed === null || listed === '' ? [] : listed.split('\\n').map((name) => name.trim());\n}\n\nexport async function revision(ref: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);\n}\n\nexport async function mergeBase(a: string, b: string, cwd: string): Promise<string | null> {\n return value(['merge-base', a, b], cwd);\n}\n\n/** Parses `git diff --name-status -z` output. */\nexport function parseNameStatus(output: string): FileChange[] {\n const fields = output.split('\\0');\n const out: FileChange[] = [];\n for (let i = 0; i < fields.length; ) {\n const code = fields[i] ?? '';\n if (code === '') {\n i += 1;\n continue;\n }\n const status = code.charAt(0) as FileChange['status'];\n if (status === 'R' || status === 'C') {\n out.push({ status, from: fields[i + 1] ?? '', path: fields[i + 2] ?? '' });\n i += 3;\n } else {\n out.push({ status, path: fields[i + 1] ?? '' });\n i += 2;\n }\n }\n return out;\n}\n\n/** The files changed from one revision to another, renames found. */\nexport async function changes(from: string, to: string, cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--name-status', '-z', '--find-renames', '--no-color', from, to, '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff ${from} ${to} failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** The files a commit would change: what is staged. */\nexport async function stagedChanges(cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--cached', '--name-status', '-z', '--find-renames', '--no-color', '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff --cached failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** A file's content at a revision, or `null` when it does not exist there. */\nexport async function show(ref: string, path: string, cwd: string): Promise<string | null> {\n const run = await git(['show', `${ref}:${path}`], cwd);\n return run.code === 0 ? run.stdout : null;\n}\n\n/**\n * The commit that last changed one line of a file, as of a revision, or of\n * the working tree when `ref` is `null` - where a line nobody committed yet\n * belongs to the all-zero commit.\n */\nexport async function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null> {\n const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);\n if (run.code !== 0) return null;\n const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];\n return sha ?? null;\n}\n\nexport interface Signature {\n readonly good: boolean;\n /** The allowed signer's principal, for a good signature. */\n readonly principal: string | null;\n readonly detail: string;\n}\n\n/**\n * Whether a commit carries a good SSH signature by a principal in the given\n * allowed-signers file. The file is passed in, never read from the commit's\n * own tree: a round cannot vouch for itself (ADR-0006).\n */\nexport async function verifyCommit(sha: string, allowedSignersFile: string, cwd: string): Promise<Signature> {\n const run = await git(\n ['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha],\n cwd,\n );\n const text = `${run.stderr}\\n${run.stdout}`;\n const principal = /Good \"git\" signature for (\\S+)/.exec(text)?.[1] ?? null;\n return { good: run.code === 0 && principal !== null, principal, detail: text.trim().split('\\n')[0] ?? '' };\n}\n\nexport async function addWorktree(path: string, rev: string, cwd: string): Promise<void> {\n const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);\n if (run.code !== 0) throw new GitError(`git worktree add failed: ${run.stderr.trim()}`);\n}\n\n/**\n * Forgets the worktree at `path`, and no other, once its directory is\n * deleted. Git refuses to remove a worktree whose directory has lost its\n * `.git` file, and removes its record of one whose directory is gone, found by\n * the path it was added at: `git worktree prune` would also forget every other\n * worktree git has lost track of, the person's among them.\n */\nexport async function removeWorktree(path: string, cwd: string): Promise<void> {\n await git(['worktree', 'remove', '--force', path], cwd);\n}\n"]}
1
+ {"version":3,"file":"git.js","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets git.\n *\n * Reads, except for two things ADR-0003 allows and bounds: a temporary\n * worktree, added and always removed by the sandbox, and nothing else. Every\n * call is an argument vector, never a shell string, so a path or a ref cannot\n * become a command.\n */\n\nimport { execFile } from 'node:child_process';\n\nimport type { FileChange } from './types.js';\n\nexport interface Run {\n readonly code: number;\n readonly stdout: string;\n readonly stderr: string;\n}\n\n/** Runs git and answers whatever it answered; a missing git is a code of -1. */\nexport function git(args: readonly string[], cwd: string): Promise<Run> {\n return new Promise((resolve) => {\n // windowsHide only keeps a console window from flashing up on Windows,\n // and the sweep runs on Linux, where its mutant is equivalent.\n execFile('git', [...args], { cwd, maxBuffer: 256 * 1024 * 1024, encoding: 'utf8', windowsHide: true }, (error, stdout, stderr) => {\n // Every caller tells 0 from any other code, never one failure from\n // another, so which code a failure gets is equivalent to its mutants.\n const code = error === null ? 0 : typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : -1;\n resolve({ code, stdout, stderr });\n });\n });\n}\n\nasync function value(args: readonly string[], cwd: string): Promise<string | null> {\n const run = await git(args, cwd);\n return run.code === 0 ? run.stdout.trim() : null;\n}\n\nexport class GitError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'GitError';\n }\n}\n\n/** The top of the work tree holding `cwd`, POSIX-separated, or `null` outside one. */\nexport async function workTreeRoot(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--show-toplevel'], cwd);\n}\n\n/** The git directory every worktree of this repository shares. */\nexport async function commonDirectory(cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);\n}\n\n/**\n * Where git runs the hook `name` from, absolute: `core.hooksPath` read as git\n * reads it - relative to the work tree, absolute, or under `~` - and a linked\n * worktree's shared hooks. `null` when git cannot say, before 2.31.\n */\nexport async function hookPath(name: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--path-format=absolute', '--git-path', `hooks/${name}`], cwd);\n}\n\n/** The checked-out branch's short name, or `null` on a detached head. */\nexport async function currentBranch(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd);\n}\n\n/** The remote's default branch as a local ref, such as `origin/main`, or `null`. */\nexport async function remoteDefault(cwd: string): Promise<string | null> {\n return value(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);\n}\n\n/** Every local branch, by its short name. */\nexport async function localBranches(cwd: string): Promise<string[]> {\n const listed = await value(['for-each-ref', '--format=%(refname:short)', 'refs/heads/'], cwd);\n // for-each-ref fails only where git itself does, outside a repository,\n // where init never runs: the null check is there for the type.\n return listed === null || listed === '' ? [] : listed.split('\\n');\n}\n\nexport async function revision(ref: string, cwd: string): Promise<string | null> {\n return value(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], cwd);\n}\n\nexport async function mergeBase(a: string, b: string, cwd: string): Promise<string | null> {\n return value(['merge-base', a, b], cwd);\n}\n\n/** Parses `git diff --name-status -z` output. */\nexport function parseNameStatus(output: string): FileChange[] {\n const fields = output.split('\\0');\n const out: FileChange[] = [];\n for (let i = 0; i < fields.length; ) {\n const code = fields[i] as string;\n if (code === '') {\n i += 1;\n continue;\n }\n const status = code.charAt(0) as FileChange['status'];\n if (status === 'R' || status === 'C') {\n out.push({ status, from: fields[i + 1] ?? '', path: fields[i + 2] ?? '' });\n i += 3;\n } else {\n out.push({ status, path: fields[i + 1] ?? '' });\n i += 2;\n }\n }\n return out;\n}\n\n/** The files changed from one revision to another, renames found. */\nexport async function changes(from: string, to: string, cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--name-status', '-z', '--find-renames', '--no-color', from, to, '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff ${from} ${to} failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** The files a commit would change: what is staged. */\nexport async function stagedChanges(cwd: string): Promise<FileChange[]> {\n const run = await git(['diff', '--cached', '--name-status', '-z', '--find-renames', '--no-color', '--'], cwd);\n if (run.code !== 0) throw new GitError(`git diff --cached failed: ${run.stderr.trim()}`);\n return parseNameStatus(run.stdout);\n}\n\n/** A file's content at a revision, or `null` when it does not exist there. */\nexport async function show(ref: string, path: string, cwd: string): Promise<string | null> {\n const run = await git(['show', `${ref}:${path}`], cwd);\n return run.code === 0 ? run.stdout : null;\n}\n\n/** Whether a commit id from {@link blameLine} is the all-zero one of a line nobody committed. */\nexport function isUncommitted(sha: string): boolean {\n return /^0+$/.test(sha);\n}\n\n/**\n * The commit that last changed one line of a file, as of a revision, or of\n * the working tree when `ref` is `null` - where a line nobody committed yet\n * belongs to the all-zero commit.\n */\nexport async function blameLine(ref: string | null, path: string, line: number, cwd: string): Promise<string | null> {\n const run = await git(['blame', '--porcelain', '-L', `${line},${line}`, ...(ref === null ? [] : [ref]), '--', path], cwd);\n // A blame that fails prints nothing on stdout, which names no commit. Its\n // output opens with the commit, so the anchor is equivalent to its mutant.\n const sha = /^([0-9a-f]{40,64}) /.exec(run.stdout)?.[1];\n return sha ?? null;\n}\n\n/** A good signature names its allowed signer's principal; any other names none. */\nexport type Signature =\n | { readonly good: true; readonly principal: string; readonly detail: string }\n | { readonly good: false; readonly principal: null; readonly detail: string };\n\n/**\n * Whether a commit carries a good SSH signature by a principal in the given\n * allowed-signers file. The file is passed in, never read from the commit's\n * own tree: a round cannot vouch for itself (ADR-0006).\n */\nexport async function verifyCommit(sha: string, allowedSignersFile: string, cwd: string): Promise<Signature> {\n const run = await git(\n ['-c', `gpg.ssh.allowedSignersFile=${allowedSignersFile}`, '-c', 'gpg.format=ssh', 'verify-commit', '--raw', sha],\n cwd,\n );\n const text = `${run.stderr}\\n${run.stdout}`;\n const principal = /Good \"git\" signature for (\\S+)/.exec(text)?.[1];\n // git writes the check to stderr, so the trim changes the first line only\n // for output on stdout alone, which verify-commit does not print; and split\n // always answers a first part.\n const detail = text.trim().split('\\n')[0] as string;\n // git exits 0 only for a signature by a listed principal, which it names:\n // either condition alone is equivalent to both, and both trust neither.\n return run.code === 0 && principal !== undefined ? { good: true, principal, detail } : { good: false, principal: null, detail };\n}\n\nexport async function addWorktree(path: string, rev: string, cwd: string): Promise<void> {\n const run = await git(['worktree', 'add', '--detach', '--force', path, rev], cwd);\n if (run.code !== 0) throw new GitError(`git worktree add failed: ${run.stderr.trim()}`);\n}\n\n/**\n * Forgets the worktree at `path`, and no other, once its directory is\n * deleted. Git refuses to remove a worktree whose directory has lost its\n * `.git` file, and removes its record of one whose directory is gone, found by\n * the path it was added at: `git worktree prune` would also forget every other\n * worktree git has lost track of, the person's among them.\n */\nexport async function removeWorktree(path: string, cwd: string): Promise<void> {\n await git(['worktree', 'remove', '--force', path], cwd);\n}\n"]}
package/dist/host.d.ts CHANGED
@@ -3,9 +3,10 @@
3
3
  * sibling: Claude Code, asked which release it is.
4
4
  *
5
5
  * Found on `PATH` and started without a shell, as every program the harness
6
- * runs is (ADR-0002). On Windows a program is an `.exe` or a `.com`; a
7
- * `claude.cmd` shim, as npm installs one, can be started only by a shell, so
8
- * it is named rather than run, and its release cannot be told.
6
+ * runs is (ADR-0002). On Windows a program is an `.exe` or a `.com`. A
7
+ * `claude.cmd` shim, as npm installs Claude Code, can be started only by a
8
+ * shell - Node refuses a `.cmd` without one since the fix for CVE-2024-27980 -
9
+ * so it is read rather than run: the package it runs declares the release.
9
10
  */
10
11
  import type { ClaudeCodeAnswer } from './versions.js';
11
12
  /** The file `name` names on `PATH` as the environment gives it, or `null`. */
package/dist/host.js CHANGED
@@ -3,17 +3,35 @@
3
3
  * sibling: Claude Code, asked which release it is.
4
4
  *
5
5
  * Found on `PATH` and started without a shell, as every program the harness
6
- * runs is (ADR-0002). On Windows a program is an `.exe` or a `.com`; a
7
- * `claude.cmd` shim, as npm installs one, can be started only by a shell, so
8
- * it is named rather than run, and its release cannot be told.
6
+ * runs is (ADR-0002). On Windows a program is an `.exe` or a `.com`. A
7
+ * `claude.cmd` shim, as npm installs Claude Code, can be started only by a
8
+ * shell - Node refuses a `.cmd` without one since the fix for CVE-2024-27980 -
9
+ * so it is read rather than run: the package it runs declares the release.
9
10
  */
10
11
  import { execFile } from 'node:child_process';
11
12
  import { existsSync, statSync } from 'node:fs';
12
- import { delimiter, join } from 'node:path';
13
+ import { delimiter, dirname, join } from 'node:path';
14
+ import { readText } from './fs.js';
15
+ import { declaredVersion } from './siblings.js';
13
16
  /** What Windows runs for a name with no extension, in order; the first two alone without a shell. */
14
17
  const WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD'];
18
+ /**
19
+ * A path a shim runs, from the shim's own directory. npm's cmd-shim writes
20
+ * `"%dp0%\node_modules\@anthropic-ai\claude-code\bin\claude.exe"`, after
21
+ * `SET dp0=%~dp0`; pnpm's writes `"%~dp0\..."`.
22
+ */
23
+ const SHIM_TARGET = /"(?:%~dp0|%dp0%)\\([^"\r\n]*)"/g;
24
+ /**
25
+ * Where npm and pnpm install Claude Code, as the segments of a path: only its
26
+ * own package declares its release, where a package that wraps it declares
27
+ * the wrapper's.
28
+ */
29
+ const CLAUDE_CODE_PACKAGE = ['node_modules', '@anthropic-ai', 'claude-code'];
15
30
  /** The file `name` names on `PATH` as the environment gives it, or `null`. */
16
31
  export function onPath(name, env, platform = process.platform) {
32
+ // With neither, any text names directories no test can have made; and an
33
+ // empty entry is passed over rather than read as the working directory,
34
+ // which a test cannot fill: both mutants are equivalent in the suite.
17
35
  const path = env['PATH'] ?? env['Path'] ?? '';
18
36
  const windows = platform === 'win32';
19
37
  const extensions = windows
@@ -30,21 +48,64 @@ export function onPath(name, env, platform = process.platform) {
30
48
  }
31
49
  return null;
32
50
  }
51
+ /** The directory of Claude Code's package that a shim runs, or `null` when it runs none. */
52
+ function shimPackage(file, text) {
53
+ for (const match of text.matchAll(SHIM_TARGET)) {
54
+ const parts = match[1].split('\\');
55
+ const at = parts.findIndex((_, start) => CLAUDE_CODE_PACKAGE.every((part, offset) => parts[start + offset] === part));
56
+ if (at >= 0)
57
+ return join(dirname(file), ...parts.slice(0, at + CLAUDE_CODE_PACKAGE.length));
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * The release a Windows script on `PATH` runs, as the package.json of the
63
+ * Claude Code package it names declares it; nothing is run. A script that
64
+ * names no such package, or one whose package.json gives no version, cannot
65
+ * tell.
66
+ */
67
+ async function shimVersion(file) {
68
+ // A shim that cannot be read names no package, as an empty one does, so
69
+ // what stands in for its text is equivalent whatever it is.
70
+ const directory = shimPackage(file, (await readText(file)) ?? '');
71
+ if (directory !== null) {
72
+ const manifest = join(directory, 'package.json');
73
+ const version = declaredVersion(manifest);
74
+ if (typeof version === 'string')
75
+ return { declared: version, file: manifest };
76
+ }
77
+ return {
78
+ missing: `claude on PATH is ${file}, a script that cannot be started without a shell, which spec-harness does not use, and it names no @anthropic-ai/claude-code package whose package.json gives a version`,
79
+ };
80
+ }
33
81
  /** `claude --version`, as the Claude Code on `PATH` answers it, or why it cannot. */
34
82
  export async function claudeVersion(env, platform = process.platform) {
35
83
  const file = onPath('claude', env, platform);
36
84
  if (file === null)
37
85
  return { missing: 'no claude is on PATH' };
38
- if (platform === 'win32' && !/\.(?:exe|com)$/i.test(file)) {
39
- return { missing: `claude on PATH is ${file}, a script that cannot be started without a shell, which spec-harness does not use` };
40
- }
86
+ if (platform === 'win32' && !/\.(?:exe|com)$/i.test(file))
87
+ return shimVersion(file);
88
+ // Of the options, only `shell: false` and the encoding change an answer a
89
+ // test can see: `windowsHide` hides a console window, and the timeout
90
+ // stops a claude that never answers, which a test would wait on for its
91
+ // length, so their mutants are equivalent in the suite.
41
92
  return new Promise((resolve) => {
42
- execFile(file, ['--version'], { encoding: 'utf8', windowsHide: true, timeout: 15_000, shell: false }, (error, stdout) => {
43
- if (error !== null)
44
- resolve({ missing: `${file} --version failed: ${error.message.split('\n')[0] ?? ''}` });
45
- else
46
- resolve({ output: stdout });
47
- });
93
+ const failed = (error) => resolve({ missing: `${file} --version failed: ${error.message.split('\n')[0]}` });
94
+ try {
95
+ execFile(file, ['--version'], { encoding: 'utf8', windowsHide: true, timeout: 15_000, shell: false }, (error, stdout) => {
96
+ if (error !== null)
97
+ failed(error);
98
+ else
99
+ resolve({ output: stdout });
100
+ });
101
+ }
102
+ catch (error) {
103
+ // Windows refuses a file that is no program before any callback, as
104
+ // spawn UNKNOWN, where Linux reports it to the callback: doctor says
105
+ // so rather than stop. The sweep runs on Linux, so this line is the
106
+ // Windows job's to hold.
107
+ failed(error);
108
+ }
48
109
  });
49
110
  }
50
111
  //# sourceMappingURL=host.js.map
package/dist/host.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"host.js","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC9C,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAI5C,qGAAqG;AACrG,MAAM,kBAAkB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAE5D,8EAA8E;AAC9E,MAAM,UAAU,MAAM,CAAC,IAAY,EAAE,GAAiD,EAAE,QAAQ,GAAoB,OAAO,CAAC,QAAQ;IAClI,MAAM,IAAI,GAAG,GAAG,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IAC9C,MAAM,OAAO,GAAG,QAAQ,KAAK,OAAO,CAAC;IACrC,MAAM,UAAU,GAAG,OAAO;QACxB,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,KAAK,EAAE,CAAC;QACrG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACT,KAAK,MAAM,SAAS,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9D,IAAI,SAAS,KAAK,EAAE;YAAE,SAAS;QAC/B,KAAK,MAAM,SAAS,IAAI,UAAU,EAAE,CAAC;YACnC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,EAAE,GAAG,IAAI,GAAG,SAAS,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;YAClE,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE;gBAAE,OAAO,IAAI,CAAC;QAC/D,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,qFAAqF;AACrF,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAiD,EAAE,QAAQ,GAAoB,OAAO,CAAC,QAAQ;IACjI,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC;IAC7C,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAC;IAC9D,IAAI,QAAQ,KAAK,OAAO,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1D,OAAO,EAAE,OAAO,EAAE,qBAAqB,IAAI,oFAAoF,EAAE,CAAC;IACpI,CAAC;IACD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,QAAQ,CAAC,IAAI,EAAE,CAAC,WAAW,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE;YACtH,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,CAAC,EAAE,OAAO,EAAE,GAAG,IAAI,sBAAsB,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;;gBACvG,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QACnC,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["/**\n * The edge where the harness meets a program that is neither git nor a\n * sibling: Claude Code, asked which release it is.\n *\n * Found on `PATH` and started without a shell, as every program the harness\n * runs is (ADR-0002). On Windows a program is an `.exe` or a `.com`; a\n * `claude.cmd` shim, as npm installs one, can be started only by a shell, so\n * it is named rather than run, and its release cannot be told.\n */\n\nimport { execFile } from 'node:child_process';\nimport { existsSync, statSync } from 'node:fs';\nimport { delimiter, join } from 'node:path';\n\nimport type { ClaudeCodeAnswer } from './versions.js';\n\n/** What Windows runs for a name with no extension, in order; the first two alone without a shell. */\nconst WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD'];\n\n/** The file `name` names on `PATH` as the environment gives it, or `null`. */\nexport function onPath(name: string, env: Readonly<Record<string, string | undefined>>, platform: NodeJS.Platform = process.platform): string | null {\n const path = env['PATH'] ?? env['Path'] ?? '';\n const windows = platform === 'win32';\n const extensions = windows\n ? (env['PATHEXT'] ?? WINDOWS_EXTENSIONS.join(';')).split(';').filter((extension) => extension !== '')\n : [''];\n for (const directory of path.split(windows ? ';' : delimiter)) {\n if (directory === '') continue;\n for (const extension of extensions) {\n const file = join(directory, `${name}${extension.toLowerCase()}`);\n if (existsSync(file) && statSync(file).isFile()) return file;\n }\n }\n return null;\n}\n\n/** `claude --version`, as the Claude Code on `PATH` answers it, or why it cannot. */\nexport async function claudeVersion(env: Readonly<Record<string, string | undefined>>, platform: NodeJS.Platform = process.platform): Promise<ClaudeCodeAnswer> {\n const file = onPath('claude', env, platform);\n if (file === null) return { missing: 'no claude is on PATH' };\n if (platform === 'win32' && !/\\.(?:exe|com)$/i.test(file)) {\n return { missing: `claude on PATH is ${file}, a script that cannot be started without a shell, which spec-harness does not use` };\n }\n return new Promise((resolve) => {\n execFile(file, ['--version'], { encoding: 'utf8', windowsHide: true, timeout: 15_000, shell: false }, (error, stdout) => {\n if (error !== null) resolve({ missing: `${file} --version failed: ${error.message.split('\\n')[0] ?? ''}` });\n else resolve({ output: stdout });\n });\n });\n}\n"]}
1
+ {"version":3,"file":"host.js","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The edge where the harness meets a program that is neither git nor a\n * sibling: Claude Code, asked which release it is.\n *\n * Found on `PATH` and started without a shell, as every program the harness\n * runs is (ADR-0002). On Windows a program is an `.exe` or a `.com`. A\n * `claude.cmd` shim, as npm installs Claude Code, can be started only by a\n * shell - Node refuses a `.cmd` without one since the fix for CVE-2024-27980 -\n * so it is read rather than run: the package it runs declares the release.\n */\n\nimport { execFile } from 'node:child_process';\nimport { existsSync, statSync } from 'node:fs';\nimport { delimiter, dirname, join } from 'node:path';\n\nimport { readText } from './fs.js';\nimport { declaredVersion } from './siblings.js';\nimport type { ClaudeCodeAnswer } from './versions.js';\n\n/** What Windows runs for a name with no extension, in order; the first two alone without a shell. */\nconst WINDOWS_EXTENSIONS = ['.COM', '.EXE', '.BAT', '.CMD'];\n\n/**\n * A path a shim runs, from the shim's own directory. npm's cmd-shim writes\n * `\"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe\"`, after\n * `SET dp0=%~dp0`; pnpm's writes `\"%~dp0\\...\"`.\n */\nconst SHIM_TARGET = /\"(?:%~dp0|%dp0%)\\\\([^\"\\r\\n]*)\"/g;\n\n/**\n * Where npm and pnpm install Claude Code, as the segments of a path: only its\n * own package declares its release, where a package that wraps it declares\n * the wrapper's.\n */\nconst CLAUDE_CODE_PACKAGE = ['node_modules', '@anthropic-ai', 'claude-code'];\n\n/** The file `name` names on `PATH` as the environment gives it, or `null`. */\nexport function onPath(name: string, env: Readonly<Record<string, string | undefined>>, platform: NodeJS.Platform = process.platform): string | null {\n // With neither, any text names directories no test can have made; and an\n // empty entry is passed over rather than read as the working directory,\n // which a test cannot fill: both mutants are equivalent in the suite.\n const path = env['PATH'] ?? env['Path'] ?? '';\n const windows = platform === 'win32';\n const extensions = windows\n ? (env['PATHEXT'] ?? WINDOWS_EXTENSIONS.join(';')).split(';').filter((extension) => extension !== '')\n : [''];\n for (const directory of path.split(windows ? ';' : delimiter)) {\n if (directory === '') continue;\n for (const extension of extensions) {\n const file = join(directory, `${name}${extension.toLowerCase()}`);\n if (existsSync(file) && statSync(file).isFile()) return file;\n }\n }\n return null;\n}\n\n/** The directory of Claude Code's package that a shim runs, or `null` when it runs none. */\nfunction shimPackage(file: string, text: string): string | null {\n for (const match of text.matchAll(SHIM_TARGET)) {\n const parts = (match[1] as string).split('\\\\');\n const at = parts.findIndex((_, start) => CLAUDE_CODE_PACKAGE.every((part, offset) => parts[start + offset] === part));\n if (at >= 0) return join(dirname(file), ...parts.slice(0, at + CLAUDE_CODE_PACKAGE.length));\n }\n return null;\n}\n\n/**\n * The release a Windows script on `PATH` runs, as the package.json of the\n * Claude Code package it names declares it; nothing is run. A script that\n * names no such package, or one whose package.json gives no version, cannot\n * tell.\n */\nasync function shimVersion(file: string): Promise<ClaudeCodeAnswer> {\n // A shim that cannot be read names no package, as an empty one does, so\n // what stands in for its text is equivalent whatever it is.\n const directory = shimPackage(file, (await readText(file)) ?? '');\n if (directory !== null) {\n const manifest = join(directory, 'package.json');\n const version = declaredVersion(manifest);\n if (typeof version === 'string') return { declared: version, file: manifest };\n }\n return {\n missing: `claude on PATH is ${file}, a script that cannot be started without a shell, which spec-harness does not use, and it names no @anthropic-ai/claude-code package whose package.json gives a version`,\n };\n}\n\n/** `claude --version`, as the Claude Code on `PATH` answers it, or why it cannot. */\nexport async function claudeVersion(env: Readonly<Record<string, string | undefined>>, platform: NodeJS.Platform = process.platform): Promise<ClaudeCodeAnswer> {\n const file = onPath('claude', env, platform);\n if (file === null) return { missing: 'no claude is on PATH' };\n if (platform === 'win32' && !/\\.(?:exe|com)$/i.test(file)) return shimVersion(file);\n // Of the options, only `shell: false` and the encoding change an answer a\n // test can see: `windowsHide` hides a console window, and the timeout\n // stops a claude that never answers, which a test would wait on for its\n // length, so their mutants are equivalent in the suite.\n return new Promise((resolve) => {\n const failed = (error: Error): void => resolve({ missing: `${file} --version failed: ${error.message.split('\\n')[0] as string}` });\n try {\n execFile(file, ['--version'], { encoding: 'utf8', windowsHide: true, timeout: 15_000, shell: false }, (error, stdout) => {\n if (error !== null) failed(error);\n else resolve({ output: stdout });\n });\n } catch (error) {\n // Windows refuses a file that is no program before any callback, as\n // spawn UNKNOWN, where Linux reports it to the callback: doctor says\n // so rather than stop. The sweep runs on Linux, so this line is the\n // Windows job's to hold.\n failed(error as Error);\n }\n });\n}\n"]}
package/dist/plugin.js CHANGED
@@ -22,7 +22,11 @@ import { rulingFor } from './guard.js';
22
22
  import { createReader } from './reader.js';
23
23
  import { checkRulings, resolveBase } from './round.js';
24
24
  import { createSiblings } from './siblings.js';
25
- /** Paths a finding names, in either of the shapes spec-brief reports them. */
25
+ /**
26
+ * Paths a finding names, in either of the shapes spec-brief reports them. A
27
+ * finding with neither names none, and a path no ruling names is waived for
28
+ * none, so the empty list is equivalent to its mutant.
29
+ */
26
30
  function pathsOf(finding) {
27
31
  if (finding.path !== undefined)
28
32
  return [finding.path];
@@ -30,11 +34,15 @@ function pathsOf(finding) {
30
34
  }
31
35
  export async function waive(context) {
32
36
  const protectedPaths = context.findings.filter((finding) => finding.rule === 'protected-file').flatMap(pathsOf);
37
+ // Asking git about nothing waives nothing: this saves the asking, and its
38
+ // mutant is equivalent.
33
39
  if (protectedPaths.length === 0)
34
40
  return [];
35
41
  const { config } = await loadConfig(context.root);
36
42
  const workspace = {
37
43
  root: context.root,
44
+ // spec-brief runs in a work tree, where git always names the common
45
+ // directory; the fallback is there for the type.
38
46
  commonDir: (await commonDirectory(context.root)) ?? `${context.root}/.git`,
39
47
  config,
40
48
  siblings: createSiblings(context.root, config),
@@ -42,22 +50,7 @@ export async function waive(context) {
42
50
  cwd: context.root,
43
51
  };
44
52
  const base = await resolveBase(workspace, context.base ?? undefined);
45
- const brief = {
46
- id: context.brief.id ?? context.brief.file,
47
- file: context.brief.file,
48
- title: null,
49
- phase: 'live',
50
- status: null,
51
- type: null,
52
- wave: null,
53
- dependsOn: [],
54
- affectedFiles: [],
55
- protectedFiles: [],
56
- tasks: { total: 0, checked: 0 },
57
- ready: false,
58
- waitingOn: [],
59
- };
60
- const { verified } = await checkRulings(workspace, brief, context.brief.text, createReader(), base);
53
+ const { verified } = await checkRulings(workspace, { file: context.brief.file }, context.brief.text, createReader(), base);
61
54
  const waivers = [];
62
55
  for (const path of protectedPaths) {
63
56
  const ruling = rulingFor(verified, path);
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The spec-brief plugin: signed rulings, read where spec-brief decides.\n *\n * spec-brief's archive refuses a round that changed a file its brief protects.\n * When a person has ruled that the round may change it, and signed the ruling\n * (ADR-0006), the refusal is lifted - but spec-brief does not read signatures,\n * and should not: the tool that defines a format ships the check for it\n * (spec-brief ADR-0007). So the check ships here, and spec-brief asks it\n * through its plugin `waive` hook, once its configuration loads the plugin,\n * as `spec-harness init` writes it:\n *\n * ```json\n * { \"plugins\": [\"@descent-vtt/spec-harness/spec-brief-plugin\"] }\n * ```\n *\n * It waives a `protected-file` refusal for a path a verified `allow` ruling\n * names, and nothing else. A ruling that does not verify waives nothing.\n */\n\nimport { loadConfig } from './fs.js';\nimport { commonDirectory, currentBranch } from './git.js';\nimport { rulingFor } from './guard.js';\nimport { createReader } from './reader.js';\nimport { checkRulings, resolveBase } from './round.js';\nimport { createSiblings } from './siblings.js';\nimport type { BriefRow } from './types.js';\nimport type { Workspace } from './workspace.js';\n\nexport interface WaiveContext {\n readonly root: string;\n /** spec-brief passes a `null` id for a brief it could not name; its file still identifies it. */\n readonly brief: { readonly id: string | null; readonly file: string; readonly text: string };\n readonly findings: readonly { readonly rule: string; readonly path?: string | undefined; readonly paths?: readonly string[] | undefined }[];\n readonly base: string | null;\n readonly commit: string | null;\n}\n\nexport interface Waiver {\n readonly rule: string;\n readonly path: string;\n readonly reason: string;\n}\n\n/** Paths a finding names, in either of the shapes spec-brief reports them. */\nfunction pathsOf(finding: WaiveContext['findings'][number]): string[] {\n if (finding.path !== undefined) return [finding.path];\n return [...(finding.paths ?? [])];\n}\n\nexport async function waive(context: WaiveContext): Promise<Waiver[]> {\n const protectedPaths = context.findings.filter((finding) => finding.rule === 'protected-file').flatMap(pathsOf);\n if (protectedPaths.length === 0) return [];\n const { config } = await loadConfig(context.root);\n const workspace: Workspace = {\n root: context.root,\n commonDir: (await commonDirectory(context.root)) ?? `${context.root}/.git`,\n config,\n siblings: createSiblings(context.root, config),\n branch: await currentBranch(context.root),\n cwd: context.root,\n };\n const base = await resolveBase(workspace, context.base ?? undefined);\n const brief: BriefRow = {\n id: context.brief.id ?? context.brief.file,\n file: context.brief.file,\n title: null,\n phase: 'live',\n status: null,\n type: null,\n wave: null,\n dependsOn: [],\n affectedFiles: [],\n protectedFiles: [],\n tasks: { total: 0, checked: 0 },\n ready: false,\n waitingOn: [],\n };\n const { verified } = await checkRulings(workspace, brief, context.brief.text, createReader(), base);\n const waivers: Waiver[] = [];\n for (const path of protectedPaths) {\n const ruling = rulingFor(verified, path);\n if (ruling !== undefined) {\n waivers.push({ rule: 'protected-file', path, reason: `ruling ${ruling.id}, signed by ${ruling.signer}, allows it` });\n }\n }\n return waivers;\n}\n\n/** The plugin, as spec-brief loads it: a function of its options returning `{ name, rules, waive }`. */\nexport default function specHarnessPlugin(): { name: string; rules: readonly never[]; waive: typeof waive } {\n return { name: 'spec-harness', rules: [], waive };\n}\n"]}
1
+ {"version":3,"file":"plugin.js","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAkB/C;;;;GAIG;AACH,SAAS,OAAO,CAAC,OAAyC;IACxD,IAAI,OAAO,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,OAAqB;IAC/C,MAAM,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,KAAK,gBAAgB,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IAChH,0EAA0E;IAC1E,wBAAwB;IACxB,IAAI,cAAc,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAC3C,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,MAAM,SAAS,GAAc;QAC3B,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,oEAAoE;QACpE,iDAAiD;QACjD,SAAS,EAAE,CAAC,MAAM,eAAe,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,OAAO;QAC1E,MAAM;QACN,QAAQ,EAAE,cAAc,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;QAC9C,MAAM,EAAE,MAAM,aAAa,CAAC,OAAO,CAAC,IAAI,CAAC;QACzC,GAAG,EAAE,OAAO,CAAC,IAAI;KAClB,CAAC;IACF,MAAM,IAAI,GAAG,MAAM,WAAW,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,IAAI,SAAS,CAAC,CAAC;IACrE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,YAAY,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,YAAY,EAAE,EAAE,IAAI,CAAC,CAAC;IAC3H,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,IAAI,IAAI,cAAc,EAAE,CAAC;QAClC,MAAM,MAAM,GAAG,SAAS,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QACzC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,MAAM,CAAC,EAAE,eAAe,MAAM,CAAC,MAAM,aAAa,EAAE,CAAC,CAAC;QACvH,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,wGAAwG;AACxG,MAAM,CAAC,OAAO,UAAU,iBAAiB;IACvC,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC;AACpD,CAAC","sourcesContent":["/**\n * The spec-brief plugin: signed rulings, read where spec-brief decides.\n *\n * spec-brief's archive refuses a round that changed a file its brief protects.\n * When a person has ruled that the round may change it, and signed the ruling\n * (ADR-0006), the refusal is lifted - but spec-brief does not read signatures,\n * and should not: the tool that defines a format ships the check for it\n * (spec-brief ADR-0007). So the check ships here, and spec-brief asks it\n * through its plugin `waive` hook, once its configuration loads the plugin,\n * as `spec-harness init` writes it:\n *\n * ```json\n * { \"plugins\": [\"@descent-vtt/spec-harness/spec-brief-plugin\"] }\n * ```\n *\n * It waives a `protected-file` refusal for a path a verified `allow` ruling\n * names, and nothing else. A ruling that does not verify waives nothing.\n */\n\nimport { loadConfig } from './fs.js';\nimport { commonDirectory, currentBranch } from './git.js';\nimport { rulingFor } from './guard.js';\nimport { createReader } from './reader.js';\nimport { checkRulings, resolveBase } from './round.js';\nimport { createSiblings } from './siblings.js';\nimport type { Workspace } from './workspace.js';\n\nexport interface WaiveContext {\n readonly root: string;\n /** spec-brief passes a `null` id for a brief it could not name; its file still identifies it. */\n readonly brief: { readonly id: string | null; readonly file: string; readonly text: string };\n readonly findings: readonly { readonly rule: string; readonly path?: string | undefined; readonly paths?: readonly string[] | undefined }[];\n readonly base: string | null;\n readonly commit: string | null;\n}\n\nexport interface Waiver {\n readonly rule: string;\n readonly path: string;\n readonly reason: string;\n}\n\n/**\n * Paths a finding names, in either of the shapes spec-brief reports them. A\n * finding with neither names none, and a path no ruling names is waived for\n * none, so the empty list is equivalent to its mutant.\n */\nfunction pathsOf(finding: WaiveContext['findings'][number]): string[] {\n if (finding.path !== undefined) return [finding.path];\n return [...(finding.paths ?? [])];\n}\n\nexport async function waive(context: WaiveContext): Promise<Waiver[]> {\n const protectedPaths = context.findings.filter((finding) => finding.rule === 'protected-file').flatMap(pathsOf);\n // Asking git about nothing waives nothing: this saves the asking, and its\n // mutant is equivalent.\n if (protectedPaths.length === 0) return [];\n const { config } = await loadConfig(context.root);\n const workspace: Workspace = {\n root: context.root,\n // spec-brief runs in a work tree, where git always names the common\n // directory; the fallback is there for the type.\n commonDir: (await commonDirectory(context.root)) ?? `${context.root}/.git`,\n config,\n siblings: createSiblings(context.root, config),\n branch: await currentBranch(context.root),\n cwd: context.root,\n };\n const base = await resolveBase(workspace, context.base ?? undefined);\n const { verified } = await checkRulings(workspace, { file: context.brief.file }, context.brief.text, createReader(), base);\n const waivers: Waiver[] = [];\n for (const path of protectedPaths) {\n const ruling = rulingFor(verified, path);\n if (ruling !== undefined) {\n waivers.push({ rule: 'protected-file', path, reason: `ruling ${ruling.id}, signed by ${ruling.signer}, allows it` });\n }\n }\n return waivers;\n}\n\n/** The plugin, as spec-brief loads it: a function of its options returning `{ name, rules, waive }`. */\nexport default function specHarnessPlugin(): { name: string; rules: readonly never[]; waive: typeof waive } {\n return { name: 'spec-harness', rules: [], waive };\n}\n"]}
package/dist/premises.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * A premise spec-guard cannot read is checked by nothing, and is a warning,
20
20
  * as it is in the audit: `--strict` fails the run on it.
21
21
  */
22
+ import { readGuardRun } from './answers.js';
22
23
  import { isPremise, premiseFinding, unreadablePremiseFinding } from './audit.js';
23
24
  import { briefIdFromBranch } from './branch.js';
24
25
  import { findActive } from './briefs.js';
@@ -32,7 +33,7 @@ export async function premisesCommand(options, io) {
32
33
  const reader = createReader();
33
34
  const briefs = await workspace.siblings.briefs();
34
35
  const live = briefs.filter((brief) => brief.phase === 'live');
35
- const fromBranch = workspace.branch === null ? null : briefIdFromBranch(workspace.config.branches, workspace.branch);
36
+ const fromBranch = briefIdFromBranch(workspace.config.branches, workspace.branch);
36
37
  const active = findActive(briefs, { flag: options.brief, environment: io.env['SPEC_BRIEF'], branch: fromBranch });
37
38
  const round = active.kind === 'found' ? active.brief.file : null;
38
39
  if (live.length === 0) {
@@ -53,23 +54,24 @@ export async function premisesCommand(options, io) {
53
54
  io.stderr.write('spec-harness: spec-guard could not run the briefs\' assertions\n');
54
55
  return EXIT_ERROR;
55
56
  }
56
- const document = answer.document;
57
- const results = (document.results ?? []).filter((result) => result.spec !== undefined);
58
- // A directive spec-guard cannot read is listed apart from the results; one
59
- // in a premise section is a premise nothing checks.
60
- const errors = (document.errors ?? []).filter((error) => error.spec !== undefined);
61
- const inBrief = (spec, file) => spec?.file.replace(/\\/g, '/') === file;
57
+ // A directive spec-guard cannot read is listed apart from the results, as
58
+ // errors; one in a premise section is a premise nothing checks.
59
+ const { results, errors } = readGuardRun(answer.document);
60
+ // A directive is a brief's when spec-guard places it in the brief's file;
61
+ // one it places nowhere is no brief's.
62
+ const inBrief = (item, file) => item.spec?.file.replace(/\\/g, '/') === file;
62
63
  const findings = [];
63
64
  let premises = 0;
64
65
  for (const brief of live) {
65
- const own = results.filter((result) => inBrief(result.spec, brief.file));
66
- const unread = errors.filter((error) => inBrief(error.spec, brief.file));
66
+ const own = results.filter((result) => inBrief(result, brief.file));
67
+ const unread = errors.filter((error) => inBrief(error, brief.file));
68
+ // Only saves reading a brief with nothing to judge: going on finds nothing, so that mutant is equivalent.
67
69
  if (own.length === 0 && unread.length === 0)
68
70
  continue;
69
71
  const text = await briefText(workspace, brief);
70
72
  const premise = (line) => isPremise(reader.sectionsAt(text, line), workspace.config.assertions.premises);
71
73
  for (const result of own) {
72
- const line = result.spec?.line ?? 0;
74
+ const { line } = result.spec;
73
75
  if (!premise(line))
74
76
  continue;
75
77
  premises += 1;
@@ -78,14 +80,16 @@ export async function premisesCommand(options, io) {
78
80
  findings.push(premiseFinding(brief, { description: result.description, message: result.message, line }, brief.file === round));
79
81
  }
80
82
  for (const error of unread) {
81
- const line = error.spec?.line ?? 0;
83
+ const { line } = error.spec;
82
84
  if (premise(line))
83
- findings.push(unreadablePremiseFinding(brief, { message: error.message, line, raw: error.raw ?? '' }));
85
+ findings.push(unreadablePremiseFinding(brief, { message: error.message, line, raw: error.raw }));
84
86
  }
85
87
  }
86
88
  const stale = findings.filter((finding) => finding.severity === 'error').length;
87
89
  const retired = findings.filter((finding) => finding.severity === 'note').length;
88
90
  const unreadable = findings.filter((finding) => finding.severity === 'warning').length;
91
+ // Only the round on the active brief retires a premise, so with one retired
92
+ // there is an active brief, and dropping the second test is equivalent.
89
93
  const intended = retired === 0 || active.kind !== 'found' ? '' : `, and ${retired} retired by the round on brief ${active.brief.id}, as it intends`;
90
94
  const unchecked = unreadable === 0 ? '' : `; ${unreadable} premise(s) spec-guard cannot read, so not checked`;
91
95
  const summary = `${premises} premise(s) in ${live.length} live brief(s), ${stale} no longer hold${intended}${unchecked}`;