hypomnema 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -1,426 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * check-tracker-ids.mjs — CLI gate: no wiki-internal tracker IDs (ISSUE-N,
4
- * fix #N, FEAT-N, IMPR-N, PRAC-N) in OSS-public artifacts.
5
- *
6
- * Rule source: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-
7
- * artifacts, 2026-06-09). The repo ships through npm + a Claude Code plugin;
8
- * an `ISSUE-N` / `fix #N` / `FEAT-N` / `IMPR-N` / `PRAC-N` in any shipped file or
9
- * in README/CHANGELOG is a dangling pointer into the maintainer's PRIVATE wiki
10
- * tracker. GitHub refs (`PR #N`, `(#N)`, `#N`, issue URLs) are legitimate and
11
- * never flagged.
12
- *
13
- * Modes:
14
- * --all (default) Walk the public-artifact scope and scan every text
15
- * file. Wired into npm `prepublishOnly` and CI.
16
- * --staged Scan the STAGED blob of every in-scope file in the
17
- * index (git show :path), mirroring the pre-commit
18
- * formatter's name-status/-z/diff-filter plumbing so
19
- * deletes, type-changes, symlinks and submodules are
20
- * skipped. Used by the pre-commit hook.
21
- * --commit-msg <file> Scan a commit message. Drops the --verbose scissors
22
- * diff, then strips comment lines ONLY when git added
23
- * its template (editor/strip mode); for `commit -m` /
24
- * whitespace / verbatim (no template) it scans comment
25
- * lines too, since git keeps them. Used by the
26
- * commit-msg hook.
27
- * --json Machine-readable output.
28
- *
29
- * Exit: 0 clean · 1 violations · 2 usage error.
30
- *
31
- * Scope (public): README.md, README.ko.md, CHANGELOG.md, package.json (npm
32
- * auto-ships it) + shipped trees commands/ hooks/ scripts/ skills/ templates/
33
- * docs/ .github/ .claude-plugin/. Excluded: tests/ and qa-runs/ (internal
34
- * maintainer artifacts — a tracker id in a test description aids traceability
35
- * and never reaches an installed user), node_modules/, .git/, and the
36
- * fix-status-verify subsystem (EXCLUDED_FILES — maintainer-only, carries wiki
37
- * `decisions/` paths as runtime data, un-shipped from the npm package). The
38
- * checker's OWN sources are scanned (NOT exempt); their examples use `N`
39
- * placeholders so they stay clean.
40
- *
41
- * ADR scope: `ADR NNNN` / `decisions/NNNN` wiki-ADR pointers (DECISION_PATTERNS)
42
- * are blocked everywhere in scope EXCEPT CHANGELOG.md, whose version history
43
- * legitimately cites the decision behind a release line. patternsFor() applies the
44
- * broader set to every in-scope file but the CHANGELOG.
45
- */
46
-
47
- import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
48
- import { spawnSync } from 'node:child_process';
49
- import { fileURLToPath } from 'node:url';
50
- import { dirname, join, relative, sep, extname } from 'node:path';
51
- import {
52
- scanText,
53
- stripScissors,
54
- messageHasGitTemplate,
55
- BLOCKED_PATTERNS,
56
- DECISION_PATTERNS,
57
- TAG_BODY_PATTERNS,
58
- } from './lib/check-tracker-ids.mjs';
59
- import {
60
- parseNameStatus,
61
- parseLsFilesStage,
62
- filterRegularFiles,
63
- } from './lib/pre-commit-format.mjs';
64
-
65
- const __dirname = dirname(fileURLToPath(import.meta.url));
66
- // Root resolves from this script's location (so --staged always gates the real
67
- // Hypomnema index regardless of cwd). CHECK_TRACKER_ROOT is a TEST-ONLY seam;
68
- // the installed git hooks `unset CHECK_TRACKER_ROOT` before invoking (see
69
- // install-git-hooks.mjs gateLines) so an inherited/hostile value cannot redirect
70
- // the real gate. Read-only either way (no writes), so it only changes what is
71
- // scanned, never what is written.
72
- const REPO_ROOT = process.env.CHECK_TRACKER_ROOT || join(__dirname, '..');
73
-
74
- const RULE_REF =
75
- 'Rule: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-artifacts). ' +
76
- 'OSS-public artifacts must not reference the private wiki tracker ' +
77
- '(ISSUE-N / fix #N / FEAT-N / IMPR-N / PRAC-N). ' +
78
- 'Use a GitHub ref (#N / PR #N) or drop the reference.';
79
-
80
- // Top-level public-artifact entry points. Files are scanned directly; dirs are
81
- // walked recursively. Anything outside this set is out of scope. package.json is
82
- // here because npm auto-ships it (it is not in the `files` allowlist but is
83
- // always packed), so a tracker id in its metadata is a public leak too.
84
- const SCOPE_FILES = ['README.md', 'README.ko.md', 'CHANGELOG.md', 'package.json'];
85
- const SCOPE_DIRS = [
86
- 'commands',
87
- 'hooks',
88
- 'scripts',
89
- 'skills',
90
- 'templates',
91
- 'docs',
92
- '.github',
93
- '.claude-plugin',
94
- ];
95
-
96
- // Never scanned even when under a scope dir. tests/ and qa-runs/ are
97
- // internal-maintainer artifacts (a tracker id in a test name aids traceability
98
- // and never reaches an installed user). The checker's OWN sources are NOT
99
- // excluded — they use `N` placeholders in their examples so they scan clean.
100
- const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'tests', 'qa-runs']);
101
- // The fix-status-verify subsystem (run only via `npm run fix:verify`, never by a
102
- // shipped command/hook/skill) is a maintainer evidence-verification tool. Its
103
- // manifest carries `decisions/NNNN` wiki paths as RUNTIME DATA (resolved against
104
- // the maintainer's local wiki), so the anchors cannot be stripped the way a
105
- // comment can. It is removed from the npm package (package.json `files`) and
106
- // excluded here so the gate does not flag its load-bearing data.
107
- // Stored POSIX-style ('/'); paths are normalized before lookup (see toPosix).
108
- const EXCLUDED_FILES = new Set([
109
- 'scripts/fix-status-verify.mjs',
110
- 'scripts/lib/fix-status-verify.mjs',
111
- 'scripts/lib/fix-manifest.mjs',
112
- 'scripts/lib/adr-corpus.mjs',
113
- ]);
114
-
115
- // Only text artifacts. Binary / lockfiles add noise and never carry prose refs.
116
- // .svg is included because the shipped docs/assets/ logos are hand-authored XML
117
- // text whose <title>/<desc>/comment fields could carry a dangling wiki pointer.
118
- const TEXT_EXT = new Set([
119
- '.md',
120
- '.mjs',
121
- '.js',
122
- '.cjs',
123
- '.json',
124
- '.yml',
125
- '.yaml',
126
- '.sh',
127
- '.txt',
128
- '.svg',
129
- ]);
130
-
131
- // Normalize OS separators to '/' so the two scan modes classify a path the same
132
- // way on every host: --all builds relPath via relative() (platform sep, so '\' on
133
- // Windows) while --staged takes git's output (always '/'). Without this, a Windows
134
- // --staged run would neither exclude the verifier files nor recognize scope dirs.
135
- // No-op on POSIX (sep is already '/').
136
- function toPosix(relPath) {
137
- return sep === '/' ? relPath : relPath.split(sep).join('/');
138
- }
139
-
140
- function isExcludedRel(relPath) {
141
- const p = toPosix(relPath);
142
- if (p.split('/').some((seg) => EXCLUDED_DIRS.has(seg))) return true;
143
- if (EXCLUDED_FILES.has(p)) return true;
144
- return false;
145
- }
146
-
147
- function isTextFile(p) {
148
- return TEXT_EXT.has(extname(p).toLowerCase());
149
- }
150
-
151
- // Single public-scope predicate shared by --all and --staged so the two modes
152
- // can never disagree on what counts as a public artifact (a root file like
153
- // `foo.md` is out of scope for both; `package.json` and scope-dir files are in).
154
- function isInScope(relPath) {
155
- if (isExcludedRel(relPath)) return false;
156
- if (!isTextFile(relPath)) return false;
157
- const p = toPosix(relPath);
158
- if (SCOPE_FILES.includes(p)) return true;
159
- return SCOPE_DIRS.includes(p.split('/')[0]);
160
- }
161
-
162
- // `ADR NNNN` / `decisions/NNNN` are dangling pointers into the maintainer's
163
- // private wiki ADR set, so they are blocked everywhere in scope EXCEPT the
164
- // CHANGELOG, whose version history legitimately cites the decision behind a
165
- // release line (and never reaches an installed user as a live link). The verifier
166
- // subsystem that carries `decisions/` paths as runtime data is already removed by
167
- // EXCLUDED_FILES, so it is never reached here.
168
- const ADR_EXEMPT_FILES = new Set(['CHANGELOG.md']);
169
- function patternsFor(relPath) {
170
- return ADR_EXEMPT_FILES.has(toPosix(relPath))
171
- ? BLOCKED_PATTERNS
172
- : [...BLOCKED_PATTERNS, ...DECISION_PATTERNS];
173
- }
174
-
175
- // Recursively collect in-scope text files under an absolute dir.
176
- function walk(absDir, acc) {
177
- let entries;
178
- try {
179
- entries = readdirSync(absDir, { withFileTypes: true });
180
- } catch {
181
- return;
182
- }
183
- for (const ent of entries) {
184
- const abs = join(absDir, ent.name);
185
- const rel = relative(REPO_ROOT, abs);
186
- if (isExcludedRel(rel)) continue;
187
- if (ent.isDirectory()) {
188
- walk(abs, acc);
189
- } else if (ent.isFile() && isTextFile(abs)) {
190
- acc.push(rel);
191
- }
192
- }
193
- }
194
-
195
- function collectAllScopeFiles() {
196
- const files = [];
197
- for (const f of SCOPE_FILES) {
198
- if (existsSync(join(REPO_ROOT, f))) files.push(f);
199
- }
200
- for (const d of SCOPE_DIRS) {
201
- walk(join(REPO_ROOT, d), files);
202
- }
203
- return files;
204
- }
205
-
206
- // ── violation reporting ──────────────────────────────────────────────────────
207
-
208
- function report(violations, json) {
209
- if (json) {
210
- process.stdout.write(
211
- JSON.stringify(
212
- {
213
- ok: violations.length === 0,
214
- count: violations.length,
215
- violations,
216
- },
217
- null,
218
- 2,
219
- ) + '\n',
220
- );
221
- } else if (violations.length === 0) {
222
- process.stdout.write('[check-tracker-ids] OK: no wiki-internal tracker ids found.\n');
223
- } else {
224
- process.stderr.write(
225
- `[check-tracker-ids] FAIL: ${violations.length} tracker-id reference(s):\n`,
226
- );
227
- for (const v of violations) {
228
- process.stderr.write(
229
- ` ${v.file}:${v.line}:${v.col} ${v.match} (${v.label})\n` +
230
- ` ${v.lineText.trim()}\n`,
231
- );
232
- }
233
- process.stderr.write(`${RULE_REF}\n`);
234
- }
235
- return violations.length === 0 ? 0 : 1;
236
- }
237
-
238
- // ── git helpers (scoped env not required: read-only, no GIT_DIR attack surface
239
- // here — the hook shim already pins identity before invoking) ──────────────
240
-
241
- function git(args, opts = {}) {
242
- return spawnSync('git', args, { cwd: REPO_ROOT, encoding: 'utf-8', ...opts });
243
- }
244
-
245
- // ── modes ────────────────────────────────────────────────────────────────────
246
-
247
- function runAll(json) {
248
- const files = collectAllScopeFiles();
249
- const violations = [];
250
- for (const rel of files) {
251
- let text;
252
- try {
253
- text = readFileSync(join(REPO_ROOT, rel), 'utf-8');
254
- } catch {
255
- continue;
256
- }
257
- for (const h of scanText(text, patternsFor(rel))) violations.push({ file: rel, ...h });
258
- }
259
- process.exit(report(violations, json));
260
- }
261
-
262
- function runStaged(json) {
263
- const diff = git(['diff', '--cached', '--name-status', '-z', '--diff-filter=ACMR', '--']);
264
- if (diff.status !== 0) {
265
- // Can't read the index → fail OPEN (don't block a commit on a git probe
266
- // failure); the --all CI gate is the hard backstop.
267
- process.stdout.write(
268
- '[check-tracker-ids] staged: git diff failed; skipping (CI is the backstop).\n',
269
- );
270
- process.exit(0);
271
- }
272
- const staged = parseNameStatus(diff.stdout || '');
273
- // Restrict to in-scope public artifacts (same predicate --all uses).
274
- const inScope = staged.filter((e) => isInScope(e.path));
275
- if (!inScope.length) {
276
- if (json) report([], json);
277
- process.exit(0);
278
- }
279
- // Drop symlinks / submodules via the staged mode bits.
280
- const ls = git(['ls-files', '--stage', '-z', '--', ...inScope.map((e) => e.path)]);
281
- let regular = inScope;
282
- if (ls.status === 0) {
283
- regular = filterRegularFiles(inScope, parseLsFilesStage(ls.stdout || ''));
284
- }
285
- const violations = [];
286
- for (const e of regular) {
287
- // Scan the STAGED blob, not the working tree — only what is actually being
288
- // committed is gated (partial stage safety).
289
- const show = git(['show', `:${e.path}`]);
290
- if (show.status !== 0) continue;
291
- for (const h of scanText(show.stdout || '', patternsFor(e.path)))
292
- violations.push({ file: e.path, ...h });
293
- }
294
- process.exit(report(violations, json));
295
- }
296
-
297
- function runCommitMsg(file, json) {
298
- let raw;
299
- try {
300
- raw = readFileSync(file, 'utf-8');
301
- } catch (err) {
302
- process.stderr.write(
303
- `[check-tracker-ids] cannot read commit-msg file ${file}: ${err.message}\n`,
304
- );
305
- process.exit(2);
306
- }
307
- // Decide comment/scissors handling by whether git added its editor template
308
- // (NOT by config, which the hook can't fully resolve). Two branches:
309
- //
310
- // template present (editor commit) → git honors the --verbose scissors AND,
311
- // in the default cleanup, strips `#` comment lines. Replicate: cut the
312
- // scissors diff, then `stripspace --strip-comments`. This matches what git
313
- // records and avoids a false-positive on the template's own branch/file
314
- // names (e.g. a ticket-numbered branch in `# On branch ...`).
315
- //
316
- // template absent (`git commit -m` / `-F` / whitespace / verbatim) → git
317
- // keeps `#` lines and does NOT treat a bare `>8` line as scissors, so scan
318
- // the message VERBATIM. This is what closes both -m gaps (a `#`-prefixed
319
- // tracker id, and content after a user-written `>8` line).
320
- //
321
- // KNOWN LIMITATION (documented, exotic): an EDITOR commit run with an explicit
322
- // non-default `--cleanup=whitespace`/`verbatim` does carry a template yet keeps
323
- // `#` lines, so a tracker id sitting in a comment line there is not caught.
324
- // This needs a non-default flag AND a `#`-prefixed id; the prose path (the real
325
- // risk) is always caught, and the file gate (--all/--staged/CI) is the hard
326
- // guarantee. commit-msg is best-effort.
327
- let text;
328
- if (messageHasGitTemplate(raw)) {
329
- const noScissors = stripScissors(raw);
330
- const strip = git(['stripspace', '--strip-comments'], { input: noScissors });
331
- text = strip.status === 0 ? strip.stdout || '' : noScissors;
332
- } else {
333
- text = raw;
334
- }
335
- const violations = scanText(text).map((h) => ({ file: relative(REPO_ROOT, file) || file, ...h }));
336
- process.exit(report(violations, json));
337
- }
338
-
339
- // Scan an annotated tag's body for ALL wiki tracker prefixes (TAG_BODY_PATTERNS:
340
- // ISSUE-/fix #/FEAT-/IMPR-/PRAC-). The tag body is the PUBLIC release surface —
341
- // `gh release create --notes-from-tag` republishes it verbatim — and it carries
342
- // no code, so unlike the file gate it must reject every prefix (changelog-pr-
343
- // guide §5 / T4). Wired into release.yml before `gh release create`. `--tag -`
344
- // reads the body from stdin (piping / test), `--tag <ref>` reads it from git.
345
- function runTag(ref, json) {
346
- let body;
347
- if (ref === '-') {
348
- try {
349
- body = readFileSync(0, 'utf-8');
350
- } catch (err) {
351
- process.stderr.write(`[check-tracker-ids] --tag -: cannot read stdin: ${err.message}\n`);
352
- process.exit(2);
353
- }
354
- } else {
355
- // Require an annotated tag: `<ref>^{tag}` peels only for an annotated tag
356
- // object. A lightweight tag has no body to leak, but a release uses
357
- // --notes-from-tag on an annotated tag, so a lightweight one here is a
358
- // release misconfiguration — fail loudly rather than silently pass.
359
- const tagObj = git(['rev-parse', '--verify', '--quiet', `${ref}^{tag}`]);
360
- if (tagObj.status !== 0) {
361
- const exists = git(['rev-parse', '--verify', '--quiet', ref]);
362
- if (exists.status !== 0) {
363
- process.stderr.write(`[check-tracker-ids] --tag: tag ${ref} not found\n`);
364
- } else {
365
- process.stderr.write(
366
- `[check-tracker-ids] --tag: ${ref} is a lightweight tag (no annotation body to scan)\n`,
367
- );
368
- }
369
- process.exit(2);
370
- }
371
- const tagBody = git(['tag', '-l', '--format=%(contents)', ref]);
372
- if (tagBody.status !== 0) {
373
- process.stderr.write(
374
- `[check-tracker-ids] --tag: failed to read tag ${ref}: ${tagBody.stderr}\n`,
375
- );
376
- process.exit(2);
377
- }
378
- body = tagBody.stdout || '';
379
- }
380
- const file = ref === '-' ? '<stdin>' : `tag:${ref}`;
381
- const violations = scanText(body, TAG_BODY_PATTERNS).map((h) => ({ file, ...h }));
382
- process.exit(report(violations, json));
383
- }
384
-
385
- // ── arg parsing ────────────────────────────────────────────────────────────
386
-
387
- function usage(code) {
388
- process.stdout.write(
389
- 'Usage:\n' +
390
- ' node scripts/check-tracker-ids.mjs [--all] [--json]\n' +
391
- ' node scripts/check-tracker-ids.mjs --staged [--json]\n' +
392
- ' node scripts/check-tracker-ids.mjs --commit-msg <file> [--json]\n' +
393
- ' node scripts/check-tracker-ids.mjs --tag <ref|-> [--json]\n' +
394
- ' Scan an annotated tag body (or stdin via "-") for ALL tracker\n' +
395
- ' prefixes; the public release surface must be tracker-ID-0.\n',
396
- );
397
- process.exit(code);
398
- }
399
-
400
- const argv = process.argv.slice(2);
401
- if (argv.includes('--help') || argv.includes('-h')) usage(0);
402
- const json = argv.includes('--json');
403
-
404
- if (argv.includes('--commit-msg')) {
405
- const i = argv.indexOf('--commit-msg');
406
- const file = argv[i + 1];
407
- if (!file || file.startsWith('--')) {
408
- process.stderr.write('[check-tracker-ids] --commit-msg requires a file path\n');
409
- usage(2);
410
- }
411
- runCommitMsg(file, json);
412
- } else if (argv.includes('--tag')) {
413
- const i = argv.indexOf('--tag');
414
- const ref = argv[i + 1];
415
- // `-` (stdin) is a valid ref token here; only a missing/flag value is an error.
416
- if (!ref || (ref.startsWith('--') && ref !== '-')) {
417
- process.stderr.write('[check-tracker-ids] --tag requires a ref argument (or "-" for stdin)\n');
418
- usage(2);
419
- }
420
- runTag(ref, json);
421
- } else if (argv.includes('--staged')) {
422
- runStaged(json);
423
- } else {
424
- // default + explicit --all
425
- runAll(json);
426
- }
@@ -1,171 +0,0 @@
1
- #!/usr/bin/env node
2
- // check-versions.mjs — assert every version-carrying file in the repo agrees, and
3
- // (with --tag) that they all match the release tag. This makes the release pipeline
4
- // OWN the plugin channel: a forgotten plugin.json / marketplace.json / hypo-config /
5
- // lockfile bump hard-fails the release instead of publishing a split-version plugin.
6
- //
7
- // The set mirrors scripts/bump-version.mjs (package.json, .claude-plugin/plugin.json,
8
- // .claude-plugin/marketplace.json, templates/hypo-config.md) PLUS package-lock.json,
9
- // which npm — not bump-version — manages, so it can lag a bump and silently break
10
- // `npm ci`. README/CHANGELOG carry prose version HISTORY (every past vX.Y.Z), not a
11
- // single release authority, so they are intentionally excluded (covered by the
12
- // bilingual + README-reconcile checklist instead).
13
- //
14
- // Usage:
15
- // node scripts/check-versions.mjs # assert all files agree
16
- // node scripts/check-versions.mjs --tag v1.4.0 # also assert they equal the tag
17
- // node scripts/check-versions.mjs --root <dir> # point at a fixture (tests)
18
- // node scripts/check-versions.mjs --json
19
- //
20
- // Exit 0 = consistent (and, with --tag, matches). Exit 1 = drift / unreadable / mismatch.
21
-
22
- import { readFileSync } from 'fs';
23
- import { join, dirname } from 'path';
24
- import { fileURLToPath } from 'url';
25
-
26
- function parseArgs(argv) {
27
- const args = { root: null, tag: null, json: false };
28
- for (let i = 0; i < argv.length; i++) {
29
- const a = argv[i];
30
- if (a.startsWith('--root=')) args.root = a.slice(7);
31
- else if (a === '--root') args.root = argv[++i];
32
- else if (a.startsWith('--tag=')) args.tag = a.slice(6);
33
- else if (a === '--tag') args.tag = argv[++i];
34
- else if (a === '--json') args.json = true;
35
- }
36
- return args;
37
- }
38
-
39
- const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
40
-
41
- function readJson(abs) {
42
- return JSON.parse(readFileSync(abs, 'utf-8'));
43
- }
44
-
45
- // Collect {label, version} for every authoritative location. A location that
46
- // cannot be read or whose field is missing becomes {label, version: null, error}.
47
- function collectVersions(root) {
48
- const sources = [];
49
- const push = (label, fn) => {
50
- try {
51
- const v = fn();
52
- if (typeof v !== 'string' || v.length === 0) {
53
- sources.push({ label, version: null, error: 'version field missing or empty' });
54
- } else {
55
- sources.push({ label, version: v });
56
- }
57
- } catch (err) {
58
- sources.push({ label, version: null, error: err?.message ?? String(err) });
59
- }
60
- };
61
-
62
- push('package.json', () => readJson(join(root, 'package.json')).version);
63
-
64
- // package-lock.json carries the version in TWO top-level spots (lockfileVersion 3):
65
- // the root `.version` and `.packages[""].version`. Dependency versions deeper in
66
- // the tree are NOT release authorities and must not be read.
67
- push('package-lock.json (root)', () => readJson(join(root, 'package-lock.json')).version);
68
- push('package-lock.json (packages[""])', () => {
69
- const lock = readJson(join(root, 'package-lock.json'));
70
- return lock.packages && lock.packages[''] ? lock.packages[''].version : undefined;
71
- });
72
-
73
- const pluginName = (() => {
74
- try {
75
- return readJson(join(root, '.claude-plugin', 'plugin.json')).name;
76
- } catch {
77
- return null;
78
- }
79
- })();
80
-
81
- push(
82
- '.claude-plugin/plugin.json',
83
- () => readJson(join(root, '.claude-plugin', 'plugin.json')).version,
84
- );
85
-
86
- // Select the marketplace entry BY NAME (matching plugin.json), not by position:
87
- // Claude Code's runtime resolves plugins by name (hooks/version-check.mjs), and a
88
- // future second marketplace entry would make plugins[0] the wrong authority.
89
- push('.claude-plugin/marketplace.json (entry: ' + (pluginName ?? '?') + ')', () => {
90
- const mp = readJson(join(root, '.claude-plugin', 'marketplace.json'));
91
- const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
92
- if (!pluginName)
93
- throw new Error('plugin.json name unreadable — cannot match marketplace entry');
94
- const matches = plugins.filter((p) => p && p.name === pluginName);
95
- if (matches.length !== 1) {
96
- throw new Error(
97
- `expected exactly one marketplace entry named "${pluginName}", found ${matches.length}`,
98
- );
99
- }
100
- return matches[0].version;
101
- });
102
-
103
- // hypo-config.md frontmatter: version: "X.Y.Z"
104
- push('templates/hypo-config.md', () => {
105
- const text = readFileSync(join(root, 'templates', 'hypo-config.md'), 'utf-8');
106
- const m = text.match(/^version:\s*"?([^"\n]+)"?/m);
107
- return m ? m[1].trim() : undefined;
108
- });
109
-
110
- return sources;
111
- }
112
-
113
- function main() {
114
- const args = parseArgs(process.argv.slice(2));
115
- const root = args.root || REPO_ROOT;
116
- const sources = collectVersions(root);
117
-
118
- const errored = sources.filter((s) => s.error);
119
- const versions = [...new Set(sources.filter((s) => s.version).map((s) => s.version))];
120
-
121
- // Normalize the tag by stripping exactly one leading `v` (release tags are vX.Y.Z,
122
- // file versions are X.Y.Z). Track tag PRESENCE separately from the normalized
123
- // value: a bare `v` (or any tag that normalizes to empty / non-semver) must HARD
124
- // FAIL, not be mistaken for "no tag supplied" — otherwise a `git tag v` push would
125
- // bypass the release gate. This preserves the old "Validate tag matches package
126
- // version" guarantee while widening it to every channel.
127
- const hasTag = args.tag != null;
128
- const tagVersion = hasTag ? args.tag.replace(/^v/, '') : null;
129
- const tagValid = hasTag && /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(tagVersion);
130
-
131
- const consistent = errored.length === 0 && versions.length === 1;
132
- const tagOk = !hasTag || (tagValid && consistent && versions[0] === tagVersion);
133
- const ok = consistent && tagOk;
134
-
135
- if (args.json) {
136
- console.log(
137
- JSON.stringify(
138
- { ok, consistent, hasTag, tagVersion, tagValid, distinctVersions: versions, sources },
139
- null,
140
- 2,
141
- ),
142
- );
143
- } else {
144
- const width = Math.max(...sources.map((s) => s.label.length));
145
- for (const s of sources) {
146
- const val = s.error ? `ERROR: ${s.error}` : s.version;
147
- console.log(` ${s.label.padEnd(width)} ${val}`);
148
- }
149
- if (errored.length) {
150
- console.error(`\n✗ ${errored.length} version source(s) unreadable.`);
151
- } else if (!consistent) {
152
- console.error(
153
- `\n✗ version drift — ${versions.length} distinct versions: ${versions.join(', ')}`,
154
- );
155
- } else if (hasTag && !tagValid) {
156
- console.error(`\n✗ tag "${args.tag}" does not normalize to a valid semver version`);
157
- } else if (!tagOk) {
158
- console.error(
159
- `\n✗ tag ${args.tag} (→ ${tagVersion}) does not match the file version ${versions[0]}`,
160
- );
161
- } else {
162
- console.log(
163
- `\n✓ all version-carrying files agree on ${versions[0]}${tagVersion ? ` (matches tag ${args.tag})` : ''}`,
164
- );
165
- }
166
- }
167
-
168
- process.exit(ok ? 0 : 1);
169
- }
170
-
171
- main();