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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
push:
branches:
- main

jobs:
check-lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: denoland/setup-deno@v2
with:
deno-version: v2.x

- name: Check types
run: deno task check

- name: Lint
run: deno task lint

- name: Test
run: deno task test
31 changes: 31 additions & 0 deletions scenarios/entropy-audit-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Entropy Audit Update

Date: 2026-02-15 Branch: `codex/entropy-reduction`

## Finding Status

1. Shared command-context adoption: **closed**
2. Resolver/enum duplication: **closed**
3. Direct handler console usage: **closed**
4. Monolithic command layout (`issue` / `project`): **closed**
5. Scale assumptions (`agentSessions`, team overview, project preview):
**closed**
6. Guardrails and CI gates: **closed**

## Notes

- Command concerns are now split and routed through directory indexes:
- `src/commands/issue/index.ts` + `read.ts` + `mutate.ts` + `comment.ts` + `watch.ts` + `shared.ts`
- `src/commands/project/index.ts` + `read.ts` + `mutate.ts` + `milestone.ts` + `status.ts` + `shared.ts`
- compatibility shims retained at `src/commands/issue.ts` and `src/commands/project.ts`
- Session and overview paths now cover scale cases:
- issue sessions fetched via paginated helper in `src/commands/issue/shared.ts`
- team overview issues paginated beyond 200 in `src/commands/team.ts`
- project issue preview semantics explicit in `src/commands/project/read.ts`
- Watch output contracts are explicit and tested for `table|compact|json`, including timeout.
- Scenario regression remains intentionally manual (LLM + eyeballing) and out of CI scope.

## Next Backlog

1. Optional: server-side filtering for issue sessions if/when SDK/API exposes issue-scoped `agentSessions` filters.
2. Optional: broader integration coverage for long-running watch polling in end-to-end harnesses.
27 changes: 27 additions & 0 deletions src/commands/__tests__/auth_initiative_smoke_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { assert, assertStringIncludes } from "@std/assert"

Deno.test("auth status/whoami/logout use shared command context", async () => {
const source = await Deno.readTextFile(
new URL("../auth.ts", import.meta.url),
)

assertStringIncludes(source, "await getCommandContext(options)")
assert(source.includes('.description("Show authentication status")'))
assert(source.includes('.description("Show current user")'))
assert(source.includes('.description("Remove stored credentials")'))
})

Deno.test("initiative list/view use shared context and centralized status parser", async () => {
const source = await Deno.readTextFile(
new URL("../initiative.ts", import.meta.url),
)

assertStringIncludes(
source,
"const { format, client } = await getCommandContext(options)",
)
assertStringIncludes(source, "function parseInitiativeStatus(input: string)")
assertStringIncludes(source, "initiativeStatusLabel(")
assertStringIncludes(source, "parseInitiativeStatus(options.status)")
assert(!source.includes("const statusMap: Record<string, InitiativeStatus>"))
})
45 changes: 45 additions & 0 deletions src/commands/__tests__/context_adoption_contract_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { assertEquals } from "@std/assert"
import { join } from "@std/path"

const COMMANDS_ROOT = new URL("../", import.meta.url)

async function listFiles(dirUrl: URL): Promise<string[]> {
const files: string[] = []
for await (const entry of Deno.readDir(dirUrl)) {
if (entry.isDirectory) {
if (entry.name === "__tests__" || entry.name === "_shared") continue
files.push(...await listFiles(new URL(`${entry.name}/`, dirUrl)))
continue
}
if (entry.isFile && entry.name.endsWith(".ts")) {
files.push(join(dirUrl.pathname, entry.name))
}
}
return files
}

Deno.test("command handlers use shared context, not manual api key/client wiring", async () => {
const files = await listFiles(COMMANDS_ROOT)
const offenders: string[] = []

for (const file of files) {
const source = await Deno.readTextFile(file)
const isAuth = file.endsWith("/auth.ts")

if (source.includes("await getAPIKey()")) {
offenders.push(`${file} -> getAPIKey`)
}
if (!isAuth && source.includes("createClient(")) {
offenders.push(`${file} -> createClient`)
}
if (!isAuth && source.includes("getFormat(")) {
offenders.push(`${file} -> getFormat`)
}
}

assertEquals(
offenders,
[],
`Manual command context wiring found:\n${offenders.join("\n")}`,
)
})
41 changes: 41 additions & 0 deletions src/commands/__tests__/formatter_snapshot_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { assertEquals } from "@std/assert"
import { render } from "../../output/formatter.ts"

function captureConsoleLog(run: () => void): string[] {
const logs: string[] = []
const original = console.log
console.log = (...args: unknown[]) => {
logs.push(args.map((v) => String(v)).join(" "))
}
try {
run()
} finally {
console.log = original
}
return logs
}

Deno.test("formatter compact table snapshot", () => {
const logs = captureConsoleLog(() => {
render("compact", {
headers: ["ID", "State"],
rows: [["POL-1", "Todo"], ["POL-2", "Done"]],
})
})

assertEquals(logs.join("\n"), "ID\tSTATE\nPOL-1\tTodo\nPOL-2\tDone")
})

Deno.test("formatter compact detail snapshot", () => {
const logs = captureConsoleLog(() => {
render("compact", {
title: "Issue",
fields: [
{ label: "ID", value: "POL-1" },
{ label: "State", value: "Todo" },
],
})
})

assertEquals(logs.join("\n"), "id\tPOL-1\nstate\tTodo")
})
10 changes: 7 additions & 3 deletions src/commands/__tests__/mutation_contract_commands_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { assertStringIncludes } from "@std/assert"

