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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- 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();
|