@holdyourvoice/hyv 3.6.0 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Readme.md CHANGED
@@ -13,63 +13,7 @@ The package also checks hidden Unicode, source-backed facts, document logic, and
13
13
 
14
14
  ## how it works
15
15
 
16
- ```mermaid
17
- flowchart TD
18
- samples["Writing samples"] --> profile["Local VoiceDNA profile"]
19
- draft["Draft"] --> analyze["hyv analyze"]
20
- profile --> analyze
21
- brief["Optional WritingBrief"] -.-> analyze
22
-
23
- subgraph inspect["1 · inspect the draft"]
24
- analyze --> voice["VoiceDNA check"]
25
- analyze --> patterns["AI pattern lint"]
26
- analyze --> hidden["Hidden-text / Unicode check"]
27
- voice --> local{"Local result"}
28
- patterns --> local
29
- hidden -.-> local
30
- end
31
-
32
- local -->|No blocking change| candidate["Candidate text"]
33
- local -->|Blocking edit scope| editTask["Prepare fingerprint-bound edit task"]
34
- local -->|Judgment required| judgment["Prepare and reduce judgments"]
35
- judgment --> route{"SHIP, EDIT, or REBUILD?"}
36
- route -->|SHIP| candidate
37
- route -->|EDIT| editTask
38
- route -->|REBUILD| authorization["REBUILD recommendation + CopySpec + signed authorization"]
39
- authorization --> rebuildTask["Prepare fingerprint-bound rebuild task"]
40
- editTask --> editor["Human editor or model you choose"]
41
- rebuildTask --> editor
42
- editor --> response["Bound response"]
43
-
44
- brief -.-> logic
45
- sources["Optional fact sources in WritingBrief"] -.-> facts
46
- spec["Optional for verify-spec; required for rebuild"] -.-> authorization
47
- spec -.-> standard
48
-
49
- subgraph verification["2 · verification gate"]
50
- candidate --> standard["hyv verify / verify-spec"]
51
- response --> mode{"Bound task mode"}
52
- mode -->|EDIT| editApply["apply-rewrite + standard verification"]
53
- mode -->|REBUILD| rebuildApply["apply-rebuild + rebuild verification"]
54
- standard --> standardRules["Preservation gate + CopySpec claims when supplied"]
55
- editApply --> standardRules
56
- rebuildApply --> rebuildRules["CopySpec claims; preservation reported"]
57
- standardRules --> engines["VoiceDNA + AI Editor checks and blocking regressions"]
58
- rebuildRules --> engines
59
- engines --> logic["Logic lint"]
60
- logic --> facts["Fact lint when sources are supplied"]
61
- facts --> outputGate["Hidden-text + final-output gate"]
62
- outputGate --> passed{"All required checks pass?"}
63
- end
64
-
65
- passed -->|No| repair["Repair externally or prepare a new task"]
66
- repair --> analyze
67
- passed -->|Yes| review["Semantic review and human approval, when required"]
68
- review --> final["Run final-check after the last change"]
69
- final --> output["Exact accepted text"]
70
- ```
71
-
72
- HYV keeps draft inspection, candidate verification, and final delivery separate. Standard verification reruns VoiceDNA and AI Editor, rejects blocking regressions, enforces preservation, runs logic lint, applies fact lint when a WritingBrief supplies sources, and withholds hidden-text failures. `verify-spec` adds CopySpec claim checks. Authorized rebuilds require an upstream REBUILD recommendation, a CopySpec, and signed authorization; their verification reports preservation without using the standard preservation threshold. Run `final-check` again after the last human, model, formatter, or template change. HYV never calls a model; a human editor or model you choose supplies edits and judgments.
16
+ inspect the draft, let your editor make the changes, then verify the candidate and check the exact output. the [architecture guide](docs/ARCHITECTURE.md#writing-workflow) shows the full flow, including judgments, authorized rebuilds, and approval gates.
73
17
 
74
18
  ## install
75
19
 
package/dist/ai-editor.js CHANGED
@@ -104,11 +104,11 @@ export function serializedRules() {
104
104
  scope: rule.scope ?? 'sentence',
105
105
  }));
