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.
- 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 +18 -5
- package/commands/doctor.md +3 -3
- package/commands/feedback.md +9 -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/ARCHITECTURE.md +4 -2
- package/docs/CONTRIBUTING.md +148 -25
- package/hooks/hypo-auto-commit.mjs +6 -12
- package/hooks/hypo-session-record.mjs +5 -0
- package/hooks/hypo-session-start.mjs +7 -0
- package/hooks/hypo-shared.mjs +68 -1
- package/package.json +10 -2
- package/scripts/check-bilingual.mjs +49 -11
- package/scripts/check-readme-version.mjs +126 -0
- package/scripts/check-tracker-ids.mjs +60 -1
- package/scripts/check-versions.mjs +171 -0
- package/scripts/crystallize.mjs +49 -34
- package/scripts/doctor.mjs +13 -4
- package/scripts/feedback.mjs +68 -1
- package/scripts/graph.mjs +5 -32
- package/scripts/init.mjs +2 -1
- 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/failure-type.mjs +33 -0
- package/scripts/lib/frontmatter.mjs +23 -2
- package/scripts/lib/schema-vocab.mjs +35 -0
- package/scripts/lib/template-schema-version.mjs +21 -0
- package/scripts/lib/wikilink.mjs +156 -0
- package/scripts/lint.mjs +86 -47
- package/scripts/rename.mjs +6 -30
- package/scripts/stats.mjs +22 -2
- package/scripts/upgrade.mjs +11 -3
- package/scripts/weekly-report.mjs +9 -3
- package/skills/crystallize/SKILL.md +21 -1
- package/templates/SCHEMA.md +25 -1
- package/templates/hypo-config.md +1 -1
- package/scripts/bump-version.mjs +0 -63
- package/scripts/smoke-pack.mjs +0 -261
|
@@ -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 }
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Feedback page `failure_type:` field vocabulary — shared single source of truth.
|
|
2
|
+
//
|
|
3
|
+
// Consumed by:
|
|
4
|
+
// - scripts/lint.mjs (lint-time enum validation of feedback frontmatter)
|
|
5
|
+
// - scripts/feedback.mjs (create/append-time --failure-type validation)
|
|
6
|
+
// Keep this the ONLY definition; both consumers import it so the two validators
|
|
7
|
+
// never drift (mirrors feedback-scope.mjs / ADR 0034). stats.mjs aggregates by
|
|
8
|
+
// plain string and does not validate, so it is intentionally not a consumer.
|
|
9
|
+
//
|
|
10
|
+
// `failure_type` is an OPTIONAL field: it classifies feedback that came from a
|
|
11
|
+
// real failure incident. Pure preferences / new conventions ("always do X")
|
|
12
|
+
// omit it. Order below is the precedence used when classifying — most specific
|
|
13
|
+
// first (a failure that matches several is labeled by the earliest match):
|
|
14
|
+
// hallucination fabricated a fact / API / path
|
|
15
|
+
// false-completion declared "done" without running the required gate/test
|
|
16
|
+
// process-stall stopped instead of asking / continuing when it should
|
|
17
|
+
// over-caution re-asked / re-gated despite standing authority
|
|
18
|
+
// overreach acted beyond the requested scope
|
|
19
|
+
// incompleteness started correctly but omitted a required step / scope
|
|
20
|
+
// instruction-miss ignored an explicit this-session instruction
|
|
21
|
+
// convention-violation broke a standing documented convention (not restated)
|
|
22
|
+
// The runtime does NOT enforce the precedence tree — it only validates that a
|
|
23
|
+
// supplied value is one of these eight; classification is a human judgement.
|
|
24
|
+
export const FAILURE_TYPE_ENUM = [
|
|
25
|
+
'hallucination',
|
|
26
|
+
'false-completion',
|
|
27
|
+
'process-stall',
|
|
28
|
+
'over-caution',
|
|
29
|
+
'overreach',
|
|
30
|
+
'incompleteness',
|
|
31
|
+
'instruction-miss',
|
|
32
|
+
'convention-violation',
|
|
33
|
+
];
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
// A YAML block sequence entry: `-` followed by whitespace or end-of-line.
|
|
2
|
+
// Narrower than `startsWith('-')` so a (nonstandard) plain key like `-key:` is
|
|
3
|
+
// still read rather than mistaken for a list item.
|
|
4
|
+
export const SEQUENCE_ENTRY_RE = /^-(\s|$)/;
|
|
5
|
+
|
|
6
|
+
// Lenient, top-level-only frontmatter field extractor (NOT a YAML parser).
|
|
7
|
+
// Reads only unindented `key: value` lines, skipping indented lines and list
|
|
8
|
+
// items, so a nested mapping (e.g. a `type:` inside a `relations:` list) cannot
|
|
9
|
+
// clobber the page's real top-level field. Without this a `learning` page
|
|
10
|
+
// carrying a relations block was mis-read as `type: depends_on` and silently
|
|
11
|
+
// dropped by type-routed consumers (doctor's verify-freshness scan, lint's
|
|
12
|
+
// type check). First-wins on a repeated top-level key. Assumes the Hypomnema
|
|
13
|
+
// convention of unindented root fields (templates/SCHEMA.md §3). scripts/lint.mjs
|
|
14
|
+
// imports this and adds a separate W9 pass for invalid-YAML classes a real
|
|
15
|
+
// parser would reject.
|
|
1
16
|
export function parseFrontmatter(content) {
|
|
2
17
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
3
18
|
if (!m) return null;
|
|
4
19
|
const fm = {};
|
|
5
20
|
for (const line of m[1].split(/\r?\n/)) {
|
|
21
|
+
if (/^\s/.test(line) || SEQUENCE_ENTRY_RE.test(line)) continue; // nested / list item
|
|
6
22
|
const idx = line.indexOf(':');
|
|
7
23
|
if (idx < 0) continue;
|
|
8
|
-
|
|
24
|
+
const key = line.slice(0, idx).trim();
|
|
25
|
+
if (!key || Object.hasOwn(fm, key)) continue; // first-wins
|
|
26
|
+
fm[key] = line
|
|
9
27
|
.slice(idx + 1)
|
|
10
28
|
.trim()
|
|
11
|
-
|
|
29
|
+
// strip a trailing YAML comment: `#` must follow whitespace to start one,
|
|
30
|
+
// so `concept#bad` stays literal (and still trips lint's unknown-type W2)
|
|
31
|
+
// while `concept # note` loses the comment.
|
|
32
|
+
.replace(/\s+#.*$/, '')
|
|
12
33
|
.replace(/^["']|["']$/g, '');
|
|
13
34
|
}
|
|
14
35
|
return fm;
|
|
@@ -94,3 +94,38 @@ export function parseSchemaPageDirs(hypoDir) {
|
|
|
94
94
|
}
|
|
95
95
|
return dirs;
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
// Type-token shape: lowercase identifier (a-z, digits, hyphen). Excludes
|
|
99
|
+
// memory-layer rows whose first cell is a filename (`hot.md`, `log.md`) — those
|
|
100
|
+
// carry a `.` and never match — so only real type names survive.
|
|
101
|
+
const TYPE_TOKEN_RE = /^[a-z][a-z0-9-]+$/;
|
|
102
|
+
|
|
103
|
+
// Derive the set of valid page `type` values from the SCHEMA "Page Type
|
|
104
|
+
// Taxonomy" table — the FIRST backticked cell of each table row (the type
|
|
105
|
+
// column). Single source of truth, mirroring parseSchemaPageDirs: adding a type
|
|
106
|
+
// row to a vault's SCHEMA automatically widens the accepted types, so a
|
|
107
|
+
// vault-local extension (e.g. working-doc / draft / qa-run) stops tripping the
|
|
108
|
+
// W2 unknown-type warning without editing lint. Returns an empty Set when
|
|
109
|
+
// SCHEMA.md or the table is absent — callers union this with a hardcoded core so
|
|
110
|
+
// the core types never depend on a present/complete SCHEMA.
|
|
111
|
+
export function parseSchemaTypes(hypoDir) {
|
|
112
|
+
const path = join(hypoDir, 'SCHEMA.md');
|
|
113
|
+
if (!existsSync(path)) return new Set();
|
|
114
|
+
const content = readFileSync(path, 'utf-8');
|
|
115
|
+
|
|
116
|
+
const headerMatch = TYPE_TAXONOMY_HEADER_RE.exec(content);
|
|
117
|
+
if (!headerMatch) return new Set();
|
|
118
|
+
|
|
119
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
120
|
+
const rest = content.slice(sectionStart);
|
|
121
|
+
const nextH2 = NEXT_H2_RE.exec(rest);
|
|
122
|
+
const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
|
|
123
|
+
|
|
124
|
+
const types = new Set();
|
|
125
|
+
for (const rawLine of section.split('\n')) {
|
|
126
|
+
if (!rawLine.trimStart().startsWith('|')) continue;
|
|
127
|
+
const m = rawLine.match(/`([^`]+)`/); // first backtick token = type column
|
|
128
|
+
if (m && TYPE_TOKEN_RE.test(m[1].trim())) types.add(m[1].trim());
|
|
129
|
+
}
|
|
130
|
+
return types;
|
|
131
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
|
|
5
|
+
// The SCHEMA version this package ships, read from templates/SCHEMA.md
|
|
6
|
+
// frontmatter. init.mjs / upgrade.mjs stamp it into hypo-pkg.json metadata;
|
|
7
|
+
// deriving it here (rather than hardcoding a literal at each write site) keeps
|
|
8
|
+
// the stamped value from going stale on a schema bump — the failure mode that
|
|
9
|
+
// FEAT-1's 2.0 → 2.1 bump would otherwise have introduced. Returns null only if
|
|
10
|
+
// the template is missing/unreadable (a broken package), in which case callers
|
|
11
|
+
// keep their prior literal default.
|
|
12
|
+
export function templateSchemaVersion(pkgRoot) {
|
|
13
|
+
const p = join(pkgRoot, 'templates', 'SCHEMA.md');
|
|
14
|
+
if (!existsSync(p)) return null;
|
|
15
|
+
try {
|
|
16
|
+
const v = (parseFrontmatter(readFileSync(p, 'utf-8')) || {}).version;
|
|
17
|
+
return v ? String(v) : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Shared wikilink primitives — the ONE source of truth for vault traversal,
|
|
2
|
+
// slug-form generation, and bare wikilink extraction.
|
|
3
|
+
//
|
|
4
|
+
// Consumed by:
|
|
5
|
+
// - scripts/lint.mjs (slug map, page collection, link-target scan)
|
|
6
|
+
// - scripts/rename.mjs (form index with collision detection, page scan)
|
|
7
|
+
// - scripts/graph.mjs (slug index, page collection, link extraction)
|
|
8
|
+
// - scripts/crystallize.mjs (page collection, link extraction)
|
|
9
|
+
//
|
|
10
|
+
// IMPR-13 consolidation. Before this lib, each script carried its own copy of
|
|
11
|
+
// collectPages + a slug-form deriver, drifting in subtle ways. The four
|
|
12
|
+
// collectPages variants are NOT interchangeable — they differ deliberately in
|
|
13
|
+
// traversal/security/output-shape policy (codex design review, CONCERN):
|
|
14
|
+
//
|
|
15
|
+
// - rename uses lstat + skips symlinks → a vault scan can never escape via a
|
|
16
|
+
// symlinked dir/file (security boundary).
|
|
17
|
+
// - lint skips `_`-prefixed DIRECTORIES → pages/feedback/_drafts scaffolds
|
|
18
|
+
// stay out of the lint set, while `_`-prefixed FILES (e.g. _index.md) lint.
|
|
19
|
+
// - crystallize skips ANY `.`-prefixed entry (dir AND file).
|
|
20
|
+
// - graph/lint/rename skip only `.`-prefixed FILES.
|
|
21
|
+
// - graph/crystallize emit raw `rel`/`slug`; lint keeps OS-native `rel`
|
|
22
|
+
// (its own buildSlugMap POSIX-normalizes downstream); rename/graph
|
|
23
|
+
// POSIX-normalize the slug at scan time.
|
|
24
|
+
//
|
|
25
|
+
// So this lib exposes ONE walker core plus four NAMED PRESETS, not a pile of
|
|
26
|
+
// boolean flags — each preset pins exactly one caller's historical behavior and
|
|
27
|
+
// is fixed by a unit test. readdirSync order is preserved verbatim (NO sort):
|
|
28
|
+
// graph's bare-first slug index is order-sensitive (first page wins a shared
|
|
29
|
+
// bare form), so adding a sort would silently change resolution.
|
|
30
|
+
//
|
|
31
|
+
// Collision RESOLUTION is intentionally NOT shared: lint dedups into a Set
|
|
32
|
+
// (membership), rename detects ambiguity to refuse unsafe auto-rewrites, graph
|
|
33
|
+
// is first-wins. Each caller builds its own structure from slugForms(); only the
|
|
34
|
+
// form GENERATION lives here.
|
|
35
|
+
|
|
36
|
+
import { existsSync, readdirSync, statSync, lstatSync } from 'fs';
|
|
37
|
+
import { join, relative, extname, basename } from 'path';
|
|
38
|
+
import { isScanIgnored } from './hypo-ignore.mjs';
|
|
39
|
+
|
|
40
|
+
// ── markdown page walker (shared core) ─────────────────────────────────────────
|
|
41
|
+
// `policy` keys (all optional):
|
|
42
|
+
// lstat — stat with lstatSync and skip symlinks (rename's security
|
|
43
|
+
// boundary). Default: statSync, symlinks followed.
|
|
44
|
+
// skipDotEntry — skip ANY entry (dir or file) whose name starts with `.`
|
|
45
|
+
// BEFORE the ignore check (crystallize).
|
|
46
|
+
// skipUnderscoreDir— skip directories whose name starts with `_` (lint).
|
|
47
|
+
// skipDotFile — among `.md` files, skip names starting with `.`.
|
|
48
|
+
// shape(full) — build the per-page record pushed onto the accumulator.
|
|
49
|
+
function walkMarkdown(dir, root, ignorePatterns, policy, acc) {
|
|
50
|
+
if (!existsSync(dir)) return acc;
|
|
51
|
+
for (const entry of readdirSync(dir)) {
|
|
52
|
+
if (policy.skipDotEntry && entry.startsWith('.')) continue;
|
|
53
|
+
const full = join(dir, entry);
|
|
54
|
+
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
55
|
+
const st = policy.lstat ? lstatSync(full) : statSync(full);
|
|
56
|
+
if (policy.lstat && st.isSymbolicLink()) continue;
|
|
57
|
+
if (st.isDirectory()) {
|
|
58
|
+
if (policy.skipUnderscoreDir && entry.startsWith('_')) continue;
|
|
59
|
+
walkMarkdown(full, root, ignorePatterns, policy, acc);
|
|
60
|
+
} else if (extname(entry) === '.md' && !(policy.skipDotFile && entry.startsWith('.'))) {
|
|
61
|
+
acc.push(policy.shape(full));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return acc;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const posixSlug = (full, root) => relative(root, full).replace(/\.md$/, '').replace(/\\/g, '/');
|
|
68
|
+
|
|
69
|
+
// lint: `_`-dir skip + `_`-file kept; OS-native `rel` (buildSlugMap normalizes).
|
|
70
|
+
export function collectPagesLint(dir, root, ignorePatterns = []) {
|
|
71
|
+
return walkMarkdown(
|
|
72
|
+
dir,
|
|
73
|
+
root,
|
|
74
|
+
ignorePatterns,
|
|
75
|
+
{
|
|
76
|
+
skipDotFile: true,
|
|
77
|
+
skipUnderscoreDir: true,
|
|
78
|
+
shape: (full) => ({ path: full, rel: relative(root, full) }),
|
|
79
|
+
},
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// graph: POSIX slug + bare, no `rel`.
|
|
85
|
+
export function collectPagesGraph(dir, root, ignorePatterns = []) {
|
|
86
|
+
return walkMarkdown(
|
|
87
|
+
dir,
|
|
88
|
+
root,
|
|
89
|
+
ignorePatterns,
|
|
90
|
+
{
|
|
91
|
+
skipDotFile: true,
|
|
92
|
+
shape: (full) => ({ path: full, slug: posixSlug(full, root), bare: basename(full, '.md') }),
|
|
93
|
+
},
|
|
94
|
+
[],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// rename: lstat + symlink skip (security); POSIX rel/slug/bare.
|
|
99
|
+
export function collectPagesRename(dir, root, ignorePatterns = []) {
|
|
100
|
+
return walkMarkdown(
|
|
101
|
+
dir,
|
|
102
|
+
root,
|
|
103
|
+
ignorePatterns,
|
|
104
|
+
{
|
|
105
|
+
lstat: true,
|
|
106
|
+
skipDotFile: true,
|
|
107
|
+
shape: (full) => {
|
|
108
|
+
const rel = relative(root, full).replace(/\\/g, '/');
|
|
109
|
+
return { path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') };
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// crystallize: skip ANY `.`-prefixed entry; OS-native `rel`.
|
|
117
|
+
export function collectPagesCrystallize(dir, root, ignorePatterns = []) {
|
|
118
|
+
return walkMarkdown(
|
|
119
|
+
dir,
|
|
120
|
+
root,
|
|
121
|
+
ignorePatterns,
|
|
122
|
+
{ skipDotEntry: true, shape: (full) => ({ path: full, rel: relative(root, full) }) },
|
|
123
|
+
[],
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── slug-form generation ───────────────────────────────────────────────────────
|
|
128
|
+
// From a POSIX no-extension slug (e.g. `pages/learnings/foo`), derive the three
|
|
129
|
+
// link forms a [[wikilink]] may use:
|
|
130
|
+
// full — the whole slug [[pages/learnings/foo]]
|
|
131
|
+
// bare — the basename [[foo]]
|
|
132
|
+
// dirRel — slug minus the leading scan-dir seg [[learnings/foo]] (null if the
|
|
133
|
+
// slug has no `/`, i.e. nothing to drop)
|
|
134
|
+
// Callers pick which forms to register and own their own collision policy. Do NOT
|
|
135
|
+
// apply this to link-target-only slugs (lint's root-md/sources extraTargets,
|
|
136
|
+
// rename's sources/*) — those resolve VERBATIM, with no derived alias, so a bare
|
|
137
|
+
// form can't mask an unrelated broken link or block a safe rewrite.
|
|
138
|
+
export function slugForms(slug) {
|
|
139
|
+
const slash = slug.indexOf('/');
|
|
140
|
+
return { full: slug, bare: basename(slug), dirRel: slash === -1 ? null : slug.slice(slash + 1) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── bare wikilink extraction (graph/crystallize variant) ───────────────────────
|
|
144
|
+
// Raw extraction: matches [[target]] / [[target|alias]] / [[target#anchor]]
|
|
145
|
+
// anywhere, INCLUDING inside code fences (graph counts those as edges, and
|
|
146
|
+
// crystallize counts them for unlinked-page detection — preserving that is
|
|
147
|
+
// behavior-stable). lint uses a DIFFERENT extractor (strips code regions and
|
|
148
|
+
// handles table-escaped `\|`); rename uses length-preserving masking. Neither
|
|
149
|
+
// shares this one.
|
|
150
|
+
export function extractWikilinks(content) {
|
|
151
|
+
const links = [];
|
|
152
|
+
for (const m of content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)) {
|
|
153
|
+
links.push(m[1].trim());
|
|
154
|
+
}
|
|
155
|
+
return links;
|
|
156
|
+
}
|