Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
CodeTruss CLI follows semantic versioning. Release artifacts and their SHA-256
checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest.json>.

The current public release is [v0.2.36 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.36),
The current public release is [v0.2.37 on GitHub](https://github.com/DeliriumPulse/codetruss-cli/releases/tag/v0.2.37),
distributed from <https://codetruss.com/downloads/codetruss-cli-latest.json>.
The npm `latest` tag is still
[`@codetruss/cli@0.2.24`](https://www.npmjs.com/package/@codetruss/cli/v/0.2.24):
Expand All @@ -16,6 +16,35 @@ were superseded before distribution.

No unreleased changes.

## 0.2.37 — 2026-08-07

- **A process tree whose leader already exited is never force-killed on
Windows.** Verification and local-provider cleanup ran
`taskkill /pid <leader> /t /f` from the child's own exit handler — where the
leader is dead by definition — and from the timeout path after the leader had
exited. Windows recycles a freed pid within milliseconds, so that force-kill
could land on an unrelated process that had just inherited the number; it is
what killed a freshly forked vitest worker mid-run in CI. Both call sites now
gate on liveness read from our own `ChildProcess` handle, which pid reuse
cannot misdirect. Nothing is lost by skipping: `taskkill /t` enumerates the
tree from the leader, so a dead leader could not have reached a descendant
anyway.
- The escaped-descendant test closed the same vector in its own cleanup. It
SIGKILLed the pid recorded in a pidfile, and once the deadline had already
reaped the tree that pid could belong to an innocent process — a liveness
probe cannot tell a recycled pid from a live descendant. The descendant now
exits on its own when a sentinel file disappears, so cleanup signals no
recorded pid at all.
- **The release build now reads the changelog it ships.** `pnpm cli:release`
fails unless the version being built has its own `## <version> — <date>`
heading and every release heading forms one unbroken descending chain — each
version exactly once, in order, no gaps, and nothing stranded above the newest
entry.
- Repairs the changelog that guard was written for: CLI 0.2.36 overwrote
`## 0.2.35 — 2026-08-07` with its own heading, leaving the entire local-SAST
release's notes orphaned under 0.2.36 and erasing 0.2.35 from the history.
Both entries are now restored to what each release actually shipped.

## 0.2.36 — 2026-08-07

- **Indexed file paths are now the same bytes on every platform.** The
Expand All @@ -32,6 +61,8 @@ No unreleased changes.
source fix already makes both sides POSIX, so this is defense in depth against
any future caller that hands in a raw platform path.

## 0.2.35 — 2026-08-07

- **Security analysis now runs locally.** The rule pack and taint solver that
previously existed only in hosted scans execute on your machine, offline, over
the JavaScript, TypeScript and TSX in your repository — the same engine, not a
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ To pin an exact version, install the immutable archive directly:

```bash
npm install --global --ignore-scripts --no-audit --no-fund \
https://codetruss.com/downloads/codetruss-cli-0.2.36.tgz
https://codetruss.com/downloads/codetruss-cli-0.2.37.tgz
```

The `@codetruss/cli` package on the npm registry is published as a separate,
Expand Down Expand Up @@ -313,8 +313,8 @@ clean global install.
Verify a downloaded release yourself:

```bash
gh attestation verify codetruss-cli-0.2.36.tgz --repo DeliriumPulse/codetruss-cli
shasum -a 256 -c codetruss-cli-0.2.36.tgz.sha256
gh attestation verify codetruss-cli-0.2.37.tgz --repo DeliriumPulse/codetruss-cli
shasum -a 256 -c codetruss-cli-0.2.37.tgz.sha256
```

Maintainers should follow [docs/RELEASE.md](docs/RELEASE.md). Tag-driven GitHub
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest

## Unreleased

## 0.2.37 — 2026-08-07

- **A process tree whose leader already exited is never force-killed on
Windows.** Verification and local-provider cleanup ran
`taskkill /pid <leader> /t /f` from the child's own exit handler — where the
leader is dead by definition — and from the timeout path after the leader had
exited. Windows recycles a freed pid within milliseconds, so that force-kill
could land on an unrelated process that had just inherited the number; it is
what killed a freshly forked vitest worker mid-run in CI. Both call sites now
gate on liveness read from our own `ChildProcess` handle, which pid reuse
cannot misdirect. Nothing is lost by skipping: `taskkill /t` enumerates the
tree from the leader, so a dead leader could not have reached a descendant
anyway.
- The escaped-descendant test closed the same vector in its own cleanup. It
SIGKILLed the pid recorded in a pidfile, and once the deadline had already
reaped the tree that pid could belong to an innocent process — a liveness
probe cannot tell a recycled pid from a live descendant. The descendant now
exits on its own when a sentinel file disappears, so cleanup signals no
recorded pid at all.
- **The release build now reads the changelog it ships.** `pnpm cli:release`
fails unless the version being built has its own `## <version> — <date>`
heading and every release heading forms one unbroken descending chain — each
version exactly once, in order, no gaps, and nothing stranded above the newest
entry.
- Repairs the changelog that guard was written for: CLI 0.2.36 overwrote
`## 0.2.35 — 2026-08-07` with its own heading, leaving the entire local-SAST
release's notes orphaned under 0.2.36 and erasing 0.2.35 from the history.
Both entries are now restored to what each release actually shipped.

## 0.2.36 — 2026-08-07

- **Indexed file paths are now the same bytes on every platform.** The
Expand All @@ -21,6 +50,8 @@ checksums are published at <https://codetruss.com/downloads/codetruss-cli-latest
source fix already makes both sides POSIX, so this is defense in depth against
any future caller that hands in a raw platform path.

## 0.2.35 — 2026-08-07

- **Security analysis now runs locally.** The rule pack and taint solver that
previously existed only in hosted scans execute on your machine, offline, over
the JavaScript, TypeScript and TSX in your repository — the same engine, not a
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codetruss/cli",
"version": "0.2.36",
"version": "0.2.37",
"description": "Local-first scope, quality, and verification receipts for coding agents",
"license": "SEE LICENSE IN LICENSE",
"type": "module",
Expand Down Expand Up @@ -45,7 +45,7 @@
"test:install": "node scripts/test-install.mjs",
"prepack": "pnpm build",
"pretest": "pnpm build",
"test": "vitest run --config vitest.config.ts && node scripts/test-deterministic-package.mjs && node scripts/test-release-verifier.mjs",
"test": "vitest run --config vitest.config.ts && node scripts/test-deterministic-package.mjs && node scripts/test-release-verifier.mjs && node scripts/test-changelog-policy.mjs",
"dev": "tsx src/cli.ts"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/scripts/build-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { assertChangelogPolicy } from './changelog-policy.mjs'
import { buildDeterministicPackageArchive } from './deterministic-package.mjs'
import { assertReleasePackagePolicy } from './release-package-policy.mjs'
import { verifyDeterministicPackageArchive } from './verify-deterministic-package.mjs'
Expand All @@ -23,6 +24,7 @@ function run(command, args, cwd = packageDir) {
try {
const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8'))
assertReleasePackagePolicy(pkg)
assertChangelogPolicy(await readFile(join(packageDir, 'CHANGELOG.md'), 'utf8'), pkg.version)
// Invoke the build through Node directly. Package-manager shims are `.cmd`
// files on Windows and cannot be spawned by Node without a shell; the build
// itself is already a portable Node script and needs no shell mediation.
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/scripts/changelog-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// A release overwrote the previous release's heading once: CLI 0.2.36 replaced
// `## 0.2.35 — …` with its own heading, leaving 0.2.35's bullets orphaned under
// 0.2.36 and erasing 0.2.35 from the history entirely. Nothing caught it, because
// nothing read the changelog. These checks do.

const HEADING = /^## (?<version>\d+\.\d+\.\d+) — (?<date>\d{4}-\d{2}-\d{2})(?<suffix> \(unpublished\))?$/
const UNRELEASED = /^## Unreleased$/

function parseVersion(version) {
const [major, minor, patch] = version.split('.').map(Number)
return { major, minor, patch }
}

/**
* True when `newer` is the immediate semver successor of `older`: the next patch
* on the same minor, or the `.0` that opens the next minor or major. Anything
* else is a gap (a lost entry) or a reordering.
*/
function succeeds(newer, older) {
if (newer.major === older.major && newer.minor === older.minor) return newer.patch === older.patch + 1
if (newer.major === older.major) return newer.minor === older.minor + 1 && newer.patch === 0
return newer.major === older.major + 1 && newer.minor === 0 && newer.patch === 0
}

/**
* Assert the changelog records `version` and that every release heading forms one
* unbroken descending chain. `changelog` is the file's raw text.
*/
export function assertChangelogPolicy(changelog, version) {
const lines = changelog.split('\n')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const headings = []
for (const [index, line] of lines.entries()) {
const match = HEADING.exec(line)
if (match) headings.push({ ...match.groups, line: index + 1, ...parseVersion(match.groups.version) })
}
if (headings.length === 0) throw new Error('CHANGELOG.md declares no release headings')

// Nothing may sit between the preamble and the first release except an
// optional, empty `## Unreleased`. Notes stranded above the newest release
// heading are notes no released version claims.
const firstHeadingIndex = headings[0].line - 1
let sawUnreleased = false
for (let index = 0; index < firstHeadingIndex; index += 1) {
const line = lines[index]
if (UNRELEASED.test(line)) {
sawUnreleased = true
continue
}
if (line.startsWith('## ')) {
throw new Error(`CHANGELOG.md line ${index + 1}: unexpected section before the first release heading: ${line}`)
}
if (sawUnreleased && line.trim() !== '') {
throw new Error(
`CHANGELOG.md line ${index + 1}: "## Unreleased" still has content; move it into the release entry: ${line}`,
)
}
}

const seen = new Map()
for (const heading of headings) {
const previous = seen.get(heading.version)
if (previous !== undefined) {
throw new Error(`CHANGELOG.md declares version ${heading.version} twice (lines ${previous} and ${heading.line})`)
}
seen.set(heading.version, heading.line)
}

for (let index = 1; index < headings.length; index += 1) {
const newer = headings[index - 1]
const older = headings[index]
if (!succeeds(newer, older)) {
throw new Error(
`CHANGELOG.md release chain breaks between ${newer.version} (line ${newer.line}) and ${older.version} `
+ `(line ${older.line}): every released version must appear exactly once, in descending order, with no gaps. `
+ 'A version that was never published still needs its own "(unpublished)" entry.',
)
}
}

if (!seen.has(version)) {
throw new Error(
`CHANGELOG.md has no "## ${version} — <date>" heading; the release being built must document itself. `
+ `Newest entry is ${headings[0].version}.`,
)
}
if (seen.get(version) !== headings[0].line) {
throw new Error(`CHANGELOG.md lists ${version} below a newer entry (${headings[0].version}); it must be the first release heading`)
}
}
87 changes: 87 additions & 0 deletions packages/cli/scripts/test-changelog-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { assertChangelogPolicy } from './changelog-policy.mjs'

const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')

const preamble = [
'# Changelog',
'',
'CodeTruss CLI follows semantic versioning.',
'',
'## Unreleased',
'',
].join('\n')

const changelog = (...entries) => `${preamble}${entries.map((entry) => `${entry}\n`).join('\n')}`
const entry = (version, date = '2026-08-07', body = '- a change') => `## ${version} — ${date}\n\n${body}`

// The shipped changelog and the version it documents must satisfy the policy.
const shipped = await readFile(join(packageDir, 'CHANGELOG.md'), 'utf8')
const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8'))
assertChangelogPolicy(shipped, pkg.version)

// A well-formed chain passes, including the roll from one minor to the next.
assertChangelogPolicy(changelog(entry('0.2.1'), entry('0.2.0'), entry('0.1.1')), '0.2.1')
assertChangelogPolicy(changelog(entry('1.0.0'), entry('0.9.3'), entry('0.9.2')), '1.0.0')
// "(unpublished)" entries still count as links in the chain.
assertChangelogPolicy(
changelog(entry('0.2.2'), `## 0.2.1 — 2026-08-06 (unpublished)\n\n- skipped`, entry('0.2.0')),
'0.2.2',
)

// (a) The version being released must have its own heading.
assert.throws(
() => assertChangelogPolicy(changelog(entry('0.2.1'), entry('0.2.0')), '0.2.2'),
/no "## 0\.2\.2 — <date>" heading/,
)

// (b) The exact 0.2.36 regression: a release overwrites the previous heading,
// leaving a gap where 0.2.35 used to be.
assert.throws(
() => assertChangelogPolicy(changelog(entry('0.2.36'), entry('0.2.34'), entry('0.2.33')), '0.2.36'),
/release chain breaks between 0\.2\.36 .* and 0\.2\.34/,
)

// (b) A version listed twice.
assert.throws(
() => assertChangelogPolicy(changelog(entry('0.2.2'), entry('0.2.2'), entry('0.2.1')), '0.2.2'),
/declares version 0\.2\.2 twice/,
)

// (b) Headings out of descending order.
assert.throws(
() => assertChangelogPolicy(changelog(entry('0.2.0'), entry('0.2.1'), entry('0.1.1')), '0.2.0'),
/release chain breaks between 0\.2\.0 .* and 0\.2\.1/,
)

// (b) The released version must be the newest entry, not buried mid-file.
assert.throws(
() => assertChangelogPolicy(changelog(entry('0.2.2'), entry('0.2.1'), entry('0.2.0')), '0.2.1'),
/lists 0\.2\.1 below a newer entry/,
)

// (b) Notes stranded above the newest release heading belong to no version.
assert.throws(
() => assertChangelogPolicy(
`${preamble}- an orphaned bullet\n\n${entry('0.2.1')}\n\n${entry('0.2.0')}\n`,
'0.2.1',
),
/"## Unreleased" still has content/,
)

// (b) A non-release section wedged above the first release heading.
assert.throws(
() => assertChangelogPolicy(
`# Changelog\n\n## Notes\n\n${entry('0.2.1')}\n\n${entry('0.2.0')}\n`,
'0.2.1',
),
/unexpected section before the first release heading/,
)

// A changelog with no releases at all is not a changelog.
assert.throws(() => assertChangelogPolicy(preamble, '0.2.1'), /declares no release headings/)

process.stdout.write('changelog policy: chain, uniqueness, ordering, and self-documentation enforced\n')
15 changes: 12 additions & 3 deletions packages/cli/src/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from 'node:child_process'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { readFile, stat } from 'node:fs/promises'
import { devNull } from 'node:os'
import { join, resolve } from 'node:path'
Expand Down Expand Up @@ -450,8 +450,17 @@ function verificationDelay(milliseconds: number): Promise<void> {
return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds))
}

async function terminateVerificationProcessTree(pid: number): Promise<void> {
async function terminateVerificationProcessTree(child: ChildProcess): Promise<void> {
const pid = child.pid
if (pid === undefined) return
if (process.platform === 'win32') {
// taskkill /t enumerates the tree from the leader pid, so once the leader
// has exited it cannot reach anything — and Windows recycles freed pids
// within milliseconds, so addressing one can force-kill an unrelated
// process. The liveness check uses our own process handle and cannot be
// misdirected; a leader exiting between this check and taskkill's own
// snapshot is the same narrow race every taskkill user carries.
if (child.exitCode !== null || child.signalCode !== null) return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], {
stdio: 'ignore',
timeout: 2_000,
Expand Down Expand Up @@ -488,7 +497,7 @@ export async function runVerification(
let cleanupPromise: Promise<void> | undefined

const cleanup = (): Promise<void> => {
cleanupPromise ??= child.pid === undefined ? Promise.resolve() : terminateVerificationProcessTree(child.pid)
cleanupPromise ??= terminateVerificationProcessTree(child)
return cleanupPromise
}
const finish = (exitCode: number, suffix = ''): void => {
Expand Down
15 changes: 12 additions & 3 deletions packages/cli/src/local-command.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn, spawnSync } from 'node:child_process'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'

export const LOCAL_COMMAND_MAX_OUTPUT_BYTES = 2_000_000

Expand Down Expand Up @@ -52,8 +52,17 @@ function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
}
}

async function terminateProcessTree(pid: number): Promise<void> {
async function terminateProcessTree(child: ChildProcess): Promise<void> {
const pid = child.pid
if (pid === undefined) return
if (process.platform === 'win32') {
// taskkill /t enumerates the tree from the leader pid, so once the leader
// has exited it cannot reach anything — and Windows recycles freed pids
// within milliseconds, so addressing one can force-kill an unrelated
// process. The liveness check uses our own process handle and cannot be
// misdirected; a leader exiting between this check and taskkill's own
// snapshot is the same narrow race every taskkill user carries.
if (child.exitCode !== null || child.signalCode !== null) return
spawnSync('taskkill', ['/pid', String(pid), '/t', '/f'], {
stdio: 'ignore',
timeout: 2_000,
Expand Down Expand Up @@ -97,7 +106,7 @@ export function runLocalCommand(request: LocalCommandRequest): Promise<LocalComm
let cleanupPromise: Promise<void> | undefined

const cleanup = () => {
cleanupPromise ??= child.pid === undefined ? Promise.resolve() : terminateProcessTree(child.pid)
cleanupPromise ??= terminateProcessTree(child)
return cleanupPromise
}
const fail = (reason: LocalCommandFailureReason) => {
Expand Down
Loading