dravoice 0.1.2 → 0.1.3

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.
@@ -1,10 +1,11 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { learnVoicePackV2, loadVoicePackV2 } from "./profile.js";
4
- import { voicePromptPackV2 } from "./prompt.js";
5
- import { reviewVoiceDraftV2 } from "./review.js";
6
- import { clampScore } from "./text-utils.js";
7
-
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { readJsonFileBounded, readUtf8FileBounded, writeUtf8FileSafely } from "./io-utils.js";
4
+ import { learnVoicePackV2, loadVoicePackV2 } from "./profile.js";
5
+ import { voicePromptPackV2 } from "./prompt.js";
6
+ import { reviewVoiceDraftV2 } from "./review.js";
7
+ import { clampScore } from "./text-utils.js";
8
+
8
9
  const BENCHMARK_SCHEMA_VERSION = 2;
9
10
  const GENERATED_BY = "dravoice-v2-benchmark";
10
11
 
@@ -19,517 +20,515 @@ export function prepareVoiceBenchmark({ examplesDir, topic, outDir, seed = 1, cw
19
20
  fs.mkdirSync(promptsDir, { recursive: true });
20
21
  fs.mkdirSync(draftsDir, { recursive: true });
21
22
 
22
- const profile = learnVoicePackV2({ examplesDir: examplesRoot, outDir: voicePackDir });
23
+ const profile = learnVoicePackV2({ examplesDir: examplesRoot, outDir: voicePackDir, excludePaths: [outputRoot] });
23
24
  const sources = benchmarkSourcesFromProfile(profile);
24
- const blind = blindMapping(normalizedSeed);
25
- const benchmark = {
26
- schemaVersion: BENCHMARK_SCHEMA_VERSION,
27
- generatedBy: GENERATED_BY,
28
- mode: "harness-only",
29
- topic,
30
- seed: normalizedSeed,
31
- corpus: {
32
- fileCount: sources.length,
33
- sourceRoot: null,
34
- files: sources.map((source) => ({
35
- id: source.id,
36
- path: source.path,
37
- title: source.title,
38
- words: source.words,
39
- })),
40
- voiceSource: profile.source,
41
- },
42
- blind,
43
- blindLabels: blind,
44
- paths: {
45
- voicePack: "voice-pack",
46
- prompts: {
47
- baselineWriter: "prompts/baseline-writer.md",
48
- voiceWriter: "prompts/voice-writer.md",
49
- judge: "prompts/judge.md",
50
- },
51
- drafts: {
52
- baseline: "drafts/baseline.md",
53
- voiceAssisted: "drafts/voice-assisted.md",
54
- },
55
- judge: "judge/judgment.json",
56
- scores: "scores.json",
57
- report: "report.md",
58
- },
59
- };
60
-
61
- writeIfMissing(path.join(draftsDir, "baseline.md"), draftPlaceholder("Baseline Draft", "Replace this file with Actor 1 output before scoring."));
62
- writeIfMissing(path.join(draftsDir, "voice-assisted.md"), draftPlaceholder("Voice-Assisted Draft", "Replace this file with Actor 2 output before scoring."));
63
- fs.writeFileSync(path.join(promptsDir, "baseline-writer.md"), renderBaselinePrompt({ topic, sources }), "utf8");
64
- fs.writeFileSync(path.join(promptsDir, "voice-writer.md"), renderVoiceWriterPrompt({ topic, sources, voice: profile }), "utf8");
65
- fs.writeFileSync(
66
- path.join(promptsDir, "judge.md"),
67
- renderJudgePrompt({
68
- topic,
69
- sources,
70
- blind,
71
- drafts: ["A", "B"].map((label) => ({
72
- label,
73
- path: blind.draftFiles[label],
74
- words: 0,
75
- })),
76
- }),
77
- "utf8",
78
- );
79
- fs.writeFileSync(path.join(outputRoot, "benchmark.json"), `${JSON.stringify(benchmark, null, 2)}\n`, "utf8");
80
-
81
- return benchmark;
82
- }
83
-
84
- export function scoreVoiceBenchmark({ runDir, judgePath, judgeFile, judge, cwd = process.cwd() }) {
85
- const root = path.resolve(resolvePath(cwd, runDir));
86
- const resolvedJudgePath = judgePath ?? judgeFile ?? judge;
87
- const benchmarkPath = path.join(root, "benchmark.json");
88
- if (!fs.existsSync(benchmarkPath)) {
89
- throw new Error(`No V2 voice benchmark found at ${runDir}. Run dravoice benchmark prepare first.`);
90
- }
91
-
92
- const benchmark = JSON.parse(fs.readFileSync(benchmarkPath, "utf8"));
93
- const resolved = resolveBenchmarkPaths(root, benchmark);
94
- assertBenchmarkDraftsReady(benchmark, resolved);
95
- const voice = loadVoicePackV2(resolved.paths.voicePack);
96
- fs.mkdirSync(path.dirname(resolved.paths.prompts.judge), { recursive: true });
97
- fs.writeFileSync(
98
- resolved.paths.prompts.judge,
99
- renderJudgePrompt({
100
- topic: benchmark.topic,
101
- sources: benchmarkSources(benchmark, resolved),
102
- blind: benchmark.blind,
103
- drafts: benchmarkDrafts(benchmark, resolved),
104
- }),
105
- "utf8",
106
- );
107
-
108
- const baselineReview = reviewVoiceDraftV2({
109
- file: resolved.paths.drafts.baseline,
110
- voice,
111
- cwd: root,
112
- });
113
- const voiceReview = reviewVoiceDraftV2({
114
- file: resolved.paths.drafts.voiceAssisted,
115
- voice,
116
- cwd: root,
117
- });
118
- const judgeData = resolvedJudgePath ? loadJudge(resolvePath(cwd, resolvedJudgePath)) : null;
119
- const scores = benchmarkScores({
120
- benchmark,
121
- baselineReview,
122
- voiceReview,
123
- judge: judgeData,
124
- judgePath: resolvedJudgePath ? displayPath(resolvePath(cwd, resolvedJudgePath), root) : null,
125
- });
126
-
127
- fs.writeFileSync(resolved.paths.scores, `${JSON.stringify(scores, null, 2)}\n`, "utf8");
128
- fs.writeFileSync(resolved.paths.report, renderBenchmarkReport(scores), "utf8");
129
- return scores;
130
- }
131
-
132
- export function renderBenchmarkReport(scores) {
133
- const lines = [
134
- "# Dravoice V2 Voice Benchmark Report",
135
- "",
136
- `Topic: ${scores.topic}`,
137
- `Mode: ${scores.mode}`,
138
- "",
139
- ];
140
-
141
- if (scores.winner.final) {
142
- lines.push(`Final winner: ${scores.winner.draft} (${scores.winner.label})`);
143
- lines.push(`Margin: ${scores.winner.margin}`);
144
- } else {
145
- lines.push("Final winner: unavailable until judge JSON is supplied.");
146
- lines.push(`Deterministic provisional leader: ${scores.deterministicWinner.draft} (${scores.deterministicWinner.label})`);
147
- }
148
- lines.push("Single benchmark run is directional, not proof; compare repeated runs and family diagnostics before deciding.");
149
-
150
- for (const key of ["baseline", "voiceAssisted"]) {
151
- const draft = scores.drafts[key];
152
- lines.push("");
153
- lines.push(`## ${draft.name}`);
154
- lines.push(`Blind label: ${draft.label}`);
155
- lines.push(`Voice fit: ${draft.deterministic.voiceFit}`);
156
- lines.push(`Fit band: ${draft.deterministic.fitBand}`);
157
- lines.push("Family scores:");
158
- for (const [family, score] of Object.entries(draft.deterministic.familyScores)) {
159
- lines.push(`- ${family}: ${score}`);
160
- }
161
- if (draft.judge) {
162
- lines.push(`Judge voice score: ${draft.judge.voiceScore}`);
163
- lines.push(`Hybrid score: ${draft.hybridScore}`);
164
- lines.push(`Rationale: ${draft.judge.rationale}`);
165
- }
166
- }
167
-
168
- return `${lines.join("\n")}\n`;
169
- }
170
-
171
- function benchmarkScores({ benchmark, baselineReview, voiceReview, judge, judgePath }) {
172
- const baselineLabel = benchmark.blind.labels.baseline;
173
- const voiceLabel = benchmark.blind.labels.voiceAssisted;
174
- const baseline = draftScore({ key: "baseline", name: "Baseline", label: baselineLabel, review: baselineReview, judge });
175
- const voiceAssisted = draftScore({ key: "voiceAssisted", name: "Voice-assisted", label: voiceLabel, review: voiceReview, judge });
176
- const deterministicWinner = winnerFromScores({
177
- baseline: baseline.deterministic.voiceFit,
178
- voiceAssisted: voiceAssisted.deterministic.voiceFit,
179
- }, benchmark.blind.labels);
180
-
181
- let winner = {
182
- final: false,
183
- draft: null,
184
- label: null,
185
- margin: null,
186
- reason: "No judge file provided.",
187
- };
188
- if (judge) {
189
- winner = {
190
- final: true,
191
- ...winnerFromScores({
192
- baseline: baseline.hybridScore,
193
- voiceAssisted: voiceAssisted.hybridScore,
194
- }, benchmark.blind.labels),
195
- reason: "Hybrid score combines blind judge voice score, deterministic family fit, judge generic-drift score, and judge originality score.",
196
- };
197
- }
198
-
199
- return {
200
- schemaVersion: BENCHMARK_SCHEMA_VERSION,
201
- generatedBy: GENERATED_BY,
202
- mode: judge ? "final" : "provisional",
203
- topic: benchmark.topic,
204
- seed: benchmark.seed,
205
- blind: benchmark.blind,
206
- judge: {
207
- provided: Boolean(judge),
208
- path: judgePath,
209
- declaredWinner: judge?.winner ?? null,
210
- },
211
- formula: {
212
- judgeVoice: 0.55,
213
- deterministicFamilyFit: 0.25,
214
- judgeGenericDrift: 0.10,
215
- judgeOriginality: 0.10,
216
- },
217
- drafts: {
218
- baseline,
219
- voiceAssisted,
220
- },
221
- deterministicWinner,
222
- winner,
223
- exitCode: 0,
224
- };
225
- }
226
-
227
- function draftScore({ key, name, label, review, judge }) {
228
- const deterministic = deterministicScore(review);
229
- const judgeDraft = judge ? normalizeJudgeDraft(judge.drafts?.[label], label) : null;
230
- const hybridScore = judgeDraft
231
- ? roundHalfUp(
232
- judgeDraft.voiceScore * 0.55 +
233
- deterministic.voiceFit * 0.25 +
234
- judgeDraft.genericDriftScore * 0.10 +
235
- judgeDraft.originalityScore * 0.10,
236
- 2,
237
- )
238
- : null;
239
-
240
- return {
241
- key,
242
- name,
243
- label,
244
- file: review.file,
245
- deterministic,
246
- judge: judgeDraft,
247
- hybridScore,
248
- };
249
- }
250
-
251
- function deterministicScore(review) {
252
- const reviewFindings = review.findings.filter((finding) => finding.priority === "review").length;
253
- const considerFindings = review.findings.filter((finding) => finding.priority === "consider").length;
254
- return {
255
- voiceFit: clampScore(100 - review.summary.fit.distance),
256
- fitBand: review.summary.fit.band,
257
- familyScores: review.summary.familyScores,
258
- voiceDistance: review.summary.fit.distance,
259
- reviewFindings,
260
- considerFindings,
261
- };
262
- }
263
-
264
- function winnerFromScores(scores, labels) {
265
- if (scores.baseline === scores.voiceAssisted) {
266
- return { draft: "tie", label: "tie", margin: 0 };
267
- }
268
- const draft = scores.voiceAssisted > scores.baseline ? "voice-assisted" : "baseline";
269
- return {
270
- draft,
271
- label: draft === "voice-assisted" ? labels.voiceAssisted : labels.baseline,
272
- margin: roundHalfUp(Math.abs(scores.voiceAssisted - scores.baseline), 2),
273
- };
274
- }
275
-
276
- function loadJudge(judgePath) {
277
- const resolved = path.resolve(judgePath);
278
- if (!fs.existsSync(resolved)) {
279
- throw new Error(`Judge file not found: ${judgePath}`);
280
- }
281
- const judge = JSON.parse(fs.readFileSync(resolved, "utf8"));
282
- if (!judge.drafts || !judge.drafts.A || !judge.drafts.B) {
283
- throw new Error("Judge JSON must include drafts.A and drafts.B.");
284
- }
285
- return judge;
286
- }
287
-
288
- function normalizeJudgeDraft(value, label) {
289
- if (!value) {
290
- throw new Error(`Judge JSON is missing scores for Draft ${label}.`);
291
- }
292
- return {
293
- voiceScore: requiredScore(value.voiceScore, `drafts.${label}.voiceScore`),
294
- genericDriftScore: requiredScore(value.genericDriftScore, `drafts.${label}.genericDriftScore`),
295
- originalityScore: requiredScore(value.originalityScore, `drafts.${label}.originalityScore`),
296
- rationale: String(value.rationale ?? ""),
297
- };
298
- }
299
-
300
- function requiredScore(value, field) {
301
- if (typeof value !== "number" || !Number.isFinite(value)) {
302
- throw new Error(`Judge JSON field ${field} must be a number.`);
303
- }
304
- if (value < 0 || value > 100) {
305
- throw new Error(`Judge JSON field ${field} must be between 0 and 100.`);
306
- }
307
- return value;
308
- }
309
-
310
- function renderBaselinePrompt({ topic, sources }) {
311
- return [
312
- "# Baseline Writer Prompt",
313
- "",
314
- "Write a new article on the requested topic using only the source articles as style examples.",
315
- "Do not mention this benchmark. Do not explain your process.",
316
- "",
317
- `Topic: ${topic}`,
318
- "",
319
- renderSources(sources),
320
- ].join("\n");
321
- }
322
-
323
- function renderVoiceWriterPrompt({ topic, sources, voice }) {
324
- return [
325
- "# Voice-Assisted Writer Prompt",
326
- "",
327
- "Write a new article on the requested topic. Use the learned Dravoice V2 feature-family guidance as the writing brief.",
328
- "Do not mention this benchmark. Do not explain your process.",
329
- "",
330
- `Topic: ${topic}`,
331
- "",
332
- voicePromptPackV2({ voice, format: "agents" }).trim(),
333
- "",
334
- renderSources(sources),
335
- ].join("\n");
336
- }
337
-
338
- function renderJudgePrompt({ topic, sources, blind, drafts }) {
339
- return [
340
- "# Blind V2 Voice Benchmark Judge Prompt",
341
- "",
342
- "You are judging which draft is closer to the source corpus across feature-family behavior: rhythm, lexical style, register, discourse, rhetorical shape, evidence habits, and structure.",
343
- "The labels are blinded. Do not guess which workflow produced a draft.",
344
- "Score each numeric field from 0 to 100 inclusive, where 0 means no fit and 100 means excellent fit.",
345
- "Generated artifacts omit source and draft content by default. Open the local files directly when you need to judge prose.",
346
- "",
347
- `Topic: ${topic}`,
348
- "",
349
- "Return only JSON in this shape:",
350
- "",
351
- "```json",
352
- JSON.stringify({
353
- winner: "A",
354
- drafts: {
355
- A: {
356
- voiceScore: 0,
357
- genericDriftScore: 0,
358
- originalityScore: 0,
359
- rationale: "short reason",
360
- },
361
- B: {
362
- voiceScore: 0,
363
- genericDriftScore: 0,
364
- originalityScore: 0,
365
- rationale: "short reason",
366
- },
367
- },
368
- }, null, 2),
369
- "```",
370
- "",
371
- `Draft A file: ${blind.draftFiles.A}`,
372
- `Draft B file: ${blind.draftFiles.B}`,
373
- "",
374
- renderDrafts(drafts),
375
- "",
376
- renderSources(sources),
377
- ].join("\n");
378
- }
379
-
380
- function renderDrafts(drafts = []) {
381
- const byLabel = new Map(drafts.map((draft) => [draft.label, draft]));
382
- return [
383
- "## Drafts",
384
- "",
385
- "Draft content is intentionally omitted from this generated artifact by default.",
386
- "Open the local draft files when judging, then return JSON using the blind labels below.",
387
- "",
388
- ...["A", "B"].map((label) => {
389
- const draft = byLabel.get(label);
390
- return [
391
- `## Draft ${label}`,
392
- "",
393
- `Draft file: ${draft?.path ?? "missing"}`,
394
- `Draft words: ${draft?.words ?? 0}`,
395
- ].join("\n");
396
- }),
397
- ].join("\n\n");
398
- }
399
-
400
- function renderSources(sources) {
401
- const lines = [
402
- "## Source Articles",
403
- "",
404
- "Source content is intentionally omitted from this generated artifact by default.",
405
- "Use the learned Dravoice profile and local source files as needed; do not paste private source text into shared reports.",
406
- "",
407
- ];
408
- for (const [index, source] of sources.entries()) {
409
- lines.push(`### Source Article ${index + 1}`);
410
- lines.push("");
411
- lines.push(`Source id: ${source.id ?? `source-${index + 1}`}`);
412
- lines.push(`Source file: ${source.path}`);
413
- lines.push(`Source words: ${source.words}`);
414
- lines.push("");
415
- }
416
- return lines.join("\n");
417
- }
418
-
419
- function draftPlaceholder(title, body) {
420
- return [`# ${title}`, "", body, ""].join("\n");
421
- }
422
-
423
- function assertBenchmarkDraftsReady(benchmark, resolved) {
424
- for (const [key, draftPath] of Object.entries(resolved.paths.drafts)) {
425
- if (!fs.existsSync(draftPath)) {
426
- throw new Error(`Replace benchmark placeholder draft before scoring: ${draftPath}`);
427
- }
428
- const contents = fs.readFileSync(draftPath, "utf8");
429
- if (isPlaceholderDraft(contents, key)) {
430
- throw new Error(`Replace benchmark placeholder draft before scoring: ${benchmark.paths.drafts[key]}`);
431
- }
432
- }
433
- }
434
-
435
- function isPlaceholderDraft(contents, key) {
436
- const expectedTitle = key === "baseline" ? "# Baseline Draft" : "# Voice-Assisted Draft";
437
- return contents.includes(expectedTitle) && contents.includes("Replace this file with Actor");
438
- }
439
-
440
- function resolveBenchmarkPaths(root, benchmark) {
441
- assertObject(benchmark, "benchmark");
442
- if (benchmark.schemaVersion !== BENCHMARK_SCHEMA_VERSION) {
443
- throw new Error(`Benchmark JSON schemaVersion must be ${BENCHMARK_SCHEMA_VERSION}.`);
444
- }
445
- if (benchmark.generatedBy !== GENERATED_BY) {
446
- throw new Error(`Benchmark JSON generatedBy must be ${GENERATED_BY}.`);
447
- }
448
- assertObject(benchmark.paths, "paths");
449
- assertObject(benchmark.paths.prompts, "paths.prompts");
450
- assertObject(benchmark.paths.drafts, "paths.drafts");
451
- assertObject(benchmark.corpus, "corpus");
452
- assertObject(benchmark.blind, "blind");
453
- assertObject(benchmark.blind.labels, "blind.labels");
454
- assertObject(benchmark.blind.draftFiles, "blind.draftFiles");
455
- if (!Array.isArray(benchmark.corpus.files)) {
456
- throw new Error("Benchmark JSON field corpus.files must be an array.");
457
- }
458
-
459
- const paths = {
460
- voicePack: containedBenchmarkPath(root, root, benchmark.paths.voicePack, "paths.voicePack"),
461
- prompts: {
462
- baselineWriter: containedBenchmarkPath(root, root, benchmark.paths.prompts.baselineWriter, "paths.prompts.baselineWriter"),
463
- voiceWriter: containedBenchmarkPath(root, root, benchmark.paths.prompts.voiceWriter, "paths.prompts.voiceWriter"),
464
- judge: containedBenchmarkPath(root, root, benchmark.paths.prompts.judge, "paths.prompts.judge"),
465
- },
466
- drafts: {
467
- baseline: containedBenchmarkPath(root, root, benchmark.paths.drafts.baseline, "paths.drafts.baseline"),
468
- voiceAssisted: containedBenchmarkPath(root, root, benchmark.paths.drafts.voiceAssisted, "paths.drafts.voiceAssisted"),
469
- },
470
- judge: containedBenchmarkPath(root, root, benchmark.paths.judge, "paths.judge"),
471
- scores: containedBenchmarkPath(root, root, benchmark.paths.scores, "paths.scores"),
472
- report: containedBenchmarkPath(root, root, benchmark.paths.report, "paths.report"),
473
- };
474
- const files = benchmark.corpus.files.map((file, index) => {
475
- assertObject(file, `corpus.files[${index}]`);
476
- return file;
477
- });
478
- const blind = {
479
- labels: benchmark.blind.labels,
480
- draftFiles: {
481
- A: containedBenchmarkPath(root, root, benchmark.blind.draftFiles.A, "blind.draftFiles.A"),
482
- B: containedBenchmarkPath(root, root, benchmark.blind.draftFiles.B, "blind.draftFiles.B"),
483
- },
484
- };
485
- assertBlindMappingMatchesDraftPaths(paths.drafts, blind, benchmark.blind.draftFiles);
486
-
487
- return {
488
- paths,
489
- corpus: { files },
490
- blind,
491
- };
492
- }
493
-
494
- function assertBlindMappingMatchesDraftPaths(draftPaths, blind, originalDraftFiles) {
495
- for (const key of ["baseline", "voiceAssisted"]) {
496
- if (!["A", "B"].includes(blind.labels[key])) {
497
- throw new Error(`Benchmark JSON field blind.labels.${key} must be A or B.`);
498
- }
499
- }
500
- if (blind.labels.baseline === blind.labels.voiceAssisted) {
501
- throw new Error("Benchmark JSON blind labels must be unique.");
502
- }
503
- for (const key of ["baseline", "voiceAssisted"]) {
504
- const label = blind.labels[key];
505
- if (path.resolve(blind.draftFiles[label]) !== path.resolve(draftPaths[key])) {
506
- throw new Error(`Benchmark JSON field blind.draftFiles.${label} must match paths.drafts.${key}.`);
507
- }
508
- }
509
- for (const label of ["A", "B"]) {
510
- if (!(label in originalDraftFiles)) {
511
- throw new Error(`Benchmark JSON field blind.draftFiles.${label} must be present.`);
512
- }
513
- }
514
- }
515
-
516
- function containedBenchmarkPath(root, base, value, field) {
517
- if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) {
518
- throw new Error(`Benchmark JSON field ${field} must be a valid path string.`);
519
- }
520
- const resolved = path.resolve(base, value);
521
- if (!isPathInsideOrEqual(root, resolved)) {
522
- throw new Error(`Benchmark JSON field ${field} points outside the benchmark run root: ${value}`);
523
- }
524
- return resolved;
525
- }
526
-
527
- function assertObject(value, field) {
528
- if (!value || typeof value !== "object" || Array.isArray(value)) {
529
- throw new Error(`Benchmark JSON field ${field} must be an object.`);
530
- }
531
- }
532
-
25
+ const blind = blindMapping(normalizedSeed);
26
+ const benchmark = {
27
+ schemaVersion: BENCHMARK_SCHEMA_VERSION,
28
+ generatedBy: GENERATED_BY,
29
+ mode: "harness-only",
30
+ topic,
31
+ seed: normalizedSeed,
32
+ corpus: {
33
+ fileCount: sources.length,
34
+ sourceRoot: null,
35
+ files: sources.map((source) => ({
36
+ id: source.id,
37
+ path: source.path,
38
+ title: source.title,
39
+ words: source.words,
40
+ })),
41
+ voiceSource: profile.source,
42
+ },
43
+ blind,
44
+ blindLabels: blind,
45
+ paths: {
46
+ voicePack: "voice-pack",
47
+ prompts: {
48
+ baselineWriter: "prompts/baseline-writer.md",
49
+ voiceWriter: "prompts/voice-writer.md",
50
+ judge: "prompts/judge.md",
51
+ },
52
+ drafts: {
53
+ baseline: "drafts/baseline.md",
54
+ voiceAssisted: "drafts/voice-assisted.md",
55
+ },
56
+ judge: "judge/judgment.json",
57
+ scores: "scores.json",
58
+ report: "report.md",
59
+ },
60
+ };
61
+
62
+ writeIfMissing(path.join(draftsDir, "baseline.md"), draftPlaceholder("Baseline Draft", "Replace this file with Actor 1 output before scoring."));
63
+ writeIfMissing(path.join(draftsDir, "voice-assisted.md"), draftPlaceholder("Voice-Assisted Draft", "Replace this file with Actor 2 output before scoring."));
64
+ writeUtf8FileSafely(path.join(promptsDir, "baseline-writer.md"), renderBaselinePrompt({ topic, sources }));
65
+ writeUtf8FileSafely(path.join(promptsDir, "voice-writer.md"), renderVoiceWriterPrompt({ topic, sources, voice: profile }));
66
+ writeUtf8FileSafely(
67
+ path.join(promptsDir, "judge.md"),
68
+ renderJudgePrompt({
69
+ topic,
70
+ sources,
71
+ blind,
72
+ drafts: ["A", "B"].map((label) => ({
73
+ label,
74
+ path: blind.draftFiles[label],
75
+ words: 0,
76
+ })),
77
+ }),
78
+ );
79
+ writeUtf8FileSafely(path.join(outputRoot, "benchmark.json"), `${JSON.stringify(benchmark, null, 2)}\n`);
80
+
81
+ return benchmark;
82
+ }
83
+
84
+ export function scoreVoiceBenchmark({ runDir, judgePath, judgeFile, judge, cwd = process.cwd() }) {
85
+ const root = path.resolve(resolvePath(cwd, runDir));
86
+ const resolvedJudgePath = judgePath ?? judgeFile ?? judge;
87
+ const benchmarkPath = path.join(root, "benchmark.json");
88
+ if (!fs.existsSync(benchmarkPath)) {
89
+ throw new Error(`No V2 voice benchmark found at ${runDir}. Run dravoice benchmark prepare first.`);
90
+ }
91
+
92
+ const benchmark = readJsonFileBounded(benchmarkPath, { label: "Benchmark JSON", maxBytes: 1024 * 1024 });
93
+ const resolved = resolveBenchmarkPaths(root, benchmark);
94
+ assertBenchmarkDraftsReady(benchmark, resolved);
95
+ const voice = loadVoicePackV2(resolved.paths.voicePack);
96
+ fs.mkdirSync(path.dirname(resolved.paths.prompts.judge), { recursive: true });
97
+ writeUtf8FileSafely(
98
+ resolved.paths.prompts.judge,
99
+ renderJudgePrompt({
100
+ topic: benchmark.topic,
101
+ sources: benchmarkSources(benchmark, resolved),
102
+ blind: benchmark.blind,
103
+ drafts: benchmarkDrafts(benchmark, resolved),
104
+ }),
105
+ );
106
+
107
+ const baselineReview = reviewVoiceDraftV2({
108
+ file: resolved.paths.drafts.baseline,
109
+ voice,
110
+ cwd: root,
111
+ });
112
+ const voiceReview = reviewVoiceDraftV2({
113
+ file: resolved.paths.drafts.voiceAssisted,
114
+ voice,
115
+ cwd: root,
116
+ });
117
+ const judgeData = resolvedJudgePath ? loadJudge(resolvePath(cwd, resolvedJudgePath)) : null;
118
+ const scores = benchmarkScores({
119
+ benchmark,
120
+ baselineReview,
121
+ voiceReview,
122
+ judge: judgeData,
123
+ judgePath: resolvedJudgePath ? displayPath(resolvePath(cwd, resolvedJudgePath), root) : null,
124
+ });
125
+
126
+ writeUtf8FileSafely(resolved.paths.scores, `${JSON.stringify(scores, null, 2)}\n`);
127
+ writeUtf8FileSafely(resolved.paths.report, renderBenchmarkReport(scores));
128
+ return scores;
129
+ }
130
+
131
+ export function renderBenchmarkReport(scores) {
132
+ const lines = [
133
+ "# Dravoice V2 Voice Benchmark Report",
134
+ "",
135
+ `Topic: ${safeInline(scores.topic)}`,
136
+ `Mode: ${scores.mode}`,
137
+ "",
138
+ ];
139
+
140
+ if (scores.winner.final) {
141
+ lines.push(`Final winner: ${scores.winner.draft} (${scores.winner.label})`);
142
+ lines.push(`Margin: ${scores.winner.margin}`);
143
+ } else {
144
+ lines.push("Final winner: unavailable until judge JSON is supplied.");
145
+ lines.push(`Deterministic provisional leader: ${scores.deterministicWinner.draft} (${scores.deterministicWinner.label})`);
146
+ }
147
+ lines.push("Single benchmark run is directional, not proof; compare repeated runs and family diagnostics before deciding.");
148
+
149
+ for (const key of ["baseline", "voiceAssisted"]) {
150
+ const draft = scores.drafts[key];
151
+ lines.push("");
152
+ lines.push(`## ${draft.name}`);
153
+ lines.push(`Blind label: ${draft.label}`);
154
+ lines.push(`Voice fit: ${draft.deterministic.voiceFit}`);
155
+ lines.push(`Fit band: ${draft.deterministic.fitBand}`);
156
+ lines.push("Family scores:");
157
+ for (const [family, score] of Object.entries(draft.deterministic.familyScores)) {
158
+ lines.push(`- ${family}: ${score}`);
159
+ }
160
+ if (draft.judge) {
161
+ lines.push(`Judge voice score: ${draft.judge.voiceScore}`);
162
+ lines.push(`Hybrid score: ${draft.hybridScore}`);
163
+ lines.push(`Rationale: ${safeInline(draft.judge.rationale)}`);
164
+ }
165
+ }
166
+
167
+ return `${lines.join("\n")}\n`;
168
+ }
169
+
170
+ function benchmarkScores({ benchmark, baselineReview, voiceReview, judge, judgePath }) {
171
+ const baselineLabel = benchmark.blind.labels.baseline;
172
+ const voiceLabel = benchmark.blind.labels.voiceAssisted;
173
+ const baseline = draftScore({ key: "baseline", name: "Baseline", label: baselineLabel, review: baselineReview, judge });
174
+ const voiceAssisted = draftScore({ key: "voiceAssisted", name: "Voice-assisted", label: voiceLabel, review: voiceReview, judge });
175
+ const deterministicWinner = winnerFromScores({
176
+ baseline: baseline.deterministic.voiceFit,
177
+ voiceAssisted: voiceAssisted.deterministic.voiceFit,
178
+ }, benchmark.blind.labels);
179
+
180
+ let winner = {
181
+ final: false,
182
+ draft: null,
183
+ label: null,
184
+ margin: null,
185
+ reason: "No judge file provided.",
186
+ };
187
+ if (judge) {
188
+ winner = {
189
+ final: true,
190
+ ...winnerFromScores({
191
+ baseline: baseline.hybridScore,
192
+ voiceAssisted: voiceAssisted.hybridScore,
193
+ }, benchmark.blind.labels),
194
+ reason: "Hybrid score combines blind judge voice score, deterministic family fit, judge generic-drift score, and judge originality score.",
195
+ };
196
+ }
197
+
198
+ return {
199
+ schemaVersion: BENCHMARK_SCHEMA_VERSION,
200
+ generatedBy: GENERATED_BY,
201
+ mode: judge ? "final" : "provisional",
202
+ topic: benchmark.topic,
203
+ seed: benchmark.seed,
204
+ blind: benchmark.blind,
205
+ judge: {
206
+ provided: Boolean(judge),
207
+ path: judgePath,
208
+ declaredWinner: judge?.winner ?? null,
209
+ },
210
+ formula: {
211
+ judgeVoice: 0.55,
212
+ deterministicFamilyFit: 0.25,
213
+ judgeGenericDrift: 0.10,
214
+ judgeOriginality: 0.10,
215
+ },
216
+ drafts: {
217
+ baseline,
218
+ voiceAssisted,
219
+ },
220
+ deterministicWinner,
221
+ winner,
222
+ exitCode: 0,
223
+ };
224
+ }
225
+
226
+ function draftScore({ key, name, label, review, judge }) {
227
+ const deterministic = deterministicScore(review);
228
+ const judgeDraft = judge ? normalizeJudgeDraft(judge.drafts?.[label], label) : null;
229
+ const hybridScore = judgeDraft
230
+ ? roundHalfUp(
231
+ judgeDraft.voiceScore * 0.55 +
232
+ deterministic.voiceFit * 0.25 +
233
+ judgeDraft.genericDriftScore * 0.10 +
234
+ judgeDraft.originalityScore * 0.10,
235
+ 2,
236
+ )
237
+ : null;
238
+
239
+ return {
240
+ key,
241
+ name,
242
+ label,
243
+ file: review.file,
244
+ deterministic,
245
+ judge: judgeDraft,
246
+ hybridScore,
247
+ };
248
+ }
249
+
250
+ function deterministicScore(review) {
251
+ const reviewFindings = review.findings.filter((finding) => finding.priority === "review").length;
252
+ const considerFindings = review.findings.filter((finding) => finding.priority === "consider").length;
253
+ return {
254
+ voiceFit: clampScore(100 - review.summary.fit.distance),
255
+ fitBand: review.summary.fit.band,
256
+ familyScores: review.summary.familyScores,
257
+ voiceDistance: review.summary.fit.distance,
258
+ reviewFindings,
259
+ considerFindings,
260
+ };
261
+ }
262
+
263
+ function winnerFromScores(scores, labels) {
264
+ if (scores.baseline === scores.voiceAssisted) {
265
+ return { draft: "tie", label: "tie", margin: 0 };
266
+ }
267
+ const draft = scores.voiceAssisted > scores.baseline ? "voice-assisted" : "baseline";
268
+ return {
269
+ draft,
270
+ label: draft === "voice-assisted" ? labels.voiceAssisted : labels.baseline,
271
+ margin: roundHalfUp(Math.abs(scores.voiceAssisted - scores.baseline), 2),
272
+ };
273
+ }
274
+
275
+ function loadJudge(judgePath) {
276
+ const resolved = path.resolve(judgePath);
277
+ if (!fs.existsSync(resolved)) {
278
+ throw new Error(`Judge file not found: ${judgePath}`);
279
+ }
280
+ const judge = readJsonFileBounded(resolved, { label: "Judge JSON", maxBytes: 1024 * 1024 });
281
+ if (!judge.drafts || !judge.drafts.A || !judge.drafts.B) {
282
+ throw new Error("Judge JSON must include drafts.A and drafts.B.");
283
+ }
284
+ return judge;
285
+ }
286
+
287
+ function normalizeJudgeDraft(value, label) {
288
+ if (!value) {
289
+ throw new Error(`Judge JSON is missing scores for Draft ${label}.`);
290
+ }
291
+ return {
292
+ voiceScore: requiredScore(value.voiceScore, `drafts.${label}.voiceScore`),
293
+ genericDriftScore: requiredScore(value.genericDriftScore, `drafts.${label}.genericDriftScore`),
294
+ originalityScore: requiredScore(value.originalityScore, `drafts.${label}.originalityScore`),
295
+ rationale: String(value.rationale ?? ""),
296
+ };
297
+ }
298
+
299
+ function requiredScore(value, field) {
300
+ if (typeof value !== "number" || !Number.isFinite(value)) {
301
+ throw new Error(`Judge JSON field ${field} must be a number.`);
302
+ }
303
+ if (value < 0 || value > 100) {
304
+ throw new Error(`Judge JSON field ${field} must be between 0 and 100.`);
305
+ }
306
+ return value;
307
+ }
308
+
309
+ function renderBaselinePrompt({ topic, sources }) {
310
+ return [
311
+ "# Baseline Writer Prompt",
312
+ "",
313
+ "Write a new article on the requested topic using only the source articles as style examples.",
314
+ "Do not mention this benchmark. Do not explain your process.",
315
+ "",
316
+ `Topic: ${safeInline(topic)}`,
317
+ "",
318
+ renderSources(sources),
319
+ ].join("\n");
320
+ }
321
+
322
+ function renderVoiceWriterPrompt({ topic, sources, voice }) {
323
+ return [
324
+ "# Voice-Assisted Writer Prompt",
325
+ "",
326
+ "Write a new article on the requested topic. Use the learned Dravoice V2 feature-family guidance as the writing brief.",
327
+ "Do not mention this benchmark. Do not explain your process.",
328
+ "",
329
+ `Topic: ${safeInline(topic)}`,
330
+ "",
331
+ voicePromptPackV2({ voice, format: "agents" }).trim(),
332
+ "",
333
+ renderSources(sources),
334
+ ].join("\n");
335
+ }
336
+
337
+ function renderJudgePrompt({ topic, sources, blind, drafts }) {
338
+ return [
339
+ "# Blind V2 Voice Benchmark Judge Prompt",
340
+ "",
341
+ "You are judging which draft is closer to the source corpus across feature-family behavior: rhythm, lexical style, register, discourse, rhetorical shape, evidence habits, and structure.",
342
+ "The labels are blinded. Do not guess which workflow produced a draft.",
343
+ "Score each numeric field from 0 to 100 inclusive, where 0 means no fit and 100 means excellent fit.",
344
+ "Generated artifacts omit source and draft content by default. Open the local files directly when you need to judge prose.",
345
+ "",
346
+ `Topic: ${safeInline(topic)}`,
347
+ "",
348
+ "Return only JSON in this shape:",
349
+ "",
350
+ "```json",
351
+ JSON.stringify({
352
+ winner: "A",
353
+ drafts: {
354
+ A: {
355
+ voiceScore: 0,
356
+ genericDriftScore: 0,
357
+ originalityScore: 0,
358
+ rationale: "short reason",
359
+ },
360
+ B: {
361
+ voiceScore: 0,
362
+ genericDriftScore: 0,
363
+ originalityScore: 0,
364
+ rationale: "short reason",
365
+ },
366
+ },
367
+ }, null, 2),
368
+ "```",
369
+ "",
370
+ `Draft A file: ${blind.draftFiles.A}`,
371
+ `Draft B file: ${blind.draftFiles.B}`,
372
+ "",
373
+ renderDrafts(drafts),
374
+ "",
375
+ renderSources(sources),
376
+ ].join("\n");
377
+ }
378
+
379
+ function renderDrafts(drafts = []) {
380
+ const byLabel = new Map(drafts.map((draft) => [draft.label, draft]));
381
+ return [
382
+ "## Drafts",
383
+ "",
384
+ "Draft content is intentionally omitted from this generated artifact by default.",
385
+ "Open the local draft files when judging, then return JSON using the blind labels below.",
386
+ "",
387
+ ...["A", "B"].map((label) => {
388
+ const draft = byLabel.get(label);
389
+ return [
390
+ `## Draft ${label}`,
391
+ "",
392
+ `Draft file: ${safeInline(draft?.path ?? "missing")}`,
393
+ `Draft words: ${draft?.words ?? 0}`,
394
+ ].join("\n");
395
+ }),
396
+ ].join("\n\n");
397
+ }
398
+
399
+ function renderSources(sources) {
400
+ const lines = [
401
+ "## Source Articles",
402
+ "",
403
+ "Source content is intentionally omitted from this generated artifact by default.",
404
+ "Use the learned Dravoice profile and local source files as needed; do not paste private source text into shared reports.",
405
+ "",
406
+ ];
407
+ for (const [index, source] of sources.entries()) {
408
+ lines.push(`### Source Article ${index + 1}`);
409
+ lines.push("");
410
+ lines.push(`Source id: ${safeInline(source.id ?? `source-${index + 1}`)}`);
411
+ lines.push(`Source file: ${safeInline(source.path)}`);
412
+ lines.push(`Source words: ${source.words}`);
413
+ lines.push("");
414
+ }
415
+ return lines.join("\n");
416
+ }
417
+
418
+ function draftPlaceholder(title, body) {
419
+ return [`# ${title}`, "", body, ""].join("\n");
420
+ }
421
+
422
+ function assertBenchmarkDraftsReady(benchmark, resolved) {
423
+ for (const [key, draftPath] of Object.entries(resolved.paths.drafts)) {
424
+ if (!fs.existsSync(draftPath)) {
425
+ throw new Error(`Replace benchmark placeholder draft before scoring: ${draftPath}`);
426
+ }
427
+ const contents = readUtf8FileBounded(draftPath, { label: "Benchmark draft", maxBytes: 2 * 1024 * 1024 });
428
+ if (isPlaceholderDraft(contents, key)) {
429
+ throw new Error(`Replace benchmark placeholder draft before scoring: ${benchmark.paths.drafts[key]}`);
430
+ }
431
+ }
432
+ }
433
+
434
+ function isPlaceholderDraft(contents, key) {
435
+ const expectedTitle = key === "baseline" ? "# Baseline Draft" : "# Voice-Assisted Draft";
436
+ return contents.includes(expectedTitle) && contents.includes("Replace this file with Actor");
437
+ }
438
+
439
+ function resolveBenchmarkPaths(root, benchmark) {
440
+ assertObject(benchmark, "benchmark");
441
+ if (benchmark.schemaVersion !== BENCHMARK_SCHEMA_VERSION) {
442
+ throw new Error(`Benchmark JSON schemaVersion must be ${BENCHMARK_SCHEMA_VERSION}.`);
443
+ }
444
+ if (benchmark.generatedBy !== GENERATED_BY) {
445
+ throw new Error(`Benchmark JSON generatedBy must be ${GENERATED_BY}.`);
446
+ }
447
+ assertObject(benchmark.paths, "paths");
448
+ assertObject(benchmark.paths.prompts, "paths.prompts");
449
+ assertObject(benchmark.paths.drafts, "paths.drafts");
450
+ assertObject(benchmark.corpus, "corpus");
451
+ assertObject(benchmark.blind, "blind");
452
+ assertObject(benchmark.blind.labels, "blind.labels");
453
+ assertObject(benchmark.blind.draftFiles, "blind.draftFiles");
454
+ if (!Array.isArray(benchmark.corpus.files)) {
455
+ throw new Error("Benchmark JSON field corpus.files must be an array.");
456
+ }
457
+
458
+ const paths = {
459
+ voicePack: containedBenchmarkPath(root, root, benchmark.paths.voicePack, "paths.voicePack"),
460
+ prompts: {
461
+ baselineWriter: containedBenchmarkPath(root, root, benchmark.paths.prompts.baselineWriter, "paths.prompts.baselineWriter"),
462
+ voiceWriter: containedBenchmarkPath(root, root, benchmark.paths.prompts.voiceWriter, "paths.prompts.voiceWriter"),
463
+ judge: containedBenchmarkPath(root, root, benchmark.paths.prompts.judge, "paths.prompts.judge"),
464
+ },
465
+ drafts: {
466
+ baseline: containedBenchmarkPath(root, root, benchmark.paths.drafts.baseline, "paths.drafts.baseline"),
467
+ voiceAssisted: containedBenchmarkPath(root, root, benchmark.paths.drafts.voiceAssisted, "paths.drafts.voiceAssisted"),
468
+ },
469
+ judge: containedBenchmarkPath(root, root, benchmark.paths.judge, "paths.judge"),
470
+ scores: containedBenchmarkPath(root, root, benchmark.paths.scores, "paths.scores"),
471
+ report: containedBenchmarkPath(root, root, benchmark.paths.report, "paths.report"),
472
+ };
473
+ const files = benchmark.corpus.files.map((file, index) => {
474
+ assertObject(file, `corpus.files[${index}]`);
475
+ return file;
476
+ });
477
+ const blind = {
478
+ labels: benchmark.blind.labels,
479
+ draftFiles: {
480
+ A: containedBenchmarkPath(root, root, benchmark.blind.draftFiles.A, "blind.draftFiles.A"),
481
+ B: containedBenchmarkPath(root, root, benchmark.blind.draftFiles.B, "blind.draftFiles.B"),
482
+ },
483
+ };
484
+ assertBlindMappingMatchesDraftPaths(paths.drafts, blind, benchmark.blind.draftFiles);
485
+
486
+ return {
487
+ paths,
488
+ corpus: { files },
489
+ blind,
490
+ };
491
+ }
492
+
493
+ function assertBlindMappingMatchesDraftPaths(draftPaths, blind, originalDraftFiles) {
494
+ for (const key of ["baseline", "voiceAssisted"]) {
495
+ if (!["A", "B"].includes(blind.labels[key])) {
496
+ throw new Error(`Benchmark JSON field blind.labels.${key} must be A or B.`);
497
+ }
498
+ }
499
+ if (blind.labels.baseline === blind.labels.voiceAssisted) {
500
+ throw new Error("Benchmark JSON blind labels must be unique.");
501
+ }
502
+ for (const key of ["baseline", "voiceAssisted"]) {
503
+ const label = blind.labels[key];
504
+ if (path.resolve(blind.draftFiles[label]) !== path.resolve(draftPaths[key])) {
505
+ throw new Error(`Benchmark JSON field blind.draftFiles.${label} must match paths.drafts.${key}.`);
506
+ }
507
+ }
508
+ for (const label of ["A", "B"]) {
509
+ if (!(label in originalDraftFiles)) {
510
+ throw new Error(`Benchmark JSON field blind.draftFiles.${label} must be present.`);
511
+ }
512
+ }
513
+ }
514
+
515
+ function containedBenchmarkPath(root, base, value, field) {
516
+ if (typeof value !== "string" || value.trim() === "" || value.includes("\0")) {
517
+ throw new Error(`Benchmark JSON field ${field} must be a valid path string.`);
518
+ }
519
+ const resolved = path.resolve(base, value);
520
+ if (!isPathInsideOrEqual(root, resolved)) {
521
+ throw new Error(`Benchmark JSON field ${field} points outside the benchmark run root: ${value}`);
522
+ }
523
+ return resolved;
524
+ }
525
+
526
+ function assertObject(value, field) {
527
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
528
+ throw new Error(`Benchmark JSON field ${field} must be an object.`);
529
+ }
530
+ }
531
+
533
532
  function benchmarkSourcesFromProfile(profile) {
534
533
  return profile.source.files.map((file, index) => ({
535
534
  id: file.id ?? `source-${index + 1}`,
@@ -556,7 +555,7 @@ function benchmarkDrafts(benchmark, resolved) {
556
555
  label,
557
556
  path: draftPath,
558
557
  words: fs.existsSync(resolvedDraftPath)
559
- ? (fs.readFileSync(resolvedDraftPath, "utf8").match(/[a-z][a-z0-9'-]*/gi) ?? []).length
558
+ ? (readUtf8FileBounded(resolvedDraftPath, { label: "Benchmark draft", maxBytes: 2 * 1024 * 1024 }).match(/[a-z][a-z0-9'-]*/gi) ?? []).length
560
559
  : 0,
561
560
  };
562
561
  });
@@ -580,54 +579,61 @@ function blindMapping(seed) {
580
579
  },
581
580
  };
582
581
  }
583
-
584
- function seededRandom(seed) {
585
- const next = (normalizeSeed(seed) * 1664525 + 1013904223) >>> 0;
586
- return next / 4294967296;
587
- }
588
-
589
- function normalizeSeed(seed) {
590
- const value = String(seed);
591
- if (!/^\d+$/.test(value)) {
592
- throw new Error(`Invalid seed: ${seed}`);
593
- }
594
- const parsed = BigInt(value);
595
- if (parsed > 0xffffffffn) {
596
- throw new Error(`Invalid seed: ${seed}`);
597
- }
598
- return Number(parsed);
599
- }
600
-
582
+
583
+ function seededRandom(seed) {
584
+ const next = (normalizeSeed(seed) * 1664525 + 1013904223) >>> 0;
585
+ return next / 4294967296;
586
+ }
587
+
588
+ function normalizeSeed(seed) {
589
+ const value = String(seed);
590
+ if (!/^\d+$/.test(value)) {
591
+ throw new Error(`Invalid seed: ${seed}`);
592
+ }
593
+ const parsed = BigInt(value);
594
+ if (parsed > 0xffffffffn) {
595
+ throw new Error(`Invalid seed: ${seed}`);
596
+ }
597
+ return Number(parsed);
598
+ }
599
+
601
600
  function writeIfMissing(filePath, contents) {
602
- if (!fs.existsSync(filePath)) {
603
- fs.writeFileSync(filePath, contents, "utf8");
604
- }
605
- }
606
-
607
- function displayPath(filePath, cwd) {
608
- const relative = path.relative(cwd, filePath);
609
- if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
610
- return toPosix(relative);
611
- }
612
- return toPosix(filePath);
613
- }
614
-
615
- function resolvePath(cwd, value) {
616
- return path.isAbsolute(value) ? value : path.join(cwd, value);
617
- }
618
-
619
- function isPathInsideOrEqual(root, filePath) {
620
- const relative = path.relative(path.resolve(root), path.resolve(filePath));
621
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
622
- }
623
-
624
- function roundHalfUp(value, digits = 0) {
625
- const multiplier = 10 ** digits;
626
- const rounded = Math.floor(value * multiplier + 0.5) / multiplier;
627
- return digits === 0 ? Math.trunc(rounded) : rounded;
628
- }
629
-
630
- function toPosix(value) {
631
- return value.split(path.sep).join("/");
632
- }
633
-
601
+ if (!fs.existsSync(filePath)) {
602
+ writeUtf8FileSafely(filePath, contents);
603
+ }
604
+ }
605
+
606
+ function displayPath(filePath, cwd) {
607
+ const relative = path.relative(cwd, filePath);
608
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
609
+ return toPosix(relative);
610
+ }
611
+ return toPosix(filePath);
612
+ }
613
+
614
+ function resolvePath(cwd, value) {
615
+ return path.isAbsolute(value) ? value : path.join(cwd, value);
616
+ }
617
+
618
+ function isPathInsideOrEqual(root, filePath) {
619
+ const relative = path.relative(path.resolve(root), path.resolve(filePath));
620
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
621
+ }
622
+
623
+ function roundHalfUp(value, digits = 0) {
624
+ const multiplier = 10 ** digits;
625
+ const rounded = Math.floor(value * multiplier + 0.5) / multiplier;
626
+ return digits === 0 ? Math.trunc(rounded) : rounded;
627
+ }
628
+
629
+ function toPosix(value) {
630
+ return value.split(path.sep).join("/");
631
+ }
632
+
633
+ function safeInline(value) {
634
+ return String(value ?? "")
635
+ .replace(/[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
636
+ .replace(/\s+/g, " ")
637
+ .replace(/`/g, "'")
638
+ .trim();
639
+ }