hypomnema 1.3.3 → 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.
@@ -23,6 +23,30 @@ function globToRegex(glob) {
23
23
  );
24
24
  }
25
25
 
26
+ // Generated, regenerable tool artifacts that live at the WIKI ROOT and must not
27
+ // pollute the knowledge catalog. These are intentionally NOT added to
28
+ // .hypoignore: that list also drives the pre-commit secret gate
29
+ // (hooks/hypo-pre-commit.mjs), so listing them there would block the report's
30
+ // commit and freeze every auto-commit while it sits at root. Instead they are
31
+ // excluded from catalog/scan only, via isScanIgnored(). The patterns are
32
+ // anchored to the root-relative path (no '/'), so an equivalent name nested
33
+ // under pages/, projects/, or sources/ is left untouched.
34
+ const GENERATED_ARTIFACT_RES = [/^MIGRATION-v[^/]*\.md$/, /^GRAPH_REPORT\.md$/];
35
+
36
+ export function isGeneratedArtifact(filePath, hypoDir) {
37
+ const rel = relative(hypoDir, filePath).replace(/\\/g, '/');
38
+ return GENERATED_ARTIFACT_RES.some((re) => re.test(rel));
39
+ }
40
+
41
+ // Catalog/scan exclusion = privacy/.hypoignore patterns PLUS generated root
42
+ // artifacts. Use this in tools that build the knowledge catalog (lint, graph,
43
+ // stats, query, verify, crystallize, rename, doctor broken-links). Do NOT use it
44
+ // in the pre-commit gate or ingest --check, which are privacy boundaries that
45
+ // must stay on isIgnored()/.hypoignore alone.
46
+ export function isScanIgnored(filePath, hypoDir, patterns) {
47
+ return isIgnored(filePath, hypoDir, patterns) || isGeneratedArtifact(filePath, hypoDir);
48
+ }
49
+
26
50
  export function isIgnored(filePath, hypoDir, patterns) {
27
51
  const rel = relative(hypoDir, filePath).replace(/\\/g, '/');
28
52
  const base = basename(filePath);
@@ -94,3 +94,38 @@ export function parseSchemaPageDirs(hypoDir) {
94
94
  }
95
95
  return dirs;
96
96
  }
