@jmtrin/kevin-core 1.3.0 → 1.5.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.
@@ -0,0 +1,236 @@
1
+ // K15-002 — spec-subset validator (plan §4.3, D15-07)
2
+ // Naive YAML subset: `key: value` + one-level map for metadata + folded scalars (>-, >, |)
3
+ // Returns {ok, errors[], warnings[]} — hard rules -> errors, soft -> warnings.
4
+ const NAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
5
+ const MAX_NAME_LEN = 64;
6
+ const MIN_DESC = 1;
7
+ const MAX_DESC = 1024;
8
+ const MAX_BODY_LINES = 500;
9
+ export function validateSkill(content, dirname) {
10
+ const errors = [];
11
+ const warnings = [];
12
+ const normalized = content.replace(/\r\n/g, "\n");
13
+ const lines = normalized.split("\n");
14
+ // frontmatter must start with ---
15
+ if (lines.length === 0 || lines[0].trim() !== "---") {
16
+ errors.push("frontmatter: missing opening '---'");
17
+ return { ok: false, errors, warnings };
18
+ }
19
+ // find closing ---
20
+ let closeIdx = -1;
21
+ for (let i = 1; i < lines.length; i++) {
22
+ if (lines[i].trim() === "---") {
23
+ closeIdx = i;
24
+ break;
25
+ }
26
+ }
27
+ if (closeIdx === -1) {
28
+ errors.push("frontmatter: missing closing '---'");
29
+ return { ok: false, errors, warnings };
30
+ }
31
+ const fmLines = lines.slice(1, closeIdx);
32
+ const bodyLines = lines.slice(closeIdx + 1);
33
+ const body = bodyLines.join("\n");
34
+ const bodyTrimmed = body.trim();
35
+ // parse frontmatter naive
36
+ const fm = {};
37
+ let currentParent = null;
38
+ let foldedKey = null;
39
+ let foldedBuffer = [];
40
+ // helper to flush folded
41
+ function flushFolded() {
42
+ if (foldedKey !== null) {
43
+ const joined = foldedBuffer.join(" ").trim();
44
+ if (currentParent === "metadata" && foldedKey) {
45
+ // inside metadata? folded not expected, but handle
46
+ const meta = fm["metadata"];
47
+ meta[foldedKey] = joined;
48
+ }
49
+ else if (foldedKey) {
50
+ fm[foldedKey] = joined;
51
+ }
52
+ foldedKey = null;
53
+ foldedBuffer = [];
54
+ }
55
+ }
56
+ for (let idx = 0; idx < fmLines.length; idx++) {
57
+ const raw = fmLines[idx];
58
+ // if we are in folded collection, indented lines are continuation
59
+ if (foldedKey !== null) {
60
+ if (/^\s+/.test(raw) && raw.trim() !== "") {
61
+ foldedBuffer.push(raw.trim());
62
+ continue;
63
+ }
64
+ else {
65
+ // end of folded block
66
+ flushFolded();
67
+ // fall through to parse current line normally
68
+ }
69
+ }
70
+ if (raw.trim() === "")
71
+ continue;
72
+ // indented means metadata child or continuation already handled
73
+ const indentMatch = raw.match(/^(\s+)(.*)$/);
74
+ if (indentMatch && indentMatch[1].length >= 2) {
75
+ const inner = indentMatch[2];
76
+ if (currentParent !== "metadata") {
77
+ errors.push(`frontmatter: unexpected indent at line ${idx + 2}`);
78
+ continue;
79
+ }
80
+ const m = inner.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
81
+ if (!m) {
82
+ errors.push(`frontmatter: invalid metadata line '${raw.trim()}'`);
83
+ continue;
84
+ }
85
+ const k = m[1];
86
+ const v = m[2].trim();
87
+ // strip surrounding quotes if present for storage but keep raw for type check
88
+ const stored = stripQuotes(v);
89
+ // detect folded for metadata? not needed
90
+ const meta = fm["metadata"];
91
+ // keep raw detection for non-string check: if v is unquoted number/boolean
92
+ // we still store but flag later; store raw trimmed
93
+ meta[k] = stored;
94
+ // also keep raw for validation via separate map
95
+ // we store raw in a hidden map
96
+ if (!fm.__metaRaw)
97
+ fm.__metaRaw = {};
98
+ fm.__metaRaw[k] = v;
99
+ continue;
100
+ }
101
+ // top-level key: value
102
+ const m = raw.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
103
+ if (!m) {
104
+ errors.push(`frontmatter: invalid line '${raw.trim()}'`);
105
+ continue;
106
+ }
107
+ const key = m[1];
108
+ const val = m[2].trim();
109
+ // handle folded scalar indicators
110
+ if (val === ">-" || val === ">" || val === "|" || val === "|-") {
111
+ foldedKey = key;
112
+ foldedBuffer = [];
113
+ // initialize parent tracking if key is metadata (but folded metadata not expected)
114
+ if (key === "metadata") {
115
+ // metadata with folded? treat as empty then parent
116
+ fm[key] = {};
117
+ currentParent = "metadata";
118
+ }
119
+ else {
120
+ currentParent = null;
121
+ fm[key] = ""; // placeholder, will be filled on flush
122
+ }
123
+ continue;
124
+ }
125
+ if (key === "metadata") {
126
+ // metadata: may be empty (map follows) or inline? We support only map form.
127
+ const normalizedVal = val.trim();
128
+ const compactVal = normalizedVal.replace(/\s/g, "");
129
+ if (normalizedVal === "" || normalizedVal === "{}" || compactVal === "{}") {
130
+ fm[key] = {};
131
+ currentParent = "metadata";
132
+ // init raw map
133
+ fm.__metaRaw = {};
134
+ }
135
+ else {
136
+ errors.push("frontmatter: metadata must be a map");
137
+ fm[key] = val;
138
+ currentParent = null;
139
+ }
140
+ continue;
141
+ }
142
+ // normal key
143
+ currentParent = null;
144
+ flushFolded();
145
+ fm[key] = stripQuotes(val);
146
+ // store raw for description length? raw stripped is fine
147
+ }
148
+ flushFolded();
149
+ // --- validation rules ---
150
+ // name
151
+ const name = fm["name"];
152
+ if (name === undefined || (typeof name === "string" && name.trim() === "")) {
153
+ errors.push("name: missing");
154
+ }
155
+ else if (typeof name !== "string") {
156
+ errors.push("name: must be a string");
157
+ }
158
+ else {
159
+ const n = name.trim();
160
+ if (n.length < 1 || n.length > MAX_NAME_LEN) {
161
+ errors.push(`name: length must be 1-${MAX_NAME_LEN} (got ${n.length})`);
162
+ }
163
+ if (!NAME_RE.test(n)) {
164
+ errors.push(`name: invalid format '${n}' (must match ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$)`);
165
+ }
166
+ if (n.includes("--")) {
167
+ errors.push("name: must not contain '--'");
168
+ }
169
+ if (dirname !== undefined && n !== dirname) {
170
+ errors.push(`name: must equal directory name '${dirname}' (got '${n}')`);
171
+ }
172
+ }
173
+ // description
174
+ const desc = fm["description"];
175
+ if (desc === undefined || (typeof desc === "string" && desc.trim() === "")) {
176
+ errors.push("description: missing or empty");
177
+ }
178
+ else if (typeof desc !== "string") {
179
+ errors.push("description: must be a string");
180
+ }
181
+ else {
182
+ const d = desc.trim();
183
+ if (d.length < MIN_DESC || d.length > MAX_DESC) {
184
+ errors.push(`description: length must be 1-${MAX_DESC} (got ${d.length})`);
185
+ }
186
+ }
187
+ // metadata values all strings - check raw entries for non-string literals
188
+ if (fm["metadata"] !== undefined) {
189
+ const meta = fm["metadata"];
190
+ const rawMap = fm.__metaRaw ?? {};
191
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) {
192
+ errors.push("metadata: must be a map of strings");
193
+ }
194
+ else {
195
+ for (const [k, v] of Object.entries(meta)) {
196
+ if (typeof v !== "string") {
197
+ errors.push(`metadata: value for '${k}' must be a string`);
198
+ }
199
+ else {
200
+ const raw = rawMap[k] ?? v;
201
+ // raw is the stored trimmed value without quotes as appears after colon
202
+ // If raw is numeric / boolean / null without quotes, treat as non-string
203
+ if (/^-?\d+(\.\d+)?$/.test(raw) || raw === "true" || raw === "false" || raw === "null") {
204
+ errors.push(`metadata: value for '${k}' must be a string (got '${raw}')`);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ // body present
211
+ if (bodyTrimmed === "") {
212
+ errors.push("body: missing");
213
+ }
214
+ else {
215
+ const bodyLineCount = bodyTrimmed.split("\n").length;
216
+ if (bodyLineCount > MAX_BODY_LINES) {
217
+ warnings.push(`body: exceeds ${MAX_BODY_LINES} lines (got ${bodyLineCount})`);
218
+ }
219
+ }
220
+ const ok = errors.length === 0;
221
+ return { ok, errors, warnings };
222
+ }
223
+ function stripQuotes(s) {
224
+ const t = s.trim();
225
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
226
+ return t.slice(1, -1);
227
+ }
228
+ return t;
229
+ }
230
+ // helper for file-based validation (reads file and passes dirname)
231
+ export function validateSkillFile(filePath, content) {
232
+ const parts = filePath.replace(/\\/g, "/").split("/");
233
+ // dirname is parent dir name: .../<dirname>/SKILL.md
234
+ const dir = parts.length >= 2 ? parts[parts.length - 2] : "";
235
+ return validateSkill(content, dir);
236
+ }
@@ -31,7 +31,7 @@ class NodeSqliteAdapter {
31
31
  }
32
32
  transaction(fn) {
33
33
  return () => {
34
- this.db.exec("BEGIN");
34
+ this.db.exec("BEGIN IMMEDIATE");
35
35
  try {
36
36
  const result = fn();
37
37
  this.db.exec("COMMIT");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmtrin/kevin-core",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Kevin core — host-agnostic deterministic brain (Bedrock)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",