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,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/check-bilingual.mjs — pure validators for the bilingual release-doc rule.
|
|
3
|
-
*
|
|
4
|
-
* Rule source: CLAUDE.md learned_behaviors release-doc-bilingual (2026-05-24).
|
|
5
|
-
* Every Hypomnema OSS ship must carry an English body PLUS a Korean summary
|
|
6
|
-
* block in both the CHANGELOG section and the git tag annotation. This module
|
|
7
|
-
* exports the parsing/validation primitives; the CLI wrapper in
|
|
8
|
-
* scripts/check-bilingual.mjs handles I/O and exit codes.
|
|
9
|
-
*
|
|
10
|
-
* Scope (deliberate): this gate only enforces the KOREAN half. The English
|
|
11
|
-
* half has been historically present in every ship — the rule exists because
|
|
12
|
-
* the Korean half is what gets silently dropped under time pressure (see
|
|
13
|
-
* release-doc-bilingual feedback page). A tag body of "---" + Korean with an
|
|
14
|
-
* empty English section would technically pass these validators, but that's
|
|
15
|
-
* acceptable because such a body is not a realistic failure mode for a
|
|
16
|
-
* maintainer who already wrote the English release notes. If "English missing"
|
|
17
|
-
* ever becomes a real regression vector, add an English-half threshold.
|
|
18
|
-
*
|
|
19
|
-
* Why pure functions: lets tests/runner.mjs construct synthetic CHANGELOG /
|
|
20
|
-
* tag-body strings without touching real git or real CHANGELOG.md.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
// AC00–D7A3 covers all precomposed Hangul syllables. We NFC-normalize the
|
|
24
|
-
// input before counting so jamo-only inputs (decomposed) still match after
|
|
25
|
-
// composition.
|
|
26
|
-
export const HANGUL_RE = /[가-힣]/g;
|
|
27
|
-
export const HANGUL_BODY_THRESHOLD = 10;
|
|
28
|
-
|
|
29
|
-
export function countHangul(text) {
|
|
30
|
-
return (text.normalize('NFC').match(HANGUL_RE) || []).length;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function escapeRegex(s) {
|
|
34
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Section model (changelog-pr-guide format.md §1/§2). A version block carries
|
|
38
|
-
// gated sections, each split into "#### English" / "#### 한국어" sub-blocks at
|
|
39
|
-
// and after the Korean cutoff. Highlights is gated too (it has a curated Korean
|
|
40
|
-
// half); Changelog and the migration callout are NOT gated (language-neutral).
|
|
41
|
-
export const GATED_HEADINGS = ['Highlights', 'New Features', 'Bug Fixes', 'Chores'];
|
|
42
|
-
|
|
43
|
-
// Korean-summary cutoff: 1.2.0 is the first release that shipped a Korean half
|
|
44
|
-
// (empirically the first "### 한글 요약" in CHANGELOG.md). 1.0.0–1.1.0 are
|
|
45
|
-
// English-only; we never fabricate a Korean half for them (format.md §9).
|
|
46
|
-
export const KOREAN_CUTOFF = [1, 2, 0];
|
|
47
|
-
|
|
48
|
-
// Parse "major.minor.patch" (ignoring any prerelease/build suffix) into a number
|
|
49
|
-
// triple, or null if it does not look like semver.
|
|
50
|
-
export function parseSemver(v) {
|
|
51
|
-
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v == null ? '' : v).trim());
|
|
52
|
-
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Is `version` at or past the Korean cutoff (>= 1.2.0)? A prerelease of a
|
|
56
|
-
// cutoff version (1.2.0-rc.1) counts as in-era. Unparseable → treated as not
|
|
57
|
-
// meeting the cutoff (the gate then only checks English presence).
|
|
58
|
-
export function meetsKoreanCutoff(version) {
|
|
59
|
-
const s = parseSemver(version);
|
|
60
|
-
if (!s) return false;
|
|
61
|
-
for (let i = 0; i < 3; i++) {
|
|
62
|
-
if (s[i] !== KOREAN_CUTOFF[i]) return s[i] > KOREAN_CUTOFF[i];
|
|
63
|
-
}
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Split `lines` into the sections introduced by a heading of exactly `hashes`
|
|
68
|
-
// '#'. A section ends at the next same-level heading OR any higher-level (fewer
|
|
69
|
-
// '#') heading; a deeper heading (more '#') stays inside the section body. So
|
|
70
|
-
// slicing a version block at level 3 keeps each "### Section" with its nested
|
|
71
|
-
// "#### English/한국어" lines, and re-slicing a section at level 4 yields those
|
|
72
|
-
// sub-blocks. Returns [{ title, body: string[] }].
|
|
73
|
-
function sliceSections(lines, hashes) {
|
|
74
|
-
const headRe = new RegExp(`^#{${hashes}} (.+?)\\s*$`);
|
|
75
|
-
const sameOrHigherRe = new RegExp(`^#{1,${hashes}} `);
|
|
76
|
-
const out = [];
|
|
77
|
-
let cur = null;
|
|
78
|
-
for (const line of lines) {
|
|
79
|
-
const m = headRe.exec(line);
|
|
80
|
-
if (m) {
|
|
81
|
-
if (cur) out.push(cur);
|
|
82
|
-
cur = { title: m[1].trim(), body: [] };
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
if (cur) {
|
|
86
|
-
// a same-or-higher level heading (that is not our own level) closes the
|
|
87
|
-
// open section without opening a tracked one.
|
|
88
|
-
if (sameOrHigherRe.test(line)) {
|
|
89
|
-
out.push(cur);
|
|
90
|
-
cur = null;
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
cur.body.push(line);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
if (cur) out.push(cur);
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// List the versions a CHANGELOG documents, in file order (skips "Unreleased").
|
|
101
|
-
export function listChangelogVersions(content) {
|
|
102
|
-
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
103
|
-
const re = /^## \[([^\]]+)\](\s.*)?$/;
|
|
104
|
-
const out = [];
|
|
105
|
-
for (const l of lines) {
|
|
106
|
-
const m = re.exec(l);
|
|
107
|
-
if (m && m[1] !== 'Unreleased') out.push(m[1]);
|
|
108
|
-
}
|
|
109
|
-
return out;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Validate one CHANGELOG version block against the section model.
|
|
114
|
-
*
|
|
115
|
-
* At/after the Korean cutoff (>= 1.2.0): the block must carry at least one gated
|
|
116
|
-
* section, and EVERY gated section present must hold both a "#### English" and a
|
|
117
|
-
* "#### 한국어" sub-block, the Korean one non-empty, with the version's total
|
|
118
|
-
* Korean >= HANGUL_BODY_THRESHOLD. Before the cutoff (1.0.0–1.1.0): English-only
|
|
119
|
-
* era — the block need only carry content; no Korean is required or fabricated.
|
|
120
|
-
*
|
|
121
|
-
* @param {string} content CHANGELOG.md raw content (CRLF tolerated).
|
|
122
|
-
* @param {string} version Semver string to look up (e.g. "1.2.1").
|
|
123
|
-
* @returns {{ok: true, hangulCount: number, koreanExempt?: boolean} | {ok: false, reason: string}}
|
|
124
|
-
*/
|
|
125
|
-
export function validateChangelog(content, version) {
|
|
126
|
-
if (!version) return { ok: false, reason: 'no version supplied' };
|
|
127
|
-
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
128
|
-
const versionEsc = escapeRegex(version);
|
|
129
|
-
// Anchor closing bracket so 1.2.1 does NOT match the prefix of 1.2.10.
|
|
130
|
-
const sectionRe = new RegExp(`^## \\[${versionEsc}\\](\\s.*)?$`);
|
|
131
|
-
|
|
132
|
-
const startIndices = [];
|
|
133
|
-
for (let i = 0; i < lines.length; i++) {
|
|
134
|
-
if (sectionRe.test(lines[i])) startIndices.push(i);
|
|
135
|
-
}
|
|
136
|
-
if (startIndices.length === 0) {
|
|
137
|
-
return { ok: false, reason: `no "## [${version}]" section in CHANGELOG.md` };
|
|
138
|
-
}
|
|
139
|
-
if (startIndices.length > 1) {
|
|
140
|
-
return {
|
|
141
|
-
ok: false,
|
|
142
|
-
reason: `duplicate "## [${version}]" sections (${startIndices.length}) in CHANGELOG.md`,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const start = startIndices[0];
|
|
147
|
-
let sectionEnd = lines.length;
|
|
148
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
149
|
-
if (/^## /.test(lines[i])) {
|
|
150
|
-
sectionEnd = i;
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const blockLines = lines.slice(start + 1, sectionEnd);
|
|
155
|
-
const sections = sliceSections(blockLines, 3);
|
|
156
|
-
const gated = sections.filter((s) => GATED_HEADINGS.includes(s.title));
|
|
157
|
-
|
|
158
|
-
if (!meetsKoreanCutoff(version)) {
|
|
159
|
-
// Pre-cutoff English-only era: require content (a non-heading body line),
|
|
160
|
-
// never a Korean half.
|
|
161
|
-
const hasContent = blockLines.some((l) => l.trim() && !/^#/.test(l));
|
|
162
|
-
if (!hasContent) {
|
|
163
|
-
return { ok: false, reason: `section [${version}] has no content` };
|
|
164
|
-
}
|
|
165
|
-
return { ok: true, hangulCount: 0, koreanExempt: true };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Cutoff+: enforce the per-section bilingual structure.
|
|
169
|
-
if (gated.length === 0) {
|
|
170
|
-
return {
|
|
171
|
-
ok: false,
|
|
172
|
-
reason:
|
|
173
|
-
`section [${version}] has no gated section ` +
|
|
174
|
-
`(one of ${GATED_HEADINGS.join(' / ')} is required at >= 1.2.0)`,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
let total = 0;
|
|
178
|
-
for (const sec of gated) {
|
|
179
|
-
const subs = sliceSections(sec.body, 4);
|
|
180
|
-
const hasEnglish = subs.some((s) => s.title === 'English');
|
|
181
|
-
const korean = subs.find((s) => s.title === '한국어');
|
|
182
|
-
if (!hasEnglish) {
|
|
183
|
-
return { ok: false, reason: `[${version}] "${sec.title}" missing "#### English" sub-block` };
|
|
184
|
-
}
|
|
185
|
-
if (!korean) {
|
|
186
|
-
return { ok: false, reason: `[${version}] "${sec.title}" missing "#### 한국어" sub-block` };
|
|
187
|
-
}
|
|
188
|
-
const koCount = countHangul(korean.body.join('\n'));
|
|
189
|
-
if (koCount < 1) {
|
|
190
|
-
return {
|
|
191
|
-
ok: false,
|
|
192
|
-
reason: `[${version}] "${sec.title}" "#### 한국어" has no Korean text (heading alone does not count)`,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
total += koCount;
|
|
196
|
-
}
|
|
197
|
-
if (total < HANGUL_BODY_THRESHOLD) {
|
|
198
|
-
return {
|
|
199
|
-
ok: false,
|
|
200
|
-
reason:
|
|
201
|
-
`section [${version}] total Korean is ${total} Hangul chars ` +
|
|
202
|
-
`(threshold: ${HANGUL_BODY_THRESHOLD}). Write real Korean summaries in the "#### 한국어" sub-blocks.`,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
return { ok: true, hangulCount: total };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Validate that a git tag annotation body has a "---" separator with Korean
|
|
210
|
-
* text after the LAST such separator. Tolerates earlier "---" markdown
|
|
211
|
-
* horizontal rules in the English body.
|
|
212
|
-
*
|
|
213
|
-
* @param {string} body Tag annotation body, as returned by
|
|
214
|
-
* `git tag -l --format='%(contents)' <ref>`.
|
|
215
|
-
* @returns {{ok: true, hangulCount: number} | {ok: false, reason: string}}
|
|
216
|
-
*/
|
|
217
|
-
export function validateTagBody(body) {
|
|
218
|
-
const lines = body.replace(/\r\n/g, '\n').split('\n');
|
|
219
|
-
let lastSepIdx = -1;
|
|
220
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
221
|
-
if (lines[i].trim() === '---') {
|
|
222
|
-
lastSepIdx = i;
|
|
223
|
-
break;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
if (lastSepIdx === -1) {
|
|
227
|
-
return {
|
|
228
|
-
ok: false,
|
|
229
|
-
reason:
|
|
230
|
-
'tag annotation has no "---" separator line (expected: English body + "---" + Korean)',
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
const tail = lines.slice(lastSepIdx + 1).join('\n');
|
|
234
|
-
const count = countHangul(tail);
|
|
235
|
-
if (count < HANGUL_BODY_THRESHOLD) {
|
|
236
|
-
return {
|
|
237
|
-
ok: false,
|
|
238
|
-
reason:
|
|
239
|
-
`tag body after the last "---" has only ${count} Hangul chars ` +
|
|
240
|
-
`(threshold: ${HANGUL_BODY_THRESHOLD}). Write a real Korean summary after the separator.`,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
return { ok: true, hangulCount: count };
|
|
244
|
-
}
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* lib/check-tracker-ids.mjs — pure scanner for wiki-internal tracker IDs.
|
|
3
|
-
*
|
|
4
|
-
* The OSS repo must not ship references to the maintainer's PRIVATE wiki issue
|
|
5
|
-
* tracker (`ISSUE-N`) or fix tracker (`fix #N`) in public artifacts: an OSS user
|
|
6
|
-
* who opens a shipped file or reads the README/CHANGELOG just sees a dangling
|
|
7
|
-
* pointer into a tracker they cannot access. GitHub references (`PR #N`, `(#N)`,
|
|
8
|
-
* bare `#N`, issue URLs) are legitimate and must NOT be flagged.
|
|
9
|
-
*
|
|
10
|
-
* This module is PURE (no fs, no git, no process) so it is unit-testable; the
|
|
11
|
-
* CLI wrapper (scripts/check-tracker-ids.mjs) does the file/git/commit-msg I/O.
|
|
12
|
-
*
|
|
13
|
-
* Blocked everywhere in scope (examples use an `N` placeholder, NOT a real
|
|
14
|
-
* digit, so this file itself scans clean and is NOT exempted from --all):
|
|
15
|
-
* - /\bISSUE-\d+\b/i → the wiki issue tracker, any case (e.g. ISSUE-N)
|
|
16
|
-
* - /\bfix[ \t ]+#\d+\b/i → the wiki fix tracker, any case (e.g. fix #N)
|
|
17
|
-
* - /\bFEAT-\d+\b/i → the wiki feature tracker, any case (e.g. FEAT-N)
|
|
18
|
-
* - /\bIMPR-\d+\b/i → the wiki improvement tracker, any case (e.g. IMPR-N)
|
|
19
|
-
* - /\bPRAC-\d+\b/i → the wiki practice tracker, any case (e.g. PRAC-N)
|
|
20
|
-
*
|
|
21
|
-
* FEAT-/IMPR-/PRAC- were once exempt inside shipped code comments; that exemption
|
|
22
|
-
* shipped dangling pointers into the maintainer's private wiki to OSS users, so it
|
|
23
|
-
* was removed. They now block everywhere in scope; the maintainer keeps them only
|
|
24
|
-
* in tests/, qa-runs/, and local notes. The verifier subsystem that legitimately
|
|
25
|
-
* carries `decisions/NNNN` runtime data is excluded from the scan instead (see the
|
|
26
|
-
* CLI's EXCLUDED_FILES).
|
|
27
|
-
*
|
|
28
|
-
* `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` (DECISION_PATTERNS) are dangling
|
|
29
|
-
* pointers into the maintainer's private wiki ADR set. They block everywhere in
|
|
30
|
-
* scope EXCEPT CHANGELOG.md, whose version history legitimately cites the decision
|
|
31
|
-
* behind a release line. The CLI applies these per-file via patternsFor().
|
|
32
|
-
*
|
|
33
|
-
* Accepted edge cases (documented, not bugs):
|
|
34
|
-
* - FALSE POSITIVE: `FOO-ISSUE-N` matches (the `-` gives a word boundary
|
|
35
|
-
* before ISSUE). Such a string in this repo would still be a tracker ref.
|
|
36
|
-
* - The `fix #N` matcher requires the literal word `fix` immediately before the
|
|
37
|
-
* `#`, so `prefix #N`, `suffix #N`, `PR #N`, `(#N)`, and bare `#N` are all
|
|
38
|
-
* safe — none start the word `fix` right before the `#`.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
// `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` point into the maintainer's private
|
|
42
|
-
// wiki ADR set, which an OSS user does not have. The CLI applies this set to every
|
|
43
|
-
// in-scope file EXCEPT CHANGELOG.md (version history may cite a decision); the
|
|
44
|
-
// verifier subsystem that carries decisions/ paths as runtime data is removed by
|
|
45
|
-
// EXCLUDED_FILES first. The ADR matcher tolerates a space, tab, or hyphen between
|
|
46
|
-
// `ADR` and the number. Examples below use `NNNN`, not real digits, so this file
|
|
47
|
-
// scans clean.
|
|
48
|
-
export const DECISION_PATTERNS = [
|
|
49
|
-
{
|
|
50
|
-
name: 'ADR NNNN',
|
|
51
|
-
label: 'wiki ADR pointer',
|
|
52
|
-
re: /\bADR[ \t-]+\d{3,4}\b/gi,
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
name: 'decisions/NNNN',
|
|
56
|
-
label: 'wiki decisions path',
|
|
57
|
-
re: /\bdecisions\/\d{3,4}\b/gi,
|
|
58
|
-
},
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
// Each entry: a named, /g/i regex over a single line of text.
|
|
62
|
-
export const BLOCKED_PATTERNS = [
|
|
63
|
-
{
|
|
64
|
-
name: 'ISSUE-N',
|
|
65
|
-
label: 'wiki issue-tracker id',
|
|
66
|
-
re: /\bISSUE-\d+\b/gi,
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
name: 'fix #N',
|
|
70
|
-
label: 'wiki fix-tracker id',
|
|
71
|
-
re: /\bfix[ \t ]+#\d+\b/gi,
|
|
72
|
-
},
|
|
73
|
-
// FEAT-/IMPR-/PRAC- were a tag-body-only set until they were promoted here so
|
|
74
|
-
// they block in shipped code comments too (the old exemption leaked dead wiki
|
|
75
|
-
// pointers to OSS users). Examples below use `N`, not a real digit, so this
|
|
76
|
-
// file scans clean and is NOT exempted from --all.
|
|
77
|
-
{ name: 'FEAT-N', label: 'wiki feature-tracker id', re: /\bFEAT-\d+\b/gi },
|
|
78
|
-
{ name: 'IMPR-N', label: 'wiki improvement-tracker id', re: /\bIMPR-\d+\b/gi },
|
|
79
|
-
{ name: 'PRAC-N', label: 'wiki practice-tracker id', re: /\bPRAC-\d+\b/gi },
|
|
80
|
-
];
|
|
81
|
-
|
|
82
|
-
// The full tracker-ID set for a no-code public surface (the annotated tag body,
|
|
83
|
-
// republished verbatim by `gh release create --notes-from-tag`). It equals
|
|
84
|
-
// BLOCKED_PATTERNS now that FEAT-/IMPR-/PRAC- live there; ADR/decisions anchors
|
|
85
|
-
// are intentionally allowed in the tag body (release notes cite decisions the way
|
|
86
|
-
// CHANGELOG history does). The CHANGELOG's own surface-ID-0 is held by the section
|
|
87
|
-
// migration + a grep regression test (changelog-pr-guide §5).
|
|
88
|
-
export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS];
|
|
89
|
-
|
|
90
|
-
// Strip a leading comment-continuation marker (`*`, `//`, `#`) so a wrapped
|
|
91
|
-
// comment line can be re-joined to its predecessor as flowing text.
|
|
92
|
-
const COMMENT_CONT_RE = /^[ \t]*(?:\*|\/\/|#+)[ \t]?/;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
|
|
96
|
-
* { pattern, label, match, line, col, lineText }
|
|
97
|
-
* `line`/`col` are 1-based. Empty array => clean. The CLI passes the broader
|
|
98
|
-
* [...BLOCKED_PATTERNS, ...DECISION_PATTERNS] set for every in-scope file but the
|
|
99
|
-
* CHANGELOG.
|
|
100
|
-
*
|
|
101
|
-
* Tracker tokens also line-wrap inside comments (`... continuing (ADR\n * 0045)`).
|
|
102
|
-
* A pure per-line scan misses those, so each line is ALSO scanned joined to the
|
|
103
|
-
* next (with the next line's comment-continuation marker collapsed to a space);
|
|
104
|
-
* only matches that START on the current line and CROSS the join are kept, so a
|
|
105
|
-
* wrap is reported exactly once at its prefix line and non-wrapped tokens are
|
|
106
|
-
* never double-counted.
|
|
107
|
-
*/
|
|
108
|
-
export function scanText(text, patterns = BLOCKED_PATTERNS) {
|
|
109
|
-
if (typeof text !== 'string' || text.length === 0) return [];
|
|
110
|
-
const hits = [];
|
|
111
|
-
const lines = text.split(/\r?\n/);
|
|
112
|
-
for (let i = 0; i < lines.length; i++) {
|
|
113
|
-
const lineText = lines[i];
|
|
114
|
-
for (const { name, label, re } of patterns) {
|
|
115
|
-
re.lastIndex = 0; // reset shared /g regex between lines
|
|
116
|
-
let m;
|
|
117
|
-
while ((m = re.exec(lineText)) !== null) {
|
|
118
|
-
hits.push({
|
|
119
|
-
pattern: name,
|
|
120
|
-
label,
|
|
121
|
-
match: m[0],
|
|
122
|
-
line: i + 1,
|
|
123
|
-
col: m.index + 1,
|
|
124
|
-
lineText,
|
|
125
|
-
});
|
|
126
|
-
if (m.index === re.lastIndex) re.lastIndex++; // never-zero-width guard
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
// Wrapped-token guard: a token can line-wrap at two points — between its
|
|
130
|
-
// prefix WORD and its number (`ADR\n0045`, where the break stands in for a
|
|
131
|
-
// space) OR right at its separator (`ISSUE-\n9`, `decisions/\n0031`,
|
|
132
|
-
// `fix #\n37`, where the digit must sit flush against `-`/`/`/`#`). So scan
|
|
133
|
-
// this line joined to the next BOTH with a space (space-separated forms) and
|
|
134
|
-
// with no gap (separator-flush forms), keeping only matches that begin on this
|
|
135
|
-
// line and cross the join. A token reconstructed by both joins (e.g. the ADR
|
|
136
|
-
// hyphen form) is de-duplicated by its whitespace-collapsed text.
|
|
137
|
-
if (i + 1 < lines.length) {
|
|
138
|
-
const lt = lineText.length;
|
|
139
|
-
const tail = lines[i + 1].replace(COMMENT_CONT_RE, '');
|
|
140
|
-
const seen = new Set();
|
|
141
|
-
for (const [joined, tailAt] of [
|
|
142
|
-
[lineText + ' ' + tail, lt + 1],
|
|
143
|
-
[lineText + tail, lt],
|
|
144
|
-
]) {
|
|
145
|
-
for (const { name, label, re } of patterns) {
|
|
146
|
-
re.lastIndex = 0;
|
|
147
|
-
let m;
|
|
148
|
-
while ((m = re.exec(joined)) !== null) {
|
|
149
|
-
if (m.index < lt && m.index + m[0].length > tailAt) {
|
|
150
|
-
const key = name + ':' + m[0].replace(/\s+/g, '');
|
|
151
|
-
if (!seen.has(key)) {
|
|
152
|
-
seen.add(key);
|
|
153
|
-
hits.push({
|
|
154
|
-
pattern: name,
|
|
155
|
-
label,
|
|
156
|
-
match: m[0],
|
|
157
|
-
line: i + 1,
|
|
158
|
-
col: m.index + 1,
|
|
159
|
-
lineText: joined,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (m.index === re.lastIndex) re.lastIndex++;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return hits;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Strip a `--verbose` commit diff: everything from the scissors line onward is
|
|
174
|
-
* dropped by git before the commit is recorded, so it must not be scanned. The
|
|
175
|
-
* scissors art is comment-char-prefixed but the `>8` token is constant across
|
|
176
|
-
* comment chars, so match on that. Returns the text up to (excluding) the
|
|
177
|
-
* scissors line. If no scissors line is present, returns the input unchanged.
|
|
178
|
-
*/
|
|
179
|
-
// git's real scissors line is COMMENT-prefixed (`# ----- >8 -----`); a bare
|
|
180
|
-
// `--- >8 ---` line is NOT a scissors marker (git keeps it as body), so it must
|
|
181
|
-
// not trigger truncation. Require the leading comment char (default `#`, or `;`).
|
|
182
|
-
const SCISSORS_RE = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/;
|
|
183
|
-
|
|
184
|
-
export function stripScissors(text) {
|
|
185
|
-
if (typeof text !== 'string') return '';
|
|
186
|
-
const lines = text.split(/\r?\n/);
|
|
187
|
-
const idx = lines.findIndex((l) => SCISSORS_RE.test(l));
|
|
188
|
-
if (idx === -1) return text;
|
|
189
|
-
return lines.slice(0, idx).join('\n');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Does a commit-message file carry git's auto-generated template? git ONLY adds
|
|
194
|
-
* the instructional / status comment block (and the --verbose scissors) when the
|
|
195
|
-
* effective cleanup mode will STRIP comment lines (the editor default). In the
|
|
196
|
-
* comment-PRESERVING modes (`git commit -m` → whitespace, `--cleanup=verbatim`)
|
|
197
|
-
* git adds NO template, so a `#` line there is real committed content.
|
|
198
|
-
*
|
|
199
|
-
* The commit-msg hook therefore decides comment handling by template presence,
|
|
200
|
-
* not by config alone: template present ⇒ scan the strip-comments view (matches
|
|
201
|
-
* git, no false-positive on the template's branch/file names); template absent ⇒
|
|
202
|
-
* scan the raw view so a `#`-prefixed tracker id git WILL keep is still caught.
|
|
203
|
-
*
|
|
204
|
-
* Markers cover the default English git template + the scissors line. Other
|
|
205
|
-
* locales may miss the template and fall through to the raw (comment-keeping)
|
|
206
|
-
* scan — which is the SAFE direction for a gate (catches more, never less).
|
|
207
|
-
*/
|
|
208
|
-
export function messageHasGitTemplate(text) {
|
|
209
|
-
if (typeof text !== 'string') return false;
|
|
210
|
-
if (SCISSORS_RE_M.test(text)) return true; // --verbose scissors (comment-prefixed)
|
|
211
|
-
return /^[#;] *(Please enter the commit message|On branch |Changes to be committed:|Changes not staged for commit:|Untracked files:|Your branch (is|and))/m.test(
|
|
212
|
-
text,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Multiline form of SCISSORS_RE for whole-message detection.
|
|
217
|
-
const SCISSORS_RE_M = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/m;
|