@skitterbyte/skitterspec 1.0.0 → 2.0.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 +27 -244
- package/assets/claude-md-section.md +0 -6
- package/assets/core/env.config.json.example +5 -1
- package/assets/core/env.config.md +21 -5
- package/assets/rules/spec-planning.md +14 -10
- package/assets/skills/spec/SKILL.md +11 -38
- package/assets/skills/spec-complete/SKILL.md +31 -4
- package/assets/skills/spec-env/SKILL.md +6 -0
- package/assets/skills/spec-env-down/SKILL.md +16 -8
- package/assets/skills/spec-go/SKILL.md +15 -17
- package/package.json +6 -11
- package/src/cli.js +174 -318
- package/src/deprecate.js +138 -0
- package/src/env/config.js +17 -4
- package/src/env/integrate.js +46 -0
- package/src/env/resolve.js +54 -45
- package/src/env/teardown.js +19 -4
- package/src/env/trust.js +87 -0
- package/src/init.js +78 -170
- package/src/prompts.js +26 -63
- package/LICENSE +0 -21
- package/assets/core/linear.config.json.example +0 -39
- package/assets/core/linear.config.md +0 -121
- package/assets/rules/commit-messages.md +0 -85
- package/assets/scripts/generate-changelog.js +0 -274
- package/assets/scripts/generate-releases.js +0 -360
- package/assets/scripts/lib/config.js +0 -127
- package/assets/scripts/lib/git-commits.js +0 -265
- package/assets/skills/commit/SKILL.md +0 -28
- package/assets/skills/spec-pull/SKILL.md +0 -46
- package/assets/skills/spec-push/SKILL.md +0 -53
- package/assets/skills/spec-status/SKILL.md +0 -46
- package/src/config.js +0 -13
- package/src/sync/apply.js +0 -66
- package/src/sync/base.js +0 -83
- package/src/sync/compare.js +0 -99
- package/src/sync/config.js +0 -198
- package/src/sync/mcp.js +0 -112
- package/src/sync/normalize.js +0 -249
- package/src/sync/pull.js +0 -84
- package/src/sync/push.js +0 -106
- package/src/sync/write.js +0 -86
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Generate a dev-facing CHANGELOG from git commits using conventional commits.
|
|
6
|
-
* Run manually or wired into the versioning process. Sibling to
|
|
7
|
-
* generate-releases.js (user-facing notes from `Release-Note:` footers).
|
|
8
|
-
*
|
|
9
|
-
* The output filename is injected (defaults to CHANGELOG.md). skitterspec's
|
|
10
|
-
* config loader supplies it in production; the pure functions below are
|
|
11
|
-
* filename-agnostic and unit-testable on their own.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const { readFileSync, writeFileSync } = require('node:fs')
|
|
15
|
-
const { join } = require('node:path')
|
|
16
|
-
|
|
17
|
-
const {
|
|
18
|
-
escapeRegex,
|
|
19
|
-
getAllVersionTags,
|
|
20
|
-
getCommitsBetween,
|
|
21
|
-
getCommitsSinceLastTag,
|
|
22
|
-
getTagDate,
|
|
23
|
-
parseCommit,
|
|
24
|
-
} = require('./lib/git-commits.js')
|
|
25
|
-
const { loadConfig } = require('./lib/config.js')
|
|
26
|
-
|
|
27
|
-
const DEFAULT_FILE = 'CHANGELOG.md'
|
|
28
|
-
|
|
29
|
-
function categorizeCommits(commits) {
|
|
30
|
-
const categories = {
|
|
31
|
-
added: [],
|
|
32
|
-
changed: [],
|
|
33
|
-
deprecated: [],
|
|
34
|
-
removed: [],
|
|
35
|
-
fixed: [],
|
|
36
|
-
security: [],
|
|
37
|
-
other: [],
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
for (const commit of commits) {
|
|
41
|
-
if (commit.breaking) {
|
|
42
|
-
// Breaking changes always land under Changed regardless of type.
|
|
43
|
-
categories.changed.push(commit)
|
|
44
|
-
continue
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
switch (commit.type) {
|
|
48
|
-
case 'feat':
|
|
49
|
-
categories.added.push(commit)
|
|
50
|
-
break
|
|
51
|
-
case 'fix':
|
|
52
|
-
categories.fixed.push(commit)
|
|
53
|
-
break
|
|
54
|
-
case 'perf':
|
|
55
|
-
case 'refactor':
|
|
56
|
-
categories.changed.push(commit)
|
|
57
|
-
break
|
|
58
|
-
case 'docs':
|
|
59
|
-
case 'style':
|
|
60
|
-
case 'test':
|
|
61
|
-
case 'chore':
|
|
62
|
-
case 'build':
|
|
63
|
-
case 'ci':
|
|
64
|
-
// Non-user-facing — skipped from the changelog.
|
|
65
|
-
break
|
|
66
|
-
default:
|
|
67
|
-
categories.other.push(commit)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return categories
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function formatChangelogEntry(entry) {
|
|
75
|
-
const scope = entry.scope ? `**${entry.scope}**: ` : ''
|
|
76
|
-
return `- ${scope}${entry.message}`
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function generateChangelogSection(version, date, categories) {
|
|
80
|
-
const sections = []
|
|
81
|
-
|
|
82
|
-
sections.push(`## [${version}] - ${date}\n`)
|
|
83
|
-
|
|
84
|
-
const ordered = [
|
|
85
|
-
['Added', categories.added],
|
|
86
|
-
['Changed', categories.changed],
|
|
87
|
-
['Deprecated', categories.deprecated],
|
|
88
|
-
['Removed', categories.removed],
|
|
89
|
-
['Fixed', categories.fixed],
|
|
90
|
-
['Security', categories.security],
|
|
91
|
-
]
|
|
92
|
-
|
|
93
|
-
for (const [heading, entries] of ordered) {
|
|
94
|
-
if (entries.length > 0) {
|
|
95
|
-
sections.push(`### ${heading}`)
|
|
96
|
-
entries.forEach((entry) => {
|
|
97
|
-
sections.push(formatChangelogEntry(entry))
|
|
98
|
-
})
|
|
99
|
-
sections.push('')
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return sections.join('\n')
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function getCurrentVersion() {
|
|
107
|
-
try {
|
|
108
|
-
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
|
|
109
|
-
return packageJson.version
|
|
110
|
-
} catch {
|
|
111
|
-
console.error('Error reading package.json')
|
|
112
|
-
process.exit(1)
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const DEFAULT_HEADER = `# Changelog
|
|
117
|
-
|
|
118
|
-
All notable changes to this project will be documented in this file.
|
|
119
|
-
|
|
120
|
-
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
121
|
-
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
function upsertSection(changelogContent, newSection, version) {
|
|
125
|
-
const existingRegex = new RegExp(
|
|
126
|
-
`(^|\\n)## \\[${escapeRegex(version)}\\][^\\n]*\\n[\\s\\S]*?(?=\\n## \\[|\\n---|$)`,
|
|
127
|
-
)
|
|
128
|
-
const existingMatch = changelogContent.match(existingRegex)
|
|
129
|
-
|
|
130
|
-
if (existingMatch) {
|
|
131
|
-
const leading = existingMatch[1] ?? ''
|
|
132
|
-
return changelogContent.replace(existingRegex, `${leading}${newSection.trimEnd()}\n`)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const unreleasedRegex = /## \[Unreleased\][\s\S]*?(?=\n## \[|$)/
|
|
136
|
-
const unreleasedMatch = changelogContent.match(unreleasedRegex)
|
|
137
|
-
if (unreleasedMatch && unreleasedMatch.index !== undefined) {
|
|
138
|
-
const insertPos = unreleasedMatch.index + unreleasedMatch[0].length
|
|
139
|
-
const before = changelogContent.slice(0, insertPos).replace(/\s+$/, '')
|
|
140
|
-
const after = changelogContent.slice(insertPos).replace(/^\s+/, '')
|
|
141
|
-
return `${before}\n\n${newSection}\n${after ? `${after}\n` : ''}`
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const firstVersionIdx = changelogContent.search(/\n## \[/)
|
|
145
|
-
if (firstVersionIdx >= 0) {
|
|
146
|
-
const before = changelogContent.slice(0, firstVersionIdx).replace(/\s+$/, '')
|
|
147
|
-
const after = changelogContent.slice(firstVersionIdx + 1)
|
|
148
|
-
return `${before}\n\n${newSection}\n${after}`
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return `${changelogContent.replace(/\s+$/, '')}\n\n${newSection}`
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function readChangelog(path) {
|
|
155
|
-
try {
|
|
156
|
-
return readFileSync(path, 'utf-8')
|
|
157
|
-
} catch {
|
|
158
|
-
return DEFAULT_HEADER
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function updateChangelog(newVersion, options = {}) {
|
|
163
|
-
const file = options.file || DEFAULT_FILE
|
|
164
|
-
const changelogPath = join(process.cwd(), file)
|
|
165
|
-
let changelogContent = readChangelog(changelogPath)
|
|
166
|
-
|
|
167
|
-
const commitLines = getCommitsSinceLastTag(newVersion)
|
|
168
|
-
const commits = commitLines.map(parseCommit).filter((commit) => commit !== null)
|
|
169
|
-
|
|
170
|
-
if (commits.length === 0) {
|
|
171
|
-
console.log(`No conventional commits found since last tag — skipping ${file} update`)
|
|
172
|
-
return
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const categories = categorizeCommits(commits)
|
|
176
|
-
const date = new Date().toISOString().split('T')[0]
|
|
177
|
-
const newSection = generateChangelogSection(newVersion, date, categories).trimEnd() + '\n'
|
|
178
|
-
|
|
179
|
-
changelogContent = upsertSection(changelogContent, newSection, newVersion)
|
|
180
|
-
|
|
181
|
-
writeFileSync(changelogPath, changelogContent, 'utf-8')
|
|
182
|
-
console.log(`✅ Updated ${file} with version ${newVersion}`)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function retroFillChangelog(count, options = {}) {
|
|
186
|
-
const file = options.file || DEFAULT_FILE
|
|
187
|
-
const changelogPath = join(process.cwd(), file)
|
|
188
|
-
let changelogContent = readChangelog(changelogPath)
|
|
189
|
-
|
|
190
|
-
const tags = getAllVersionTags()
|
|
191
|
-
if (tags.length === 0) {
|
|
192
|
-
console.log('No version tags found — nothing to retro-fill')
|
|
193
|
-
return
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Walk newest → oldest; for each tag, compute commits since the previous tag.
|
|
197
|
-
// Write oldest-first so that when we upsert, the newest ends up on top.
|
|
198
|
-
const targets = tags.slice(0, count).reverse()
|
|
199
|
-
let updated = 0
|
|
200
|
-
|
|
201
|
-
for (const tag of targets) {
|
|
202
|
-
const idx = tags.indexOf(tag)
|
|
203
|
-
const previousTag = idx < tags.length - 1 ? tags[idx + 1] : null
|
|
204
|
-
const version = tag.replace(/^v/, '')
|
|
205
|
-
const commits = getCommitsBetween(previousTag, tag)
|
|
206
|
-
.map(parseCommit)
|
|
207
|
-
.filter((c) => c !== null)
|
|
208
|
-
|
|
209
|
-
if (commits.length === 0) {
|
|
210
|
-
console.log(`⚠️ ${tag}: no conventional commits — skipping`)
|
|
211
|
-
continue
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const categories = categorizeCommits(commits)
|
|
215
|
-
const date = getTagDate(tag)
|
|
216
|
-
const section = generateChangelogSection(version, date, categories).trimEnd() + '\n'
|
|
217
|
-
changelogContent = upsertSection(changelogContent, section, version)
|
|
218
|
-
updated += 1
|
|
219
|
-
console.log(`✅ ${tag}: wrote ${commits.length} commit(s)`)
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (updated > 0) {
|
|
223
|
-
writeFileSync(changelogPath, changelogContent, 'utf-8')
|
|
224
|
-
console.log(`✅ Retro-filled ${updated} release(s) into ${file}`)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function main(argv) {
|
|
229
|
-
const args = argv.slice(2)
|
|
230
|
-
|
|
231
|
-
let config
|
|
232
|
-
try {
|
|
233
|
-
config = loadConfig()
|
|
234
|
-
} catch (error) {
|
|
235
|
-
console.error(error.message)
|
|
236
|
-
process.exit(1)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
if (!config.changelog.enabled) {
|
|
240
|
-
console.log('Changelog generation disabled in skitterspec.config.json — skipping')
|
|
241
|
-
return
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const options = { file: config.changelog.file }
|
|
245
|
-
const retroIdx = args.indexOf('--retro')
|
|
246
|
-
|
|
247
|
-
if (retroIdx >= 0) {
|
|
248
|
-
const countArg = args[retroIdx + 1]
|
|
249
|
-
const count = Number.parseInt(countArg ?? '', 10)
|
|
250
|
-
if (!Number.isFinite(count) || count <= 0) {
|
|
251
|
-
console.error('Usage: generate-changelog.js --retro <count>')
|
|
252
|
-
process.exit(1)
|
|
253
|
-
}
|
|
254
|
-
retroFillChangelog(count, options)
|
|
255
|
-
} else {
|
|
256
|
-
const version = args[0] || getCurrentVersion()
|
|
257
|
-
updateChangelog(version, options)
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
module.exports = {
|
|
262
|
-
categorizeCommits,
|
|
263
|
-
formatChangelogEntry,
|
|
264
|
-
generateChangelogSection,
|
|
265
|
-
upsertSection,
|
|
266
|
-
updateChangelog,
|
|
267
|
-
retroFillChangelog,
|
|
268
|
-
DEFAULT_HEADER,
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Run the CLI only when invoked directly (keeps pure functions importable).
|
|
272
|
-
if (require.main === module) {
|
|
273
|
-
main(process.argv)
|
|
274
|
-
}
|
|
@@ -1,360 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Generate user-facing release notes from `Release-Note:` commit footers.
|
|
6
|
-
* Sibling to generate-changelog.js (which builds the dev-facing CHANGELOG from
|
|
7
|
-
* commit subjects). Both walk the same tag ranges via lib/git-commits.js.
|
|
8
|
-
*
|
|
9
|
-
* Opt-in: ONLY commits carrying a `Release-Note:` footer appear here. The dev
|
|
10
|
-
* subject stays terse; the footer carries the user-facing sentence.
|
|
11
|
-
*
|
|
12
|
-
* feat(tasks): explicit state/created dates + sort-by
|
|
13
|
-
*
|
|
14
|
-
* - Add stateEnteredAt column, sortBy param
|
|
15
|
-
*
|
|
16
|
-
* Release-Note: You can now sort your task inbox by when an item entered its
|
|
17
|
-
* current state or when it was created, with both dates shown on every row.
|
|
18
|
-
*
|
|
19
|
-
* Footer grammar:
|
|
20
|
-
* Release-Note: <text> user-facing note (multi-line via continuation)
|
|
21
|
-
* Release-Note!: <text> same, but also promoted into the Highlights line
|
|
22
|
-
* Release-Area: <name> override the scope->area mapping
|
|
23
|
-
* Release-Note: none explicit "not user-facing" (skipped)
|
|
24
|
-
*
|
|
25
|
-
* Project-specific values — the scope→area map, the product name in the header,
|
|
26
|
-
* and the output filename — are injected (skitterspec's config loader supplies
|
|
27
|
-
* them in production). Unmapped scopes fall back to Title-Case of the scope.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
|
|
31
|
-
const { basename, join } = require('node:path')
|
|
32
|
-
|
|
33
|
-
const {
|
|
34
|
-
escapeRegex,
|
|
35
|
-
getAllVersionTags,
|
|
36
|
-
getCommitsBetween,
|
|
37
|
-
getCommitsSinceLastTag,
|
|
38
|
-
getTagDate,
|
|
39
|
-
parseCommit,
|
|
40
|
-
} = require('./lib/git-commits.js')
|
|
41
|
-
const { loadConfig } = require('./lib/config.js')
|
|
42
|
-
|
|
43
|
-
// Buckets render in this order within each area.
|
|
44
|
-
const BUCKET_ORDER = ['Action required', 'New', 'Improved', 'Fixed']
|
|
45
|
-
|
|
46
|
-
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
47
|
-
|
|
48
|
-
const DEFAULT_FILE = 'RELEASES.md'
|
|
49
|
-
|
|
50
|
-
/** A line that looks like a footer key: `Key: ` (stops note continuation). */
|
|
51
|
-
const FOOTER_KEY = /^[A-Za-z][\w-]*:\s/
|
|
52
|
-
|
|
53
|
-
function defaultProductName() {
|
|
54
|
-
return basename(process.cwd())
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function defaultReleasesHeader(productName, changelogFile = 'CHANGELOG.md') {
|
|
58
|
-
return `# Release Notes
|
|
59
|
-
|
|
60
|
-
What's new for users of ${productName}. For the full technical log see
|
|
61
|
-
[${changelogFile}](./${changelogFile}).
|
|
62
|
-
|
|
63
|
-
Generated from \`Release-Note:\` commit footers.
|
|
64
|
-
`
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Map a conventional type (+breaking flag) to a user bucket, or null to omit. */
|
|
68
|
-
function bucketFor(type, breaking) {
|
|
69
|
-
if (breaking) return 'Action required'
|
|
70
|
-
switch (type) {
|
|
71
|
-
case 'feat':
|
|
72
|
-
return 'New'
|
|
73
|
-
case 'fix':
|
|
74
|
-
return 'Fixed'
|
|
75
|
-
case 'perf':
|
|
76
|
-
case 'refactor':
|
|
77
|
-
return 'Improved'
|
|
78
|
-
default:
|
|
79
|
-
// docs / style / test / chore / build / ci / unknown → never user-facing
|
|
80
|
-
return null
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function titleCase(value) {
|
|
85
|
-
return value
|
|
86
|
-
.split(/[-_\s]+/)
|
|
87
|
-
.filter(Boolean)
|
|
88
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
89
|
-
.join(' ')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Resolve the user-facing area: explicit override wins, else the injected
|
|
94
|
-
* scope→area map, else Title-Case of the scope. Missing scope → 'General'.
|
|
95
|
-
*/
|
|
96
|
-
function resolveArea(scope, override, scopeAreas = {}) {
|
|
97
|
-
if (override && override.trim()) return override.trim()
|
|
98
|
-
if (!scope) return 'General'
|
|
99
|
-
return scopeAreas[scope.toLowerCase()] ?? titleCase(scope)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Extract the user-facing note from a parsed commit, or null if the commit has
|
|
104
|
-
* no `Release-Note:` footer, is marked `none`, or is a non-user-facing type.
|
|
105
|
-
*/
|
|
106
|
-
function parseReleaseNote(commit, scopeAreas = {}) {
|
|
107
|
-
const bucket = bucketFor(commit.type, commit.breaking)
|
|
108
|
-
if (!bucket) return null // omitted type — never user-facing, even with a footer
|
|
109
|
-
|
|
110
|
-
const body = commit.body
|
|
111
|
-
if (!body) return null
|
|
112
|
-
|
|
113
|
-
const lines = body.split('\n')
|
|
114
|
-
let noteText = null
|
|
115
|
-
let highlight = false
|
|
116
|
-
let areaOverride
|
|
117
|
-
|
|
118
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
119
|
-
const noteMatch = lines[i].match(/^Release-Note(!)?:\s*(.*)$/i)
|
|
120
|
-
if (noteMatch) {
|
|
121
|
-
highlight = Boolean(noteMatch[1])
|
|
122
|
-
const parts = [noteMatch[2]]
|
|
123
|
-
// Gather continuation lines (indented or unindented prose) until a blank
|
|
124
|
-
// line, another footer key, or end of body.
|
|
125
|
-
for (let j = i + 1; j < lines.length; j += 1) {
|
|
126
|
-
const next = lines[j]
|
|
127
|
-
if (next.trim() === '') break
|
|
128
|
-
if (FOOTER_KEY.test(next)) break
|
|
129
|
-
parts.push(next.trim())
|
|
130
|
-
}
|
|
131
|
-
noteText = parts.join(' ').replace(/\s+/g, ' ').trim()
|
|
132
|
-
continue
|
|
133
|
-
}
|
|
134
|
-
const areaMatch = lines[i].match(/^Release-Area:\s*(.+)$/i)
|
|
135
|
-
if (areaMatch) areaOverride = areaMatch[1].trim()
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!noteText) return null
|
|
139
|
-
if (/^none$/i.test(noteText)) return null // explicit not-user-facing marker
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
area: resolveArea(commit.scope, areaOverride, scopeAreas),
|
|
143
|
-
bucket,
|
|
144
|
-
text: noteText,
|
|
145
|
-
highlight,
|
|
146
|
-
hash: commit.hash,
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** ISO `2026-06-19` → friendly `19 Jun 2026` (parsed without Date to avoid TZ shift). */
|
|
151
|
-
function formatReleaseDate(isoDate) {
|
|
152
|
-
const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
|
153
|
-
if (!match) return isoDate
|
|
154
|
-
const [, year, month, day] = match
|
|
155
|
-
return `${Number.parseInt(day, 10)} ${MONTHS[Number.parseInt(month, 10) - 1]} ${year}`
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Render one release section: heading, optional Highlights, then areas × buckets. */
|
|
159
|
-
function renderReleasesSection(version, isoDate, notes) {
|
|
160
|
-
const lines = []
|
|
161
|
-
lines.push(`## ${version} — ${formatReleaseDate(isoDate)}`)
|
|
162
|
-
lines.push('')
|
|
163
|
-
|
|
164
|
-
const highlights = notes.filter((n) => n.highlight)
|
|
165
|
-
if (highlights.length === 1) {
|
|
166
|
-
lines.push(`**Highlights:** ${highlights[0].text}`)
|
|
167
|
-
lines.push('')
|
|
168
|
-
} else if (highlights.length > 1) {
|
|
169
|
-
lines.push('**Highlights:**')
|
|
170
|
-
highlights.forEach((h) => lines.push(`- ${h.text}`))
|
|
171
|
-
lines.push('')
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const areas = [...new Set(notes.map((n) => n.area))].sort((a, b) => a.localeCompare(b))
|
|
175
|
-
for (const area of areas) {
|
|
176
|
-
lines.push(`### ${area}`)
|
|
177
|
-
const areaNotes = notes.filter((n) => n.area === area)
|
|
178
|
-
for (const bucket of BUCKET_ORDER) {
|
|
179
|
-
areaNotes
|
|
180
|
-
.filter((n) => n.bucket === bucket)
|
|
181
|
-
.forEach((n) => lines.push(`- **${bucket}** — ${n.text}`))
|
|
182
|
-
}
|
|
183
|
-
lines.push('')
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return lines.join('\n').trimEnd() + '\n'
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/** Idempotently insert/replace a release's section by version (newest on top). */
|
|
190
|
-
function upsertReleasesSection(content, newSection, version) {
|
|
191
|
-
const existingRegex = new RegExp(
|
|
192
|
-
`(^|\\n)## ${escapeRegex(version)} [^\\n]*\\n[\\s\\S]*?(?=\\n## \\d|\\n---|$)`,
|
|
193
|
-
)
|
|
194
|
-
const existingMatch = content.match(existingRegex)
|
|
195
|
-
if (existingMatch) {
|
|
196
|
-
const leading = existingMatch[1] ?? ''
|
|
197
|
-
return content.replace(existingRegex, `${leading}${newSection.trimEnd()}\n`)
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Insert above the newest existing version section (versions start with a digit).
|
|
201
|
-
const firstVersionIdx = content.search(/\n## \d/)
|
|
202
|
-
if (firstVersionIdx >= 0) {
|
|
203
|
-
const before = content.slice(0, firstVersionIdx).replace(/\s+$/, '')
|
|
204
|
-
const after = content.slice(firstVersionIdx + 1)
|
|
205
|
-
return `${before}\n\n${newSection}\n${after}`
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
return `${content.replace(/\s+$/, '')}\n\n${newSection}`
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function readReleases(path, header) {
|
|
212
|
-
try {
|
|
213
|
-
return readFileSync(path, 'utf-8')
|
|
214
|
-
} catch {
|
|
215
|
-
return header
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function getCurrentVersion() {
|
|
220
|
-
try {
|
|
221
|
-
const packageJson = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'))
|
|
222
|
-
return packageJson.version
|
|
223
|
-
} catch {
|
|
224
|
-
console.error('Error reading package.json')
|
|
225
|
-
process.exit(1)
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function notesFor(commitLines, scopeAreas) {
|
|
230
|
-
return commitLines
|
|
231
|
-
.map(parseCommit)
|
|
232
|
-
.filter((c) => c !== null)
|
|
233
|
-
.map((c) => parseReleaseNote(c, scopeAreas))
|
|
234
|
-
.filter((n) => n !== null)
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function resolveOptions(options = {}) {
|
|
238
|
-
return {
|
|
239
|
-
file: options.file || DEFAULT_FILE,
|
|
240
|
-
productName: options.productName || defaultProductName(),
|
|
241
|
-
scopeAreas: options.scopeAreas || {},
|
|
242
|
-
changelogFile: options.changelogFile || 'CHANGELOG.md',
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function updateReleases(newVersion, options = {}) {
|
|
247
|
-
const { file, productName, scopeAreas, changelogFile } = resolveOptions(options)
|
|
248
|
-
const releasesPath = join(process.cwd(), file)
|
|
249
|
-
const header = defaultReleasesHeader(productName, changelogFile)
|
|
250
|
-
let content = readReleases(releasesPath, header)
|
|
251
|
-
|
|
252
|
-
const notes = notesFor(getCommitsSinceLastTag(newVersion), scopeAreas)
|
|
253
|
-
if (notes.length === 0) {
|
|
254
|
-
console.log(`No Release-Note footers found since last tag — skipping ${file} update`)
|
|
255
|
-
// Ensure the artifact exists so a version hook's downstream steps have a
|
|
256
|
-
// file to act on even on a release with no user-facing notes.
|
|
257
|
-
if (!existsSync(releasesPath)) writeFileSync(releasesPath, content, 'utf-8')
|
|
258
|
-
return
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const date = new Date().toISOString().split('T')[0]
|
|
262
|
-
const section = renderReleasesSection(newVersion, date, notes)
|
|
263
|
-
content = upsertReleasesSection(content, section, newVersion)
|
|
264
|
-
|
|
265
|
-
writeFileSync(releasesPath, content, 'utf-8')
|
|
266
|
-
console.log(`✅ Updated ${file} with version ${newVersion} (${notes.length} note(s))`)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function retroFillReleases(count, options = {}) {
|
|
270
|
-
const { file, productName, scopeAreas, changelogFile } = resolveOptions(options)
|
|
271
|
-
const releasesPath = join(process.cwd(), file)
|
|
272
|
-
const header = defaultReleasesHeader(productName, changelogFile)
|
|
273
|
-
let content = readReleases(releasesPath, header)
|
|
274
|
-
|
|
275
|
-
const tags = getAllVersionTags()
|
|
276
|
-
if (tags.length === 0) {
|
|
277
|
-
console.log('No version tags found — nothing to retro-fill')
|
|
278
|
-
return
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Oldest-first so upsert leaves the newest on top.
|
|
282
|
-
const targets = tags.slice(0, count).reverse()
|
|
283
|
-
let updated = 0
|
|
284
|
-
|
|
285
|
-
for (const tag of targets) {
|
|
286
|
-
const idx = tags.indexOf(tag)
|
|
287
|
-
const previousTag = idx < tags.length - 1 ? tags[idx + 1] : null
|
|
288
|
-
const version = tag.replace(/^v/, '')
|
|
289
|
-
const notes = notesFor(getCommitsBetween(previousTag, tag), scopeAreas)
|
|
290
|
-
|
|
291
|
-
if (notes.length === 0) {
|
|
292
|
-
console.log(`⚠️ ${tag}: no Release-Note footers — skipping`)
|
|
293
|
-
continue
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const section = renderReleasesSection(version, getTagDate(tag), notes)
|
|
297
|
-
content = upsertReleasesSection(content, section, version)
|
|
298
|
-
updated += 1
|
|
299
|
-
console.log(`✅ ${tag}: wrote ${notes.length} note(s)`)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (updated > 0) {
|
|
303
|
-
writeFileSync(releasesPath, content, 'utf-8')
|
|
304
|
-
console.log(`✅ Retro-filled ${updated} release(s) into ${file}`)
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function main(argv) {
|
|
309
|
-
const args = argv.slice(2)
|
|
310
|
-
|
|
311
|
-
let config
|
|
312
|
-
try {
|
|
313
|
-
config = loadConfig()
|
|
314
|
-
} catch (error) {
|
|
315
|
-
console.error(error.message)
|
|
316
|
-
process.exit(1)
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
if (!config.releases.enabled) {
|
|
320
|
-
console.log('Release-notes generation disabled in skitterspec.config.json — skipping')
|
|
321
|
-
return
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const options = {
|
|
325
|
-
file: config.releases.file,
|
|
326
|
-
productName: config.releases.productName,
|
|
327
|
-
scopeAreas: config.releases.scopeAreas,
|
|
328
|
-
changelogFile: config.changelog.file,
|
|
329
|
-
}
|
|
330
|
-
const retroIdx = args.indexOf('--retro')
|
|
331
|
-
|
|
332
|
-
if (retroIdx >= 0) {
|
|
333
|
-
const count = Number.parseInt(args[retroIdx + 1] ?? '', 10)
|
|
334
|
-
if (!Number.isFinite(count) || count <= 0) {
|
|
335
|
-
console.error('Usage: generate-releases.js --retro <count>')
|
|
336
|
-
process.exit(1)
|
|
337
|
-
}
|
|
338
|
-
retroFillReleases(count, options)
|
|
339
|
-
} else {
|
|
340
|
-
updateReleases(args[0] || getCurrentVersion(), options)
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
module.exports = {
|
|
345
|
-
bucketFor,
|
|
346
|
-
resolveArea,
|
|
347
|
-
parseReleaseNote,
|
|
348
|
-
formatReleaseDate,
|
|
349
|
-
renderReleasesSection,
|
|
350
|
-
upsertReleasesSection,
|
|
351
|
-
defaultReleasesHeader,
|
|
352
|
-
updateReleases,
|
|
353
|
-
retroFillReleases,
|
|
354
|
-
BUCKET_ORDER,
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Run the CLI only when invoked directly (keeps pure functions importable).
|
|
358
|
-
if (require.main === module) {
|
|
359
|
-
main(process.argv)
|
|
360
|
-
}
|