@jeremiewerner/motto 0.0.3

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/src/lint.js ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Motto lint orchestrator.
3
+ *
4
+ * Exports a single async function `lintProject(projectRoot)` that wires the
5
+ * Phase 1 pure validators (parseFrontmatter, validateSkill, loadConfig) to the
6
+ * filesystem: it discovers skills and shared references on disk, reads each
7
+ * SKILL.md, runs the pure validators, and aggregates all errors across all
8
+ * skills before returning.
9
+ *
10
+ * Per D-01 / D2-06: lintProject NEVER throws at its boundary. Every I/O or
11
+ * parse error is converted to a `{ skill, message }` entry and the scan
12
+ * continues to all remaining skills.
13
+ *
14
+ * Return shape: { ok: boolean, errors: Array<{ skill: string, message: string }>, count: number }
15
+ * ok — true when errors is empty.
16
+ * errors — aggregated error list; config errors (labelled 'motto.yaml') always
17
+ * precede skill errors; skill errors are in alphabetical-by-dirName order.
18
+ * count — number of skills discovered (0 when none found).
19
+ *
20
+ * Internal helpers (not exported): processConfig, loadSharedRefs,
21
+ * discoverSkillNames, processSkill — per RESEARCH Module Decomposition
22
+ * Recommendation.
23
+ */
24
+
25
+ import { readFile, readdir } from 'node:fs/promises';
26
+ import { join, basename, extname } from 'node:path';
27
+
28
+ import { parseFrontmatter } from './frontmatter.js';
29
+ import { validateSkill } from './schema.js';
30
+ import { loadConfig } from './config.js';
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // processConfig — step 1 of lintProject (D2-09, D2-10)
34
+ // ---------------------------------------------------------------------------
35
+
36
+ /**
37
+ * Read and validate motto.yaml. Any error is pushed to the shared `errors`
38
+ * array under the `'motto.yaml'` label (D2-10). Never throws; always returns.
39
+ *
40
+ * @param {string} projectRoot
41
+ * @param {Array<{skill: string, message: string}>} errors - mutated in place
42
+ */
43
+ async function processConfig(projectRoot, errors) {
44
+ const configPath = join(projectRoot, 'motto.yaml');
45
+ let text;
46
+ try {
47
+ text = await readFile(configPath, 'utf8');
48
+ } catch (e) {
49
+ if (e.code === 'ENOENT') {
50
+ errors.push({ skill: 'motto.yaml', message: 'file not found' });
51
+ } else {
52
+ errors.push({ skill: 'motto.yaml', message: `could not read: ${e.message}` });
53
+ }
54
+ return; // config errors collected; skill scan still runs (D2-10)
55
+ }
56
+ const { errors: configErrors } = loadConfig(text);
57
+ for (const e of configErrors) {
58
+ // Lift: loadConfig errors have no skill field (Pitfall 3); add 'motto.yaml' label
59
+ errors.push({ skill: 'motto.yaml', message: e.message });
60
+ }
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // loadSharedRefs — step 2 of lintProject (D2-07, D2-08)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Scan shared/references/*.md and return a Set of basenames (without .md).
69
+ * A missing shared/ or shared/references/ directory is NOT an error — returns
70
+ * an empty Set (D2-08). Other unexpected I/O errors are rethrown.
71
+ *
72
+ * @param {string} projectRoot
73
+ * @returns {Promise<Set<string>>}
74
+ */
75
+ async function loadSharedRefs(projectRoot) {
76
+ const refsDir = join(projectRoot, 'shared', 'references');
77
+ try {
78
+ const entries = await readdir(refsDir, { withFileTypes: true });
79
+ return new Set(
80
+ entries
81
+ .filter((e) => e.isFile() && extname(e.name) === '.md')
82
+ .map((e) => basename(e.name, '.md')),
83
+ );
84
+ } catch (e) {
85
+ if (e.code === 'ENOENT') return new Set(); // D2-08: absence is not an error
86
+ throw e; // unexpected — propagate to the lintProject boundary
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // discoverSkillNames — step 3 of lintProject (D2-02, D2-03, D2-04)
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Discover candidate skill directories under `skillsDir` (one level deep).
96
+ *
97
+ * Filters: non-hidden directories only (D2-04).
98
+ * Sort: localeCompare for deterministic alphabetical order (D2-03, Pitfall 1).
99
+ *
100
+ * Returns `null` when skillsDir is missing (ENOENT) — caller maps to "no skills found".
101
+ * Returns `[]` when skillsDir exists but contains no candidates — same treatment.
102
+ * Rethrows any other I/O error.
103
+ *
104
+ * @param {string} skillsDir - absolute path to the skills/ directory
105
+ * @returns {Promise<string[]|null>}
106
+ */
107
+ async function discoverSkillNames(skillsDir) {
108
+ let entries;
109
+ try {
110
+ entries = await readdir(skillsDir, { withFileTypes: true });
111
+ } catch (e) {
112
+ if (e.code === 'ENOENT') return null; // caller converts to "no skills found"
113
+ throw e;
114
+ }
115
+ return entries
116
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.')) // D2-04
117
+ .sort((a, b) => a.name.localeCompare(b.name)) // D2-03 — MANDATORY (Pitfall 1)
118
+ .map((e) => e.name);
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // processSkill — step 4 helper (D2-05, D2-06)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Read and validate a single skill directory. Errors are pushed to the shared
127
+ * `errors` array. Any unexpected exception is caught and converted to an error
128
+ * entry so the scan ALWAYS continues (D2-06 backstop).
129
+ *
130
+ * @param {string} skillsDir
131
+ * @param {string} dirName
132
+ * @param {Set<string>} sharedRefs
133
+ * @param {Array<{skill: string, message: string}>} errors - mutated in place
134
+ */
135
+ async function processSkill(skillsDir, dirName, sharedRefs, errors) {
136
+ try {
137
+ const skillPath = join(skillsDir, dirName, 'SKILL.md');
138
+ let text;
139
+ try {
140
+ text = await readFile(skillPath, 'utf8');
141
+ } catch (e) {
142
+ if (e.code === 'ENOENT') {
143
+ // D2-05: missing SKILL.md is a hard error; report it and move on
144
+ errors.push({ skill: dirName, message: 'missing SKILL.md' });
145
+ } else {
146
+ errors.push({ skill: dirName, message: `could not read SKILL.md: ${e.message}` });
147
+ }
148
+ return; // return from helper == continue in the outer loop (Pitfall 5)
149
+ }
150
+
151
+ // Parse frontmatter — never throws (D-01)
152
+ const { data, body, errors: parseErrors } = parseFrontmatter(text);
153
+ // Lift parseFrontmatter errors: no skill field in returned errors (Pitfall 2)
154
+ for (const e of parseErrors) {
155
+ errors.push({ skill: dirName, message: e.message });
156
+ }
157
+
158
+ // Schema validation — already returns { skill, message } shaped (already normalized)
159
+ const schemaErrors = validateSkill({ dirName, data, body }, sharedRefs);
160
+ errors.push(...schemaErrors);
161
+ } catch (e) {
162
+ // Backstop: any unexpected failure during per-skill I/O or validation (D2-06)
163
+ errors.push({ skill: dirName, message: `unexpected error: ${e.message}` });
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // lintProject — main export
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Lint a Motto project rooted at `projectRoot`.
173
+ *
174
+ * Execution order (locked per D2-10 — config errors MUST precede skill errors):
175
+ * 1. processConfig — reads motto.yaml; errors labelled 'motto.yaml'
176
+ * 2. loadSharedRefs — scans shared/references/*.md → Set of basenames
177
+ * 3. discoverSkillNames — reads skills/ directory; null or [] → "no skills found"
178
+ * 4. processSkill (each, in sorted order) — reads + validates each SKILL.md
179
+ *
180
+ * @param {string} projectRoot - absolute path to the project root
181
+ * @returns {Promise<{ ok: boolean, errors: Array<{skill: string, message: string}>, count: number }>}
182
+ */
183
+ export async function lintProject(projectRoot) {
184
+ const errors = [];
185
+
186
+ // 1. Config errors first so they precede all skill errors (D2-10)
187
+ await processConfig(projectRoot, errors);
188
+
189
+ // 2. Shared references set (empty if shared/ missing — D2-08)
190
+ const sharedRefs = await loadSharedRefs(projectRoot);
191
+
192
+ // 3. Discover skills — null = ENOENT, [] = empty dir; both map to "no skills found" (D2-13, Pitfall 9)
193
+ const skillsDir = join(projectRoot, 'skills');
194
+ const skillNames = await discoverSkillNames(skillsDir);
195
+
196
+ if (skillNames === null || skillNames.length === 0) {
197
+ errors.push({ skill: '(project)', message: 'no skills found' });
198
+ return { ok: false, errors, count: 0 };
199
+ }
200
+
201
+ // 4. Process each skill in the sorted (alphabetical) order returned by discoverSkillNames
202
+ for (const dirName of skillNames) {
203
+ await processSkill(skillsDir, dirName, sharedRefs, errors);
204
+ }
205
+
206
+ return { ok: errors.length === 0, errors, count: skillNames.length };
207
+ }
package/src/schema.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Motto skill-schema validator.
3
+ *
4
+ * Pure object validator — no filesystem, no YAML parsing, no imports beyond
5
+ * this file's own scope. NEVER throws (D-01). All validation failures surface
6
+ * through the returned errors[].
7
+ *
8
+ * Exports:
9
+ * - NAME_KEBAB {RegExp} — canonical letter-start kebab regex (D-08, D-16)
10
+ * - validateSkill(skill, sharedRefs?) -> Array<{ skill, message }>
11
+ */
12
+
13
+ /**
14
+ * Canonical letter-start kebab-case regex for skill and plugin names.
15
+ *
16
+ * Pattern: /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
17
+ * - starts with exactly one lowercase letter (letter-start — D-08 fix; the
18
+ * prior-art /^[a-z0-9]+/ wrongly allowed a leading digit)
19
+ * - followed by zero or more lowercase letters or digits
20
+ * - then zero or more groups of (one hyphen + one or more lowercase letters/digits)
21
+ *
22
+ * Valid examples: "my-skill", "abc", "a1", "abc-def-123"
23
+ * Invalid examples: "0bad" (leading digit), "My_Skill" (uppercase/underscore),
24
+ * "my--skill" (double dash), "-bad" (leading dash), "bad-" (trailing dash)
25
+ *
26
+ * Anchored and unambiguous — no catastrophic backtracking (T-02-01): each `-`
27
+ * unambiguously opens exactly one group; `-` cannot appear in [a-z0-9].
28
+ *
29
+ * Exported so src/config.js can reuse the same regex without diverging (D-16).
30
+ *
31
+ * @type {RegExp}
32
+ */
33
+ export const NAME_KEBAB = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
34
+
35
+ /**
36
+ * Reserved substrings that must not appear in skill or plugin names (LINT-02, D-09).
37
+ * @type {string[]}
38
+ */
39
+ const RESERVED = ["anthropic", "claude"];
40
+
41
+ /**
42
+ * Validate a parsed skill object against the Motto schema.
43
+ *
44
+ * Error-aggregation model (D-13):
45
+ * - NAME checks CASCADE: missing/falsy → non-string → non-kebab → max-64 → reserved-word → ≠folder.
46
+ * The chain stops at the first failure; subsequent name checks are skipped
47
+ * because they are meaningless once an earlier check fails.
48
+ * - All OTHER checks (description, audience, body Title, body Role, each
49
+ * shared_references entry) are INDEPENDENT: they all run and all errors
50
+ * are collected together regardless of what other checks find.
51
+ *
52
+ * Unknown frontmatter keys (`template`, `dependencies`, …) are accepted
53
+ * without error (D-14).
54
+ *
55
+ * @param {{ dirName: string, data: object, body: string }} skill
56
+ * `dirName` — the skill's source folder name (cascade anchor + error.skill)
57
+ * `data` — parsed YAML frontmatter object (plain JS; never mutated)
58
+ * `body` — Markdown body string after the closing `---` delimiter
59
+ * @param {Set<string>} [sharedRefs]
60
+ * Set of available shared-reference basenames (without `.md`). Defaults to
61
+ * an empty Set; entries in data.shared_references are resolved against it.
62
+ * @returns {Array<{ skill: string, message: string }>}
63
+ * Empty array when the skill is fully valid; one object per error otherwise.
64
+ * Each object's `skill` field equals `dirName`.
65
+ */
66
+ export function validateSkill(skill, sharedRefs = new Set()) {
67
+ const { dirName, data, body } = skill;
68
+ const errors = [];
69
+
70
+ /** Push one error attributed to this skill's dirName. */
71
+ const err = (message) => errors.push({ skill: dirName, message });
72
+
73
+ // ── NAME (cascade — D-08, D-09, D-13) ─────────────────────────────────────
74
+ // Stop at the first failure. Do NOT accumulate multiple name errors.
75
+ const name = data.name;
76
+ if (!name) {
77
+ // Step 1: missing / empty / falsy name (false, 0, "", null, undefined)
78
+ err("name is required");
79
+ } else if (typeof name !== "string") {
80
+ // Step 2: non-string truthy name (e.g. YAML boolean true, number 123, array, object).
81
+ // Must guard BEFORE NAME_KEBAB.test() and RESERVED.includes() — both coerce and
82
+ // may throw (`.includes` is not defined on booleans). D-01: never throw. (REVIEW-01)
83
+ err(`name must be a string (got ${typeof name})`);
84
+ } else if (!NAME_KEBAB.test(name)) {
85
+ // Step 3: not letter-start kebab-case (D-08)
86
+ err(
87
+ `name must be letter-start kebab-case (/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/): "${name}"`
88
+ );
89
+ } else if (name.length > 64) {
90
+ // Step 3: name exceeds maximum length of 64 characters (D-03)
91
+ err(`name must not exceed 64 characters (got ${name.length}): "${name}"`);
92
+ } else if (RESERVED.some((r) => name.includes(r))) {
93
+ // Step 4: contains a reserved substring (D-09)
94
+ err(
95
+ `name must not contain the reserved substrings "anthropic" or "claude": "${name}"`
96
+ );
97
+ } else if (name !== dirName) {
98
+ // Step 5: name does not match folder
99
+ err(`name "${name}" must equal its folder name "${dirName}"`);
100
+ }
101
+
102
+ // ── DESCRIPTION (LINT-01, independent) ────────────────────────────────────
103
+ if (!data.description) {
104
+ err("description is required");
105
+ } else {
106
+ // Both checks run independently — neither guards the other (D-13).
107
+ // Guards are inside the else branch so a falsy description cannot throw (D-01).
108
+ if (data.description.length > 1024) {
109
+ // D-03: description must not exceed 1024 characters
110
+ err(
111
+ `description must not exceed 1024 characters (got ${data.description.length})`
112
+ );
113
+ }
114
+ // D-05: description must not contain XML-tag shapes. Pattern matches only
115
+ // real tag-like constructs (optional leading /, letter-led tag name,
116
+ // optional trailing whitespace + optional self-close /, then >) so that
117
+ // ordinary comparison/math prose like "a<b and b>c" is NOT flagged.
118
+ // Adjacent quantifiers cover disjoint character classes ([a-zA-Z0-9-] vs \s)
119
+ // — no catastrophic backtracking (T-Q-01). (REVIEW-04)
120
+ if (/<\/?[a-zA-Z][a-zA-Z0-9-]*\s*\/?>/.test(data.description)) {
121
+ err("description must not contain XML tags (e.g. <example>)");
122
+ }
123
+ }
124
+
125
+ // ── AUDIENCE (LINT-03, D-11, independent) ─────────────────────────────────
126
+ // Missing and invalid values both produce the same error message (D-11).
127
+ if (data.audience !== "public" && data.audience !== "private") {
128
+ err("audience must be public|private");
129
+ }
130
+
131
+ // ── BODY SPINE (LINT-04, D-12) — two INDEPENDENT checks ──────────────────
132
+ const bodyStr = body || "";
133
+ const bodyLines = bodyStr.split("\n");
134
+
135
+ // Title check: the first non-blank line must be an H1 ("# " + non-space char).
136
+ // Regex is anchored and linear-time (T-02-01).
137
+ const firstNonBlankLine = bodyLines.find((l) => l.trim() !== "");
138
+ if (!firstNonBlankLine || !/^# \S/.test(firstNonBlankLine)) {
139
+ err(
140
+ "body must begin with an H1 title line (# Title) as its first non-blank line"
141
+ );
142
+ }
143
+
144
+ // Role check: body must contain at least one line starting with "**Role:".
145
+ // Anchored multiline regex; `^` matches start of any line with the `m` flag (T-02-01).
146
+ if (!/^\*\*Role:/m.test(bodyStr)) {
147
+ err("body must contain a **Role:** line");
148
+ }
149
+
150
+ // ── SHARED_REFERENCES (LINT-05, D-10) — each entry is independent ─────────
151
+ // For each entry, the safe-basename check runs FIRST (D-10): if the entry
152
+ // contains "/" or ".", it is immediately flagged as an unsafe path reference
153
+ // and the membership check is skipped for that entry. This prevents a
154
+ // malicious reference from escaping shared/references/ in the build step
155
+ // (T-02-02).
156
+ const refs = Array.isArray(data.shared_references) ? data.shared_references : [];
157
+ for (const entry of refs) {
158
+ if (typeof entry === "string" && (entry.includes("/") || entry.includes("."))) {
159
+ // Unsafe basename: contains a path separator or extension dot.
160
+ err(
161
+ `shared_references entry "${entry}" is an unsafe basename (must not contain "/" or ".")`
162
+ );
163
+ } else if (!sharedRefs.has(entry)) {
164
+ // Safe basename but not in the available set.
165
+ err(`shared_references entry "${entry}" not found in shared/references/`);
166
+ }
167
+ }
168
+
169
+ return errors;
170
+ }