@mcptoolshop/style-dataset-lab 3.3.0 → 3.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.
Files changed (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  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/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -6,6 +6,28 @@
6
6
  */
7
7
 
8
8
  import { detectLane, detectGroup } from './config.js';
9
+ import { warn } from './log.js';
10
+
11
+ /**
12
+ * F5: stable, machine-readable codes — one per exclusion reason, in the
13
+ * exact same order/count as the human-readable `reasons` strings pushed
14
+ * below. Added so categorizeExclusions() can categorize by code instead
15
+ * of depending on prose matching (rewording a `reasons` string used to
16
+ * silently break categorization). Purely additive: `reasons` keeps its
17
+ * exact original shape and text, this is a NEW parallel field
18
+ * (`reason_codes` on the returned object) — any existing caller that only
19
+ * reads `.eligible` / `.reasons` sees no change at all.
20
+ */
21
+ export const REASON_CODES = {
22
+ NO_JUDGMENT: 'no_judgment',
23
+ WRONG_STATUS: 'wrong_status',
24
+ NOT_CANON_BOUND: 'not_canon_bound',
25
+ UNMEASURED_PASS_COUNT: 'unmeasured_pass_count',
26
+ LOW_PASS_RATIO: 'low_pass_ratio',
27
+ EXCLUDED_LANE: 'excluded_lane',
28
+ RIGHTS_FILTER: 'rights_filter',
29
+ EXCLUDED_TAGS: 'excluded_tags',
30
+ };
9
31
 
10
32
  /**
11
33
  * Evaluate a single record against a selection profile.
@@ -13,15 +35,18 @@ import { detectLane, detectGroup } from './config.js';
13
35
  * @param {Object} profile — selection profile
14
36
  * @param {Object} lanesConfig — from lanes.json
15
37
  * @param {Object} termConfig — from terminology.json
16
- * @returns {{ eligible: boolean, reasons: string[] }}
38
+ * @returns {{ eligible: boolean, reasons: string[], reason_codes: string[] }}
17
39
  */
18
40
  export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
19
41
  const reasons = [];
42
+ // F5: parallel array — reasonCodes[i] is the stable code for reasons[i].
43
+ const reasonCodes = [];
20
44
 
21
45
  // 1. Judgment required
22
46
  if (profile.require_judgment) {
23
47
  if (!record.judgment) {
24
48
  reasons.push('no judgment (record has not been reviewed)');
49
+ reasonCodes.push(REASON_CODES.NO_JUDGMENT);
25
50
  }
26
51
  }
27
52
 
@@ -29,6 +54,7 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
29
54
  if (profile.require_status?.length > 0 && record.judgment) {
30
55
  if (!profile.require_status.includes(record.judgment.status)) {
31
56
  reasons.push(`status "${record.judgment.status}" not in [${profile.require_status.join(', ')}]`);
57
+ reasonCodes.push(REASON_CODES.WRONG_STATUS);
32
58
  }
33
59
  }
34
60
 
@@ -36,14 +62,31 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
36
62
  if (profile.require_canon_bound) {
37
63
  if (!record.canon?.assertions || record.canon.assertions.length === 0) {
38
64
  reasons.push('not canon-bound (no assertions)');
65
+ reasonCodes.push(REASON_CODES.NOT_CANON_BOUND);
39
66
  }
40
67
  }
41
68
 
42
69
  // 4. Minimum pass ratio
43
70
  if (profile.minimum_pass_ratio != null && record.canon?.assertion_count > 0) {
44
- const ratio = record.canon.pass_count / record.canon.assertion_count;
45
- if (ratio < profile.minimum_pass_ratio) {
46
- reasons.push(`pass ratio ${(ratio * 100).toFixed(0)}% below minimum ${(profile.minimum_pass_ratio * 100).toFixed(0)}%`);
71
+ const passCount = record.canon.pass_count;
72
+ // pass_count missing/non-numeric while assertion_count > 0 must NOT
73
+ // silently compute NaN: `NaN < minimum_pass_ratio` is always false, so
74
+ // the exclusion would never fire and a record whose pass ratio was never
75
+ // actually measured would be treated as having cleared a bar it never
76
+ // touched. Law 2 (inclusion is explainable) requires this be its own
77
+ // named reason, not a silent pass.
78
+ if (typeof passCount !== 'number' || !Number.isFinite(passCount)) {
79
+ reasons.push(
80
+ `pass_count is missing or not a number (assertion_count is ${record.canon.assertion_count}); ` +
81
+ `cannot verify pass ratio, so it is excluded rather than assumed passing`
82
+ );
83
+ reasonCodes.push(REASON_CODES.UNMEASURED_PASS_COUNT);
84
+ } else {
85
+ const ratio = passCount / record.canon.assertion_count;
86
+ if (ratio < profile.minimum_pass_ratio) {
87
+ reasons.push(`pass ratio ${(ratio * 100).toFixed(0)}% below minimum ${(profile.minimum_pass_ratio * 100).toFixed(0)}%`);
88
+ reasonCodes.push(REASON_CODES.LOW_PASS_RATIO);
89
+ }
47
90
  }
48
91
  }
49
92
 
@@ -53,6 +96,7 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
53
96
  const lane = detectLane(record.id, prompt, lanesConfig);
54
97
  if (profile.exclude_lanes.includes(lane)) {
55
98
  reasons.push(`lane "${lane}" is excluded`);
99
+ reasonCodes.push(REASON_CODES.EXCLUDED_LANE);
56
100
  }
57
101
  }
58
102
 
@@ -61,6 +105,7 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
61
105
  const rights = record.rights_status || record.training_rights_status || null;
62
106
  if (rights !== profile.rights_filter) {
63
107
  reasons.push(`rights "${rights || 'unknown'}" does not match required "${profile.rights_filter}"`);
108
+ reasonCodes.push(REASON_CODES.RIGHTS_FILTER);
64
109
  }
65
110
  }
