@mcptoolshop/style-dataset-lab 3.3.0 → 3.4.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.es.md +35 -25
  3. package/README.fr.md +38 -28
  4. package/README.hi.md +36 -26
  5. package/README.it.md +37 -27
  6. package/README.ja.md +40 -30
  7. package/README.md +15 -5
  8. package/README.pt-BR.md +35 -25
  9. package/README.zh.md +33 -23
  10. package/bin/sdlab.js +51 -4
  11. package/lib/adapters/ai-toolkit.js +37 -5
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +10 -2
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/batch-modes.js +29 -8
  18. package/lib/batch-runs.js +29 -1
  19. package/lib/batch-sheet-render.js +13 -3
  20. package/lib/brief-compiler.js +19 -0
  21. package/lib/candidate-sheet.js +327 -0
  22. package/lib/canon-build/build.js +146 -93
  23. package/lib/captions.js +57 -0
  24. package/lib/card.js +74 -2
  25. package/lib/comfyui.js +116 -18
  26. package/lib/config.js +116 -12
  27. package/lib/critique-engine.js +36 -2
  28. package/lib/eligibility.js +121 -9
  29. package/lib/eval-pack.js +14 -14
  30. package/lib/eval-runs.js +33 -0
  31. package/lib/export.js +122 -34
  32. package/lib/freeze-gate.js +13 -3
  33. package/lib/freeze-stamp.js +28 -3
  34. package/lib/generated-provenance.js +16 -1
  35. package/lib/implementation-packs.js +95 -17
  36. package/lib/ingest.js +396 -0
  37. package/lib/judgment-provenance.js +220 -0
  38. package/lib/measure.js +422 -0
  39. package/lib/paths.js +196 -7
  40. package/lib/reingest-selected.js +98 -16
  41. package/lib/run-manifest.js +102 -16
  42. package/lib/run-summary.js +12 -6
  43. package/lib/runtime-runs.js +45 -4
  44. package/lib/snapshot.js +114 -15
  45. package/lib/split.js +364 -48
  46. package/lib/training-manifests.js +114 -21
  47. package/lib/training-packages.js +147 -15
  48. package/lib/workflow-profiles.js +24 -3
  49. package/package.json +3 -2
  50. package/runtime/comfyui/txt2img-qwen.json +38 -0
  51. package/runtime/comfyui/txt2img-standard.json +21 -0
  52. package/scripts/_resume.js +62 -0
  53. package/scripts/_shared-args.js +44 -0
  54. package/scripts/brief-compile.js +2 -1
  55. package/scripts/brief-show.js +2 -1
  56. package/scripts/canon-bind.js +2 -2
  57. package/scripts/canon-build.js +27 -20
  58. package/scripts/canon-drift.js +16 -7
  59. package/scripts/canon-freeze-status.js +14 -6
  60. package/scripts/canon-freeze.js +27 -11
  61. package/scripts/canon-unfreeze.js +17 -9
  62. package/scripts/card.js +2 -2
  63. package/scripts/compare.js +2 -2
  64. package/scripts/critique.js +16 -1
  65. package/scripts/curate.js +2 -2
  66. package/scripts/doctor.js +181 -55
  67. package/scripts/eligibility.js +42 -2
  68. package/scripts/eval-pack.js +2 -2
  69. package/scripts/eval-run.js +2 -2
  70. package/scripts/export.js +2 -2
  71. package/scripts/generate-controlnet.js +45 -25
  72. package/scripts/generate-identity.js +53 -23
  73. package/scripts/generate-ipadapter.js +44 -23
  74. package/scripts/generate.js +48 -5
  75. package/scripts/implementation-pack.js +2 -2
  76. package/scripts/ingest.js +145 -0
  77. package/scripts/init.js +73 -13
  78. package/scripts/measure.js +170 -0
  79. package/scripts/measure_image.py +458 -0
  80. package/scripts/migrate-records.js +142 -0
  81. package/scripts/painterly-test.js +4 -3
  82. package/scripts/painterly.js +7 -3
  83. package/scripts/qwen_generate.py +138 -20
  84. package/scripts/reingest.js +2 -2
  85. package/scripts/sheet.js +142 -0
  86. package/scripts/snapshot.js +2 -2
  87. package/scripts/split.js +2 -2
  88. package/scripts/training-manifest.js +2 -2
  89. package/scripts/training-package.js +2 -2
  90. package/scripts/training-profile.js +2 -2
  91. package/scripts/workflow-list.js +2 -1
  92. package/scripts/workflow-show.js +2 -1
