hypomnema 1.4.0 → 1.4.2
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 +151 -153
- package/README.md +121 -123
- package/commands/audit.md +4 -4
- package/commands/crystallize.md +22 -7
- package/commands/doctor.md +3 -3
- package/commands/feedback.md +5 -3
- package/commands/graph.md +3 -3
- package/commands/ingest.md +6 -4
- package/commands/init.md +5 -3
- package/commands/lint.md +3 -3
- package/commands/query.md +3 -3
- package/commands/rename.md +4 -4
- package/commands/resume.md +4 -2
- package/commands/stats.md +3 -3
- package/commands/upgrade.md +4 -4
- package/commands/verify.md +3 -3
- package/docs/CONTRIBUTING.md +107 -16
- package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
- package/hooks/hypo-shared.mjs +96 -14
- package/package.json +6 -1
- package/scripts/check-bilingual.mjs +49 -11
- package/scripts/check-tracker-ids.mjs +60 -1
- package/scripts/crystallize.mjs +275 -39
- package/scripts/lib/changelog-classify.mjs +216 -0
- package/scripts/lib/check-bilingual.mjs +125 -22
- package/scripts/lib/check-tracker-ids.mjs +19 -0
- package/scripts/lib/project-create.mjs +23 -5
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lint.mjs +9 -1
- package/scripts/weekly-report.mjs +9 -3
- package/skills/crystallize/SKILL.md +30 -2
- package/templates/SCHEMA.md +11 -3
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -1
- package/scripts/bump-version.mjs +0 -77
- package/scripts/smoke-pack.mjs +0 -261
- package/scripts/smoke-plugin.mjs +0 -194
|
@@ -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
|
+
}
|
|
@@ -34,20 +34,99 @@ export function escapeRegex(s) {
|
|
|
34
34
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
35
35
|
}
|
|
36
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
|
+
|
|
37
112
|
/**
|
|
38
|
-
* Validate
|
|
39
|
-
*
|
|
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.
|
|
40
120
|
*
|
|
41
121
|
* @param {string} content CHANGELOG.md raw content (CRLF tolerated).
|
|
42
122
|
* @param {string} version Semver string to look up (e.g. "1.2.1").
|
|
43
|
-
* @returns {{ok: true, hangulCount: number} | {ok: false, reason: string}}
|
|
123
|
+
* @returns {{ok: true, hangulCount: number, koreanExempt?: boolean} | {ok: false, reason: string}}
|
|
44
124
|
*/
|
|
45
125
|
export function validateChangelog(content, version) {
|
|
46
126
|
if (!version) return { ok: false, reason: 'no version supplied' };
|
|
47
127
|
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
48
128
|
const versionEsc = escapeRegex(version);
|
|
49
129
|
// Anchor closing bracket so 1.2.1 does NOT match the prefix of 1.2.10.
|
|
50
|
-
// Allow trailing " - YYYY-MM-DD" or nothing.
|
|
51
130
|
const sectionRe = new RegExp(`^## \\[${versionEsc}\\](\\s.*)?$`);
|
|
52
131
|
|
|
53
132
|
const startIndices = [];
|
|
@@ -72,34 +151,58 @@ export function validateChangelog(content, version) {
|
|
|
72
151
|
break;
|
|
73
152
|
}
|
|
74
153
|
}
|
|
75
|
-
const
|
|
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));
|
|
76
157
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 };
|
|
80
166
|
}
|
|
81
167
|
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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` };
|
|
90
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;
|
|
91
196
|
}
|
|
92
|
-
|
|
93
|
-
const count = countHangul(body);
|
|
94
|
-
if (count < HANGUL_BODY_THRESHOLD) {
|
|
197
|
+
if (total < HANGUL_BODY_THRESHOLD) {
|
|
95
198
|
return {
|
|
96
199
|
ok: false,
|
|
97
200
|
reason:
|
|
98
|
-
`section [${version}]
|
|
99
|
-
`(threshold: ${HANGUL_BODY_THRESHOLD}).
|
|
201
|
+
`section [${version}] total Korean is ${total} Hangul chars ` +
|
|
202
|
+
`(threshold: ${HANGUL_BODY_THRESHOLD}). Write real Korean summaries in the "#### 한국어" sub-blocks.`,
|
|
100
203
|
};
|
|
101
204
|
}
|
|
102
|
-
return { ok: true, hangulCount:
|
|
205
|
+
return { ok: true, hangulCount: total };
|
|
103
206
|
}
|
|
104
207
|
|
|
105
208
|
/**
|
|
@@ -61,6 +61,25 @@ export const BLOCKED_PATTERNS = [
|
|
|
61
61
|
},
|
|
62
62
|
];
|
|
63
63
|
|
|
64
|
+
// The remaining wiki tracker prefixes (FEAT-/IMPR-/PRAC-). These are NOT in
|
|
65
|
+
// BLOCKED_PATTERNS because shipped CODE COMMENTS legitimately cite them for
|
|
66
|
+
// maintainer context (e.g. `// FEAT-17 hardening`), exactly like ADR anchors —
|
|
67
|
+
// blocking them everywhere would flag ~20 real in-code references. They ARE
|
|
68
|
+
// blocked on the PUBLIC RELEASE SURFACE that carries no code: the annotated tag
|
|
69
|
+
// body (the source of the GitHub Release via --notes-from-tag). The CLI applies
|
|
70
|
+
// [...BLOCKED_PATTERNS, ...SURFACE_TRACKER_PATTERNS] only in --tag mode. The
|
|
71
|
+
// CHANGELOG's own surface-ID-0 is held by the section migration + a grep
|
|
72
|
+
// regression test, not by widening this file-scan set (changelog-pr-guide §5).
|
|
73
|
+
// Examples below use `N`, not a real digit, so this file scans clean.
|
|
74
|
+
export const SURFACE_TRACKER_PATTERNS = [
|
|
75
|
+
{ name: 'FEAT-N', label: 'wiki feature-tracker id', re: /\bFEAT-\d+\b/gi },
|
|
76
|
+
{ name: 'IMPR-N', label: 'wiki improvement-tracker id', re: /\bIMPR-\d+\b/gi },
|
|
77
|
+
{ name: 'PRAC-N', label: 'wiki practice-tracker id', re: /\bPRAC-\d+\b/gi },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// The full tracker-ID set for a no-code public surface (the annotated tag body).
|
|
81
|
+
export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS, ...SURFACE_TRACKER_PATTERNS];
|
|
82
|
+
|
|
64
83
|
/**
|
|
65
84
|
* Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
|
|
66
85
|
* { pattern, label, match, line, col, lineText }
|
|
@@ -90,13 +90,31 @@ export function insertHotRow(content, name, today) {
|
|
|
90
90
|
* @param {{hypoDir: string, name: string, workingDir: string, started?: string, today?: string}} opts
|
|
91
91
|
* @returns {{created: string[], skipped: string[], warnings: string[], projectDir: string}}
|
|
92
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* A project name must be a SINGLE path segment with at least one alnum. The
|
|
95
|
+
* charset alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet
|
|
96
|
+
* would resolve `projects/<name>` to the wiki root or `projects/` itself (codex
|
|
97
|
+
* review 2026-05-22, both workers) — so dot-only names are rejected and ≥1 alnum
|
|
98
|
+
* required. The leading `typeof` guard lets non-CLI callers (e.g. a JSON payload
|
|
99
|
+
* where the value could be a number) reuse this without a JS regex coercing a
|
|
100
|
+
* non-string into a "valid" name. Shared so the apply path (crystallize.mjs B-3)
|
|
101
|
+
* accepts exactly the namespace createProject can scaffold — no wider, no narrower.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} name
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function isValidProjectName(name) {
|
|
107
|
+
return (
|
|
108
|
+
typeof name === 'string' &&
|
|
109
|
+
/^[A-Za-z0-9._-]+$/.test(name) &&
|
|
110
|
+
!/^\.+$/.test(name) &&
|
|
111
|
+
/[A-Za-z0-9]/.test(name)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
export function createProject(opts) {
|
|
94
116
|
const { name, workingDir } = opts;
|
|
95
|
-
|
|
96
|
-
// alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet would
|
|
97
|
-
// resolve `projects/<name>` to the wiki root or `projects/` itself (codex
|
|
98
|
-
// review 2026-05-22, both workers). Reject dot-only names and require alnum.
|
|
99
|
-
if (!name || !/^[A-Za-z0-9._-]+$/.test(name) || /^\.+$/.test(name) || !/[A-Za-z0-9]/.test(name)) {
|
|
117
|
+
if (!isValidProjectName(name)) {
|
|
100
118
|
throw new Error(
|
|
101
119
|
`invalid project name: ${JSON.stringify(name)} (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`,
|
|
102
120
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
|
|
@@ -66,6 +66,110 @@ export function parseSchemaVocab(hypoDir) {
|
|
|
66
66
|
return vocab;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Prose seeded into a freshly-created `### Pending` block. It carries NO
|
|
70
|
+
// backtick tokens, so parseSchemaVocab's vocabLineTokens never mistakes it for a
|
|
71
|
+
// vocabulary line — only the `**Pending**:` data line below it contributes tags.
|
|
72
|
+
const PENDING_HEADING = '### Pending (auto-registered)';
|
|
73
|
+
const PENDING_PROSE =
|
|
74
|
+
'Tags auto-registered by a crystallize close because they were not yet in the ' +
|
|
75
|
+
'vocabulary above. Review periodically: promote each into a category and delete ' +
|
|
76
|
+
'it here, or drop the tag from the page.';
|
|
77
|
+
const PENDING_HEADING_RE = /^###[ \t]+Pending\b.*$/im; // prefix/word match
|
|
78
|
+
const PENDING_DATA_RE = /^\*\*Pending[^*]*\*\*:.*$/im;
|
|
79
|
+
const NEXT_H3_RE = /^###[ \t]+/m;
|
|
80
|
+
const FORBIDDEN_HEADING_RE = /^###[ \t]+Forbidden\b/im;
|
|
81
|
+
|
|
82
|
+
function backtickList(tags) {
|
|
83
|
+
return tags.map((t) => `\`${t}\``).join(', ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-register unknown (non-forbidden) tags into the vault SCHEMA.md `### Pending`
|
|
87
|
+
// section so a close never stalls on a vocabulary gap and the next lint sees them
|
|
88
|
+
// as known (B-4). Pending lives INSIDE the "## Tag Vocabulary" H2 section because
|
|
89
|
+
// parseSchemaVocab is H2-bounded — placing it elsewhere would not widen the vocab.
|
|
90
|
+
// No-ops (returns []) when SCHEMA.md or the Tag Vocabulary header is absent, when
|
|
91
|
+
// every tag is already in the vocabulary, or when a tag is forbidden — registering
|
|
92
|
+
// a forbidden pattern is pointless (lint errors on it before the vocab check) and
|
|
93
|
+
// would only pollute Pending. Idempotent: re-running with the same tags writes
|
|
94
|
+
// nothing (they are already in the parsed vocabulary on the second call).
|
|
95
|
+
export function appendPendingTags(hypoDir, tags) {
|
|
96
|
+
const path = join(hypoDir, 'SCHEMA.md');
|
|
97
|
+
if (!existsSync(path)) return [];
|
|
98
|
+
const content = readFileSync(path, 'utf-8');
|
|
99
|
+
|
|
100
|
+
const headerMatch = VOCAB_HEADER_RE.exec(content);
|
|
101
|
+
if (!headerMatch) return [];
|
|
102
|
+
|
|
103
|
+
const current = parseSchemaVocab(hypoDir);
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const newTags = [];
|
|
106
|
+
for (const raw of tags || []) {
|
|
107
|
+
const t = typeof raw === 'string' ? raw.trim() : '';
|
|
108
|
+
// A literal backtick cannot be serialized inside the backtick-delimited
|
|
109
|
+
// `**Pending**:` line — it would close the token early and make
|
|
110
|
+
// parseSchemaVocab mis-tokenize (or blank) the whole line, taking valid
|
|
111
|
+
// sibling tags down with it (codex stage-2 CONCERN). Skip it: the tag stays a
|
|
112
|
+
// non-blocking unknown-tag warn the author can rename. (A `"` is safe — it
|
|
113
|
+
// round-trips between backticks — so only the backtick is rejected.)
|
|
114
|
+
if (!t || t.includes('`') || seen.has(t) || current.has(t) || checkForbidden(t)) continue;
|
|
115
|
+
seen.add(t);
|
|
116
|
+
newTags.push(t);
|
|
117
|
+
}
|
|
118
|
+
if (newTags.length === 0) return [];
|
|
119
|
+
|
|
120
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
121
|
+
const rest = content.slice(sectionStart);
|
|
122
|
+
const nextH2 = NEXT_H2_RE.exec(rest);
|
|
123
|
+
const sectionEnd = nextH2 ? sectionStart + nextH2.index : content.length;
|
|
124
|
+
const section = content.slice(sectionStart, sectionEnd);
|
|
125
|
+
|
|
126
|
+
let newSection;
|
|
127
|
+
const heading = PENDING_HEADING_RE.exec(section);
|
|
128
|
+
if (heading) {
|
|
129
|
+
// Pending subsection exists — bound it (heading → next H3 or section end) and
|
|
130
|
+
// either extend its `**Pending**:` data line or seed one if the block is empty
|
|
131
|
+
// (the template ships heading + prose with no tags yet).
|
|
132
|
+
const subStart = heading.index;
|
|
133
|
+
const after = section.slice(heading.index + heading[0].length);
|
|
134
|
+
const nextH3 = NEXT_H3_RE.exec(after);
|
|
135
|
+
const subEnd = nextH3 ? heading.index + heading[0].length + nextH3.index : section.length;
|
|
136
|
+
const sub = section.slice(subStart, subEnd);
|
|
137
|
+
|
|
138
|
+
const dataMatch = PENDING_DATA_RE.exec(sub);
|
|
139
|
+
let newSub;
|
|
140
|
+
if (dataMatch) {
|
|
141
|
+
const existing = [];
|
|
142
|
+
for (const m of dataMatch[0].matchAll(BACKTICK_TOKEN_RE)) {
|
|
143
|
+
const t = m[1].trim();
|
|
144
|
+
if (t && !t.includes(' ')) existing.push(t);
|
|
145
|
+
}
|
|
146
|
+
const merged = [...existing, ...newTags];
|
|
147
|
+
const dataLine = `**Pending**: ${backtickList(merged)}`;
|
|
148
|
+
newSub =
|
|
149
|
+
sub.slice(0, dataMatch.index) + dataLine + sub.slice(dataMatch.index + dataMatch[0].length);
|
|
150
|
+
} else {
|
|
151
|
+
const dataLine = `**Pending**: ${backtickList(newTags)}`;
|
|
152
|
+
newSub = `${sub.replace(/\s*$/, '')}\n\n${dataLine}\n`;
|
|
153
|
+
}
|
|
154
|
+
newSection = section.slice(0, subStart) + newSub + section.slice(subEnd);
|
|
155
|
+
} else {
|
|
156
|
+
// No Pending block yet — build one and place it before "### Forbidden patterns"
|
|
157
|
+
// if present (Pending is vocabulary; Forbidden is a separate concern), else at
|
|
158
|
+
// the end of the Tag Vocabulary section.
|
|
159
|
+
const block = `${PENDING_HEADING}\n\n${PENDING_PROSE}\n\n**Pending**: ${backtickList(newTags)}\n`;
|
|
160
|
+
const forbidden = FORBIDDEN_HEADING_RE.exec(section);
|
|
161
|
+
if (forbidden) {
|
|
162
|
+
newSection =
|
|
163
|
+
section.slice(0, forbidden.index) + `${block}\n` + section.slice(forbidden.index);
|
|
164
|
+
} else {
|
|
165
|
+
newSection = `${section.replace(/\s*$/, '')}\n\n${block}\n`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
writeFileSync(path, content.slice(0, sectionStart) + newSection + content.slice(sectionEnd));
|
|
170
|
+
return newTags;
|
|
171
|
+
}
|
|
172
|
+
|
|
69
173
|
// Derive the set of valid immediate subdirectories under pages/ from the
|
|
70
174
|
// SCHEMA "Page Type Taxonomy" table (e.g. learnings, feedback, people). Single
|
|
71
175
|
// source of truth — adding a type+dir row to SCHEMA automatically widens the
|
package/scripts/lint.mjs
CHANGED
|
@@ -426,7 +426,15 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
|
426
426
|
continue;
|
|
427
427
|
}
|
|
428
428
|
if (!tagVocab.has(tag)) {
|
|
429
|
-
|
|
429
|
+
// W10 (B-4): an unknown but non-forbidden tag is a WARNING, not a hard
|
|
430
|
+
// error — a session close must never stall on a vocabulary gap. The
|
|
431
|
+
// crystallize apply path parses these warns (by this exact message
|
|
432
|
+
// string — load-bearing, see scripts/crystallize.mjs auto-register block)
|
|
433
|
+
// and registers them into SCHEMA.md's `### Pending` section, so the next
|
|
434
|
+
// lint sees them as known. W10 is intentionally NOT in STRICT_PROMOTE_IDS:
|
|
435
|
+
// an unregistered tag is a vocabulary lag, not a content defect, so even
|
|
436
|
+
// `--strict` keeps it a warning (it only surfaces the id in --json output).
|
|
437
|
+
issue('warn', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`, null, 'W10');
|
|
430
438
|
}
|
|
431
439
|
}
|
|
432
440
|
}
|
|
@@ -123,7 +123,7 @@ export function buildReport(
|
|
|
123
123
|
return { week: weekLabel, count: weekResults.length, counts, score, results: weekResults };
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
function renderMarkdown(report) {
|
|
126
|
+
function renderMarkdown(report, hypoDir) {
|
|
127
127
|
const { week, count, counts, score, results } = report;
|
|
128
128
|
const today = new Date().toISOString().slice(0, 10);
|
|
129
129
|
const lines = [];
|
|
@@ -138,8 +138,14 @@ function renderMarkdown(report) {
|
|
|
138
138
|
lines.push('');
|
|
139
139
|
lines.push(`# Observability — Week ${week}`);
|
|
140
140
|
lines.push('');
|
|
141
|
+
// Only emit the wikilink when its target page exists, so a fresh vault
|
|
142
|
+
// (or a deleted _index) does not stamp a broken link into every weekly.
|
|
143
|
+
const indexExists = !!hypoDir && existsSync(join(hypoDir, 'pages', 'observability', '_index.md'));
|
|
144
|
+
const heuristicRef = indexExists
|
|
145
|
+
? '[[pages/observability/_index]]'
|
|
146
|
+
: '`pages/observability/_index.md` (not present in this vault)';
|
|
141
147
|
lines.push(
|
|
142
|
-
`> Generated by \`scripts/weekly-report.mjs\`. Heuristic definition:
|
|
148
|
+
`> Generated by \`scripts/weekly-report.mjs\`. Heuristic definition: ${heuristicRef}.`,
|
|
143
149
|
);
|
|
144
150
|
lines.push('');
|
|
145
151
|
lines.push(`## Autonomy score (heuristic v0): **${score}%**`);
|
|
@@ -196,7 +202,7 @@ if (isMain()) {
|
|
|
196
202
|
process.exit(0);
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
const md = renderMarkdown(report);
|
|
205
|
+
const md = renderMarkdown(report, args.hypoDir);
|
|
200
206
|
|
|
201
207
|
if (args.write) {
|
|
202
208
|
const dir = join(args.hypoDir, 'journal', 'weekly');
|