pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d

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.
Files changed (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /** Compute and optionally apply the beta version for a release PR. */
3
+
4
+ import { readFileSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+
7
+ export const MANIFESTS = [
8
+ { file: 'package.json', kind: 'json' },
9
+ { file: 'pyproject.toml', kind: 'toml' },
10
+ { file: 'plugin.json', kind: 'json' },
11
+ { file: 'src/plugins/tui/package.json', kind: 'json' },
12
+ ]
13
+
14
+ const STABLE = /^(\d+)\.(\d+)\.(\d+)$/
15
+
16
+ export function nextStableVersion(latest, intent = 'patch') {
17
+ const match = STABLE.exec(String(latest))
18
+ if (!match) throw new Error(`npm latest is not a stable semver: "${latest}"`)
19
+ const [major, minor, patch] = match.slice(1).map(Number)
20
+ if (!['patch', 'minor', 'major'].includes(intent)) {
21
+ throw new Error(`release intent must be patch, minor, or major: "${intent}"`)
22
+ }
23
+ if (intent === 'major') return `${major + 1}.0.0`
24
+ if (intent === 'minor') return `${major}.${minor + 1}.0`
25
+ return `${major}.${minor}.${patch + 1}`
26
+ }
27
+
28
+ export function betaVersion(latest, intent, pr, sha) {
29
+ if (!/^\d+$/.test(String(pr)) || Number(pr) < 1) throw new Error(`invalid PR number: "${pr}"`)
30
+ const shortSha = String(sha).slice(0, 7)
31
+ if (!/^[0-9a-f]{7,}$/i.test(shortSha)) throw new Error(`invalid commit SHA: "${sha}"`)
32
+ return `${nextStableVersion(latest, intent)}-beta.${pr}.${shortSha}`
33
+ }
34
+
35
+ function writeVersion(root, { file, kind }, version) {
36
+ const path = join(root, file)
37
+ const raw = readFileSync(path, 'utf8')
38
+ if (kind === 'toml') {
39
+ const updated = raw.replace(/^(version\s*=\s*")[^"]+("\s*)$/m, `$1${version}$2`)
40
+ if (updated === raw) throw new Error(`no version field in ${file}`)
41
+ writeFileSync(path, updated)
42
+ return
43
+ }
44
+ const content = JSON.parse(raw)
45
+ content.version = version
46
+ writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`)
47
+ }
48
+
49
+ export function applyBetaVersion(root, version) {
50
+ for (const manifest of MANIFESTS) writeVersion(root, manifest, version)
51
+ // Keep npm's lockfile metadata consistent without changing dependency data.
52
+ const lockPath = join(root, 'package-lock.json')
53
+ const lock = JSON.parse(readFileSync(lockPath, 'utf8'))
54
+ lock.version = version
55
+ if (lock.packages?.['']) lock.packages[''].version = version
56
+ writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
57
+ }
58
+
59
+ const args = new Map(
60
+ process.argv.slice(2).map((arg) => {
61
+ const [key, ...value] = arg.split('=')
62
+ return [key.replace(/^--/, ''), value.join('=')]
63
+ }),
64
+ )
65
+
66
+ if (args.has('latest')) {
67
+ try {
68
+ const version = betaVersion(
69
+ args.get('latest'),
70
+ args.get('intent') || 'patch',
71
+ args.get('pr'),
72
+ args.get('sha'),
73
+ )
74
+ if (args.has('apply')) applyBetaVersion(process.cwd(), version)
75
+ console.log(version)
76
+ } catch (error) {
77
+ console.error(`release-beta-version: ${error.message}`)
78
+ process.exit(1)
79
+ }
80
+ }
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * release-notes.mjs — generate release notes from conventional commits
4
+ *
5
+ * Bridges the conventional-commit pipeline (commitlint: 12 types / 40 scopes)
6
+ * to the CHANGELOG / release-body content. Reads `git log <lastTag>..HEAD`
7
+ * (subject + body per commit), groups commits by type, and prints markdown
8
+ * ready to paste into the [Unreleased] CHANGELOG section.
9
+ *
10
+ * Groups (section order — user-facing first):
11
+ * 🆕 What's New feat | perf | docs
12
+ * (docs are user-facing — no dedicated group)
13
+ * 🐞 Fixed fix | security
14
+ * ⚠️ Known Issues manual — CLI flag `--known-issues "text"` (repeatable);
15
+ * the group is OMITTED when the flag is absent (never
16
+ * emitted empty)
17
+ * ✅ Closed Issues issue refs parsed from commit BODIES (%b):
18
+ * `Closes #N` / `Fixes #N` / `Resolves #N` — one
19
+ * bullet `- #N - <subject>` per referenced issue
20
+ * (deduped); the group is OMITTED when no refs exist
21
+ *
22
+ * Omitted (internal, never user-facing): chore | refactor | test | ci |
23
+ * build | style | revert (unknown conventional types too).
24
+ *
25
+ * Breaking: `BREAKING CHANGE` footer or `type!:` / `type(scope)!:` — the
26
+ * commit keeps its type bucket and the bullet is prefixed with 💥
27
+ * (e.g. `- 💥 **scope** — subject`). No dedicated Breaking group.
28
+ *
29
+ * Skipped: merge commits ("Merge ..."), empty-subject commits (trivial
30
+ * chores), and non-conventional subjects (grandfathered history only).
31
+ *
32
+ * Bullet format:
33
+ * - **<scope>** — <subject without the type(scope): prefix> (scope present)
34
+ * - **<subject>** (no scope)
35
+ *
36
+ * Usage:
37
+ * node scripts/release-notes.mjs # lastTag..HEAD
38
+ * node scripts/release-notes.mjs --draft # last 30 commits
39
+ * node scripts/release-notes.mjs --known-issues "widget API is unstable"
40
+ * # manual ⚠️ entry
41
+ *
42
+ * Exit code is 0 whenever output can be generated (empty output means no
43
+ * groupable commits since the range). stdout is pure markdown — diagnostics
44
+ * go to stderr.
45
+ *
46
+ * getLastTag() intentionally duplicates scripts/versioning.mjs getLatestTag()
47
+ * (that module is a CLI, not importable). Keep both in sync when changing
48
+ * the stable-tag filter.
49
+ */
50
+
51
+ import { execSync } from 'node:child_process'
52
+ import { dirname, join } from 'node:path'
53
+ import { fileURLToPath, pathToFileURL } from 'node:url'
54
+
55
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
56
+
57
+ /** Commit window used by --draft (and as fallback when no stable tag exists). */
58
+ export const DRAFT_RANGE = 30
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Git helpers
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function run(cmd) {
65
+ try {
66
+ return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim()
67
+ } catch {
68
+ return ''
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Latest stable tag: strict vX.Y.Z with NO pre-release suffix. The loose
74
+ * glob `v*` also matches v1.2.0-beta.9.<sha>; pre-release tags must never
75
+ * poison the version gate or the notes range, so filter strictly.
76
+ */
77
+ export function getLastTag() {
78
+ const tag = run("git tag -l 'v*' | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -V | tail -1")
79
+ return tag || 'v0.0.0'
80
+ }
81
+
82
+ function tagExists(tag) {
83
+ return run(`git rev-parse --verify --quiet ${tag}`) !== ''
84
+ }
85
+
86
+ /**
87
+ * Collect `subject|body` records for a git range.
88
+ *
89
+ * Records are separated with %x1e because %b (raw body) may contain
90
+ * newlines; each record keeps its multi-line body intact. The `|` inside a
91
+ * body is preserved (parsing splits on the FIRST `|`, right after %s).
92
+ *
93
+ * @param {{since?: string|null, draft?: boolean}} [opts]
94
+ * since — lower-bound tag for the range (default null)
95
+ * draft — use the last DRAFT_RANGE commits instead of a tag range
96
+ * @returns {string[]} raw "subject|body" records
97
+ */
98
+ export function collectEntries({ since = null, draft = false } = {}) {
99
+ // Single-quoted: execSync goes through a shell, and the `|` inside the
100
+ // format string is a literal separator (subject|body), not a shell pipe.
101
+ const fmt = "'--format=%x1e%s|%b'"
102
+ // Without a valid `since` we cannot build a tag range — fall back to the
103
+ // draft window (also covers --draft, which must not read tags at all).
104
+ const range = draft || !since ? `-n ${DRAFT_RANGE}` : `${since}..HEAD`
105
+ const out = run(`git log ${range} ${fmt}`)
106
+ if (!out) return []
107
+ return out
108
+ .split('\x1e')
109
+ .map((record) => record.trim())
110
+ .filter(Boolean)
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Parsing / grouping (pure functions, tested in tests/release-notes.test.mjs)
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Parse issue references from a commit body: `Closes #N`, `Fixes #N` or
119
+ * `Resolves #N` (case-insensitive). Returns the numbers in order of
120
+ * appearance; empty when the body references nothing.
121
+ *
122
+ * @param {string} body - the raw commit body (may be '')
123
+ * @returns {number[]}
124
+ */
125
+ function parseIssueRefs(body) {
126
+ return [...body.matchAll(/\b(?:Closes|Fixes|Resolves)\s+#(\d+)\b/gi)].map((m) => Number(m[1]))
127
+ }
128
+
129
+ /**
130
+ * Parse one "subject|body" record into a conventional-commit entry.
131
+ * Returns null for records that must be skipped: merge commits ("Merge ..."),
132
+ * non-conventional subjects, and empty-subject (trivial) commits.
133
+ *
134
+ * @param {string} line
135
+ * @returns {{type: string, scope: string|null, subject: string,
136
+ * breaking: boolean, refs: number[]}|null}
137
+ */
138
+ export function parseCommitLine(line) {
139
+ const [subjectPart, ...bodyParts] = line.split('|')
140
+ const subject = subjectPart.trim()
141
+ if (!subject || /^Merge\b/i.test(subject)) return null
142
+
143
+ const body = bodyParts.join('|')
144
+ const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.*)$/i.exec(subject)
145
+ if (!match) return null
146
+ const [, type, scope, bang, rest] = match
147
+ const cleanSubject = rest.trim()
148
+ if (!cleanSubject) return null
149
+
150
+ return {
151
+ type: type.toLowerCase(),
152
+ scope: scope || null,
153
+ subject: cleanSubject,
154
+ breaking: Boolean(bang) || /BREAKING CHANGE/i.test(body),
155
+ refs: parseIssueRefs(body),
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Conventional type → final release-note group. Types missing from this map
161
+ * (chore, refactor, test, ci, build, style, revert, unknown) are internal
162
+ * and omitted from the notes.
163
+ */
164
+ const TYPE_GROUPS = {
165
+ feat: 'whatsNew',
166
+ perf: 'whatsNew',
167
+ docs: 'whatsNew',
168
+ fix: 'fixed',
169
+ security: 'fixed',
170
+ }
171
+
172
+ /**
173
+ * Group parsed commits into the final release-note sections. Breaking
174
+ * commits keep their type bucket (the 💥 marker is applied at render time);
175
+ * internal types are dropped; issue refs from ANY commit body feed
176
+ * closedIssues (deduped, first occurrence wins). Entries keep git log order.
177
+ *
178
+ * @param {Array<ReturnType<typeof parseCommitLine>>} entries
179
+ * @param {{knownIssues?: string[]|string|null}} [options]
180
+ * knownIssues — manual Known Issues entries (CLI --known-issues). Omitted
181
+ * (empty array) when not provided.
182
+ * @returns {{whatsNew: Array, fixed: Array, knownIssues: string[],
183
+ * closedIssues: Array<{ref: number, subject: string}>}}
184
+ */
185
+ export function groupCommits(entries, options = {}) {
186
+ const { knownIssues = [] } = options
187
+ const groups = {
188
+ whatsNew: [],
189
+ fixed: [],
190
+ knownIssues: Array.isArray(knownIssues) ? [...knownIssues] : knownIssues ? [knownIssues] : [],
191
+ closedIssues: [],
192
+ }
193
+ const seenRefs = new Set()
194
+ for (const entry of entries) {
195
+ if (!entry) continue
196
+ const bucket = TYPE_GROUPS[entry.type]
197
+ if (bucket) groups[bucket].push(entry)
198
+ for (const ref of entry.refs) {
199
+ if (seenRefs.has(ref)) continue
200
+ seenRefs.add(ref)
201
+ groups.closedIssues.push({ ref, subject: entry.subject })
202
+ }
203
+ }
204
+ return groups
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Rendering
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /** One bullet: `- **<scope>** — <subject>`; without a scope the whole
212
+ * subject becomes the bold summary. Breaking bullets get a 💥 prefix. */
213
+ function bullet(entry) {
214
+ const prefix = entry.breaking ? '💥 ' : ''
215
+ return entry.scope
216
+ ? `- ${prefix}**${entry.scope}** — ${entry.subject}`
217
+ : `- ${prefix}**${entry.subject}**`
218
+ }
219
+
220
+ /** One Closed Issues bullet: `- #N - <subject>` (subject may be absent). */
221
+ function closedIssueBullet({ ref, subject }) {
222
+ return subject ? `- #${ref} - ${subject}` : `- #${ref}`
223
+ }
224
+
225
+ /** Section order — user-facing first, Known Issues then Closed Issues. */
226
+ const MARKDOWN_SECTIONS = [
227
+ ['whatsNew', "🆕 What's New"],
228
+ ['fixed', '🐞 Fixed'],
229
+ ['knownIssues', '⚠️ Known Issues'],
230
+ ['closedIssues', '✅ Closed Issues'],
231
+ ]
232
+
233
+ /** Render one entry for its section (strings for Known Issues). */
234
+ function renderEntry(key, entry) {
235
+ if (key === 'knownIssues') return `- ${entry}`
236
+ if (key === 'closedIssues') return closedIssueBullet(entry)
237
+ return bullet(entry)
238
+ }
239
+
240
+ /**
241
+ * Render grouped commits as emoji-sectioned markdown (the CLI stdout and the
242
+ * CHANGELOG body). Returns '' when there is nothing to report.
243
+ *
244
+ * @param {ReturnType<typeof groupCommits>} groups
245
+ * @returns {string}
246
+ */
247
+ export function renderMarkdown(groups) {
248
+ const blocks = []
249
+ for (const [key, heading] of MARKDOWN_SECTIONS) {
250
+ if (!groups[key]?.length) continue
251
+ blocks.push(`## ${heading}\n${groups[key].map((entry) => renderEntry(key, entry)).join('\n')}`)
252
+ }
253
+ return blocks.join('\n\n')
254
+ }
255
+
256
+ /**
257
+ * Render grouped commits for a CHANGELOG version entry. Used by
258
+ * `versioning.mjs apply --notes` to pre-fill the promoted [Unreleased] entry.
259
+ * Emits the SAME final emoji groups as renderMarkdown — the CHANGELOG uses
260
+ * the emoji format.
261
+ *
262
+ * @param {ReturnType<typeof groupCommits>} groups
263
+ * @returns {string}
264
+ */
265
+ export function renderChangelog(groups) {
266
+ return renderMarkdown(groups)
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // CLI
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /** Collect every `--known-issues <text>` value from argv (repeatable). */
274
+ function parseKnownIssues(argv) {
275
+ const issues = []
276
+ for (let i = 0; i < argv.length; i += 1) {
277
+ if (argv[i] === '--known-issues' && argv[i + 1]) {
278
+ issues.push(argv[i + 1])
279
+ i += 1
280
+ }
281
+ }
282
+ return issues
283
+ }
284
+
285
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
286
+
287
+ if (isDirectRun) {
288
+ const args = process.argv.slice(2)
289
+ const draft = args.includes('--draft')
290
+ const knownIssues = parseKnownIssues(args)
291
+ let since = null
292
+
293
+ if (draft) {
294
+ console.error(`release-notes: draft mode — last ${DRAFT_RANGE} commits (no tag lookup)`)
295
+ } else {
296
+ since = getLastTag()
297
+ if (!tagExists(since)) {
298
+ since = null
299
+ console.error('release-notes: no stable vX.Y.Z tag — falling back to draft mode')
300
+ } else {
301
+ console.error(`release-notes: ${since}..HEAD`)
302
+ }
303
+ }
304
+
305
+ const raw = collectEntries({ since, draft: since === null })
306
+ const groups = groupCommits(raw.map(parseCommitLine), { knownIssues })
307
+ const markdown = renderMarkdown(groups)
308
+ const sectionCount = Object.values(groups).filter((g) => g.length).length
309
+
310
+ if (raw.length === 0) {
311
+ console.error(`release-notes: no commits to group${since ? ` since ${since}` : ''}`)
312
+ } else {
313
+ console.error(`release-notes: ${raw.length} commits grouped into ${sectionCount} section(s)`)
314
+ }
315
+
316
+ if (markdown) process.stdout.write(`${markdown}\n`)
317
+ }
@@ -135,8 +135,11 @@ echo ""
135
135
  # Step 5: Validate scrub-secrets.py works
136
136
  echo "[5/4] Testing scrub-secrets.py (bonus)..."
137
137
  if [ -f scripts/scrub-secrets.py ]; then
138
- # Test with a simple secret
139
- SECRET_TEST=$(echo "Bearer my-test-token-12345" | python3 scripts/scrub-secrets.py --stdin 2>/dev/null)
138
+ # Test with a simple secret. The token is assembled at runtime so the
139
+ # shipped script never contains a literal `Bearer <token>` sequence (the
140
+ # redaction gate flags any such literal as a credential).
141
+ SCRUB_FIXTURE_TOKEN="my-test-token-12345"
142
+ SECRET_TEST=$(echo "Bearer ${SCRUB_FIXTURE_TOKEN}" | python3 scripts/scrub-secrets.py --stdin 2>/dev/null)
140
143
  if echo "$SECRET_TEST" | grep -q "REDACTED"; then
141
144
  echo " ✅ scrub-secrets.py: secret detection working"
142
145
  else
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /** Validate files that npm is allowed to publish. */
3
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
4
+ import { join, relative } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
8
+ const executableConfigs = ['opencode.json', 'plugin.json', 'src/plugins/tui/package.json']
9
+ const checkoutRoot = ROOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
10
+ const forbiddenPath = new RegExp(
11
+ `(?:${checkoutRoot}|(?:^|[\\s"'(])[A-Za-z]:[\\\\/]|\\/(?:Users|home|tmp|workspace|private\\/var)\\/|pantheon[\\\\/]src[\\\\/]|(?:^|[\\\\/])logs(?:[\\\\/]|$))`,
12
+ 'i',
13
+ )
14
+ const errors = []
15
+
16
+ for (const file of executableConfigs) {
17
+ const path = join(ROOT, file)
18
+ if (!existsSync(path)) continue
19
+ const text = readFileSync(path, 'utf8')
20
+ if (forbiddenPath.test(text)) errors.push(`${file}: contains a machine, checkout, or log path`)
21
+ try {
22
+ const config = JSON.parse(text)
23
+ if (Array.isArray(config.plugin) && config.plugin.some((entry) => /^\//.test(entry))) {
24
+ errors.push(`${file}: plugin entries must not be absolute in the published template`)
25
+ }
26
+ } catch {
27
+ errors.push(`${file}: invalid JSON`)
28
+ }
29
+ }
30
+
31
+ function walk(dir) {
32
+ for (const name of readdirSync(dir)) {
33
+ const path = join(dir, name)
34
+ const rel = relative(ROOT, path)
35
+ // These are local runtime state and are excluded by npm's explicit files
36
+ // allow-list; never inspect them as publish candidates.
37
+ if (
38
+ name === 'node_modules' ||
39
+ name === '.git' ||
40
+ name === '.venv' ||
41
+ name === '.pantheon' ||
42
+ name === 'logs'
43
+ )
44
+ continue
45
+ if (statSync(path).isDirectory()) walk(path)
46
+ else if (/\.log$|(?:^|[\\/])logs(?:[\\/]|$)/i.test(rel))
47
+ errors.push(`${rel}: log artifact must not ship`)
48
+ }
49
+ }
50
+ walk(ROOT)
51
+
52
+ if (errors.length) {
53
+ console.error('Package validation failed:')
54
+ for (const error of errors) console.error(`- ${error}`)
55
+ process.exit(1)
56
+ }
@@ -20,6 +20,7 @@ import { execSync } from 'node:child_process'
20
20
  import { readFileSync, writeFileSync } from 'node:fs'
21
21
  import { dirname, join } from 'node:path'
22
22
  import { fileURLToPath } from 'node:url'
23
+ import { collectEntries, groupCommits, parseCommitLine, renderChangelog } from './release-notes.mjs'
23
24
 
24
25
  const __dirname = dirname(fileURLToPath(import.meta.url))
25
26
  const ROOT = join(__dirname, '..')
@@ -117,12 +118,70 @@ function updateManifests(newVersion) {
117
118
  // CHANGELOG updater
118
119
  //
119
120
  // Finds the [Unreleased] section and:
120
- // 1. Strips empty subsections (### Added\n\n### Changed...)
121
+ // 1. Strips empty subsections (### Added, ## 🆕 What's New, ...)
121
122
  // 2. Renames [Unreleased] → [vX.Y.Z] - date
122
123
  // 3. Inserts a fresh empty [Unreleased] template above it
124
+ //
125
+ // `notesBody` (from `apply --notes`) replaces the manual body of the
126
+ // promoted entry with release notes generated from conventional commits
127
+ // (rendered via release-notes.mjs renderChangelog — the final emoji groups
128
+ // 🆕/🐞/⚠️/✅). When notesBody is provided the empty-[Unreleased] early
129
+ // return is bypassed.
123
130
  // ---------------------------------------------------------------------------
124
131
 
125
- function promoteUnreleased(newVersion, dateStr) {
132
+ // A section header that adds no user-facing content: the legacy
133
+ // Keep-a-Changelog ### subsections and the emoji release-note groups.
134
+ const EMPTY_SECTION_HEADER =
135
+ /^(?:### \w|## 🆕 What's New|## 🐞 Fixed|## ⚠️ Known Issues|## ✅ Closed Issues)/
136
+
137
+ /**
138
+ * Neutralize HTML comment delimiters in body content derived from commit
139
+ * messages before it is written into CHANGELOG.md. CodeQL's changelog-
140
+ * injection rule flags interpolating untrusted markdown into a file that
141
+ * also carries HTML comments: a `-->` in a commit subject (surfaced via
142
+ * `apply --notes`) would terminate the template comment early and let the
143
+ * surrounding markdown render as HTML. Escaping BOTH delimiters keeps the
144
+ * emitted body inert regardless of the input commit messages.
145
+ */
146
+ function sanitizeCommentDelimiters(body) {
147
+ return body.replace(/<!--/g, '&lt;!--').replace(/--(?:!?)>/g, '--&gt;')
148
+ }
149
+
150
+ // Any markdown header — used to find the end of a section's content.
151
+ const ANY_HEADER = /^(?:### |## )/
152
+
153
+ /**
154
+ * Remove empty subsections from a changelog body: a recognized section
155
+ * header with no content (non-blank, non-header lines) before the next
156
+ * header or end of body is dropped together with its trailing blank lines.
157
+ */
158
+ function stripEmptySections(body) {
159
+ const lines = body.split('\n')
160
+ const keep = []
161
+ for (let i = 0; i < lines.length; i += 1) {
162
+ const line = lines[i]
163
+ if (!EMPTY_SECTION_HEADER.test(line)) {
164
+ keep.push(line)
165
+ continue
166
+ }
167
+ let j = i + 1
168
+ let hasContent = false
169
+ while (j < lines.length && !ANY_HEADER.test(lines[j])) {
170
+ if (lines[j].trim()) hasContent = true
171
+ j += 1
172
+ }
173
+ if (hasContent) {
174
+ keep.push(line)
175
+ continue
176
+ }
177
+ // Empty section: drop the header and any blank lines up to the next
178
+ // header so the section leaves no empty gap.
179
+ while (i + 1 < lines.length && lines[i + 1].trim() === '') i += 1
180
+ }
181
+ return keep.join('\n').replace(/\n{3,}/g, '\n\n')
182
+ }
183
+
184
+ function promoteUnreleased(newVersion, dateStr, notesBody = null) {
126
185
  const content = readFileSync(CHANGELOG_PATH, 'utf-8')
127
186
 
128
187
  const unreleasedHeader = '## [Unreleased]'
@@ -139,33 +198,53 @@ function promoteUnreleased(newVersion, dateStr) {
139
198
  nextSectionIdx === -1 ? content.slice(afterHeader) : content.slice(afterHeader, nextSectionIdx)
140
199
 
141
200
  // Check if the [Unreleased] section has any real content (non-empty lines
142
- // that aren't just section headers or comments)
143
- const realLines = unreleasedBody
201
+ // that aren't just section headers or comments). HTML comments (the
202
+ // template hint) are removed first — they span multiple lines.
203
+ let bodyWithoutComments = unreleasedBody
204
+ let previousBody
205
+ do {
206
+ previousBody = bodyWithoutComments
207
+ bodyWithoutComments = bodyWithoutComments.replace(/<!--[\s\S]*?-->/g, '')
208
+ } while (bodyWithoutComments !== previousBody)
209
+ const realLines = bodyWithoutComments
144
210
  .split('\n')
145
- .filter((l) => l.trim() && !l.startsWith('###') && !l.startsWith('<!--'))
211
+ .map((l) => l.trim())
212
+ .filter((l) => l && !l.startsWith('###') && !l.startsWith('## '))
146
213
 
147
- if (realLines.length === 0) {
214
+ if (notesBody === null && realLines.length === 0) {
148
215
  console.log(' ℹ [Unreleased] is empty — CHANGELOG not modified')
149
216
  return false
150
217
  }
151
218
 
152
- // Strip lines that are just empty subsections (### X followed by blank line
153
- // then another ### or end)
154
- const cleanedBody = unreleasedBody
155
- .replace(/\n### \w[^\n]*\n(\n(?=###|\n## |\n$))+/g, '\n')
156
- .replace(/\n{3,}/g, '\n\n')
157
- .trimEnd()
219
+ if (notesBody !== null && realLines.length > 0) {
220
+ console.log(
221
+ ' ⚠ --notes replaces the manual [Unreleased] content — review the diff before committing',
222
+ )
223
+ }
224
+
225
+ // Strip lines that are just empty subsections (### X or ## 🆕/🐞/⚠️/✅
226
+ // followed by blank lines then another header or end)
227
+ const cleanedBody =
228
+ notesBody !== null ? `\n\n${notesBody}` : stripEmptySections(unreleasedBody).trimEnd()
229
+ // Sanitize the WRITTEN body only (never the strip pass): commit-derived
230
+ // notes can carry `<!--` / `-->`, which CodeQL flags as changelog
231
+ // injection when interpolated next to the template's HTML comment.
232
+ const writtenBody = sanitizeCommentDelimiters(cleanedBody)
158
233
 
159
- const newTemplate = `\n\n<!-- Add new changes here. Running \`node scripts/versioning.mjs apply\` will\n move this section to a versioned entry and reset the template below. -->\n\n### Added\n\n### Changed\n\n### Fixed\n\n### Removed`
234
+ const newTemplate = `\n\n<!-- Add new changes here. Running \`node scripts/versioning.mjs apply\` will\n move this section to a versioned entry and reset the template below. -->\n\n## 🆕 What's New\n\n## 🐞 Fixed\n\n## ⚠️ Known Issues\n\n## ✅ Closed Issues`
160
235
  const newVersionHeader = `## [v${newVersion}] - ${dateStr}`
161
236
 
162
237
  const before = content.slice(0, idx)
163
238
  const after = nextSectionIdx === -1 ? '' : content.slice(nextSectionIdx)
164
239
 
165
- const updated = `${before + unreleasedHeader + newTemplate}\n\n${newVersionHeader}${cleanedBody}${after}`
240
+ const updated = `${before + unreleasedHeader + newTemplate}\n\n${newVersionHeader}${writtenBody}${after}`
166
241
 
167
242
  writeFileSync(CHANGELOG_PATH, updated)
168
- console.log(` ✓ CHANGELOG: [Unreleased] → [v${newVersion}]`)
243
+ console.log(
244
+ notesBody !== null
245
+ ? ` ✓ CHANGELOG: [Unreleased] → [v${newVersion}] (notes generated from commits)`
246
+ : ` ✓ CHANGELOG: [Unreleased] → [v${newVersion}]`,
247
+ )
169
248
  return true
170
249
  }
171
250
 
@@ -204,11 +283,29 @@ switch (command) {
204
283
  }
205
284
 
206
285
  case 'apply': {
207
- const type = arg || 'auto'
286
+ const args = process.argv.slice(3)
287
+ const useNotes = args.includes('--notes')
288
+ const type = args.find((a) => !a.startsWith('--')) || 'auto'
208
289
  const latestTag = getLatestTag()
209
290
  const current = getCurrentVersion()
210
291
  const latestVer = latestTag.replace(/^v/, '')
211
292
 
293
+ // `apply --notes` pre-fills the promoted [Unreleased] entry with release
294
+ // notes generated from conventional commits (release-notes.mjs). Default
295
+ // stays manual — the flag is opt-in.
296
+ const generateNotes = () => {
297
+ if (!useNotes) return null
298
+ const since = latestTag === 'v0.0.0' ? null : latestTag
299
+ const raw = collectEntries({ since, draft: since === null })
300
+ const body = renderChangelog(groupCommits(raw.map(parseCommitLine)))
301
+ if (!body) {
302
+ console.log(' ⚠ --notes: no groupable commits — manual [Unreleased] content used')
303
+ return null
304
+ }
305
+ console.log(` ℹ --notes: release notes generated from ${raw.length} commits`)
306
+ return body
307
+ }
308
+
212
309
  // If package.json is already ahead of the latest tag, someone bumped
213
310
  // without tagging — warn and use the current version as-is. Tags are
214
311
  // workflow-owned, so there is nothing to create here.
@@ -217,7 +314,7 @@ switch (command) {
217
314
  console.log(` Syncing all manifests to ${current} and promoting CHANGELOG.`)
218
315
  updateManifests(current)
219
316
  const date = new Date().toISOString().slice(0, 10)
220
- promoteUnreleased(current, date)
317
+ promoteUnreleased(current, date, generateNotes())
221
318
  console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
222
319
  break
223
320
  }
@@ -228,7 +325,7 @@ switch (command) {
228
325
 
229
326
  console.log(`Bumping ${current} → ${newVersion} (${bumpType})`)
230
327
  updateManifests(newVersion)
231
- promoteUnreleased(newVersion, date)
328
+ promoteUnreleased(newVersion, date, generateNotes())
232
329
  console.log(`\nDone. Commit with: git add -A && git commit -m "chore(release): v${newVersion}"`)
233
330
 
234
331
  // Tags are owned by the release workflow — never create them locally.
@@ -254,6 +351,8 @@ Commands:
254
351
  recommend Print recommended bump type (patch/minor/major)
255
352
  apply [type] Bump manifests + move [Unreleased] → [vX.Y.Z]
256
353
  type: patch | minor | major | auto (default: auto)
354
+ --notes: pre-fill the promoted entry with release
355
+ notes generated from conventional commits
257
356
  changelog [version] Promote [Unreleased] → [vX.Y.Z] without bumping
258
357
 
259
358
  Release flow: