@icntswm/skillcheck 0.1.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 +149 -0
- package/dist/agents/claude.js +384 -0
- package/dist/agents/types.js +1 -0
- package/dist/batch.js +111 -0
- package/dist/budget.js +29 -0
- package/dist/cases.js +249 -0
- package/dist/cli.js +673 -0
- package/dist/confusion.js +45 -0
- package/dist/describe.js +282 -0
- package/dist/judge.js +53 -0
- package/dist/junit.js +84 -0
- package/dist/lexical.js +117 -0
- package/dist/lint.js +51 -0
- package/dist/pool.js +35 -0
- package/dist/report.js +93 -0
- package/dist/results.js +48 -0
- package/dist/version.js +7 -0
- package/package.json +47 -0
package/dist/budget.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running spend estimate for --budget. Runs killed by early-stop never report
|
|
3
|
+
* a cost, so unknown costs are estimated at the average known cost (0 until
|
|
4
|
+
* the first known cost arrives); without this the budget could never trigger.
|
|
5
|
+
*/
|
|
6
|
+
export class Budget {
|
|
7
|
+
limitUsd;
|
|
8
|
+
knownSum = 0;
|
|
9
|
+
knownCount = 0;
|
|
10
|
+
unknownCount = 0;
|
|
11
|
+
constructor(limitUsd) {
|
|
12
|
+
this.limitUsd = limitUsd;
|
|
13
|
+
}
|
|
14
|
+
add(cost) {
|
|
15
|
+
if (cost === null)
|
|
16
|
+
this.unknownCount++;
|
|
17
|
+
else {
|
|
18
|
+
this.knownSum += cost;
|
|
19
|
+
this.knownCount++;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
get spent() {
|
|
23
|
+
const avg = this.knownCount > 0 ? this.knownSum / this.knownCount : 0;
|
|
24
|
+
return this.knownSum + this.unknownCount * avg;
|
|
25
|
+
}
|
|
26
|
+
get exceeded() {
|
|
27
|
+
return this.spent >= this.limitUsd;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/cases.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { commandNames, skillDirNames, skillRoots } from "./describe.js";
|
|
6
|
+
export class ConfigError extends Error {
|
|
7
|
+
errors;
|
|
8
|
+
constructor(errors) {
|
|
9
|
+
super(errors.join("; "));
|
|
10
|
+
this.errors = errors;
|
|
11
|
+
this.name = "ConfigError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const CASE_KEYS = [
|
|
15
|
+
"query", "expect", "expect_any", "forbid", "first",
|
|
16
|
+
"none", "note", "repeat", "threshold", "agents", "id",
|
|
17
|
+
];
|
|
18
|
+
const SUITE_KEYS = ["agent", "model", "repeat", "threshold", "cases"];
|
|
19
|
+
const DEFAULTS = { agent: "claude", repeat: 1, threshold: 1.0 };
|
|
20
|
+
export const BUILTIN_SKILLS = [
|
|
21
|
+
"code-review", "simplify", "security-review", "init", "loop", "schedule",
|
|
22
|
+
"update-config", "keybindings-help", "fewer-permission-prompts",
|
|
23
|
+
"claude-api", "run",
|
|
24
|
+
// seen in the init event of a clean config dir, verified by hand
|
|
25
|
+
"deep-research", "design", "design-sync", "dataviz", "verify", "debug",
|
|
26
|
+
"batch", "doctor", "workflow-authoring", "run-skill-generator",
|
|
27
|
+
];
|
|
28
|
+
function isObject(v) {
|
|
29
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
function isNonEmptyString(v) {
|
|
32
|
+
return typeof v === "string" && v.length > 0;
|
|
33
|
+
}
|
|
34
|
+
function isStringArray(v) {
|
|
35
|
+
return Array.isArray(v) && v.every(isNonEmptyString);
|
|
36
|
+
}
|
|
37
|
+
/** Collect all problems, index-prefixed per case; throws one ConfigError at the end. */
|
|
38
|
+
export function parseSuite(data) {
|
|
39
|
+
const errors = [];
|
|
40
|
+
const legacy = Array.isArray(data);
|
|
41
|
+
let rawCases = [];
|
|
42
|
+
const suite = { agent: DEFAULTS.agent, repeat: DEFAULTS.repeat, threshold: DEFAULTS.threshold, cases: [] };
|
|
43
|
+
if (legacy) {
|
|
44
|
+
rawCases = data;
|
|
45
|
+
if (rawCases.length === 0)
|
|
46
|
+
errors.push("no cases");
|
|
47
|
+
}
|
|
48
|
+
else if (isObject(data)) {
|
|
49
|
+
for (const key of Object.keys(data)) {
|
|
50
|
+
if (!SUITE_KEYS.includes(key))
|
|
51
|
+
errors.push(`unknown key "${key}"`);
|
|
52
|
+
}
|
|
53
|
+
if (typeof data.agent !== "undefined") {
|
|
54
|
+
if (typeof data.agent === "string")
|
|
55
|
+
suite.agent = data.agent;
|
|
56
|
+
else
|
|
57
|
+
errors.push("agent must be a string");
|
|
58
|
+
}
|
|
59
|
+
if (typeof data.model !== "undefined") {
|
|
60
|
+
if (typeof data.model === "string")
|
|
61
|
+
suite.model = data.model;
|
|
62
|
+
else
|
|
63
|
+
errors.push("model must be a string");
|
|
64
|
+
}
|
|
65
|
+
if (typeof data.repeat !== "undefined") {
|
|
66
|
+
if (isPositiveInt(data.repeat))
|
|
67
|
+
suite.repeat = data.repeat;
|
|
68
|
+
else
|
|
69
|
+
errors.push("repeat must be an integer >= 1");
|
|
70
|
+
}
|
|
71
|
+
if (typeof data.threshold !== "undefined") {
|
|
72
|
+
if (isThreshold(data.threshold))
|
|
73
|
+
suite.threshold = data.threshold;
|
|
74
|
+
else
|
|
75
|
+
errors.push("threshold must be a number with 0 < x <= 1");
|
|
76
|
+
}
|
|
77
|
+
if (typeof data.cases === "undefined")
|
|
78
|
+
errors.push("cases is required");
|
|
79
|
+
else if (!Array.isArray(data.cases) || data.cases.length === 0)
|
|
80
|
+
errors.push("cases must be a non-empty array");
|
|
81
|
+
else
|
|
82
|
+
rawCases = data.cases;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
throw new ConfigError(["cases file must be an array of cases or an object with a cases key"]);
|
|
86
|
+
}
|
|
87
|
+
const seenIds = new Set();
|
|
88
|
+
rawCases.forEach((raw, i) => {
|
|
89
|
+
const idx = i + 1;
|
|
90
|
+
const c = validateCase(raw, idx, errors);
|
|
91
|
+
if (!c)
|
|
92
|
+
return;
|
|
93
|
+
if (typeof c.id === "string") {
|
|
94
|
+
if (seenIds.has(c.id))
|
|
95
|
+
errors.push(`#${idx}: duplicate id "${c.id}"`);
|
|
96
|
+
seenIds.add(c.id);
|
|
97
|
+
}
|
|
98
|
+
suite.cases.push(c);
|
|
99
|
+
});
|
|
100
|
+
if (errors.length > 0)
|
|
101
|
+
throw new ConfigError(errors);
|
|
102
|
+
return suite;
|
|
103
|
+
}
|
|
104
|
+
function isPositiveInt(v) {
|
|
105
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 1;
|
|
106
|
+
}
|
|
107
|
+
function isThreshold(v) {
|
|
108
|
+
return typeof v === "number" && v > 0 && v <= 1;
|
|
109
|
+
}
|
|
110
|
+
function validateCase(raw, idx, errors) {
|
|
111
|
+
if (!isObject(raw)) {
|
|
112
|
+
errors.push(`#${idx}: case must be an object`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
for (const key of Object.keys(raw)) {
|
|
116
|
+
if (!CASE_KEYS.includes(key))
|
|
117
|
+
errors.push(`#${idx}: unknown key "${key}"`);
|
|
118
|
+
}
|
|
119
|
+
const c = {
|
|
120
|
+
index: idx, query: "", expect: [], expect_any: [], forbid: [], none: false,
|
|
121
|
+
};
|
|
122
|
+
let bad = false;
|
|
123
|
+
const fail = (msg) => { errors.push(`#${idx}: ${msg}`); bad = true; };
|
|
124
|
+
if (typeof raw.query === "undefined")
|
|
125
|
+
fail("query is required");
|
|
126
|
+
else if (!isNonEmptyString(raw.query))
|
|
127
|
+
fail("query must be a non-empty string");
|
|
128
|
+
else
|
|
129
|
+
c.query = raw.query;
|
|
130
|
+
for (const key of ["expect", "expect_any", "forbid", "agents"]) {
|
|
131
|
+
const v = raw[key];
|
|
132
|
+
if (typeof v === "undefined")
|
|
133
|
+
continue;
|
|
134
|
+
if (!isStringArray(v)) {
|
|
135
|
+
fail(`${key} must be an array of non-empty strings`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
c[key] = [...v];
|
|
139
|
+
}
|
|
140
|
+
for (const key of ["first", "note", "id"]) {
|
|
141
|
+
const v = raw[key];
|
|
142
|
+
if (typeof v === "undefined")
|
|
143
|
+
continue;
|
|
144
|
+
if (typeof v !== "string" || (key === "first" && v.length === 0)) {
|
|
145
|
+
fail(`${key} must be a string`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
c[key] = v;
|
|
149
|
+
}
|
|
150
|
+
if (typeof raw.none !== "undefined") {
|
|
151
|
+
if (typeof raw.none !== "boolean")
|
|
152
|
+
fail("none must be a boolean");
|
|
153
|
+
else
|
|
154
|
+
c.none = raw.none;
|
|
155
|
+
}
|
|
156
|
+
if (typeof raw.repeat !== "undefined") {
|
|
157
|
+
if (isPositiveInt(raw.repeat))
|
|
158
|
+
c.repeat = raw.repeat;
|
|
159
|
+
else
|
|
160
|
+
fail("repeat must be an integer >= 1");
|
|
161
|
+
}
|
|
162
|
+
if (typeof raw.threshold !== "undefined") {
|
|
163
|
+
if (isThreshold(raw.threshold))
|
|
164
|
+
c.threshold = raw.threshold;
|
|
165
|
+
else
|
|
166
|
+
fail("threshold must be a number with 0 < x <= 1");
|
|
167
|
+
}
|
|
168
|
+
const hasExpect = c.expect.length > 0;
|
|
169
|
+
const hasAny = c.expect_any.length > 0;
|
|
170
|
+
const hasForbid = c.forbid.length > 0;
|
|
171
|
+
if (!hasExpect && !hasAny && !hasForbid && !c.first && !c.none) {
|
|
172
|
+
fail("at least one of expect, expect_any, forbid, first, none is required");
|
|
173
|
+
}
|
|
174
|
+
if (c.none && (hasExpect || hasAny || c.first)) {
|
|
175
|
+
fail('none: true contradicts expect/expect_any/first');
|
|
176
|
+
}
|
|
177
|
+
const wanted = new Set([...c.expect, ...c.expect_any, ...(c.first ? [c.first] : [])]);
|
|
178
|
+
for (const name of c.forbid) {
|
|
179
|
+
if (wanted.has(name))
|
|
180
|
+
fail(`"${name}" is both expected and forbidden`);
|
|
181
|
+
}
|
|
182
|
+
return bad ? null : c;
|
|
183
|
+
}
|
|
184
|
+
/** Read a cases file (.json / .yaml / .yml) and parse it. */
|
|
185
|
+
export async function loadSuite(filePath) {
|
|
186
|
+
let text;
|
|
187
|
+
try {
|
|
188
|
+
text = await readFile(filePath, "utf8");
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
throw new ConfigError([`cannot read ${filePath}: ${e.message}`]);
|
|
192
|
+
}
|
|
193
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
194
|
+
let data;
|
|
195
|
+
try {
|
|
196
|
+
if (ext === ".json")
|
|
197
|
+
data = JSON.parse(text);
|
|
198
|
+
else if (ext === ".yaml" || ext === ".yml")
|
|
199
|
+
data = parseYaml(text);
|
|
200
|
+
else
|
|
201
|
+
throw new ConfigError([`unsupported file format "${ext || filePath}": use .json, .yaml or .yml`]);
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
if (e instanceof ConfigError)
|
|
205
|
+
throw e;
|
|
206
|
+
throw new ConfigError([`cannot parse ${filePath}: ${e.message}`]);
|
|
207
|
+
}
|
|
208
|
+
return parseSuite(data);
|
|
209
|
+
}
|
|
210
|
+
export function findDefaultCasesFile(cwd) {
|
|
211
|
+
for (const name of ["skillcheck.yaml", "skillcheck.yml", "skillcheck.json"]) {
|
|
212
|
+
const p = path.join(cwd, name);
|
|
213
|
+
try {
|
|
214
|
+
if (fs.statSync(p).isFile())
|
|
215
|
+
return p;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// missing, keep looking
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
/** Names of skills/commands discoverable on disk, plus built-in slash skills. */
|
|
224
|
+
export function knownSkillNames(opts) {
|
|
225
|
+
const out = new Set(BUILTIN_SKILLS);
|
|
226
|
+
for (const root of skillRoots(opts)) {
|
|
227
|
+
for (const name of skillDirNames(path.join(root, "skills")))
|
|
228
|
+
out.add(name);
|
|
229
|
+
for (const name of commandNames(path.join(root, "commands")))
|
|
230
|
+
out.add(name);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
const NAME_FIELDS = ["expect", "expect_any", "forbid", "first"];
|
|
235
|
+
export function unknownNames(suite, known) {
|
|
236
|
+
const errors = [];
|
|
237
|
+
for (const c of suite.cases) {
|
|
238
|
+
for (const field of NAME_FIELDS) {
|
|
239
|
+
const names = field === "first" ? (c.first ? [c.first] : []) : c[field];
|
|
240
|
+
for (const name of names) {
|
|
241
|
+
if (name.includes(":"))
|
|
242
|
+
continue; // plugin skills are not checked
|
|
243
|
+
if (!known.has(name))
|
|
244
|
+
errors.push(`#${c.index}: unknown skill "${name}" in ${field}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return errors;
|
|
249
|
+
}
|