hypomnema 1.6.2 → 1.7.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.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -1,293 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * scripts/install-git-hooks.mjs — idempotent git pre-commit hook installer.
4
- *
5
- * Wired into package.json as the `prepare` script so it runs after every
6
- * `npm install` / `npm ci` in this checkout. The installer is FULLY fail-open:
7
- * any filesystem or git error → silent exit 0. The CLAUDE.md formatter rule
8
- * is best-effort; a failed install must never block contributor onboarding.
9
- *
10
- * Trust model:
11
- * - `expectedRoot` is derived from THIS script's filesystem location (via
12
- * `import.meta.url` → realpath), NOT from `git rev-parse`. Git probes
13
- * can be redirected by ambient GIT_DIR/GIT_WORK_TREE; the script's own
14
- * path cannot.
15
- * - All git probes use a scrubbed env (every name from `--local-env-vars`
16
- * plus GIT_NAMESPACE / GIT_CEILING_DIRECTORIES / GIT_CONFIG_*).
17
- * - Probes run with `cwd: expectedRoot` so npm `--prefix` invocations
18
- * can't redirect resolution either.
19
- * - Generated shim embeds `HYPOMNEMA_ROOT` + `HYPOMNEMA_GIT_DIR`. Runtime
20
- * shim refuses to exec unless both literals match the current values.
21
- *
22
- * Refusal conditions (all exit 0):
23
- * - `CI=true` env (npm ci on CI runs prepare; we must not touch hooks)
24
- * - `npm_command` in {pack, publish} or `npm_lifecycle_event=prepublishOnly`
25
- * - `.git/` absent (consumer install of the published tarball)
26
- * - linked worktree (--absolute-git-dir != --git-common-dir)
27
- * - toplevel != expectedRoot
28
- * - hooks dir / pre-commit file is a symlink
29
- * - existing non-marker pre-commit (don't clobber the user's own hook)
30
- * - any filesystem error (ENOENT, EPERM, EACCES, …)
31
- *
32
- * Verbose mode: set HYPOMNEMA_HOOK_VERBOSE=1 to see skip/install reasons.
33
- */
34
-
35
- import { execFileSync } from 'node:child_process';
36
- import fs from 'node:fs';
37
- import path from 'node:path';
38
- import url from 'node:url';
39
-
40
- function exitSilent(msg) {
41
- if (process.env.HYPOMNEMA_HOOK_VERBOSE === '1') {
42
- process.stderr.write(`[install-git-hooks] ${msg}\n`);
43
- }
44
- process.exit(0);
45
- }
46
-
47
- // Static fallback list for `--local-env-vars`. Used when we don't yet have a
48
- // trusted git invocation (the installer is bootstrapping its own trust chain),
49
- // and also when git is too old to support `--local-env-vars`.
50
- const STATIC_LOCAL_ENV_VARS = [
51
- 'GIT_DIR',
52
- 'GIT_WORK_TREE',
53
- 'GIT_INDEX_FILE',
54
- 'GIT_OBJECT_DIRECTORY',
55
- 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
56
- 'GIT_COMMON_DIR',
57
- 'GIT_CONFIG',
58
- 'GIT_CONFIG_PARAMETERS',
59
- 'GIT_PREFIX',
60
- 'GIT_IMPLICIT_WORK_TREE',
61
- 'GIT_GRAFT_FILE',
62
- 'GIT_NO_REPLACE_OBJECTS',
63
- 'GIT_REPLACE_REF_BASE',
64
- 'GIT_SHALLOW_FILE',
65
- ];
66
-
67
- function buildScrubbedEnv(localEnvList) {
68
- const scrub = new Set([
69
- ...(localEnvList || STATIC_LOCAL_ENV_VARS),
70
- 'GIT_NAMESPACE',
71
- 'GIT_CEILING_DIRECTORIES',
72
- ...Object.keys(process.env).filter((k) => /^GIT_CONFIG_/.test(k)),
73
- ]);
74
- return Object.fromEntries(Object.entries(process.env).filter(([k]) => !scrub.has(k)));
75
- }
76
-
77
- function shellSingleQuote(s) {
78
- // POSIX-safe: 'x' → 'x', x'y → 'x'\''y'
79
- return `'` + s.replace(/'/g, `'\\''`) + `'`;
80
- }
81
-
82
- // Hook-specific gate body. Runs AFTER the shared identity guards, so everything
83
- // here is already proven to be our trusted checkout. `set +e` is active, so the
84
- // gate must explicitly `|| exit 1` to BLOCK a commit; a missing script is a
85
- // fail-open skip (old checkout that predates the script).
86
- function gateLines(kind) {
87
- // CHECK_TRACKER_ROOT is a test-only seam in check-tracker-ids.mjs. Clear any
88
- // inherited value so the real hook always gates THIS checkout's index, never a
89
- // redirected one.
90
- const unsetSeam = 'unset CHECK_TRACKER_ROOT';
91
- if (kind === 'pre-commit') {
92
- // 1) Auto-format staged files (its own exit 1 = a true git-add failure block).
93
- // The sentinel tells pre-commit-format.mjs it runs under the trusted shim,
94
- // so it preserves an inherited GIT_INDEX_FILE (index-whitelist defence).
95
- // 2) Tracker-id gate on the staged blobs — blocks the commit on a leak.
96
- return `${unsetSeam}
97
- FMT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
98
- [ -f "$FMT" ] && { HYPOMNEMA_HOOK_INVOCATION=1 node "$FMT" || exit 1; }
99
- TRK="$HYPOMNEMA_ROOT/scripts/check-tracker-ids.mjs"
100
- [ -f "$TRK" ] && { node "$TRK" --staged || exit 1; }
101
- exit 0`;
102
- }
103
- // commit-msg: scan the message file (passed as $1) for wiki tracker ids.
104
- return `${unsetSeam}
105
- TRK="$HYPOMNEMA_ROOT/scripts/check-tracker-ids.mjs"
106
- [ -f "$TRK" ] || exit 0
107
- node "$TRK" --commit-msg "$1" || exit 1
108
- exit 0`;
109
- }
110
-
111
- function shimBody(kind, root, gitDir) {
112
- return `#!/bin/sh
113
- # hypomnema-${kind}-marker v2
114
- # Fail-open at every identity/setup guard. Only the gate below can exit nonzero.
115
- set +e
116
- HYPOMNEMA_ROOT=${shellSingleQuote(root)}
117
- HYPOMNEMA_GIT_DIR=${shellSingleQuote(gitDir)}
118
- TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
119
- [ -z "$TOPLEVEL" ] && exit 0
120
- [ "$TOPLEVEL" = "$HYPOMNEMA_ROOT" ] || exit 0
121
- ABSGITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
122
- [ "$ABSGITDIR" = "$HYPOMNEMA_GIT_DIR" ] || exit 0
123
- command -v node >/dev/null 2>&1 || exit 0
124
- ${gateLines(kind)}
125
- `;
126
- }
127
-
128
- // Write a single hook shim. Returns true on success, false on write failure.
129
- // Does NOT exit — the caller installs multiple hooks and reports once.
130
- function writeShim(target, kind, root, gitDir) {
131
- try {
132
- fs.writeFileSync(target, shimBody(kind, root, gitDir), { mode: 0o755 });
133
- try {
134
- fs.chmodSync(target, 0o755);
135
- } catch {}
136
- return true;
137
- } catch {
138
- return false;
139
- }
140
- }
141
-
142
- // Install/refresh one hook of the given kind under absHooksDir. Returns a short
143
- // status string (never throws, never exits). Refreshes our own marker (any
144
- // version) so a checkout move or a shim-body change re-propagates; never
145
- // clobbers a user's own non-marker hook or a symlink.
146
- function installHook(kind, absHooksDir, root, gitDir) {
147
- const target = path.join(absHooksDir, kind);
148
- let existing;
149
- try {
150
- existing = fs.lstatSync(target);
151
- } catch (e) {
152
- if (e.code === 'ENOENT') {
153
- return writeShim(target, kind, root, gitDir) ? `installed ${kind}` : `write ${kind} failed`;
154
- }
155
- return `stat ${kind} failed: ${e.code}`;
156
- }
157
- if (existing.isSymbolicLink()) return `${kind} is symlink; not overwriting`;
158
- let head;
159
- try {
160
- head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
161
- } catch {
162
- return `read ${kind} failed; skipping`;
163
- }
164
- if (head.includes(`hypomnema-${kind}-marker`)) {
165
- return writeShim(target, kind, root, gitDir) ? `refreshed ${kind}` : `refresh ${kind} failed`;
166
- }
167
- return `existing non-marker ${kind}; not overwriting`;
168
- }
169
-
170
- async function main() {
171
- try {
172
- // (0) CI / lifecycle guards.
173
- if (process.env.CI === 'true') return exitSilent('CI=true; skipping');
174
- const lc = process.env.npm_command;
175
- if (lc === 'pack' || lc === 'publish') {
176
- return exitSilent(`npm_command=${lc}; skipping`);
177
- }
178
- if (process.env.npm_lifecycle_event === 'prepublishOnly') {
179
- return exitSilent('prepublishOnly; skipping');
180
- }
181
-
182
- // (1) Derive expectedRoot from THIS script's location, not from git.
183
- const here = path.dirname(url.fileURLToPath(import.meta.url));
184
- let expectedRoot;
185
- try {
186
- expectedRoot = fs.realpathSync(path.resolve(here, '..'));
187
- } catch {
188
- return exitSilent('cannot resolve script location; skipping');
189
- }
190
-
191
- // (2) Bootstrap scrubbed env with the static fallback list, just enough to
192
- // get a trusted git probe running. Then enrich from --local-env-vars at
193
- // runtime (modern git) so the scrub list always tracks git's own truth.
194
- let cleanEnv = buildScrubbedEnv(null);
195
- let run = (args) =>
196
- execFileSync('git', args, {
197
- encoding: 'utf-8',
198
- env: cleanEnv,
199
- cwd: expectedRoot,
200
- }).trim();
201
- try {
202
- const list = run(['rev-parse', '--local-env-vars']).split(/\r?\n/).filter(Boolean);
203
- cleanEnv = buildScrubbedEnv(list);
204
- run = (args) =>
205
- execFileSync('git', args, {
206
- encoding: 'utf-8',
207
- env: cleanEnv,
208
- cwd: expectedRoot,
209
- }).trim();
210
- } catch {
211
- // Old git without --local-env-vars; keep static-list cleanEnv.
212
- }
213
-
214
- // (3) Probe git with sanitized env.
215
- let topR, absGitDir, commonDir;
216
- try {
217
- topR = fs.realpathSync(run(['rev-parse', '--show-toplevel']));
218
- absGitDir = fs.realpathSync(run(['rev-parse', '--absolute-git-dir']));
219
- const cd = run(['rev-parse', '--git-common-dir']);
220
- commonDir = fs.realpathSync(path.isAbsolute(cd) ? cd : path.join(absGitDir, '..', cd));
221
- } catch {
222
- return exitSilent('git probe failed; skipping');
223
- }
224
-
225
- // (4) Identity + worktree + containment.
226
- if (topR !== expectedRoot) {
227
- return exitSilent('toplevel != expectedRoot; skipping');
228
- }
229
- if (absGitDir !== commonDir) {
230
- return exitSilent('linked worktree; skipping');
231
- }
232
- if (!absGitDir.startsWith(expectedRoot + path.sep)) {
233
- return exitSilent('gitDir outside expectedRoot; skipping');
234
- }
235
-
236
- // (5) Resolve hooks dir (still under sanitized env).
237
- let rawHooksDir;
238
- try {
239
- rawHooksDir = run(['rev-parse', '--git-path', 'hooks']);
240
- } catch {
241
- return exitSilent('cannot resolve hooks dir; skipping');
242
- }
243
- if (!path.isAbsolute(rawHooksDir)) {
244
- rawHooksDir = path.resolve(expectedRoot, rawHooksDir);
245
- }
246
-
247
- if (!fs.existsSync(rawHooksDir)) {
248
- // Git creates .git/hooks lazily. Only create when it would land inside
249
- // absGitDir — protects against `core.hooksPath=/elsewhere`.
250
- if (!rawHooksDir.startsWith(absGitDir + path.sep)) {
251
- return exitSilent('hooks dir outside gitDir; skipping');
252
- }
253
- try {
254
- fs.mkdirSync(rawHooksDir, { recursive: true });
255
- } catch {
256
- return exitSilent('mkdir hooks dir failed; skipping');
257
- }
258
- }
259
-
260
- let absHooksDir;
261
- try {
262
- absHooksDir = fs.realpathSync(rawHooksDir);
263
- } catch {
264
- return exitSilent('realpath hooks dir failed; skipping');
265
- }
266
-
267
- // (6) Symlink rejection + containment.
268
- try {
269
- if (fs.lstatSync(rawHooksDir).isSymbolicLink()) {
270
- return exitSilent('hooks dir is symlink; skipping');
271
- }
272
- } catch {
273
- return exitSilent('lstat hooks dir failed; skipping');
274
- }
275
- const rel = path.relative(absGitDir, absHooksDir);
276
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
277
- return exitSilent('hooks dir outside .git/; skipping');
278
- }
279
-
280
- // (7) Install both hooks: pre-commit (format + tracker-id gate on staged
281
- // blobs) and commit-msg (tracker-id gate on the message). Each is
282
- // independently marker-detected so a user's own hook is never clobbered.
283
- const results = [
284
- installHook('pre-commit', absHooksDir, expectedRoot, absGitDir),
285
- installHook('commit-msg', absHooksDir, expectedRoot, absGitDir),
286
- ];
287
- return exitSilent(results.join('; '));
288
- } catch (e) {
289
- return exitSilent(`unexpected: ${e.code || e.message}; skipping`);
290
- }
291
- }
292
-
293
- main();
@@ -1,216 +0,0 @@
1
- /**
2
- * lib/changelog-classify.mjs — pure classifier + surface sanitizer for the
3
- * section-model CHANGELOG (changelog-pr-guide, format.md §3 / §5).
4
- *
5
- * Two responsibilities, both pure (no fs/git/process) so tests/runner.mjs can
6
- * exercise them on synthetic strings:
7
- *
8
- * 1. classifyChange(commitTitle) -> { section, basis }
9
- * Decide which of the three migrated sections a change belongs to.
10
- * Precedence (format.md §3): tracker ID first, Conventional-Commit type
11
- * second, the legacy heading hint third, a safe Chores fallback last.
12
- * - tracker: FEAT-* -> New Features, ISSUE-* -> Bug Fixes,
13
- * IMPR-* / PRAC-* -> Chores. Tracker wins over type, so a
14
- * `feat(...)` commit tagged IMPR-N lands in Chores, and a
15
- * `docs(...)` commit tagged ISSUE-N lands in Bug Fixes.
16
- * (IDs in this comment use an `N` placeholder, not a real
17
- * digit, so check-tracker-ids does not flag this file.)
18
- * - type: feat -> New Features, fix -> Bug Fixes, and the
19
- * non-user-visible types (chore/refactor/docs/ci/perf/...) ->
20
- * Chores.
21
- * - heading: an ID-less, type-less pre-convention item maps by the
22
- * legacy heading it sits under (Added -> New Features,
23
- * Fixed -> Bug Fixes, Changed/Internal/... -> Chores), passed
24
- * via opts.legacyHeading.
25
- * - fallback: nothing resolves -> Chores (basis tells the caller it was a
26
- * guess, so a human can re-check).
27
- *
28
- * 2. sanitizeTrackerIds(text) -> text
29
- * Strip every wiki tracker ID (FEAT-/IMPR-/ISSUE-/PRAC-N) from public
30
- * surface, leaving the PR number (`#N`) as the only identifier
31
- * (format.md §5, "표면 ID 0"). It also cleans the `(FEAT-N)` parens the ID
32
- * left empty. NOTE: `fix #N` (the wiki fix-tracker) is NOT handled here —
33
- * that pattern is the domain of check-tracker-ids; the classifier output
34
- * never produces it. ADR anchors (`ADR NNNN`, `decisions/NNNN`) are left
35
- * intact: CHANGELOG history keeps them (format.md §10).
36
- *
37
- * Section keys are stable machine strings; the human heading text
38
- * (`New Features` etc.) is mapped by SECTION_TITLE for callers that render.
39
- */
40
-
41
- // Stable section keys. Order is the canonical render order (format.md §1).
42
- export const SECTION = {
43
- NEW_FEATURES: 'new-features',
44
- BUG_FIXES: 'bug-fixes',
45
- CHORES: 'chores',
46
- };
47
-
48
- export const SECTION_TITLE = {
49
- 'new-features': 'New Features',
50
- 'bug-fixes': 'Bug Fixes',
51
- 'chores': 'Chores',
52
- };
53
-
54
- // Tracker-ID -> section, in precedence order. A change carries one tracker in
55
- // practice; if two ever co-occur, the earlier rule wins (FEAT > ISSUE > IMPR >
56
- // PRAC), so a feature-with-cleanup is surfaced as a feature.
57
- export const TRACKER_RULES = [
58
- { prefix: 'FEAT', re: /\bFEAT-\d+\b/i, section: SECTION.NEW_FEATURES },
59
- { prefix: 'ISSUE', re: /\bISSUE-\d+\b/i, section: SECTION.BUG_FIXES },
60
- { prefix: 'IMPR', re: /\bIMPR-\d+\b/i, section: SECTION.CHORES },
61
- { prefix: 'PRAC', re: /\bPRAC-\d+\b/i, section: SECTION.CHORES },
62
- ];
63
-
64
- // Conventional-Commit type -> section (secondary signal). feat/fix are the two
65
- // user-facing buckets; everything else is internal -> Chores (format.md §4:
66
- // Chores is defined by KIND, not by visibility).
67
- export const TYPE_SECTION = {
68
- feat: SECTION.NEW_FEATURES,
69
- fix: SECTION.BUG_FIXES,
70
- chore: SECTION.CHORES,
71
- refactor: SECTION.CHORES,
72
- docs: SECTION.CHORES,
73
- ci: SECTION.CHORES,
74
- perf: SECTION.CHORES,
75
- build: SECTION.CHORES,
76
- test: SECTION.CHORES,
77
- style: SECTION.CHORES,
78
- };
79
-
80
- // Legacy CHANGELOG heading -> section (format.md §6, the section-bound rows
81
- // only). The hint used when neither a tracker ID nor a Conventional-Commit type
82
- // resolves a change (a pre-convention prose item under an old `### Added` etc.).
83
- // Headings that map to NON-section blocks (Highlights, Breaking, Upgrading,
84
- // Known Issues, Notes) are deliberately absent: they are structural, not one of
85
- // the three sections this function returns, so the caller routes them (§6/§8).
86
- export const HEADING_SECTION = {
87
- added: SECTION.NEW_FEATURES,
88
- fixed: SECTION.BUG_FIXES,
89
- changed: SECTION.CHORES,
90
- internal: SECTION.CHORES,
91
- maintenance: SECTION.CHORES,
92
- documentation: SECTION.CHORES,
93
- };
94
-
95
- // Normalize a legacy heading to its HEADING_SECTION key: drop a leading `###`,
96
- // the `⚠ ` warning glyph, and a trailing ` (한글)` variant marker, then
97
- // lowercase. `### Fixed (한글)` and `⚠ Breaking` both reduce cleanly.
98
- function normalizeHeading(heading) {
99
- return String(heading == null ? '' : heading)
100
- .replace(/^#+\s*/, '')
101
- .replace(/^[⚠️\s]+/, '')
102
- .replace(/\s*\(한글\)\s*$/, '')
103
- .trim()
104
- .toLowerCase();
105
- }
106
-
107
- // All four wiki tracker prefixes, as one alternation. Used by both the
108
- // classifier (read) and the sanitizer (strip). Kept here as the single source
109
- // so the two never drift.
110
- export const TRACKER_ID_RE = /\b(?:FEAT|IMPR|ISSUE|PRAC)-\d+\b/gi;
111
-
112
- // Internal labels that are NOT tracker IDs but must also leave the public
113
- // surface (format.md §10): `Track A`, `Track A-sot`, `OQ-34`. These are NOT
114
- // stripped by sanitizeTrackerIds — removing `Track A` cleanly needs a PR-number
115
- // substitution, which is a human migration call (T5), not a regex. detect them
116
- // so the migration can flag and replace them by hand rather than miss them.
117
- export const INTERNAL_LABEL_RE = /\bTrack [A-Z](?:-[a-z]+)?\b|\bOQ-\d+\b/g;
118
-
119
- // Parse the `type` out of a Conventional-Commit subject: `type(scope)!: rest`.
120
- // Returns the lowercased type or null. The scope and the breaking-change `!`
121
- // are optional.
122
- function commitType(text) {
123
- const m = /^([a-zA-Z]+)(?:\([^)]*\))?!?:/.exec(String(text).trim());
124
- return m ? m[1].toLowerCase() : null;
125
- }
126
-
127
- /**
128
- * Classify one change into a section, following format.md §3 precedence:
129
- * tracker ID first, Conventional-Commit type second, the legacy heading hint
130
- * third (for pre-convention prose items that carry neither), and a Chores
131
- * fallback last. Returns { section, basis } where basis is
132
- * 'tracker' | 'type' | 'heading' | 'fallback' — the basis is for the snapshot
133
- * fixture / human audit, not public surface.
134
- *
135
- * @param {string} text commit subject / changelog line to classify
136
- * @param {{legacyHeading?: string}} [opts] the `### Added` etc. the item sits
137
- * under, used only when text alone is ambiguous (an ID-less, type-less line).
138
- * @returns {{section: string, basis: 'tracker'|'type'|'heading'|'fallback'}}
139
- */
140
- export function classifyChange(text, opts = {}) {
141
- const s = String(text == null ? '' : text);
142
- // 1. tracker ID wins.
143
- for (const rule of TRACKER_RULES) {
144
- if (rule.re.test(s)) return { section: rule.section, basis: 'tracker' };
145
- }
146
- // 2. Conventional-Commit type.
147
- const type = commitType(s);
148
- if (type && TYPE_SECTION[type]) {
149
- return { section: TYPE_SECTION[type], basis: 'type' };
150
- }
151
- // 3. legacy heading hint (format.md §3 step 3 / §6) — an old `### Added` item
152
- // with no conventional prefix maps by its heading, not the Chores default.
153
- if (opts && opts.legacyHeading != null) {
154
- const key = normalizeHeading(opts.legacyHeading);
155
- if (key in HEADING_SECTION) return { section: HEADING_SECTION[key], basis: 'heading' };
156
- }
157
- // 4. safe default — surfaced as a guess so a human can re-check.
158
- return { section: SECTION.CHORES, basis: 'fallback' };
159
- }
160
-
161
- /**
162
- * Remove every wiki tracker ID from `text`, leaving `#N` PR numbers untouched.
163
- * Cleans up the parens / stray whitespace the removed ID leaves behind so
164
- * `... (FEAT-N) (#N)` becomes `... (#N)`, not `... () (#N)`.
165
- *
166
- * @param {string} text
167
- * @returns {string}
168
- */
169
- export function sanitizeTrackerIds(text) {
170
- if (text == null) return '';
171
- let s = String(text);
172
- // 1. drop the tracker IDs themselves.
173
- s = s.replace(TRACKER_ID_RE, '');
174
- // 2. parens the ID emptied: `()` or `( )` -> gone.
175
- s = s.replace(/\(\s*\)/g, '');
176
- // 3. tidy whitespace the removal left: collapse runs, drop space hugging
177
- // brackets/punctuation, strip per-line trailing space.
178
- s = s.replace(/[ \t]{2,}/g, ' ');
179
- s = s.replace(/([([])[ \t]+/g, '$1');
180
- s = s.replace(/[ \t]+([)\].,;:])/g, '$1');
181
- // a removed leading label leaves orphaned punctuation at the start of a line
182
- // (e.g. `ISSUE-N: foo` -> `: foo`); drop it. NOTE: a tracker ID glued to other
183
- // text by a non-paren separator (`ISSUE-N/foo`) is not a CHANGELOG form and is
184
- // left minimally cleaned.
185
- s = s.replace(/^[ \t]*[:;,][ \t]*/gm, '');
186
- s = s.replace(/[ \t]+$/gm, '');
187
- return s.trim();
188
- }
189
-
190
- /**
191
- * Find internal labels (`Track A`, `OQ-NN`) that format.md §10 wants off the
192
- * public surface but that sanitizeTrackerIds intentionally does NOT auto-strip
193
- * (they need a PR-number substitution, a human call). Returns the matches so the
194
- * migration (T5) can flag and replace them rather than silently ship them.
195
- *
196
- * @param {string} text
197
- * @returns {string[]} matched labels (empty if none)
198
- */
199
- export function detectInternalLabels(text) {
200
- INTERNAL_LABEL_RE.lastIndex = 0;
201
- const out = String(text == null ? '' : text).match(INTERNAL_LABEL_RE);
202
- return out ? out : [];
203
- }
204
-
205
- /**
206
- * Does `text` still carry any wiki tracker ID? A cheap predicate for the
207
- * regression gate ("surface ID 0") — distinct from check-tracker-ids, which
208
- * only blocks ISSUE-/fix #. This sees all four prefixes.
209
- *
210
- * @param {string} text
211
- * @returns {boolean}
212
- */
213
- export function hasTrackerId(text) {
214
- TRACKER_ID_RE.lastIndex = 0;
215
- return TRACKER_ID_RE.test(String(text == null ? '' : text));
216
- }