@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,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
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fixture-based test for AC-003: when a project is not a git repository,
|
|
3
|
+
# /triage SHALL auto-except changelog alongside commit and the swarm-* phases,
|
|
4
|
+
# AND no Run /changelog task SHALL be seeded.
|
|
5
|
+
#
|
|
6
|
+
# Static-analysis test: reads the LIVE .claude/skills/triage/SKILL.md content
|
|
7
|
+
# (which the implement worker edits) and asserts on its content. Until the
|
|
8
|
+
# implement worker updates triage SKILL.md, this test fails RED.
|
|
9
|
+
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
13
|
+
REPO_ROOT="$(cd "$HERE/../../../.." && pwd)"
|
|
14
|
+
TRIAGE_SKILL="$REPO_ROOT/.claude/skills/triage/SKILL.md"
|
|
15
|
+
|
|
16
|
+
PASS=0; FAIL=0; FAILED=()
|
|
17
|
+
|
|
18
|
+
fail() { echo " FAIL: $*"; return 1; }
|
|
19
|
+
|
|
20
|
+
assert_file_contains() {
|
|
21
|
+
local path="$1" needle="$2" msg="$3"
|
|
22
|
+
if grep -qF "$needle" "$path" 2>/dev/null; then return 0; fi
|
|
23
|
+
fail "$msg :: file $path missing: $needle"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
assert_file_matches() {
|
|
27
|
+
local path="$1" pattern="$2" msg="$3"
|
|
28
|
+
if grep -qE "$pattern" "$path" 2>/dev/null; then return 0; fi
|
|
29
|
+
fail "$msg :: file $path missing pattern: $pattern"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
run() {
|
|
33
|
+
local name="$1"
|
|
34
|
+
echo "RUN $name"
|
|
35
|
+
if "$name"; then
|
|
36
|
+
PASS=$((PASS+1)); echo "PASS $name"
|
|
37
|
+
else
|
|
38
|
+
FAIL=$((FAIL+1)); FAILED+=("$name"); echo "FAIL $name"
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# --- AC-003 — static-analysis on triage SKILL.md -----------------------------
|
|
43
|
+
|
|
44
|
+
test_when_triage_skill_md_describes_nongit_then_changelog_in_exceptions_list() {
|
|
45
|
+
[ -f "$TRIAGE_SKILL" ] || { fail "AC-003 triage/SKILL.md missing"; return 1; }
|
|
46
|
+
# The non-git auto-except list MUST mention 'changelog' alongside the
|
|
47
|
+
# existing 'swarm-plan', 'approve-swarm', 'swarm-dispatch', 'grant-commit',
|
|
48
|
+
# 'commit' entries. The exact phrasing may vary; assert presence of the
|
|
49
|
+
# changelog token within reasonable proximity of the other tokens.
|
|
50
|
+
if ! grep -qE '"swarm-plan".*"swarm-dispatch".*"commit"' "$TRIAGE_SKILL"; then
|
|
51
|
+
fail "AC-003 baseline non-git auto-except list not found in triage SKILL.md"
|
|
52
|
+
return 1
|
|
53
|
+
fi
|
|
54
|
+
assert_file_contains "$TRIAGE_SKILL" '"changelog"' \
|
|
55
|
+
"AC-003 triage SKILL.md non-git auto-except list must include \"changelog\"" \
|
|
56
|
+
|| return 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test_when_triage_task_templates_include_changelog_row_between_grant_commit_and_commit() {
|
|
60
|
+
[ -f "$TRIAGE_SKILL" ] || { fail "AC-003 triage/SKILL.md missing"; return 1; }
|
|
61
|
+
# Each non-chore template (tdd-entry, spec-entry, intake-entry) AND the
|
|
62
|
+
# chore template SHOULD include a Run /changelog task row between
|
|
63
|
+
# "Wait for /grant-commit" and "Run /commit". The exact prose varies but the
|
|
64
|
+
# ordering must hold.
|
|
65
|
+
# Strategy: extract the prose between "Wait for /grant-commit" and "Run /commit"
|
|
66
|
+
# blocks and require "changelog" to appear within that slice.
|
|
67
|
+
if ! python3 - "$TRIAGE_SKILL" <<'PY'
|
|
68
|
+
import re, sys
|
|
69
|
+
path = sys.argv[1]
|
|
70
|
+
text = open(path).read()
|
|
71
|
+
# Find every occurrence of "Wait for /grant-commit" followed by content up to "Run /commit"
|
|
72
|
+
matches = list(re.finditer(
|
|
73
|
+
r'Wait for /grant-commit[\s\S]*?Run /commit', text))
|
|
74
|
+
if not matches:
|
|
75
|
+
sys.exit('no "Wait for /grant-commit" → "Run /commit" sequence found in triage SKILL.md')
|
|
76
|
+
# Every such slice must mention "changelog".
|
|
77
|
+
missing = [i for i, m in enumerate(matches) if 'changelog' not in m.group(0).lower()]
|
|
78
|
+
if missing:
|
|
79
|
+
sys.exit(f'{len(missing)} task-seeding slice(s) missing changelog: indices {missing}')
|
|
80
|
+
PY
|
|
81
|
+
then
|
|
82
|
+
fail "AC-003 triage SKILL.md task-seeding templates must include changelog between grant-commit and commit"
|
|
83
|
+
return 1
|
|
84
|
+
fi
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# --- runner -------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
run test_when_triage_skill_md_describes_nongit_then_changelog_in_exceptions_list
|
|
90
|
+
run test_when_triage_task_templates_include_changelog_row_between_grant_commit_and_commit
|
|
91
|
+
|
|
92
|
+
echo "----"
|
|
93
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
94
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
95
|
+
echo "Failed tests:"
|
|
96
|
+
for t in "${FAILED[@]}"; do echo " - $t"; done
|
|
97
|
+
fi
|
|
98
|
+
exit $((FAIL > 0))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fixture-based integration test for AC-012: the changelog actuator's
|
|
3
|
+
# --preview-only mode prints projected next semver + draft fragment to stdout,
|
|
4
|
+
# requires no commit_consent gesture, and writes no files.
|
|
5
|
+
#
|
|
6
|
+
# Pre-implement RED: actuator does not exist; test fails on missing file.
|
|
7
|
+
|
|
8
|
+
set -uo pipefail
|
|
9
|
+
|
|
10
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
11
|
+
REPO_ROOT="$(cd "$HERE/../../../.." && pwd)"
|
|
12
|
+
ACTUATOR="$REPO_ROOT/.claude/skills/changelog/changelog.mjs"
|
|
13
|
+
|
|
14
|
+
PASS=0; FAIL=0; FAILED=()
|
|
15
|
+
|
|
16
|
+
fail() { echo " FAIL: $*"; return 1; }
|
|
17
|
+
|
|
18
|
+
# Build a tempdir project WITHOUT commit_consent (preview must work without it).
|
|
19
|
+
seed_preview_project() {
|
|
20
|
+
local proj="$1"
|
|
21
|
+
mkdir -p "$proj/.claude/state"
|
|
22
|
+
cd "$proj"
|
|
23
|
+
git init -q
|
|
24
|
+
git config user.email "test@example.com"
|
|
25
|
+
git config user.name "Test"
|
|
26
|
+
git commit --allow-empty -q -m "chore: initial"
|
|
27
|
+
git tag v0.1.0
|
|
28
|
+
echo "preview" > thing.txt
|
|
29
|
+
git add thing.txt
|
|
30
|
+
git commit -q -m "feat: preview path"
|
|
31
|
+
# NO commit_consent file.
|
|
32
|
+
# NO workflow.json — preview must not require one.
|
|
33
|
+
cat > "$proj/.releaserc.json" <<'EOF'
|
|
34
|
+
{
|
|
35
|
+
"branches": ["main"],
|
|
36
|
+
"plugins": [
|
|
37
|
+
["@semantic-release/commit-analyzer", { "preset": "angular" }]
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
EOF
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
run() {
|
|
44
|
+
local name="$1"
|
|
45
|
+
echo "RUN $name"
|
|
46
|
+
if "$name"; then
|
|
47
|
+
PASS=$((PASS+1)); echo "PASS $name"
|
|
48
|
+
else
|
|
49
|
+
FAIL=$((FAIL+1)); FAILED+=("$name"); echo "FAIL $name"
|
|
50
|
+
fi
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# --- AC-012 -------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
test_when_preview_only_flag_then_stdout_projection_no_writes() {
|
|
56
|
+
local proj; proj="$(mktemp -d)"; trap "rm -rf $proj" RETURN
|
|
57
|
+
seed_preview_project "$proj"
|
|
58
|
+
if [ ! -f "$ACTUATOR" ]; then
|
|
59
|
+
fail "AC-012 actuator not yet at $ACTUATOR — expected pre-implement RED state"
|
|
60
|
+
return 1
|
|
61
|
+
fi
|
|
62
|
+
local out
|
|
63
|
+
out="$(node "$ACTUATOR" --preview-only --slug demo --project-root "$proj" 2>/tmp/preview-stderr.$$)"
|
|
64
|
+
local ec=$?
|
|
65
|
+
if [ "$ec" -ne 0 ]; then
|
|
66
|
+
fail "AC-012 preview-only must exit 0; got $ec; stderr: $(cat /tmp/preview-stderr.$$)"
|
|
67
|
+
return 1
|
|
68
|
+
fi
|
|
69
|
+
# stdout matches Projected: <semver>.
|
|
70
|
+
if ! printf '%s' "$out" | grep -qE 'Projected:\s*[0-9]+\.[0-9]+\.[0-9]+'; then
|
|
71
|
+
fail "AC-012 stdout must contain Projected: <semver>; got: $out"
|
|
72
|
+
return 1
|
|
73
|
+
fi
|
|
74
|
+
# No state file written.
|
|
75
|
+
if [ -f "$proj/.claude/state/changelog/demo.json" ]; then
|
|
76
|
+
fail "AC-012 preview-only must NOT write state file"
|
|
77
|
+
return 1
|
|
78
|
+
fi
|
|
79
|
+
# CHANGELOG.md is either absent (we never created one) or unchanged.
|
|
80
|
+
if [ -f "$proj/CHANGELOG.md" ]; then
|
|
81
|
+
fail "AC-012 preview-only must NOT create CHANGELOG.md when absent"
|
|
82
|
+
return 1
|
|
83
|
+
fi
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# --- runner -------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
run test_when_preview_only_flag_then_stdout_projection_no_writes
|
|
89
|
+
|
|
90
|
+
echo "----"
|
|
91
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
92
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
93
|
+
echo "Failed tests:"
|
|
94
|
+
for t in "${FAILED[@]}"; do echo " - $t"; done
|
|
95
|
+
fi
|
|
96
|
+
exit $((FAIL > 0))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Aggregate test runner for .claude/skills/changelog/.
|
|
3
|
+
# Invokes each sibling *_test.sh AND any *_test.mjs (node --test) and exits
|
|
4
|
+
# non-zero if any fail.
|
|
5
|
+
|
|
6
|
+
set -uo pipefail
|
|
7
|
+
|
|
8
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
FAIL=0
|
|
10
|
+
|
|
11
|
+
for t in "$HERE"/*_test.sh; do
|
|
12
|
+
[ -f "$t" ] || continue
|
|
13
|
+
echo "=== $(basename "$t") ==="
|
|
14
|
+
bash "$t" || FAIL=$((FAIL+1))
|
|
15
|
+
done
|
|
16
|
+
|
|
17
|
+
for t in "$HERE"/*_test.mjs; do
|
|
18
|
+
[ -f "$t" ] || continue
|
|
19
|
+
echo "=== $(basename "$t") ==="
|
|
20
|
+
node --test "$t" || FAIL=$((FAIL+1))
|
|
21
|
+
done
|
|
22
|
+
|
|
23
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
24
|
+
echo "changelog/tests: $FAIL suite(s) failed"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
echo "changelog/tests: all suites passed"
|
|
28
|
+
exit 0
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// CHANGELOG.md curation under ## [Unreleased].
|
|
2
|
+
//
|
|
3
|
+
// Two exports:
|
|
4
|
+
// appendUnderUnreleased(changelogPath, entries) — per-commit RMW.
|
|
5
|
+
// reinsertUnreleasedHeading(changelogPath) — AC-013 fallback for the
|
|
6
|
+
// case where @semantic-release/changelog has prepended versioned notes
|
|
7
|
+
// above the `# Changelog` and `## [Unreleased]` headings. Restores the
|
|
8
|
+
// canonical order: `# Changelog\n\n## [Unreleased]\n\n<rest>`.
|
|
9
|
+
//
|
|
10
|
+
// File shape we maintain (keepachangelog 1.0.0):
|
|
11
|
+
//
|
|
12
|
+
// # Changelog
|
|
13
|
+
//
|
|
14
|
+
// ## [Unreleased]
|
|
15
|
+
//
|
|
16
|
+
// ### Added
|
|
17
|
+
// - <entry>
|
|
18
|
+
//
|
|
19
|
+
// ### Fixed
|
|
20
|
+
// - <entry>
|
|
21
|
+
//
|
|
22
|
+
// ## [0.1.0] - 2026-01-01
|
|
23
|
+
//
|
|
24
|
+
// ...
|
|
25
|
+
|
|
26
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { existsSync } from 'node:fs';
|
|
28
|
+
|
|
29
|
+
const UNRELEASED_HEADING = '## [Unreleased]';
|
|
30
|
+
const CHANGELOG_HEADING = '# Changelog';
|
|
31
|
+
|
|
32
|
+
const CATEGORY_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security'];
|
|
33
|
+
|
|
34
|
+
function groupBySection(entries) {
|
|
35
|
+
const map = new Map();
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (!map.has(entry.section)) map.set(entry.section, []);
|
|
38
|
+
map.get(entry.section).push(entry);
|
|
39
|
+
}
|
|
40
|
+
return map;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderUnreleasedBody(entries) {
|
|
44
|
+
if (entries.length === 0) return '';
|
|
45
|
+
const grouped = groupBySection(entries);
|
|
46
|
+
const lines = [];
|
|
47
|
+
for (const section of CATEGORY_ORDER) {
|
|
48
|
+
const items = grouped.get(section);
|
|
49
|
+
if (!items || items.length === 0) continue;
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(`### ${section}`);
|
|
52
|
+
lines.push('');
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const prefix = item.breaking ? '**BREAKING:** ' : '';
|
|
55
|
+
lines.push(`- ${prefix}${item.body}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lines.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Split text into { preamble, unreleasedBody, rest } where preamble is
|
|
62
|
+
// everything up to and including the `## [Unreleased]` line, unreleasedBody
|
|
63
|
+
// is the content between that and the next `##` heading, and rest is from
|
|
64
|
+
// the next `##` heading onward.
|
|
65
|
+
function splitAroundUnreleased(text) {
|
|
66
|
+
const unreleasedIdx = text.indexOf(UNRELEASED_HEADING);
|
|
67
|
+
if (unreleasedIdx < 0) return null;
|
|
68
|
+
const afterHeading = text.indexOf('\n', unreleasedIdx);
|
|
69
|
+
const headingEnd = afterHeading < 0 ? text.length : afterHeading + 1;
|
|
70
|
+
// Find the next `## ` heading after the Unreleased heading.
|
|
71
|
+
const restMatch = text.slice(headingEnd).match(/\n## [^\n]+\n/);
|
|
72
|
+
const restOffset = restMatch ? headingEnd + restMatch.index + 1 : text.length;
|
|
73
|
+
return {
|
|
74
|
+
preamble: text.slice(0, headingEnd),
|
|
75
|
+
unreleasedBody: text.slice(headingEnd, restOffset),
|
|
76
|
+
rest: text.slice(restOffset),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function defaultChangelogText() {
|
|
81
|
+
return `# Changelog\n\n## [Unreleased]\n\n`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function appendUnderUnreleased(changelogPath, entries) {
|
|
85
|
+
let text;
|
|
86
|
+
if (existsSync(changelogPath)) {
|
|
87
|
+
text = await readFile(changelogPath, 'utf8');
|
|
88
|
+
} else {
|
|
89
|
+
text = defaultChangelogText();
|
|
90
|
+
}
|
|
91
|
+
if (!text.includes(CHANGELOG_HEADING)) {
|
|
92
|
+
text = `${CHANGELOG_HEADING}\n\n${text}`;
|
|
93
|
+
}
|
|
94
|
+
if (!text.includes(UNRELEASED_HEADING)) {
|
|
95
|
+
text = text.replace(
|
|
96
|
+
new RegExp(`(${CHANGELOG_HEADING}\\n)`, ''),
|
|
97
|
+
`$1\n${UNRELEASED_HEADING}\n\n`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const parts = splitAroundUnreleased(text);
|
|
101
|
+
if (!parts) {
|
|
102
|
+
// Defensive: should be unreachable after the insertions above.
|
|
103
|
+
throw new Error(`could not locate ${UNRELEASED_HEADING} in ${changelogPath}`);
|
|
104
|
+
}
|
|
105
|
+
const body = renderUnreleasedBody(entries);
|
|
106
|
+
const merged = `${parts.preamble}${body}\n${parts.rest.startsWith('\n') ? parts.rest : '\n' + parts.rest}`;
|
|
107
|
+
await writeFile(changelogPath, merged, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function reinsertUnreleasedHeading(changelogPath) {
|
|
111
|
+
const text = await readFile(changelogPath, 'utf8');
|
|
112
|
+
// If the first `##` heading in the file is already `## [Unreleased]`, the
|
|
113
|
+
// file is structurally correct; do nothing.
|
|
114
|
+
const firstH2 = text.match(/^## .+$/m);
|
|
115
|
+
if (firstH2 && firstH2[0].includes('[Unreleased]')) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Otherwise: find the existing Unreleased heading (which may sit deeper in
|
|
119
|
+
// the file because @semantic-release/changelog prepended notes above it)
|
|
120
|
+
// and lift it to the canonical top position.
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
// Identify the Unreleased section's start and the next `##` start.
|
|
123
|
+
let unreleasedStart = -1;
|
|
124
|
+
let unreleasedEnd = -1;
|
|
125
|
+
for (let i = 0; i < lines.length; i++) {
|
|
126
|
+
if (unreleasedStart < 0 && lines[i] === UNRELEASED_HEADING) {
|
|
127
|
+
unreleasedStart = i;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (unreleasedStart >= 0 && /^## /.test(lines[i])) {
|
|
131
|
+
unreleasedEnd = i;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (unreleasedStart < 0) {
|
|
136
|
+
// No Unreleased heading anywhere; insert a fresh one at the top.
|
|
137
|
+
const top = `${CHANGELOG_HEADING}\n\n${UNRELEASED_HEADING}\n\n`;
|
|
138
|
+
await writeFile(changelogPath, top + text, 'utf8');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (unreleasedEnd < 0) unreleasedEnd = lines.length;
|
|
142
|
+
const unreleasedBlock = lines.slice(unreleasedStart, unreleasedEnd);
|
|
143
|
+
const without = lines.slice(0, unreleasedStart).concat(lines.slice(unreleasedEnd));
|
|
144
|
+
// Strip any leading blank lines from the without-block so the result starts
|
|
145
|
+
// with the `# Changelog` heading (we'll insert one if absent).
|
|
146
|
+
const withoutText = without.join('\n');
|
|
147
|
+
const head = withoutText.startsWith(CHANGELOG_HEADING)
|
|
148
|
+
? withoutText
|
|
149
|
+
: `${CHANGELOG_HEADING}\n\n${withoutText.replace(/^\n+/, '')}`;
|
|
150
|
+
const restored = head.replace(
|
|
151
|
+
new RegExp(`^(${CHANGELOG_HEADING})\\n\\n?`),
|
|
152
|
+
`$1\n\n${unreleasedBlock.join('\n')}\n\n`,
|
|
153
|
+
);
|
|
154
|
+
await writeFile(changelogPath, restored, 'utf8');
|
|
155
|
+
}
|