hypomnema 1.3.4 → 1.4.1

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/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +151 -153
  4. package/README.md +121 -123
  5. package/commands/audit.md +4 -4
  6. package/commands/crystallize.md +18 -5
  7. package/commands/doctor.md +3 -3
  8. package/commands/feedback.md +9 -3
  9. package/commands/graph.md +3 -3
  10. package/commands/ingest.md +6 -4
  11. package/commands/init.md +5 -3
  12. package/commands/lint.md +3 -3
  13. package/commands/query.md +3 -3
  14. package/commands/rename.md +4 -4
  15. package/commands/resume.md +4 -2
  16. package/commands/stats.md +3 -3
  17. package/commands/upgrade.md +4 -4
  18. package/commands/verify.md +3 -3
  19. package/docs/ARCHITECTURE.md +4 -2
  20. package/docs/CONTRIBUTING.md +148 -25
  21. package/hooks/hypo-auto-commit.mjs +6 -12
  22. package/hooks/hypo-session-record.mjs +5 -0
  23. package/hooks/hypo-session-start.mjs +7 -0
  24. package/hooks/hypo-shared.mjs +68 -1
  25. package/package.json +10 -2
  26. package/scripts/check-bilingual.mjs +49 -11
  27. package/scripts/check-readme-version.mjs +126 -0
  28. package/scripts/check-tracker-ids.mjs +60 -1
  29. package/scripts/check-versions.mjs +171 -0
  30. package/scripts/crystallize.mjs +49 -34
  31. package/scripts/doctor.mjs +13 -4
  32. package/scripts/feedback.mjs +68 -1
  33. package/scripts/graph.mjs +5 -32
  34. package/scripts/init.mjs +2 -1
  35. package/scripts/lib/changelog-classify.mjs +216 -0
  36. package/scripts/lib/check-bilingual.mjs +125 -22
  37. package/scripts/lib/check-tracker-ids.mjs +19 -0
  38. package/scripts/lib/failure-type.mjs +33 -0
  39. package/scripts/lib/frontmatter.mjs +23 -2
  40. package/scripts/lib/schema-vocab.mjs +35 -0
  41. package/scripts/lib/template-schema-version.mjs +21 -0
  42. package/scripts/lib/wikilink.mjs +156 -0
  43. package/scripts/lint.mjs +86 -47
  44. package/scripts/rename.mjs +6 -30
  45. package/scripts/stats.mjs +22 -2
  46. package/scripts/upgrade.mjs +11 -3
  47. package/scripts/weekly-report.mjs +9 -3
  48. package/skills/crystallize/SKILL.md +21 -1
  49. package/templates/SCHEMA.md +25 -1
  50. package/templates/hypo-config.md +1 -1
  51. package/scripts/bump-version.mjs +0 -63
  52. package/scripts/smoke-pack.mjs +0 -261
@@ -60,20 +60,14 @@
60
60
  * hard-fails regardless of scope.
61
61
  */
62
62
 
63
- import {
64
- existsSync,
65
- readFileSync,
66
- readdirSync,
67
- statSync,
68
- writeFileSync,
69
- mkdirSync,
70
- renameSync,
71
- } from 'fs';
72
- import { join, relative, extname, dirname } from 'path';
63
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
64
+ import { join, dirname } from 'path';
65
+ import { hostname } from 'os';
73
66
  import { spawnSync } from 'child_process';
74
67
  import { fileURLToPath } from 'url';
