@uinaf/skillcheck 1.4.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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { lintSkills } from "./lint.js";
3
2
  import { encodeRunNamePart, generateRun, requiredEvalPackages, resolvePackageDir, runNameFor } from "./scenario.js";
3
+ import { lintSkills } from "./lint.js";
4
4
  import { execFileSync, spawnSync } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import fs from "node:fs";
@@ -357,6 +357,17 @@ function readJson(file) {
357
357
  return;
358
358
  }
359
359
  }
360
+ function liveScenarios(root) {
361
+ const cli = path.join(root, "cli");
362
+ if (!(fs.existsSync(path.join(root, "skills")) || fs.existsSync(cli) && fs.readdirSync(cli).some((d) => fs.existsSync(path.join(cli, d, "skills"))))) return void 0;
363
+ return new Set(discoverScenarios(root).map((dir) => {
364
+ const parts = dir.split(path.sep);
365
+ return `${parts.at(-3)}\0${parts.at(-1)}`;
366
+ }));
367
+ }
368
+ function isLive(live, e) {
369
+ return live === void 0 || live.has(`${e.skill}\0${e.scenario}`);
370
+ }
360
371
  function discoverScenarios(root) {
361
372
  const roots = [path.join(root, "skills")];
362
373
  const cliDir = path.join(root, "cli");
@@ -464,15 +475,26 @@ function resultIdentity(file, dir) {
464
475
  variant
465
476
  };
466
477
  }