Deno.test("issue porcelain mutations define standardized action ids", async () => {
const issueSource = await Deno.readTextFile(
new URL("../issue.ts", import.meta.url),
new URL("../issue/mutate.ts", import.meta.url),
)

for (const action of ["close", "reopen", "start", "assign"]) {
Expand All @@ -11,9 +11,13 @@ Deno.test("issue porcelain mutations define standardized action ids", async () =
})

Deno.test("project porcelain mutations define standardized action ids", async () => {
const projectSource = await Deno.readTextFile(
new URL("../project.ts", import.meta.url),
const projectMutateSource = await Deno.readTextFile(
new URL("../project/mutate.ts", import.meta.url),
)
const projectStatusSource = await Deno.readTextFile(
new URL("../project/status.ts", import.meta.url),
)
const projectSource = `${projectMutateSource}\n${projectStatusSource}`

for (
const action of [
Expand Down
40 changes: 40 additions & 0 deletions src/commands/__tests__/no_direct_console_in_handlers_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { assertEquals } from "@std/assert"
import { join } from "@std/path"

const COMMANDS_ROOT = new URL("../", import.meta.url)

async function listCommandFiles(dirUrl: URL): Promise<string[]> {
const files: string[] = []
for await (const entry of Deno.readDir(dirUrl)) {
const nextPath = join(dirUrl.pathname, entry.name)
if (entry.isDirectory) {
if (entry.name === "__tests__" || entry.name === "_shared") continue
files.push(...await listCommandFiles(new URL(`${entry.name}/`, dirUrl)))
continue
}
if (entry.isFile && entry.name.endsWith(".ts")) {
files.push(nextPath)
}
}
return files
}

Deno.test("command handlers do not directly call console.log/error", async () => {
const files = await listCommandFiles(COMMANDS_ROOT)
const offenders: string[] = []

for (const file of files) {
const text = await Deno.readTextFile(file)
if (/console\.(log|error)\(/.test(text)) {
offenders.push(file)
}
}

assertEquals(
offenders,
[],
`Direct console usage is not allowed in command handlers:\n${
offenders.join("\n")
}`,
)
})
12 changes: 12 additions & 0 deletions src/commands/__tests__/project_preview_contract_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { assertStringIncludes } from "@std/assert"

Deno.test("project view includes explicit preview metadata fields", async () => {
const source = await Deno.readTextFile(
new URL("../project/read.ts", import.meta.url),
)

assertStringIncludes(source, "issuePreviewCount")
assertStringIncludes(source, "issuePreviewLimit")
assertStringIncludes(source, "issuePreviewHasMore")
assertStringIncludes(source, "issueTotalCount")
})
85 changes: 85 additions & 0 deletions src/commands/__tests__/resolver_parity_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { assertEquals, assertRejects } from "@std/assert"
import type { LinearClient } from "@linear/sdk"
import { CliError } from "../../errors.ts"
import {
resolveTeam,
resolveTeamId,
resolveUser,
resolveUserEntity,
} from "../../resolve.ts"

function mockClient(overrides: Partial<LinearClient>): LinearClient {
return overrides as LinearClient
}

Deno.test("resolveUserEntity and resolveUser share exact/partial semantics", async () => {
const users = [
{ id: "u1", name: "Jane Smith", email: "jane@example.com" },
{ id: "u2", name: "Janet Stone", email: "janet@example.com" },
{ id: "u3", name: "Alice Doe", email: "alice@example.com" },
]
const client = mockClient({
viewer: Promise.resolve(users[2] as never),
users: () => Promise.resolve({ nodes: users } as never),
})

const byExactName = await resolveUserEntity(client, "jane smith")
assertEquals(byExactName.id, "u1")

const byEmail = await resolveUserEntity(client, "JANET@EXAMPLE.COM")
assertEquals(byEmail.id, "u2")

const byPartial = await resolveUserEntity(client, "alice")
assertEquals(byPartial.id, "u3")

const meId = await resolveUser(client, "me")
assertEquals(meId, "u3")

const parityId = await resolveUser(client, "jane smith")
assertEquals(parityId, byExactName.id)

await assertRejects(
() => resolveUserEntity(client, "jan"),
CliError,
'ambiguous user "jan"',
)

await assertRejects(
() => resolveUserEntity(client, "unknown"),
CliError,
'user not found: "unknown"',
)
})

Deno.test("resolveTeam and resolveTeamId share exact/partial semantics", async () => {
const teams = [
{ id: "t1", key: "POL" },
{ id: "t2", key: "PLAT" },
{ id: "t3", key: "OPS" },
]

const client = mockClient({
teams: () => Promise.resolve({ nodes: teams } as never),
})

const exact = await resolveTeam(client, "pol")
assertEquals(exact.id, "t1")

const partial = await resolveTeam(client, "pla")
assertEquals(partial.id, "t2")

const parityId = await resolveTeamId(client, "OPS")
assertEquals(parityId, "t3")

await assertRejects(
() => resolveTeam(client, "p"),
CliError,
'ambiguous team "p"',
)

await assertRejects(
() => resolveTeam(client, "zzz"),
CliError,
'team not found: "zzz"',
)
})
Loading