106
106
  }
107
- function documentFinding(rule, sentence) {
107
+ function ruleFinding(rule, sentence) {
108
108
  return { engine: 'ai_editor', id: rule.id, severity: rule.severity, sentence: sentence.index, excerpt: sentence.text, reason: rule.reason, suggestion: rule.suggestion };
109
109
  }
110
110
  function documentMatches(rule, prose, mapped) {
111
- const at = (index) => mapped[index] ? [documentFinding(rule, mapped[index])] : [];
111
+ const at = (index) => mapped[index] ? [ruleFinding(rule, mapped[index])] : [];
112
112
  if (rule.id === 'ai.repeated-sentence-opening') {
113
113
  for (let index = 0; index + 2 < mapped.length; index += 1) {
114
114
  const opening = mapped[index].text.match(/^\s*(\p{L}+)/u)?.[1]?.toLocaleLowerCase();
@@ -161,15 +161,7 @@ export function analyzeAiEditor(text, profile) {
161
161
  for (const sentence of mapped) {
162
162
  for (const rule of sentenceRules) {
163
163
  if (rule.expression.test(prose.slice(sentence.start, sentence.end))) {
164
- matched.push({
165
- engine: 'ai_editor',
166
- id: rule.id,
167
- severity: rule.severity,
168
- sentence: sentence.index,
169
- excerpt: sentence.text,
170
- reason: rule.reason,
171
- suggestion: rule.suggestion,
172
- });
164
+ matched.push(ruleFinding(rule, sentence));
173
165
  }
174
166
  }
175
167
  }
@@ -184,15 +176,7 @@ export function analyzeAiEditor(text, profile) {
184
176
  ?? mapped.find((candidate) => candidate.start >= lineStart && candidate.start < lineStart + line.length);
185
177
  if (!sentence)
186
178
  continue;
187
- matched.push({
188
- engine: 'ai_editor',
189
- id: rule.id,
190
- severity: rule.severity,
191
- sentence: sentence.index,
192
- excerpt: sentence.text,
193
- reason: rule.reason,
194
- suggestion: rule.suggestion,
195
- });
179
+ matched.push(ruleFinding(rule, sentence));
196
180
  }
197
181
  lineStart += line.length + 1;
198
182
  }
@@ -0,0 +1,108 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { loadAll, validateAll, validateId, sortedIds, describe, emitJson, emitPrompt } from '../agents/index.js';
3
+ import { json } from './io.js';
4
+ function agentFlags(args) {
5
+ const values = [];
6
+ let host = 'generic';
7
+ let mode;
8
+ let output;
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const argument = args[index];
11
+ if (argument === '--host') {
12
+ const value = args[index + 1];
13
+ if (!value || value.startsWith('--'))
14
+ throw new Error('Usage: hyv agent --host HOST requires a value.');
15
+ host = value;
16
+ index += 1;
17
+ }
18
+ else if (argument.startsWith('--host=')) {
19
+ const value = argument.slice('--host='.length);
20
+ if (!value)
21
+ throw new Error('Usage: hyv agent --host HOST requires a value.');
22
+ host = value;
23
+ }
24
+ else if (argument === '--mode') {
25
+ const value = args[index + 1];
26
+ if (value !== 'prompt' && value !== 'json')
27
+ throw new Error('Usage: hyv agent --mode prompt|json requires a mode.');
28
+ mode = value;
29
+ index += 1;
30
+ }
31
+ else if (argument.startsWith('--mode=')) {
32
+ const value = argument.slice('--mode='.length);
33
+ if (value !== 'prompt' && value !== 'json')
34
+ throw new Error('Usage: hyv agent --mode prompt|json requires a mode.');
35
+ mode = value;
36
+ }
37
+ else if (argument === '--output') {
38
+ const value = args[index + 1];
39
+ if (!value || value.startsWith('--'))
40
+ throw new Error('Usage: hyv agent --output FILE requires a value.');
41
+ output = value;
42
+ index += 1;
43
+ }
44
+ else if (argument.startsWith('--output=')) {
45
+ const value = argument.slice('--output='.length);
46
+ if (!value)
47
+ throw new Error('Usage: hyv agent --output FILE requires a value.');
48
+ output = value;
49
+ }
50
+ else {
51
+ values.push(argument);
52
+ }
53
+ }
54
+ return { values, host, mode, output };
55
+ }
56
+ export function runAgent(args) {
57
+ const [subcommand, ...subargs] = args;
58
+ if (subcommand === 'list') {
59
+ if (subargs.length)
60
+ throw new Error('Usage: hyv agent list');
61
+ const packages = loadAll();
62
+ const ids = sortedIds(packages);
63
+ json(ids.map((id) => {
64
+ const descriptor = packages.get(id).descriptor;
65
+ return { id, role: descriptor.role, workflow_phase: descriptor.workflow_phase, description: descriptor.description };
66
+ }));
67
+ return 0;
68
+ }
69
+ if (subcommand === 'validate') {
70
+ if (subargs.length > 1)
71
+ throw new Error('Usage: hyv agent validate [id]');
72
+ const packages = loadAll();
73
+ const id = subargs[0];
74
+ if (id !== undefined)
75
+ validateId(packages, id);
76
+ validateAll(packages);
77
+ json({ schema_version: '1.0.0', status: 'PASS', agent: id ?? 'all' });
78
+ return 0;
79
+ }
80
+ if (subcommand === 'describe') {
81
+ const { values, host, mode, output } = agentFlags(subargs);
82
+ if (values.length !== 1 || mode !== undefined || output !== undefined)
83
+ throw new Error('Usage: hyv agent describe <id> [--host HOST]');
84
+ const packages = loadAll();
85
+ validateId(packages, values[0]);
86
+ json(describe(packages.get(values[0]), host));
87
+ return 0;
88
+ }
89
+ if (subcommand === 'emit') {
90
+ const { values, host, mode, output } = agentFlags(subargs);
91
+ if (values.length !== 1)
92
+ throw new Error('Usage: hyv agent emit <id> --mode prompt|json [--host HOST] [--output FILE]');
93
+ if (!mode)
94
+ throw new Error('Usage: hyv agent emit <id> --mode prompt|json [--host HOST] [--output FILE]');
95
+ const packages = loadAll();
96
+ validateId(packages, values[0]);
97
+ const pkg = packages.get(values[0]);
98
+ const body = mode === 'prompt' ? emitPrompt(pkg, host) : `${emitJson(pkg, host)}\n`;
99
+ if (output !== undefined) {
100
+ writeFileSync(output, body, { encoding: 'utf8', flag: 'wx' });
101
+ }
102
+ else {
103
+ process.stdout.write(body);
104
+ }
105
+ return 0;
106
+ }
107
+ throw new Error('Usage: hyv agent <list|validate|describe|emit> ...');
108
+ }
@@ -0,0 +1,226 @@
1
+ import { linkSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, extname, join, resolve } from 'node:path';
3
+ import { RULESET_VERSION, serializedRules } from '../ai-editor.js';
4
+ import { parseCopySpec } from '../copy-spec.js';
5
+ import { analyzeBatch } from '../editorial-packs.js';
6
+ import { cleanHygiene, finalOutputCheck, inspectHygiene } from '../hygiene.js';
7
+ import { applyHiddenTextPolicy, inspectHiddenText, parseHiddenTextPolicy } from '../hidden-text.js';
8
+ import { analyze, verify, verifyWithCopySpec } from '../pipeline.js';
9
+ import { formatFactLintReport, lintFacts } from '../fact-linter.js';
10
+ import { lintLogic } from '../logic-linter.js';
11
+ import { inspectDeliveryIntegrity, parseDeliveryIntegrityPolicy } from '../delivery-integrity.js';
12
+ import { evaluateStrictQuality } from '../strict-quality.js';
13
+ import { normalizeFinding, parseSurfacePolicy } from '../disposition.js';
14
+ import { input, readJson, readProfile, readBrief, json } from './io.js';
15
+ function cleanedPath(path) {
16
+ const extension = extname(path);
17
+ const stem = extension ? path.slice(0, -extension.length) : path;
18
+ return `${stem}.cleaned${extension}`;
19
+ }
20
+ function hygieneArguments(args) {
21
+ const [path, ...options] = args;
22
+ if (!path)
23
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
24
+ let fix = false;
25
+ let output;
26
+ for (const option of options) {
27
+ if (option === '--fix')
28
+ fix = true;
29
+ else if (option.startsWith('--output='))
30
+ output = option.slice('--output='.length).trim();
31
+ else
32
+ throw new Error('Usage: hyv hygiene draft.md [--fix] [--output=cleaned.md]');
33
+ }
34
+ if (output !== undefined && (!output || !fix))
35
+ throw new Error('--output requires --fix and a non-empty path.');
36
+ return { path, fix, ...(output ? { output } : {}) };
37
+ }
38
+ function writeNewFileAtomically(path, text) {
39
+ const temporaryDirectory = mkdtempSync(join(dirname(resolve(path)), '.hyv-hygiene-'));
40
+ const temporaryPath = join(temporaryDirectory, 'cleaned');
41
+ let primaryError;
42
+ try {
43
+ writeFileSync(temporaryPath, text, 'utf8');
44
+ try {
45
+ linkSync(temporaryPath, path);
46
+ }
47
+ catch (error) {
48
+ const code = error.code;
49
+ if (!['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'EXDEV'].includes(code ?? ''))
50
+ throw error;
51
+ throw new Error(`Atomic hygiene output is not supported by this filesystem: ${path}`);
52
+ }
53
+ }
54
+ catch (error) {
55
+ primaryError = error.code === 'EEXIST' ? new Error(`Hygiene output already exists: ${path}`) : error;
56
+ }
57
+ try {
58
+ rmSync(temporaryDirectory, { recursive: true, force: true });
59
+ }
60
+ catch (error) {
61
+ if (!primaryError)
62
+ console.error(`Warning: output was published, but temporary-file cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
63
+ }
64
+ if (primaryError)
65
+ throw primaryError;
66
+ }
67
+ export function runDeliveryCheck(args) {
68
+ const [path, policyPath, ...extra] = args;
69
+ if (!path || extra.length)
70
+ throw new Error('Usage: hyv delivery-check <path|-> [policy.json]');
71
+ const report = inspectDeliveryIntegrity(input(path), policyPath ? parseDeliveryIntegrityPolicy(readJson(policyPath)) : undefined, process.cwd());
72
+ json(report);
73
+ return report.passed ? 0 : 2;
74
+ }
75
+ export function runAnalyze(args) {
76
+ const [draft, profilePath, briefPath] = args;
77
+ if (!draft || !profilePath)
78
+ throw new Error('Usage: hyv analyze draft.md profile.json [writing-brief.json]');
79
+ json(analyze(input(draft), readProfile(profilePath), readBrief(briefPath)));
80
+ return 0;
81
+ }
82
+ export function runStrictCheck(args) {
83
+ const [draft, profilePath, ...samplePaths] = args;
84
+ if (!draft || !profilePath || samplePaths.length < 2)
85
+ throw new Error('Usage: hyv strict-check draft.md profile-v3.json sample-a.md sample-b.md [sample-c.md ...]');
86
+ const report = evaluateStrictQuality(input(draft), readProfile(profilePath), samplePaths.map(input));
87
+ json(report);
88
+ return report.disposition === 'strict-ready' ? 0 : 2;
89
+ }
90
+ export function runHygiene(args) {
91
+ const { path, fix, output } = hygieneArguments(args);
92
+ if (fix && path === '-')
93
+ throw new Error('hyv hygiene --fix requires a file path so the original can be preserved.');
94
+ const text = input(path);
95
+ if (!fix) {
96
+ json(inspectHygiene(text));
97
+ return 0;
98
+ }
99
+ const outputPath = output ?? cleanedPath(path);
100
+ if (resolve(outputPath) === resolve(path))
101
+ throw new Error('Hygiene output must differ from the input path.');
102
+ const result = cleanHygiene(text);
103
+ writeNewFileAtomically(outputPath, result.cleaned);
104
+ json({ ...result.report, changed: result.changed, changes: result.changes, outputPath });
105
+ return 0;
106
+ }
107
+ export function runInspectHiddenText(args) {
108
+ const [path, policyPath, ...extra] = args;
109
+ if (!path || extra.length)
110
+ throw new Error('Usage: hyv inspect-hidden-text draft.md [policy.json]');
111
+ json(inspectHiddenText(input(path), policyPath ? parseHiddenTextPolicy(readJson(policyPath)) : undefined));
112
+ return 0;
113
+ }
114
+ export function runApplyHiddenTextPolicy(args) {
115
+ const [path, policyPath, output, ...extra] = args;
116
+ if (!path || !policyPath || !output || extra.length)
117
+ throw new Error('Usage: hyv apply-hidden-text-policy draft.md policy.json output.md');
118
+ if (path === '-' || resolve(path) === resolve(output))
119
+ throw new Error('Hidden-text output must differ from the input path.');
120
+ const result = applyHiddenTextPolicy(input(path), parseHiddenTextPolicy(readJson(policyPath)));
121
+ writeNewFileAtomically(output, result.output);
122
+ json({ ...result, outputPath: output });
123
+ return 0;
124
+ }
125
+ export function runFinalCheck(args) {
126
+ const [path, ...options] = args;
127
+ if (!path || options.length)
128
+ throw new Error('Usage: hyv final-check <path|->');
129
+ const result = finalOutputCheck(input(path));
130
+ if (!result.accepted) {
131
+ console.error(JSON.stringify(result, null, 2));
132
+ return 2;
133
+ }
134
+ if (result.changed)
135
+ console.error(JSON.stringify({ changed: true, changes: result.changes }, null, 2));
136
+ process.stdout.write(result.output);
137
+ return 0;
138
+ }
139
+ export function runFactLint(args) {
140
+ const [draftPath, ...options] = args;
141
+ const sources = [];
142
+ let metadata;
143
+ let strict = false;
144
+ let human = false;
145
+ if (!draftPath)
146
+ throw new Error('Usage: hyv fact-lint <draft|-> --source=id:path [--source=id:path] [--metadata=metadata.json] [--strict] [--human]');
147
+ for (const option of options) {
148
+ if (option === '--strict') {
149
+ strict = true;
150
+ continue;
151
+ }
152
+ if (option === '--human') {
153
+ human = true;
154
+ continue;
155
+ }
156
+ if (option.startsWith('--source=')) {
157
+ const value = option.slice('--source='.length);
158
+ const separator = value.indexOf(':');
159
+ const id = value.slice(0, separator).trim();
160
+ const path = value.slice(separator + 1);
161
+ if (separator < 1 || !id || !path)
162
+ throw new Error('Sources must use --source=id:path.');
163
+ sources.push({ id, text: input(path) });
164
+ continue;
165
+ }
166
+ if (option.startsWith('--metadata=')) {
167
+ metadata = JSON.parse(input(option.slice('--metadata='.length)));
168
+ continue;
169
+ }
170
+ throw new Error('Usage: hyv fact-lint <draft|-> --source=id:path [--source=id:path] [--metadata=metadata.json] [--strict] [--human]');
171
+ }
172
+ const report = lintFacts({ sources, draft: input(draftPath), metadata });
173
+ if (human)
174
+ console.log(formatFactLintReport(report));
175
+ else
176
+ json(report);
177
+ return strict && report.findings.some((item) => item.severity === 'error') ? 2 : 0;
178
+ }
179
+ export function runLogicLint(args) {
180
+ const [draftPath, briefPath, ...extra] = args;
181
+ if (!draftPath || extra.length)
182
+ throw new Error('Usage: hyv logic-lint <draft|-> [writing-brief.json]');
183
+ const report = lintLogic(input(draftPath), readBrief(briefPath));
184
+ json(report);
185
+ return report.passed ? 0 : 2;
186
+ }
187
+ export function runBatchAnalyze(args) {
188
+ if (args.length < 2)
189
+ throw new Error('Usage: hyv batch-analyze draft-a.md draft-b.md [draft-c.md]');
190
+ json(analyzeBatch(args.map(input)));
191
+ return 0;
192
+ }
193
+ export function runVerify(args) {
194
+ const [original, candidate, profilePath, briefPath] = args;
195
+ if (!original || !candidate || !profilePath)
196
+ throw new Error('Usage: hyv verify original.md candidate.md profile.json [writing-brief.json]');
197
+ const profile = readProfile(profilePath);
198
+ const originalText = input(original);
199
+ const candidateText = input(candidate);
200
+ const result = verify(originalText, candidateText, profile, readBrief(briefPath));
201
+ json(result);
202
+ return result.passed ? 0 : 2;
203
+ }
204
+ export function runVerifySpec(args) {
205
+ const [original, candidate, profilePath, specPath, briefPath] = args;
206
+ if (!original || !candidate || !profilePath || !specPath)
207
+ throw new Error('Usage: hyv verify-spec original.md candidate.md profile.json copy-spec.json [writing-brief.json]');
208
+ const profile = readProfile(profilePath);
209
+ const candidateText = input(candidate);
210
+ const result = verifyWithCopySpec(input(original), candidateText, profile, parseCopySpec(JSON.parse(input(specPath))), readBrief(briefPath));
211
+ json(result);
212
+ return result.passed ? 0 : 2;
213
+ }
214
+ export function runPatterns() {
215
+ json({ version: RULESET_VERSION, rules: serializedRules() });
216
+ return 0;
217
+ }
218
+ export function runDispositions(args) {
219
+ const [draft, profilePath, briefPath, surfacePolicyPath] = args;
220
+ if (!draft || !profilePath)
221
+ throw new Error('Usage: hyv dispositions draft.md profile.json [writing-brief.json]');
222
+ const report = analyze(input(draft), readProfile(profilePath), readBrief(briefPath));
223
+ const policy = surfacePolicyPath ? parseSurfacePolicy(readJson(surfacePolicyPath)) : undefined;
224
+ json({ version: '1', findings: [report.voiceDna, report.aiEditor, report.editorial].flatMap((engine) => engine?.findings.map((finding) => normalizeFinding(finding, policy)) ?? []) });
225
+ return 0;
226
+ }
@@ -0,0 +1,55 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import test from 'node:test';
7
+ import { buildProfile } from '../voice-dna.js';
8
+ test('prepare-rewrite recognizes contexts in either order and rejects duplicates before writing', () => {
9
+ const directory = mkdtempSync(join(tmpdir(), 'hyv-cli-context-'));
10
+ try {
11
+ const draft = join(directory, 'draft.md');
12
+ const profile = join(directory, 'profile.json');
13
+ const task = join(directory, 'task.json');
14
+ const spec = join(directory, 'spec.json');
15
+ const brief = join(directory, 'brief.json');
16
+ const ambiguous = join(directory, 'ambiguous.json');
17
+ const invalid = join(directory, 'invalid.json');
18
+ const copySpec = { version: '1', audience: 'writers', intent: 'explain', channel: 'general', claims: [{ id: 'mechanism', text: 'I keep the mechanism clear.', evidence: 'The source draft.' }] };
19
+ const writingBrief = { version: '1', audience: 'writers', intent: 'explain', format: 'general' };
20
+ writeFileSync(draft, 'I keep the mechanism clear.');
21
+ writeFileSync(profile, JSON.stringify(buildProfile(['I write plainly. I name the work.', 'I keep the mechanism clear. I avoid filler.'])));
22
+ writeFileSync(spec, JSON.stringify(copySpec));
23
+ writeFileSync(brief, JSON.stringify(writingBrief));
24
+ writeFileSync(ambiguous, JSON.stringify({ ...copySpec, ...writingBrief }));
25
+ writeFileSync(invalid, '{}');
26
+ const run = (contexts) => spawnSync(process.execPath, [new URL('../cli.js', import.meta.url).pathname, 'prepare-rewrite', draft, profile, task, ...contexts], { encoding: 'utf8' });
27
+ for (const contexts of [[spec, brief], [brief, spec]]) {
28
+ const result = run(contexts);
29
+ assert.equal(result.status, 0, result.stderr);
30
+ const prepared = JSON.parse(readFileSync(task, 'utf8'));
31
+ assert.deepEqual(prepared.copySpec, copySpec);
32
+ assert.deepEqual(prepared.writingBrief, writingBrief);
33
+ }
34
+ const result = run([ambiguous]);
35
+ assert.equal(result.status, 0, result.stderr);
36
+ const prepared = JSON.parse(readFileSync(task, 'utf8'));
37
+ assert.deepEqual(prepared.copySpec, { ...copySpec, ...writingBrief });
38
+ assert.equal(prepared.writingBrief, undefined);
39
+ for (const [contexts, error] of [
40
+ [[spec, spec], 'Prepare-rewrite accepts at most one CopySpec.'],
41
+ [[brief, brief], 'Prepare-rewrite accepts at most one WritingBrief.'],
42
+ [[invalid], `Expected a valid CopySpec or WritingBrief at ${invalid}.`],
43
+ ]) {
44
+ const before = readFileSync(task, 'utf8');
45
+ const rejected = run([...contexts]);
46
+ assert.equal(rejected.status, 1);
47
+ assert.equal(rejected.stdout, '');
48
+ assert.equal(rejected.stderr, `${error}\n`);
49
+ assert.equal(readFileSync(task, 'utf8'), before);
50
+ }
51
+ }
52
+ finally {
53
+ rmSync(directory, { recursive: true, force: true });
54
+ }
55
+ });
package/dist/cli/io.js ADDED
@@ -0,0 +1,137 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, readSync, writeFileSync } from 'node:fs';
2
+ import { parseCopySpec } from '../copy-spec.js';
3
+ import { parseWritingBrief } from '../editorial-packs.js';
4
+ import { parseProfile } from '../profile.js';
5
+ import { canonicalJson, parseCanonicalJson } from '../canonical-json.js';
6
+ import { MAX_JSON_BYTES } from '../internal.js';
7
+ export function input(path) {
8
+ return path === '-' ? readFileSync(0, 'utf8') : readFileSync(path, 'utf8');
9
+ }
10
+ function parseBoundedJson(text) {
11
+ if (Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES)
12
+ throw new Error('JSON input exceeds the byte limit.');
13
+ const value = JSON.parse(text);
14
+ canonicalJson(value);
15
+ return value;
16
+ }
17
+ function readBoundedDescriptor(descriptor) {
18
+ const chunks = [];
19
+ let size = 0;
20
+ while (size <= MAX_JSON_BYTES) {
21
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, MAX_JSON_BYTES + 1 - size));
22
+ const count = readSync(descriptor, chunk, 0, chunk.length, null);
23
+ if (!count)
24
+ break;
25
+ chunks.push(chunk.subarray(0, count));
26
+ size += count;
27
+ }
28
+ if (size > MAX_JSON_BYTES)
29
+ throw new Error('JSON input exceeds the byte limit.');
30
+ return Buffer.concat(chunks, size).toString('utf8');
31
+ }
32
+ export function readJson(path) {
33
+ if (path === '-')
34
+ return parseBoundedJson(readBoundedDescriptor(0));
35
+ let descriptor;
36
+ try {
37
+ descriptor = openSync(path, constants.O_RDONLY);
38
+ return parseBoundedJson(readBoundedDescriptor(descriptor));
39
+ }
40
+ finally {
41
+ if (descriptor !== undefined)
42
+ closeSync(descriptor);
43
+ }
44
+ }
45
+ export function capabilityArguments(args) {
46
+ const values = [];
47
+ let source;
48
+ for (let index = 0; index < args.length; index += 1) {
49
+ if (args[index] === '--capability-stdin') {
50
+ if (source)
51
+ throw new Error('Choose one capability source.');
52
+ source = { kind: 'stdin' };
53
+ continue;
54
+ }
55
+ if (args[index] === '--capability-file') {
56
+ const path = args[index + 1];
57
+ if (source || !path || path.startsWith('--capability-'))
58
+ throw new Error('Choose one capability source.');
59
+ source = { kind: 'file', path };
60
+ index += 1;
61
+ continue;
62
+ }
63
+ values.push(args[index]);
64
+ }
65
+ if (!source)
66
+ return { values };
67
+ if (source.kind === 'stdin' && values.includes('-'))
68
+ throw new Error('Capability stdin cannot be combined with another stdin input.');
69
+ let raw;
70
+ if (source.kind === 'stdin')
71
+ raw = readBoundedDescriptor(0);
72
+ else {
73
+ let descriptor;
74
+ try {
75
+ descriptor = openSync(source.path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
76
+ const before = fstatSync(descriptor);
77
+ if (!before.isFile() || before.uid !== process.geteuid?.() || (before.mode & 0o077) !== 0 || before.nlink !== 1 || before.size > MAX_JSON_BYTES)
78
+ throw new Error('Capability file is unavailable or unsafe.');
79
+ raw = readBoundedDescriptor(descriptor);
80
+ const after = fstatSync(descriptor);
81
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs)
82
+ throw new Error('Capability file is unavailable or unsafe.');
83
+ }
84
+ catch {
85
+ throw new Error('Capability file is unavailable or unsafe.');
86
+ }
87
+ finally {
88
+ if (descriptor !== undefined)
89
+ closeSync(descriptor);
90
+ }
91
+ }
92
+ if (Buffer.byteLength(raw, 'utf8') > MAX_JSON_BYTES)
93
+ throw new Error('JSON input exceeds the byte limit.');
94
+ return { values, capability: parseCanonicalJson(Buffer.from(raw, 'utf8')) };
95
+ }
96
+ export function readProfile(path) {
97
+ return parseProfile(JSON.parse(input(path)));
98
+ }
99
+ export function readBrief(path) {
100
+ return path ? parseWritingBrief(JSON.parse(input(path))) : undefined;
101
+ }
102
+ function tryParse(parse, value) {
103
+ try {
104
+ return parse(value);
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ export function prepareContext(paths) {
111
+ let copySpec;
112
+ let writingBrief;
113
+ for (const path of paths) {
114
+ const value = JSON.parse(input(path));
115
+ const parsedCopySpec = tryParse(parseCopySpec, value);
116
+ if (parsedCopySpec) {
117
+ if (copySpec)
118
+ throw new Error('Prepare-rewrite accepts at most one CopySpec.');
119
+ copySpec = parsedCopySpec;
120
+ continue;
121
+ }
122
+ const parsedBrief = tryParse(parseWritingBrief, value);
123
+ if (!parsedBrief)
124
+ throw new Error(`Expected a valid CopySpec or WritingBrief at ${path}.`);
125
+ if (writingBrief)
126
+ throw new Error('Prepare-rewrite accepts at most one WritingBrief.');
127
+ writingBrief = parsedBrief;
128
+ }
129
+ return { copySpec, writingBrief };
130
+ }
131
+ export function json(value) {
132
+ console.log(JSON.stringify(value, null, 2));
133
+ }
134
+ export function canonical(value) { process.stdout.write(`${canonicalJson(value)}\n`); }
135
+ export function writeJson(path, value) {
136
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
137
+ }