@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.
- package/LICENSE +21 -0
- package/README.md +667 -0
- package/dist/ArtifactWriter.js +1 -1
- package/dist/InjectionLedger.d.ts +5 -2
- package/dist/InjectionLedger.js +132 -113
- package/dist/Materializer.d.ts +5 -0
- package/dist/Materializer.js +11 -0
- package/dist/MemoryService.js +93 -91
- package/dist/RepoIdentity.js +1 -1
- package/dist/Retrospective.js +10 -0
- package/dist/Store.d.ts +1 -0
- package/dist/Store.js +15 -2
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +31 -2
- package/dist/import-host.d.ts +41 -0
- package/dist/import-host.js +286 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +12 -1
- package/dist/kevin_audit.d.ts +31 -0
- package/dist/kevin_audit.js +94 -0
- package/dist/metrics.d.ts +1 -1
- package/dist/metrics.js +10 -0
- package/dist/mif.d.ts +32 -0
- package/dist/mif.js +112 -0
- package/dist/migrations/013_v14_bridge.sql +46 -0
- package/dist/perf.d.ts +1 -1
- package/dist/perf.js +2 -0
- package/dist/skills-emit.d.ts +38 -0
- package/dist/skills-emit.js +421 -0
- package/dist/skills-validate.d.ts +7 -0
- package/dist/skills-validate.js +236 -0
- package/dist/sqlite-adapter.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/sqlite-adapter.js
CHANGED