@friedbotstudio/create-baseline 0.3.0 → 0.4.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 +197 -119
- 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 +2 -2
- package/obj/template/docs/init/seed.md +4 -4
- package/obj/template/manifest.json +21 -7
- package/package.json +5 -2
- package/src/CLAUDE.template.md +2 -2
- package/src/cli/merge.js +15 -10
- package/src/cli/tui/doctor.js +56 -0
- package/src/cli/tui/install.js +79 -0
- package/src/cli/tui/meta.js +30 -0
- package/src/cli/tui/tokens.js +38 -0
- package/src/cli/tui/upgrade.js +100 -0
- package/src/seed.template.md +4 -4
|
@@ -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))
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for the idempotency invariant: invoking the changelog
|
|
3
|
+
# actuator twice on the same slug + same commit SHA SHALL NOT duplicate
|
|
4
|
+
# entries under ## [Unreleased]; the state file mtime advances but content
|
|
5
|
+
# (excluding generated_at) is byte-equal.
|
|
6
|
+
#
|
|
7
|
+
# Pre-implement RED: 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
|
+
# Identical setup to golden-path_test.sh; replicated here so each test file
|
|
20
|
+
# stays independently runnable.
|
|
21
|
+
seed_idem_project() {
|
|
22
|
+
local proj="$1" slug="$2"
|
|
23
|
+
mkdir -p "$proj/.claude/state"
|
|
24
|
+
cd "$proj"
|
|
25
|
+
git init -q
|
|
26
|
+
git config user.email "test@example.com"
|
|
27
|
+
git config user.name "Test"
|
|
28
|
+
git commit --allow-empty -q -m "chore: initial"
|
|
29
|
+
git tag v0.1.0
|
|
30
|
+
echo "thing" > thing.txt
|
|
31
|
+
git add thing.txt
|
|
32
|
+
git commit -q -m "feat: add the thing"
|
|
33
|
+
date +%s > "$proj/.claude/state/commit_consent"
|
|
34
|
+
echo "fresh" >> "$proj/.claude/state/commit_consent"
|
|
35
|
+
cat > "$proj/.claude/state/workflow.json" <<EOF
|
|
36
|
+
{
|
|
37
|
+
"request": "idempotent re-entry test",
|
|
38
|
+
"slug": "$slug",
|
|
39
|
+
"entry_phase": "intake",
|
|
40
|
+
"exceptions": [],
|
|
41
|
+
"completed": ["intake","scout","research","spec","approve-spec","tdd","simplify","security","integrate","document","archive","memory-flush"],
|
|
42
|
+
"source_backlog_keys": [],
|
|
43
|
+
"created_at": 1700000000,
|
|
44
|
+
"updated_at": 1700000000
|
|
45
|
+
}
|
|
46
|
+
EOF
|
|
47
|
+
cat > "$proj/CHANGELOG.md" <<'EOF'
|
|
48
|
+
# Changelog
|
|
49
|
+
|
|
50
|
+
## [0.1.0] - 2026-01-01
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
- Initial release
|
|
54
|
+
EOF
|
|
55
|
+
cat > "$proj/.releaserc.json" <<'EOF'
|
|
56
|
+
{
|
|
57
|
+
"branches": ["main"],
|
|
58
|
+
"plugins": [
|
|
59
|
+
["@semantic-release/commit-analyzer", { "preset": "angular" }]
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
EOF
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
run() {
|
|
66
|
+
local name="$1"
|
|
67
|
+
echo "RUN $name"
|
|
68
|
+
if "$name"; then
|
|
69
|
+
PASS=$((PASS+1)); echo "PASS $name"
|
|
70
|
+
else
|
|
71
|
+
FAIL=$((FAIL+1)); FAILED+=("$name"); echo "FAIL $name"
|
|
72
|
+
fi
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# --- regression: idempotency -------------------------------------------------
|
|
76
|
+
|
|
77
|
+
test_when_invoked_twice_then_no_duplicate_unreleased_entries() {
|
|
78
|
+
local proj; proj="$(mktemp -d)"; trap "rm -rf $proj" RETURN
|
|
79
|
+
seed_idem_project "$proj" "idem-test"
|
|
80
|
+
if [ ! -f "$ACTUATOR" ]; then
|
|
81
|
+
fail "actuator not yet at $ACTUATOR — expected pre-implement RED state"
|
|
82
|
+
return 1
|
|
83
|
+
fi
|
|
84
|
+
# First invocation.
|
|
85
|
+
node "$ACTUATOR" --slug idem-test --project-root "$proj" >/dev/null 2>&1 \
|
|
86
|
+
|| { fail "first invocation exited non-zero"; return 1; }
|
|
87
|
+
local hash1; hash1="$(sha256sum "$proj/CHANGELOG.md" | awk '{print $1}')"
|
|
88
|
+
local state1; state1="$(cat "$proj/.claude/state/changelog/idem-test.json")"
|
|
89
|
+
# Sleep 1s so generated_at advances detectably.
|
|
90
|
+
sleep 1
|
|
91
|
+
# Second invocation (same slug, same git HEAD).
|
|
92
|
+
node "$ACTUATOR" --slug idem-test --project-root "$proj" >/dev/null 2>&1 \
|
|
93
|
+
|| { fail "second invocation exited non-zero"; return 1; }
|
|
94
|
+
local hash2; hash2="$(sha256sum "$proj/CHANGELOG.md" | awk '{print $1}')"
|
|
95
|
+
if [ "$hash1" != "$hash2" ]; then
|
|
96
|
+
fail "CHANGELOG.md changed on re-entry; hash1=$hash1 hash2=$hash2"
|
|
97
|
+
return 1
|
|
98
|
+
fi
|
|
99
|
+
# State file: contents excluding generated_at and unreleased_inserted_at MUST be byte-equal.
|
|
100
|
+
python3 - "$proj/.claude/state/changelog/idem-test.json" "$state1" <<'PY' || { fail "state file content drifted on re-entry"; return 1; }
|
|
101
|
+
import json, sys
|
|
102
|
+
new = json.load(open(sys.argv[1]))
|
|
103
|
+
old = json.loads(sys.argv[2])
|
|
104
|
+
for k in ('generated_at', 'unreleased_inserted_at'):
|
|
105
|
+
new.pop(k, None); old.pop(k, None)
|
|
106
|
+
if json.dumps(new, sort_keys=True) != json.dumps(old, sort_keys=True):
|
|
107
|
+
sys.exit(f'state diverged: new={new!r} old={old!r}')
|
|
108
|
+
PY
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# --- runner -------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
run test_when_invoked_twice_then_no_duplicate_unreleased_entries
|
|
114
|
+
|
|
115
|
+
echo "----"
|
|
116
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
117
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
118
|
+
echo "Failed tests:"
|
|
119
|
+
for t in "${FAILED[@]}"; do echo " - $t"; done
|
|
120
|
+
fi
|
|
121
|
+
exit $((FAIL > 0))
|
package/obj/template/.claude/skills/changelog/tests/keepachangelog-unreleased-preserved_test.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Integration test for AC-013: @semantic-release/changelog 6.0.3 SHALL
|
|
2
|
+
// preserve the "## [Unreleased]" heading at the top of CHANGELOG.md when it
|
|
3
|
+
// prepends a new versioned release block during the prepare step.
|
|
4
|
+
//
|
|
5
|
+
// Resolves the verification gap flagged in the spec: context7 did not
|
|
6
|
+
// document this seam, so this test answers the question empirically.
|
|
7
|
+
//
|
|
8
|
+
// Pre-implement RED note: this test imports @semantic-release/changelog
|
|
9
|
+
// (installed as a devDep at package.json:48) and the production actuator at
|
|
10
|
+
// .claude/skills/changelog/unreleased-writer.mjs. Until the actuator exists,
|
|
11
|
+
// the test errors on the import — the correct TDD failure mode.
|
|
12
|
+
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
const FIXTURE_CHANGELOG = `# Changelog
|
|
20
|
+
|
|
21
|
+
## [Unreleased]
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- A draft entry the local changelog skill inserted
|
|
25
|
+
|
|
26
|
+
## [0.1.0] - 2026-01-01
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- Initial release
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
async function setupFixture() {
|
|
33
|
+
const dir = await mkdtemp(join(tmpdir(), 'changelog-fixture-'));
|
|
34
|
+
await writeFile(join(dir, 'CHANGELOG.md'), FIXTURE_CHANGELOG, 'utf8');
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function cleanup(dir) {
|
|
39
|
+
await rm(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load the plugin. The plugin exports `verifyConditions` and `prepare` as
|
|
43
|
+
// named exports (ESM) or as properties on a CommonJS default (legacy). Try
|
|
44
|
+
// the modern shape first and fall back.
|
|
45
|
+
async function loadPlugin() {
|
|
46
|
+
try {
|
|
47
|
+
const mod = await import('@semantic-release/changelog');
|
|
48
|
+
if (typeof mod.prepare === 'function') return mod;
|
|
49
|
+
if (mod.default && typeof mod.default.prepare === 'function') return mod.default;
|
|
50
|
+
throw new Error('@semantic-release/changelog does not expose a prepare function in the expected shape');
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new Error(`failed to load @semantic-release/changelog: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A no-op logger that satisfies semantic-release's expected shape.
|
|
57
|
+
const noopLogger = {
|
|
58
|
+
log: () => {},
|
|
59
|
+
warn: () => {},
|
|
60
|
+
error: () => {},
|
|
61
|
+
success: () => {},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
test('AC-013: @semantic-release/changelog prepare leaves ## [Unreleased] in file but displaces it (motivates the fallback hook)', async () => {
|
|
65
|
+
// Empirical documentation test: confirms two facts about
|
|
66
|
+
// @semantic-release/changelog 6.0.3 prepare-step behavior:
|
|
67
|
+
// (1) it does NOT delete the ## [Unreleased] heading — the heading
|
|
68
|
+
// survives in the file body, so a fallback can find it.
|
|
69
|
+
// (2) it does NOT preserve top-of-file position — the plugin prepends
|
|
70
|
+
// `nextRelease.notes` ABOVE the existing # Changelog + ## [Unreleased]
|
|
71
|
+
// headings, displacing them downward.
|
|
72
|
+
// Together (1) + (2) motivate the second test in this file, which exercises
|
|
73
|
+
// the unreleased-writer.mjs `reinsertUnreleasedHeading` fallback that lifts
|
|
74
|
+
// the heading back to canonical top position.
|
|
75
|
+
const dir = await setupFixture();
|
|
76
|
+
try {
|
|
77
|
+
const plugin = await loadPlugin();
|
|
78
|
+
const context = {
|
|
79
|
+
cwd: dir,
|
|
80
|
+
env: { ...process.env },
|
|
81
|
+
branch: { name: 'main' },
|
|
82
|
+
lastRelease: { version: '0.1.0', gitTag: 'v0.1.0' },
|
|
83
|
+
nextRelease: {
|
|
84
|
+
type: 'minor',
|
|
85
|
+
version: '0.2.0',
|
|
86
|
+
gitTag: 'v0.2.0',
|
|
87
|
+
// Full release-notes payload as semantic-release's
|
|
88
|
+
// release-notes-generator would produce upstream of the changelog
|
|
89
|
+
// plugin: version heading + body.
|
|
90
|
+
notes: '## [0.2.0] - 2026-05-18\n\n### Added\n\n- A new feature shipped by the release pipeline',
|
|
91
|
+
},
|
|
92
|
+
logger: noopLogger,
|
|
93
|
+
};
|
|
94
|
+
await plugin.prepare({ changelogFile: 'CHANGELOG.md' }, context);
|
|
95
|
+
const after = await readFile(join(dir, 'CHANGELOG.md'), 'utf8');
|
|
96
|
+
// Fact (1): ## [Unreleased] heading survives somewhere in the file.
|
|
97
|
+
assert.ok(
|
|
98
|
+
after.includes('## [Unreleased]'),
|
|
99
|
+
`## [Unreleased] heading must survive in file (plugin must not delete it); got:\n${after}`,
|
|
100
|
+
);
|
|
101
|
+
// Fact (1, cont.): the new versioned block is in the file.
|
|
102
|
+
assert.ok(
|
|
103
|
+
after.includes('0.2.0'),
|
|
104
|
+
`CHANGELOG.md must contain new release version 0.2.0 after prepare; got:\n${after}`,
|
|
105
|
+
);
|
|
106
|
+
// Fact (2): the plugin prepends ABOVE the existing headings, so the
|
|
107
|
+
// 0.2.0 block ends up BEFORE the ## [Unreleased] heading. This is the
|
|
108
|
+
// exact behavior that motivates the fallback hook.
|
|
109
|
+
const unreleasedIdx = after.indexOf('## [Unreleased]');
|
|
110
|
+
const newReleaseIdx = after.indexOf('## [0.2.0]');
|
|
111
|
+
assert.ok(
|
|
112
|
+
newReleaseIdx >= 0 && unreleasedIdx > newReleaseIdx,
|
|
113
|
+
`plugin behavior contract: 0.2.0 block prepended ABOVE ## [Unreleased]; ` +
|
|
114
|
+
`expected 0.2.0@N < unreleased@M; got 0.2.0@${newReleaseIdx}, unreleased@${unreleasedIdx}; full:\n${after}`,
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
await cleanup(dir);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('AC-013 fallback: when prepare destroys Unreleased, our re-insertion path exists', async () => {
|
|
122
|
+
// If the plugin DOES strip the heading, our post-prepare hook re-inserts it.
|
|
123
|
+
// The hook is at .claude/skills/changelog/unreleased-writer.mjs and exports
|
|
124
|
+
// a reinsertUnreleasedHeading(changelogPath) function. Until implement
|
|
125
|
+
// lands, this import errors — the correct RED state.
|
|
126
|
+
const { reinsertUnreleasedHeading } = await import(
|
|
127
|
+
new URL('../unreleased-writer.mjs', import.meta.url).href
|
|
128
|
+
);
|
|
129
|
+
const dir = await setupFixture();
|
|
130
|
+
try {
|
|
131
|
+
// Synthesize the "plugin destroyed Unreleased" state by removing it.
|
|
132
|
+
const broken = FIXTURE_CHANGELOG.replace(/## \[Unreleased\][\s\S]*?(?=## \[)/, '');
|
|
133
|
+
await writeFile(join(dir, 'CHANGELOG.md'), broken, 'utf8');
|
|
134
|
+
await reinsertUnreleasedHeading(join(dir, 'CHANGELOG.md'));
|
|
135
|
+
const after = await readFile(join(dir, 'CHANGELOG.md'), 'utf8');
|
|
136
|
+
assert.ok(
|
|
137
|
+
after.includes('## [Unreleased]'),
|
|
138
|
+
`reinsertUnreleasedHeading must restore the ## [Unreleased] heading; got:\n${after}`,
|
|
139
|
+
);
|
|
140
|
+
// The Unreleased heading must be the FIRST ## heading in the file.
|
|
141
|
+
const firstH2 = after.match(/^## .+$/m);
|
|
142
|
+
assert.ok(
|
|
143
|
+
firstH2 && firstH2[0].includes('[Unreleased]'),
|
|
144
|
+
`first ## heading must be [Unreleased]; got: ${firstH2 ? firstH2[0] : '(none)'}`,
|
|
145
|
+
);
|
|
146
|
+
} finally {
|
|
147
|
+
await cleanup(dir);
|
|
148
|
+
}
|
|
149
|
+
});
|