467
- function reduceResults(dir, allowMixed) {
478
+ function reduceResults(dir, allowMixed, live) {
468
479
  const entries = [];
469
480
  const skipped = [];
481
+ const retired = /* @__PURE__ */ new Set();
470
482
  const gradedAt = /* @__PURE__ */ new Map();
471
483
  const shas = /* @__PURE__ */ new Set();
472
484
  const files = fs.readdirSync(dir);
473
485
  const incomplete = new Set(files.filter((f) => f.endsWith(".json.attempt")).map((f) => f.replace(/\.attempt$/, "")));
474
486
  const results = files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json"));
475
487
  for (const f of [.../* @__PURE__ */ new Set([...results, ...incomplete])].sort()) {
488
+ let identity;
489
+ try {
490
+ identity = resultIdentity(f, dir);
491
+ } catch {
492
+ identity = void 0;
493
+ }
494
+ if (identity !== void 0 && identity.scenario !== "" && !isLive(live, identity)) {
495
+ retired.add(entryKey(identity));
496
+ continue;
497
+ }
476
498
  if (f.endsWith("--cursor.json")) {
477
499
  console.error(`skipping ${f}: Cursor harness is retired`);
478
500
  skipped.push(f);
@@ -522,7 +544,8 @@ function reduceResults(dir, allowMixed) {
522
544
  treeSha: shas.size === 1 ? [...shas][0] : shas.size === 0 ? "none" : "mixed",
523
545
  entries,
524
546
  skipped,
525
- gradedAt
547
+ gradedAt,
548
+ retired
526
549
  };
527
550
  }
528
551
  function entryKey(e) {
@@ -565,12 +588,17 @@ function readExistingScorecard(out) {
565
588
  function cmdSummarize(argv) {
566
589
  const { positional, flags } = parseArgs(argv);
567
590
  if (positional.length > 0) fail("usage: skillcheck summarize [--root DIR] [--allow-mixed]");
568
- const dirs = stateDirs(resolveRoot(flags));
591
+ const root = resolveRoot(flags);
592
+ const dirs = stateDirs(root);
569
593
  if (!fs.existsSync(dirs.results)) fail(`no results directory at ${dirs.results}; run some evals first`);
570
- const { entries, skipped, gradedAt } = reduceResults(dirs.results, flags.get("--allow-mixed") === true);
594
+ const live = liveScenarios(root);
595
+ const { entries, skipped, gradedAt, retired: retiredResults } = reduceResults(dirs.results, flags.get("--allow-mixed") === true, live);
571
596
  fs.mkdirSync(dirs.scorecards, { recursive: true });
572
597
  const out = path.join(dirs.scorecards, `${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.json`);
573
- const existing = readExistingScorecard(out);
598
+ const previous = readExistingScorecard(out);
599
+ const existing = previous.filter((e) => isLive(live, e));
600
+ for (const e of previous) if (!isLive(live, e)) retiredResults.add(entryKey(e));
601
+ const retired = retiredResults.size;
574
602
  const skippedKeys = new Set(skipped.map((file) => ({
575
603
  key: entryKey(resultIdentity(file, dirs.results)),
576
604
  modifiedAt: fs.statSync(fs.existsSync(path.join(dirs.results, `${file}.attempt`)) ? path.join(dirs.results, `${file}.attempt`) : path.join(dirs.results, file)).mtimeMs
@@ -587,6 +615,7 @@ function cmdSummarize(argv) {
587
615
  scenarios: merged.entries
588
616
  };
589
617
  fs.writeFileSync(out, JSON.stringify(scorecard, null, 2) + "\n");
618
+ if (retired > 0) console.log(`dropped ${retired} row(s) for scenarios no longer in the tree`);
590
619
  const scenarios = merged.entries.filter((e) => e.variant !== "control");
591
620
  console.log(`${out}: ${scenarios.length} scenario(s), ${scenarios.filter((e) => e.pass).length} passing, ${merged.entries.length - scenarios.length} control(s), ${skipped.length} skipped file(s)`);
592
621
  if (existing.length > 0) console.log(`merged into today's scorecard: ${entries.length} from this run, ${merged.carried} carried over`);
@@ -688,4 +717,4 @@ if (isMainModule()) {
688
717
  }
689
718
  }
690
719
  //#endregion
691
- export { NOISY_SPREAD, aggregateTrials, assertUniformConfig, classifyResult, ensurePrivateDir, formatStats, mergeScorecard, parseArgs, parsePositiveInt, reduceResults, resolveRoot, resultRunConfig, runConfigOf, runOptions, stateDirs, summarizeSkills, toolVersion, treeShaOf };
720
+ export { NOISY_SPREAD, aggregateTrials, assertUniformConfig, classifyResult, ensurePrivateDir, formatStats, liveScenarios, mergeScorecard, parseArgs, parsePositiveInt, reduceResults, resolveRoot, resultRunConfig, runConfigOf, runOptions, stateDirs, summarizeSkills, toolVersion, treeShaOf };
package/dist/lint.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { FILE_BLOCK } from "./scenario.js";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  //#region src/lint.ts
@@ -61,19 +62,36 @@ function lintSkill(dir, root, errors) {
61
62
  function lintSkills(root) {
62
63
  const roots = ["skills", ...fs.globSync("cli/*/skills", { cwd: root })].map((r) => path.join(root, r));
63
64
  const errors = [];
64
- let count = 0;
65
+ const skills = [];
65
66
  for (const dir of roots) {
66
67
  if (!fs.existsSync(dir)) continue;
67
68
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
68
69
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
69
70
  lintSkill(path.join(dir, entry.name), root, errors);
70
- count++;
71
+ skills.push(path.join(dir, entry.name));
71
72
  }
72
73
  }
74
+ lintTasks(skills, root, errors);
73
75
  return {
74
76
  errors,
75
- count
77
+ count: skills.length
76
78
  };
77
79
  }
80
+ function lintTasks(skills, root, errors) {
81
+ const names = skills.map((dir) => path.basename(dir));
82
+ for (const dir of skills) {
83
+ const evals = path.join(dir, "evals");
84
+ if (!fs.existsSync(evals)) continue;
85
+ for (const scenario of fs.readdirSync(evals, { withFileTypes: true })) {
86
+ const file = path.join(evals, scenario.name, "task.md");
87
+ if (!scenario.isDirectory() || !fs.existsSync(file)) continue;
88
+ const text = fs.readFileSync(file, "utf8").replace(FILE_BLOCK, "");
89
+ for (const name of names) {
90
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ if (new RegExp(`(?<![\\w-])${escaped}(?![\\w-])`, "i").test(text)) errors.push(`${path.relative(root, file)}: task names skill ${name}`);
92
+ }
93
+ }
94
+ }
95
+ }
78
96
  //#endregion
79
97
  export { lintSkills };
package/dist/scenario.js CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  //#region src/scenario.ts
7
+ const FILE_BLOCK = /^=+ FILE: (.+?) =+\n([\s\S]*?)\n=+ END FILE =+$/gm;
7
8
  const DEFAULT_CLAUDE_AGENT = "claude-opus-5";
8
9
  function loadScenario(scenarioDir) {
9
10
  const match = scenarioDir.match(/skills\/([^/]+)\/evals\/([^/]+)$/);
@@ -15,7 +16,7 @@ function loadScenario(scenarioDir) {
15
16
  if (criteria.skill_use !== void 0 && criteria.skill_use !== "required" && criteria.skill_use !== "optional") throw new Error(`skill_use must be "required" or "optional" in ${scenarioDir}`);
16
17
  for (const item of criteria.checklist) if (!(typeof item?.name === "string" && item.name.trim() !== "" && typeof item?.description === "string" && item.description.trim() !== "" && Number.isFinite(item?.max_score) && item.max_score > 0)) throw new Error(`invalid checklist item in ${scenarioDir}: ${JSON.stringify(item)}`);
17
18
  const files = [];
18
- const task = taskMd.replace(/^=+ FILE: (.+?) =+\n([\s\S]*?)\n=+ END FILE =+$/gm, (_, name, content) => {
19
+ const task = taskMd.replace(FILE_BLOCK, (_, name, content) => {
19
20
  files.push({
20
21
  name: name.trim(),
21
22
  content: content + "\n"
@@ -322,4 +323,4 @@ function generateRun(scenarioDir, opts, paths) {
322
323
  };
323
324
  }
324
325
  //#endregion
325
- export { DEFAULT_CLAUDE_AGENT, buildConfig, encodeRunNamePart, generateRun, isHiddenSkill, loadScenario, materialize, privateCodexHome, requiredEvalPackages, resolvePackageDir, runNameFor, sdkNodeModulesDir, stripHiddenFlag, trialLabel };
326
+ export { DEFAULT_CLAUDE_AGENT, FILE_BLOCK, buildConfig, encodeRunNamePart, generateRun, isHiddenSkill, loadScenario, materialize, privateCodexHome, requiredEvalPackages, resolvePackageDir, runNameFor, sdkNodeModulesDir, stripHiddenFlag, trialLabel };
package/docs/scenarios.md CHANGED
@@ -35,6 +35,7 @@ own examiner or embed generated dependencies.
35
35
 
36
36
  Write the task the way a user would write it. Do not name the skill, describe
37
37
  its steps, or hint at the checklist: routing is part of what is being measured.
38
+ `skillcheck lint` rejects a task whose prose names any skill in the root.
38
39
 
39
40
  ## criteria.json
40
41
 
package/docs/usage.md CHANGED
@@ -20,6 +20,9 @@ Checks each `<root>/skills/<skill>/`:
20
20
  - `disable-model-invocation`, when present, is the bare YAML boolean `true`.
21
21
  A quoted `"true"` is an error
22
22
  - Relative links in the body resolve on disk
23
+ - No eval `task.md` names a skill from the root, outside its inline input
24
+ files. A named skill gives routing away, and a no-skill control can read
25
+ the operator's installed copy
23
26
 
24
27
  Code spans and fenced blocks are stripped before links are checked, so example
25
28
  links never fail. External schemes and `#anchors` pass. Dot-directories under
@@ -179,6 +182,11 @@ six of twenty-nine scenarios therefore leaves twenty-nine rows in the file, not
179
182
  six. A same-date file that cannot be parsed stops the write instead of being
180
183
  overwritten.
181
184
 
185
+ Rows for scenarios that no longer exist in the tree, whether carried from
186
+ today's scorecard or left in `results/`, are dropped and counted on stdout.
187
+ A root with no skills tree (`skills/` or `cli/*/skills/`) holds results only
188
+ and keeps every row.
189
+
182
190
  Files that are not promptfoo results and ungraded transport errors are skipped
183
191
  with a warning rather than failing the reduction. Graded assertion failures
184
192
  remain scored results. If a skipped file matches an existing scorecard row,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uinaf/skillcheck",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Lint and eval harness for agent skills",
5
5
  "homepage": "https://github.com/uinaf/skillcheck#readme",
6
6
  "bugs": {