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
package/scripts/crystallize.mjs
CHANGED
|
@@ -60,20 +60,14 @@
|
|
|
60
60
|
* hard-fails regardless of scope.
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
|
-
import {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
readdirSync,
|
|
67
|
-
statSync,
|
|
68
|
-
writeFileSync,
|
|
69
|
-
mkdirSync,
|
|
70
|
-
renameSync,
|
|
71
|
-
} from 'fs';
|
|
72
|
-
import { join, relative, extname, dirname } from 'path';
|
|
63
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
|
64
|
+
import { join, dirname } from 'path';
|
|
65
|
+
import { hostname } from 'os';
|
|
73
66
|
import { spawnSync } from 'child_process';
|
|
74
67
|
import { fileURLToPath } from 'url';
|
|
75
68
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
76
|
-
import { loadHypoIgnore
|
|
69
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
70
|
+
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
77
71
|
import {
|
|
78
72
|
sessionCloseFileStatus,
|
|
79
73
|
sessionCloseGlobalStatus,
|
|
@@ -756,9 +750,21 @@ function applySessionClose(args) {
|
|
|
756
750
|
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
757
751
|
// heading lives inside the entry, so freshness / derive / design-history
|
|
758
752
|
// are unchanged.
|
|
753
|
+
// PRAC-17 audit fields. The shard frontmatter is git-tracked and synced, so
|
|
754
|
+
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
755
|
+
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
756
|
+
// machine that first seeds the daily shard is recorded; later same-day
|
|
757
|
+
// appends do not touch it. The per-session-accurate store is the LOCAL
|
|
758
|
+
// (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
|
|
759
|
+
// `session_id` is honest naming: the value is the Claude session UUID, and
|
|
760
|
+
// it is present only on the Stop-chain close path that passes --session-id.
|
|
761
|
+
const device = String(hostname() || 'unknown').replace(/[\r\n]/g, '');
|
|
762
|
+
const auditFm =
|
|
763
|
+
(args.sessionId ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n` : '') +
|
|
764
|
+
`device: ${device}\n`;
|
|
759
765
|
const header =
|
|
760
766
|
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
761
|
-
`type: session-log\nupdated: ${date}\n---\n\n` +
|
|
767
|
+
`type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
|
|
762
768
|
`# Session Log ${date} (${project})\n`;
|
|
763
769
|
const entry = payload.sessionLog.entry;
|
|
764
770
|
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
@@ -913,8 +919,36 @@ function applySessionClose(args) {
|
|
|
913
919
|
for (const a of applied) console.log(` ✓ wrote ${a}`);
|
|
914
920
|
for (const s of skipped) console.log(` · skipped ${s} (already current)`);
|
|
915
921
|
if (ok) {
|
|
916
|
-
|
|
917
|
-
|
|
922
|
+
// When the marker was withheld, qualify the success line so a reader scanning
|
|
923
|
+
// stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
|
|
924
|
+
// is non-null exactly when args.sessionId is set and the marker did not land.
|
|
925
|
+
if (markerSkipReason) {
|
|
926
|
+
console.log(
|
|
927
|
+
'\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
|
|
928
|
+
'\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
|
|
929
|
+
);
|
|
930
|
+
} else {
|
|
931
|
+
console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// When ok:true but the session-close marker was NOT written, the Stop-chain
|
|
935
|
+
// still sees an open session and will re-prompt at the next Stop. Surface this
|
|
936
|
+
// loudly so neither the human nor a skill-following model reads "ok:true" as
|
|
937
|
+
// "session fully closed". Gate on markerSkipReason (non-null exactly when
|
|
938
|
+
// args.sessionId is present and the marker was withheld).
|
|
939
|
+
if (markerSkipReason) {
|
|
940
|
+
process.stderr.write(
|
|
941
|
+
`\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
|
|
942
|
+
` The 5 mandatory files were applied and verified (ok:true), but the\n` +
|
|
943
|
+
` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
|
|
944
|
+
` closed: the Stop hook will re-prompt until the marker is present.\n` +
|
|
945
|
+
` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
|
|
946
|
+
` a background task or Agent UUID from a /tmp/... path).\n` +
|
|
947
|
+
` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
|
|
948
|
+
` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
if (!ok) {
|
|
918
952
|
if (!verification.ok) {
|
|
919
953
|
const bad = [
|
|
920
954
|
...verification.missing.map((f) => `${f} (missing)`),
|
|
@@ -944,21 +978,6 @@ function applySessionClose(args) {
|
|
|
944
978
|
|
|
945
979
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
946
980
|
|
|
947
|
-
function collectPages(dir, root, acc = [], ignorePatterns = []) {
|
|
948
|
-
if (!existsSync(dir)) return acc;
|
|
949
|
-
for (const entry of readdirSync(dir)) {
|
|
950
|
-
if (entry.startsWith('.')) continue;
|
|
951
|
-
const full = join(dir, entry);
|
|
952
|
-
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
953
|
-
const st = statSync(full);
|
|
954
|
-
if (st.isDirectory()) collectPages(full, root, acc, ignorePatterns);
|
|
955
|
-
else if (extname(entry) === '.md') {
|
|
956
|
-
acc.push({ path: full, rel: relative(root, full) });
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
return acc;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
981
|
function parseFrontmatter(content) {
|
|
963
982
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
964
983
|
if (!m) return null;
|
|
@@ -983,10 +1002,6 @@ function parseTags(fm) {
|
|
|
983
1002
|
.filter(Boolean);
|
|
984
1003
|
}
|
|
985
1004
|
|
|
986
|
-
function extractWikilinks(content) {
|
|
987
|
-
return [...content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) => m[1].trim());
|
|
988
|
-
}
|
|
989
|
-
|
|
990
1005
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
991
1006
|
|
|
992
1007
|
const args = parseArgs(process.argv);
|
|
@@ -1005,7 +1020,7 @@ if (args.checkSessionClose) {
|
|
|
1005
1020
|
|
|
1006
1021
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
1007
1022
|
const pagesDir = join(args.hypoDir, 'pages');
|
|
1008
|
-
const pages =
|
|
1023
|
+
const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
|
|
1009
1024
|
|
|
1010
1025
|
const tagGroups = {}; // tag → [{ slug, title }]
|
|
1011
1026
|
const unlinked = []; // pages with no outbound wikilinks
|
package/scripts/doctor.mjs
CHANGED
|
@@ -528,10 +528,19 @@ function checkSyncState(hypoDir) {
|
|
|
528
528
|
pass('Sync state', 'No unresolved sync failures');
|
|
529
529
|
} else {
|
|
530
530
|
const last = entries[entries.length - 1];
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
531
|
+
// A merge conflict needs a real manual merge, not a plain push/pull — give
|
|
532
|
+
// the same explicit guidance session-start does instead of the generic hint.
|
|
533
|
+
if (String(last.op || '').startsWith('conflict')) {
|
|
534
|
+
warn(
|
|
535
|
+
'Sync state',
|
|
536
|
+
`${entries.length} unresolved sync issue(s) — last: remote diverged (merge conflict). Your local work is committed; the other machine's version is on the remote. Resolve with \`git pull --no-rebase\`, fix conflicts, then push.`,
|
|
537
|
+
);
|
|
538
|
+
} else {
|
|
539
|
+
warn(
|
|
540
|
+
'Sync state',
|
|
541
|
+
`${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
535
544
|
}
|
|
536
545
|
}
|
|
537
546
|
|
package/scripts/feedback.mjs
CHANGED
|
@@ -48,6 +48,8 @@ import { spawnSync } from 'child_process';
|
|
|
48
48
|
import { fileURLToPath } from 'url';
|
|
49
49
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
50
50
|
import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
|
|
51
|
+
import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
|
|
52
|
+
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
51
53
|
|
|
52
54
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
53
55
|
|
|
@@ -69,6 +71,7 @@ function parseArgs(argv) {
|
|
|
69
71
|
promoteToGlobal: false,
|
|
70
72
|
reason: null,
|
|
71
73
|
source: null,
|
|
74
|
+
failureType: null,
|
|
72
75
|
behavior: null,
|
|
73
76
|
claudeHome: null,
|
|
74
77
|
projectId: null,
|
|
@@ -91,6 +94,7 @@ function parseArgs(argv) {
|
|
|
91
94
|
else if (arg === '--promote-to-global') args.promoteToGlobal = true;
|
|
92
95
|
else if (arg.startsWith('--reason=')) args.reason = arg.slice(9);
|
|
93
96
|
else if (arg.startsWith('--source=')) args.source = arg.slice(9);
|
|
97
|
+
else if (arg.startsWith('--failure-type=')) args.failureType = arg.slice(15);
|
|
94
98
|
else if (arg.startsWith('--behavior=')) args.behavior = arg.slice(11);
|
|
95
99
|
else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
|
|
96
100
|
else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
|
|
@@ -132,6 +136,16 @@ function parseTargets(raw) {
|
|
|
132
136
|
.filter(Boolean);
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
// FEAT-1: `failure_type` is OPTIONAL — an unset value is always fine. A set value
|
|
140
|
+
// must be one of the eight enum members (same vocabulary lint enforces). Returns
|
|
141
|
+
// an error string or null. Shared by create (validateClassification) and append.
|
|
142
|
+
function failureTypeError(value) {
|
|
143
|
+
if (!value) return null;
|
|
144
|
+
if (!FAILURE_TYPE_ENUM.includes(value))
|
|
145
|
+
return `--failure-type invalid: "${value}" (allowed: ${FAILURE_TYPE_ENUM.join(', ')})`;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
// Validate the create-mode classification. Returns an array of error strings.
|
|
136
150
|
function validateClassification(args, targets) {
|
|
137
151
|
const errs = [];
|
|
@@ -151,6 +165,8 @@ function validateClassification(args, targets) {
|
|
|
151
165
|
errs.push(`--priority must be an integer 1-5 (got "${args.priority}")`);
|
|
152
166
|
if (!args.memorySummary) errs.push('--memory-summary is required');
|
|
153
167
|
if (!args.reason) errs.push('--reason is required');
|
|
168
|
+
const ftErr = failureTypeError(args.failureType);
|
|
169
|
+
if (ftErr) errs.push(ftErr);
|
|
154
170
|
|
|
155
171
|
// CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
|
|
156
172
|
// carry the two conditional fields (lint #8). Enforce here so we never write a
|
|
@@ -201,6 +217,7 @@ function renderPage(args, targets, today) {
|
|
|
201
217
|
}
|
|
202
218
|
lines.push(`reason: ${oneLine(args.reason)}`);
|
|
203
219
|
lines.push(`source: ${oneLine(args.source || `session:${today}`)}`);
|
|
220
|
+
if (args.failureType) lines.push(`failure_type: ${args.failureType}`);
|
|
204
221
|
lines.push(`corrected_at: ${today}`);
|
|
205
222
|
lines.push(`updated: ${today}`);
|
|
206
223
|
lines.push(`created: ${today}`);
|
|
@@ -233,6 +250,24 @@ function bumpUpdated(content, today) {
|
|
|
233
250
|
return content.replace(m[0], `---\n${bumped}\n---`);
|
|
234
251
|
}
|
|
235
252
|
|
|
253
|
+
// Set `failure_type: <value>` in the leading frontmatter block. Scoped to the
|
|
254
|
+
// first `---` fence (like bumpUpdated) so a body line starting "failure_type:" is
|
|
255
|
+
// never touched; the `^` anchor keeps it top-level (an indented/nested key starts
|
|
256
|
+
// with whitespace and won't match). CRLF-aware (the shared parser accepts CRLF,
|
|
257
|
+
// so this must too — an LF-only match would silently skip a CRLF page) and it
|
|
258
|
+
// REPLACES an existing empty `failure_type:` line rather than leaving it blank.
|
|
259
|
+
// The caller only invokes this when the page has no real value (absent or empty),
|
|
260
|
+
// so a populated top-level key never reaches here.
|
|
261
|
+
function addFailureType(content, value) {
|
|
262
|
+
const m = content.match(/^---(\r?\n)([\s\S]*?)\r?\n---/);
|
|
263
|
+
if (!m) return content; // no frontmatter fence to host the field
|
|
264
|
+
const nl = m[1];
|
|
265
|
+
const fm = /^failure_type:\s*.*$/m.test(m[2])
|
|
266
|
+
? m[2].replace(/^failure_type:\s*.*$/m, `failure_type: ${value}`)
|
|
267
|
+
: `${m[2]}${nl}failure_type: ${value}`;
|
|
268
|
+
return content.replace(m[0], `---${nl}${fm}${nl}---`);
|
|
269
|
+
}
|
|
270
|
+
|
|
236
271
|
function writeFeedback(args, today) {
|
|
237
272
|
const feedbackDir = join(args.hypoDir, 'pages', 'feedback');
|
|
238
273
|
const filePath = join(feedbackDir, `${args.topic}.md`);
|
|
@@ -244,7 +279,39 @@ function writeFeedback(args, today) {
|
|
|
244
279
|
// Append a dated entry; preserve existing frontmatter classification.
|
|
245
280
|
mode = 'append';
|
|
246
281
|
const existing = readFileSync(filePath, 'utf-8');
|
|
247
|
-
|
|
282
|
+
// FEAT-1: failure_type is a per-page classification property. On append,
|
|
283
|
+
// set it if the page has none, error if it conflicts with an existing value,
|
|
284
|
+
// no-op if it matches. Without the flag, append is byte-for-byte unchanged.
|
|
285
|
+
const existingFt = (parseFrontmatter(existing) || {}).failure_type || null;
|
|
286
|
+
if (args.failureType) {
|
|
287
|
+
const ftErr = failureTypeError(args.failureType);
|
|
288
|
+
if (ftErr) {
|
|
289
|
+
console.error(`Error: ${ftErr}`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
if (existingFt && existingFt !== args.failureType) {
|
|
293
|
+
console.error(
|
|
294
|
+
`Error: failure_type mismatch on append: page has "${existingFt}", ` +
|
|
295
|
+
`--failure-type=${args.failureType}. A feedback page carries a single ` +
|
|
296
|
+
`failure_type; use a separate topic for a different failure type.`,
|
|
297
|
+
);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
|
|
302
|
+
if (args.failureType && !existingFt) {
|
|
303
|
+
appended = addFailureType(appended, args.failureType);
|
|
304
|
+
// Fail loud rather than silently appending without the field: if the page's
|
|
305
|
+
// frontmatter is too malformed to host failure_type (no fence at all), the
|
|
306
|
+
// set-if-absent contract could not be honored.
|
|
307
|
+
if ((parseFrontmatter(appended) || {}).failure_type !== args.failureType) {
|
|
308
|
+
console.error(
|
|
309
|
+
`Error: could not set failure_type on "${args.topic}" — its frontmatter ` +
|
|
310
|
+
`is malformed (no parseable --- block). Fix the page, then retry.`,
|
|
311
|
+
);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
248
315
|
content = bumpUpdated(appended, today);
|
|
249
316
|
} else {
|
|
250
317
|
mode = 'create';
|
package/scripts/graph.mjs
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* --min-edges=<n> Only include nodes with at least N edges (default: 0)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import { join
|
|
17
|
+
import { readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
19
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
20
|
-
import { loadHypoIgnore
|
|
20
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
21
|
+
import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
|
|
21
22
|
|
|
22
23
|
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -32,24 +33,6 @@ function parseArgs(argv) {
|
|
|
32
33
|
return args;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
// ── page collector ────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
38
|
-
if (!existsSync(dir)) return pages;
|
|
39
|
-
for (const entry of readdirSync(dir)) {
|
|
40
|
-
const full = join(dir, entry);
|
|
41
|
-
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
42
|
-
const st = statSync(full);
|
|
43
|
-
if (st.isDirectory()) {
|
|
44
|
-
collectPages(full, root, pages, ignorePatterns);
|
|
45
|
-
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
46
|
-
const slug = relative(root, full).replace(/\.md$/, '').replace(/\\/g, '/');
|
|
47
|
-
pages.push({ path: full, slug, bare: basename(full, '.md') });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return pages;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
36
|
// ── slug resolver ─────────────────────────────────────────────────────────────
|
|
54
37
|
|
|
55
38
|
function buildSlugIndex(pages) {
|
|
@@ -61,16 +44,6 @@ function buildSlugIndex(pages) {
|
|
|
61
44
|
return index;
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
// ── wikilink extractor ────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
function extractWikilinks(content) {
|
|
67
|
-
const links = [];
|
|
68
|
-
for (const m of content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)) {
|
|
69
|
-
links.push(m[1].trim());
|
|
70
|
-
}
|
|
71
|
-
return links;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
47
|
// ── graph builder ─────────────────────────────────────────────────────────────
|
|
75
48
|
|
|
76
49
|
function buildGraph(pages, slugIndex) {
|
|
@@ -180,7 +153,7 @@ const args = parseArgs(process.argv);
|
|
|
180
153
|
|
|
181
154
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
182
155
|
const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
|
|
183
|
-
const pages = scanDirs.flatMap((d) =>
|
|
156
|
+
const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignorePatterns));
|
|
184
157
|
const slugIndex = buildSlugIndex(pages);
|
|
185
158
|
const graph = buildGraph(pages, slugIndex);
|
|
186
159
|
|
package/scripts/init.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
readFileIfRegular,
|
|
47
47
|
} from './lib/pkg-json.mjs';
|
|
48
48
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
49
|
+
import { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
49
50
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
50
51
|
|
|
51
52
|
const HOME = homedir();
|
|
@@ -477,7 +478,7 @@ function writePkgJson(dryRun, extraFields = {}) {
|
|
|
477
478
|
...existing,
|
|
478
479
|
pkgRoot: PKG_ROOT,
|
|
479
480
|
pkgVersion: PKG_VERSION,
|
|
480
|
-
schemaVersion: '2.
|
|
481
|
+
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
|
|
481
482
|
...extraFields,
|
|
482
483
|
};
|
|
483
484
|
if (!dryRun) {
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/changelog-classify.mjs — pure classifier + surface sanitizer for the
|
|
3
|
+
* section-model CHANGELOG (changelog-pr-guide, format.md §3 / §5).
|
|
4
|
+
*
|
|
5
|
+
* Two responsibilities, both pure (no fs/git/process) so tests/runner.mjs can
|
|
6
|
+
* exercise them on synthetic strings:
|
|
7
|
+
*
|
|
8
|
+
* 1. classifyChange(commitTitle) -> { section, basis }
|
|
9
|
+
* Decide which of the three migrated sections a change belongs to.
|
|
10
|
+
* Precedence (format.md §3): tracker ID first, Conventional-Commit type
|
|
11
|
+
* second, the legacy heading hint third, a safe Chores fallback last.
|
|
12
|
+
* - tracker: FEAT-* -> New Features, ISSUE-* -> Bug Fixes,
|
|
13
|
+
* IMPR-* / PRAC-* -> Chores. Tracker wins over type, so a
|
|
14
|
+
* `feat(...)` commit tagged IMPR-N lands in Chores, and a
|
|
15
|
+
* `docs(...)` commit tagged ISSUE-N lands in Bug Fixes.
|
|
16
|
+
* (IDs in this comment use an `N` placeholder, not a real
|
|
17
|
+
* digit, so check-tracker-ids does not flag this file.)
|
|
18
|
+
* - type: feat -> New Features, fix -> Bug Fixes, and the
|
|
19
|
+
* non-user-visible types (chore/refactor/docs/ci/perf/...) ->
|
|
20
|
+
* Chores.
|
|
21
|
+
* - heading: an ID-less, type-less pre-convention item maps by the
|
|
22
|
+
* legacy heading it sits under (Added -> New Features,
|
|
23
|
+
* Fixed -> Bug Fixes, Changed/Internal/... -> Chores), passed
|
|
24
|
+
* via opts.legacyHeading.
|
|
25
|
+
* - fallback: nothing resolves -> Chores (basis tells the caller it was a
|
|
26
|
+
* guess, so a human can re-check).
|
|
27
|
+
*
|
|
28
|
+
* 2. sanitizeTrackerIds(text) -> text
|
|
29
|
+
* Strip every wiki tracker ID (FEAT-/IMPR-/ISSUE-/PRAC-N) from public
|
|
30
|
+
* surface, leaving the PR number (`#N`) as the only identifier
|
|
31
|
+
* (format.md §5, "표면 ID 0"). It also cleans the `(FEAT-N)` parens the ID
|
|
32
|
+
* left empty. NOTE: `fix #N` (the wiki fix-tracker) is NOT handled here —
|
|
33
|
+
* that pattern is the domain of check-tracker-ids; the classifier output
|
|
34
|
+
* never produces it. ADR anchors (`ADR NNNN`, `decisions/NNNN`) are left
|
|
35
|
+
* intact: CHANGELOG history keeps them (format.md §10).
|
|
36
|
+
*
|
|
37
|
+
* Section keys are stable machine strings; the human heading text
|
|
38
|
+
* (`New Features` etc.) is mapped by SECTION_TITLE for callers that render.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
// Stable section keys. Order is the canonical render order (format.md §1).
|
|
42
|
+
export const SECTION = {
|
|
43
|
+
NEW_FEATURES: 'new-features',
|
|
44
|
+
BUG_FIXES: 'bug-fixes',
|
|
45
|
+
CHORES: 'chores',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const SECTION_TITLE = {
|
|
49
|
+
'new-features': 'New Features',
|
|
50
|
+
'bug-fixes': 'Bug Fixes',
|
|
51
|
+
'chores': 'Chores',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Tracker-ID -> section, in precedence order. A change carries one tracker in
|
|
55
|
+
// practice; if two ever co-occur, the earlier rule wins (FEAT > ISSUE > IMPR >
|
|
56
|
+
// PRAC), so a feature-with-cleanup is surfaced as a feature.
|
|
57
|
+
export const TRACKER_RULES = [
|
|
58
|
+
{ prefix: 'FEAT', re: /\bFEAT-\d+\b/i, section: SECTION.NEW_FEATURES },
|
|
59
|
+
{ prefix: 'ISSUE', re: /\bISSUE-\d+\b/i, section: SECTION.BUG_FIXES },
|
|
60
|
+
{ prefix: 'IMPR', re: /\bIMPR-\d+\b/i, section: SECTION.CHORES },
|
|
61
|
+
{ prefix: 'PRAC', re: /\bPRAC-\d+\b/i, section: SECTION.CHORES },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// Conventional-Commit type -> section (secondary signal). feat/fix are the two
|
|
65
|
+
// user-facing buckets; everything else is internal -> Chores (format.md §4:
|
|
66
|
+
// Chores is defined by KIND, not by visibility).
|
|
67
|
+
export const TYPE_SECTION = {
|
|
68
|
+
feat: SECTION.NEW_FEATURES,
|
|
69
|
+
fix: SECTION.BUG_FIXES,
|
|
70
|
+
chore: SECTION.CHORES,
|
|
71
|
+
refactor: SECTION.CHORES,
|
|
72
|
+
docs: SECTION.CHORES,
|
|
73
|
+
ci: SECTION.CHORES,
|
|
74
|
+
perf: SECTION.CHORES,
|
|
75
|
+
build: SECTION.CHORES,
|
|
76
|
+
test: SECTION.CHORES,
|
|
77
|
+
style: SECTION.CHORES,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Legacy CHANGELOG heading -> section (format.md §6, the section-bound rows
|
|
81
|
+
// only). The hint used when neither a tracker ID nor a Conventional-Commit type
|
|
82
|
+
// resolves a change (a pre-convention prose item under an old `### Added` etc.).
|
|
83
|
+
// Headings that map to NON-section blocks (Highlights, Breaking, Upgrading,
|
|
84
|
+
// Known Issues, Notes) are deliberately absent: they are structural, not one of
|
|
85
|
+
// the three sections this function returns, so the caller routes them (§6/§8).
|
|
86
|
+
export const HEADING_SECTION = {
|
|
87
|
+
added: SECTION.NEW_FEATURES,
|
|
88
|
+
fixed: SECTION.BUG_FIXES,
|
|
89
|
+
changed: SECTION.CHORES,
|
|
90
|
+
internal: SECTION.CHORES,
|
|
91
|
+
maintenance: SECTION.CHORES,
|
|
92
|
+
documentation: SECTION.CHORES,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Normalize a legacy heading to its HEADING_SECTION key: drop a leading `###`,
|
|
96
|
+
// the `⚠ ` warning glyph, and a trailing ` (한글)` variant marker, then
|
|
97
|
+
// lowercase. `### Fixed (한글)` and `⚠ Breaking` both reduce cleanly.
|
|
98
|
+
function normalizeHeading(heading) {
|
|
99
|
+
return String(heading == null ? '' : heading)
|
|
100
|
+
.replace(/^#+\s*/, '')
|
|
101
|
+
.replace(/^[⚠️\s]+/, '')
|
|
102
|
+
.replace(/\s*\(한글\)\s*$/, '')
|
|
103
|
+
.trim()
|
|
104
|
+
.toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// All four wiki tracker prefixes, as one alternation. Used by both the
|
|
108
|
+
// classifier (read) and the sanitizer (strip). Kept here as the single source
|
|
109
|
+
// so the two never drift.
|
|
110
|
+
export const TRACKER_ID_RE = /\b(?:FEAT|IMPR|ISSUE|PRAC)-\d+\b/gi;
|
|
111
|
+
|
|
112
|
+
// Internal labels that are NOT tracker IDs but must also leave the public
|
|
113
|
+
// surface (format.md §10): `Track A`, `Track A-sot`, `OQ-34`. These are NOT
|
|
114
|
+
// stripped by sanitizeTrackerIds — removing `Track A` cleanly needs a PR-number
|
|
115
|
+
// substitution, which is a human migration call (T5), not a regex. detect them
|
|
116
|
+
// so the migration can flag and replace them by hand rather than miss them.
|
|
117
|
+
export const INTERNAL_LABEL_RE = /\bTrack [A-Z](?:-[a-z]+)?\b|\bOQ-\d+\b/g;
|
|
118
|
+
|
|
119
|
+
// Parse the `type` out of a Conventional-Commit subject: `type(scope)!: rest`.
|
|
120
|
+
// Returns the lowercased type or null. The scope and the breaking-change `!`
|
|
121
|
+
// are optional.
|
|
122
|
+
function commitType(text) {
|
|
123
|
+
const m = /^([a-zA-Z]+)(?:\([^)]*\))?!?:/.exec(String(text).trim());
|
|
124
|
+
return m ? m[1].toLowerCase() : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Classify one change into a section, following format.md §3 precedence:
|
|
129
|
+
* tracker ID first, Conventional-Commit type second, the legacy heading hint
|
|
130
|
+
* third (for pre-convention prose items that carry neither), and a Chores
|
|
131
|
+
* fallback last. Returns { section, basis } where basis is
|
|
132
|
+
* 'tracker' | 'type' | 'heading' | 'fallback' — the basis is for the snapshot
|
|
133
|
+
* fixture / human audit, not public surface.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} text commit subject / changelog line to classify
|
|
136
|
+
* @param {{legacyHeading?: string}} [opts] the `### Added` etc. the item sits
|
|
137
|
+
* under, used only when text alone is ambiguous (an ID-less, type-less line).
|
|
138
|
+
* @returns {{section: string, basis: 'tracker'|'type'|'heading'|'fallback'}}
|
|
139
|
+
*/
|
|
140
|
+
export function classifyChange(text, opts = {}) {
|
|
141
|
+
const s = String(text == null ? '' : text);
|
|
142
|
+
// 1. tracker ID wins.
|
|
143
|
+
for (const rule of TRACKER_RULES) {
|
|
144
|
+
if (rule.re.test(s)) return { section: rule.section, basis: 'tracker' };
|
|
145
|
+
}
|
|
146
|
+
// 2. Conventional-Commit type.
|
|
147
|
+
const type = commitType(s);
|
|
148
|
+
if (type && TYPE_SECTION[type]) {
|
|
149
|
+
return { section: TYPE_SECTION[type], basis: 'type' };
|
|
150
|
+
}
|
|
151
|
+
// 3. legacy heading hint (format.md §3 step 3 / §6) — an old `### Added` item
|
|
152
|
+
// with no conventional prefix maps by its heading, not the Chores default.
|
|
153
|
+
if (opts && opts.legacyHeading != null) {
|
|
154
|
+
const key = normalizeHeading(opts.legacyHeading);
|
|
155
|
+
if (key in HEADING_SECTION) return { section: HEADING_SECTION[key], basis: 'heading' };
|
|
156
|
+
}
|
|
157
|
+
// 4. safe default — surfaced as a guess so a human can re-check.
|
|
158
|
+
return { section: SECTION.CHORES, basis: 'fallback' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove every wiki tracker ID from `text`, leaving `#N` PR numbers untouched.
|
|
163
|
+
* Cleans up the parens / stray whitespace the removed ID leaves behind so
|
|
164
|
+
* `... (FEAT-N) (#N)` becomes `... (#N)`, not `... () (#N)`.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} text
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function sanitizeTrackerIds(text) {
|
|
170
|
+
if (text == null) return '';
|
|
171
|
+
let s = String(text);
|
|
172
|
+
// 1. drop the tracker IDs themselves.
|
|
173
|
+
s = s.replace(TRACKER_ID_RE, '');
|
|
174
|
+
// 2. parens the ID emptied: `()` or `( )` -> gone.
|
|
175
|
+
s = s.replace(/\(\s*\)/g, '');
|
|
176
|
+
// 3. tidy whitespace the removal left: collapse runs, drop space hugging
|
|
177
|
+
// brackets/punctuation, strip per-line trailing space.
|
|
178
|
+
s = s.replace(/[ \t]{2,}/g, ' ');
|
|
179
|
+
s = s.replace(/([([])[ \t]+/g, '$1');
|
|
180
|
+
s = s.replace(/[ \t]+([)\].,;:])/g, '$1');
|
|
181
|
+
// a removed leading label leaves orphaned punctuation at the start of a line
|
|
182
|
+
// (e.g. `ISSUE-N: foo` -> `: foo`); drop it. NOTE: a tracker ID glued to other
|
|
183
|
+
// text by a non-paren separator (`ISSUE-N/foo`) is not a CHANGELOG form and is
|
|
184
|
+
// left minimally cleaned.
|
|
185
|
+
s = s.replace(/^[ \t]*[:;,][ \t]*/gm, '');
|
|
186
|
+
s = s.replace(/[ \t]+$/gm, '');
|
|
187
|
+
return s.trim();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Find internal labels (`Track A`, `OQ-NN`) that format.md §10 wants off the
|
|
192
|
+
* public surface but that sanitizeTrackerIds intentionally does NOT auto-strip
|
|
193
|
+
* (they need a PR-number substitution, a human call). Returns the matches so the
|
|
194
|
+
* migration (T5) can flag and replace them rather than silently ship them.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} text
|
|
197
|
+
* @returns {string[]} matched labels (empty if none)
|
|
198
|
+
*/
|
|
199
|
+
export function detectInternalLabels(text) {
|
|
200
|
+
INTERNAL_LABEL_RE.lastIndex = 0;
|
|
201
|
+
const out = String(text == null ? '' : text).match(INTERNAL_LABEL_RE);
|
|
202
|
+
return out ? out : [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Does `text` still carry any wiki tracker ID? A cheap predicate for the
|
|
207
|
+
* regression gate ("surface ID 0") — distinct from check-tracker-ids, which
|
|
208
|
+
* only blocks ISSUE-/fix #. This sees all four prefixes.
|
|
209
|
+
*
|
|
210
|
+
* @param {string} text
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
export function hasTrackerId(text) {
|
|
214
|
+
TRACKER_ID_RE.lastIndex = 0;
|
|
215
|
+
return TRACKER_ID_RE.test(String(text == null ? '' : text));
|
|
216
|
+
}
|