@skill-harness/core 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/dist/spec.js ADDED
@@ -0,0 +1,175 @@
1
+ import { readFileSync } from "node:fs";
2
+ import yaml from "js-yaml";
3
+ /** Thrown on any validation failure. Message always carries the spec file path. */
4
+ export class SpecError extends Error {
5
+ constructor(message, file) {
6
+ super(`${file}: ${message}`);
7
+ this.name = "SpecError";
8
+ }
9
+ }
10
+ function isStringArray(v) {
11
+ return Array.isArray(v) && v.every((x) => typeof x === "string");
12
+ }
13
+ /**
14
+ * Require a non-empty list of strings, with a targeted error. A common authoring
15
+ * trap: an unquoted "key: value" list item parses as a YAML mapping, not a string
16
+ * — call that out explicitly so the fix (quote the item) is obvious.
17
+ */
18
+ function assertStringList(v, id, field, file) {
19
+ if (!Array.isArray(v) || v.length === 0) {
20
+ throw new SpecError(`scenario \`${id}\` needs at least one \`${field}\` entry`, file);
21
+ }
22
+ const i = v.findIndex((x) => typeof x !== "string");
23
+ if (i >= 0) {
24
+ const bad = v[i];
25
+ const hint = bad !== null && typeof bad === "object"
26
+ ? ` — item #${i + 1} parsed as a YAML mapping; an unquoted ": " does that, so quote the item`
27
+ : ` — item #${i + 1} is not a string`;
28
+ throw new SpecError(`scenario \`${id}\` \`${field}\` items must all be strings${hint}`, file);
29
+ }
30
+ }
31
+ /** Resolve a scenario's `env.workspace` into a WorkspaceKind, applying defaults. */
32
+ function resolveWorkspace(env, mode, fixture, id, file) {
33
+ const raw = env && typeof env === "object" ? env.workspace : undefined;
34
+ if (raw === undefined) {
35
+ // Default: a seeded scenario runs in its fixture repo; everything else is bare.
36
+ if (mode === "seeded" && fixture)
37
+ return { fixture };
38
+ return "none";
39
+ }
40
+ if (raw === "none") {
41
+ if (mode === "seeded") {
42
+ throw new SpecError(`seeded scenario \`${id}\` cannot use env.workspace: none — seeded gates need a git repo ` +
43
+ `(omit env to use its fixture, or use empty-git/fixture:<path>)`, file);
44
+ }
45
+ return raw;
46
+ }
47
+ if (raw === "empty-git")
48
+ return raw;
49
+ if (typeof raw === "string" && raw.startsWith("fixture:")) {
50
+ const p = raw.slice("fixture:".length).trim();
51
+ if (!p)
52
+ throw new SpecError(`scenario \`${id}\` env.workspace fixture path is empty`, file);
53
+ return { fixture: p };
54
+ }
55
+ throw new SpecError(`scenario \`${id}\` env.workspace must be none | empty-git | fixture:<path>`, file);
56
+ }
57
+ /** Parse + validate a specification.yaml from its raw text. `file` is used in error messages. */
58
+ export function parseSpec(text, file) {
59
+ let doc;
60
+ try {
61
+ doc = yaml.load(text);
62
+ }
63
+ catch (e) {
64
+ throw new SpecError(`not valid YAML — ${e.message}`, file);
65
+ }
66
+ if (doc === null || typeof doc !== "object") {
67
+ throw new SpecError("spec must be a YAML mapping", file);
68
+ }
69
+ const o = doc;
70
+ if (typeof o.skill !== "string" || o.skill.length === 0) {
71
+ throw new SpecError("missing or invalid `skill` (string)", file);
72
+ }
73
+ if (typeof o.judge_persona !== "string" || o.judge_persona.length === 0) {
74
+ throw new SpecError("missing or invalid `judge_persona` (string)", file);
75
+ }
76
+ const sb = o.ship_bar;
77
+ if (!sb || typeof sb !== "object") {
78
+ throw new SpecError("missing `ship_bar` mapping", file);
79
+ }
80
+ if (typeof sb.total !== "number" || typeof sb.min_pass !== "number") {
81
+ throw new SpecError("`ship_bar` requires numeric `total` and `min_pass`", file);
82
+ }
83
+ const ship_bar = {
84
+ total: sb.total,
85
+ min_pass: sb.min_pass,
86
+ no_critical_fail: sb.no_critical_fail !== false, // default true
87
+ };
88
+ const critical = o.critical === undefined ? [] : o.critical;
89
+ if (!isStringArray(critical)) {
90
+ throw new SpecError("`critical` must be a list of scenario ids (strings)", file);
91
+ }
92
+ if (!Array.isArray(o.scenarios)) {
93
+ throw new SpecError("missing `scenarios` (list)", file);
94
+ }
95
+ const seen = new Set();
96
+ const scenarios = o.scenarios.map((raw, i) => {
97
+ if (raw === null || typeof raw !== "object") {
98
+ throw new SpecError(`scenario #${i + 1} is not a mapping`, file);
99
+ }
100
+ const s = raw;
101
+ const id = s.id;
102
+ if (typeof id !== "string" || id.length === 0) {
103
+ throw new SpecError(`scenario #${i + 1} missing \`id\` (string)`, file);
104
+ }
105
+ if (seen.has(id)) {
106
+ throw new SpecError(`duplicate scenario id \`${id}\``, file);
107
+ }
108
+ seen.add(id);
109
+ if (typeof s.title !== "string" || s.title.length === 0) {
110
+ throw new SpecError(`scenario \`${id}\` missing \`title\``, file);
111
+ }
112
+ const mode = s.mode === undefined ? "inline" : s.mode;
113
+ if (mode !== "inline" && mode !== "seeded") {
114
+ throw new SpecError(`scenario \`${id}\` has invalid \`mode\` (inline|seeded)`, file);
115
+ }
116
+ assertStringList(s.turns, id, "turns", file);
117
+ assertStringList(s.checklist, id, "checklist", file);
118
+ const critFlag = s.critical === true || critical.includes(id);
119
+ const scenario = {
120
+ id,
121
+ title: s.title,
122
+ critical: critFlag,
123
+ mode,
124
+ turns: s.turns,
125
+ checklist: s.checklist,
126
+ workspace: "none",
127
+ };
128
+ if (mode === "seeded") {
129
+ if (typeof s.fixture !== "string" || s.fixture.length === 0) {
130
+ throw new SpecError(`seeded scenario \`${id}\` requires a \`fixture\` path`, file);
131
+ }
132
+ scenario.fixture = s.fixture;
133
+ const a = s.assert;
134
+ if (a) {
135
+ const assertObj = {};
136
+ if (a.vitest !== undefined)
137
+ assertObj.vitest = a.vitest === true;
138
+ if (a.diff_contains !== undefined) {
139
+ if (!isStringArray(a.diff_contains)) {
140
+ throw new SpecError(`seeded scenario \`${id}\` \`assert.diff_contains\` must be strings`, file);
141
+ }
142
+ assertObj.diff_contains = a.diff_contains;
143
+ }
144
+ scenario.assert = assertObj;
145
+ }
146
+ }
147
+ scenario.workspace = resolveWorkspace(s.env, mode, scenario.fixture, id, file);
148
+ if (s.reps !== undefined) {
149
+ if (typeof s.reps !== "number" || !Number.isInteger(s.reps) || s.reps < 1) {
150
+ throw new SpecError(`scenario \`${id}\` \`reps\` must be a positive integer`, file);
151
+ }
152
+ scenario.reps = s.reps;
153
+ }
154
+ if (s.pass_threshold !== undefined) {
155
+ if (typeof s.pass_threshold !== "number" || s.pass_threshold < 0 || s.pass_threshold > 1) {
156
+ throw new SpecError(`scenario \`${id}\` \`pass_threshold\` must be a number in [0, 1]`, file);
157
+ }
158
+ scenario.passThreshold = s.pass_threshold;
159
+ }
160
+ return scenario;
161
+ });
162
+ return { skill: o.skill, judge_persona: o.judge_persona, ship_bar, critical, scenarios };
163
+ }
164
+ /** Load + validate a specification.yaml from disk. */
165
+ export function loadSpec(file) {
166
+ let text;
167
+ try {
168
+ text = readFileSync(file, "utf8");
169
+ }
170
+ catch (e) {
171
+ throw new SpecError(`cannot read spec file — ${e.message}`, file);
172
+ }
173
+ return parseSpec(text, file);
174
+ }
175
+ //# sourceMappingURL=spec.js.map
@@ -0,0 +1,54 @@
1
+ import { type ResultsFile } from "./results.js";
2
+ import type { Verdict } from "./score.js";
3
+ export interface TrendCell {
4
+ verdict: Verdict;
5
+ suspect: boolean;
6
+ flakiness?: number;
7
+ }
8
+ export interface TrendRun {
9
+ timestamp: string;
10
+ label: string | null;
11
+ grade: ResultsFile["effective_grade"];
12
+ cells: Record<string, TrendCell>;
13
+ }
14
+ export interface TrendModel {
15
+ model: string;
16
+ tag: string;
17
+ runs: TrendRun[];
18
+ truncated: boolean;
19
+ skipped: number;
20
+ }
21
+ export interface TrendData {
22
+ skill: string;
23
+ scenarios: {
24
+ id: string;
25
+ title: string;
26
+ critical: boolean;
27
+ }[];
28
+ models: TrendModel[];
29
+ }
30
+ /**
31
+ * Per model-tag, read the full run history (not just the latest) from
32
+ * <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
33
+ * correctly), keeping the most recent `limit` runs. Each run's cell carries the
34
+ * override-aware verdict + suspect (matching `effectiveVerdicts`'s canonical
35
+ * rule: an override resolves a misfire) + reps flakiness. Read-only; no
36
+ * absolute paths in the result.
37
+ *
38
+ * Only scored (mode === "green") runs are included in the history — a
39
+ * red/force run has no real grade (`effective_grade` is a "not scored"
40
+ * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
41
+ * the sparkline/grid. Non-green runs are deliberately excluded, which is
42
+ * distinct from `skipped`: a run's mode can only be known after reading its
43
+ * results.yaml, so every candidate run-dir in the tag is read (not just the
44
+ * most recent `limit`) before filtering to green and applying the `limit`
45
+ * window — trends is a bounded, on-demand, local view, so this extra read
46
+ * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
47
+ *
48
+ * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
49
+ * write) is logged via `console.warn` and skipped — never surfaced or thrown —
50
+ * and counted in that model's `skipped`. `truncated` reflects only the
51
+ * run-count cap on the green-run history (more green runs existed than
52
+ * `limit`), not parse-skips or mode-excluded runs.
53
+ */
54
+ export declare function collectTrends(skillDir: string, limit?: number): TrendData;
package/dist/trends.js ADDED
@@ -0,0 +1,102 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { loadSpec } from "./spec.js";
4
+ import { readResults, effectiveVerdicts } from "./results.js";
5
+ /** A directory that exists right now; false (never throws) if it vanished concurrently (e.g. ENOENT). */
6
+ function isDir(p) {
7
+ try {
8
+ return statSync(p).isDirectory();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /**
15
+ * Per model-tag, read the full run history (not just the latest) from
16
+ * <skillDir>/tests/results/, chronologically (timestamp-slug dir names sort
17
+ * correctly), keeping the most recent `limit` runs. Each run's cell carries the
18
+ * override-aware verdict + suspect (matching `effectiveVerdicts`'s canonical
19
+ * rule: an override resolves a misfire) + reps flakiness. Read-only; no
20
+ * absolute paths in the result.
21
+ *
22
+ * Only scored (mode === "green") runs are included in the history — a
23
+ * red/force run has no real grade (`effective_grade` is a "not scored"
24
+ * placeholder; see run.ts) and would otherwise plot as a misleading 0% dip in
25
+ * the sparkline/grid. Non-green runs are deliberately excluded, which is
26
+ * distinct from `skipped`: a run's mode can only be known after reading its
27
+ * results.yaml, so every candidate run-dir in the tag is read (not just the
28
+ * most recent `limit`) before filtering to green and applying the `limit`
29
+ * window — trends is a bounded, on-demand, local view, so this extra read
30
+ * cost is acceptable. If a tag has zero green runs, it's omitted entirely.
31
+ *
32
+ * A run whose `results.yaml` fails to parse (e.g. an interrupted non-atomic
33
+ * write) is logged via `console.warn` and skipped — never surfaced or thrown —
34
+ * and counted in that model's `skipped`. `truncated` reflects only the
35
+ * run-count cap on the green-run history (more green runs existed than
36
+ * `limit`), not parse-skips or mode-excluded runs.
37
+ */
38
+ export function collectTrends(skillDir, limit = 20) {
39
+ const specPath = join(skillDir, "tests", "specification.yaml");
40
+ const spec = loadSpec(specPath);
41
+ const scenarios = spec.scenarios.map((s) => ({ id: s.id, title: s.title, critical: s.critical }));
42
+ const resultsRoot = join(skillDir, "tests", "results");
43
+ const models = [];
44
+ if (existsSync(resultsRoot)) {
45
+ const tags = readdirSync(resultsRoot)
46
+ .filter((n) => isDir(join(resultsRoot, n)))
47
+ .sort();
48
+ for (const tag of tags) {
49
+ const tagDir = join(resultsRoot, tag);
50
+ const runDirs = readdirSync(tagDir)
51
+ .map((n) => join(tagDir, n))
52
+ .filter((p) => isDir(p) && existsSync(join(p, "results.yaml")))
53
+ .sort(); // timestamp-slug dir names ⇒ chronological ascending
54
+ if (runDirs.length === 0)
55
+ continue;
56
+ // Read every candidate run (mode isn't knowable from the dir name) and
57
+ // filter to green (scored) runs before applying the `limit` window —
58
+ // filtering after the slice would let red/force runs consume window
59
+ // slots, undercounting the green history even when more exists.
60
+ const greenRuns = [];
61
+ let skipped = 0;
62
+ for (const rd of runDirs) {
63
+ let r;
64
+ try {
65
+ r = readResults(rd);
66
+ }
67
+ catch (e) {
68
+ // A corrupt/truncated results.yaml must not take down the whole
69
+ // trends view — skip that run, but surface the failure.
70
+ console.warn(`skill-harness trends: skipping unreadable run ${rd}: ${e instanceof Error ? e.message : e}`);
71
+ skipped++;
72
+ continue;
73
+ }
74
+ if (r.mode !== "green")
75
+ continue; // not scored — deliberate exclusion, not a skip
76
+ greenRuns.push(r);
77
+ }
78
+ if (greenRuns.length === 0)
79
+ continue;
80
+ const truncated = greenRuns.length > limit;
81
+ const kept = greenRuns.slice(-limit); // most recent `limit`, newest last
82
+ const runs = [];
83
+ let model = "";
84
+ for (const r of kept) {
85
+ // effectiveVerdicts is the single source of truth for the
86
+ // override-aware verdict/suspect rule (suspect = s.suspect &&
87
+ // s.override == null — an override resolves the misfire); zip in
88
+ // flakiness from the matching ScenarioResult.
89
+ const verdicts = effectiveVerdicts(r.scenarios);
90
+ const cells = {};
91
+ r.scenarios.forEach((s, i) => {
92
+ cells[s.id] = { verdict: verdicts[i].verdict, suspect: verdicts[i].suspect ?? false, flakiness: s.flakiness };
93
+ });
94
+ runs.push({ timestamp: r.timestamp, label: r.label, grade: r.effective_grade, cells });
95
+ model = r.model; // last successfully-read run (kept is ascending) wins
96
+ }
97
+ models.push({ model, tag, runs, truncated, skipped });
98
+ }
99
+ }
100
+ return { skill: spec.skill, scenarios, models };
101
+ }
102
+ //# sourceMappingURL=trends.js.map
@@ -0,0 +1,14 @@
1
+ export interface ExecResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ code: number | null;
5
+ }
6
+ export interface ExecOpts {
7
+ cwd?: string;
8
+ timeoutMs?: number;
9
+ env?: NodeJS.ProcessEnv;
10
+ }
11
+ /** Spawn a command, capture stdout/stderr. Never throws on non-zero exit; returns the code. */
12
+ export declare function exec(cmd: string, args: string[], opts?: ExecOpts): Promise<ExecResult>;
13
+ /** True if a binary is resolvable on PATH. */
14
+ export declare function onPath(bin: string): boolean;
@@ -0,0 +1,43 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join, delimiter } from "node:path";
4
+ /** Spawn a command, capture stdout/stderr. Never throws on non-zero exit; returns the code. */
5
+ export function exec(cmd, args, opts = {}) {
6
+ return new Promise((resolve, reject) => {
7
+ // stdin must be ignored (not inherited): pi blocks reading stdin even in
8
+ // --print mode if its stdin is an open pipe/tty.
9
+ const child = spawn(cmd, args, {
10
+ cwd: opts.cwd,
11
+ env: opts.env ?? process.env,
12
+ stdio: ["ignore", "pipe", "pipe"],
13
+ });
14
+ let stdout = "";
15
+ let stderr = "";
16
+ let timer;
17
+ if (opts.timeoutMs) {
18
+ timer = setTimeout(() => {
19
+ child.kill("SIGKILL");
20
+ stderr += `\n[skill-harness] killed after ${opts.timeoutMs}ms timeout`;
21
+ }, opts.timeoutMs);
22
+ }
23
+ child.stdout.on("data", (d) => (stdout += d.toString()));
24
+ child.stderr.on("data", (d) => (stderr += d.toString()));
25
+ child.on("error", (e) => {
26
+ if (timer)
27
+ clearTimeout(timer);
28
+ reject(e);
29
+ });
30
+ child.on("close", (code) => {
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ resolve({ stdout, stderr, code });
34
+ });
35
+ });
36
+ }
37
+ /** True if a binary is resolvable on PATH. */
38
+ export function onPath(bin) {
39
+ const dirs = (process.env.PATH ?? "").split(delimiter);
40
+ const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
41
+ return dirs.some((d) => d && exts.some((ext) => existsSync(join(d, bin + ext))));
42
+ }
43
+ //# sourceMappingURL=exec.js.map
@@ -0,0 +1,17 @@
1
+ /** How a scenario's working directory is prepared. */
2
+ export type WorkspaceKind = "none" | "empty-git" | {
3
+ fixture: string;
4
+ };
5
+ export interface Workspace {
6
+ cwd: string;
7
+ cleanup(): void;
8
+ }
9
+ /**
10
+ * Create an isolated temp-dir working directory for one scenario. `none` is an
11
+ * empty dir (no git); `empty-git` initialises a clean repo; `{ fixture }` copies
12
+ * the fixture (relative paths resolve against `specDir`) then initialises a repo
13
+ * with a baseline commit. Child processes run here, never in the user's home.
14
+ */
15
+ export declare function createWorkspace(kind: WorkspaceKind, opts: {
16
+ specDir: string;
17
+ }): Workspace;
@@ -0,0 +1,42 @@
1
+ import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ const GIT_TIMEOUT_MS = 30_000;
6
+ /** git init + a baseline commit, so a later `git diff --cached` shows only edits. */
7
+ function gitBaseline(cwd) {
8
+ execFileSync("git", ["init", "-q"], { cwd, timeout: GIT_TIMEOUT_MS });
9
+ execFileSync("git", ["add", "-A"], { cwd, timeout: GIT_TIMEOUT_MS });
10
+ execFileSync("git", ["-c", "user.email=sc@local", "-c", "user.name=skill-check", "commit", "-q", "--allow-empty", "-m", "baseline"], { cwd, timeout: GIT_TIMEOUT_MS });
11
+ }
12
+ /**
13
+ * Create an isolated temp-dir working directory for one scenario. `none` is an
14
+ * empty dir (no git); `empty-git` initialises a clean repo; `{ fixture }` copies
15
+ * the fixture (relative paths resolve against `specDir`) then initialises a repo
16
+ * with a baseline commit. Child processes run here, never in the user's home.
17
+ */
18
+ export function createWorkspace(kind, opts) {
19
+ const cwd = mkdtempSync(join(tmpdir(), "sc-ws-"));
20
+ const cleanup = () => rmSync(cwd, { recursive: true, force: true });
21
+ try {
22
+ if (kind === "none") {
23
+ // empty isolated dir; nothing to set up
24
+ }
25
+ else if (kind === "empty-git") {
26
+ gitBaseline(cwd);
27
+ }
28
+ else {
29
+ const src = isAbsolute(kind.fixture) ? kind.fixture : resolve(opts.specDir, kind.fixture);
30
+ if (!existsSync(src))
31
+ throw new Error(`fixture not found: ${src}`);
32
+ cpSync(src, cwd, { recursive: true });
33
+ gitBaseline(cwd);
34
+ }
35
+ }
36
+ catch (e) {
37
+ cleanup(); // never leak a temp dir on a setup failure
38
+ throw e;
39
+ }
40
+ return { cwd, cleanup };
41
+ }
42
+ //# sourceMappingURL=workspace.js.map
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@skill-harness/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": { ".": "./dist/index.js" },
9
+ "files": ["dist/**/*.js", "dist/**/*.d.ts", "LICENSE"],
10
+ "repository": { "type": "git", "url": "git+https://github.com/mojomanyana/skill-harness.git" },
11
+ "publishConfig": { "access": "public" },
12
+ "engines": { "node": ">=20" },
13
+ "scripts": {
14
+ "prepack": "cp ../../LICENSE ./LICENSE"
15
+ },
16
+ "dependencies": { "js-yaml": "^4.1.0" }
17
+ }