97
+
98
+ // Type-token shape: lowercase identifier (a-z, digits, hyphen). Excludes
99
+ // memory-layer rows whose first cell is a filename (`hot.md`, `log.md`) — those
100
+ // carry a `.` and never match — so only real type names survive.
101
+ const TYPE_TOKEN_RE = /^[a-z][a-z0-9-]+$/;
102
+
103
+ // Derive the set of valid page `type` values from the SCHEMA "Page Type
104
+ // Taxonomy" table — the FIRST backticked cell of each table row (the type
105
+ // column). Single source of truth, mirroring parseSchemaPageDirs: adding a type
106
+ // row to a vault's SCHEMA automatically widens the accepted types, so a
107
+ // vault-local extension (e.g. working-doc / draft / qa-run) stops tripping the
108
+ // W2 unknown-type warning without editing lint. Returns an empty Set when
109
+ // SCHEMA.md or the table is absent — callers union this with a hardcoded core so
110
+ // the core types never depend on a present/complete SCHEMA.
111
+ export function parseSchemaTypes(hypoDir) {
112
+ const path = join(hypoDir, 'SCHEMA.md');
113
+ if (!existsSync(path)) return new Set();
114
+ const content = readFileSync(path, 'utf-8');
115
+
116
+ const headerMatch = TYPE_TAXONOMY_HEADER_RE.exec(content);
117
+ if (!headerMatch) return new Set();
118
+
119
+ const sectionStart = headerMatch.index + headerMatch[0].length;
120
+ const rest = content.slice(sectionStart);
121
+ const nextH2 = NEXT_H2_RE.exec(rest);
122
+ const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
123
+
124
+ const types = new Set();
125
+ for (const rawLine of section.split('\n')) {
126
+ if (!rawLine.trimStart().startsWith('|')) continue;
127
+ const m = rawLine.match(/`([^`]+)`/); // first backtick token = type column
128
+ if (m && TYPE_TOKEN_RE.test(m[1].trim())) types.add(m[1].trim());
129
+ }
130
+ return types;
131
+ }
@@ -0,0 +1,21 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { parseFrontmatter } from './frontmatter.mjs';
4
+
5
+ // The SCHEMA version this package ships, read from templates/SCHEMA.md
6
+ // frontmatter. init.mjs / upgrade.mjs stamp it into hypo-pkg.json metadata;
7
+ // deriving it here (rather than hardcoding a literal at each write site) keeps
8
+ // the stamped value from going stale on a schema bump — the failure mode that
9
+ // FEAT-1's 2.0 → 2.1 bump would otherwise have introduced. Returns null only if
10
+ // the template is missing/unreadable (a broken package), in which case callers
11
+ // keep their prior literal default.
12
+ export function templateSchemaVersion(pkgRoot) {
13
+ const p = join(pkgRoot, 'templates', 'SCHEMA.md');
14
+ if (!existsSync(p)) return null;
15
+ try {
16
+ const v = (parseFrontmatter(readFileSync(p, 'utf-8')) || {}).version;
17
+ return v ? String(v) : null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
@@ -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, relative, extname, basename } from 'path';
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
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
24
- import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
23
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
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
- function parseFrontmatter(content) {
45
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
46
- if (!m) return null;
47
- const fm = {};
48
- for (const line of m[1].split('\n')) {
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
- fm[line.slice(0, idx).trim()] = line
52
- .slice(idx + 1)
53
- .trim()
54
- .replace(/^["']|["']$/g, '');
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 fm;
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 (isIgnored(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
- map.add(noExt);
146
- map.add(basename(rel, '.md'));
147
- // Dir-relative alias: drop the leading scan-dir segment so the vault's
148
- // convention link [[learnings/foo]] resolves to pages/learnings/foo.md.
149
- // (A page directly under a scan dir has no extra segment to drop.)
150
- const slash = noExt.indexOf('/');
151
- if (slash !== -1) map.add(noExt.slice(slash + 1));
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;
@@ -164,13 +182,14 @@ function collectLinkTargets(hypoDir, ignorePatterns = []) {
164
182
  if (existsSync(hypoDir)) {
165
183
  for (const entry of readdirSync(hypoDir)) {
166
184
  const full = join(hypoDir, entry);
167
- // root-level *.md FILES only (no recursion), honoring .hypoignore exactly
168
- // like collectPages otherwise an ignored root file (e.g. a secret) would
169
- // resolve [[its-slug]] as valid, a false negative.
185
+ // root-level *.md FILES only (no recursion), honoring .hypoignore plus the
186
+ // scan-only generated-artifact exclusions (isScanIgnored) like collectPagesLint
187
+ // otherwise an ignored root file (e.g. a secret) or a regenerable report
188
+ // would resolve [[its-slug]] as valid, a false negative.
170
189
  if (
171
190
  extname(entry) === '.md' &&
172
191
  !entry.startsWith('.') &&
173
- !isIgnored(full, hypoDir, ignorePatterns) &&
192
+ !isScanIgnored(full, hypoDir, ignorePatterns) &&
174
193
  statSync(full).isFile()
175
194
  ) {
176
195
  targets.push(entry.replace(/\.md$/, ''));
@@ -179,7 +198,7 @@ function collectLinkTargets(hypoDir, ignorePatterns = []) {
179
198
  }
180
199
  // sources/*: linkable as the full 'sources/<name>' slug only — deliberately no
181
200
  // bare basename, so a stale [[name]] can't silently resolve to a source file.
182
- for (const { rel } of collectPages(join(hypoDir, 'sources'), hypoDir, [], ignorePatterns)) {
201
+ for (const { rel } of collectPagesLint(join(hypoDir, 'sources'), hypoDir, ignorePatterns)) {
183
202
  targets.push(rel.replace(/\.md$/, '').replace(/\\/g, '/'));
184
203
  }
185
204
  return targets;
@@ -258,10 +277,15 @@ const issues = [];
258
277
  // STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
259
278
  // defects only.
260
279
  // W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
280
+ // W9 invalid-YAML → promote (frontmatter a real YAML parser rejects).
261
281
  // W3 missing-updated → excluded (auto-repaired by --fix).
262
282
  // W8 design-history-stale → excluded (hypo-personal-check handles it; would
263
283
  // double-gate).
264
- const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4']);
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']);
265
289
 
266
290
  function issue(severity, rel, msg, fullPath = null, id = null) {
267
291
  issues.push({ severity, file: rel, message: msg, path: fullPath, id });
@@ -284,7 +308,7 @@ function lintSessionStateHeadings(content, rel) {
284
308
  }
285
309
  }
286
310
 
287
- function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
311
+ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
288
312
  // Directory whitelist: a content page under pages/<subdir>/ must live in a
289
313
  // SCHEMA-defined directory. Catches typo'd dirs (e.g. pages/learning/ vs the
290
314
  // canonical pages/learnings/) regardless of frontmatter, since a directory
@@ -318,6 +342,16 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
318
342
  return;
319
343
  }
320
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
+
321
355
  const fm = parseFrontmatter(content);
322
356
  if (!fm) {
323
357
  issue('error', rel, 'Malformed frontmatter (unclosed ---)');
@@ -328,7 +362,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
328
362
  if (!fm[field]) issue('error', rel, `Missing required frontmatter field: ${field}`);
329
363
  }
330
364
 
331
- if (fm.type && !VALID_TYPES.includes(fm.type)) {
365
+ if (fm.type && !validTypes.has(fm.type)) {
332
366
  issue('warn', rel, `Unknown type: "${fm.type}"`, null, 'W2');
333
367
  }
334
368
 
@@ -412,13 +446,19 @@ const args = parseArgs(process.argv);
412
446
 
413
447
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
414
448
  const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
415
- const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
449
+ const pages = scanDirs.flatMap((d) => collectPagesLint(d, args.hypoDir, ignorePatterns));
416
450
  const linkTargets = collectLinkTargets(args.hypoDir, ignorePatterns);
417
451
  const slugMap = buildSlugMap(pages, linkTargets);
418
452
  const tagVocab = parseSchemaVocab(args.hypoDir);
419
453
  const pageDirs = parseSchemaPageDirs(args.hypoDir);
420
-
421
- for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs);
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);
422
462
 
423
463
  // W8: design-history.md stale relative to session-log.md. Emitted once per
424
464
  // project (not per page) — runs outside the page loop. POSIX-separated path
package/scripts/query.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
20
20
  import { join, relative, extname } from 'path';
21
21
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
22
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
22
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
23
23
 
24
24
  // ── arg parsing ──────────────────────────────────────────────────────────────
25
25
 
@@ -42,7 +42,7 @@ function collectMdFiles(dir, root, acc = [], ignorePatterns = []) {
42
42
  for (const entry of readdirSync(dir)) {
43
43
  if (entry.startsWith('.')) continue;
44
44
  const full = join(dir, entry);
45
- if (isIgnored(full, root, ignorePatterns)) continue;
45
+ if (isScanIgnored(full, root, ignorePatterns)) continue;
46
46
  const st = statSync(full);
47
47
  if (st.isDirectory()) collectMdFiles(full, root, acc, ignorePatterns);
48
48
  else if (extname(entry) === '.md') acc.push({ path: full, rel: relative(root, full) });
@@ -47,9 +47,10 @@ import {
47
47
  lstatSync,
48
48
  realpathSync,
49
49
  } from 'fs';
50
- import { join, relative, extname, basename, dirname, normalize, isAbsolute, sep } from 'path';
50
+ import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
51
51
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
52
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
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 (isIgnored(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
- function dirRelForm(slug) {
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 = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
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 = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
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);