diff --git a/README.md b/README.md index 082c357..e85e16f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Shadow dev workflow 的确定性脚手架 CLI。所有命令走 plan → execute | 命令 | 说明 | |------|------| | `repo inspect` | 查看仓库状态(分支、HEAD、脏文件) | -| `change create\|approve\|list` | 创建/批准变更 brief;列出活动变更 | +| `change create\|approve\|list` | 创建/批准变更 brief;`list` 默认只列活动变更,`--all` 合并归档、`--archived` 只列归档(条目带 `archived` 布尔) | | `issue plan\|execute` | 创建 GitHub issue | | `branch plan\|execute` | 建功能分支 | | `sync plan\|execute` | fast-forward 同步上游 | @@ -79,7 +79,7 @@ bash scripts/install-cli.sh install --from dist/shadow-dev-cli-v1.1.0.tar.gz # ## 开发 ```bash -npm test # node --test,49 项 CLI 契约 + 6 项安装器契约(离线产物全链、冲突保护、回滚、自校验) +npm test # node --test,52 项 CLI 契约 + 6 项安装器契约(离线产物全链、冲突保护、回滚、自校验) ``` 行为契约:命令、JSON 输出结构、错误码、planHash 机制保持稳定;`test/cli.test.mjs` 是唯一契约规格。 diff --git a/cli.mjs b/cli.mjs index be8c7e7..dd29044 100755 --- a/cli.mjs +++ b/cli.mjs @@ -71,7 +71,7 @@ async function handle(r, p, o) { if (d === 'task' && a === 'set') return { ok: true, command: 'task.set', data: task.set(r, o) } if (d === 'change' && a === 'create') return { ok: true, command: 'change.create', data: change.create(r, o) } if (d === 'change' && a === 'approve') return { ok: true, command: 'change.approve', data: change.approve(r, o) } - if (d === 'change' && a === 'list') return { ok: true, command: 'change.list', data: change.list(r) } + if (d === 'change' && a === 'list') return { ok: true, command: 'change.list', data: change.list(r, o) } if (Object.hasOwn(DOMAINS, d)) { const verb = a === 'rebuild' ? s : a, c = a === 'rebuild' ? `${d}.rebuild` : d if (verb === 'plan') return await planDomain(c, DOMAINS[d], r, o) diff --git a/lib/args.mjs b/lib/args.mjs index 8a5058c..318a977 100644 --- a/lib/args.mjs +++ b/lib/args.mjs @@ -6,7 +6,7 @@ export function args(a) { const p = [], o = {} for (let i = 0; i < a.length; i++) { if (!a[i].startsWith('--')) p.push(a[i]) - else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help'].includes(k)) o[k] = true; else o[k] = a[++i] } + else { const k = a[i].slice(2); if (['confirm', 'json', 'full', 'help', 'all', 'archived'].includes(k)) o[k] = true; else o[k] = a[++i] } } return { p, o } } diff --git a/lib/commands.mjs b/lib/commands.mjs index d13ccae..7a5137d 100644 --- a/lib/commands.mjs +++ b/lib/commands.mjs @@ -14,7 +14,7 @@ export const COMMANDS = { 'repo.inspect': c('repo inspect', '查看仓库状态(分支、HEAD、脏文件)', 'show repository state (branch, HEAD, dirty files)', [], 'shadow-dev repo inspect'), 'change.create': c('change create', '创建变更 brief', 'create a change brief', [N, f('--type', false, 'feature|fix|build|chore|docs|refactor|style|test,默认 feat', 'one of feature|fix|build|chore|docs|refactor|style|test, default feat'), f('--scope', false, '影响范围', 'scope'), f('--base-branch', false, '基线分支,默认 main', 'base branch, default main'), FI, f('--body-file', false, 'brief 正文来源文件', 'file supplying the brief body'), f('--repository', false, 'GitHub owner/repo', 'GitHub owner/repo'), CF], 'shadow-dev change create --name --type feature --confirm', 'change approve --name {name} --confirm'), 'change.approve': c('change approve', '批准 brief(draft → proposed)', 'approve the brief (draft → proposed)', [N, CF], 'shadow-dev change approve --name --confirm', 'branch plan --name {name}'), - 'change.list': c('change list', '列出活动变更的名称、类型、状态与分支', 'list active changes with name, type, status and branch', [], 'shadow-dev change list'), + 'change.list': c('change list', '列出变更的名称、类型、状态与分支(默认只列活动)', 'list changes with name, type, status and branch (active only by default)', [f('--all', false, '同时包含已归档变更(与 --archived 同传时按 --all 处理)', 'include archived changes as well (--all wins when both are passed)'), f('--archived', false, '只列已归档变更', 'list archived changes only')], 'shadow-dev change list'), 'issue.plan': c('issue plan', '预览创建 GitHub issue', 'plan a GitHub issue', [N, T, B, f('--labels', false, '逗号分隔标签', 'comma-separated labels')], 'shadow-dev issue plan --name --title "标题" --labels feature', 'issue execute --name {name} --title "{title}" --body "{body}" --labels {labels} --confirm'), 'issue.execute': c('issue execute', '创建 GitHub issue', 'create the GitHub issue', [N, T, B, f('--labels', false, '逗号分隔标签', 'comma-separated labels'), PH, CF], 'shadow-dev issue execute --name --confirm', null), 'branch.plan': c('branch plan', '预览建功能分支', 'plan creating the feature branch', [N], 'shadow-dev branch plan --name ', 'branch execute --name {name} --confirm'), diff --git a/lib/domains/change.mjs b/lib/domains/change.mjs index 284ff91..ebaaba4 100644 --- a/lib/domains/change.mjs +++ b/lib/domains/change.mjs @@ -29,17 +29,24 @@ export function create(r, o) { return { name: n, path: b.path } } -// 活动变更发现入口:变更名即 shadow-docs/changes/ 下子目录名;archive 与解析失败的目录跳过(与 indexer 同规则) -export function list(r) { - const base = join(r, 'shadow-docs', 'changes'), changes = [] - if (existsSync(base)) for (const e of readdirSync(base, { withFileTypes: true })) { - if (!e.isDirectory() || e.name === 'archive') continue - try { - const d = brief(r, e.name).data - changes.push({ name: d.name ?? e.name, type: d.type ?? null, status: d.status ?? null, branch: d.branch ?? null }) - } catch {} +// 变更发现入口:变更名即 shadow-docs/changes/ 下子目录名;解析失败的目录跳过(与 indexer 同规则)。 +// 默认只列活动目录;--all 合并活动与归档,--archived 只列归档;两参同传按超集 --all 处理 +export function list(r, o = {}) { + const all = !!o.all, only = !!o.archived && !all + const dirs = only ? [] : [[join(r, 'shadow-docs', 'changes'), false]] + if (all || only) dirs.push([join(r, 'shadow-docs', 'changes', 'archive'), true]) + const changes = [] + for (const [dir, a] of dirs) { + if (!existsSync(dir)) continue + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (!e.isDirectory() || (!a && e.name === 'archive')) continue + try { + const d = brief(r, e.name, a).data + changes.push({ name: d.name ?? e.name, type: d.type ?? null, status: d.status ?? null, branch: d.branch ?? null, archived: a }) + } catch {} + } } - changes.sort((a, b) => a.name.localeCompare(b.name)) + changes.sort((x, y) => x.name.localeCompare(y.name)) return { changes } } diff --git a/shadow-docs/changes/20260917-feature-change-list-archived/brief.md b/shadow-docs/changes/20260917-feature-change-list-archived/brief.md new file mode 100644 index 0000000..d2be07e --- /dev/null +++ b/shadow-docs/changes/20260917-feature-change-list-archived/brief.md @@ -0,0 +1,91 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20260917-feature-change-list-archived", + "type": "feature", + "scope": null, + "status": "reviewed", + "baseBranch": "main", + "branch": "feature/20260917-feature-change-list-archived", + "files": [], + "github": { + "repository": "stack-wuh/shadow-dev-cli", + "issue": 17, + "issueUrl": "https://github.com/stack-wuh/shadow-dev-cli/issues/17", + "pullRequest": null, + "pullRequestUrl": null + }, + "review": { + "conclusion": "passed", + "verifiedCommit": "f10148117080397c1d76b75ae12a7492fb7867af", + "verifiedAt": "2026-09-17T11:59:26.844Z" + }, + "workflow": { + "operation": null, + "checkpoint": "issue:17", + "planHash": "e5ee5e90f8f71653279dd3e219f28d4aabf002430d986f6131d74a78871b662a", + "updatedAt": null, + "lastError": null, + "issuePlan": { + "title": "change list 支持查询已归档变更(--all / --archived)", + "body": "", + "labels": [ + "feature" + ] + } + }, + "knowledge": { + "action": "更新", + "target": "shadow-docs/knowledge/cli-output-contract.md", + "reason": "本变更直接修订该卡片的 change list 契约条款(opt-in 归档参数与 archived 字段),并已随变更写回卡片" + } +} +--- + +# change list 支持查询已归档变更(--all / --archived) + +## 动机 + +`change list` 按现行契约只列活动变更、静默跳过 archive。实际使用中用户的第一个问题就是"做过的提案有哪些"——当前 main 上活动变更为空,`change list` 恒返回 `[]`,8 个已归档提案只能翻 `shadow-docs/changes/archive/` 目录或 `INDEX.md`,CLI 无法回答。需要把归档可见性纳入 `change list`,且不破坏刚钉住的默认输出契约。 + +## 引用规范 + +- shadow-docs/knowledge/cli-output-contract.md + - 当前结论: stdout 单行 JSON 为机器契约;`change list` 现约束为"只列活动目录,archive 与解析失败目录静默跳过"——本变更修订该条款为 opt-in 参数形态 + - 适用 scope: cli.mjs, lib/commands.mjs, lib/human.mjs, lib/i18n.mjs, lib/output.mjs +- norms/code-style.md + - 当前结论: 渐进式治理,只改与本变更相关的;不为未来场景提前抽象(故不新增 path/pullRequest 等未要求字段,不新增互斥错误码) + - 适用 scope: lib/domains/change.mjs +- norms/tdd-verification.md + - 当前结论: 先写失败测试确认红,再最小实现转绿 + - 适用 scope: test/cli.test.mjs + +## 决策 + +- **选型:** 扁平合并列表 + 每条 `archived` 布尔字段;`--all` 列活动+归档(按 name 排序),`--archived` 只列归档,默认仍只列活动(条目同样带 `archived:false`,视为契约演进,同步更新既有 deepEqual 钉住用例)。 +- **对比方案:** 分组返回 `{changes, archivedChanges}` 未选——同一命令在不同 flag 下 JSON 形状不一致,机器流需分支处理;独立子命令 `change archived` 未选——多一个命令面且"看全部"需跑两次(接口形态已在对齐阶段与用户确认为 opt-in 参数)。 +- **理由:** 单数组 + 判别字段是消费者最稳定的形状,jq 过滤即可还原两个视图;双目录扫描直接复用 `lib/indexer.mjs` 既有规则(含 `brief(r, name, archived)` 第三参与解析失败静默跳过),不引入第二套目录遍历事实源;`--all` 与 `--archived` 同传按超集 `--all` 处理,不新增错误码,避免无谓扩大契约面;参数描述进 `COMMANDS` 目录后 help 与缺参提示零成本派生,符合单一事实源约束。 + +## 任务 + +### Phase 1(TDD:红 → 绿) + +- [x] task-1 — `test/cli.test.mjs` — 新增失败用例:`--all` 返回活动+归档合并且排序、`--archived` 只列归档、默认只列活动;所有条目含 `archived` 布尔;既有 `change list` 用例的 deepEqual 同步补 `archived: false`;跑一遍确认红 +- [x] task-2 — `lib/commands.mjs` — `change.list` 目录补 `--all`/`--archived` 参数描述(zh/en,注明同传按 --all),example 不变 +- [x] task-3 — `lib/domains/change.mjs`、`cli.mjs` — `list(r, o)` 按参数决定扫描目录集合(复用 indexer 的目录规则与 `brief(r, name, archived)`),条目加 `archived` 字段;`cli.mjs` dispatch 透传 `o`;测试转绿 + +### Phase 2(文档与知识回写) + +- [x] task-4 — `README.md` — 命令表 `change list` 行补 `--all`/`--archived` 说明 +- [x] task-5 — `shadow-docs/knowledge/cli-output-contract.md` — 修订执行约束第 3 条为 opt-in 契约(含 `archived` 字段与两参数同传语义),验证方式补新用例名,source 追加本 brief + +## 结果 + +- 实际耗时: — +- 验证: — + +## 知识评估 + +- **预期影响:** 更新 +- **候选卡片:** shadow-docs/knowledge/cli-output-contract.md +- **理由:** 本变更直接修订该卡片中 `change list` 的执行约束条款("只列活动目录,跳过 archive"→ opt-in 参数契约),不新增卡片 diff --git a/shadow-docs/knowledge/cli-output-contract.md b/shadow-docs/knowledge/cli-output-contract.md index 859c818..9175a6b 100644 --- a/shadow-docs/knowledge/cli-output-contract.md +++ b/shadow-docs/knowledge/cli-output-contract.md @@ -9,6 +9,7 @@ source: - changes/20260917-feature-help-compact-noise/brief.md - changes/20260917-feature-tty-human-default/brief.md - changes/20260917-fix-missing-arg-hints/brief.md + - changes/20260917-feature-change-list-archived/brief.md verified: 2026-09-17 --- @@ -22,7 +23,7 @@ verified: 2026-09-17 - 任何新增输出必须二选一:进 stdout JSON 契约(视为公开 API,需测试钉住),或进 stderr 人用层;**禁止**向 stdout 写非 JSON 内容。 - 缺必填参数报错时,stderr 必须从 `COMMANDS` 逐行列出缺失参数的目录描述(`flag * desc`,经 `human.argLine` 与 help 详情共用同一渲染),示例行占位符与目录 example 一致(变更名为 ``);`HINTS` 只保留 code 级短句兜底,不得重复目录中的参数说明。 -- `shadow-dev change list` 是变更名发现入口:stdout 契约 `data.changes:[{name,type,status,branch}]`(按 name 排序),只列 `shadow-docs/changes/` 活动目录,archive 与解析失败目录静默跳过(与 indexer 同规则)。 +- `shadow-dev change list` 是变更名发现入口:stdout 契约 `data.changes:[{name,type,status,branch,archived}]`(按 name 排序)。默认只列 `shadow-docs/changes/` 活动目录(条目 `archived:false`);`--all` 合并归档条目(`archived:true`),`--archived` 只列归档,两参同传按超集 `--all`;解析失败目录静默跳过(与 indexer 同规则,双目录扫描复用 `brief(r, name, archived)` 第三参)。新增无值布尔 flag 必须在 `lib/args.mjs` 白名单登记,否则会被当作取值 flag 吞掉后随参数。 - 抑制 stdout 的分支必须仍然设置退出码;plan 的人用收场行必须透出 `planHash`(PTY 环境下的 agent 兜底)。`--json` 是跨环境逃生门,不得复用为其他语义。 - 错误 code 与 `data.nextStep` 模板永不本地化;语言链固定为 `--lang` > `SHADOW_DEV_LANG` > locale 探测 > 默认 zh,且只影响 stderr 文案。 - `nextStep` 为 additive 字段,写入发生在 planHash 持久化与计算之后,不得参与 hash 输入。 @@ -35,7 +36,7 @@ verified: 2026-09-17 ## 验证方式 -`node --test test/cli.test.mjs` 全绿即契约成立(关键用例:`jsonEnabled routes the JSON surface by environment and explicit flags`、`TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr`、`human layer: banners and hints on stderr, stdout contract language-invariant`、`SHADOW_DEV_QUIET silences the human channel`、`missing required args render from the command catalog on stderr`、`change list enumerates active briefs and skips archive and unreadable dirs`)。手工复验:管道中 `shadow-dev repo inspect | jq .` 有 JSON;TTY 终端里 `shadow-dev help` 只见中文表、`shadow-dev --json help` 恢复 JSON;`SHADOW_DEV_QUIET=1 shadow-dev repo inspect` 的 stderr 应为空;`shadow-dev task list`(不带参数)的 stderr 应含 `--name * 变更名(shadow-docs/changes/ 下的子目录…)` 参数行。 +`node --test test/cli.test.mjs` 全绿即契约成立(关键用例:`jsonEnabled routes the JSON surface by environment and explicit flags`、`TTY suppresses stdout JSON; --json and env restore it; planHash surfaces on stderr`、`human layer: banners and hints on stderr, stdout contract language-invariant`、`SHADOW_DEV_QUIET silences the human channel`、`missing required args render from the command catalog on stderr`、`change list enumerates active briefs and skips archive and unreadable dirs`、`change list --all merges active and archived; --archived scopes to archive`)。手工复验:`shadow-dev change list --archived` 应列出 `changes/archive/` 下全部条目且 `archived:true`;管道中 `shadow-dev repo inspect | jq .` 有 JSON;TTY 终端里 `shadow-dev help` 只见中文表、`shadow-dev --json help` 恢复 JSON;`SHADOW_DEV_QUIET=1 shadow-dev repo inspect` 的 stderr 应为空;`shadow-dev task list`(不带参数)的 stderr 应含 `--name * 变更名(shadow-docs/changes/ 下的子目录…)` 参数行。 ## 关联知识 diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 0acbd75..96abc74 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -175,12 +175,28 @@ test('change list enumerates active briefs and skips archive and unreadable dirs assert.equal(result.status, 0, result.stderr) const payload = JSON.parse(result.stdout) assert.equal(payload.command, 'change.list') - assert.deepEqual(payload.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null }]) + assert.deepEqual(payload.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null, archived: false }]) mkdirSync(join(root, 'shadow-docs', 'changes', 'archive', 'old'), { recursive: true }) writeFileSync(join(root, 'shadow-docs', 'changes', 'archive', 'old', 'brief.md'), '---\n{"schema":"shadow-dev/v1","name":"old","type":"feat","status":"archived"}\n---\nbody\n') mkdirSync(join(root, 'shadow-docs', 'changes', 'broken'), { recursive: true }) const second = JSON.parse(run(['change', 'list'], root).stdout) - assert.deepEqual(second.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null }]) + assert.deepEqual(second.data.changes, [{ name: 'sample', type: 'feat', status: 'draft', branch: null, archived: false }]) +}) + +test('change list --all merges active and archived; --archived scopes to archive', () => { + const root = fixture() + mkdirSync(join(root, 'shadow-docs', 'changes', 'archive', 'aaa-old'), { recursive: true }) + writeFileSync(join(root, 'shadow-docs', 'changes', 'archive', 'aaa-old', 'brief.md'), '---\n{"schema":"shadow-dev/v1","name":"aaa-old","type":"feat","status":"archived","branch":null}\n---\nbody\n') + mkdirSync(join(root, 'shadow-docs', 'changes', 'archive', 'broken'), { recursive: true }) + const both = JSON.parse(run(['change', 'list', '--all'], root).stdout) + assert.deepEqual(both.data.changes, [ + { name: 'aaa-old', type: 'feat', status: 'archived', branch: null, archived: true }, + { name: 'sample', type: 'feat', status: 'draft', branch: null, archived: false }, + ]) + const only = JSON.parse(run(['change', 'list', '--archived'], root).stdout) + assert.deepEqual(only.data.changes, [{ name: 'aaa-old', type: 'feat', status: 'archived', branch: null, archived: true }]) + const superset = JSON.parse(run(['change', 'list', '--all', '--archived'], root).stdout) + assert.deepEqual(superset.data.changes, both.data.changes, 'passing both flags behaves as --all') }) test('task list exposes stable task identifiers', () => {