hypomnema 1.0.1 → 1.2.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 +12 -5
- package/README.md +12 -5
- package/commands/audit.md +46 -0
- package/commands/crystallize.md +113 -23
- package/commands/feedback.md +40 -26
- package/commands/ingest.md +31 -9
- package/commands/upgrade.md +2 -2
- package/docs/ARCHITECTURE.md +83 -9
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hooks.json +39 -1
- package/hooks/hypo-auto-commit.mjs +23 -4
- package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
- package/hooks/hypo-auto-stage.mjs +9 -5
- package/hooks/hypo-compact-guard.mjs +33 -24
- package/hooks/hypo-cwd-change.mjs +107 -24
- package/hooks/hypo-file-watch.mjs +23 -10
- package/hooks/hypo-first-prompt.mjs +37 -23
- package/hooks/hypo-hot-rebuild.mjs +31 -8
- package/hooks/hypo-lookup.mjs +171 -65
- package/hooks/hypo-personal-check.mjs +207 -112
- package/hooks/hypo-pre-commit.mjs +46 -0
- package/hooks/hypo-session-end.mjs +58 -0
- package/hooks/hypo-session-record.mjs +60 -0
- package/hooks/hypo-session-start.mjs +312 -44
- package/hooks/hypo-shared.mjs +880 -28
- package/hooks/hypo-web-fetch-ingest.mjs +121 -0
- package/hooks/version-check-fetch.mjs +74 -0
- package/hooks/version-check.mjs +184 -0
- package/package.json +17 -3
- package/scripts/crystallize.mjs +623 -18
- package/scripts/doctor.mjs +739 -46
- package/scripts/feedback-sync.mjs +974 -0
- package/scripts/feedback.mjs +253 -44
- package/scripts/graph.mjs +35 -22
- package/scripts/ingest.mjs +89 -16
- package/scripts/init.mjs +442 -114
- package/scripts/lib/design-history-stale.mjs +83 -0
- package/scripts/lib/extensions.mjs +749 -0
- package/scripts/lib/frontmatter.mjs +5 -1
- package/scripts/lib/hypo-ignore.mjs +12 -10
- package/scripts/lib/pkg-json.mjs +23 -5
- package/scripts/lib/project-create.mjs +225 -0
- package/scripts/lib/schema-vocab.mjs +96 -0
- package/scripts/lint.mjs +238 -31
- package/scripts/query.mjs +26 -10
- package/scripts/resume.mjs +11 -5
- package/scripts/session-audit.mjs +277 -0
- package/scripts/smoke-pack.mjs +224 -0
- package/scripts/stats.mjs +24 -10
- package/scripts/uninstall.mjs +369 -48
- package/scripts/upgrade.mjs +766 -195
- package/scripts/verify.mjs +24 -14
- package/scripts/weekly-report.mjs +211 -0
- package/skills/crystallize/SKILL.md +24 -7
- package/skills/graph/SKILL.md +4 -0
- package/skills/ingest/SKILL.md +29 -5
- package/skills/lint/SKILL.md +4 -0
- package/skills/query/SKILL.md +4 -0
- package/skills/verify/SKILL.md +4 -0
- package/templates/.hypoignore +19 -2
- package/templates/Home.md +2 -0
- package/templates/SCHEMA.md +61 -6
- package/templates/extensions/agents/.gitkeep +0 -0
- package/templates/extensions/commands/.gitkeep +0 -0
- package/templates/extensions/hooks/.gitkeep +0 -0
- package/templates/extensions/skills/.gitkeep +0 -0
- package/templates/gitignore +5 -0
- package/templates/hot.md +2 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +63 -1
- package/templates/hypo-help.md +1 -1
- package/templates/pages/observability/_index.md +77 -0
- package/templates/projects/_template/index.md +2 -2
- package/templates/projects/_template/prd.md +1 -1
|
@@ -5,7 +5,11 @@ export function parseFrontmatter(content) {
|
|
|
5
5
|
for (const line of m[1].split(/\r?\n/)) {
|
|
6
6
|
const idx = line.indexOf(':');
|
|
7
7
|
if (idx < 0) continue;
|
|
8
|
-
fm[line.slice(0, idx).trim()] = line
|
|
8
|
+
fm[line.slice(0, idx).trim()] = line
|
|
9
|
+
.slice(idx + 1)
|
|
10
|
+
.trim()
|
|
11
|
+
.replace(/\s*#.*$/, '')
|
|
12
|
+
.replace(/^["']|["']$/g, '');
|
|
9
13
|
}
|
|
10
14
|
return fm;
|
|
11
15
|
}
|
|
@@ -6,19 +6,21 @@ export function loadHypoIgnore(hypoDir) {
|
|
|
6
6
|
if (!existsSync(ignorePath)) return [];
|
|
7
7
|
return readFileSync(ignorePath, 'utf-8')
|
|
8
8
|
.split('\n')
|
|
9
|
-
.map(l => l.trim())
|
|
10
|
-
.filter(l => l && !l.startsWith('#'));
|
|
9
|
+
.map((l) => l.trim())
|
|
10
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function globToRegex(glob) {
|
|
14
|
-
return new RegExp(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
return new RegExp(
|
|
15
|
+
'^' +
|
|
16
|
+
glob
|
|
17
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
18
|
+
.replace(/\*\*/g, '\x00') // placeholder before single-* replacement
|
|
19
|
+
.replace(/\*/g, '[^/]*')
|
|
20
|
+
.replace(/\?/g, '[^/]')
|
|
21
|
+
.replace(/\x00/g, '.*') + // restore ** → .*
|
|
22
|
+
'$',
|
|
23
|
+
);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export function isIgnored(filePath, hypoDir, patterns) {
|
package/scripts/lib/pkg-json.mjs
CHANGED
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
* that as a refusal to operate on the destination.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
lstatSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
} from 'fs';
|
|
14
22
|
import { join, dirname } from 'path';
|
|
15
23
|
import { createHash } from 'crypto';
|
|
16
24
|
|
|
@@ -20,12 +28,20 @@ export function sha256(buf) {
|
|
|
20
28
|
|
|
21
29
|
export function isRegularFile(path) {
|
|
22
30
|
if (!existsSync(path)) return false;
|
|
23
|
-
try {
|
|
31
|
+
try {
|
|
32
|
+
return lstatSync(path).isFile();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export function readFileIfRegular(path) {
|
|
27
39
|
if (!isRegularFile(path)) return null;
|
|
28
|
-
try {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(path);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
export function readPkgJson(pkgJsonPath) {
|
|
@@ -36,7 +52,7 @@ export function readPkgJson(pkgJsonPath) {
|
|
|
36
52
|
} catch (err) {
|
|
37
53
|
// Preserve corrupt file as <name>.corrupt-<ts>.json so the user can recover.
|
|
38
54
|
try {
|
|
39
|
-
const ts
|
|
55
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
40
56
|
const bak = `${pkgJsonPath}.corrupt-${ts}.json`;
|
|
41
57
|
renameSync(pkgJsonPath, bak);
|
|
42
58
|
console.error(`[hypomnema] WARN: ${pkgJsonPath} was not valid JSON. Preserved as ${bak}.`);
|
|
@@ -53,7 +69,9 @@ export function writePkgJsonAtomic(pkgJsonPath, data) {
|
|
|
53
69
|
try {
|
|
54
70
|
renameSync(tmp, pkgJsonPath);
|
|
55
71
|
} catch (err) {
|
|
56
|
-
try {
|
|
72
|
+
try {
|
|
73
|
+
unlinkSync(tmp);
|
|
74
|
+
} catch {}
|
|
57
75
|
throw err;
|
|
58
76
|
}
|
|
59
77
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* project-create.mjs — atomic auto-project scaffold (fix #23, ADR 0023)
|
|
4
|
+
*
|
|
5
|
+
* Invoked by the LLM (NOT a user-facing subcommand — ADR 0023 deprecated
|
|
6
|
+
* `hypomnema project new`) after the user answers "Y" to a SessionStart /
|
|
7
|
+
* CwdChanged auto-project offer. One call materializes a whole project:
|
|
8
|
+
*
|
|
9
|
+
* 1. mkdir projects/<name>/{decisions,session-log}
|
|
10
|
+
* 2. copy templates/projects/_template/*.md with token substitution
|
|
11
|
+
* <project-name> → name, <started> → date, <working_dir> → cwd,
|
|
12
|
+
* YYYY-MM-DD → today (frontmatter `updated:` only)
|
|
13
|
+
* 3. append a row to root hot.md "Active Projects" table
|
|
14
|
+
* 4. append a `## [today] project-create | <name>` entry to log.md
|
|
15
|
+
*
|
|
16
|
+
* Idempotent: existing files/rows/entries are preserved, never overwritten or
|
|
17
|
+
* duplicated, so a re-run after a partial failure converges.
|
|
18
|
+
*
|
|
19
|
+
* CLI:
|
|
20
|
+
* node scripts/lib/project-create.mjs --name <slug> --working-dir <path> \
|
|
21
|
+
* [--hypo-dir <path>] [--started <YYYY-MM-DD>] [--json]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
25
|
+
import { join, dirname, resolve, sep } from 'path';
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import { resolveHypoRoot, expandHome } from './hypo-root.mjs';
|
|
28
|
+
|
|
29
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const PKG_ROOT = join(SCRIPT_DIR, '..', '..');
|
|
31
|
+
const TEMPLATE_DIR = join(PKG_ROOT, 'templates', 'projects', '_template');
|
|
32
|
+
|
|
33
|
+
const TEMPLATE_FILES = ['index.md', 'prd.md', 'hot.md', 'session-state.md'];
|
|
34
|
+
|
|
35
|
+
function todayISO() {
|
|
36
|
+
return new Date().toISOString().slice(0, 10);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Substitute the project tokens in a template file's content.
|
|
41
|
+
* @param {string} content
|
|
42
|
+
* @param {{name: string, started: string, workingDir: string, today: string}} vars
|
|
43
|
+
*/
|
|
44
|
+
export function substituteTokens(content, { name, started, workingDir, today }) {
|
|
45
|
+
return content
|
|
46
|
+
.split('<project-name>')
|
|
47
|
+
.join(name)
|
|
48
|
+
.split('<started>')
|
|
49
|
+
.join(started)
|
|
50
|
+
.split('<working_dir>')
|
|
51
|
+
.join(workingDir)
|
|
52
|
+
.split('YYYY-MM-DD')
|
|
53
|
+
.join(today);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Insert a project row into the root hot.md "Active Projects" table.
|
|
58
|
+
* Idempotent: returns the original content unchanged if a row already links
|
|
59
|
+
* to `[[projects/<name>/hot]]`. Returns null if the table cannot be located.
|
|
60
|
+
* @returns {string|null} new content, or null when no table marker is found
|
|
61
|
+
*/
|
|
62
|
+
export function insertHotRow(content, name, today) {
|
|
63
|
+
const link = `[[projects/${name}/hot]]`;
|
|
64
|
+
if (content.includes(link)) return content; // already present
|
|
65
|
+
const lines = content.split('\n');
|
|
66
|
+
// Scope the search to the "## Active Projects" section so a table appearing
|
|
67
|
+
// earlier in hot.md can't capture the row (codex review 2026-05-22). Start
|
|
68
|
+
// looking from the heading; stop at the next H2 so we never cross sections.
|
|
69
|
+
const headingIdx = lines.findIndex((l) => /^##\s+Active Projects\s*$/.test(l));
|
|
70
|
+
if (headingIdx === -1) return null;
|
|
71
|
+
let sepIdx = -1;
|
|
72
|
+
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
73
|
+
if (/^##\s/.test(lines[i])) break; // next section — table not found in scope
|
|
74
|
+
if (/^\|\s*-{2,}\s*\|/.test(lines[i])) {
|
|
75
|
+
sepIdx = i;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (sepIdx === -1) return null;
|
|
80
|
+
const row = `| ${name} | ${today} | ${link} |`;
|
|
81
|
+
lines.splice(sepIdx + 1, 0, row);
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create a project. Idempotent and best-effort per side effect: a missing root
|
|
87
|
+
* hot.md / log.md is reported in `warnings` rather than thrown, so the core
|
|
88
|
+
* project files still land.
|
|
89
|
+
*
|
|
90
|
+
* @param {{hypoDir: string, name: string, workingDir: string, started?: string, today?: string}} opts
|
|
91
|
+
* @returns {{created: string[], skipped: string[], warnings: string[], projectDir: string}}
|
|
92
|
+
*/
|
|
93
|
+
export function createProject(opts) {
|
|
94
|
+
const { name, workingDir } = opts;
|
|
95
|
+
// Name must be a single path segment with at least one alnum. The charset
|
|
96
|
+
// alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet would
|
|
97
|
+
// resolve `projects/<name>` to the wiki root or `projects/` itself (codex
|
|
98
|
+
// review 2026-05-22, both workers). Reject dot-only names and require alnum.
|
|
99
|
+
if (!name || !/^[A-Za-z0-9._-]+$/.test(name) || /^\.+$/.test(name) || !/[A-Za-z0-9]/.test(name)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`invalid project name: ${JSON.stringify(name)} (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (!workingDir) throw new Error('workingDir is required');
|
|
105
|
+
|
|
106
|
+
const hypoDir = opts.hypoDir || resolveHypoRoot();
|
|
107
|
+
const today = opts.today || todayISO();
|
|
108
|
+
const started = opts.started || today;
|
|
109
|
+
const vars = { name, started, workingDir, today };
|
|
110
|
+
|
|
111
|
+
const created = [];
|
|
112
|
+
const skipped = [];
|
|
113
|
+
const warnings = [];
|
|
114
|
+
|
|
115
|
+
const projectsRoot = resolve(hypoDir, 'projects');
|
|
116
|
+
const projectDir = join(projectsRoot, name);
|
|
117
|
+
// Defense in depth: the resolved target must stay strictly inside projects/.
|
|
118
|
+
if (
|
|
119
|
+
resolve(projectDir) !== join(projectsRoot, name) ||
|
|
120
|
+
!resolve(projectDir).startsWith(projectsRoot + sep)
|
|
121
|
+
) {
|
|
122
|
+
throw new Error(`project name escapes projects/: ${JSON.stringify(name)}`);
|
|
123
|
+
}
|
|
124
|
+
for (const sub of ['decisions', 'session-log']) {
|
|
125
|
+
mkdirSync(join(projectDir, sub), { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const file of TEMPLATE_FILES) {
|
|
129
|
+
const src = join(TEMPLATE_DIR, file);
|
|
130
|
+
const dest = join(projectDir, file);
|
|
131
|
+
if (!existsSync(src)) {
|
|
132
|
+
warnings.push(`template missing: ${file}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (existsSync(dest)) {
|
|
136
|
+
skipped.push(`projects/${name}/${file}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
writeFileSync(dest, substituteTokens(readFileSync(src, 'utf-8'), vars));
|
|
140
|
+
created.push(`projects/${name}/${file}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// root hot.md pointer row
|
|
144
|
+
const hotPath = join(hypoDir, 'hot.md');
|
|
145
|
+
if (existsSync(hotPath)) {
|
|
146
|
+
const orig = readFileSync(hotPath, 'utf-8');
|
|
147
|
+
const next = insertHotRow(orig, name, today);
|
|
148
|
+
if (next === null) {
|
|
149
|
+
warnings.push('root hot.md: no Active Projects table found — add row manually');
|
|
150
|
+
} else if (next !== orig) {
|
|
151
|
+
writeFileSync(hotPath, next);
|
|
152
|
+
created.push('hot.md row');
|
|
153
|
+
} else {
|
|
154
|
+
skipped.push('hot.md row');
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
warnings.push('root hot.md missing — skipped pointer row');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// log.md entry
|
|
161
|
+
const logPath = join(hypoDir, 'log.md');
|
|
162
|
+
const entry = `## [${today}] project-create | ${name}`;
|
|
163
|
+
if (existsSync(logPath)) {
|
|
164
|
+
const log = readFileSync(logPath, 'utf-8');
|
|
165
|
+
if (log.includes(entry)) {
|
|
166
|
+
skipped.push('log.md entry');
|
|
167
|
+
} else {
|
|
168
|
+
writeFileSync(logPath, log.replace(/\s*$/, '\n') + `\n${entry}\n`);
|
|
169
|
+
created.push('log.md entry');
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
warnings.push('log.md missing — skipped activity entry');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { created, skipped, warnings, projectDir };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
179
|
+
function parseArgs(argv) {
|
|
180
|
+
const args = { json: false };
|
|
181
|
+
for (let i = 0; i < argv.length; i++) {
|
|
182
|
+
const a = argv[i];
|
|
183
|
+
if (a === '--json') args.json = true;
|
|
184
|
+
else if (a === '--name') args.name = argv[++i];
|
|
185
|
+
else if (a === '--working-dir') args.workingDir = expandHome(argv[++i]);
|
|
186
|
+
else if (a === '--hypo-dir') args.hypoDir = expandHome(argv[++i]);
|
|
187
|
+
else if (a === '--started') args.started = argv[++i];
|
|
188
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
189
|
+
}
|
|
190
|
+
return args;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const USAGE = `Usage: project-create.mjs --name <slug> --working-dir <path> [--hypo-dir <path>] [--started YYYY-MM-DD] [--json]`;
|
|
194
|
+
|
|
195
|
+
function main() {
|
|
196
|
+
const args = parseArgs(process.argv.slice(2));
|
|
197
|
+
if (args.help) {
|
|
198
|
+
console.log(USAGE);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!args.name || !args.workingDir) {
|
|
202
|
+
console.error(`Error: --name and --working-dir are required\n${USAGE}`);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
let result;
|
|
206
|
+
try {
|
|
207
|
+
result = createProject(args);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error(`Error: ${err?.message ?? String(err)}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
if (args.json) {
|
|
213
|
+
console.log(JSON.stringify(result, null, 2));
|
|
214
|
+
} else {
|
|
215
|
+
result.created.forEach((c) => console.log(`✓ created ${c}`));
|
|
216
|
+
result.skipped.forEach((s) => console.log(`· skipped ${s} (exists)`));
|
|
217
|
+
result.warnings.forEach((w) => console.warn(`⚠ ${w}`));
|
|
218
|
+
console.log(`\nProject '${args.name}' ready at ${result.projectDir}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Run as CLI only when invoked directly, not when imported by tests.
|
|
223
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
224
|
+
main();
|
|
225
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
|
|
5
|
+
const TYPE_TAXONOMY_HEADER_RE = /^##\s+(?:\d+\.\s+)?Page\s+Type\s+Taxonomy\s*$/m;
|
|
6
|
+
const PAGE_DIR_TOKEN_RE = /`pages\/([a-z0-9-]+)\//g;
|
|
7
|
+
const NEXT_H2_RE = /^##\s+/m;
|
|
8
|
+
const CATEGORY_PREFIX_RE = /^\s*\*\*[^*]+\*\*:\s*/;
|
|
9
|
+
const BACKTICK_TOKEN_RE = /`([^`]+)`/g;
|
|
10
|
+
|
|
11
|
+
const FORBIDDEN_PATTERNS = [
|
|
12
|
+
{ name: 'PascalCase', test: (t) => /[A-Z]/.test(t) },
|
|
13
|
+
{ name: 'whitespace', test: (t) => /\s/.test(t) },
|
|
14
|
+
{
|
|
15
|
+
name: 'generic',
|
|
16
|
+
test: (t) => ['general', 'misc', 'other', 'todo'].includes(t),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'plural',
|
|
20
|
+
test: (t) => ['learnings', 'tips', 'feedbacks', 'concepts', 'playbooks'].includes(t),
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function checkForbidden(tag) {
|
|
25
|
+
for (const rule of FORBIDDEN_PATTERNS) {
|
|
26
|
+
if (rule.test(tag)) return rule.name;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Extract only backtick tokens from canonical vocabulary lines.
|
|
32
|
+
// A vocabulary line is one whose non-backtick residue (after stripping an
|
|
33
|
+
// optional `**Category**:` prefix) contains only separators (whitespace, commas).
|
|
34
|
+
// This excludes prose like "...`lint` blocks unknown tags..." and table rows
|
|
35
|
+
// in the Forbidden patterns section that mention example tokens.
|
|
36
|
+
function vocabLineTokens(rawLine) {
|
|
37
|
+
const line = rawLine.replace(CATEGORY_PREFIX_RE, '').trim();
|
|
38
|
+
if (!line) return [];
|
|
39
|
+
const residue = line.replace(/`[^`]+`/g, '').trim();
|
|
40
|
+
if (!/^[\s,]*$/.test(residue)) return [];
|
|
41
|
+
const tokens = [];
|
|
42
|
+
for (const m of line.matchAll(BACKTICK_TOKEN_RE)) {
|
|
43
|
+
const t = m[1].trim();
|
|
44
|
+
if (t && !t.includes(' ')) tokens.push(t);
|
|
45
|
+
}
|
|
46
|
+
return tokens;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseSchemaVocab(hypoDir) {
|
|
50
|
+
const path = join(hypoDir, 'SCHEMA.md');
|
|
51
|
+
if (!existsSync(path)) return new Set();
|
|
52
|
+
const content = readFileSync(path, 'utf-8');
|
|
53
|
+
|
|
54
|
+
const headerMatch = VOCAB_HEADER_RE.exec(content);
|
|
55
|
+
if (!headerMatch) return new Set();
|
|
56
|
+
|
|
57
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
58
|
+
const rest = content.slice(sectionStart);
|
|
59
|
+
const nextH2 = NEXT_H2_RE.exec(rest);
|
|
60
|
+
const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
|
|
61
|
+
|
|
62
|
+
const vocab = new Set();
|
|
63
|
+
for (const rawLine of section.split('\n')) {
|
|
64
|
+
for (const token of vocabLineTokens(rawLine)) vocab.add(token);
|
|
65
|
+
}
|
|
66
|
+
return vocab;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Derive the set of valid immediate subdirectories under pages/ from the
|
|
70
|
+
// SCHEMA "Page Type Taxonomy" table (e.g. learnings, feedback, people). Single
|
|
71
|
+
// source of truth — adding a type+dir row to SCHEMA automatically widens the
|
|
72
|
+
// whitelist. Returns an empty Set when SCHEMA.md or the table is absent, which
|
|
73
|
+
// callers treat as "skip the check" for back-compat with minimal wikis.
|
|
74
|
+
export function parseSchemaPageDirs(hypoDir) {
|
|
75
|
+
const path = join(hypoDir, 'SCHEMA.md');
|
|
76
|
+
if (!existsSync(path)) return new Set();
|
|
77
|
+
const content = readFileSync(path, 'utf-8');
|
|
78
|
+
|
|
79
|
+
const headerMatch = TYPE_TAXONOMY_HEADER_RE.exec(content);
|
|
80
|
+
if (!headerMatch) return new Set();
|
|
81
|
+
|
|
82
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
83
|
+
const rest = content.slice(sectionStart);
|
|
84
|
+
const nextH2 = NEXT_H2_RE.exec(rest);
|
|
85
|
+
const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
|
|
86
|
+
|
|
87
|
+
// Only scan table rows (lines starting with `|`) so a backticked path that
|
|
88
|
+
// appears in surrounding prose (e.g. an example of a *wrong* dir) can never
|
|
89
|
+
// leak into the whitelist.
|
|
90
|
+
const dirs = new Set();
|
|
91
|
+
for (const rawLine of section.split('\n')) {
|
|
92
|
+
if (!rawLine.trimStart().startsWith('|')) continue;
|
|
93
|
+
for (const m of rawLine.matchAll(PAGE_DIR_TOKEN_RE)) dirs.add(m[1]);
|
|
94
|
+
}
|
|
95
|
+
return dirs;
|
|
96
|
+
}
|