hypomnema 1.3.4 → 1.4.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 +2 -2
- package/README.md +2 -2
- package/commands/feedback.md +4 -0
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CONTRIBUTING.md +56 -17
- 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 +5 -2
- package/scripts/bump-version.mjs +20 -6
- package/scripts/check-readme-version.mjs +126 -0
- package/scripts/check-versions.mjs +171 -0
- package/scripts/crystallize.mjs +19 -32
- 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/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/smoke-plugin.mjs +194 -0
- package/scripts/stats.mjs +22 -2
- package/scripts/upgrade.mjs +11 -3
- package/templates/SCHEMA.md +25 -1
- package/templates/hypo-config.md +1 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Shared wikilink primitives — the ONE source of truth for vault traversal,
|
|
2
|
+
// slug-form generation, and bare wikilink extraction.
|
|
3
|
+
//
|
|
4
|
+
// Consumed by:
|
|
5
|
+
// - scripts/lint.mjs (slug map, page collection, link-target scan)
|
|
6
|
+
// - scripts/rename.mjs (form index with collision detection, page scan)
|
|
7
|
+
// - scripts/graph.mjs (slug index, page collection, link extraction)
|
|
8
|
+
// - scripts/crystallize.mjs (page collection, link extraction)
|
|
9
|
+
//
|
|
10
|
+
// IMPR-13 consolidation. Before this lib, each script carried its own copy of
|
|
11
|
+
// collectPages + a slug-form deriver, drifting in subtle ways. The four
|
|
12
|
+
// collectPages variants are NOT interchangeable — they differ deliberately in
|
|
13
|
+
// traversal/security/output-shape policy (codex design review, CONCERN):
|
|
14
|
+
//
|
|
15
|
+
// - rename uses lstat + skips symlinks → a vault scan can never escape via a
|
|
16
|
+
// symlinked dir/file (security boundary).
|
|
17
|
+
// - lint skips `_`-prefixed DIRECTORIES → pages/feedback/_drafts scaffolds
|
|
18
|
+
// stay out of the lint set, while `_`-prefixed FILES (e.g. _index.md) lint.
|
|
19
|
+
// - crystallize skips ANY `.`-prefixed entry (dir AND file).
|
|
20
|
+
// - graph/lint/rename skip only `.`-prefixed FILES.
|
|
21
|
+
// - graph/crystallize emit raw `rel`/`slug`; lint keeps OS-native `rel`
|
|
22
|
+
// (its own buildSlugMap POSIX-normalizes downstream); rename/graph
|
|
23
|
+
// POSIX-normalize the slug at scan time.
|
|
24
|
+
//
|
|
25
|
+
// So this lib exposes ONE walker core plus four NAMED PRESETS, not a pile of
|
|
26
|
+
// boolean flags — each preset pins exactly one caller's historical behavior and
|
|
27
|
+
// is fixed by a unit test. readdirSync order is preserved verbatim (NO sort):
|
|
28
|
+
// graph's bare-first slug index is order-sensitive (first page wins a shared
|
|
29
|
+
// bare form), so adding a sort would silently change resolution.
|
|
30
|
+
//
|
|
31
|
+
// Collision RESOLUTION is intentionally NOT shared: lint dedups into a Set
|
|
32
|
+
// (membership), rename detects ambiguity to refuse unsafe auto-rewrites, graph
|
|
33
|
+
// is first-wins. Each caller builds its own structure from slugForms(); only the
|
|
34
|
+
// form GENERATION lives here.
|
|
35
|
+
|
|
36
|
+
import { existsSync, readdirSync, statSync, lstatSync } from 'fs';
|
|
37
|
+
import { join, relative, extname, basename } from 'path';
|
|
38
|
+
import { isScanIgnored } from './hypo-ignore.mjs';
|
|
39
|
+
|
|
40
|
+
// ── markdown page walker (shared core) ─────────────────────────────────────────
|
|
41
|
+
// `policy` keys (all optional):
|
|
42
|
+
// lstat — stat with lstatSync and skip symlinks (rename's security
|
|
43
|
+
// boundary). Default: statSync, symlinks followed.
|
|
44
|
+
// skipDotEntry — skip ANY entry (dir or file) whose name starts with `.`
|
|
45
|
+
// BEFORE the ignore check (crystallize).
|
|
46
|
+
// skipUnderscoreDir— skip directories whose name starts with `_` (lint).
|
|
47
|
+
// skipDotFile — among `.md` files, skip names starting with `.`.
|
|
48
|
+
// shape(full) — build the per-page record pushed onto the accumulator.
|
|
49
|
+
function walkMarkdown(dir, root, ignorePatterns, policy, acc) {
|
|
50
|
+
if (!existsSync(dir)) return acc;
|
|
51
|
+
for (const entry of readdirSync(dir)) {
|
|
52
|
+
if (policy.skipDotEntry && entry.startsWith('.')) continue;
|
|
53
|
+
const full = join(dir, entry);
|
|
54
|
+
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
55
|
+
const st = policy.lstat ? lstatSync(full) : statSync(full);
|
|
56
|
+
if (policy.lstat && st.isSymbolicLink()) continue;
|
|
57
|
+
if (st.isDirectory()) {
|
|
58
|
+
if (policy.skipUnderscoreDir && entry.startsWith('_')) continue;
|
|
59
|
+
walkMarkdown(full, root, ignorePatterns, policy, acc);
|
|
60
|
+
} else if (extname(entry) === '.md' && !(policy.skipDotFile && entry.startsWith('.'))) {
|
|
61
|
+
acc.push(policy.shape(full));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return acc;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const posixSlug = (full, root) => relative(root, full).replace(/\.md$/, '').replace(/\\/g, '/');
|
|
68
|
+
|
|
69
|
+
// lint: `_`-dir skip + `_`-file kept; OS-native `rel` (buildSlugMap normalizes).
|
|
70
|
+
export function collectPagesLint(dir, root, ignorePatterns = []) {
|
|
71
|
+
return walkMarkdown(
|
|
72
|
+
dir,
|
|
73
|
+
root,
|
|
74
|
+
ignorePatterns,
|
|
75
|
+
{
|
|
76
|
+
skipDotFile: true,
|
|
77
|
+
skipUnderscoreDir: true,
|
|
78
|
+
shape: (full) => ({ path: full, rel: relative(root, full) }),
|
|
79
|
+
},
|
|
80
|
+
[],
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// graph: POSIX slug + bare, no `rel`.
|
|
85
|
+
export function collectPagesGraph(dir, root, ignorePatterns = []) {
|
|
86
|
+
return walkMarkdown(
|
|
87
|
+
dir,
|
|
88
|
+
root,
|
|
89
|
+
ignorePatterns,
|
|
90
|
+
{
|
|
91
|
+
skipDotFile: true,
|
|
92
|
+
shape: (full) => ({ path: full, slug: posixSlug(full, root), bare: basename(full, '.md') }),
|
|
93
|
+
},
|
|
94
|
+
[],
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// rename: lstat + symlink skip (security); POSIX rel/slug/bare.
|
|
99
|
+
export function collectPagesRename(dir, root, ignorePatterns = []) {
|
|
100
|
+
return walkMarkdown(
|
|
101
|
+
dir,
|
|
102
|
+
root,
|
|
103
|
+
ignorePatterns,
|
|
104
|
+
{
|
|
105
|
+
lstat: true,
|
|
106
|
+
skipDotFile: true,
|
|
107
|
+
shape: (full) => {
|
|
108
|
+
const rel = relative(root, full).replace(/\\/g, '/');
|
|
109
|
+
return { path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') };
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
[],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// crystallize: skip ANY `.`-prefixed entry; OS-native `rel`.
|
|
117
|
+
export function collectPagesCrystallize(dir, root, ignorePatterns = []) {
|
|
118
|
+
return walkMarkdown(
|
|
119
|
+
dir,
|
|
120
|
+
root,
|
|
121
|
+
ignorePatterns,
|
|
122
|
+
{ skipDotEntry: true, shape: (full) => ({ path: full, rel: relative(root, full) }) },
|
|
123
|
+
[],
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── slug-form generation ───────────────────────────────────────────────────────
|
|
128
|
+
// From a POSIX no-extension slug (e.g. `pages/learnings/foo`), derive the three
|
|
129
|
+
// link forms a [[wikilink]] may use:
|
|
130
|
+
// full — the whole slug [[pages/learnings/foo]]
|
|
131
|
+
// bare — the basename [[foo]]
|
|
132
|
+
// dirRel — slug minus the leading scan-dir seg [[learnings/foo]] (null if the
|
|
133
|
+
// slug has no `/`, i.e. nothing to drop)
|
|
134
|
+
// Callers pick which forms to register and own their own collision policy. Do NOT
|
|
135
|
+
// apply this to link-target-only slugs (lint's root-md/sources extraTargets,
|
|
136
|
+
// rename's sources/*) — those resolve VERBATIM, with no derived alias, so a bare
|
|
137
|
+
// form can't mask an unrelated broken link or block a safe rewrite.
|
|
138
|
+
export function slugForms(slug) {
|
|
139
|
+
const slash = slug.indexOf('/');
|
|
140
|
+
return { full: slug, bare: basename(slug), dirRel: slash === -1 ? null : slug.slice(slash + 1) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── bare wikilink extraction (graph/crystallize variant) ───────────────────────
|
|
144
|
+
// Raw extraction: matches [[target]] / [[target|alias]] / [[target#anchor]]
|
|
145
|
+
// anywhere, INCLUDING inside code fences (graph counts those as edges, and
|
|
146
|
+
// crystallize counts them for unlinked-page detection — preserving that is
|
|
147
|
+
// behavior-stable). lint uses a DIFFERENT extractor (strips code regions and
|
|
148
|
+
// handles table-escaped `\|`); rename uses length-preserving masking. Neither
|
|
149
|
+
// shares this one.
|
|
150
|
+
export function extractWikilinks(content) {
|
|
151
|
+
const links = [];
|
|
152
|
+
for (const m of content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)) {
|
|
153
|
+
links.push(m[1].trim());
|
|
154
|
+
}
|
|
155
|
+
return links;
|
|
156
|
+
}
|
package/scripts/lint.mjs
CHANGED
|
@@ -17,13 +17,21 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
20
|
-
import { join,
|
|
20
|
+
import { join, extname, basename } from 'path';
|
|
21
21
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
22
22
|
import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
|
|
23
23
|
import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
parseSchemaVocab,
|
|
26
|
+
checkForbidden,
|
|
27
|
+
parseSchemaPageDirs,
|
|
28
|
+
parseSchemaTypes,
|
|
29
|
+
} from './lib/schema-vocab.mjs';
|
|
25
30
|
import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
|
|
26
31
|
import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
|
|
32
|
+
import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
|
|
33
|
+
import { collectPagesLint, slugForms } from './lib/wikilink.mjs';
|
|
34
|
+
import { parseFrontmatter, SEQUENCE_ENTRY_RE } from './lib/frontmatter.mjs';
|
|
27
35
|
|
|
28
36
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
29
37
|
|
|
@@ -41,19 +49,45 @@ function parseArgs(argv) {
|
|
|
41
49
|
|
|
42
50
|
// ── frontmatter parser ────────────────────────────────────────────────────────
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
// ── W9: narrow invalid-YAML detector ───────────────────────────────────────────
|
|
53
|
+
// NOT a full YAML parser (zero-dep policy). Catches only specific classes the
|
|
54
|
+
// lenient line-scanner (parseFrontmatter, imported from lib) silently passes but
|
|
55
|
+
// a real YAML parser (js-yaml, what Obsidian uses) rejects. Each class is
|
|
56
|
+
// conservative — it may miss, but must NOT false-positive on valid YAML, so it
|
|
57
|
+
// inspects only top-level (unindented) lines: indented lines may be block-scalar
|
|
58
|
+
// content where ": "/tabs are legal, and reliably telling content from structure
|
|
59
|
+
// needs a real parser. (A leading-tab class was considered and dropped for this
|
|
60
|
+
// reason — it cannot distinguish `relations:\n\t- x` from a tabbed block-scalar
|
|
61
|
+
// body without tracking block context.) Returns an array of reasons.
|
|
62
|
+
function checkYamlInvalid(block) {
|
|
63
|
+
const reasons = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const raw of block.split('\n')) {
|
|
66
|
+
const line = raw.replace(/\r$/, '');
|
|
67
|
+
if (!line.trim()) continue;
|
|
68
|
+
if (/^\s/.test(line) || SEQUENCE_ENTRY_RE.test(line)) continue; // top-level keys only
|
|
49
69
|
const idx = line.indexOf(':');
|
|
50
70
|
if (idx < 0) continue;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
const key = line.slice(0, idx).trim();
|
|
72
|
+
if (!key) continue;
|
|
73
|
+
// duplicate top-level key (YAML rejects; line-scanner silently keeps one)
|
|
74
|
+
if (seen.has(key)) reasons.push(`duplicate key: "${key}"`);
|
|
75
|
+
else seen.add(key);
|
|
76
|
+
// colon-space inside an unquoted, non-flow plain scalar. A plain scalar may
|
|
77
|
+
// not contain ": " — js-yaml hard-errors or silently truncates. Skip quoted
|
|
78
|
+
// ("/') and flow ([/{) values where ": " is legal, and strip an inline
|
|
79
|
+
// " #" comment before scanning.
|
|
80
|
+
let val = line.slice(idx + 1).trim();
|
|
81
|
+
const c = val[0];
|
|
82
|
+
if (val && c !== '"' && c !== "'" && c !== '[' && c !== '{') {
|
|
83
|
+
const hash = val.indexOf(' #');
|
|
84
|
+
if (hash >= 0) val = val.slice(0, hash);
|
|
85
|
+
if (/:\s/.test(val)) {
|
|
86
|
+
reasons.push(`unquoted "${key}" value contains ": " (quote it): "${val.slice(0, 40)}"`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
55
89
|
}
|
|
56
|
-
return
|
|
90
|
+
return reasons;
|
|
57
91
|
}
|
|
58
92
|
|
|
59
93
|
function parseTagsField(rawValue) {
|
|
@@ -108,31 +142,14 @@ const TYPE_ENUM_FIELDS = {
|
|
|
108
142
|
status: ['active', 'superseded', 'archived'],
|
|
109
143
|
tier: ['L1', 'L2'],
|
|
110
144
|
sensitivity: ['public', 'sanitized'],
|
|
145
|
+
// FEAT-1: optional failure taxonomy. The enum loop guards on `if (fm[field])`,
|
|
146
|
+
// so an omitted failure_type is never validated (optional, migration-safe).
|
|
147
|
+
failure_type: FAILURE_TYPE_ENUM,
|
|
111
148
|
},
|
|
112
149
|
};
|
|
113
150
|
|
|
114
151
|
// ── page collector ────────────────────────────────────────────────────────────
|
|
115
152
|
|
|
116
|
-
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
117
|
-
if (!existsSync(dir)) return pages;
|
|
118
|
-
for (const entry of readdirSync(dir)) {
|
|
119
|
-
const full = join(dir, entry);
|
|
120
|
-
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
121
|
-
const st = statSync(full);
|
|
122
|
-
if (st.isDirectory()) {
|
|
123
|
-
// `_`-prefixed dirs (e.g. pages/feedback/_drafts) hold scaffolds / scratch
|
|
124
|
-
// not yet promoted to content — skip so incomplete frontmatter never errors
|
|
125
|
-
// (mirrors loadFeedbackPages in feedback-sync.mjs). `_`-prefixed *files*
|
|
126
|
-
// (e.g. _index.md) are still linted.
|
|
127
|
-
if (entry.startsWith('_')) continue;
|
|
128
|
-
collectPages(full, root, pages, ignorePatterns);
|
|
129
|
-
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
130
|
-
pages.push({ path: full, rel: relative(root, full) });
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return pages;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
153
|
// ── slug map ─────────────────────────────────────────────────────────────────
|
|
137
154
|
|
|
138
155
|
// `extraTargets` are link-target-only slugs (root *.md, sources/*) that resolve
|
|
@@ -142,13 +159,14 @@ function buildSlugMap(pages, extraTargets = []) {
|
|
|
142
159
|
const map = new Set();
|
|
143
160
|
for (const { rel } of pages) {
|
|
144
161
|
const noExt = rel.replace(/\.md$/, '').replace(/\\/g, '/');
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
// full slug + bare basename + dir-relative alias (drop the leading scan-dir
|
|
163
|
+
// segment so the convention link [[learnings/foo]] resolves to
|
|
164
|
+
// pages/learnings/foo.md). slugForms returns dirRel=null when the slug has no
|
|
165
|
+
// `/` (a page directly under a scan dir has no extra segment to drop).
|
|
166
|
+
const { full, bare, dirRel } = slugForms(noExt);
|
|
167
|
+
map.add(full);
|
|
168
|
+
map.add(bare);
|
|
169
|
+
if (dirRel) map.add(dirRel);
|
|
152
170
|
}
|
|
153
171
|
for (const t of extraTargets) map.add(t);
|
|
154
172
|
return map;
|
|
@@ -165,7 +183,7 @@ function collectLinkTargets(hypoDir, ignorePatterns = []) {
|
|
|
165
183
|
for (const entry of readdirSync(hypoDir)) {
|
|
166
184
|
const full = join(hypoDir, entry);
|
|
167
185
|
// root-level *.md FILES only (no recursion), honoring .hypoignore plus the
|
|
168
|
-
// scan-only generated-artifact exclusions (isScanIgnored) like
|
|
186
|
+
// scan-only generated-artifact exclusions (isScanIgnored) like collectPagesLint
|
|
169
187
|
// — otherwise an ignored root file (e.g. a secret) or a regenerable report
|
|
170
188
|
// would resolve [[its-slug]] as valid, a false negative.
|
|
171
189
|
if (
|
|
@@ -180,7 +198,7 @@ function collectLinkTargets(hypoDir, ignorePatterns = []) {
|
|
|
180
198
|
}
|
|
181
199
|
// sources/*: linkable as the full 'sources/<name>' slug only — deliberately no
|
|
182
200
|
// bare basename, so a stale [[name]] can't silently resolve to a source file.
|
|
183
|
-
for (const { rel } of
|
|
201
|
+
for (const { rel } of collectPagesLint(join(hypoDir, 'sources'), hypoDir, ignorePatterns)) {
|
|
184
202
|
targets.push(rel.replace(/\.md$/, '').replace(/\\/g, '/'));
|
|
185
203
|
}
|
|
186
204
|
return targets;
|
|
@@ -259,10 +277,15 @@ const issues = [];
|
|
|
259
277
|
// STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
|
|
260
278
|
// defects only.
|
|
261
279
|
// W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
|
|
280
|
+
// W9 invalid-YAML → promote (frontmatter a real YAML parser rejects).
|
|
262
281
|
// W3 missing-updated → excluded (auto-repaired by --fix).
|
|
263
282
|
// W8 design-history-stale → excluded (hypo-personal-check handles it; would
|
|
264
283
|
// double-gate).
|
|
265
|
-
|
|
284
|
+
// NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
|
|
285
|
+
// crystallize / the close-gate all run plain lint), so promotion is
|
|
286
|
+
// forward-looking — these surface as warnings today. IMPR-3's deliverable is
|
|
287
|
+
// "stop silently green-passing invalid YAML"; the W9 warning satisfies that.
|
|
288
|
+
const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4', 'W9']);
|
|
266
289
|
|
|
267
290
|
function issue(severity, rel, msg, fullPath = null, id = null) {
|
|
268
291
|
issues.push({ severity, file: rel, message: msg, path: fullPath, id });
|
|
@@ -285,7 +308,7 @@ function lintSessionStateHeadings(content, rel) {
|
|
|
285
308
|
}
|
|
286
309
|
}
|
|
287
310
|
|
|
288
|
-
function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
311
|
+
function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
289
312
|
// Directory whitelist: a content page under pages/<subdir>/ must live in a
|
|
290
313
|
// SCHEMA-defined directory. Catches typo'd dirs (e.g. pages/learning/ vs the
|
|
291
314
|
// canonical pages/learnings/) regardless of frontmatter, since a directory
|
|
@@ -319,6 +342,16 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
|
319
342
|
return;
|
|
320
343
|
}
|
|
321
344
|
|
|
345
|
+
// W9: invalid-YAML frontmatter the lenient line-scanner would otherwise pass
|
|
346
|
+
// green. Runs on the raw `---` block (the `content.match` above guarantees an
|
|
347
|
+
// opening fence; an unclosed fence falls through to the W3/parse path below).
|
|
348
|
+
const fmBlock = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
349
|
+
if (fmBlock) {
|
|
350
|
+
for (const reason of checkYamlInvalid(fmBlock[1])) {
|
|
351
|
+
issue('warn', rel, `Invalid YAML frontmatter: ${reason}`, null, 'W9');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
322
355
|
const fm = parseFrontmatter(content);
|
|
323
356
|
if (!fm) {
|
|
324
357
|
issue('error', rel, 'Malformed frontmatter (unclosed ---)');
|
|
@@ -329,7 +362,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
|
|
|
329
362
|
if (!fm[field]) issue('error', rel, `Missing required frontmatter field: ${field}`);
|
|
330
363
|
}
|
|
331
364
|
|
|
332
|
-
if (fm.type && !
|
|
365
|
+
if (fm.type && !validTypes.has(fm.type)) {
|
|
333
366
|
issue('warn', rel, `Unknown type: "${fm.type}"`, null, 'W2');
|
|
334
367
|
}
|
|
335
368
|
|
|
@@ -413,13 +446,19 @@ const args = parseArgs(process.argv);
|
|
|
413
446
|
|
|
414
447
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
415
448
|
const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
|
|
416
|
-
const pages = scanDirs.flatMap((d) =>
|
|
449
|
+
const pages = scanDirs.flatMap((d) => collectPagesLint(d, args.hypoDir, ignorePatterns));
|
|
417
450
|
const linkTargets = collectLinkTargets(args.hypoDir, ignorePatterns);
|
|
418
451
|
const slugMap = buildSlugMap(pages, linkTargets);
|
|
419
452
|
const tagVocab = parseSchemaVocab(args.hypoDir);
|
|
420
453
|
const pageDirs = parseSchemaPageDirs(args.hypoDir);
|
|
421
|
-
|
|
422
|
-
|
|
454
|
+
// Accepted page types = hardcoded core ∪ the vault SCHEMA's taxonomy. Union (not
|
|
455
|
+
// replace) so core types lint specially handles (session-state, source, …) are
|
|
456
|
+
// never lost when a vault's SCHEMA omits or predates them, while a vault-local
|
|
457
|
+
// extension (working-doc / draft / qa-run) is honored without editing lint —
|
|
458
|
+
// mirroring how tags and page dirs are SCHEMA-derived.
|
|
459
|
+
const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
|
|
460
|
+
|
|
461
|
+
for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
|
|
423
462
|
|
|
424
463
|
// W8: design-history.md stale relative to session-log.md. Emitted once per
|
|
425
464
|
// project (not per page) — runs outside the page loop. POSIX-separated path
|
package/scripts/rename.mjs
CHANGED
|
@@ -47,9 +47,10 @@ import {
|
|
|
47
47
|
lstatSync,
|
|
48
48
|
realpathSync,
|
|
49
49
|
} from 'fs';
|
|
50
|
-
import { join,
|
|
50
|
+
import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
|
|
51
51
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
52
|
-
import { loadHypoIgnore
|
|
52
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
53
|
+
import { collectPagesRename, slugForms } from './lib/wikilink.mjs';
|
|
53
54
|
|
|
54
55
|
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
55
56
|
|
|
@@ -66,28 +67,6 @@ function parseArgs(argv) {
|
|
|
66
67
|
return args;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
// ── page collection (mirrors graph.mjs / crystallize.mjs collectPages) ──────────
|
|
70
|
-
|
|
71
|
-
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
72
|
-
if (!existsSync(dir)) return pages;
|
|
73
|
-
for (const entry of readdirSync(dir)) {
|
|
74
|
-
const full = join(dir, entry);
|
|
75
|
-
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
76
|
-
// lstat (never follow): a symlinked dir or file is skipped entirely so the
|
|
77
|
-
// whole-vault scan can never traverse out of the vault via a symlink.
|
|
78
|
-
const st = lstatSync(full);
|
|
79
|
-
if (st.isSymbolicLink()) {
|
|
80
|
-
continue;
|
|
81
|
-
} else if (st.isDirectory()) {
|
|
82
|
-
collectPages(full, root, pages, ignorePatterns);
|
|
83
|
-
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
84
|
-
const rel = relative(root, full).replace(/\\/g, '/');
|
|
85
|
-
pages.push({ path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return pages;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
70
|
// ── preservation class of a link-source path ───────────────────────────────────
|
|
92
71
|
// Two distinct reasons a file is normally skipped as a link SOURCE, kept separate
|
|
93
72
|
// because directory mode treats them differently (codex design BLOCKER):
|
|
@@ -123,10 +102,7 @@ function isPreservedSource(rel) {
|
|
|
123
102
|
// needs to KNOW when a form is shared, so it maps each form → the set of page
|
|
124
103
|
// rels that expose it. precedence forms per page: full noExt slug, bare
|
|
125
104
|
// basename, dir-relative (drop the leading scan-dir segment).
|
|
126
|
-
|
|
127
|
-
const slash = slug.indexOf('/');
|
|
128
|
-
return slash === -1 ? null : slug.slice(slash + 1);
|
|
129
|
-
}
|
|
105
|
+
const dirRelForm = (slug) => slugForms(slug).dirRel;
|
|
130
106
|
|
|
131
107
|
function buildFormIndex(pages) {
|
|
132
108
|
const index = new Map(); // form → Set<rel>
|
|
@@ -473,7 +449,7 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
|
|
|
473
449
|
fail(args, `--from subtree contains a symlink — refusing (move could escape the vault)`);
|
|
474
450
|
}
|
|
475
451
|
|
|
476
|
-
const pages =
|
|
452
|
+
const pages = collectPagesRename(args.hypoDir, args.hypoDir, ignorePatterns);
|
|
477
453
|
const formIndex = buildFormIndex(pages);
|
|
478
454
|
|
|
479
455
|
// The moving pages: every collected page whose rel is under the from-directory.
|
|
@@ -676,7 +652,7 @@ function run(args) {
|
|
|
676
652
|
return runDirectory(args, fromNorm, ignorePatterns);
|
|
677
653
|
}
|
|
678
654
|
|
|
679
|
-
const pages =
|
|
655
|
+
const pages = collectPagesRename(args.hypoDir, args.hypoDir, ignorePatterns);
|
|
680
656
|
const formIndex = buildFormIndex(pages);
|
|
681
657
|
|
|
682
658
|
const fromPage = resolveArgToPage(args.from, pages, formIndex);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// smoke-plugin.mjs — a cheap, LOCAL structural check that the Claude Code plugin
|
|
3
|
+
// would load, with no network and no Claude CLI dependency (so it runs in CI). It
|
|
4
|
+
// validates the three surfaces Claude Code actually reads:
|
|
5
|
+
// 1. .claude-plugin/plugin.json — manifest (name, version, commands/skills paths)
|
|
6
|
+
// 2. hooks/hooks.json — every `${CLAUDE_PLUGIN_ROOT}/...` target must exist
|
|
7
|
+
// 3. component files — commands/*.md and skills/*/SKILL.md must be REAL
|
|
8
|
+
// (a .gitkeep alone is not a surface — a plugin with zero commands/skills must fail)
|
|
9
|
+
// plus marketplace↔plugin name parity and that the marketplace `source` resolves to a
|
|
10
|
+
// dir holding .claude-plugin/plugin.json.
|
|
11
|
+
//
|
|
12
|
+
// For a deep, real load check on a dev machine: `claude --plugin-dir . plugin list`.
|
|
13
|
+
// That needs Claude Code installed, so it is NOT used here; this is the CI floor.
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// node scripts/smoke-plugin.mjs # smoke the repo's plugin
|
|
17
|
+
// node scripts/smoke-plugin.mjs --root <dir> # point at a fixture (tests)
|
|
18
|
+
//
|
|
19
|
+
// Exit 0 = plugin surfaces are structurally valid. Exit 1 = a load-blocking problem.
|
|
20
|
+
|
|
21
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
|
|
22
|
+
import { join, dirname } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv) {
|
|
26
|
+
const args = { root: null };
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a.startsWith('--root=')) args.root = a.slice(7);
|
|
30
|
+
else if (a === '--root') args.root = argv[++i];
|
|
31
|
+
}
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
36
|
+
|
|
37
|
+
function isDir(p) {
|
|
38
|
+
try {
|
|
39
|
+
return statSync(p).isDirectory();
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isFile(p) {
|
|
46
|
+
try {
|
|
47
|
+
return statSync(p).isFile();
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function smoke(root) {
|
|
54
|
+
const errors = [];
|
|
55
|
+
const notes = [];
|
|
56
|
+
const fail = (msg) => errors.push(msg);
|
|
57
|
+
|
|
58
|
+
// 1. plugin.json manifest.
|
|
59
|
+
let plugin = null;
|
|
60
|
+
const pluginPath = join(root, '.claude-plugin', 'plugin.json');
|
|
61
|
+
try {
|
|
62
|
+
plugin = JSON.parse(readFileSync(pluginPath, 'utf-8'));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
fail(`.claude-plugin/plugin.json: ${err?.message ?? err}`);
|
|
65
|
+
}
|
|
66
|
+
if (plugin) {
|
|
67
|
+
if (typeof plugin.name !== 'string' || !plugin.name) fail('plugin.json: missing "name"');
|
|
68
|
+
if (typeof plugin.version !== 'string' || !plugin.version)
|
|
69
|
+
fail('plugin.json: missing "version"');
|
|
70
|
+
// commands/skills are declared as relative dir paths; if declared they must resolve.
|
|
71
|
+
for (const key of ['commands', 'skills']) {
|
|
72
|
+
if (plugin[key] != null) {
|
|
73
|
+
const rel = String(plugin[key]).replace(/^\.\//, '');
|
|
74
|
+
if (!isDir(join(root, rel)))
|
|
75
|
+
fail(`plugin.json: "${key}" path "${plugin[key]}" is not a directory`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Component files must be REAL regular files, not just a .gitkeep placeholder
|
|
81
|
+
// (or a directory that happens to be named like a component — isFile, not exists).
|
|
82
|
+
const commandsMd = isDir(join(root, 'commands'))
|
|
83
|
+
? readdirSync(join(root, 'commands')).filter(
|
|
84
|
+
(f) => f.endsWith('.md') && isFile(join(root, 'commands', f)),
|
|
85
|
+
)
|
|
86
|
+
: [];
|
|
87
|
+
if (commandsMd.length === 0) fail('commands/: no *.md command files (only a placeholder?)');
|
|
88
|
+
else notes.push(`commands: ${commandsMd.length}`);
|
|
89
|
+
|
|
90
|
+
let skillCount = 0;
|
|
91
|
+
if (isDir(join(root, 'skills'))) {
|
|
92
|
+
for (const entry of readdirSync(join(root, 'skills'))) {
|
|
93
|
+
if (isFile(join(root, 'skills', entry, 'SKILL.md'))) skillCount++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (skillCount === 0) fail('skills/: no */SKILL.md skills (only a placeholder?)');
|
|
97
|
+
else notes.push(`skills: ${skillCount}`);
|
|
98
|
+
|
|
99
|
+
// 3. hooks/hooks.json — every command target AND every `shared` file must be a
|
|
100
|
+
// real regular file on disk. hooks.json is the hook source of truth, so a missing
|
|
101
|
+
// hypo-shared.mjs / version-check.mjs would pass a manifest-only check but break
|
|
102
|
+
// hook imports at runtime.
|
|
103
|
+
const hooksPath = join(root, 'hooks', 'hooks.json');
|
|
104
|
+
if (!existsSync(hooksPath)) {
|
|
105
|
+
fail('hooks/hooks.json: missing');
|
|
106
|
+
} else {
|
|
107
|
+
let hooksJson = null;
|
|
108
|
+
try {
|
|
109
|
+
hooksJson = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
fail(`hooks/hooks.json: ${err?.message ?? err}`);
|
|
112
|
+
}
|
|
113
|
+
if (hooksJson) {
|
|
114
|
+
if (!hooksJson.hooks || typeof hooksJson.hooks !== 'object') {
|
|
115
|
+
fail('hooks/hooks.json: missing top-level "hooks" object');
|
|
116
|
+
} else {
|
|
117
|
+
const targets = new Set();
|
|
118
|
+
for (const groups of Object.values(hooksJson.hooks)) {
|
|
119
|
+
for (const group of groups || []) {
|
|
120
|
+
for (const hk of group?.hooks || []) {
|
|
121
|
+
if (hk?.command) {
|
|
122
|
+
// command looks like `node ${CLAUDE_PLUGIN_ROOT}/hooks/foo.mjs [args]`
|
|
123
|
+
const m = String(hk.command).match(/\$\{CLAUDE_PLUGIN_ROOT\}\/(\S+)/);
|
|
124
|
+
if (m) targets.add(m[1]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (targets.size === 0)
|
|
130
|
+
fail('hooks/hooks.json: no ${CLAUDE_PLUGIN_ROOT} command targets found');
|
|
131
|
+
for (const rel of targets) {
|
|
132
|
+
if (!isFile(join(root, rel))) fail(`hooks/hooks.json: target "${rel}" is not a file`);
|
|
133
|
+
}
|
|
134
|
+
notes.push(`hook targets: ${targets.size}`);
|
|
135
|
+
}
|
|
136
|
+
// `shared` lists hook-relative support files that the targets import.
|
|
137
|
+
if (Array.isArray(hooksJson.shared)) {
|
|
138
|
+
for (const shared of hooksJson.shared) {
|
|
139
|
+
if (!isFile(join(root, 'hooks', shared)))
|
|
140
|
+
fail(`hooks/hooks.json: shared file "hooks/${shared}" is not a file`);
|
|
141
|
+
}
|
|
142
|
+
notes.push(`shared: ${hooksJson.shared.length}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 4. marketplace.json — name parity + source resolves.
|
|
148
|
+
const mpPath = join(root, '.claude-plugin', 'marketplace.json');
|
|
149
|
+
let mp = null;
|
|
150
|
+
try {
|
|
151
|
+
mp = JSON.parse(readFileSync(mpPath, 'utf-8'));
|
|
152
|
+
} catch (err) {
|
|
153
|
+
fail(`.claude-plugin/marketplace.json: ${err?.message ?? err}`);
|
|
154
|
+
}
|
|
155
|
+
if (mp) {
|
|
156
|
+
const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
|
|
157
|
+
if (plugins.length === 0) fail('marketplace.json: "plugins" is empty');
|
|
158
|
+
const name = plugin?.name;
|
|
159
|
+
if (name) {
|
|
160
|
+
const matches = plugins.filter((p) => p && p.name === name);
|
|
161
|
+
if (matches.length !== 1) {
|
|
162
|
+
fail(
|
|
163
|
+
`marketplace.json: expected exactly one entry named "${name}", found ${matches.length}`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
const src = matches[0].source ?? './';
|
|
167
|
+
const srcDir = join(root, String(src).replace(/^\.\//, ''));
|
|
168
|
+
if (!existsSync(join(srcDir, '.claude-plugin', 'plugin.json'))) {
|
|
169
|
+
fail(
|
|
170
|
+
`marketplace.json: source "${src}" does not resolve to a dir containing .claude-plugin/plugin.json`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { errors, notes };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function main() {
|
|
181
|
+
const args = parseArgs(process.argv.slice(2));
|
|
182
|
+
const root = args.root || REPO_ROOT;
|
|
183
|
+
const { errors, notes } = smoke(root);
|
|
184
|
+
|
|
185
|
+
if (errors.length) {
|
|
186
|
+
for (const e of errors) console.error(` ✗ ${e}`);
|
|
187
|
+
console.error(`\n✗ plugin smoke failed (${errors.length} problem(s)).`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
console.log(`✓ plugin surfaces valid (${notes.join(', ')}).`);
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main();
|