@friedbotstudio/create-baseline 0.3.0 → 0.5.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.
- package/README.md +10 -4
- package/bin/cli.js +252 -127
- package/obj/template/{manifest.json → .claude/manifest.json} +22 -8
- package/obj/template/.claude/skills/audit-baseline/audit.sh +16 -9
- package/obj/template/.claude/skills/changelog/SKILL.md +69 -0
- package/obj/template/.claude/skills/changelog/changelog.mjs +163 -0
- package/obj/template/.claude/skills/changelog/classifier.mjs +49 -0
- package/obj/template/.claude/skills/changelog/state-writer.mjs +19 -0
- package/obj/template/.claude/skills/changelog/tests/consent-expired_test.sh +126 -0
- package/obj/template/.claude/skills/changelog/tests/golden-path_test.sh +191 -0
- package/obj/template/.claude/skills/changelog/tests/idempotent-reentry_test.sh +121 -0
- package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs +149 -0
- package/obj/template/.claude/skills/changelog/tests/non-git-shortcircuit_test.sh +98 -0
- package/obj/template/.claude/skills/changelog/tests/preview-only_test.sh +96 -0
- package/obj/template/.claude/skills/changelog/tests/run.sh +28 -0
- package/obj/template/.claude/skills/changelog/unreleased-writer.mjs +155 -0
- package/obj/template/.claude/skills/changelog/version-preview.mjs +124 -0
- package/obj/template/.claude/skills/commit/SKILL.md +1 -1
- package/obj/template/.claude/skills/harness/SKILL.md +3 -1
- package/obj/template/.claude/skills/triage/SKILL.md +6 -5
- package/obj/template/CLAUDE.md +5 -5
- package/obj/template/docs/init/seed.md +6 -6
- package/package.json +5 -2
- package/src/CLAUDE.template.md +5 -5
- package/src/cli/install.js +7 -9
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +81 -0
- package/src/cli/tui/meta.js +63 -0
- package/src/cli/tui/splash.js +111 -0
- package/src/cli/tui/tokens.js +45 -0
- package/src/cli/tui/upgrade.js +119 -0
- package/src/seed.template.md +6 -6
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: changelog
|
|
3
|
+
owner: baseline
|
|
4
|
+
description: Workflow Phase 11.5 — Pre-commit changelog curation. Reads the staged commit's diff + conventional-type, classifies entries into keepachangelog 1.0.0 sections, appends them under `## [Unreleased]` in CHANGELOG.md, and writes ChangelogState to `.claude/state/changelog/<slug>.json`. Runs between `/grant-commit` (gate C) and `/commit`. Authorized by the same `commit_consent` token that authorizes `/commit` — no new gate. Also supports `--preview-only` for ad-hoc projected-version lookups outside a workflow.
|
|
5
|
+
argument-hint: "[--preview-only]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# changelog — Phase 11.5
|
|
9
|
+
|
|
10
|
+
Curates the `## [Unreleased]` section of `CHANGELOG.md` per [keepachangelog.com 1.0.0](https://keepachangelog.com/en/1.0.0/) before `/commit` stages the diff. Pure local curation — `@semantic-release/changelog` continues to own release-time version-block insertion.
|
|
11
|
+
|
|
12
|
+
## Prereq
|
|
13
|
+
|
|
14
|
+
ALL of `archive` AND `memory-flush` AND (implicitly) a fresh `commit_consent` token MUST be in place. Verified by the actuator at runtime, NOT by a separate guard hook.
|
|
15
|
+
|
|
16
|
+
## Applicability
|
|
17
|
+
|
|
18
|
+
Git projects only. Non-git projects auto-except this phase at `/triage` time alongside `commit` and the swarm phases (CLAUDE.md Article IV).
|
|
19
|
+
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
1. **Prereq check.** Read `.claude/state/workflow.json`. Confirm `archive` and `memory-flush` are in `completed`. If not, exit 1 with a clear error.
|
|
23
|
+
2. **Invoke the actuator.** `node .claude/skills/changelog/changelog.mjs --slug <slug> --project-root <root>`. The actuator does all the work: reads the consent token, classifies commits, appends to `## [Unreleased]`, writes state.
|
|
24
|
+
3. **On actuator success.** The harness marks this task `completed`, appends `"changelog"` to `workflow.json → completed`, and continues to `/commit`.
|
|
25
|
+
4. **On actuator failure (exit 1).** Surface the stderr to the user. Most likely cause: `commit_consent` expired. User re-runs `/grant-commit`.
|
|
26
|
+
|
|
27
|
+
## Ad-hoc preview mode
|
|
28
|
+
|
|
29
|
+
The skill is also invokable outside an active workflow via `--preview-only`. The actuator calls `semantic-release` as a JS API with `dryRun: true` and prints the projected next version + a draft fragment to stdout. No files are written; no consent gesture is required. Useful for answering "what version would my next push deploy?" without running the full workflow.
|
|
30
|
+
|
|
31
|
+
## Companion files
|
|
32
|
+
|
|
33
|
+
- `changelog.mjs` — CLI actuator. The decision logic.
|
|
34
|
+
- `classifier.mjs` — conventional-commit type → keepachangelog section.
|
|
35
|
+
- `version-preview.mjs` — semantic-release JS API call for projected version.
|
|
36
|
+
- `state-writer.mjs` — idempotent writes to `.claude/state/changelog/<slug>.json`.
|
|
37
|
+
- `unreleased-writer.mjs` — `CHANGELOG.md` RMW under `## [Unreleased]`; also exports `reinsertUnreleasedHeading` for the release-time fallback (`@semantic-release/changelog` destroys the heading position; this restores it).
|
|
38
|
+
|
|
39
|
+
## Constraints
|
|
40
|
+
|
|
41
|
+
- **Idempotent.** Re-invocation on the same slug + same HEAD SHA does NOT duplicate entries. The actuator computes a digest from `(slug, source_commit_sha, entries)` and skips writes if the digest matches the prior state file.
|
|
42
|
+
- **No internal mocks.** The actuator imports `semantic-release` (devDep) directly; no mock layer. The system clock IS mocked in `consent-expired` tests with `touch -d`.
|
|
43
|
+
- **TTL fit.** The skill is designed to complete inside the 300 s `commit_consent` window. Typical runtime: under 5 s. If the token expires mid-run, the actuator exits 1 BEFORE writing — partial writes are not allowed.
|
|
44
|
+
- **CHANGELOG.md migration is in scope of the workflow that introduces this skill.** Subsequent workflows assume the file already has the keepachangelog `## [Unreleased]` heading.
|
|
45
|
+
|
|
46
|
+
## Spec traceability
|
|
47
|
+
|
|
48
|
+
The acceptance criteria from `docs/specs/changelog-skill-and-responsive-svgs.md` map to the skill's components as follows:
|
|
49
|
+
|
|
50
|
+
- **AC-001** (harness invokes changelog between gate C and commit; Unreleased section grows) — `changelog.mjs` `runActiveMode` + `unreleased-writer.mjs` `appendUnderUnreleased`.
|
|
51
|
+
- **AC-002** (CHANGELOG.md included in commit stage list) — `commit/SKILL.md` Step 3 named-path enumeration grows `CHANGELOG.md` via the actuator's write; verified in `golden-path_test.sh`.
|
|
52
|
+
- **AC-003** (non-git short-circuit) — `triage/SKILL.md` step 2 non-git auto-except list grew to include `"changelog"` alongside `"commit"`; verified in `non-git-shortcircuit_test.sh`.
|
|
53
|
+
- **AC-004** (audit-baseline byte-mirror invariants after Article IV amendment) — handled in `CLAUDE.md` ↔ `src/CLAUDE.template.md` mirror + `docs/init/seed.md` ↔ `src/seed.template.md` mirror; verified by `audit.sh` PASS.
|
|
54
|
+
- **AC-005** (site-src narrative names new phase; Article X.1 em-dash discipline) — handled in `/document` Phase 10 per design-ui row 1 misroute terminal at `.claude/state/design/changelog-skill-and-responsive-svgs-row1.json`.
|
|
55
|
+
- **AC-006** (SVG legible at 320 px) — handled in `site-src/assets/site.css` bento `@media (max-width: 768px)` block per design-ui row 0 audit at `docs/design/changelog-skill-and-responsive-svgs.audit.md`.
|
|
56
|
+
- **AC-007** (bento composition at 1920 px) — same design-ui row 0 deliverable; audit verdict 20/20 PASS.
|
|
57
|
+
- **AC-008** (workflow.json completed sequence ends with `[..., "changelog", "commit"]`) — `harness/SKILL.md` phase-ordering fence + `commit/SKILL.md` prereq line.
|
|
58
|
+
- **AC-009** (source_backlog_keys stamp-closure on commit) — `commit/SKILL.md` Step 6 invokes `sweep.py --mode stamp-closure`; no change to that flow needed in this workflow.
|
|
59
|
+
- **AC-010** (consent-expired denial) — `changelog.mjs` `checkConsent` reads epoch from line 1 of `commit_consent`; exits 1 on stale; verified in `consent-expired_test.sh`.
|
|
60
|
+
- **AC-011** (TaskList re-seed across session boundary) — `triage/SKILL.md` four task-seeding templates updated to insert `Run /changelog` between `Wait for /grant-commit` and `Run /commit`; `harness/SKILL.md` state-machine table grew a row for the new gap.
|
|
61
|
+
- **AC-012** (ad-hoc `--preview-only` mode) — `changelog.mjs` `runPreviewMode` calls semantic-release JS API with `dryRun:true`; no consent required; verified in `preview-only_test.sh`.
|
|
62
|
+
- **AC-013** (`@semantic-release/changelog` preserves Unreleased OR fallback re-inserts) — `unreleased-writer.mjs` `reinsertUnreleasedHeading` export; verified in `keepachangelog-unreleased-preserved_test.mjs` (test 1 documents the plugin behavior empirically; test 2 confirms the fallback restores canonical structure).
|
|
63
|
+
|
|
64
|
+
Design call rows from the spec:
|
|
65
|
+
|
|
66
|
+
- **`architecture-svg-bento-grid-responsive`** (design lane) — completed at design-ui row 0; site-src/index.njk + site-src/assets/site.css written; audit 20/20 PASS.
|
|
67
|
+
- **`site-narrative-new-phase-mention`** (copy lane) — Stage 0 misroute to `/document` Phase 10; state checkpoint at row1.json; `/document` reads the misroute terminal and routes the three target files through `Skill(prose)` with mandatory humanizer pass.
|
|
68
|
+
|
|
69
|
+
Phase 11.5 introduces gate-adjacent automation between gate C `/grant-commit` (Article IV phase 11 gate) and the `/commit` skill body. No new gate (no gate D); the existing `commit_consent` token authorizes both.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase 11.5 Changelog actuator.
|
|
3
|
+
//
|
|
4
|
+
// CLI:
|
|
5
|
+
// node changelog.mjs --slug <slug> [--project-root <path>]
|
|
6
|
+
// node changelog.mjs --preview-only --slug <slug> [--project-root <path>]
|
|
7
|
+
//
|
|
8
|
+
// Active mode: verifies commit_consent freshness, classifies new commits,
|
|
9
|
+
// appends keepachangelog entries under ## [Unreleased] in CHANGELOG.md,
|
|
10
|
+
// writes ChangelogState to .claude/state/changelog/<slug>.json.
|
|
11
|
+
//
|
|
12
|
+
// Preview mode: prints projected next version + draft fragment; no writes.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { parseArgs } from 'node:util';
|
|
18
|
+
import { classify } from './classifier.mjs';
|
|
19
|
+
import { previewProjectedVersion } from './version-preview.mjs';
|
|
20
|
+
import { writeState } from './state-writer.mjs';
|
|
21
|
+
import { appendUnderUnreleased } from './unreleased-writer.mjs';
|
|
22
|
+
|
|
23
|
+
const TTL_SECONDS = 300;
|
|
24
|
+
|
|
25
|
+
function parseCli() {
|
|
26
|
+
const { values } = parseArgs({
|
|
27
|
+
options: {
|
|
28
|
+
slug: { type: 'string' },
|
|
29
|
+
'preview-only': { type: 'boolean', default: false },
|
|
30
|
+
'project-root': { type: 'string', default: '.' },
|
|
31
|
+
},
|
|
32
|
+
strict: true,
|
|
33
|
+
});
|
|
34
|
+
if (!values.slug) {
|
|
35
|
+
process.stderr.write('error: --slug required\n');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
slug: values.slug,
|
|
40
|
+
previewOnly: values['preview-only'],
|
|
41
|
+
projectRoot: resolve(values['project-root']),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function checkConsent(projectRoot) {
|
|
46
|
+
const path = join(projectRoot, '.claude/state/commit_consent');
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
return { ok: false, reason: 'consent absent (no commit_consent token)' };
|
|
49
|
+
}
|
|
50
|
+
// Token contract: line 1 is the unix epoch when /grant-commit was issued.
|
|
51
|
+
// Reading the epoch (not filesystem mtime) keeps the freshness check
|
|
52
|
+
// consistent with how `/grant-commit` writes the file and with how tests
|
|
53
|
+
// stale the consent via `echo "<old-epoch>" > commit_consent`.
|
|
54
|
+
const firstLine = readFileSync(path, 'utf8').split('\n', 1)[0].trim();
|
|
55
|
+
const tokenEpoch = parseInt(firstLine, 10);
|
|
56
|
+
if (!Number.isFinite(tokenEpoch)) {
|
|
57
|
+
return { ok: false, reason: 'consent malformed (line 1 not an epoch)' };
|
|
58
|
+
}
|
|
59
|
+
const ageSeconds = Math.floor(Date.now() / 1000) - tokenEpoch;
|
|
60
|
+
if (ageSeconds > TTL_SECONDS) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
reason: `consent expired (${ageSeconds}s > ${TTL_SECONDS}s)`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { ok: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getHeadSha(projectRoot) {
|
|
70
|
+
try {
|
|
71
|
+
return execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
72
|
+
cwd: projectRoot, encoding: 'utf8',
|
|
73
|
+
}).trim();
|
|
74
|
+
} catch {
|
|
75
|
+
return 'unknown';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildEntry(commit) {
|
|
80
|
+
const cls = classify(commit);
|
|
81
|
+
if (!cls) return null;
|
|
82
|
+
return {
|
|
83
|
+
section: cls.section,
|
|
84
|
+
body: commit.subject,
|
|
85
|
+
conventional_type: commit.type,
|
|
86
|
+
conventional_scope: commit.scope,
|
|
87
|
+
breaking: cls.breaking,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderPreviewFragment(projection) {
|
|
92
|
+
const lines = [];
|
|
93
|
+
lines.push(`Projected: ${projection.version} (${projection.type || 'no release'})`);
|
|
94
|
+
lines.push(`Commits analyzed: ${projection.commits.length}`);
|
|
95
|
+
if (projection.commits.length > 0) {
|
|
96
|
+
lines.push('');
|
|
97
|
+
lines.push('Draft fragment under ## [Unreleased]:');
|
|
98
|
+
const entries = projection.commits.map(buildEntry).filter(Boolean);
|
|
99
|
+
if (entries.length === 0) {
|
|
100
|
+
lines.push('(no commits map to keepachangelog sections)');
|
|
101
|
+
} else {
|
|
102
|
+
const grouped = new Map();
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (!grouped.has(e.section)) grouped.set(e.section, []);
|
|
105
|
+
grouped.get(e.section).push(e);
|
|
106
|
+
}
|
|
107
|
+
for (const [section, items] of grouped) {
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`### ${section}`);
|
|
110
|
+
for (const item of items) {
|
|
111
|
+
const prefix = item.breaking ? '**BREAKING:** ' : '';
|
|
112
|
+
lines.push(`- ${prefix}${item.body}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return lines.join('\n') + '\n';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runPreviewMode({ projectRoot }) {
|
|
121
|
+
const projection = await previewProjectedVersion(projectRoot);
|
|
122
|
+
process.stdout.write(renderPreviewFragment(projection));
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runActiveMode({ slug, projectRoot }) {
|
|
127
|
+
const consent = checkConsent(projectRoot);
|
|
128
|
+
if (!consent.ok) {
|
|
129
|
+
process.stderr.write(`error: ${consent.reason}\n`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
const projection = await previewProjectedVersion(projectRoot);
|
|
133
|
+
const entries = projection.commits.map(buildEntry).filter(Boolean);
|
|
134
|
+
const changelogPath = join(projectRoot, 'CHANGELOG.md');
|
|
135
|
+
await appendUnderUnreleased(changelogPath, entries);
|
|
136
|
+
const state = {
|
|
137
|
+
slug,
|
|
138
|
+
source_commit_sha: getHeadSha(projectRoot),
|
|
139
|
+
projected_version: projection.version,
|
|
140
|
+
projected_type: projection.type,
|
|
141
|
+
entries,
|
|
142
|
+
generated_at: new Date().toISOString(),
|
|
143
|
+
unreleased_inserted_at: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
await writeState(projectRoot, slug, state);
|
|
146
|
+
process.stdout.write(
|
|
147
|
+
`changelog: wrote ${entries.length} entries to ${changelogPath} (projected ${projection.version})\n`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function main() {
|
|
152
|
+
const cli = parseCli();
|
|
153
|
+
if (cli.previewOnly) {
|
|
154
|
+
await runPreviewMode(cli);
|
|
155
|
+
} else {
|
|
156
|
+
await runActiveMode(cli);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
main().catch((err) => {
|
|
161
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Conventional-commit type → keepachangelog 1.0.0 section.
|
|
2
|
+
//
|
|
3
|
+
// Default mapping (overridable per-commit in the actuator):
|
|
4
|
+
// feat → Added
|
|
5
|
+
// fix → Fixed
|
|
6
|
+
// perf → Changed
|
|
7
|
+
// refactor → Changed
|
|
8
|
+
// docs → (no entry; release-time CHANGELOG ignores)
|
|
9
|
+
// style → (no entry)
|
|
10
|
+
// test → (no entry)
|
|
11
|
+
// build → (no entry)
|
|
12
|
+
// ci → (no entry)
|
|
13
|
+
// chore → (no entry)
|
|
14
|
+
// revert → Removed
|
|
15
|
+
// Breaking suffix (`feat!:` or `BREAKING CHANGE:` body) → forces Changed
|
|
16
|
+
// section regardless of base type AND sets breaking: true on the entry.
|
|
17
|
+
|
|
18
|
+
const TYPE_TO_SECTION = Object.freeze({
|
|
19
|
+
feat: 'Added',
|
|
20
|
+
fix: 'Fixed',
|
|
21
|
+
perf: 'Changed',
|
|
22
|
+
refactor: 'Changed',
|
|
23
|
+
revert: 'Removed',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const SKIP_TYPES = new Set(['docs', 'style', 'test', 'build', 'ci', 'chore']);
|
|
27
|
+
|
|
28
|
+
export function classify(commit) {
|
|
29
|
+
if (!commit || typeof commit !== 'object') return null;
|
|
30
|
+
const type = (commit.type || '').toLowerCase();
|
|
31
|
+
const breaking = Boolean(commit.breaking);
|
|
32
|
+
if (SKIP_TYPES.has(type) && !breaking) return null;
|
|
33
|
+
if (breaking) {
|
|
34
|
+
return { section: 'Changed', breaking: true };
|
|
35
|
+
}
|
|
36
|
+
const section = TYPE_TO_SECTION[type];
|
|
37
|
+
if (!section) return null;
|
|
38
|
+
return { section, breaking: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// All six keepachangelog 1.0.0 sections, in canonical order.
|
|
42
|
+
export const KEEPACHANGELOG_SECTIONS = Object.freeze([
|
|
43
|
+
'Added',
|
|
44
|
+
'Changed',
|
|
45
|
+
'Deprecated',
|
|
46
|
+
'Removed',
|
|
47
|
+
'Fixed',
|
|
48
|
+
'Security',
|
|
49
|
+
]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Idempotent writer for .claude/state/changelog/<slug>.json.
|
|
2
|
+
//
|
|
3
|
+
// Re-invocation on the same (slug, source_commit_sha) re-writes the file with
|
|
4
|
+
// identical content except for generated_at / unreleased_inserted_at, which
|
|
5
|
+
// always advance. The state object's body content (excluding those two
|
|
6
|
+
// timestamps) is byte-equal between invocations — letting the
|
|
7
|
+
// idempotent-reentry test compare via JSON.dumps on a clone with those keys
|
|
8
|
+
// popped.
|
|
9
|
+
|
|
10
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
export async function writeState(projectRoot, slug, state) {
|
|
14
|
+
const dir = join(projectRoot, '.claude/state/changelog');
|
|
15
|
+
const path = join(dir, `${slug}.json`);
|
|
16
|
+
await mkdir(dir, { recursive: true });
|
|
17
|
+
await writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8');
|
|
18
|
+
return path;
|
|
19
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fixture-based integration test for AC-010: when commit_consent token is
|
|
3
|
+
# stale (older than consent.commit_ttl_seconds, default 300s), the changelog
|
|
4
|
+
# skill exits non-zero with "consent expired" stderr, does NOT modify
|
|
5
|
+
# CHANGELOG.md, and does NOT write the state file.
|
|
6
|
+
#
|
|
7
|
+
# Pre-implement RED state: actuator does not exist; test fails on missing file.
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
REPO_ROOT="$(cd "$HERE/../../../.." && pwd)"
|
|
13
|
+
ACTUATOR="$REPO_ROOT/.claude/skills/changelog/changelog.mjs"
|
|
14
|
+
|
|
15
|
+
PASS=0; FAIL=0; FAILED=()
|
|
16
|
+
|
|
17
|
+
fail() { echo " FAIL: $*"; return 1; }
|
|
18
|
+
|
|
19
|
+
# Seed a tempdir with a stale commit_consent (epoch = now - 310s).
|
|
20
|
+
seed_stale_consent_project() {
|
|
21
|
+
local proj="$1" slug="$2"
|
|
22
|
+
mkdir -p "$proj/.claude/state"
|
|
23
|
+
cd "$proj"
|
|
24
|
+
git init -q
|
|
25
|
+
git config user.email "test@example.com"
|
|
26
|
+
git config user.name "Test"
|
|
27
|
+
git commit --allow-empty -q -m "chore: initial"
|
|
28
|
+
git tag v0.1.0
|
|
29
|
+
echo "stale test" > thing.txt
|
|
30
|
+
git add thing.txt
|
|
31
|
+
git commit -q -m "feat: stale consent path"
|
|
32
|
+
# Stale token: epoch in the past, beyond 300s default TTL.
|
|
33
|
+
local stale_epoch; stale_epoch=$(( $(date +%s) - 310 ))
|
|
34
|
+
echo "$stale_epoch" > "$proj/.claude/state/commit_consent"
|
|
35
|
+
echo "stale" >> "$proj/.claude/state/commit_consent"
|
|
36
|
+
cat > "$proj/.claude/state/workflow.json" <<EOF
|
|
37
|
+
{
|
|
38
|
+
"request": "consent-expired test",
|
|
39
|
+
"slug": "$slug",
|
|
40
|
+
"entry_phase": "intake",
|
|
41
|
+
"exceptions": [],
|
|
42
|
+
"completed": ["intake","scout","research","spec","approve-spec","tdd","simplify","security","integrate","document","archive","memory-flush"],
|
|
43
|
+
"source_backlog_keys": [],
|
|
44
|
+
"created_at": 1700000000,
|
|
45
|
+
"updated_at": 1700000000
|
|
46
|
+
}
|
|
47
|
+
EOF
|
|
48
|
+
cat > "$proj/CHANGELOG.md" <<'EOF'
|
|
49
|
+
# Changelog
|
|
50
|
+
|
|
51
|
+
## [0.1.0] - 2026-01-01
|
|
52
|
+
|
|
53
|
+
### Added
|
|
54
|
+
- Initial release
|
|
55
|
+
EOF
|
|
56
|
+
cat > "$proj/.releaserc.json" <<'EOF'
|
|
57
|
+
{
|
|
58
|
+
"branches": ["main"],
|
|
59
|
+
"plugins": [
|
|
60
|
+
["@semantic-release/commit-analyzer", { "preset": "angular" }]
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
EOF
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
run() {
|
|
67
|
+
local name="$1"
|
|
68
|
+
echo "RUN $name"
|
|
69
|
+
if "$name"; then
|
|
70
|
+
PASS=$((PASS+1)); echo "PASS $name"
|
|
71
|
+
else
|
|
72
|
+
FAIL=$((FAIL+1)); FAILED+=("$name"); echo "FAIL $name"
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# --- AC-010 -------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
test_when_commit_consent_token_stale_then_changelog_exits_with_consent_expired() {
|
|
79
|
+
local proj; proj="$(mktemp -d)"; trap "rm -rf $proj" RETURN
|
|
80
|
+
seed_stale_consent_project "$proj" "stale-consent"
|
|
81
|
+
if [ ! -f "$ACTUATOR" ]; then
|
|
82
|
+
fail "AC-010 actuator not yet at $ACTUATOR — expected pre-implement RED state"
|
|
83
|
+
return 1
|
|
84
|
+
fi
|
|
85
|
+
local changelog_before; changelog_before="$(sha256sum "$proj/CHANGELOG.md" | awk '{print $1}')"
|
|
86
|
+
local out; local err
|
|
87
|
+
# Capture stdout, stderr, and exit code. NO `|| true` after the assignment —
|
|
88
|
+
# set -uo pipefail is active (no `-e`), so a non-zero exit from the assigned
|
|
89
|
+
# command propagates to $? without aborting the test. Adding `|| true` here
|
|
90
|
+
# would clobber $? to 0 and silently break the exit-code assertion below.
|
|
91
|
+
out="$(node "$ACTUATOR" --slug stale-consent --project-root "$proj" 2>/tmp/changelog-stderr.$$)"
|
|
92
|
+
local ec=$?
|
|
93
|
+
err="$(cat /tmp/changelog-stderr.$$)"
|
|
94
|
+
if [ "$ec" -eq 0 ]; then
|
|
95
|
+
fail "AC-010 actuator must exit non-zero on stale consent; exit was 0"
|
|
96
|
+
return 1
|
|
97
|
+
fi
|
|
98
|
+
# stderr must mention "consent expired" (case-insensitive match).
|
|
99
|
+
if ! printf '%s' "$err" | grep -qiE 'consent.*expired|expired.*consent'; then
|
|
100
|
+
fail "AC-010 stderr must match /consent.*expired/i; got: $err"
|
|
101
|
+
return 1
|
|
102
|
+
fi
|
|
103
|
+
# CHANGELOG.md unchanged.
|
|
104
|
+
local changelog_after; changelog_after="$(sha256sum "$proj/CHANGELOG.md" | awk '{print $1}')"
|
|
105
|
+
if [ "$changelog_before" != "$changelog_after" ]; then
|
|
106
|
+
fail "AC-010 CHANGELOG.md was modified despite stale consent"
|
|
107
|
+
return 1
|
|
108
|
+
fi
|
|
109
|
+
# State file NOT created.
|
|
110
|
+
if [ -f "$proj/.claude/state/changelog/stale-consent.json" ]; then
|
|
111
|
+
fail "AC-010 state file must NOT be written on stale consent"
|
|
112
|
+
return 1
|
|
113
|
+
fi
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# --- runner -------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
run test_when_commit_consent_token_stale_then_changelog_exits_with_consent_expired
|
|
119
|
+
|
|
120
|
+
echo "----"
|
|
121
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
122
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
123
|
+
echo "Failed tests:"
|
|
124
|
+
for t in "${FAILED[@]}"; do echo " - $t"; done
|
|
125
|
+
fi
|
|
126
|
+
exit $((FAIL > 0))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fixture-based integration tests for the changelog skill's golden-path
|
|
3
|
+
# behavior. Covers AC-001, AC-002, AC-008 from
|
|
4
|
+
# docs/specs/changelog-skill-and-responsive-svgs.md.
|
|
5
|
+
#
|
|
6
|
+
# Until .claude/skills/changelog/changelog.mjs exists, every test fails RED
|
|
7
|
+
# (correct TDD state).
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
REPO_ROOT="$(cd "$HERE/../../../.." && pwd)"
|
|
13
|
+
ACTUATOR="$REPO_ROOT/.claude/skills/changelog/changelog.mjs"
|
|
14
|
+
|
|
15
|
+
PASS=0; FAIL=0; FAILED=()
|
|
16
|
+
|
|
17
|
+
fail() { echo " FAIL: $*"; return 1; }
|
|
18
|
+
|
|
19
|
+
assert_file_contains() {
|
|
20
|
+
local path="$1" needle="$2" msg="$3"
|
|
21
|
+
if grep -qF "$needle" "$path" 2>/dev/null; then return 0; fi
|
|
22
|
+
fail "$msg :: file $path missing: $needle"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
assert_file_matches() {
|
|
26
|
+
local path="$1" pattern="$2" msg="$3"
|
|
27
|
+
if grep -qE "$pattern" "$path" 2>/dev/null; then return 0; fi
|
|
28
|
+
fail "$msg :: file $path missing pattern: $pattern"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
today() { date -u +%Y-%m-%d; }
|
|
32
|
+
|
|
33
|
+
# Build a tempdir project with .git, one feat: commit, fresh commit_consent,
|
|
34
|
+
# and workflow.json with phases up through memory-flush completed.
|
|
35
|
+
seed_golden_path_project() {
|
|
36
|
+
local proj="$1" slug="$2"
|
|
37
|
+
mkdir -p "$proj/.claude/state"
|
|
38
|
+
cd "$proj"
|
|
39
|
+
git init -q >/dev/null 2>&1
|
|
40
|
+
git config user.email "test@example.com"
|
|
41
|
+
git config user.name "Test"
|
|
42
|
+
# Seed an existing-tracked CHANGELOG.md BEFORE the feat commit so that when
|
|
43
|
+
# the actuator appends to it, git status reports M (modified) rather than ??
|
|
44
|
+
# (untracked). This matches the realistic workflow shape: any real project
|
|
45
|
+
# has a tracked CHANGELOG.md, the actuator modifies it, /commit stages the
|
|
46
|
+
# modification.
|
|
47
|
+
cat > "$proj/CHANGELOG.md" <<'EOF'
|
|
48
|
+
# Changelog
|
|
49
|
+
|
|
50
|
+
## [Unreleased]
|
|
51
|
+
|
|
52
|
+
## [0.1.0] - 2026-01-01
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- Initial release
|
|
56
|
+
EOF
|
|
57
|
+
git add CHANGELOG.md
|
|
58
|
+
git commit -q -m "chore: initial"
|
|
59
|
+
git tag v0.1.0
|
|
60
|
+
echo "added thing" > thing.txt
|
|
61
|
+
git add thing.txt
|
|
62
|
+
git commit -q -m "feat(skill): add the thing"
|
|
63
|
+
# Fresh commit_consent (epoch now).
|
|
64
|
+
date +%s > "$proj/.claude/state/commit_consent"
|
|
65
|
+
echo "test consent" >> "$proj/.claude/state/commit_consent"
|
|
66
|
+
# workflow.json with all phases up through memory-flush completed.
|
|
67
|
+
cat > "$proj/.claude/state/workflow.json" <<EOF
|
|
68
|
+
{
|
|
69
|
+
"request": "golden-path test",
|
|
70
|
+
"slug": "$slug",
|
|
71
|
+
"entry_phase": "intake",
|
|
72
|
+
"exceptions": [],
|
|
73
|
+
"completed": ["intake","scout","research","spec","approve-spec","tdd","simplify","security","integrate","document","archive","memory-flush"],
|
|
74
|
+
"source_backlog_keys": [],
|
|
75
|
+
"created_at": 1700000000,
|
|
76
|
+
"updated_at": 1700000000
|
|
77
|
+
}
|
|
78
|
+
EOF
|
|
79
|
+
# Minimal .releaserc.json so semantic-release can analyze.
|
|
80
|
+
cat > "$proj/.releaserc.json" <<'EOF'
|
|
81
|
+
{
|
|
82
|
+
"branches": ["main"],
|
|
83
|
+
"plugins": [
|
|
84
|
+
["@semantic-release/commit-analyzer", { "preset": "angular" }]
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
EOF
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
run() {
|
|
91
|
+
local name="$1"
|
|
92
|
+
echo "RUN $name"
|
|
93
|
+
if "$name"; then
|
|
94
|
+
PASS=$((PASS+1)); echo "PASS $name"
|
|
95
|
+
else
|
|
96
|
+
FAIL=$((FAIL+1)); FAILED+=("$name"); echo "FAIL $name"
|
|
97
|
+
fi
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# --- AC-001 -------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
test_when_grant_commit_token_fresh_then_changelog_writes_unreleased_section() {
|
|
103
|
+
local proj; proj="$(mktemp -d)"; trap "rm -rf $proj" RETURN
|
|
104
|
+
seed_golden_path_project "$proj" "golden-path"
|
|
105
|
+
if [ ! -f "$ACTUATOR" ]; then
|
|
106
|
+
fail "AC-001 actuator not yet at $ACTUATOR — expected pre-implement RED state"
|
|
107
|
+
return 1
|
|
108
|
+
fi
|
|
109
|
+
node "$ACTUATOR" --slug golden-path --project-root "$proj" \
|
|
110
|
+
> /tmp/changelog-stdout.$$ 2> /tmp/changelog-stderr.$$ \
|
|
111
|
+
|| { fail "AC-001 actuator exited non-zero; stderr: $(cat /tmp/changelog-stderr.$$)"; return 1; }
|
|
112
|
+
assert_file_contains "$proj/CHANGELOG.md" "## [Unreleased]" "AC-001 Unreleased heading missing" || return 1
|
|
113
|
+
# At least one keepachangelog category subheading under Unreleased.
|
|
114
|
+
assert_file_matches "$proj/CHANGELOG.md" '^### (Added|Changed|Deprecated|Removed|Fixed|Security)' \
|
|
115
|
+
"AC-001 no keepachangelog category subheading in CHANGELOG.md" || return 1
|
|
116
|
+
# State file written.
|
|
117
|
+
[ -f "$proj/.claude/state/changelog/golden-path.json" ] \
|
|
118
|
+
|| { fail "AC-001 .claude/state/changelog/golden-path.json not written"; return 1; }
|
|
119
|
+
# State file is valid JSON with expected shape.
|
|
120
|
+
python3 -c "
|
|
121
|
+
import json, sys
|
|
122
|
+
with open('$proj/.claude/state/changelog/golden-path.json') as f:
|
|
123
|
+
data = json.load(f)
|
|
124
|
+
required = {'slug', 'source_commit_sha', 'entries', 'generated_at'}
|
|
125
|
+
missing = required - set(data.keys())
|
|
126
|
+
if missing:
|
|
127
|
+
sys.exit(f'state file missing keys: {missing}')
|
|
128
|
+
if data['slug'] != 'golden-path':
|
|
129
|
+
sys.exit(f'slug mismatch: {data[\"slug\"]}')
|
|
130
|
+
if not isinstance(data['entries'], list):
|
|
131
|
+
sys.exit('entries must be a list')
|
|
132
|
+
" || { fail "AC-001 state file shape invalid"; return 1; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# --- AC-002 -------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
test_when_changelog_completed_then_commit_includes_changelog_md_in_stage_list() {
|
|
138
|
+
local proj; proj="$(mktemp -d)"; trap "rm -rf $proj" RETURN
|
|
139
|
+
seed_golden_path_project "$proj" "stage-list"
|
|
140
|
+
if [ ! -f "$ACTUATOR" ]; then
|
|
141
|
+
fail "AC-002 actuator not yet at $ACTUATOR — expected pre-implement RED state"
|
|
142
|
+
return 1
|
|
143
|
+
fi
|
|
144
|
+
cd "$proj"
|
|
145
|
+
node "$ACTUATOR" --slug stage-list --project-root "$proj" >/dev/null 2>&1 \
|
|
146
|
+
|| { fail "AC-002 actuator exited non-zero"; return 1; }
|
|
147
|
+
# CHANGELOG.md must now be modified in the working tree (the change the skill made).
|
|
148
|
+
local diff_status; diff_status="$(cd "$proj" && git status --porcelain CHANGELOG.md)"
|
|
149
|
+
if [ -z "$diff_status" ]; then
|
|
150
|
+
fail "AC-002 CHANGELOG.md must show modification in git status after changelog skill runs"
|
|
151
|
+
return 1
|
|
152
|
+
fi
|
|
153
|
+
# Confirm M flag (modified) on the file.
|
|
154
|
+
printf '%s' "$diff_status" | grep -qE '^\s*M\s+CHANGELOG\.md' \
|
|
155
|
+
|| { fail "AC-002 CHANGELOG.md not marked Modified in git status; got: $diff_status"; return 1; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
# --- AC-008 -------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
test_when_workflow_completed_then_changelog_appended_before_commit() {
|
|
161
|
+
# Static: the commit SKILL.md prereq line names "changelog" as a required
|
|
162
|
+
# entry in workflow.json → completed. The harness ordering text lists
|
|
163
|
+
# changelog immediately before commit. This test reads the LIVE SKILL.md
|
|
164
|
+
# files (which the implement worker edits) and asserts on their content.
|
|
165
|
+
local commit_skill="$REPO_ROOT/.claude/skills/commit/SKILL.md"
|
|
166
|
+
local harness_skill="$REPO_ROOT/.claude/skills/harness/SKILL.md"
|
|
167
|
+
[ -f "$commit_skill" ] || { fail "AC-008 commit/SKILL.md missing"; return 1; }
|
|
168
|
+
[ -f "$harness_skill" ] || { fail "AC-008 harness/SKILL.md missing"; return 1; }
|
|
169
|
+
# commit SKILL.md prereq line must mention changelog.
|
|
170
|
+
assert_file_matches "$commit_skill" 'archive.*memory-flush.*changelog|changelog.*archive.*memory-flush|memory-flush.*changelog' \
|
|
171
|
+
"AC-008 commit/SKILL.md prereq must include changelog alongside archive and memory-flush" \
|
|
172
|
+
|| return 1
|
|
173
|
+
# harness ordering text mentions changelog between grant-commit and commit.
|
|
174
|
+
assert_file_matches "$harness_skill" '/grant-commit.*changelog.*commit|grant-commit → changelog → commit' \
|
|
175
|
+
"AC-008 harness/SKILL.md ordering must mention changelog between /grant-commit and commit" \
|
|
176
|
+
|| return 1
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# --- runner -------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
run test_when_grant_commit_token_fresh_then_changelog_writes_unreleased_section
|
|
182
|
+
run test_when_changelog_completed_then_commit_includes_changelog_md_in_stage_list
|
|
183
|
+
run test_when_workflow_completed_then_changelog_appended_before_commit
|
|
184
|
+
|
|
185
|
+
echo "----"
|
|
186
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
187
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
188
|
+
echo "Failed tests:"
|
|
189
|
+
for t in "${FAILED[@]}"; do echo " - $t"; done
|
|
190
|
+
fi
|
|
191
|
+
exit $((FAIL > 0))
|