@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/LICENSE +21 -0
- package/dist/adapters/types.d.ts +29 -0
- package/dist/adapters/types.js +18 -0
- package/dist/discover.d.ts +14 -0
- package/dist/discover.js +35 -0
- package/dist/grade.d.ts +44 -0
- package/dist/grade.js +89 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +18 -0
- package/dist/journal.d.ts +76 -0
- package/dist/journal.js +32 -0
- package/dist/lint.d.ts +14 -0
- package/dist/lint.js +137 -0
- package/dist/regrade.d.ts +54 -0
- package/dist/regrade.js +107 -0
- package/dist/report.d.ts +80 -0
- package/dist/report.js +106 -0
- package/dist/reps.d.ts +34 -0
- package/dist/reps.js +45 -0
- package/dist/results.d.ts +108 -0
- package/dist/results.js +241 -0
- package/dist/run.d.ts +28 -0
- package/dist/run.js +148 -0
- package/dist/scheduler.d.ts +11 -0
- package/dist/scheduler.js +27 -0
- package/dist/score.d.ts +33 -0
- package/dist/score.js +62 -0
- package/dist/seeded.d.ts +21 -0
- package/dist/seeded.js +45 -0
- package/dist/spec.d.ts +39 -0
- package/dist/spec.js +175 -0
- package/dist/trends.d.ts +54 -0
- package/dist/trends.js +102 -0
- package/dist/util/exec.d.ts +14 -0
- package/dist/util/exec.js +43 -0
- package/dist/workspace.d.ts +17 -0
- package/dist/workspace.js +42 -0
- package/package.json +17 -0
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
|
package/dist/trends.d.ts
ADDED
|
@@ -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
|
+
}
|