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/hooks/hypo-shared.mjs
CHANGED
|
@@ -846,7 +846,10 @@ function syncStatePath(hypoDir) {
|
|
|
846
846
|
* failure-log must not break the Stop hook that calls it.
|
|
847
847
|
*
|
|
848
848
|
* @param {string} hypoDir
|
|
849
|
-
* @param {'pull'|'push'} op
|
|
849
|
+
* @param {'pull'|'push'|'conflict'|'conflict-unresolved'} op 'conflict' = a
|
|
850
|
+
* merge conflict was detected and aborted (the tree was left clean at the
|
|
851
|
+
* local commit); 'conflict-unresolved' = the abort itself failed and the tree
|
|
852
|
+
* may still be half-merged (rare). See syncRemote.
|
|
850
853
|
* @param {string} error raw stderr/stdout; first non-empty line is kept
|
|
851
854
|
*/
|
|
852
855
|
export function appendSyncFailure(hypoDir, op, error) {
|
|
@@ -870,6 +873,70 @@ export function appendSyncFailure(hypoDir, op, error) {
|
|
|
870
873
|
}
|
|
871
874
|
}
|
|
872
875
|
|
|
876
|
+
/**
|
|
877
|
+
* Pull + push the wiki against its remote, guaranteeing the working tree is
|
|
878
|
+
* never left half-merged. Called by the auto-commit Stop hook after a local
|
|
879
|
+
* commit succeeds.
|
|
880
|
+
*
|
|
881
|
+
* Failure policy (v1.4 "sync hardening", tracker FEAT-17):
|
|
882
|
+
* - clean fast-forward / conflict-free merge → push.
|
|
883
|
+
* - MERGE CONFLICT (`git pull --no-rebase` leaves unmerged paths): abort the
|
|
884
|
+
* merge so the tree returns to the just-committed local state ("ours"),
|
|
885
|
+
* record op='conflict', and do NOT push (a diverged branch cannot
|
|
886
|
+
* fast-forward, so the push would only add a noisy second failure). No data
|
|
887
|
+
* is lost: ours stays committed locally, "theirs" stays on the remote, and
|
|
888
|
+
* the divergence is surfaced by session-start + doctor until the user merges
|
|
889
|
+
* manually. Inline auto-resolution (preserving the losing version as a
|
|
890
|
+
* `.conflict-*` sibling) is deferred — see tracker PRAC-18.
|
|
891
|
+
* - non-conflict pull failure (network/auth: no unmerged paths) → record
|
|
892
|
+
* op='pull', then still attempt push (a transient pull blip should not block
|
|
893
|
+
* an otherwise-pushable commit); record op='push' if that also fails.
|
|
894
|
+
*
|
|
895
|
+
* Best-effort: never throws — a sync failure must not break the Stop hook.
|
|
896
|
+
*
|
|
897
|
+
* @param {string} hypoDir
|
|
898
|
+
* @returns {{pulled: boolean, pushed: boolean, conflict: boolean}}
|
|
899
|
+
*/
|
|
900
|
+
export function syncRemote(hypoDir) {
|
|
901
|
+
const git = (...args) =>
|
|
902
|
+
spawnSync('git', ['-C', hypoDir, ...args], { encoding: 'utf-8', timeout: 30000 });
|
|
903
|
+
const result = { pulled: false, pushed: false, conflict: false };
|
|
904
|
+
try {
|
|
905
|
+
const pull = git('pull', '--no-rebase', '-q');
|
|
906
|
+
if (pull.status === 0) {
|
|
907
|
+
result.pulled = true;
|
|
908
|
+
} else {
|
|
909
|
+
// A merge conflict leaves unmerged index entries; a network/auth failure
|
|
910
|
+
// leaves none. Only the former must be aborted to keep the tree clean.
|
|
911
|
+
const unmerged = git('ls-files', '-u');
|
|
912
|
+
const hasConflict = unmerged.status === 0 && (unmerged.stdout || '').trim().length > 0;
|
|
913
|
+
if (hasConflict) {
|
|
914
|
+
// Abort to return the tree to the just-committed local state. Verify the
|
|
915
|
+
// abort actually cleaned up: if it fails (filesystem/concurrent-mutation
|
|
916
|
+
// edge), the tree may still be half-merged, so record that distinctly
|
|
917
|
+
// ('conflict-unresolved') rather than masking it as a clean abort.
|
|
918
|
+
const abort = git('merge', '--abort');
|
|
919
|
+
const stillUnmerged = git('ls-files', '-u');
|
|
920
|
+
const aborted = abort.status === 0 && (stillUnmerged.stdout || '').trim().length === 0;
|
|
921
|
+
appendSyncFailure(
|
|
922
|
+
hypoDir,
|
|
923
|
+
aborted ? 'conflict' : 'conflict-unresolved',
|
|
924
|
+
pull.stderr || pull.stdout,
|
|
925
|
+
);
|
|
926
|
+
result.conflict = true;
|
|
927
|
+
return result; // do not push from a diverged branch
|
|
928
|
+
}
|
|
929
|
+
appendSyncFailure(hypoDir, 'pull', pull.stderr || pull.stdout);
|
|
930
|
+
}
|
|
931
|
+
const push = git('push');
|
|
932
|
+
if (push.status === 0) result.pushed = true;
|
|
933
|
+
else appendSyncFailure(hypoDir, 'push', push.stderr || push.stdout);
|
|
934
|
+
} catch {
|
|
935
|
+
// best-effort — never break the Stop hook
|
|
936
|
+
}
|
|
937
|
+
return result;
|
|
938
|
+
}
|
|
939
|
+
|
|
873
940
|
/**
|
|
874
941
|
* Stage + commit every non-.hypoignore change in the wiki. Does NOT pull/push —
|
|
875
942
|
* remote sync stays in the auto-commit Stop hook (commit is local + cheap; sync is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hypomnema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "LLM-native personal wiki system for Claude Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"scripts/",
|
|
12
|
+
"!scripts/collect-changelog.mjs",
|
|
13
|
+
"!scripts/lib/collect-changelog.mjs",
|
|
14
|
+
"!scripts/bump-version.mjs",
|
|
15
|
+
"!scripts/smoke-pack.mjs",
|
|
16
|
+
"!scripts/smoke-plugin.mjs",
|
|
12
17
|
"commands/",
|
|
13
18
|
"hooks/",
|
|
14
19
|
"skills/",
|
|
@@ -42,12 +47,15 @@
|
|
|
42
47
|
"fix:verify": "node scripts/fix-status-verify.mjs",
|
|
43
48
|
"graph": "node scripts/graph.mjs",
|
|
44
49
|
"smoke-pack": "node scripts/smoke-pack.mjs",
|
|
50
|
+
"check:versions": "node scripts/check-versions.mjs",
|
|
51
|
+
"smoke:plugin": "node scripts/smoke-plugin.mjs",
|
|
45
52
|
"check:bilingual": "node scripts/check-bilingual.mjs --changelog",
|
|
53
|
+
"check:readme": "node scripts/check-readme-version.mjs",
|
|
46
54
|
"check:tracker-ids": "node scripts/check-tracker-ids.mjs --all",
|
|
47
55
|
"format": "prettier --write .",
|
|
48
56
|
"format:check": "prettier --check .",
|
|
49
57
|
"prepare": "node scripts/install-git-hooks.mjs",
|
|
50
|
-
"prepublishOnly": "npm test && npm run lint && npm run smoke-pack && npm run check:bilingual && npm run check:tracker-ids"
|
|
58
|
+
"prepublishOnly": "npm test && npm run lint && npm run check:versions && npm run smoke:plugin && npm run smoke-pack && npm run check:bilingual && npm run check:readme && npm run check:tracker-ids"
|
|
51
59
|
},
|
|
52
60
|
"devDependencies": {
|
|
53
61
|
"prettier": "^3.8.3"
|
|
@@ -20,7 +20,12 @@ import { readFileSync } from 'fs';
|
|
|
20
20
|
import { spawnSync } from 'child_process';
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
22
22
|
import { dirname, join } from 'path';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
validateChangelog,
|
|
25
|
+
validateTagBody,
|
|
26
|
+
listChangelogVersions,
|
|
27
|
+
meetsKoreanCutoff,
|
|
28
|
+
} from './lib/check-bilingual.mjs';
|
|
24
29
|
|
|
25
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
31
|
const REPO_ROOT = join(__dirname, '..');
|
|
@@ -43,7 +48,11 @@ function usage(exitCode) {
|
|
|
43
48
|
process.stdout.write(
|
|
44
49
|
`Usage:\n` +
|
|
45
50
|
` node scripts/check-bilingual.mjs --changelog [version]\n` +
|
|
46
|
-
` Validate CHANGELOG.md "## [<version>]" section
|
|
51
|
+
` Validate one CHANGELOG.md "## [<version>]" section (section model).\n` +
|
|
52
|
+
` Default version: package.json.\n` +
|
|
53
|
+
` node scripts/check-bilingual.mjs --changelog --all\n` +
|
|
54
|
+
` Validate EVERY documented version (Korean enforced at >= 1.2.0,\n` +
|
|
55
|
+
` English-only versions below the cutoff pass).\n` +
|
|
47
56
|
` node scripts/check-bilingual.mjs --tag <ref>\n` +
|
|
48
57
|
` Validate annotated tag body (lightweight tags are rejected).\n`,
|
|
49
58
|
);
|
|
@@ -52,12 +61,44 @@ function usage(exitCode) {
|
|
|
52
61
|
|
|
53
62
|
const args = process.argv.slice(2);
|
|
54
63
|
const mode = args[0];
|
|
64
|
+
const wantAll = args.includes('--all');
|
|
55
65
|
|
|
56
66
|
if (mode === '--help' || mode === '-h') usage(0);
|
|
57
67
|
if (!mode) usage(1);
|
|
58
68
|
|
|
59
69
|
if (mode === '--changelog') {
|
|
60
|
-
let
|
|
70
|
+
let content;
|
|
71
|
+
try {
|
|
72
|
+
content = readFileSync(join(REPO_ROOT, 'CHANGELOG.md'), 'utf-8');
|
|
73
|
+
} catch (err) {
|
|
74
|
+
fail(`cannot read CHANGELOG.md: ${err.message}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (wantAll) {
|
|
78
|
+
// Validate every documented version. Korean is enforced only at/after the
|
|
79
|
+
// cutoff; pre-cutoff versions pass on English presence (format.md §9). This
|
|
80
|
+
// is the migration gate — it must not green-pass a half-migrated file.
|
|
81
|
+
const versions = listChangelogVersions(content);
|
|
82
|
+
if (versions.length === 0) fail('no "## [<version>]" sections found in CHANGELOG.md');
|
|
83
|
+
const failures = [];
|
|
84
|
+
let enforced = 0;
|
|
85
|
+
for (const v of versions) {
|
|
86
|
+
const r = validateChangelog(content, v);
|
|
87
|
+
if (meetsKoreanCutoff(v)) enforced++;
|
|
88
|
+
if (!r.ok) failures.push(` [${v}] ${r.reason}`);
|
|
89
|
+
}
|
|
90
|
+
if (failures.length) {
|
|
91
|
+
fail(`${failures.length}/${versions.length} version(s) failed:\n${failures.join('\n')}`);
|
|
92
|
+
}
|
|
93
|
+
ok(
|
|
94
|
+
`CHANGELOG.md --all: ${versions.length} versions conform ` +
|
|
95
|
+
`(${enforced} Korean-enforced at >= 1.2.0, ${versions.length - enforced} English-only below cutoff).`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// single-version path (kept: prepublishOnly and release.yml call --changelog
|
|
100
|
+
// with no version, defaulting to package.json's).
|
|
101
|
+
let version = args[1] && !args[1].startsWith('--') ? args[1] : undefined;
|
|
61
102
|
if (!version) {
|
|
62
103
|
try {
|
|
63
104
|
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, 'package.json'), 'utf-8'));
|
|
@@ -68,16 +109,13 @@ if (mode === '--changelog') {
|
|
|
68
109
|
}
|
|
69
110
|
if (!version) fail('no version (arg empty, package.json has no "version" field)');
|
|
70
111
|
|
|
71
|
-
let content;
|
|
72
|
-
try {
|
|
73
|
-
content = readFileSync(join(REPO_ROOT, 'CHANGELOG.md'), 'utf-8');
|
|
74
|
-
} catch (err) {
|
|
75
|
-
fail(`cannot read CHANGELOG.md: ${err.message}`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
112
|
const result = validateChangelog(content, version);
|
|
79
113
|
if (!result.ok) fail(result.reason);
|
|
80
|
-
|
|
114
|
+
if (result.koreanExempt) {
|
|
115
|
+
ok(`CHANGELOG.md [${version}] — pre-cutoff version, English-only (Korean exempt).`);
|
|
116
|
+
} else {
|
|
117
|
+
ok(`CHANGELOG.md [${version}] — ${result.hangulCount} Hangul chars across "#### 한국어" sub-blocks.`);
|
|
118
|
+
}
|
|
81
119
|
} else if (mode === '--tag') {
|
|
82
120
|
const ref = args[1];
|
|
83
121
|
if (!ref) fail('--tag requires a ref argument (e.g. v1.2.1)');
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-readme-version.mjs — assert the release version string is present in BOTH
|
|
3
|
+
// README.md and README.ko.md. This is the machine FLOOR for the README-reconcile
|
|
4
|
+
// step that was dropped three times (v1.2 / v1.3.0 / v1.3.1): a publish of vX.Y.Z
|
|
5
|
+
// must not go out unless vX.Y.Z is written into both READMEs' version narrative.
|
|
6
|
+
//
|
|
7
|
+
// SCOPE (honest): this catches the GROSS drop — shipping a version whose sentence
|
|
8
|
+
// was never added to the README at all. It does NOT verify the narrative is
|
|
9
|
+
// accurate or that the first-viewport "current release" pointer was updated; that
|
|
10
|
+
// stays a judgment step owned by the release checklist (docs/CONTRIBUTING.md
|
|
11
|
+
// "Cutting a release"). README/CHANGELOG carry
|
|
12
|
+
// prose version HISTORY (every past vX.Y.Z), so check-versions.mjs intentionally
|
|
13
|
+
// excludes them — this script is their complement, not a duplicate.
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// node scripts/check-readme-version.mjs # version from package.json
|
|
17
|
+
// node scripts/check-readme-version.mjs --version 1.4.0 # explicit version
|
|
18
|
+
// node scripts/check-readme-version.mjs --root <dir> # point at a fixture (tests)
|
|
19
|
+
// node scripts/check-readme-version.mjs --json
|
|
20
|
+
//
|
|
21
|
+
// Exit 0 = both READMEs mention the version. Exit 1 = missing in one/both, or unreadable.
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from 'fs';
|
|
24
|
+
import { join, dirname } from 'path';
|
|
25
|
+
import { fileURLToPath } from 'url';
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = { root: null, version: null, json: false };
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const a = argv[i];
|
|
31
|
+
if (a.startsWith('--root=')) args.root = a.slice(7);
|
|
32
|
+
else if (a === '--root') args.root = argv[++i];
|
|
33
|
+
else if (a.startsWith('--version=')) args.version = a.slice(10);
|
|
34
|
+
else if (a === '--version') args.version = argv[++i];
|
|
35
|
+
else if (a === '--json') args.json = true;
|
|
36
|
+
}
|
|
37
|
+
return args;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
|
|
42
|
+
const README_FILES = ['README.md', 'README.ko.md'];
|
|
43
|
+
|
|
44
|
+
// Build a boundary-aware matcher so a version does not spuriously match inside a
|
|
45
|
+
// LONGER version token yet still matches at a sentence boundary. The checked
|
|
46
|
+
// version must be a standalone token, NOT a prefix of another version:
|
|
47
|
+
// - "1.3.40", "v11.3.4" → reject (digit continuation)
|
|
48
|
+
// - "1.3.4.5" → reject (dot-then-digit continuation)
|
|
49
|
+
// - "1.3.4-rc.1", "1.3.4+b" → reject (a stable check must NOT pass on a
|
|
50
|
+
// prerelease/build string — that defeats the floor)
|
|
51
|
+
// - "1.3.0-rc.1a", "1.3.0-rc.10", "1.3.0-rc.1.alpha" → a prerelease check must
|
|
52
|
+
// reject these continuation tokens too
|
|
53
|
+
// - "shipped v1.3.4." / "v1.3.4**" / "(v1.3.4)" → match (sentence/markup boundary)
|
|
54
|
+
// Leading: no digit/dot immediately before (an optional "v" is fine — it is
|
|
55
|
+
// neither). Trailing: not a semver-continuation char ([0-9A-Za-z+-]) and not a
|
|
56
|
+
// dot-then-alphanumeric (".5" / ".alpha" continuation); a sentence period
|
|
57
|
+
// (dot followed by space / punctuation / end) is allowed.
|
|
58
|
+
function versionPresent(text, version) {
|
|
59
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
60
|
+
return new RegExp(`(?<![\\d.])${escaped}(?![\\dA-Za-z+-])(?!\\.[\\dA-Za-z])`).test(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkReadmeVersions(root, version) {
|
|
64
|
+
const results = README_FILES.map((file) => {
|
|
65
|
+
const abs = join(root, file);
|
|
66
|
+
try {
|
|
67
|
+
const text = readFileSync(abs, 'utf-8');
|
|
68
|
+
return { file, present: versionPresent(text, version) };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return { file, present: false, error: err?.message ?? String(err) };
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
const ok = results.every((r) => r.present);
|
|
74
|
+
return { ok, version, results };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function main() {
|
|
78
|
+
const args = parseArgs(process.argv.slice(2));
|
|
79
|
+
const root = args.root || REPO_ROOT;
|
|
80
|
+
|
|
81
|
+
let version = args.version;
|
|
82
|
+
if (!version) {
|
|
83
|
+
try {
|
|
84
|
+
version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const msg = `cannot read package.json for version: ${err?.message ?? err}`;
|
|
87
|
+
if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
|
|
88
|
+
else console.error(`✗ ${msg}`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (typeof version !== 'string' || !version) {
|
|
93
|
+
const msg = 'no version (—version empty and package.json has no "version" field)';
|
|
94
|
+
if (args.json) console.log(JSON.stringify({ ok: false, error: msg }, null, 2));
|
|
95
|
+
else console.error(`✗ ${msg}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const report = checkReadmeVersions(root, version);
|
|
100
|
+
|
|
101
|
+
if (args.json) {
|
|
102
|
+
console.log(JSON.stringify(report, null, 2));
|
|
103
|
+
} else {
|
|
104
|
+
for (const r of report.results) {
|
|
105
|
+
const mark = r.error ? `ERROR: ${r.error}` : r.present ? 'found' : 'MISSING';
|
|
106
|
+
console.log(` ${r.file.padEnd(13)} ${mark}`);
|
|
107
|
+
}
|
|
108
|
+
if (report.ok) {
|
|
109
|
+
console.log(`\n✓ both READMEs mention version ${version}`);
|
|
110
|
+
} else {
|
|
111
|
+
const missing = report.results
|
|
112
|
+
.filter((r) => !r.present)
|
|
113
|
+
.map((r) => (r.error ? `${r.file} (unreadable)` : r.file))
|
|
114
|
+
.join(', ');
|
|
115
|
+
console.error(
|
|
116
|
+
`\n✗ version ${version} missing from: ${missing}\n` +
|
|
117
|
+
` Reconcile the version narrative in both READMEs before releasing ` +
|
|
118
|
+
`(the README reconcile step — see docs/CONTRIBUTING.md "Cutting a release").`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
process.exit(report.ok ? 0 : 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main();
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
messageHasGitTemplate,
|
|
52
52
|
BLOCKED_PATTERNS,
|
|
53
53
|
USER_FACING_PATTERNS,
|
|
54
|
+
TAG_BODY_PATTERNS,
|
|
54
55
|
} from './lib/check-tracker-ids.mjs';
|
|
55
56
|
import {
|
|
56
57
|
parseNameStatus,
|
|
@@ -297,6 +298,52 @@ function runCommitMsg(file, json) {
|
|
|
297
298
|
process.exit(report(violations, json));
|
|
298
299
|
}
|
|
299
300
|
|
|
301
|
+
// Scan an annotated tag's body for ALL wiki tracker prefixes (TAG_BODY_PATTERNS:
|
|
302
|
+
// ISSUE-/fix #/FEAT-/IMPR-/PRAC-). The tag body is the PUBLIC release surface —
|
|
303
|
+
// `gh release create --notes-from-tag` republishes it verbatim — and it carries
|
|
304
|
+
// no code, so unlike the file gate it must reject every prefix (changelog-pr-
|
|
305
|
+
// guide §5 / T4). Wired into release.yml before `gh release create`. `--tag -`
|
|
306
|
+
// reads the body from stdin (piping / test), `--tag <ref>` reads it from git.
|
|
307
|
+
function runTag(ref, json) {
|
|
308
|
+
let body;
|
|
309
|
+
if (ref === '-') {
|
|
310
|
+
try {
|
|
311
|
+
body = readFileSync(0, 'utf-8');
|
|
312
|
+
} catch (err) {
|
|
313
|
+
process.stderr.write(`[check-tracker-ids] --tag -: cannot read stdin: ${err.message}\n`);
|
|
314
|
+
process.exit(2);
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
// Require an annotated tag: `<ref>^{tag}` peels only for an annotated tag
|
|
318
|
+
// object. A lightweight tag has no body to leak, but a release uses
|
|
319
|
+
// --notes-from-tag on an annotated tag, so a lightweight one here is a
|
|
320
|
+
// release misconfiguration — fail loudly rather than silently pass.
|
|
321
|
+
const tagObj = git(['rev-parse', '--verify', '--quiet', `${ref}^{tag}`]);
|
|
322
|
+
if (tagObj.status !== 0) {
|
|
323
|
+
const exists = git(['rev-parse', '--verify', '--quiet', ref]);
|
|
324
|
+
if (exists.status !== 0) {
|
|
325
|
+
process.stderr.write(`[check-tracker-ids] --tag: tag ${ref} not found\n`);
|
|
326
|
+
} else {
|
|
327
|
+
process.stderr.write(
|
|
328
|
+
`[check-tracker-ids] --tag: ${ref} is a lightweight tag (no annotation body to scan)\n`,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
process.exit(2);
|
|
332
|
+
}
|
|
333
|
+
const tagBody = git(['tag', '-l', '--format=%(contents)', ref]);
|
|
334
|
+
if (tagBody.status !== 0) {
|
|
335
|
+
process.stderr.write(
|
|
336
|
+
`[check-tracker-ids] --tag: failed to read tag ${ref}: ${tagBody.stderr}\n`,
|
|
337
|
+
);
|
|
338
|
+
process.exit(2);
|
|
339
|
+
}
|
|
340
|
+
body = tagBody.stdout || '';
|
|
341
|
+
}
|
|
342
|
+
const file = ref === '-' ? '<stdin>' : `tag:${ref}`;
|
|
343
|
+
const violations = scanText(body, TAG_BODY_PATTERNS).map((h) => ({ file, ...h }));
|
|
344
|
+
process.exit(report(violations, json));
|
|
345
|
+
}
|
|
346
|
+
|
|
300
347
|
// ── arg parsing ────────────────────────────────────────────────────────────
|
|
301
348
|
|
|
302
349
|
function usage(code) {
|
|
@@ -304,7 +351,10 @@ function usage(code) {
|
|
|
304
351
|
'Usage:\n' +
|
|
305
352
|
' node scripts/check-tracker-ids.mjs [--all] [--json]\n' +
|
|
306
353
|
' node scripts/check-tracker-ids.mjs --staged [--json]\n' +
|
|
307
|
-
' node scripts/check-tracker-ids.mjs --commit-msg <file> [--json]\n'
|
|
354
|
+
' node scripts/check-tracker-ids.mjs --commit-msg <file> [--json]\n' +
|
|
355
|
+
' node scripts/check-tracker-ids.mjs --tag <ref|-> [--json]\n' +
|
|
356
|
+
' Scan an annotated tag body (or stdin via "-") for ALL tracker\n' +
|
|
357
|
+
' prefixes; the public release surface must be tracker-ID-0.\n',
|
|
308
358
|
);
|
|
309
359
|
process.exit(code);
|
|
310
360
|
}
|
|
@@ -321,6 +371,15 @@ if (argv.includes('--commit-msg')) {
|
|
|
321
371
|
usage(2);
|
|
322
372
|
}
|
|
323
373
|
runCommitMsg(file, json);
|
|
374
|
+
} else if (argv.includes('--tag')) {
|
|
375
|
+
const i = argv.indexOf('--tag');
|
|
376
|
+
const ref = argv[i + 1];
|
|
377
|
+
// `-` (stdin) is a valid ref token here; only a missing/flag value is an error.
|
|
378
|
+
if (!ref || (ref.startsWith('--') && ref !== '-')) {
|
|
379
|
+
process.stderr.write('[check-tracker-ids] --tag requires a ref argument (or "-" for stdin)\n');
|
|
380
|
+
usage(2);
|
|
381
|
+
}
|
|
382
|
+
runTag(ref, json);
|
|
324
383
|
} else if (argv.includes('--staged')) {
|
|
325
384
|
runStaged(json);
|
|
326
385
|
} else {
|
|
@@ -0,0 +1,171 @@
|
|
|
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();
|