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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- 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
|
-
}
|