66
111
 
@@ -69,41 +114,108 @@ export function evaluateEligibility(record, profile, lanesConfig, termConfig) {
69
114
  const excluded = record.dataset_tags.filter(t => profile.exclude_tags.includes(t));
70
115
  if (excluded.length > 0) {
71
116
  reasons.push(`has excluded tags: ${excluded.join(', ')}`);
117
+ reasonCodes.push(REASON_CODES.EXCLUDED_TAGS);
72
118
  }
73
119
  }
74
120
 
75
121
  return {
76
122
  eligible: reasons.length === 0,
77
123
  reasons,
124
+ reason_codes: reasonCodes,
78
125
  };
79
126
  }
80
127
 
81
128
  /**
82
129
  * Categorize exclusion reasons for a set of records.
83
- * @param {Array<{record_id: string, reasons: string[]}>} excluded
84
- * @returns {Object} — category → count mapping
130
+ *
131
+ * F5: every reason is guaranteed to land in exactly one bucket — the
132
+ * mutually-exclusive if/else-if chain below now ends in a trailing `else`
133
+ * that increments `uncategorized` instead of silently matching nothing.
134
+ * Previously, rewording a `reasons` string in evaluateEligibility(), or
135
+ * adding a ninth reason with no matching branch, meant those exclusions
136
+ * were counted in NO category at all — a gap invisible in the output
137
+ * (the category sum would just quietly fall short of the true count).
138
+ *
139
+ * When an `excluded` entry carries `reason_codes` (evaluateEligibility's
140
+ * new parallel array — see REASON_CODES above), categorization uses the
141
+ * stable code directly instead of prose-matching `reasons`. Entries
142
+ * without `reason_codes` (older excluded.jsonl written before F5, or a
143
+ * caller that only forwards `reasons`) fall back to the original
144
+ * free-text matching, unchanged.
145
+ *
146
+ * @param {Array<{record_id: string, reasons: string[], reason_codes?: string[]}>} excluded
147
+ * @returns {Object} — category → count mapping (includes `uncategorized`)
85
148
  */
