@skill-harness/core 0.1.2 → 0.3.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/workspace.js CHANGED
@@ -1,36 +1,155 @@
1
- import { cpSync, existsSync, mkdtempSync, rmSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
5
  const GIT_TIMEOUT_MS = 30_000;
6
- /** git init + a baseline commit, so a later `git diff --cached` shows only edits. */
6
+ /**
7
+ * Fixture subdirectories copied over the workspace AFTER the baseline commit, so their
8
+ * files land as pending changes rather than history. Lets a scenario start from a dirty
9
+ * tree — "I fixed the typo, commit it" needs the fix present but not yet committed, and
10
+ * "commit my staged changes" needs a populated index. `_staged/` contents are added to
11
+ * the index, `_uncommitted/` contents are left unstaged; a fixture may use either or
12
+ * both. Neither marker directory ever appears in the workspace.
13
+ */
14
+ const UNCOMMITTED_DIR = "_uncommitted";
15
+ const STAGED_DIR = "_staged";
16
+ export const MARKERS = [STAGED_DIR, UNCOMMITTED_DIR];
17
+ /**
18
+ * Top-level directories in a fixture that claim to be markers but aren't one.
19
+ *
20
+ * A misspelled marker (`_uncommited/`) used to be copied into the baseline commit as a
21
+ * literal directory: the tree came up clean, the scenario silently measured the opposite
22
+ * of its intent, and nothing reported it. Any top-level `_name/` is therefore treated as
23
+ * a marker claim and must be a real one.
24
+ *
25
+ * The predicate is `_` followed by a LETTER, so `__tests__` and `__pycache__` — ordinary
26
+ * directories a fixture may legitimately contain — are not marker claims, and neither are
27
+ * oddities like `_2fa/` or `_-tmp/`, which no marker could plausibly be mistaken for.
28
+ * Nested `pkg/_staged/` is likewise ordinary content: markers are top-level only.
29
+ *
30
+ * Exported so `lint` can report the same set this module refuses to run. If the two
31
+ * disagreed, lint would hand out a clean bill of health for a fixture the runtime then
32
+ * rejects — which is worse than not checking at all.
33
+ */
34
+ export function unknownMarkerDirs(src) {
35
+ return readdirSync(src, { withFileTypes: true })
36
+ .filter((e) => e.isDirectory() && /^_[A-Za-z]/.test(e.name) && !MARKERS.includes(e.name))
37
+ .map((e) => e.name)
38
+ .sort();
39
+ }
40
+ /**
41
+ * The known marker a misspelling was probably reaching for, or null.
42
+ *
43
+ * Case-insensitive within an edit distance of 2 — enough for `_uncommited` (one
44
+ * dropped `t`), `_Staged` (case) and `_uncommmitted` (doubled letter), while
45
+ * `_fixtures` or `_helpers` correctly suggest nothing rather than a confident
46
+ * wrong guess.
47
+ */
48
+ export function suggestMarker(name) {
49
+ let best = null;
50
+ let bestDistance = 3;
51
+ for (const m of MARKERS) {
52
+ const d = editDistance(name.toLowerCase(), m.toLowerCase());
53
+ if (d < bestDistance) {
54
+ bestDistance = d;
55
+ best = m;
56
+ }
57
+ }
58
+ return best;
59
+ }
60
+ /** Levenshtein distance, iterative single-row. */
61
+ function editDistance(a, b) {
62
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
63
+ for (let i = 1; i <= a.length; i++) {
64
+ const curr = [i];
65
+ for (let j = 1; j <= b.length; j++) {
66
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
67
+ }
68
+ prev = curr;
69
+ }
70
+ return prev[b.length];
71
+ }
72
+ function assertKnownMarkers(src) {
73
+ const suspects = unknownMarkerDirs(src);
74
+ if (suspects.length > 0) {
75
+ throw new Error(`fixture ${src}: unknown marker director${suspects.length > 1 ? "ies" : "y"} ` +
76
+ `${suspects.map((s) => `\`${s}/\``).join(", ")} — known markers are ` +
77
+ `${MARKERS.map((m) => `\`${m}/\``).join(" and ")}. Rename it, or move it deeper if it is ordinary content.`);
78
+ }
79
+ }
80
+ /**
81
+ * git init + a baseline commit, so a later `git diff --cached` shows only edits.
82
+ * Pinned to `main`: the host's init.defaultBranch is not ours to depend on, and
83
+ * scenarios say things like "I'm on the main branch".
84
+ */
7
85
  function gitBaseline(cwd) {
8
- execFileSync("git", ["init", "-q"], { cwd, timeout: GIT_TIMEOUT_MS });
86
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
9
87
  execFileSync("git", ["add", "-A"], { cwd, timeout: GIT_TIMEOUT_MS });
10
88
  execFileSync("git", ["-c", "user.email=sh@local", "-c", "user.name=skill-harness", "commit", "-q", "--allow-empty", "-m", "baseline"], { cwd, timeout: GIT_TIMEOUT_MS });
11
89
  }
90
+ /**
91
+ * Wire a bare repo in its own temp dir as `origin` and push the baseline, so `main`
92
+ * tracks `origin/main` and the baseline commit genuinely exists upstream.
93
+ *
94
+ * Without this, a git fixture has no remote — and a model reasonably reads a missing
95
+ * upstream as "solo throwaway repo", which is how git-ops A4 passes or fails depending
96
+ * on the run. A real remote makes "shared work" a fact of the fixture rather than a
97
+ * claim in the prompt, and needs no network.
98
+ */
99
+ function addLocalRemote(cwd) {
100
+ const bare = mkdtempSync(join(tmpdir(), "sc-remote-")) + ".git";
101
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", bare], { timeout: GIT_TIMEOUT_MS });
102
+ execFileSync("git", ["remote", "add", "origin", bare], { cwd, timeout: GIT_TIMEOUT_MS });
103
+ execFileSync("git", ["push", "-q", "-u", "origin", "main"], { cwd, timeout: GIT_TIMEOUT_MS });
104
+ return bare;
105
+ }
12
106
  /**
13
107
  * Create an isolated temp-dir working directory for one scenario. `none` is an
14
108
  * empty dir (no git); `empty-git` initialises a clean repo; `{ fixture }` copies
15
109
  * 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.
110
+ * with a baseline commit. A fixture may carry top-level `_staged/` and
111
+ * `_uncommitted/` subdirectories, applied after that commit to start the scenario
112
+ * from a dirty tree. `opts.remote` additionally wires a local bare `origin`.
113
+ * Child processes run here, never in the user's home.
17
114
  */
18
115
  export function createWorkspace(kind, opts) {
19
116
  const cwd = mkdtempSync(join(tmpdir(), "sc-ws-"));
20
- const cleanup = () => rmSync(cwd, { recursive: true, force: true });
117
+ let bare = null;
118
+ const cleanup = () => {
119
+ rmSync(cwd, { recursive: true, force: true });
120
+ if (bare)
121
+ rmSync(bare, { recursive: true, force: true }); // the remote is ours too
122
+ };
21
123
  try {
22
124
  if (kind === "none") {
23
125
  // empty isolated dir; nothing to set up
24
126
  }
25
127
  else if (kind === "empty-git") {
26
128
  gitBaseline(cwd);
129
+ if (opts.remote)
130
+ bare = addLocalRemote(cwd);
27
131
  }
28
132
  else {
29
133
  const src = isAbsolute(kind.fixture) ? kind.fixture : resolve(opts.specDir, kind.fixture);
30
134
  if (!existsSync(src))
31
135
  throw new Error(`fixture not found: ${src}`);
32
- cpSync(src, cwd, { recursive: true });
136
+ assertKnownMarkers(src);
137
+ const pending = [STAGED_DIR, UNCOMMITTED_DIR].map((d) => join(src, d));
138
+ cpSync(src, cwd, {
139
+ recursive: true,
140
+ filter: (from) => !pending.includes(from), // committed baseline only
141
+ });
33
142
  gitBaseline(cwd);
143
+ // Before the pending changes land, so the remote holds the baseline only.
144
+ if (opts.remote)
145
+ bare = addLocalRemote(cwd);
146
+ const [staged, uncommitted] = pending;
147
+ if (existsSync(staged)) {
148
+ cpSync(staged, cwd, { recursive: true });
149
+ execFileSync("git", ["add", "-A"], { cwd, timeout: GIT_TIMEOUT_MS });
150
+ }
151
+ if (existsSync(uncommitted))
152
+ cpSync(uncommitted, cwd, { recursive: true });
34
153
  }
35
154
  }
36
155
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skill-harness/core",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "skill-harness engine — spec, discover, run, LLM-judge grade, score, results (internal API)",
5
5
  "type": "module",
6
6
  "license": "MIT",