75
68
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
76
- import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
69
+ import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
70
+ import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
77
71
  import {
78
72
  sessionCloseFileStatus,
79
73
  sessionCloseGlobalStatus,
@@ -756,9 +750,21 @@ function applySessionClose(args) {
756
750
  // otherwise mistake it for the evidence file. The dated `## [date] ...`
757
751
  // heading lives inside the entry, so freshness / derive / design-history
758
752
  // are unchanged.
753
+ // PRAC-17 audit fields. The shard frontmatter is git-tracked and synced, so
754
+ // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
755
+ // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
756
+ // machine that first seeds the daily shard is recorded; later same-day
757
+ // appends do not touch it. The per-session-accurate store is the LOCAL
758
+ // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
759
+ // `session_id` is honest naming: the value is the Claude session UUID, and
760
+ // it is present only on the Stop-chain close path that passes --session-id.
761
+ const device = String(hostname() || 'unknown').replace(/[\r\n]/g, '');
762
+ const auditFm =
763
+ (args.sessionId ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n` : '') +
764
+ `device: ${device}\n`;
759
765
  const header =
760
766
  `---\ntitle: Session Log ${date} (${project})\n` +
761
- `type: session-log\nupdated: ${date}\n---\n\n` +
767
+ `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
762
768
  `# Session Log ${date} (${project})\n`;
763
769
  const entry = payload.sessionLog.entry;
764
770
  const body = entry.endsWith('\n') ? entry : `${entry}\n`;
@@ -913,8 +919,36 @@ function applySessionClose(args) {
913
919
  for (const a of applied) console.log(` ✓ wrote ${a}`);
914
920
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
915
921
  if (ok) {
916
- console.log('\n✓ session-close verified all 5 mandatory files fresh, lint clean.');
917
- } else {
922
+ // When the marker was withheld, qualify the success line so a reader scanning
923
+ // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
924
+ // is non-null exactly when args.sessionId is set and the marker did not land.
925
+ if (markerSkipReason) {
926
+ console.log(
927
+ '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
928
+ '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
929
+ );
930
+ } else {
931
+ console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
932
+ }
933
+ }
934
+ // When ok:true but the session-close marker was NOT written, the Stop-chain
935
+ // still sees an open session and will re-prompt at the next Stop. Surface this
936
+ // loudly so neither the human nor a skill-following model reads "ok:true" as
937
+ // "session fully closed". Gate on markerSkipReason (non-null exactly when
938
+ // args.sessionId is present and the marker was withheld).
939
+ if (markerSkipReason) {
940
+ process.stderr.write(
941
+ `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
942
+ ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
943
+ ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
944
+ ` closed: the Stop hook will re-prompt until the marker is present.\n` +
945
+ ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
946
+ ` a background task or Agent UUID from a /tmp/... path).\n` +
947
+ ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
948
+ ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
949
+ );
950
+ }
951
+ if (!ok) {
918
952
  if (!verification.ok) {
919
953
  const bad = [
920
954
  ...verification.missing.map((f) => `${f} (missing)`),
@@ -944,21 +978,6 @@ function applySessionClose(args) {
944
978
 
945
979
  // ── helpers ──────────────────────────────────────────────────────────────────
946
980
 
947
- function collectPages(dir, root, acc = [], ignorePatterns = []) {
948
- if (!existsSync(dir)) return acc;
949
- for (const entry of readdirSync(dir)) {
950
- if (entry.startsWith('.')) continue;
951
- const full = join(dir, entry);
952
- if (isScanIgnored(full, root, ignorePatterns)) continue;
953
- const st = statSync(full);
954
- if (st.isDirectory()) collectPages(full, root, acc, ignorePatterns);
955
- else if (extname(entry) === '.md') {
956
- acc.push({ path: full, rel: relative(root, full) });
957
- }
958
- }
959
- return acc;
960
- }
961
-
962
981
  function parseFrontmatter(content) {
963
982
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
964
983
  if (!m) return null;
@@ -983,10 +1002,6 @@ function parseTags(fm) {
983
1002
  .filter(Boolean);
984
1003
  }
985
1004
 
986
- function extractWikilinks(content) {
987
- return [...content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) => m[1].trim());
988
- }
989
-
990
1005
  // ── main ─────────────────────────────────────────────────────────────────────
991
1006
 
992
1007
  const args = parseArgs(process.argv);
@@ -1005,7 +1020,7 @@ if (args.checkSessionClose) {
1005
1020
 
1006
1021
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
1007
1022
  const pagesDir = join(args.hypoDir, 'pages');
1008
- const pages = collectPages(pagesDir, args.hypoDir, [], ignorePatterns);
1023
+ const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
1009
1024
 
1010
1025
  const tagGroups = {}; // tag → [{ slug, title }]
1011
1026
  const unlinked = []; // pages with no outbound wikilinks
@@ -528,10 +528,19 @@ function checkSyncState(hypoDir) {
528
528
  pass('Sync state', 'No unresolved sync failures');
529
529
  } else {
530
530
  const last = entries[entries.length - 1];
531
- warn(
532
- 'Sync state',
533
- `${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
534
- );
531
+ // A merge conflict needs a real manual merge, not a plain push/pull — give
532
+ // the same explicit guidance session-start does instead of the generic hint.
533
+ if (String(last.op || '').startsWith('conflict')) {
534
+ warn(
535
+ 'Sync state',
536
+ `${entries.length} unresolved sync issue(s) — last: remote diverged (merge conflict). Your local work is committed; the other machine's version is on the remote. Resolve with \`git pull --no-rebase\`, fix conflicts, then push.`,
537
+ );
538
+ } else {
539
+ warn(
540
+ 'Sync state',
541
+ `${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
542
+ );
543
+ }
535
544
  }
536
545
  }
537
546
 
@@ -48,6 +48,8 @@ import { spawnSync } from 'child_process';
48
48
  import { fileURLToPath } from 'url';
49
49
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
50
50
  import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
51
+ import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
52
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
51
53
 
52
54
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
53
55
 
@@ -69,6 +71,7 @@ function parseArgs(argv) {
69
71
  promoteToGlobal: false,
70
72
  reason: null,
71
73
  source: null,
74
+ failureType: null,
72
75
  behavior: null,
73
76
  claudeHome: null,
74
77
  projectId: null,
@@ -91,6 +94,7 @@ function parseArgs(argv) {
91
94
  else if (arg === '--promote-to-global') args.promoteToGlobal = true;
92
95
  else if (arg.startsWith('--reason=')) args.reason = arg.slice(9);
93
96
  else if (arg.startsWith('--source=')) args.source = arg.slice(9);
97
+ else if (arg.startsWith('--failure-type=')) args.failureType = arg.slice(15);
94
98
  else if (arg.startsWith('--behavior=')) args.behavior = arg.slice(11);
95
99
  else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
96
100
  else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
@@ -132,6 +136,16 @@ function parseTargets(raw) {
132
136
  .filter(Boolean);
133
137
  }
134
138
 
139
+ // FEAT-1: `failure_type` is OPTIONAL — an unset value is always fine. A set value
140
+ // must be one of the eight enum members (same vocabulary lint enforces). Returns
141
+ // an error string or null. Shared by create (validateClassification) and append.
142
+ function failureTypeError(value) {
143
+ if (!value) return null;
144
+ if (!FAILURE_TYPE_ENUM.includes(value))
145
+ return `--failure-type invalid: "${value}" (allowed: ${FAILURE_TYPE_ENUM.join(', ')})`;
146
+ return null;
147
+ }
148
+
135
149
  // Validate the create-mode classification. Returns an array of error strings.
136
150
  function validateClassification(args, targets) {
137
151
  const errs = [];
@@ -151,6 +165,8 @@ function validateClassification(args, targets) {
151
165
  errs.push(`--priority must be an integer 1-5 (got "${args.priority}")`);
152
166
  if (!args.memorySummary) errs.push('--memory-summary is required');
153
167
  if (!args.reason) errs.push('--reason is required');
168
+ const ftErr = failureTypeError(args.failureType);
169
+ if (ftErr) errs.push(ftErr);
154
170
 
155
171
  // CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
156
172
  // carry the two conditional fields (lint #8). Enforce here so we never write a
@@ -201,6 +217,7 @@ function renderPage(args, targets, today) {
201
217
  }
202
218
  lines.push(`reason: ${oneLine(args.reason)}`);
203
219
  lines.push(`source: ${oneLine(args.source || `session:${today}`)}`);
220
+ if (args.failureType) lines.push(`failure_type: ${args.failureType}`);
204
221
  lines.push(`corrected_at: ${today}`);
205
222
  lines.push(`updated: ${today}`);
206
223
  lines.push(`created: ${today}`);
@@ -233,6 +250,24 @@ function bumpUpdated(content, today) {
233
250
  return content.replace(m[0], `---\n${bumped}\n---`);
234
251
  }
235
252
 
253
+ // Set `failure_type: <value>` in the leading frontmatter block. Scoped to the
254
+ // first `---` fence (like bumpUpdated) so a body line starting "failure_type:" is
255
+ // never touched; the `^` anchor keeps it top-level (an indented/nested key starts
256
+ // with whitespace and won't match). CRLF-aware (the shared parser accepts CRLF,
257
+ // so this must too — an LF-only match would silently skip a CRLF page) and it
258
+ // REPLACES an existing empty `failure_type:` line rather than leaving it blank.
259
+ // The caller only invokes this when the page has no real value (absent or empty),
260
+ // so a populated top-level key never reaches here.
261
+ function addFailureType(content, value) {
262
+ const m = content.match(/^---(\r?\n)([\s\S]*?)\r?\n---/);
263
+ if (!m) return content; // no frontmatter fence to host the field
264
+ const nl = m[1];
265
+ const fm = /^failure_type:\s*.*$/m.test(m[2])
266
+ ? m[2].replace(/^failure_type:\s*.*$/m, `failure_type: ${value}`)
267
+ : `${m[2]}${nl}failure_type: ${value}`;
268
+ return content.replace(m[0], `---${nl}${fm}${nl}---`);
269
+ }
270
+
236
271
  function writeFeedback(args, today) {
237
272
  const feedbackDir = join(args.hypoDir, 'pages', 'feedback');
238
273
  const filePath = join(feedbackDir, `${args.topic}.md`);
@@ -244,7 +279,39 @@ function writeFeedback(args, today) {
244
279
  // Append a dated entry; preserve existing frontmatter classification.
245
280
  mode = 'append';
246
281
  const existing = readFileSync(filePath, 'utf-8');
247
- const appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
282
+ // FEAT-1: failure_type is a per-page classification property. On append,
283
+ // set it if the page has none, error if it conflicts with an existing value,
284
+ // no-op if it matches. Without the flag, append is byte-for-byte unchanged.
285
+ const existingFt = (parseFrontmatter(existing) || {}).failure_type || null;
286
+ if (args.failureType) {
287
+ const ftErr = failureTypeError(args.failureType);
288
+ if (ftErr) {
289
+ console.error(`Error: ${ftErr}`);
290
+ process.exit(1);
291
+ }
292
+ if (existingFt && existingFt !== args.failureType) {
293
+ console.error(
294
+ `Error: failure_type mismatch on append: page has "${existingFt}", ` +
295
+ `--failure-type=${args.failureType}. A feedback page carries a single ` +
296
+ `failure_type; use a separate topic for a different failure type.`,
297
+ );
298
+ process.exit(1);
299
+ }
300
+ }
301
+ let appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
302
+ if (args.failureType && !existingFt) {
303
+ appended = addFailureType(appended, args.failureType);
304
+ // Fail loud rather than silently appending without the field: if the page's
305
+ // frontmatter is too malformed to host failure_type (no fence at all), the
306
+ // set-if-absent contract could not be honored.
307
+ if ((parseFrontmatter(appended) || {}).failure_type !== args.failureType) {
308
+ console.error(
309
+ `Error: could not set failure_type on "${args.topic}" — its frontmatter ` +
310
+ `is malformed (no parseable --- block). Fix the page, then retry.`,
311
+ );
312
+ process.exit(1);
313
+ }
314
+ }
248
315
  content = bumpUpdated(appended, today);
249
316
  } else {
250
317
  mode = 'create';
package/scripts/graph.mjs CHANGED
@@ -14,10 +14,11 @@
14
14
  * --min-edges=<n> Only include nodes with at least N edges (default: 0)
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
18
- import { join, relative, extname, basename } from 'path';
17
+ import { readFileSync } from 'fs';
18
+ import { join } from 'path';
19
19
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
20
- import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
20
+ import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
21
+ import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
21
22
 
22
23
  // ── arg parsing ───────────────────────────────────────────────────────────────
23
24
 
@@ -32,24 +33,6 @@ function parseArgs(argv) {
32
33
  return args;
33
34
  }
34
35
 
35
- // ── page collector ────────────────────────────────────────────────────────────
36
-
37
- function collectPages(dir, root, pages = [], ignorePatterns = []) {
38
- if (!existsSync(dir)) return pages;
39
- for (const entry of readdirSync(dir)) {
40
- const full = join(dir, entry);
41
- if (isScanIgnored(full, root, ignorePatterns)) continue;
42
- const st = statSync(full);
43
- if (st.isDirectory()) {
44
- collectPages(full, root, pages, ignorePatterns);
45
- } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
46
- const slug = relative(root, full).replace(/\.md$/, '').replace(/\\/g, '/');
47
- pages.push({ path: full, slug, bare: basename(full, '.md') });
48
- }
49
- }
50
- return pages;
51
- }
52
-
53
36
  // ── slug resolver ─────────────────────────────────────────────────────────────
54
37
 
55
38
  function buildSlugIndex(pages) {
@@ -61,16 +44,6 @@ function buildSlugIndex(pages) {
61
44
  return index;
62
45
  }
63
46
 
64
- // ── wikilink extractor ────────────────────────────────────────────────────────
65
-
66
- function extractWikilinks(content) {
67
- const links = [];
68
- for (const m of content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)) {
69
- links.push(m[1].trim());
70
- }
71
- return links;
72
- }
73
-
74
47
  // ── graph builder ─────────────────────────────────────────────────────────────
75
48
 
76
49
  function buildGraph(pages, slugIndex) {
@@ -180,7 +153,7 @@ const args = parseArgs(process.argv);
180
153
 
181
154
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
182
155
  const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
183
- const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
156
+ const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignorePatterns));
184
157
  const slugIndex = buildSlugIndex(pages);
185
158
  const graph = buildGraph(pages, slugIndex);
186
159
 
package/scripts/init.mjs CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  readFileIfRegular,
47
47
  } from './lib/pkg-json.mjs';
48
48
  import { syncExtensions } from './lib/extensions.mjs';
49
+ import { templateSchemaVersion } from './lib/template-schema-version.mjs';
49
50
  import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
50
51
 
51
52
  const HOME = homedir();
@@ -477,7 +478,7 @@ function writePkgJson(dryRun, extraFields = {}) {
477
478
  ...existing,
478
479
  pkgRoot: PKG_ROOT,
479
480
  pkgVersion: PKG_VERSION,
480
- schemaVersion: '2.0',
481
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
481
482
  ...extraFields,
482
483
  };
483
484
  if (!dryRun) {
@@ -0,0 +1,216 @@
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
+ }