86
149
  export function categorizeExclusions(excluded) {
87
150
  const categories = {
88
151
  no_judgment: 0,
89
152
  wrong_status: 0,
90
153
  not_canon_bound: 0,
154
+ unmeasured_pass_count: 0,
91
155
  low_pass_ratio: 0,
92
156
  excluded_lane: 0,
93
157
  rights_filter: 0,
94
158
  excluded_tags: 0,
159
+ uncategorized: 0,
95
160
  };
161
+ const KNOWN_CODES = new Set(Object.values(REASON_CODES));
96
162
 
97
- for (const { reasons } of excluded) {
98
- for (const r of reasons) {
163
+ // F5: sample of the actual uncategorized reason strings, surfaced via
164
+ // warn() below so an operator sees WHAT needs a new branch/code instead
165
+ // of just a suspicious gap in the numbers.
166
+ const uncategorizedSamples = [];
167
+ let totalReasons = 0;
168
+
169
+ for (const { reasons, reason_codes: reasonCodes } of excluded) {
170
+ reasons.forEach((r, i) => {
171
+ totalReasons++;
172
+ const code = reasonCodes?.[i];
173
+ if (code && KNOWN_CODES.has(code)) {
174
+ categories[code]++;
175
+ return;
176
+ }
99
177
  if (r.includes('no judgment')) categories.no_judgment++;
100
178
  else if (r.includes('status')) categories.wrong_status++;
101
179
  else if (r.includes('canon-bound')) categories.not_canon_bound++;
180
+ // Order matters: "pass_count missing" must be checked before the
181
+ // generic "pass ratio" match below, since an unmeasured pass_count
182
+ // is a distinct exclusion reason from a measured-and-low ratio (Law 2
183
+ // — inclusion must be explainable, so "never measured" cannot be
184
+ // folded into "measured and failed").
185
+ else if (r.includes('pass_count')) categories.unmeasured_pass_count++;
102
186
  else if (r.includes('pass ratio')) categories.low_pass_ratio++;
103
187
  else if (r.includes('lane')) categories.excluded_lane++;
104
188
  else if (r.includes('rights')) categories.rights_filter++;
105
189
  else if (r.includes('tags')) categories.excluded_tags++;
106
- }
190
+ else {
191
+ categories.uncategorized++;
192
+ if (uncategorizedSamples.length < 10) uncategorizedSamples.push(r);
193
+ }
194
+ });
195
+ }
196
+
197
+ if (categories.uncategorized > 0) {
198
+ const shown = uncategorizedSamples.slice(0, 5);
199
+ warn(
200
+ `eligibility: ${categories.uncategorized} exclusion reason(s) matched no known category ` +
201
+ `(first ${shown.length}: ${shown.join(' | ')})` +
202
+ (uncategorizedSamples.length > shown.length ? ', ...' : '') +
203
+ ' — add a matching reason_code/branch, or these will keep landing in "uncategorized".',
204
+ );
205
+ }
206
+
207
+ // F5: defensive tripwire, not a normal-path check — the if/else-if chain
208
+ // above is mutually exclusive and the trailing else always catches
209
+ // anything unmatched, so this sum is mathematically guaranteed to equal
210
+ // totalReasons for any input. A mismatch would mean a future edit broke
211
+ // that mutual-exclusivity guarantee (e.g. turned an `else if` into a
212
+ // bare `if`, allowing one reason to double-count).
213
+ const categorizedSum = Object.values(categories).reduce((a, b) => a + b, 0);
214
+ if (categorizedSum !== totalReasons) {
215
+ warn(
216
+ `eligibility: categorization invariant violated — categorized ${categorizedSum} reason(s) but ` +
217
+ `${totalReasons} were processed. This indicates a bug in categorizeExclusions itself.`,
218
+ );
107
219
  }
108
220
 
109
221
  return categories;
package/lib/eval-pack.js CHANGED
@@ -10,14 +10,13 @@
10
10
  * 4. Subject continuity — same-subject records for identity testing
11
11
  */
12
12
 
13
- import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
14
14
  import { join } from 'node:path';
15
15
  import { existsSync } from 'node:fs';
16
16
  import { randomBytes } from 'node:crypto';
17
17
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
18
18
  import { loadRecord } from './records.js';
19
- import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
20
- import { inputError } from './errors.js';
19
+ import { computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
21
20
 
22
21
  /**
23
22
  * Generate an eval-pack ID: eval-YYYYMMDD-HHMMSS-XXXX
@@ -73,17 +72,18 @@ export async function buildEvalPack(projectRoot, options = {}) {
73
72
  const subjectContinuity = buildSubjectContinuity(records, maxContinuity);
74
73
 
75
74
  // Write eval pack
76
- const evalId = generateEvalId();
77
- const evalDir = join(projectRoot, 'eval-packs', evalId);
78
- // D-005: refuse to silently overwrite on ID collision.
79
- if (existsSync(evalDir)) {
80
- throw inputError(
81
- 'EVAL_PACK_ID_COLLISION',
82
- `Eval pack directory already exists: ${evalDir}`,
83
- 'Retry to generate a fresh eval pack ID.',
84
- );
85
- }
86
- await mkdir(evalDir, { recursive: true });
75
+ // SDL-M10: atomically claim the eval-pack id/dir — closes the
76
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
77
+ // (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
78
+ const { id: evalId, dir: evalDir } = await claimIdDir(
79
+ join(projectRoot, 'eval-packs'),
80
+ generateEvalId,
81
+ {
82
+ code: 'EVAL_PACK_ID_COLLISION',
83
+ message: 'Could not claim a unique eval-pack ID — every candidate collided with an existing eval-pack directory.',
84
+ hint: 'Retry; if this persists, another sdlab process may be creating eval packs in a tight loop.',
85
+ },
86
+ );
87
87
 
88
88
  // DB-014: compute `records_available` so the manifest reports sampled vs.
89
89
  // available. Operators can see when they're leaving records on the table.
package/lib/eval-runs.js CHANGED
@@ -14,6 +14,7 @@ import { loadTrainingManifest } from './training-manifests.js';
14
14
  import { loadEvalPack } from './eval-pack.js';
15
15
  import { loadProjectConfig, detectLane, detectGroup } from './config.js';
16
16
  import { SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
17
+ import { inputError } from './errors.js';
17
18
 
18
19
  // DB-008: cap sample record_ids per failure bucket so scorecards stay small
19
20
  // but the operator has enough detail to start investigation.
@@ -91,6 +92,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
91
92
 
92
93
  // Load eval pack tasks
93
94
  const evalPackDir = join(projectRoot, 'eval-packs', runManifest.eval_pack_id);
95
+ // F2: createEvalRun only ever stores the eval_pack_id string — it never
96
+ // re-verifies at scoring time that the pack directory still exists. If
97
+ // the pack was deleted (or moved) between run creation and scoring, this
98
+ // used to fall straight through to the all-four-skip / vacuous-pass path
99
+ // below. Fail loudly here with the exact missing path instead.
100
+ if (!existsSync(evalPackDir)) {
101
+ throw inputError(
102
+ 'EVAL_PACK_DIR_MISSING',
103
+ `Eval pack directory not found: ${evalPackDir} (eval run "${evalRunId}" references eval_pack_id "${runManifest.eval_pack_id}").`,
104
+ 'The eval pack this run was created against may have been deleted or moved. Rebuild the eval pack, or create a fresh eval run against one that still exists.',
105
+ );
106
+ }
94
107
  const taskFiles = {
95
108
  'lane-coverage': 'lane-coverage.jsonl',
96
109
  'forbidden-drift': 'forbidden-drift.jsonl',
@@ -102,10 +115,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
102
115
 
103
116
  // Score each task type
104
117
  const scores = {};
118
+ // F2: count task files that actually resolved. Object.values({}).every(...)
119
+ // on an EMPTY scores object is vacuously true in JS — with zero task files
120
+ // found, `scores` stays `{}` and the verdict below silently computed
121
+ // overall_verdict:'pass', overall_score:0.000 for a model that was never
122
+ // evaluated against anything. resolvedTaskFiles turns that into a named,
123
+ // structured failure instead of a green rubber stamp.
124
+ let resolvedTaskFiles = 0;
105
125
 
106
126
  for (const [taskName, fileName] of Object.entries(taskFiles)) {
107
127
  const taskPath = join(evalPackDir, fileName);
108
128
  if (!existsSync(taskPath)) continue;
129
+ resolvedTaskFiles++;
109
130
 
110
131
  const taskEntries = (await readFile(taskPath, 'utf-8'))
111
132
  .trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
@@ -121,6 +142,18 @@ export async function scoreEvalRun(projectRoot, evalRunId, outputsPath) {
121
142
  }
122
143
  }
123
144
 
145
+ // F2: belt-and-suspenders — even with the directory-existence guard above,
146
+ // a pack directory that exists but has had every individual task file
147
+ // deleted out from under it must hit the same guard, not the vacuous
148
+ // Object.values({}).every(...) === true path.
149
+ if (resolvedTaskFiles === 0) {
150
+ throw inputError(
151
+ 'EVAL_PACK_NO_TASK_FILES',
152
+ `Eval pack "${runManifest.eval_pack_id}" at ${evalPackDir} has no resolvable task files — nothing was scored for eval run "${evalRunId}".`,
153
+ `Expected at least one of: ${Object.values(taskFiles).join(', ')}. Verify the eval pack was built correctly and its directory was not partially deleted.`,
154
+ );
155
+ }
156
+
124
157
  // Compute overall verdict
125
158
  const allPassed = Object.values(scores).every(s => s.passed);
126
159
  const overallScore = Object.values(scores).reduce((sum, s) => sum + (s.score || 0), 0) /
package/lib/export.js CHANGED
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { platform } from 'node:os';
12
12
  import { createHash, randomBytes } from 'node:crypto';
13
13
  import { loadProjectConfig, loadExportProfile, detectLane, detectGroup } from './config.js';
14
- import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion } from './snapshot.js';
14
+ import { loadSnapshot, loadSnapshotIncluded, computeConfigFingerprint, SCHEMA_VERSION, checkManifestVersion, claimIdDir } from './snapshot.js';
15
15
  import { loadSplit, loadSplitPartition } from './split.js';
16
16
  import { loadRecord } from './records.js';
17
17
  import { generateCard } from './card.js';
@@ -36,6 +36,63 @@ async function sha256File(filePath) {
36
36
  return createHash('sha256').update(data).digest('hex');
37
37
  }
38
38
 
39
+ /**
40
+ * SDL-H6: validate that a record-relative asset_path does not escape
41
+ * projectRoot. Rejects absolute paths outright (ASSET_PATH_INVALID) and
42
+ * rejects any relative path that resolves outside projectRoot
43
+ * (ASSET_PATH_TRAVERSAL — e.g. "../../../../etc/passwd").
44
+ * Returns the resolved absolute path on success.
45
+ *
46
+ * This was previously inlined here only (D-007) and never ported to the
47
+ * training-package adapters, which checked only `existsSync` — a
48
+ * traversal path satisfies existsSync just as well as a legitimate one.
49
+ * lib/training-packages.js and the adapters import this so the exact same
50
+ * guard (same error codes) applies on every path that turns asset_path
51
+ * into a filesystem read.
52
+ */
53
+ export function assertSafeAssetPath(assetPath, projectRoot, recordId) {
54
+ if (isAbsolute(assetPath)) {
55
+ throw inputError(
56
+ 'ASSET_PATH_INVALID',
57
+ `Record "${recordId}" has absolute asset_path: ${assetPath}`,
58
+ 'asset_path must be relative to the project root.',
59
+ );
60
+ }
61
+ const projectRootResolved = resolve(projectRoot);
62
+ const resolvedSource = resolve(projectRootResolved, assetPath);
63
+ if (resolvedSource !== projectRootResolved &&
64
+ !resolvedSource.startsWith(projectRootResolved + sep)) {
65
+ throw inputError(
66
+ 'ASSET_PATH_TRAVERSAL',
67
+ `Record "${recordId}" asset_path escapes projectRoot: ${assetPath}`,
68
+ 'Reject path traversal segments (e.g. "../") in asset_path.',
69
+ );
70
+ }
71
+ return resolvedSource;
72
+ }
73
+
74
+ /**
75
+ * SDL-H5: allowlist model for an id destined to become a filename or path
76
+ * segment (letters, digits, dot, underscore, hyphen only — no `/`, `\`,
77
+ * or `..`, so no traversal is expressible). Mirrors the character class
78
+ * lib/selections.js's SAFE_FILENAME_RE uses for approved-output filenames;
79
+ * that constant isn't exported and additionally requires an image
80
+ * extension suffix that doesn't apply to a bare record/entity id, so this
81
+ * is a parallel definition over the same allowlist model rather than a
82
+ * copy of a differently-shaped regex.
83
+ */
84
+ export const SAFE_ID_RE = /^[A-Za-z0-9._-]+$/;
85
+
86
+ export function assertSafeId(id, context) {
87
+ if (typeof id !== 'string' || !SAFE_ID_RE.test(id)) {
88
+ throw inputError(
89
+ 'UNSAFE_RECORD_ID',
90
+ `Invalid id "${id}" in ${context}`,
91
+ 'ids must match [A-Za-z0-9._-]+ — letters, digits, dot, underscore, hyphen only.',
92
+ );
93
+ }
94
+ }
95
+
39
96
  /**
40
97
  * Build an export package.
41
98
  *
@@ -55,22 +112,12 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
55
112
  const included = await loadSnapshotIncluded(projectRoot, snapshotId);
56
113
  const recordsDir = join(projectRoot, 'records');
57
114
 
58
- const exportId = generateExportId();
59
- const exportDir = join(projectRoot, 'exports', exportId);
60
-
61
- // D-005: existsSync guard prevents silent overwrite on ID collision
62
- if (existsSync(exportDir)) {
63
- throw inputError(
64
- 'EXPORT_ID_COLLISION',
65
- `Export directory already exists: ${exportDir}`,
66
- 'Retry to generate a fresh export ID, or delete the existing directory if this was intentional.',
67
- );
68
- }
69
-
70
115
  // D-001: Verify fingerprint has not drifted since snapshot creation.
71
116
  // We inherit the snapshot's fingerprint in the export manifest; if a caller
72
117
  // has edited project config mid-flight we surface that loudly rather than
73
118
  // silently embedding a new fingerprint that masks reproducibility loss.
119
+ // (Checked BEFORE claiming an export id/dir below — no point reserving
120
+ // one if we're about to abort.)
74
121
  const currentFingerprint = computeConfigFingerprint(projectRoot);
75
122
  if (snapshot.config_fingerprint && currentFingerprint !== snapshot.config_fingerprint) {
76
123
  throw inputError(
@@ -80,18 +127,44 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
80
127
  );
81
128
  }
82
129
 
83
- await mkdir(exportDir, { recursive: true });
130
+ // SDL-M10: atomically claim the export id/dir — closes the
131
+ // check-then-act race the old existsSync+recursive-mkdir pattern had
132
+ // (D-005's intent, now actually race-proof; see claimIdDir in snapshot.js).
133
+ const { id: exportId, dir: exportDir } = await claimIdDir(
134
+ join(projectRoot, 'exports'),
135
+ generateExportId,
136
+ {
137
+ code: 'EXPORT_ID_COLLISION',
138
+ message: 'Could not claim a unique export ID — every candidate collided with an existing export directory.',
139
+ hint: 'Retry; if this persists, another sdlab process may be creating exports in a tight loop.',
140
+ },
141
+ );
84
142
 
85
143
  // Build metadata + collect image paths
86
144
  const metadata = [];
87
145
  const imagePaths = [];
88
146
  const checksumEntries = [];
147
+ // F3: a record the (frozen, Law-1) snapshot included whose file was
148
+ // later deleted from records/ must never silently drop the export's
149
+ // record count below snapshot.counts.included. loadRecord() returns
150
+ // null on ENOENT (lib/records.js) — previously that null was a silent
151
+ // `continue` with no counter, no warning, no id recorded, so an export
152
+ // built from a snapshot with stale/deleted records would just quietly
153
+ // contain fewer rows than snapshot.counts.included promised. Accumulated
154
+ // across the whole loop (not fail-fast on the first) so the operator
155
+ // sees every missing id in one error instead of fixing them one at a
156
+ // time; hard-failed below rather than warned — see the comment at the
157
+ // throw site for why export specifically earns that escalation.
158
+ const missingRecords = [];
89
159
 
90
160
  const projectRootResolved = resolve(projectRoot);
91
161
 
92
162
  for (const entry of included) {
93
163
  const record = await loadRecord(recordsDir, entry.record_id);
94
- if (!record) continue;
164
+ if (!record) {
165
+ missingRecords.push({ record_id: entry.record_id, stage: 'export' });
166
+ continue;
167
+ }
95
168
 
96
169
  const prompt = record.provenance?.prompt || '';
97
170
  const lane = detectLane(record.id, prompt, config.lanes);
@@ -115,28 +188,20 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
115
188
 
116
189
  // Track image for linking
117
190
  if (profile.include_images && record.asset_path) {
118
- // D-007: validate asset_path does not escape projectRoot, use
119
- // platform-safe basename, and name output files by record.id
120
- // (D-009) to avoid collisions on shared basenames.
121
- if (isAbsolute(record.asset_path)) {
122
- throw inputError(
123
- 'ASSET_PATH_INVALID',
124
- `Record "${record.id}" has absolute asset_path: ${record.asset_path}`,
125
- 'asset_path must be relative to the project root.',
126
- );
127
- }
128
- const resolvedSource = resolve(projectRootResolved, record.asset_path);
129
- if (resolvedSource !== projectRootResolved &&
130
- !resolvedSource.startsWith(projectRootResolved + sep)) {
131
- throw inputError(
132
- 'ASSET_PATH_TRAVERSAL',
133
- `Record "${record.id}" asset_path escapes projectRoot: ${record.asset_path}`,
134
- 'Reject path traversal segments (e.g. "../") in asset_path.',
135
- );
136
- }
191
+ // D-007 / SDL-H6: validate asset_path does not escape projectRoot.
192
+ const resolvedSource = assertSafeAssetPath(record.asset_path, projectRootResolved, record.id);
137
193
  const sourceBasename = basename(record.asset_path);
138
194
  const extMatch = sourceBasename.match(/\.[^.]+$/);
139
195
  const ext = extMatch ? extMatch[0] : '';
196
+ // SDL-H5: the block above only validated the SOURCE asset_path —
197
+ // record.id becomes part of the DESTINATION filename below and was
198
+ // completely unvalidated despite the variable being named
199
+ // "safeFilename". A record id of "../../../evil" would escape the
200
+ // export dir on write. Reachable without hand-authoring: reingest
201
+ // derives ids from generated-output filenames.
202
+ assertSafeId(record.id, `record.id for export image filename (record "${record.id}")`);
203
+ // D-009: name output files by record.id to avoid collisions on
204
+ // shared basenames.
140
205
  const safeFilename = `${record.id}${ext}`;
141
206
  imagePaths.push({
142
207
  record_id: record.id,
@@ -146,6 +211,29 @@ export async function buildExport(projectRoot, snapshotId, splitId, options = {}
146
211
  }
147
212
  }
148
213
 
214
+ // F3: hard-fail on any record the snapshot promised but records/ no
215
+ // longer has. Nothing has been written to exportDir yet (metadata.jsonl,
216
+ // images/, manifest.json, etc. are all written below this point) — an
217
+ // aborted export leaves only the empty claimed directory behind, no
218
+ // partial/misleading artifacts. Export (and training-packages.js one
219
+ // layer further downstream) is where Law 4 ("exports are reproducible —
220
+ // rebuildable from snapshot + split + profile") makes completeness an
221
+ // actual contract rather than a courtesy, which is why this escalates to
222
+ // a thrown error instead of the warn()-and-continue ledger pattern used
223
+ // in split.js and implementation-packs.js, where the artifact produced
224
+ // is not itself the reproducibility-guaranteeing one.
225
+ if (missingRecords.length > 0) {
226
+ const sample = missingRecords.slice(0, 10).map(m => m.record_id);
227
+ throw inputError(
228
+ 'EXPORT_MISSING_RECORDS',
229
+ `${missingRecords.length} of ${included.length} record(s) referenced by snapshot "${snapshotId}" could not be loaded from ${recordsDir} — this export would silently contain fewer records than the snapshot's included count.` +
230
+ (missingRecords.length > 10
231
+ ? ` First 10: ${sample.join(', ')}, ...`
232
+ : ` Missing: ${sample.join(', ')}.`),
233
+ 'Record file(s) were likely deleted after the snapshot was created (a snapshot freezes the SELECTION, not the underlying record files). Restore the missing file(s), or create a fresh snapshot that reflects current on-disk records before exporting.',
234
+ );
235
+ }
236
+
149
237
  // Write metadata.jsonl
150
238
  const metadataPath = join(exportDir, 'metadata.jsonl');
151
239
  await writeFile(metadataPath, metadata.map(r => JSON.stringify(r)).join('\n') + '\n');
@@ -26,6 +26,10 @@ import { loadCanonEntriesInDir, entryId } from './canon-build/load-entry.js';
26
26
  import { readFreezeStatus } from './freeze-stamp.js';
27
27
  import { appendEvent, readEventsFor } from './freeze-events.js';
28
28
  import { inputError } from './errors.js';
29
+ import { warn } from './log.js';
30
+ import { assertDirectorRatifiedForFreeze } from './canon-review-gate.js';
31
+
32
+ export { assertDirectorRatifiedForFreeze };
29
33
 
30
34
  /**
31
35
  * Best-effort freeze date for an entry: the `at` of its most recent freeze /
@@ -74,9 +78,18 @@ export async function loadAllCanonEntries(projectRoot) {
74
78
  let config;
75
79
  try {
76
80
  config = await loadBuildConfig(configPath, projectRoot);
77
- } catch {
78
- // Invalid config → degrade to "no gate" rather than blocking every write.
79
- // The operator will hit the config error on `sdlab canon build`.
81
+ } catch (err) {
82
+ // Invalid config → degrade to "no gate" rather than blocking every write
83
+ // (a config typo shouldn't turn into "no one can generate/curate/paint
84
+ // anything in this project"). But that degrade-open MUST be audible —
85
+ // silently disabling freeze protection project-wide with zero operator
86
+ // signal is its own defect. Warn loudly to stderr; the operator will
87
+ // also hit the same config error surfaced properly on `sdlab canon build`.
88
+ warn(
89
+ `freeze gate disabled for this project: canon-build/config.json is invalid ` +
90
+ `(${err.message}). Every entry is being treated as not canon-bound — freeze/soft-advisory ` +
91
+ `protection is OFF until the config is fixed. Run "sdlab canon build" to see the full error.`,
92
+ );
80
93
  return [];
81
94
  }
82
95
  const entries = [];
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { createHash } from 'node:crypto';
12
12
  import { getByPath } from './canon-build/load-config.js';
13
+ import { inputError } from './errors.js';
13
14
 
14
15
  /**
15
16
  * The four legal freeze statuses. Entries without a `freeze` block behave
@@ -19,13 +20,37 @@ export const FREEZE_STATUSES = Object.freeze(['auto', 'frozen', 'soft-advisory',
19
20
 
20
21
  /**
21
22
  * Read the normalized freeze status for an entry frontmatter.
22
- * Missing block / missing status / invalid value all collapse to `auto`.
23
+ *
24
+ * A missing `freeze` block, or a `freeze` block with no `status` key at all,
25
+ * collapses to `auto` — that permissive default is deliberate (D8): an entry
26
+ * that has never opted into freeze protection should never block a write.
27
+ *
28
+ * But a `freeze` block that IS present with a `status` value that is a
29
+ * non-empty string outside `FREEZE_STATUSES` is not "absent" — it is
30
+ * malformed. Coercing that case to `auto` too would silently disable hard
31
+ * freeze protection on a hand-edited typo (e.g. `status: Frozen` against the
32
+ * lowercase-only enum) with no error, no warning, and no bypass event, since
33
+ * the bypass path is never entered. Fail loud instead: throw so the operator
34
+ * fixes the typo rather than unknowingly shipping an unprotected entry.
35
+ *
36
+ * @throws {SdlabError} CANON_FREEZE_STATUS_INVALID when `freeze` is present
37
+ * and `status` is a non-empty string that isn't a legal FREEZE_STATUSES value.
23
38
  */
24
39
  export function readFreezeStatus(frontmatter) {
25
40
  const status = frontmatter?.freeze?.status;
26
41
  if (typeof status !== 'string') return 'auto';
27
- if (!FREEZE_STATUSES.includes(status)) return 'auto';
28
- return status;
42
+ if (FREEZE_STATUSES.includes(status)) return status;
43
+ if (status.length > 0) {
44
+ throw inputError(
45
+ 'CANON_FREEZE_STATUS_INVALID',
46
+ `Invalid freeze.status "${status}"${frontmatter?.id ? ` on entry "${frontmatter.id}"` : ''}: ` +
47
+ `not one of [${FREEZE_STATUSES.join(', ')}].`,
48
+ `Fix the freeze.status value (check for a case typo — statuses are lowercase-only). ` +
49
+ `A malformed status is treated as an error, not as "auto", so a typo can never ` +
50
+ `silently disable freeze protection.`,
51
+ );
52
+ }
53
+ return 'auto';
29
54
  }
30
55
 
31
56
  /**