package/lib/measure.js ADDED
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Measure — orchestration for `sdlab measure`: resolve which images to
3
+ * measure, shell out to scripts/measure_image.py for the actual pixel math,
4
+ * and write the resulting numbers onto records.
5
+ *
6
+ * MEASUREMENT, NOT VERDICT (same spirit as lib/critique-engine.js's H2 fix):
7
+ * this module attaches a `measurements` block to a record. It never touches
8
+ * `judgment`, `canon`, or any pass/fail/approved/rejected field, and it
9
+ * never invents one. See scripts/measure_image.py's module docstring for
10
+ * the full port rationale (ported from the salt-road measure-palette.py /
11
+ * measure-texture.py instruments; DISTS/StyleLoss perceptual distance is
12
+ * deliberately out of scope — needs torch+piq).
13
+ *
14
+ * Python is an OPTIONAL runtime dependency of this ONE feature, not of the
15
+ * package (mirrors scripts/qwen_generate.py's relationship to the rest of
16
+ * sdlab, and tests/cli-scripts/qwen-python-parity.test.js's detection
17
+ * pattern, adopted here as findPython()/checkPythonDeps() so both
18
+ * scripts/measure.js and tests/lib-dataset/measure.test.js share one
19
+ * implementation instead of each hand-rolling their own).
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import {
24
+ existsSync, readFileSync, statSync, readdirSync, writeFileSync, mkdtempSync, rmSync,
25
+ } from 'node:fs';
26
+ import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
27
+ import { join, relative } from 'node:path';
28
+ import { tmpdir } from 'node:os';
29
+ import { getPackageRoot, resolveSafeProjectPath } from './paths.js';
30
+ import { inputError, runtimeError } from './errors.js';
31
+
32
+ // ─── Identity / provenance constants (mirrors lib/verifier.js's
33
+ // RULE_BASED_JUDGE / HUMAN_JUDGE naming discipline — "who produced this
34
+ // number" gets a stable id the same way "who judged this" does) ──────────
35
+
36
+ export const MEASURE_TOOL_ID = 'sdlab-measure-v1';
37
+ export const MEASURE_SCHEMA_VERSION = '1.0.0';
38
+
39
+ const IMAGE_EXT_RE = /\.(png|jpe?g)$/i;
40
+
41
+ // Ported verbatim from measure-palette.py's saturated-pixel gate — see
42
+ // scripts/measure_image.py's module docstring for the "ignore greys/darks"
43
+ // rationale. Kept in sync with the Python-side defaults by hand (both files
44
+ // document the same origin; there is no cross-language pinning contract
45
+ // here the way there is for comfy_workflow_sha, since these are STATIC
46
+ // defaults, not per-run computed values).
47
+ const DEFAULT_SAT_MIN = 0.22;
48
+ const DEFAULT_VAL_MIN = 0.18;
49
+ const DEFAULT_HUE_TOLERANCE_DEG = 20;
50
+
51
+ const REQUIRED_PY_MODULES = ['PIL', 'numpy', 'scipy'];
52
+
53
+ const MEASURE_SCRIPT = join(getPackageRoot(), 'scripts', 'measure_image.py');
54
+
55
+ // ─── Python environment detection ──────────────────────────────────────
56
+
57
+ /**
58
+ * Locate a working Python interpreter. Precedence mirrors the rest of this
59
+ * codebase's env-var-override convention (paths.js's SDLAB_ROOT resolution:
60
+ * cheapest/most-explicit signal first): SDLAB_PYTHON env var if set, else
61
+ * try "python" then "python3" on PATH. Returns the command string to spawn,
62
+ * or null if nothing worked.
63
+ * @param {NodeJS.ProcessEnv} [env]
64
+ * @returns {string|null}
65
+ */
66
+ export function findPython(env = process.env) {
67
+ const candidates = env.SDLAB_PYTHON ? [env.SDLAB_PYTHON] : ['python', 'python3'];
68
+ for (const cmd of candidates) {
69
+ const res = spawnSync(cmd, ['--version'], { encoding: 'utf-8' });
70
+ if (res.status === 0) return cmd;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Probe whether `pythonCmd` has the packages measure_image.py needs.
77
+ * Reports EVERY missing module in one pass (not just the first import
78
+ * failure) so the resulting error names all of them at once.
79
+ * @param {string} pythonCmd
80
+ * @param {{modules?: string[]}} [opts] — override the probed module list (tests use this)
81
+ * @returns {{ok: boolean, missing: string[]}}
82
+ */
83
+ export function checkPythonDeps(pythonCmd, { modules = REQUIRED_PY_MODULES } = {}) {
84
+ const probe =
85
+ 'import json\n' +
86
+ 'mods = {}\n' +
87
+ `for m in ${JSON.stringify(modules)}:\n` +
88
+ ' try:\n' +
89
+ ' __import__(m)\n' +
90
+ ' mods[m] = True\n' +
91
+ ' except ImportError:\n' +
92
+ ' mods[m] = False\n' +
93
+ 'print(json.dumps(mods))\n';
94
+ const res = spawnSync(pythonCmd, ['-c', probe], { encoding: 'utf-8' });
95
+ if (res.status !== 0) {
96
+ return { ok: false, missing: [...modules], raw: (res.stderr || res.error?.message || 'probe failed').trim() };
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse((res.stdout || '').trim());
101
+ } catch {
102
+ return { ok: false, missing: [...modules], raw: (res.stdout || '').trim() };
103
+ }
104
+ const missing = modules.filter((m) => !parsed[m]);
105
+ return { ok: missing.length === 0, missing };
106
+ }
107
+
108
+ /**
109
+ * Throwing convenience wrapper for the CLI boundary: locate Python and
110
+ * verify its deps, or fail with a clear, actionable SdlabError naming
111
+ * exactly what is missing. This is the ONLY function in this module that
112
+ * throws on a missing/incomplete Python environment — findPython() and
113
+ * checkPythonDeps() stay pure so tests can drive every branch directly.
114
+ * @param {NodeJS.ProcessEnv} [env]
115
+ * @returns {string} the python command to use
116
+ */
117
+ export function assertMeasurementPrereqs(env = process.env) {
118
+ const python = findPython(env);
119
+ if (!python) {
120
+ throw runtimeError(
121
+ 'RUNTIME_PYTHON_MISSING',
122
+ 'Python was not found on PATH (tried "python", "python3").',
123
+ 'sdlab measure needs Python 3.9+ with Pillow, numpy, and scipy for image measurement ' +
124
+ '(see scripts/measure_image.py). Install Python and ensure it is on PATH, or set ' +
125
+ 'SDLAB_PYTHON to an explicit interpreter path.'
126
+ );
127
+ }
128
+ const deps = checkPythonDeps(python);
129
+ if (!deps.ok) {
130
+ throw runtimeError(
131
+ 'RUNTIME_PYTHON_DEPS_MISSING',
132
+ `Python found (${python}) but missing required package(s): ${deps.missing.join(', ')}.`,
133
+ `Install them: pip install pillow numpy scipy${deps.raw ? ` (probe said: ${deps.raw})` : ''}`
134
+ );
135
+ }
136
+ return python;
137
+ }
138
+
139
+ // ─── Target resolution ──────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Minimal `*`/`?` glob-to-RegExp. No npm glob dependency exists in this
143
+ * package (checked before writing this — lib/ and scripts/ have none) and
144
+ * this file may not add one (package.json is outside this feature's
145
+ * exclusive file ownership), so record-id matching gets a small hand-rolled
146
+ * matcher instead of a library.
147
+ */
148
+ function globToRegExp(pattern) {
149
+ let out = '';
150
+ for (const ch of pattern) {
151
+ if (ch === '*') out += '.*';
152
+ else if (ch === '?') out += '.';
153
+ else out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
154
+ }
155
+ return new RegExp(`^${out}$`);
156
+ }
157
+
158
+ /**
159
+ * Resolve the `<dir-or-record-glob>` positional into a concrete list of
160
+ * {id, recordPath, imagePath} targets.
161
+ *
162
+ * - If it resolves to an existing directory (inside the project — see
163
+ * resolveSafeProjectPath), every *.png/*.jpg/*.jpeg inside is paired
164
+ * with records/<stem>.json by filename stem. Images with no matching
165
+ * record are reported in `skipped`, never silently dropped (mirrors
166
+ * lib/runtime-runs.js listRuns()'s B01 fix: always report a count,
167
+ * detail under --verbose).
168
+ * - Otherwise it is treated as a glob PATTERN matched against record ids
169
+ * under <project>/records/*.json; each match's image comes from that
170
+ * record's own `asset_path` (a trusted, already-on-disk field — joined
171
+ * plainly, same as curate.js does with record.asset_path, not run
172
+ * through resolveSafeProjectPath a second time).
173
+ *
174
+ * @param {string} projectRoot
175
+ * @param {string} targetArg
176
+ * @returns {{targets: Array<{id:string, recordPath:string, imagePath:string}>, skipped: Array<{id:string, reason:string}>}}
177
+ */
178
+ export function resolveMeasureTargets(projectRoot, targetArg) {
179
+ if (!targetArg) {
180
+ throw inputError(
181
+ 'INPUT_MISSING_ARGS',
182
+ 'sdlab measure requires a <dir-or-record-glob> positional argument.',
183
+ 'Example: sdlab measure outputs/approved --project salt-road, or ' +
184
+ 'sdlab measure "styleset_p*" --project salt-road (quote glob patterns so your shell does not expand them).'
185
+ );
186
+ }
187
+
188
+ const recordsDir = join(projectRoot, 'records');
189
+ // Security boundary FIRST: whether targetArg turns out to be a directory
190
+ // or a glob pattern, an escape attempt (`..`, an absolute path outside
191
+ // the project) is rejected before either interpretation is considered.
192
+ const asPath = resolveSafeProjectPath(projectRoot, targetArg, { flagName: 'target' });
193
+
194
+ const targets = [];
195
+ const skipped = [];
196
+ const isDir = existsSync(asPath) && statSync(asPath).isDirectory();
197
+
198
+ if (isDir) {
199
+ const files = readdirSync(asPath, { withFileTypes: true })
200
+ .filter((e) => e.isFile() && IMAGE_EXT_RE.test(e.name))
201
+ .map((e) => e.name)
202
+ .sort();
203
+ for (const file of files) {
204
+ const id = file.replace(IMAGE_EXT_RE, '');
205
+ const recordPath = join(recordsDir, `${id}.json`);
206
+ if (existsSync(recordPath)) {
207
+ targets.push({ id, recordPath, imagePath: join(asPath, file) });
208
+ } else {
209
+ skipped.push({ id, reason: `no matching record at records/${id}.json` });
210
+ }
211
+ }
212
+ } else {
213
+ const re = globToRegExp(targetArg);
214
+ const files = existsSync(recordsDir) ? readdirSync(recordsDir).filter((f) => f.endsWith('.json')) : [];
215
+ for (const file of files) {
216
+ const id = file.slice(0, -'.json'.length);
217
+ if (!re.test(id)) continue;
218
+ const recordPath = join(recordsDir, file);
219
+ const record = JSON.parse(readFileSync(recordPath, 'utf-8'));
220
+ if (!record.asset_path) {
221
+ skipped.push({ id, reason: 'record has no asset_path' });
222
+ continue;
223
+ }
224
+ targets.push({ id, recordPath, imagePath: join(projectRoot, record.asset_path) });
225
+ }
226
+ }
227
+
228
+ if (targets.length === 0) {
229
+ throw inputError(
230
+ 'INPUT_NO_MATCHES',
231
+ `No records matched "${targetArg}" under ${recordsDir}.`,
232
+ isDir
233
+ ? 'The directory exists but no image inside it has a matching records/<id>.json.'
234
+ : 'Check the glob pattern (quote it so your shell does not expand it), or pass an existing directory of images instead.'
235
+ );
236
+ }
237
+
238
+ return { targets, skipped };
239
+ }
240
+
241
+ // ─── Anchors ─────────────────────────────────────────────────────────────
242
+
243
+ const HEX_RE = /^#[0-9a-fA-F]{6}$/;
244
+
245
+ /**
246
+ * Load a palette anchor config from `--anchors <path>`.
247
+ *
248
+ * constitution.json carries no hex/numeric palette data for either salt-road
249
+ * or star-freight (checked directly before building this — both files are
250
+ * qualitative rule descriptions with rationale templates, not colour
251
+ * values; the actual hex anchors salt-road uses live in prose in
252
+ * canon/salt-road-art-contract.md, not in machine-readable config). So this
253
+ * is a real `--anchors` flag, not a schema invented to route around a
254
+ * missing feature.
255
+ *
256
+ * Unlike --source/--outputs elsewhere in this codebase (which legitimately
257
+ * reference shared cross-project directories, hence their REPO_ROOT-scoped
258
+ * containment), --anchors is scoped to the PROJECT root: anchor definitions
259
+ * are project-specific canon data, same as constitution.json/lanes.json.
260
+ *
261
+ * @param {string|undefined} anchorsFlag — raw --anchors value, or undefined
262
+ * @param {string} projectRoot
263
+ * @returns {{anchors: Array<{name:string,hex:string}>, hue_tolerance_deg: number, sat_min: number, val_min: number, source: string|null}}
264
+ */
265
+ export function loadAnchors(anchorsFlag, projectRoot) {
266
+ if (!anchorsFlag) {
267
+ return {
268
+ anchors: [],
269
+ hue_tolerance_deg: DEFAULT_HUE_TOLERANCE_DEG,
270
+ sat_min: DEFAULT_SAT_MIN,
271
+ val_min: DEFAULT_VAL_MIN,
272
+ source: null,
273
+ };
274
+ }
275
+
276
+ const absPath = resolveSafeProjectPath(projectRoot, anchorsFlag, { flagName: 'anchors' });
277
+ if (!existsSync(absPath)) {
278
+ throw inputError('INPUT_ANCHORS_NOT_FOUND', `--anchors file not found: ${absPath}`);
279
+ }
280
+ let data;
281
+ try {
282
+ data = JSON.parse(readFileSync(absPath, 'utf-8'));
283
+ } catch (err) {
284
+ throw inputError('INPUT_INVALID_JSON', `--anchors file is not valid JSON: ${absPath} (${err.message})`);
285
+ }
286
+ const anchors = Array.isArray(data) ? data : data.anchors;
287
+ if (!Array.isArray(anchors) || anchors.length === 0) {
288
+ throw inputError(
289
+ 'INPUT_BAD_ANCHORS',
290
+ `${absPath} must contain a non-empty "anchors" array of {name, hex}.`,
291
+ 'Example: { "anchors": [{"name":"ochre-warm","hex":"#c9a877"}] }'
292
+ );
293
+ }
294
+ for (const a of anchors) {
295
+ if (!a || typeof a.name !== 'string' || !a.name || typeof a.hex !== 'string' || !HEX_RE.test(a.hex)) {
296
+ throw inputError(
297
+ 'INPUT_BAD_ANCHORS',
298
+ `Invalid anchor entry in ${absPath}: ${JSON.stringify(a)}`,
299
+ 'Each anchor needs a "name" string and a "hex" color like "#c9a877".'
300
+ );
301
+ }
302
+ }
303
+ return {
304
+ anchors,
305
+ hue_tolerance_deg: data.hue_tolerance_deg ?? DEFAULT_HUE_TOLERANCE_DEG,
306
+ sat_min: data.sat_min ?? DEFAULT_SAT_MIN,
307
+ val_min: data.val_min ?? DEFAULT_VAL_MIN,
308
+ source: relative(projectRoot, absPath),
309
+ };
310
+ }
311
+
312
+ // ─── Python invocation ───────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Spawn scripts/measure_image.py once for a whole batch of images (one
316
+ * process, not one-per-image — keeps interpreter-startup overhead off the
317
+ * per-image cost). Writes the request to a temp file, mirroring
318
+ * scripts/qwen_generate.py's `--wave <path>` handoff shape.
319
+ *
320
+ * @param {string} pythonCmd
321
+ * @param {{images: Array<{id:string,path:string}>, anchors?: Array, hue_tolerance_deg?: number, sat_min?: number, val_min?: number}} request
322
+ * @returns {{schema_version, tool, results: Array, errors: Array, ok_count: number, error_count: number}}
323
+ */
324
+ export function runPythonMeasurement(pythonCmd, request) {
325
+ const dir = mkdtempSync(join(tmpdir(), 'sdlab-measure-'));
326
+ try {
327
+ const requestPath = join(dir, 'request.json');
328
+ const outPath = join(dir, 'result.json');
329
+ writeFileSync(requestPath, JSON.stringify(request, null, 2));
330
+
331
+ const res = spawnSync(pythonCmd, [MEASURE_SCRIPT, '--request', requestPath, '--out', outPath], {
332
+ encoding: 'utf-8',
333
+ });
334
+
335
+ if (!existsSync(outPath)) {
336
+ throw runtimeError(
337
+ 'RUNTIME_MEASURE_FAILED',
338
+ `measure_image.py did not produce a result (exit ${res.status ?? 'unknown'}).`,
339
+ (res.stderr || res.stdout || '').trim().slice(0, 2000) || 'Run with --debug for detail.'
340
+ );
341
+ }
342
+ return JSON.parse(readFileSync(outPath, 'utf-8'));
343
+ } finally {
344
+ rmSync(dir, { recursive: true, force: true });
345
+ }
346
+ }
347
+
348
+ // ─── Record writing (atomic) ─────────────────────────────────────────────
349
+
350
+ /**
351
+ * Temp-file-in-same-dir + rename — the exact pattern documented in
352
+ * lib/runtime-runs.js's atomicWriteJson (that function is module-private
353
+ * there, and is duplicated ad hoc in lib/batch-runs.js and
354
+ * lib/reingest-selected.js too — this file cannot import a shared version
355
+ * because none is exported anywhere in the codebase; extracting one would
356
+ * mean editing those files, which sit outside this feature's exclusive file
357
+ * ownership). Same tmp-name scheme as all three existing copies, for
358
+ * consistency: `${targetPath}.tmp-${pid}-${timestamp}`.
359
+ */
360
+ async function atomicWriteJson(targetPath, obj) {
361
+ const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
362
+ const body = JSON.stringify(obj, null, 2) + '\n';
363
+ try {
364
+ await writeFile(tmpPath, body);
365
+ await rename(tmpPath, targetPath);
366
+ } catch (err) {
367
+ try {
368
+ await unlink(tmpPath);
369
+ } catch {
370
+ /* ignore */
371
+ }
372
+ throw err;
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Build the `measurements` block and (unless dryRun) write it onto each
378
+ * matched record. Idempotent by design — an unchanged image + unchanged
379
+ * code reproduces the exact same palette/texture numbers on every run;
380
+ * only `measured_at` moves forward, the same way re-running `sdlab curate`
381
+ * or `sdlab bind` overwrites the prior judgment/binding rather than
382
+ * appending to a history log (see the sdlab compensators table).
383
+ *
384
+ * @param {Array<{id:string, recordPath:string}>} targets
385
+ * @param {{results: Array, errors: Array}} pyResult
386
+ * @param {{anchorsSource: string|null, dryRun: boolean}} opts
387
+ * @returns {Promise<{applied: Array<{id:string, measurements:Object}>, failed: Array<{id:string, error:string}>}>}
388
+ */
389
+ export async function applyMeasurements(targets, pyResult, { anchorsSource = null, dryRun = false } = {}) {
390
+ const byId = new Map((pyResult.results || []).map((r) => [r.id, r]));
391
+ const errById = new Map((pyResult.errors || []).map((e) => [e.id, e]));
392
+ const applied = [];
393
+ const failed = [];
394
+
395
+ for (const t of targets) {
396
+ const ok = byId.get(t.id);
397
+ if (!ok) {
398
+ const err = errById.get(t.id);
399
+ failed.push({ id: t.id, error: err?.error || 'no result returned for this image' });
400
+ continue;
401
+ }
402
+
403
+ const measurements = {
404
+ measured_at: new Date().toISOString(),
405
+ tool: MEASURE_TOOL_ID,
406
+ schema_version: MEASURE_SCHEMA_VERSION,
407
+ anchors_source: anchorsSource,
408
+ image: ok.image,
409
+ palette: ok.palette,
410
+ texture: ok.texture,
411
+ };
412
+ applied.push({ id: t.id, measurements });
413
+
414
+ if (!dryRun) {
415
+ const record = JSON.parse(await readFile(t.recordPath, 'utf-8'));
416
+ record.measurements = measurements;
417
+ await atomicWriteJson(t.recordPath, record);
418
+ }
419
+ }
420
+
421
+ return { applied, failed };
422
+ }
package/lib/paths.js CHANGED
@@ -10,12 +10,176 @@ import { inputError } from './errors.js';
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = dirname(__filename);
12
12
 
13
- /** Absolute path to the repository root. */
14
- export const REPO_ROOT = join(__dirname, '..');
13
+ // ─── Workspace root resolution (SDL-C1) ───────────────────────────
14
+ //
15
+ // `REPO_ROOT` used to be `join(__dirname, '..')` — always the directory
16
+ // this module happens to live in. That is correct for an in-repo checkout,
17
+ // but for an npm-installed consumer it resolves to
18
+ // `node_modules/@mcptoolshop/style-dataset-lab`, so `sdlab init demo`
19
+ // silently wrote the user's whole project tree into node_modules — gone on
20
+ // the next `npm ci`.
21
+ //
22
+ // The fix is a 4-step search, cheapest/most-explicit signal first:
23
+ // 1. SDLAB_ROOT env var, if set and it exists.
24
+ // 2. Walk up from process.cwd() looking for a directory containing
25
+ // `projects/`. First hit wins — this is how a user's own workspace
26
+ // (wherever it lives) gets found.
27
+ // 3. The module's own root, IF it already has a `projects/` dir — this is
28
+ // what preserves every existing in-repo dev/test workflow, where cwd
29
+ // already resolves via step 2 anyway, but is kept as an explicit
30
+ // fallback for callers with an unusual cwd.
31
+ // 4. process.cwd() itself — a fresh workspace where `init` will create
32
+ // `projects/`.
33
+ //
34
+ // Whatever step wins, the result is never allowed to sit inside a
35
+ // node_modules directory — that is the exact shape of the original bug, so
36
+ // it is a hard error instead of a silent write into someone's
37
+ // node_modules tree.
38
+
39
+ /**
40
+ * True if any path segment of `absPath` is literally `node_modules`.
41
+ */
42
+ function isInsideNodeModules(absPath) {
43
+ return resolve(absPath)
44
+ .split(/[\\/]+/)
45
+ .some((seg) => seg.toLowerCase() === 'node_modules');
46
+ }
47
+
48
+ function assertNotInNodeModules(absPath, label) {
49
+ if (isInsideNodeModules(absPath)) {
50
+ throw inputError(
51
+ 'INPUT_ROOT_IN_NODE_MODULES',
52
+ `Resolved workspace root (${label}) sits inside a node_modules directory: ${absPath}`,
53
+ 'Run sdlab from your own project directory, or set SDLAB_ROOT to your workspace path.'
54
+ );
55
+ }
56
+ return absPath;
57
+ }
58
+
59
+ /**
60
+ * Walk up from `startDir` looking for a directory that contains a
61
+ * `projects/` subdirectory. Returns the first hit, or null if the walk
62
+ * reaches the filesystem root without finding one.
63
+ */
64
+ function walkUpForProjectsDir(startDir) {
65
+ let dir = resolve(startDir);
66
+ while (true) {
67
+ if (existsSync(join(dir, 'projects'))) return dir;
68
+ const parent = dirname(dir);
69
+ if (parent === dir) return null; // reached the filesystem root
70
+ dir = parent;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Pure resolution logic for the workspace root, taking every input
76
+ * explicitly (cwd, env, module root) rather than reading global process
77
+ * state directly. This is what makes it possible to unit-test all four
78
+ * branches deterministically without mutating the real process.cwd() or
79
+ * environment (which are shared, global, and unsafe to mutate across a
80
+ * concurrently-run test suite).
81
+ *
82
+ * Production callers should use `getWorkspaceRoot()` instead, which
83
+ * supplies the real process.cwd()/env and memoizes the result.
84
+ *
85
+ * @param {string} cwd
86
+ * @param {{env?: Object, moduleRoot?: string}} [opts]
87
+ * @returns {string} absolute workspace root
88
+ */
89
+ export function resolveWorkspaceRootFrom(cwd, opts = {}) {
90
+ const env = opts.env || process.env;
91
+ const moduleRoot = opts.moduleRoot || join(__dirname, '..');
92
+
93
+ // 1. SDLAB_ROOT env var, if set and it exists.
94
+ const envRoot = env.SDLAB_ROOT;
95
+ if (envRoot && existsSync(envRoot)) {
96
+ return assertNotInNodeModules(resolve(envRoot), 'from SDLAB_ROOT');
97
+ }
98
+
99
+ // 2. Walk up from cwd looking for projects/.
100
+ const fromCwd = walkUpForProjectsDir(cwd);
101
+ if (fromCwd) {
102
+ return assertNotInNodeModules(fromCwd, 'found by walking up from the current directory');
103
+ }
104
+
105
+ // 3. The module root itself is the repo checkout.
106
+ if (existsSync(join(moduleRoot, 'projects'))) {
107
+ return assertNotInNodeModules(resolve(moduleRoot), 'the style-dataset-lab install directory');
108
+ }
109
+
110
+ // 4. Fresh workspace — init will create projects/ here.
111
+ return assertNotInNodeModules(resolve(cwd), 'the current directory');
112
+ }
113
+
114
+ let _workspaceRootCache = null;
115
+
116
+ /**
117
+ * Resolve (and memoize) the workspace root using the real process.cwd()
118
+ * and process.env. Safe to call repeatedly — it only walks the filesystem
119
+ * once per process, unless reset.
120
+ * @returns {string}
121
+ */
122
+ export function getWorkspaceRoot() {
123
+ if (_workspaceRootCache === null) {
124
+ _workspaceRootCache = resolveWorkspaceRootFrom(process.cwd());
125
+ }
126
+ return _workspaceRootCache;
127
+ }
128
+
129
+ /**
130
+ * Clear the memoized workspace root so the next getWorkspaceRoot() call
131
+ * re-resolves from scratch. Exists for tests that need to exercise the
132
+ * resolver under different SDLAB_ROOT/cwd conditions within one process.
133
+ */
134
+ export function resetWorkspaceRootCache() {
135
+ _workspaceRootCache = null;
136
+ }
137
+
138
+ /**
139
+ * Absolute path to the workspace root — the directory that contains (or
140
+ * will contain) `projects/`. Kept as a plain exported constant for
141
+ * backward compatibility with the ~30 existing call sites that do
142
+ * `join(REPO_ROOT, ...)`, but its VALUE now comes from the resolver above
143
+ * rather than being hardcoded to this module's own location.
144
+ */
145
+ export const REPO_ROOT = getWorkspaceRoot();
146
+
147
+ /**
148
+ * Absolute path to the INSTALLED PACKAGE's own root — always module-relative,
149
+ * never the user's workspace.
150
+ *
151
+ * There are two distinct roots and conflating them is a shipping bug:
152
+ *
153
+ * - **workspace root** (`getWorkspaceRoot()`) — where the user's `projects/`
154
+ * live. Correctly cwd-relative, so an npm-installed user's data lands in
155
+ * their own directory instead of inside `node_modules`.
156
+ * - **package root** (this) — where assets that SHIP IN THE TARBALL live:
157
+ * `templates/` (domain starter packs) and `runtime/` (ComfyUI workflow
158
+ * graphs). These belong to the install, not to the workspace.
159
+ *
160
+ * In a repo checkout the two coincide, which is exactly why the distinction
161
+ * is easy to lose and impossible to catch with in-repo tests. Measured in a
162
+ * clean-room tarball install: with `templates/` resolved against the
163
+ * workspace root, `sdlab init demo --domain character-design` failed with
164
+ * `INPUT_UNKNOWN_DOMAIN: Available: generic,` — the five domain packs were
165
+ * sitting in the package the whole time, being looked for in the user's cwd.
166
+ */
167
+ export function getPackageRoot() {
168
+ return resolve(join(__dirname, '..'));
169
+ }
170
+
171
+ // ─── Project name / path safety (SDL-C3) ──────────────────────────
172
+
173
+ // A project name must be a single path segment: no separators, no `..`.
174
+ // This is deliberately a blacklist (not the stricter creation-time
175
+ // `/^[a-z0-9][a-z0-9-]*[a-z0-9]$/` from scripts/init.js) so existing
176
+ // project directories keep resolving; it is paired with the
177
+ // resolveSafeProjectPath() containment check below as defense in depth.
178
+ const UNSAFE_PROJECT_NAME_RE = /\.\.|[\\/]/;
15
179
 
16
180
  /**
17
181
  * Resolve the root directory for a named project.
18
- * @param {string} name — project name (e.g. 'star-freight')
182
+ * @param {string} name — a single path segment, e.g. 'my-project'
19
183
  * @returns {string} absolute path to projects/<name>/
20
184
  */
21
185
  export function getProjectRoot(name) {
@@ -26,12 +190,32 @@ export function getProjectRoot(name) {
26
190
  'Pass --project <name>.'
27
191
  );
28
192
  }
29
- const root = join(REPO_ROOT, 'projects', name);
193
+ if (UNSAFE_PROJECT_NAME_RE.test(name)) {
194
+ throw inputError(
195
+ 'INPUT_UNSAFE_PROJECT_NAME',
196
+ `Project name "${name}" is not valid — it contains a path separator or "..".`,
197
+ `--project takes a name, not a path: it names a directory under projects/. ` +
198
+ `Try --project my-project. Run "sdlab init <name>" to create one.`
199
+ );
200
+ }
201
+ const projectsDir = join(getWorkspaceRoot(), 'projects');
202
+ // Defense in depth: even though the blacklist above already rejects the
203
+ // characters needed to escape projectsDir, reuse the same containment
204
+ // pattern as every other user-path entry point in this file so a future
205
+ // change to the blacklist can't silently reopen the traversal.
206
+ const root = resolveSafeProjectPath(projectsDir, name, { flagName: 'project' });
30
207
  if (!existsSync(root)) {
31
208
  throw inputError(
32
209
  'INPUT_UNKNOWN_PROJECT',
33
210
  `Project "${name}" not found at ${root}.`,
34
- `Run: sdlab init ${name} --domain <domain>`
211
+ // Two different causes land here and they need opposite responses, so
212
+ // name both rather than assuming the project is missing. Since the
213
+ // workspace root is resolved from the current directory, running from
214
+ // the wrong place produces this same error — and blindly following
215
+ // "run init" would then scaffold a duplicate project somewhere else.
216
+ `Looking under ${projectsDir} (workspace resolved from the current directory).\n` +
217
+ ` · Wrong directory? cd to your workspace, or set SDLAB_ROOT to it.\n` +
218
+ ` · New project? sdlab init ${name} --domain character-design`
35
219
  );
36
220
  }
37
221
  return root;
@@ -100,8 +284,13 @@ export function getRunsDir(projectRoot) {
100
284
  }
101
285
 
102
286
  /**
103
- * Return the runtime templates directory (repo-level).
287
+ * Return the ComfyUI runtime workflow templates directory.
288
+ *
289
+ * PACKAGE-relative, deliberately — see `getPackageRoot()`. `runtime/` ships
290
+ * inside the npm tarball; it is not workspace content. Resolving it against
291
+ * the workspace root made `listWorkflowTemplates()` return `[]` for every
292
+ * installed user, which is the bug `getPackageRoot()` exists to prevent.
104
293
  */
105
294
  export function getRuntimeDir() {
106
- return join(REPO_ROOT, 'runtime');
295
+ return join(getPackageRoot(), 'runtime');
107
296
  }