@voce-engine/cli 0.1.0-rc.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/LICENSE +1 -0
- package/README.md +9 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +730 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Apache License 2.0
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @voce-engine/cli
|
|
2
|
+
|
|
3
|
+
The `voce` command is the offline-first, explicit-path CLI for VOCE v0.1. It does not load lifecycle scripts, discover global packages, inspect credentials, or call a Provider unless the command explicitly selects the built-in `mock` provider.
|
|
4
|
+
|
|
5
|
+
> `0.1.0-rc.1` is a release candidate, not production-ready. CLI behavior may change before `0.1.0`.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --global @voce-engine/cli@0.1.0-rc.1
|
|
9
|
+
```
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as process from 'node:process';
|
|
5
|
+
import { MOCK_IMAGE_PROFILE, MOCK_JPEG_PROFILE, MOCK_LIMITED_REFERENCE_PROFILE, compileEvaluationReport, computeExecutionRunHash, computeCompilationContextHash, computeOntologyInstanceHash, computeSemanticReviewRequestHash, createConstraintWaiver, createHumanAcceptanceDecision, compileConstraints, createScenarioPackRegistry, executeOffline, executeSemanticReview, FixtureReferenceInterpreter, FixtureSemanticReviewer, guardPromptCandidate, renderStaticTraceReport, sha256, traceModelFromExecution, validateStructuralImage, canonicalize, } from '@voce-engine/core';
|
|
6
|
+
import { FIXTURE_M6_OPAQUE_PNG, fixtureChangeIntent, fixtureM4ConstraintInput, fixtureM5Candidate, fixtureM5ExecutionInput, fixtureM5GuardInput, fixtureM5PromptIR, fixtureM6Artifact, fixtureM6Authorization, fixtureScopePlan, } from '@voce-engine/testkit';
|
|
7
|
+
import { BUNDLE_MANIFEST_SCHEMA_VERSION } from '@voce-engine/contracts';
|
|
8
|
+
export const CLI_VERSION = '0.1.0-rc.1';
|
|
9
|
+
const TOOL_ID = '@voce-engine/cli';
|
|
10
|
+
const CONTRACTS_VERSION = '0.1.0-rc.1';
|
|
11
|
+
const CORE_VERSION = '0.1.0-rc.1';
|
|
12
|
+
const SOURCE_SCHEMA = 'voce.scenario-pack-source/v1alpha1';
|
|
13
|
+
const PACK_SCHEMA = 'voce.scenario-pack/v1alpha1';
|
|
14
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
15
|
+
const EXIT = { ok: 0, usage: 2, input: 3, contract: 4, offline: 5, output: 6, internal: 7 };
|
|
16
|
+
class CliError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
exitCode;
|
|
19
|
+
constructor(code, message, exitCode = EXIT.input) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.exitCode = exitCode;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function compare(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
|
|
26
|
+
function json(value) { return canonicalize(value); }
|
|
27
|
+
function bytesHash(bytes) { return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; }
|
|
28
|
+
function textBytes(value) { return new TextEncoder().encode(json(value)); }
|
|
29
|
+
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
|
30
|
+
function isObject(value) { return !!value && typeof value === 'object' && !Array.isArray(value); }
|
|
31
|
+
function record(value, code) {
|
|
32
|
+
if (!isObject(value))
|
|
33
|
+
throw new CliError(code, 'Expected a JSON object.');
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function assertKnown(object, fields, code) { const allowed = new Set(fields); for (const key of Object.keys(object))
|
|
37
|
+
if (!allowed.has(key))
|
|
38
|
+
throw new CliError(code, `Unknown field ${key}.`, EXIT.contract); }
|
|
39
|
+
function stringField(object, field, code = 'INPUT_FIELD_INVALID') {
|
|
40
|
+
if (typeof object[field] !== 'string' || object[field].length === 0)
|
|
41
|
+
throw new CliError(code, `Field ${field} is required.`);
|
|
42
|
+
return object[field];
|
|
43
|
+
}
|
|
44
|
+
function safeRelative(value) {
|
|
45
|
+
return value.length > 0 && !value.includes('\\') && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && !value.split('/').some((part) => part === '' || part === '.' || part === '..');
|
|
46
|
+
}
|
|
47
|
+
function safeDisplayPath(value) { return path.basename(value).replaceAll('\\', '/'); }
|
|
48
|
+
function assertHash(value, code = 'HASH_INVALID') { if (typeof value !== 'string' || !HASH.test(value))
|
|
49
|
+
throw new CliError(code, 'A content hash is invalid.', EXIT.contract); }
|
|
50
|
+
function assertNoUnsafe(value, location = 'document') {
|
|
51
|
+
if (typeof value === 'string') {
|
|
52
|
+
if (/^(?:https?:|data:|file:|\\\\|[A-Za-z]:[\\/])/.test(value))
|
|
53
|
+
throw new CliError('PUBLIC_PATH_OR_URL_FORBIDDEN', `Unsafe path or URL in ${location}.`, EXIT.contract);
|
|
54
|
+
if (value.length > 128 && /^[A-Za-z0-9+/=_-]+$/.test(value))
|
|
55
|
+
throw new CliError('PUBLIC_BASE64_FORBIDDEN', `Encoded payload in ${location}.`, EXIT.contract);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
value.forEach((item, index) => assertNoUnsafe(item, `${location}[${index}]`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (isObject(value)) {
|
|
63
|
+
for (const [key, item] of Object.entries(value)) {
|
|
64
|
+
if (/(?:password|secret|token|api[_-]?key|credential|signed[_-]?url|base64)/i.test(key) && key !== 'leftTokens' && key !== 'rightTokens')
|
|
65
|
+
throw new CliError('PUBLIC_SECRET_FIELD_FORBIDDEN', `Sensitive field ${key} is not allowed.`, EXIT.contract);
|
|
66
|
+
assertNoUnsafe(item, `${location}.${key}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function omit(value, field) { const copy = clone(value); delete copy[field]; return copy; }
|
|
71
|
+
function contributionBody(category, raw, packId) {
|
|
72
|
+
const body = { ...raw };
|
|
73
|
+
delete body.contentDigest;
|
|
74
|
+
body.id = typeof body.id === 'string' ? body.id : `${packId}.${category}.default`;
|
|
75
|
+
body.schemaVersion = typeof body.schemaVersion === 'string' ? body.schemaVersion : `voce.${category}/v1alpha1`;
|
|
76
|
+
if (category === 'rulePacks') {
|
|
77
|
+
body.namespace = typeof body.namespace === 'string' ? body.namespace : `${packId}.rules`;
|
|
78
|
+
body.rules = Array.isArray(body.rules) ? body.rules : [];
|
|
79
|
+
}
|
|
80
|
+
const digest = sha256(body);
|
|
81
|
+
return { ...body, contentDigest: digest };
|
|
82
|
+
}
|
|
83
|
+
function fixtureSuite(raw) {
|
|
84
|
+
const base = { id: raw.id, schemaVersion: 'voce.fixture-suite/v1alpha1', cases: raw.cases };
|
|
85
|
+
return { ...base, cases: raw.cases, contentDigest: sha256(base) };
|
|
86
|
+
}
|
|
87
|
+
function distributionFiles(bodies) {
|
|
88
|
+
return bodies.map((item) => ({ path: item.path, bytes: textBytes(item.content) }));
|
|
89
|
+
}
|
|
90
|
+
function makeManifest(doc, contributions, files, suiteList) {
|
|
91
|
+
const indexes = Object.keys(contributions).reduce((result, category) => {
|
|
92
|
+
if (category === 'fixtureSuites')
|
|
93
|
+
return result;
|
|
94
|
+
const values = contributions[category];
|
|
95
|
+
result[category] = values.map((body) => ({ id: String(body.id), schemaVersion: String(body.schemaVersion), contentDigest: String(body.contentDigest) }));
|
|
96
|
+
return result;
|
|
97
|
+
}, {});
|
|
98
|
+
return {
|
|
99
|
+
schemaVersion: PACK_SCHEMA, packId: doc.packId, version: doc.version, kind: doc.kind ?? 'root',
|
|
100
|
+
supportedInteractionModes: ['text_only', 'reference_guided', 'edit_existing'],
|
|
101
|
+
inputExpectations: [{ id: 'intent', inputKind: 'text_intent', dataType: 'text', requiredIn: ['text_only', 'reference_guided', 'edit_existing'], cardinality: { min: 1, max: 1 }, sensitivity: 'none' }],
|
|
102
|
+
outputExpectations: [{ id: 'image', artifactKind: 'image', dataType: 'image', producedIn: ['text_only', 'reference_guided', 'edit_existing'], cardinality: { min: 1, max: 1 }, mediaTypes: ['image/png', 'image/jpeg'] }],
|
|
103
|
+
license: 'Apache-2.0', provenance: { publisher: 'VOCE fixture authors', sourceRepository: 'https://github.com/windforce19820520-ai/visual-ontology-constraint-engine' },
|
|
104
|
+
coreRange: '>=0.1.0', contractRanges: { 'voce.scenario-pack': '>=0.1.0' }, ui: { defaultLocale: 'en', locales: { en: { displayName: doc.packId, description: 'Redistributable offline VOCE fixture pack.', messages: {} } }, disclosures: [], accessibility: { textAlternativesRequired: true, keyboardOperableReferenceUI: true, doesNotRelyOnColorAlone: true } },
|
|
105
|
+
dependencies: [], conflicts: [], composition: { before: [], after: [] }, contributions: indexes, fixtures: suiteList.map((suite) => ({ id: suite.id, schemaVersion: suite.schemaVersion, contentDigest: suite.contentDigest })), migrations: [], capabilityRequirements: [],
|
|
106
|
+
declarations: { containsExecutableScenarioCode: false, distributionLifecycleScripts: false, containsExecutableFiles: false, fixturesRequireNetwork: false, fixturesRequireRealProvider: false, collectsTelemetry: false, ...doc.declarations },
|
|
107
|
+
permissions: { network: false, remoteCalls: false, secrets: false, filesystemWrite: false, mutateConfirmedFacts: false, authorizeCalls: false, overrideHostPolicy: false, selectProvider: false, changeBudgets: false },
|
|
108
|
+
distributionInventory: files.map((file) => ({ path: file.path, role: file.path.includes('/fixtures/') ? 'fixture' : 'contribution', contentDigest: bytesHash(file.bytes) })),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function sourceDocument(raw) {
|
|
112
|
+
const value = record(raw, 'PACK_SOURCE_INVALID');
|
|
113
|
+
if (value.schemaVersion !== SOURCE_SCHEMA)
|
|
114
|
+
throw new CliError('PACK_SCHEMA_UNSUPPORTED', 'ScenarioPack source schema is unsupported.', EXIT.contract);
|
|
115
|
+
const packId = stringField(value, 'packId', 'PACK_SOURCE_INVALID');
|
|
116
|
+
const version = stringField(value, 'version', 'PACK_SOURCE_INVALID');
|
|
117
|
+
if (value.kind !== undefined && value.kind !== 'root' && value.kind !== 'extension')
|
|
118
|
+
throw new CliError('PACK_SOURCE_INVALID', 'Pack kind is invalid.');
|
|
119
|
+
const declarations = value.declarations === undefined ? { mayHandlePersonImages: false, rightsDisclosureRequired: false } : record(value.declarations, 'PACK_DECLARATIONS_INVALID');
|
|
120
|
+
assertKnown(declarations, ['mayHandlePersonImages', 'rightsDisclosureRequired'], 'PACK_DECLARATIONS_UNKNOWN_FIELD');
|
|
121
|
+
if (typeof declarations.mayHandlePersonImages !== 'boolean' || typeof declarations.rightsDisclosureRequired !== 'boolean')
|
|
122
|
+
throw new CliError('PACK_DECLARATIONS_INVALID', 'ScenarioPack declarations must be booleans.', EXIT.contract);
|
|
123
|
+
if (value.contribution !== undefined && !isObject(value.contribution))
|
|
124
|
+
throw new CliError('PACK_SOURCE_INVALID', 'Contribution must be an object.');
|
|
125
|
+
if (value.contributions !== undefined && !isObject(value.contributions))
|
|
126
|
+
throw new CliError('PACK_SOURCE_INVALID', 'Contributions must be an object.');
|
|
127
|
+
if (value.fixtures !== undefined && (!Array.isArray(value.fixtures) || value.fixtures.some((item) => !isObject(item) || typeof item.id !== 'string' || !Array.isArray(item.cases))))
|
|
128
|
+
throw new CliError('PACK_SOURCE_INVALID', 'Fixture suites are invalid.');
|
|
129
|
+
const known = new Set(['schemaVersion', 'packId', 'version', 'kind', 'scenarioLabel', 'declarations', 'contribution', 'contributions', 'fixtures']);
|
|
130
|
+
for (const key of Object.keys(value))
|
|
131
|
+
if (!known.has(key))
|
|
132
|
+
throw new CliError('PACK_UNKNOWN_FIELD', `Unknown ScenarioPack source field ${key}.`, EXIT.contract);
|
|
133
|
+
return { ...value, declarations };
|
|
134
|
+
}
|
|
135
|
+
async function noSymlinkAncestors(filePath, code) {
|
|
136
|
+
const absolute = path.resolve(filePath);
|
|
137
|
+
const parsed = path.parse(absolute);
|
|
138
|
+
let current = parsed.root;
|
|
139
|
+
for (const part of path.relative(parsed.root, absolute).split(path.sep).filter(Boolean)) {
|
|
140
|
+
current = path.join(current, part);
|
|
141
|
+
const info = await lstat(current).catch(() => undefined);
|
|
142
|
+
if (info?.isSymbolicLink())
|
|
143
|
+
throw new CliError(code, 'Input path contains a symbolic link.', EXIT.contract);
|
|
144
|
+
if (!info)
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function regularFile(filePath, unsafeCode) {
|
|
149
|
+
const absolute = path.resolve(filePath);
|
|
150
|
+
await noSymlinkAncestors(absolute, unsafeCode);
|
|
151
|
+
const info = await lstat(absolute).catch(() => undefined);
|
|
152
|
+
if (!info)
|
|
153
|
+
throw new CliError('INPUT_READ_FAILED', 'Input file cannot be read.');
|
|
154
|
+
if (info.isSymbolicLink() || info.nlink > 1 || !info.isFile())
|
|
155
|
+
throw new CliError(unsafeCode, 'Input must be a regular, single-link file.', EXIT.contract);
|
|
156
|
+
}
|
|
157
|
+
async function existingBoundary(inputPath, unsafeCode) {
|
|
158
|
+
const absolute = path.resolve(inputPath);
|
|
159
|
+
await noSymlinkAncestors(absolute, unsafeCode);
|
|
160
|
+
const info = await lstat(absolute).catch(() => undefined);
|
|
161
|
+
if (!info)
|
|
162
|
+
throw new CliError('INPUT_READ_FAILED', 'Input path cannot be read.');
|
|
163
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()) || info.isFile() && info.nlink > 1)
|
|
164
|
+
throw new CliError(unsafeCode, 'Input path is not a safe explicit file or directory.', EXIT.contract);
|
|
165
|
+
return realpath(absolute);
|
|
166
|
+
}
|
|
167
|
+
async function outputBoundary(outputPath) {
|
|
168
|
+
const absolute = path.resolve(outputPath);
|
|
169
|
+
await noSymlinkAncestors(absolute, 'OUTPUT_PATH_UNSAFE');
|
|
170
|
+
const info = await lstat(absolute).catch(() => undefined);
|
|
171
|
+
if (info?.isSymbolicLink() || info && !info.isDirectory() && !info.isFile())
|
|
172
|
+
throw new CliError('OUTPUT_PATH_UNSAFE', 'Output path is not a regular file or directory.', EXIT.output);
|
|
173
|
+
if (info)
|
|
174
|
+
return realpath(absolute);
|
|
175
|
+
const missing = [];
|
|
176
|
+
let cursor = absolute;
|
|
177
|
+
while (!(await lstat(cursor).catch(() => undefined))) {
|
|
178
|
+
const parent = path.dirname(cursor);
|
|
179
|
+
if (parent === cursor)
|
|
180
|
+
return absolute;
|
|
181
|
+
missing.unshift(path.basename(cursor));
|
|
182
|
+
cursor = parent;
|
|
183
|
+
}
|
|
184
|
+
return path.join(await realpath(cursor), ...missing);
|
|
185
|
+
}
|
|
186
|
+
function boundaryOverlaps(left, right) {
|
|
187
|
+
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
188
|
+
const a = normalize(path.resolve(left));
|
|
189
|
+
const b = normalize(path.resolve(right));
|
|
190
|
+
const same = (x, y) => x === y || x.startsWith(`${y}${path.sep}`);
|
|
191
|
+
return same(a, b) || same(b, a);
|
|
192
|
+
}
|
|
193
|
+
async function assertOutputSeparated(outputPath, inputPaths) {
|
|
194
|
+
const output = await outputBoundary(outputPath);
|
|
195
|
+
for (const inputPath of inputPaths) {
|
|
196
|
+
const input = await existingBoundary(inputPath, 'INPUT_PATH_UNSAFE');
|
|
197
|
+
if (boundaryOverlaps(output, input))
|
|
198
|
+
throw new CliError('OUTPUT_INPUT_OVERLAP', 'Output must be independent from every input path.', EXIT.output);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function readJson(filePath, unsafeCode = 'INPUT_FILE_UNSAFE') {
|
|
202
|
+
await regularFile(filePath, unsafeCode);
|
|
203
|
+
let raw;
|
|
204
|
+
try {
|
|
205
|
+
raw = await readFile(filePath, 'utf8');
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new CliError('INPUT_READ_FAILED', 'Input file cannot be read.');
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(raw);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
throw new CliError('INPUT_JSON_INVALID', 'Input is not valid JSON.');
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function loadPack(sourcePath) {
|
|
218
|
+
const explicit = path.resolve(sourcePath);
|
|
219
|
+
let inputPath = explicit;
|
|
220
|
+
const info = await lstat(explicit).catch(() => undefined);
|
|
221
|
+
if (!info)
|
|
222
|
+
throw new CliError('PACK_SOURCE_NOT_FOUND', 'Explicit ScenarioPack source was not found.');
|
|
223
|
+
if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile()) || info.isFile() && info.nlink > 1)
|
|
224
|
+
throw new CliError('PACK_SOURCE_UNSAFE', 'ScenarioPack source must be a regular file or directory.', EXIT.contract);
|
|
225
|
+
if (info.isDirectory()) {
|
|
226
|
+
const simple = path.join(explicit, 'pack.json');
|
|
227
|
+
const standard = path.join(explicit, 'scenario-pack', 'manifest.json');
|
|
228
|
+
if (await lstat(simple).catch(() => undefined))
|
|
229
|
+
inputPath = simple;
|
|
230
|
+
else if (await lstat(standard).catch(() => undefined))
|
|
231
|
+
return loadStandardDirectory(explicit, standard);
|
|
232
|
+
else
|
|
233
|
+
throw new CliError('PACK_SOURCE_INVALID', 'Explicit directory has no pack.json or scenario-pack/manifest.json.');
|
|
234
|
+
}
|
|
235
|
+
const doc = sourceDocument(await readJson(inputPath, 'PACK_SOURCE_UNSAFE'));
|
|
236
|
+
const categories = ['ontologyVocabulary', 'rulePacks', 'interpretationScopes', 'promptSections', 'reviewTemplates', 'defaults', 'overridePoints'];
|
|
237
|
+
const bodies = {};
|
|
238
|
+
const files = [];
|
|
239
|
+
for (const category of categories) {
|
|
240
|
+
const rawValues = doc.contributions?.[category] ?? (category === 'rulePacks' && doc.contribution ? [doc.contribution] : []);
|
|
241
|
+
const values = rawValues.map((raw) => contributionBody(category, raw, doc.packId));
|
|
242
|
+
bodies[category] = values;
|
|
243
|
+
values.forEach((body) => files.push({ path: `scenario-pack/contributions/${category}/${String(body.id)}.json`, content: body, role: 'contribution' }));
|
|
244
|
+
}
|
|
245
|
+
const suites = (doc.fixtures ?? [{ id: `${doc.packId}.offline`, cases: [] }]).map(fixtureSuite);
|
|
246
|
+
bodies.fixtureSuites = suites;
|
|
247
|
+
for (const suite of suites)
|
|
248
|
+
files.push({ path: `scenario-pack/fixtures/${suite.id}.json`, content: suite, role: 'fixture' });
|
|
249
|
+
const logicalFiles = distributionFiles(files);
|
|
250
|
+
const manifest = makeManifest(doc, bodies, logicalFiles, suites);
|
|
251
|
+
const definition = { manifest, contributions: bodies, migrations: [] };
|
|
252
|
+
const registry = createScenarioPackRegistry();
|
|
253
|
+
const source = { kind: 'memory', definition, logicalFiles };
|
|
254
|
+
let descriptor;
|
|
255
|
+
try {
|
|
256
|
+
descriptor = registry.register(source);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
throw new CliError(error instanceof Error ? error.message : 'PACK_REGISTER_FAILED', 'ScenarioPack validation failed.', EXIT.contract);
|
|
260
|
+
}
|
|
261
|
+
return { source, definition, descriptor, fixtures: suites.flatMap((suite) => suite.cases) };
|
|
262
|
+
}
|
|
263
|
+
async function loadStandardDirectory(root, manifestPath) {
|
|
264
|
+
await existingBoundary(root, 'PACK_SOURCE_UNSAFE');
|
|
265
|
+
await regularFile(manifestPath, 'PACK_MANIFEST_UNSAFE');
|
|
266
|
+
const manifest = await readJson(manifestPath, 'PACK_MANIFEST_UNSAFE');
|
|
267
|
+
if (!isObject(manifest) || manifest.schemaVersion !== PACK_SCHEMA || !Array.isArray(manifest.distributionInventory))
|
|
268
|
+
throw new CliError('PACK_MANIFEST_INVALID', 'ScenarioPack manifest is invalid.', EXIT.contract);
|
|
269
|
+
assertKnown(manifest, ['schemaVersion', 'packId', 'version', 'kind', 'supportedInteractionModes', 'inputExpectations', 'outputExpectations', 'extensionOf', 'license', 'provenance', 'coreRange', 'contractRanges', 'configurationSchema', 'ui', 'dependencies', 'conflicts', 'composition', 'contributions', 'fixtures', 'migrations', 'capabilityRequirements', 'declarations', 'permissions', 'distributionInventory'], 'PACK_UNKNOWN_FIELD');
|
|
270
|
+
if (manifest.distributionInventory.some((item) => !isObject(item) || typeof item.path !== 'string' || !safeRelative(item.path) || typeof item.contentDigest !== 'string' || !HASH.test(item.contentDigest)))
|
|
271
|
+
throw new CliError('PACK_MANIFEST_INVALID', 'ScenarioPack inventory is invalid.', EXIT.contract);
|
|
272
|
+
const inventoryPaths = new Set(manifest.distributionInventory.map((item) => item.path));
|
|
273
|
+
async function enumerate(directory, relative = '') {
|
|
274
|
+
const names = await readdir(path.join(directory, relative), { withFileTypes: true });
|
|
275
|
+
const result = [];
|
|
276
|
+
for (const entry of names) {
|
|
277
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
|
278
|
+
const metadata = await lstat(path.join(directory, ...child.split('/')));
|
|
279
|
+
if (metadata.isSymbolicLink() || metadata.nlink > 1)
|
|
280
|
+
throw new CliError('PACK_ENTRY_UNSAFE', 'ScenarioPack contains a symlink or hardlink.', EXIT.contract);
|
|
281
|
+
if (metadata.isDirectory())
|
|
282
|
+
result.push(...await enumerate(directory, child));
|
|
283
|
+
else if (metadata.isFile())
|
|
284
|
+
result.push(child.replaceAll('\\', '/'));
|
|
285
|
+
else
|
|
286
|
+
throw new CliError('PACK_ENTRY_UNSAFE', 'ScenarioPack contains a device or non-file entry.', EXIT.contract);
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
const actualPaths = await enumerate(root);
|
|
291
|
+
const expectedPaths = new Set(['scenario-pack/manifest.json', ...inventoryPaths]);
|
|
292
|
+
if (actualPaths.some((item) => !expectedPaths.has(item)) || expectedPaths.size !== actualPaths.length)
|
|
293
|
+
throw new CliError('PACK_INVENTORY_INCOMPLETE', 'ScenarioPack files do not match the explicit inventory.', EXIT.contract);
|
|
294
|
+
const logicalFiles = [];
|
|
295
|
+
const contributions = { ontologyVocabulary: [], rulePacks: [], interpretationScopes: [], promptSections: [], reviewTemplates: [], defaults: [], overridePoints: [], fixtureSuites: [] };
|
|
296
|
+
for (const item of manifest.distributionInventory) {
|
|
297
|
+
if (!isObject(item) || typeof item.path !== 'string' || !safeRelative(item.path))
|
|
298
|
+
throw new CliError('PACK_PATH_UNSAFE', 'ScenarioPack inventory path is unsafe.', EXIT.contract);
|
|
299
|
+
const filePath = path.join(root, ...item.path.split('/'));
|
|
300
|
+
await regularFile(filePath, 'PACK_ENTRY_UNSAFE');
|
|
301
|
+
const bytes = await readFile(filePath).catch(() => { throw new CliError('PACK_FILE_MISSING', 'ScenarioPack inventory file is missing.', EXIT.contract); });
|
|
302
|
+
if (bytesHash(bytes) !== item.contentDigest)
|
|
303
|
+
throw new CliError('PACK_DIGEST_MISMATCH', 'ScenarioPack inventory digest does not match.', EXIT.contract);
|
|
304
|
+
logicalFiles.push({ path: item.path, bytes });
|
|
305
|
+
if (item.path.includes('/contributions/')) {
|
|
306
|
+
const parts = item.path.split('/');
|
|
307
|
+
const category = parts[2];
|
|
308
|
+
const body = await parseBytes(bytes);
|
|
309
|
+
if (category in contributions && category !== 'fixtureSuites')
|
|
310
|
+
contributions[category].push(body);
|
|
311
|
+
}
|
|
312
|
+
else if (item.path.includes('/fixtures/'))
|
|
313
|
+
contributions.fixtureSuites.push(await parseBytes(bytes));
|
|
314
|
+
}
|
|
315
|
+
const definition = { manifest, contributions, migrations: [] };
|
|
316
|
+
const source = { kind: 'memory', definition, logicalFiles };
|
|
317
|
+
const registry = createScenarioPackRegistry();
|
|
318
|
+
let descriptor;
|
|
319
|
+
try {
|
|
320
|
+
descriptor = registry.register(source);
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
throw new CliError(error instanceof Error ? error.message : 'PACK_REGISTER_FAILED', 'ScenarioPack validation failed.', EXIT.contract);
|
|
324
|
+
}
|
|
325
|
+
return { source, definition, descriptor, fixtures: contributions.fixtureSuites.flatMap((suite) => suite.cases) };
|
|
326
|
+
}
|
|
327
|
+
async function parseBytes(bytes) { try {
|
|
328
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
throw new CliError('PACK_JSON_INVALID', 'ScenarioPack data file is not valid JSON.', EXIT.contract);
|
|
332
|
+
} }
|
|
333
|
+
function probeInput(probe, resolution, caseSpec) {
|
|
334
|
+
const base = fixtureM4ConstraintInput();
|
|
335
|
+
const contextBase = { ...base.context, caseSpecId: caseSpec.id, caseSpecRevision: caseSpec.revision, caseSpecHash: sha256({ fixtureCase: caseSpec.id, revision: caseSpec.revision }), effectiveScenarioHash: resolution.effectiveScenario.effectiveScenarioHash };
|
|
336
|
+
const context = { ...contextBase, contextHash: computeCompilationContextHash(contextBase) };
|
|
337
|
+
const requestedScopePlan = fixtureScopePlan(['person.identity'], 'ref-01', caseSpec.id, caseSpec.revision);
|
|
338
|
+
const ontologyBase = { ...base.ontologyInstance, id: `ontology-${probe.id}`, caseId: caseSpec.id, caseRevision: caseSpec.revision, contextHash: context.contextHash, requestedScopePlanHash: requestedScopePlan.planHash, unknownPaths: probe.unknownPaths ?? [], unspecifiedPaths: [], facts: [], conflicts: [], unresolvedItems: [], decisionTrace: [] };
|
|
339
|
+
const ontologyInstance = { ...ontologyBase, instanceHash: computeOntologyInstanceHash({ ...ontologyBase, instanceHash: '' }) };
|
|
340
|
+
const intents = (probe.intents ?? []).map((intent) => ({ ...fixtureChangeIntent(intent.id, intent.operation, intent.targetPath, intent.requestedValue), importance: intent.importance ?? 'required' }));
|
|
341
|
+
const waivers = probe.waiverTarget ? [createConstraintWaiver({ schemaVersion: 'voce.constraint-waiver/v1alpha1', id: `waiver-${probe.id}`, caseId: caseSpec.id, caseRevision: caseSpec.revision, contextHash: context.contextHash, targetId: probe.waiverTarget, authority: 'user', decidedBy: 'fixture-reviewer', reasonCode: 'FIXTURE_EXPLICIT_WAIVER', decidedAt: '2026-01-01T00:00:00.000Z' })] : [];
|
|
342
|
+
return fixtureM4ConstraintInput({ caseId: caseSpec.id, caseRevision: caseSpec.revision, context, contextHash: context.contextHash, requestedScopePlanHash: requestedScopePlan.planHash, ontologyInstance, changeIntents: intents, effectiveScenario: resolution.effectiveScenario, waivers });
|
|
343
|
+
}
|
|
344
|
+
function coreProbeEvidence(probe, resolution, caseSpec) {
|
|
345
|
+
const ir = compileConstraints(probeInput(probe, resolution, caseSpec));
|
|
346
|
+
return {
|
|
347
|
+
status: ir.status, constraintHash: ir.deterministicSignature, blockingConflictCount: ir.conflicts.filter((item) => item.blocking).length,
|
|
348
|
+
conflictCodes: ir.conflicts.map((item) => item.code).sort(compare), reviewRequirementCount: ir.reviewRequirements.length,
|
|
349
|
+
reviewReasons: ir.reviewRequirements.map((item) => item.reasonCode).sort(compare), waivedWarnings: ir.warnings.filter((item) => item === 'REQUIRED_CONFLICT_WAIVED' || item === 'HARD_CONFLICT_CANNOT_WAIVE').sort(compare),
|
|
350
|
+
degradationCount: ir.degradedPreferences.length, personIntentCount: (probe.intents ?? []).filter((item) => item.targetPath.startsWith('person.')).length,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function referenceEvidence(fixture, resolution, caseSpec) {
|
|
354
|
+
const paths = fixture.scopePaths ?? [];
|
|
355
|
+
const plan = fixtureScopePlan(paths, 'ref-01', caseSpec.id, caseSpec.revision);
|
|
356
|
+
const result = new FixtureReferenceInterpreter().interpret({ schemaVersion: 'voce.reference-interpreter-input/v1alpha1', caseId: caseSpec.id, caseRevision: caseSpec.revision, contextHash: sha256({ fixture: fixture.id, caseId: caseSpec.id }), assets: caseSpec.assets, requestedScopePlan: plan, effectiveScenario: resolution.effectiveScenario, fixtureId: fixture.id });
|
|
357
|
+
return { status: result.status, observationCount: result.observations.length, personObservationCount: result.observations.filter((item) => item.ontologyPath.startsWith('person.')).length, observationPaths: result.observations.map((item) => item.ontologyPath).sort(compare), observationAssetIds: result.observations.map((item) => item.assetId).sort(compare), unresolvedCodes: result.unresolvedItems.map((item) => item.code).sort(compare), resultHash: result.resultHash };
|
|
358
|
+
}
|
|
359
|
+
function pathValue(value, target) {
|
|
360
|
+
let current = value;
|
|
361
|
+
for (const part of target.split('.')) {
|
|
362
|
+
if (!isObject(current) && !Array.isArray(current))
|
|
363
|
+
return undefined;
|
|
364
|
+
current = current[part];
|
|
365
|
+
}
|
|
366
|
+
return current;
|
|
367
|
+
}
|
|
368
|
+
function assertionMatches(actual, assertion) {
|
|
369
|
+
if (assertion.operator === 'exists')
|
|
370
|
+
return actual !== undefined;
|
|
371
|
+
if (assertion.operator === 'sha256')
|
|
372
|
+
return typeof actual === 'string' && HASH.test(actual);
|
|
373
|
+
if (assertion.operator === 'gte')
|
|
374
|
+
return typeof actual === 'number' && typeof assertion.expected === 'number' && actual >= assertion.expected;
|
|
375
|
+
if (assertion.operator === 'includes')
|
|
376
|
+
return Array.isArray(actual) ? actual.some((item) => canonicalize(item) === canonicalize(assertion.expected)) : typeof actual === 'string' && typeof assertion.expected === 'string' && actual.includes(assertion.expected);
|
|
377
|
+
return canonicalize(actual) === canonicalize(assertion.expected);
|
|
378
|
+
}
|
|
379
|
+
function assertFixtureAssertions(fixture, context, phase) {
|
|
380
|
+
const assertions = fixture.expectedAssertions ?? [];
|
|
381
|
+
const selected = assertions.filter((assertion) => phase === 'all' || assertion.phase === phase);
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
const results = [];
|
|
384
|
+
for (const assertion of selected) {
|
|
385
|
+
if (seen.has(assertion.id) || !assertion.id || !assertion.target || !['equals', 'includes', 'gte', 'exists', 'sha256'].includes(assertion.operator))
|
|
386
|
+
throw new CliError('FIXTURE_ASSERTION_INVALID', 'Fixture assertion contract is invalid.', EXIT.contract);
|
|
387
|
+
seen.add(assertion.id);
|
|
388
|
+
const actual = pathValue(context, assertion.target);
|
|
389
|
+
if (!assertionMatches(actual, assertion))
|
|
390
|
+
throw new CliError('FIXTURE_ASSERTION_FAILED', `Fixture assertion ${assertion.id} did not match.`, EXIT.contract);
|
|
391
|
+
results.push({ id: assertion.id, status: 'passed', target: assertion.target, operator: assertion.operator, observedHash: sha256(actual === undefined ? null : actual) });
|
|
392
|
+
}
|
|
393
|
+
if (phase === 'all' && results.length !== assertions.length)
|
|
394
|
+
throw new CliError('FIXTURE_ASSERTION_NOT_EXECUTED', 'A declared fixture assertion was not executed.', EXIT.contract);
|
|
395
|
+
return results.sort((left, right) => compare(String(left.id), String(right.id)));
|
|
396
|
+
}
|
|
397
|
+
function profileFor(id) {
|
|
398
|
+
if (id === MOCK_IMAGE_PROFILE.id)
|
|
399
|
+
return MOCK_IMAGE_PROFILE;
|
|
400
|
+
if (id === MOCK_JPEG_PROFILE.id)
|
|
401
|
+
return MOCK_JPEG_PROFILE;
|
|
402
|
+
if (id === MOCK_LIMITED_REFERENCE_PROFILE.id)
|
|
403
|
+
return MOCK_LIMITED_REFERENCE_PROFILE;
|
|
404
|
+
throw new CliError('PROFILE_UNSUPPORTED', 'Only a declared offline Mock capability profile is accepted.', EXIT.contract);
|
|
405
|
+
}
|
|
406
|
+
async function loadProfile(filePath) { const object = record(await readJson(path.resolve(filePath), 'PROFILE_FILE_UNSAFE'), 'PROFILE_JSON_INVALID'); return profileFor(stringField(object, 'id', 'PROFILE_INVALID')); }
|
|
407
|
+
function profilePin(profile) { return { id: profile.id, version: profile.version, digest: profile.profileHash }; }
|
|
408
|
+
function toolPin(id, version) { return { id, version, digest: sha256({ id, version }) }; }
|
|
409
|
+
function defaultCase(doc, packId) {
|
|
410
|
+
assertKnown(doc, ['schemaVersion', 'id', 'revision', 'fixtureId', 'mode', 'scenario', 'userIntent', 'assets', 'trustedMetadata', 'policies', 'requestedOutput'], 'CASE_UNKNOWN_FIELD');
|
|
411
|
+
const id = typeof doc.id === 'string' ? doc.id : `${packId}-case`;
|
|
412
|
+
const revision = typeof doc.revision === 'number' ? doc.revision : 1;
|
|
413
|
+
const output = isObject(doc.requestedOutput) ? doc.requestedOutput : { artifactKind: 'image', dataType: 'image', mediaTypes: ['image/png'], cardinality: { min: 1, max: 1 }, background: 'opaque', allowAlpha: false };
|
|
414
|
+
const asset = { id: 'ref-01', storeId: 'fixture-store', contentHash: sha256({ fixture: `${id}:ref-01` }), mediaType: 'image/png', byteLength: 64, role: 'reference', resolverId: 'fixture-resolver', availability: 'available', retentionClass: 'fixture', redactionPolicy: 'hash-only' };
|
|
415
|
+
return { schemaVersion: 'voce.case-spec/v1alpha1', id, revision, mode: 'manual', scenario: { root: { packId, versionRange: '0.1.0' }, extensions: [] }, userIntent: typeof doc.userIntent === 'string' ? doc.userIntent : 'Offline fixture case.', assets: [asset], trustedMetadata: [], policies: { schemaVersion: 'voce.case-policies/v1alpha1', observationConfirmation: 'explicit', bindingConfirmation: 'explicit', allowDeclaredDefaults: true }, requestedOutput: output };
|
|
416
|
+
}
|
|
417
|
+
async function compileInputs(casePath, scenarioPath, profilePath) {
|
|
418
|
+
const caseDoc = record(await readJson(path.resolve(casePath), 'CASE_FILE_UNSAFE'), 'CASE_JSON_INVALID');
|
|
419
|
+
const loaded = await loadPack(scenarioPath);
|
|
420
|
+
const registry = createScenarioPackRegistry();
|
|
421
|
+
registry.register(loaded.source);
|
|
422
|
+
const resolution = registry.resolve({ root: { packId: loaded.descriptor.manifest.packId, versionRange: loaded.descriptor.manifest.version }, extensions: [] });
|
|
423
|
+
if (resolution.status !== 'resolved')
|
|
424
|
+
throw new CliError('PACK_RESOLUTION_BLOCKED', 'ScenarioPack resolution is blocked.', EXIT.contract);
|
|
425
|
+
const caseSpec = defaultCase(caseDoc, loaded.descriptor.manifest.packId);
|
|
426
|
+
const fixtureId = typeof caseDoc.fixtureId === 'string' ? caseDoc.fixtureId : caseSpec.id;
|
|
427
|
+
const fixture = loaded.fixtures.find((candidate) => candidate.id === fixtureId) ?? loaded.fixtures[0] ?? { id: fixtureId, profileId: MOCK_IMAGE_PROFILE.id };
|
|
428
|
+
const profile = await loadProfile(profilePath);
|
|
429
|
+
const prompt = fixtureM5Candidate(fixtureM5PromptIR(profile));
|
|
430
|
+
const input = fixtureM5ExecutionInput(profile);
|
|
431
|
+
const coreProbes = Object.fromEntries((fixture.coreProbes ?? []).sort((left, right) => compare(left.id, right.id)).map((probe) => [probe.id, coreProbeEvidence(probe, resolution, caseSpec)]));
|
|
432
|
+
const acceptance = { resolution: { status: resolution.status, selectedCount: resolution.report.selected.length, effectiveScenarioHash: resolution.effectiveScenario.effectiveScenarioHash }, reference: referenceEvidence(fixture, resolution, caseSpec), coreProbes, compile: { constraintHash: input.constraintIR.deterministicSignature, referencePlanHash: input.referencePlan.planHash, pipelinePlanHash: input.pipelinePlan.planHash } };
|
|
433
|
+
return { loaded, resolution, caseSpec, profile, fixture, input, prompt, acceptance };
|
|
434
|
+
}
|
|
435
|
+
function bundlePins(profile, scenario) { return { tool: toolPin(TOOL_ID, CLI_VERSION), core: toolPin('@voce-engine/core', CORE_VERSION), contracts: toolPin('@voce-engine/contracts', CONTRACTS_VERSION), scenario: { id: scenario.descriptor.manifest.packId, version: scenario.descriptor.manifest.version, digest: scenario.descriptor.packageDigest }, profile: profilePin(profile) }; }
|
|
436
|
+
function payloadBytes(payload) { return Object.entries(payload).sort((a, b) => compare(a[0], b[0])).map(([name, value]) => ({ path: `${name}.json`, bytes: textBytes(value) })); }
|
|
437
|
+
function semanticManifestBase(kind, caseInfo, pins, files) { return { schemaVersion: BUNDLE_MANIFEST_SCHEMA_VERSION, kind, case: caseInfo, pins, files, createdBy: 'voce-cli' }; }
|
|
438
|
+
async function prepareOutput(directory, allowed) {
|
|
439
|
+
const absolute = path.resolve(directory);
|
|
440
|
+
await mkdir(absolute, { recursive: true });
|
|
441
|
+
const entries = await readdir(absolute, { withFileTypes: true });
|
|
442
|
+
for (const entry of entries)
|
|
443
|
+
if (!allowed.has(entry.name))
|
|
444
|
+
throw new CliError('OUTPUT_DIRECTORY_NOT_EXCLUSIVE', 'Output directory contains an unrelated entry.', EXIT.output);
|
|
445
|
+
}
|
|
446
|
+
async function atomicWrite(filePath, bytes) { await mkdir(path.dirname(filePath), { recursive: true }); const temporary = path.join(path.dirname(filePath), `.voce-tmp-${randomUUID()}`); await writeFile(temporary, bytes); await rename(temporary, filePath); }
|
|
447
|
+
async function writeBundle(directory, kind, caseInfo, pins, payload) {
|
|
448
|
+
const files = payloadBytes(payload);
|
|
449
|
+
const fileEntries = files.map((file) => ({ path: file.path, sha256: bytesHash(file.bytes), byteLength: file.bytes.byteLength })).sort((a, b) => compare(a.path, b.path));
|
|
450
|
+
const base = semanticManifestBase(kind, caseInfo, pins, fileEntries);
|
|
451
|
+
const semanticHash = sha256(base);
|
|
452
|
+
const manifest = { ...base, semanticHash };
|
|
453
|
+
const allowed = new Set(['manifest.json', ...files.map((file) => file.path)]);
|
|
454
|
+
await prepareOutput(directory, allowed);
|
|
455
|
+
for (const file of files)
|
|
456
|
+
await atomicWrite(path.join(path.resolve(directory), ...file.path.split('/')), file.bytes);
|
|
457
|
+
await atomicWrite(path.join(path.resolve(directory), 'manifest.json'), textBytes(manifest));
|
|
458
|
+
return { manifest, semanticHash };
|
|
459
|
+
}
|
|
460
|
+
async function validateBundlePath(bundlePath) {
|
|
461
|
+
const directory = path.resolve(bundlePath);
|
|
462
|
+
const info = await lstat(directory).catch(() => undefined);
|
|
463
|
+
if (!info?.isDirectory() || info.isSymbolicLink())
|
|
464
|
+
throw new CliError('BUNDLE_NOT_FOUND', 'Bundle directory was not found.', EXIT.contract);
|
|
465
|
+
await existingBoundary(directory, 'BUNDLE_SOURCE_UNSAFE');
|
|
466
|
+
const manifestPath = path.join(directory, 'manifest.json');
|
|
467
|
+
await regularFile(manifestPath, 'BUNDLE_MANIFEST_UNSAFE');
|
|
468
|
+
const manifest = record(await readJson(manifestPath, 'BUNDLE_MANIFEST_UNSAFE'), 'BUNDLE_MANIFEST_INVALID');
|
|
469
|
+
assertKnown(manifest, ['schemaVersion', 'kind', 'case', 'pins', 'files', 'semanticHash', 'createdBy'], 'BUNDLE_UNKNOWN_FIELD');
|
|
470
|
+
if (manifest.schemaVersion !== BUNDLE_MANIFEST_SCHEMA_VERSION || !['compiled', 'run', 'evaluation', 'trace', 'release-candidate'].includes(manifest.kind))
|
|
471
|
+
throw new CliError('BUNDLE_SCHEMA_UNSUPPORTED', 'Bundle manifest schema or kind is unsupported.', EXIT.contract);
|
|
472
|
+
assertHash(manifest.semanticHash, 'BUNDLE_SEMANTIC_HASH_INVALID');
|
|
473
|
+
if (!isObject(manifest.case) || typeof manifest.case.id !== 'string' || typeof manifest.case.revision !== 'number')
|
|
474
|
+
throw new CliError('BUNDLE_CASE_INVALID', 'Bundle case pin is invalid.', EXIT.contract);
|
|
475
|
+
assertKnown(manifest.case, ['id', 'revision'], 'BUNDLE_CASE_UNKNOWN_FIELD');
|
|
476
|
+
if (!isObject(manifest.pins))
|
|
477
|
+
throw new CliError('BUNDLE_PINS_INVALID', 'Bundle pins are invalid.', EXIT.contract);
|
|
478
|
+
assertKnown(manifest.pins, ['tool', 'core', 'contracts', 'scenario', 'profile'], 'BUNDLE_PINS_UNKNOWN_FIELD');
|
|
479
|
+
for (const pin of Object.values(manifest.pins)) {
|
|
480
|
+
if (pin === undefined)
|
|
481
|
+
continue;
|
|
482
|
+
if (!isObject(pin))
|
|
483
|
+
throw new CliError('BUNDLE_PIN_INVALID', 'Bundle version pin is invalid.', EXIT.contract);
|
|
484
|
+
assertKnown(pin, ['id', 'version', 'digest'], 'BUNDLE_PIN_UNKNOWN_FIELD');
|
|
485
|
+
assertHash(pin.digest, 'BUNDLE_PIN_DIGEST_INVALID');
|
|
486
|
+
}
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
const files = Array.isArray(manifest.files) ? manifest.files : [];
|
|
489
|
+
for (const entry of files) {
|
|
490
|
+
if (!isObject(entry))
|
|
491
|
+
throw new CliError('BUNDLE_INVENTORY_INVALID', 'Bundle inventory entry is invalid.', EXIT.contract);
|
|
492
|
+
assertKnown(entry, ['path', 'sha256', 'byteLength'], 'BUNDLE_FILE_UNKNOWN_FIELD');
|
|
493
|
+
if (typeof entry.path !== 'string' || !safeRelative(entry.path) || typeof entry.byteLength !== 'number' || !Number.isInteger(entry.byteLength) || entry.byteLength < 0 || seen.has(entry.path) || [...seen].some((item) => item.toLowerCase() === entry.path.toLowerCase()))
|
|
494
|
+
throw new CliError('BUNDLE_INVENTORY_INVALID', 'Bundle inventory contains an unsafe or duplicate path.', EXIT.contract);
|
|
495
|
+
seen.add(entry.path);
|
|
496
|
+
assertHash(entry.sha256, 'BUNDLE_FILE_HASH_INVALID');
|
|
497
|
+
}
|
|
498
|
+
const actual = await readdir(directory, { withFileTypes: true });
|
|
499
|
+
for (const entry of actual) {
|
|
500
|
+
if (entry.name === 'manifest.json')
|
|
501
|
+
continue;
|
|
502
|
+
if (!seen.has(entry.name))
|
|
503
|
+
throw new CliError('BUNDLE_EXTRA_FILE', 'Bundle has a file outside its manifest.', EXIT.contract);
|
|
504
|
+
const info = await lstat(path.join(directory, entry.name));
|
|
505
|
+
if (info.isSymbolicLink() || info.nlink > 1 || !info.isFile())
|
|
506
|
+
throw new CliError('BUNDLE_ENTRY_UNSAFE', 'Bundle contains a symlink, hardlink, device, or non-file entry.', EXIT.contract);
|
|
507
|
+
}
|
|
508
|
+
for (const entry of files) {
|
|
509
|
+
const filePath = path.join(directory, ...entry.path.split('/'));
|
|
510
|
+
const present = await lstat(filePath).catch(() => undefined);
|
|
511
|
+
if (!present)
|
|
512
|
+
throw new CliError('BUNDLE_FILE_MISSING', 'Bundle inventory file is missing.', EXIT.contract);
|
|
513
|
+
await regularFile(filePath, 'BUNDLE_ENTRY_UNSAFE');
|
|
514
|
+
const bytes = await readFile(filePath).catch(() => { throw new CliError('BUNDLE_FILE_MISSING', 'Bundle inventory file is missing.', EXIT.contract); });
|
|
515
|
+
if (bytes.byteLength !== entry.byteLength || bytesHash(bytes) !== entry.sha256)
|
|
516
|
+
throw new CliError('BUNDLE_FILE_HASH_MISMATCH', 'Bundle file hash or length does not match its manifest.', EXIT.contract);
|
|
517
|
+
}
|
|
518
|
+
const semantic = sha256(semanticManifestBase(manifest.kind, manifest.case, manifest.pins, files));
|
|
519
|
+
if (semantic !== manifest.semanticHash)
|
|
520
|
+
throw new CliError('BUNDLE_SEMANTIC_HASH_MISMATCH', 'Bundle semantic hash does not match its manifest.', EXIT.contract);
|
|
521
|
+
const payload = {};
|
|
522
|
+
for (const entry of files) {
|
|
523
|
+
if (!entry.path.endsWith('.json'))
|
|
524
|
+
continue;
|
|
525
|
+
payload[entry.path.slice(0, -5)] = await readJson(path.join(directory, ...entry.path.split('/')), 'BUNDLE_ENTRY_UNSAFE');
|
|
526
|
+
}
|
|
527
|
+
assertNoUnsafe(payload, 'bundle');
|
|
528
|
+
return { manifest, payload };
|
|
529
|
+
}
|
|
530
|
+
function outputSummary(manifest, extra = {}) { return { status: 'ok', kind: manifest.kind, caseId: manifest.case.id, revision: manifest.case.revision, semanticHash: manifest.semanticHash, ...extra }; }
|
|
531
|
+
async function compileCommand(args) {
|
|
532
|
+
const casePath = required(args, 'case');
|
|
533
|
+
const scenarioPath = required(args, 'scenario');
|
|
534
|
+
const profilePath = required(args, 'profile');
|
|
535
|
+
const outputPath = required(args, 'out');
|
|
536
|
+
await assertOutputSeparated(outputPath, [casePath, scenarioPath, profilePath]);
|
|
537
|
+
const result = await compileInputs(casePath, scenarioPath, profilePath);
|
|
538
|
+
const guardInput = fixtureM5GuardInput(fixtureM5PromptIR(result.profile), result.prompt);
|
|
539
|
+
const guard = guardPromptCandidate(guardInput);
|
|
540
|
+
const acceptance = clone(result.acceptance);
|
|
541
|
+
acceptance.compile = { ...acceptance.compile, promptGuard: guard.status };
|
|
542
|
+
const assertionResults = assertFixtureAssertions(result.fixture, acceptance, 'compile');
|
|
543
|
+
acceptance.assertions = assertionResults;
|
|
544
|
+
acceptance.assertionHash = sha256(assertionResults);
|
|
545
|
+
const payload = { 'case': result.caseSpec, 'scenario': result.resolution, 'profile': { id: result.profile.id, version: result.profile.version, digest: result.profile.profileHash }, 'fixture': result.fixture, 'constraint-ir': result.input.constraintIR, 'reference-plan': result.input.referencePlan, 'pipeline-plan': result.input.pipelinePlan, 'prompt-ir': result.input.promptArtifact, 'prompt-guard': guard, 'execution-input': result.input, acceptance, 'replay-contract': { planReplay: 'available', artifactReplay: 'deferred-until-run', liveRerun: 'requires-new-authorization' } };
|
|
546
|
+
const written = await writeBundle(outputPath, 'compiled', { id: result.caseSpec.id, revision: result.caseSpec.revision }, bundlePins(result.profile, result.loaded), payload);
|
|
547
|
+
return outputSummary(written.manifest, { promptGuard: guard.status, scenario: result.loaded.descriptor.manifest.packId });
|
|
548
|
+
}
|
|
549
|
+
async function semanticForRun(run, artifacts, profile) {
|
|
550
|
+
const reviewer = new FixtureSemanticReviewer();
|
|
551
|
+
const inputHash = sha256({ runId: run.id, artifacts: artifacts.map((item) => item.contentHash).sort() });
|
|
552
|
+
const model = reviewer.version;
|
|
553
|
+
const adapter = { id: 'voce.fixture-semantic-reviewer-adapter', version: '1.0.0', digest: sha256({ id: 'voce.fixture-semantic-reviewer-adapter', version: '1.0.0' }) };
|
|
554
|
+
const requestBase = { schemaVersion: 'voce.semantic-review-request/v1alpha1', id: `semantic-${run.id}`, caseId: run.caseId, caseRevision: run.caseRevision, contextHash: run.contextHash, inputHash, outputArtifacts: artifacts, criteria: [{ id: 'fixture.semantic', kind: 'semantic_fidelity', importance: 'required', prompt: 'Offline fixture semantic proposal.' }], model, adapter, profile: { id: profile.id, version: profile.version, digest: profile.profileHash }, authorizationId: `semantic-auth-${run.id}`, destination: 'local', allowedEvidenceRegionIds: [], dataCategories: ['image'], budget: { schemaVersion: 'voce.budget/v1alpha1', id: `semantic-budget-${run.id}`, maximumCalls: 1, maximumRetries: 0, timeoutMs: 60_000 } };
|
|
555
|
+
const request = { ...requestBase, requestHash: computeSemanticReviewRequestHash(requestBase) };
|
|
556
|
+
const authorization = fixtureM6Authorization({ id: request.authorizationId, caseId: request.caseId, caseRevision: request.caseRevision, contextHash: request.contextHash, stepId: request.id, purpose: 'semantic_review', inputHash, artifactHashes: artifacts.map((item) => item.contentHash), adapter, profileDigest: request.profile.digest, destination: 'local', dataCategories: ['image'], budget: request.budget, modelId: model.id, modelVersion: model.version });
|
|
557
|
+
const execution = await executeSemanticReview(reviewer, request, authorization);
|
|
558
|
+
return { report: execution.report, receipt: execution.receipt, remote: execution.remoteCallRun };
|
|
559
|
+
}
|
|
560
|
+
async function runCommand(args) {
|
|
561
|
+
if (args.provider !== 'mock')
|
|
562
|
+
throw new CliError('PROVIDER_DISABLED', 'The default provider is disabled; pass --provider mock for offline execution.', EXIT.offline);
|
|
563
|
+
const bundlePath = required(args, 'bundle');
|
|
564
|
+
const outputPath = required(args, 'out');
|
|
565
|
+
await assertOutputSeparated(outputPath, [bundlePath]);
|
|
566
|
+
const bundle = await validateBundlePath(bundlePath);
|
|
567
|
+
if (bundle.manifest.kind !== 'compiled')
|
|
568
|
+
throw new CliError('BUNDLE_KIND_INVALID', 'case run requires a compiled bundle.', EXIT.contract);
|
|
569
|
+
const input = bundle.payload['execution-input'];
|
|
570
|
+
if (!input || input.schemaVersion !== 'voce.offline-execution-input/v1alpha1')
|
|
571
|
+
throw new CliError('EXECUTION_INPUT_INVALID', 'Compiled bundle has no valid offline execution input.', EXIT.contract);
|
|
572
|
+
const result = executeOffline(input);
|
|
573
|
+
const run = result.run ?? result.executionRun;
|
|
574
|
+
if (!run)
|
|
575
|
+
throw new CliError(result.code || 'EXECUTION_BLOCKED', 'Offline execution did not produce a run.', EXIT.offline);
|
|
576
|
+
const artifacts = run.outputArtifacts ?? [];
|
|
577
|
+
const structuralArtifact = fixtureM6Artifact(`structural-${run.id}`, FIXTURE_M6_OPAQUE_PNG, 'image/png', 'generated-image');
|
|
578
|
+
const outputContract = { artifactKind: 'image', dataType: 'image', mediaTypes: ['image/png'], cardinality: { min: 1, max: 1 }, background: 'opaque', allowAlpha: false };
|
|
579
|
+
const structural = validateStructuralImage({ schemaVersion: 'voce.structural-validation-input/v1alpha1', id: `structural-${run.id}`, artifacts: [{ artifact: structuralArtifact, bytes: FIXTURE_M6_OPAQUE_PNG }], outputContract, expectedCardinality: { min: 1, max: 1 } });
|
|
580
|
+
const fixture = bundle.payload.fixture;
|
|
581
|
+
let semanticProposal;
|
|
582
|
+
const semanticReceipts = [];
|
|
583
|
+
const reconciliation = [];
|
|
584
|
+
if (fixture?.semanticReview) {
|
|
585
|
+
const semantic = await semanticForRun(run, [structuralArtifact, ...artifacts], profileFor(String(bundle.payload.profile?.id ?? MOCK_IMAGE_PROFILE.id)));
|
|
586
|
+
semanticProposal = semantic.report;
|
|
587
|
+
semanticReceipts.push(semantic.receipt);
|
|
588
|
+
reconciliation.push(semantic.remote);
|
|
589
|
+
}
|
|
590
|
+
const human = fixture?.humanPending ? createHumanAcceptanceDecision({ schemaVersion: 'voce.human-acceptance-decision/v1alpha1', id: `human-${run.id}`, runId: run.id, status: 'pending', annotations: [], artifactIds: [structuralArtifact.id, ...artifacts.map((item) => item.id)] }) : undefined;
|
|
591
|
+
const evaluation = compileEvaluationReport({ run: { id: run.id, technicalOutcome: run.technicalOutcome, state: run.state, contextHash: run.contextHash, pipelinePlanHash: run.pipelinePlanHash, promptArtifactHash: run.promptArtifactHash }, structural, semanticProposal, humanAcceptance: human, cleanup: result.cleanupReceipts, replay: { mode: 'artifact', status: 'available', code: 'REPLAY_AVAILABLE', artifactIds: artifacts.map((item) => item.id) }, artifacts: [structuralArtifact, ...artifacts], sourceHashes: { compiledBundle: bundle.manifest.semanticHash, executionRun: computeExecutionRunHash(run) } });
|
|
592
|
+
const traceModel = traceModelFromExecution({ run, receipts: [...result.receipts, ...semanticReceipts], cleanup: result.cleanupReceipts, reconciliation: [...result.remoteCallRuns, ...reconciliation], artifacts: [structuralArtifact, ...artifacts], structural, semanticProposal, humanAcceptance: human, budgets: input.pipelinePlan.steps.map((step) => step.budget), destinations: input.pipelinePlan.dataTransfers.map((item) => item.destination), constraintHash: input.constraintIR.deterministicSignature, referencePlanHash: input.referencePlan.planHash, promptHash: input.executionAuthorization.promptArtifactHash, warnings: result.reasons });
|
|
593
|
+
const acceptance = isObject(bundle.payload.acceptance) ? clone(bundle.payload.acceptance) : {};
|
|
594
|
+
const runEvidence = { executionStatus: result.status, executionCode: result.code, receiptCount: result.receipts.length + semanticReceipts.length, receiptAdapters: [...result.receipts, ...semanticReceipts].map((item) => item.adapterId).sort(compare), artifactMediaTypes: artifacts.map((item) => item.mediaType).sort(compare), artifactRoles: artifacts.map((item) => item.role).sort(compare), structuralStatus: structural.status, semanticStatus: semanticProposal?.status ?? 'absent', humanStatus: human?.status ?? 'absent', evaluationStatus: evaluation.status, traceModelPresent: true, traceModelHash: traceModel.modelHash };
|
|
595
|
+
acceptance.run = runEvidence;
|
|
596
|
+
const runAssertions = fixture ? assertFixtureAssertions(fixture, acceptance, 'run') : [];
|
|
597
|
+
acceptance.assertions = [...(Array.isArray(acceptance.assertions) ? acceptance.assertions : []), ...runAssertions].sort((left, right) => compare(String(left.id), String(right.id)));
|
|
598
|
+
acceptance.assertionHash = sha256(acceptance.assertions);
|
|
599
|
+
const payload = { 'compiled-ref': { semanticHash: bundle.manifest.semanticHash }, 'run': run, 'events': result.events, 'receipts': [...result.receipts, ...semanticReceipts], 'remote-call-runs': [...result.remoteCallRuns, ...reconciliation], 'cleanup': result.cleanupReceipts, 'compensation': result.compensationReceipts, 'evaluation': evaluation, 'trace-model': traceModel, acceptance, 'comparison-snapshot': { constraintIR: input.constraintIR, referencePlan: input.referencePlan, promptIR: input.promptArtifact, pipelinePlan: input.pipelinePlan, receipts: [...result.receipts, ...semanticReceipts], evaluation } };
|
|
600
|
+
const written = await writeBundle(outputPath, 'run', bundle.manifest.case, bundle.manifest.pins, payload);
|
|
601
|
+
return outputSummary(written.manifest, { execution: result.status, executionCode: result.code, evaluation: evaluation.status, traceModel: traceModel.modelHash });
|
|
602
|
+
}
|
|
603
|
+
async function traceCommand(args) { const bundlePath = required(args, 'bundle'); const outputPath = required(args, 'out'); await assertOutputSeparated(outputPath, [bundlePath]); const bundle = await validateBundlePath(bundlePath); const model = bundle.payload['trace-model']; if (!model || model.schemaVersion !== 'voce.static-trace-report-model/v1alpha1')
|
|
604
|
+
throw new CliError('TRACE_MODEL_INVALID', 'Bundle has no valid static trace model.', EXIT.contract); const report = renderStaticTraceReport(model); await atomicWrite(path.resolve(outputPath), report.content); return { status: 'ok', mediaType: report.mediaType, contentHash: report.contentHash, modelHash: report.modelHash }; }
|
|
605
|
+
function comparisonSnapshot(payload) { return { constraintIR: payload['constraint-ir'], referencePlan: payload['reference-plan'], promptIR: payload['prompt-ir'], pipelinePlan: payload['pipeline-plan'], receipts: payload.receipts, evaluation: payload.evaluation }; }
|
|
606
|
+
async function compareCommand(args) { const beforePath = required(args, 'before'); const afterPath = required(args, 'after'); const out = args.out ? path.resolve(args.out) : undefined; if (out)
|
|
607
|
+
await assertOutputSeparated(out, [beforePath, afterPath]); const before = await validateBundlePath(beforePath); const after = await validateBundlePath(afterPath); const beforeSnapshot = before.payload['comparison-snapshot'] ?? comparisonSnapshot(before.payload); const afterSnapshot = after.payload['comparison-snapshot'] ?? comparisonSnapshot(after.payload); const { compareSnapshots } = await import('@voce-engine/core'); const report = compareSnapshots({ caseId: after.manifest.case.id, beforeRevision: before.manifest.case.revision, afterRevision: after.manifest.case.revision, before: beforeSnapshot, after: afterSnapshot }); if (out)
|
|
608
|
+
await atomicWrite(out, textBytes(report)); return out ? { status: 'ok', reportHash: report.reportHash, output: safeDisplayPath(out) } : report; }
|
|
609
|
+
async function packInspectCommand(source) { const loaded = await loadPack(source); return { status: 'ok', packId: loaded.descriptor.manifest.packId, version: loaded.descriptor.manifest.version, kind: loaded.descriptor.manifest.kind, declarations: { mayHandlePersonImages: loaded.descriptor.manifest.declarations.mayHandlePersonImages, rightsDisclosureRequired: loaded.descriptor.manifest.declarations.rightsDisclosureRequired }, manifestHash: loaded.descriptor.manifestHash, packageDigest: loaded.descriptor.packageDigest, distributionDigest: loaded.descriptor.distributionDigest, fixtureSuites: loaded.definition.contributions.fixtureSuites.map((suite) => ({ id: suite.id, caseIds: suite.cases.map((item) => isObject(item) && typeof item.id === 'string' ? item.id : 'invalid') })) }; }
|
|
610
|
+
async function packValidateCommand(source) { const loaded = await loadPack(source); return { status: 'ok', valid: true, packId: loaded.descriptor.manifest.packId, version: loaded.descriptor.manifest.version, manifestHash: loaded.descriptor.manifestHash, packageDigest: loaded.descriptor.packageDigest, distributionDigest: loaded.descriptor.distributionDigest, lifecycleScriptsExecuted: false }; }
|
|
611
|
+
async function packTestCommand(source) {
|
|
612
|
+
const loaded = await loadPack(source);
|
|
613
|
+
const registry = createScenarioPackRegistry();
|
|
614
|
+
registry.register(loaded.source);
|
|
615
|
+
const resolution = registry.resolve({ root: { packId: loaded.descriptor.manifest.packId, versionRange: loaded.descriptor.manifest.version }, extensions: [] });
|
|
616
|
+
if (resolution.status !== 'resolved')
|
|
617
|
+
throw new CliError('PACK_RESOLUTION_BLOCKED', 'ScenarioPack resolution is blocked.', EXIT.contract);
|
|
618
|
+
const results = [];
|
|
619
|
+
for (const fixture of loaded.fixtures) {
|
|
620
|
+
const profile = profileFor(fixture.profileId ?? MOCK_IMAGE_PROFILE.id);
|
|
621
|
+
const caseSpec = defaultCase({ id: fixture.id }, loaded.descriptor.manifest.packId);
|
|
622
|
+
const input = fixtureM5ExecutionInput(profile);
|
|
623
|
+
const prompt = fixtureM5Candidate(fixtureM5PromptIR(profile));
|
|
624
|
+
const guard = guardPromptCandidate(fixtureM5GuardInput(fixtureM5PromptIR(profile), prompt));
|
|
625
|
+
const coreProbes = Object.fromEntries((fixture.coreProbes ?? []).sort((left, right) => compare(left.id, right.id)).map((probe) => [probe.id, coreProbeEvidence(probe, resolution, caseSpec)]));
|
|
626
|
+
const acceptance = { resolution: { status: resolution.status, selectedCount: resolution.report.selected.length, effectiveScenarioHash: resolution.effectiveScenario.effectiveScenarioHash }, reference: referenceEvidence(fixture, resolution, caseSpec), coreProbes, compile: { constraintHash: input.constraintIR.deterministicSignature, referencePlanHash: input.referencePlan.planHash, pipelinePlanHash: input.pipelinePlan.planHash, promptGuard: guard.status } };
|
|
627
|
+
const result = executeOffline(input);
|
|
628
|
+
const run = result.run ?? result.executionRun;
|
|
629
|
+
if (!run)
|
|
630
|
+
throw new CliError(result.code || 'EXECUTION_BLOCKED', 'Offline fixture execution did not produce a run.', EXIT.offline);
|
|
631
|
+
const artifacts = run.outputArtifacts ?? [];
|
|
632
|
+
const structuralArtifact = fixtureM6Artifact(`structural-${run.id}`, FIXTURE_M6_OPAQUE_PNG, 'image/png', 'generated-image');
|
|
633
|
+
const outputContract = { artifactKind: 'image', dataType: 'image', mediaTypes: ['image/png'], cardinality: { min: 1, max: 1 }, background: 'opaque', allowAlpha: false };
|
|
634
|
+
const structural = validateStructuralImage({ schemaVersion: 'voce.structural-validation-input/v1alpha1', id: `structural-${run.id}`, artifacts: [{ artifact: structuralArtifact, bytes: FIXTURE_M6_OPAQUE_PNG }], outputContract, expectedCardinality: { min: 1, max: 1 } });
|
|
635
|
+
let semanticProposal;
|
|
636
|
+
const semanticReceipts = [];
|
|
637
|
+
const reconciliation = [];
|
|
638
|
+
if (fixture.semanticReview) {
|
|
639
|
+
const semantic = await semanticForRun(run, [structuralArtifact, ...artifacts], profile);
|
|
640
|
+
semanticProposal = semantic.report;
|
|
641
|
+
semanticReceipts.push(semantic.receipt);
|
|
642
|
+
reconciliation.push(semantic.remote);
|
|
643
|
+
}
|
|
644
|
+
const human = fixture.humanPending ? createHumanAcceptanceDecision({ schemaVersion: 'voce.human-acceptance-decision/v1alpha1', id: `human-${run.id}`, runId: run.id, status: 'pending', annotations: [], artifactIds: [structuralArtifact.id, ...artifacts.map((item) => item.id)] }) : undefined;
|
|
645
|
+
const compiledHash = sha256({ packId: loaded.descriptor.manifest.packId, fixtureId: fixture.id });
|
|
646
|
+
const evaluation = compileEvaluationReport({ run: { id: run.id, technicalOutcome: run.technicalOutcome, state: run.state, contextHash: run.contextHash, pipelinePlanHash: run.pipelinePlanHash, promptArtifactHash: run.promptArtifactHash }, structural, semanticProposal, humanAcceptance: human, cleanup: result.cleanupReceipts, replay: { mode: 'artifact', status: 'available', code: 'REPLAY_AVAILABLE', artifactIds: artifacts.map((item) => item.id) }, artifacts: [structuralArtifact, ...artifacts], sourceHashes: { compiledBundle: compiledHash, executionRun: computeExecutionRunHash(run) } });
|
|
647
|
+
const traceModel = traceModelFromExecution({ run, receipts: [...result.receipts, ...semanticReceipts], cleanup: result.cleanupReceipts, reconciliation: [...result.remoteCallRuns, ...reconciliation], artifacts: [structuralArtifact, ...artifacts], structural, semanticProposal, humanAcceptance: human, budgets: input.pipelinePlan.steps.map((step) => step.budget), destinations: input.pipelinePlan.dataTransfers.map((item) => item.destination), constraintHash: input.constraintIR.deterministicSignature, referencePlanHash: input.referencePlan.planHash, promptHash: input.executionAuthorization.promptArtifactHash, warnings: result.reasons });
|
|
648
|
+
acceptance.run = { executionStatus: result.status, executionCode: result.code, receiptCount: result.receipts.length + semanticReceipts.length, receiptAdapters: [...result.receipts, ...semanticReceipts].map((item) => item.adapterId).sort(compare), artifactMediaTypes: artifacts.map((item) => item.mediaType).sort(compare), artifactRoles: artifacts.map((item) => item.role).sort(compare), structuralStatus: structural.status, semanticStatus: semanticProposal?.status ?? 'absent', humanStatus: human?.status ?? 'absent', evaluationStatus: evaluation.status, traceModelPresent: true, traceModelHash: traceModel.modelHash };
|
|
649
|
+
const assertions = assertFixtureAssertions(fixture, acceptance, 'all');
|
|
650
|
+
const expected = fixture.expectedStatus ?? 'completed';
|
|
651
|
+
const accepted = result.status === expected || (expected === 'needs_review' && result.status === 'completed');
|
|
652
|
+
results.push({ id: fixture.id, status: accepted ? 'passed' : 'failed', expectedStatus: expected, observedStatus: result.status, executionCode: result.code, reasons: result.reasons, assertionIds: assertions.map((item) => item.id), assertions, assertionHash: sha256(assertions), observedHashes: { constraint: input.constraintIR.deterministicSignature, trace: traceModel.modelHash, evaluation: evaluation.reportHash } });
|
|
653
|
+
}
|
|
654
|
+
const failed = results.some((item) => item.status === 'failed');
|
|
655
|
+
return { status: failed ? 'failed' : 'passed', packId: loaded.descriptor.manifest.packId, resolutionHash: resolution.report.reportHash, lockHash: resolution.lock.lockHash, effectiveScenarioHash: resolution.effectiveScenario.effectiveScenarioHash, fixtureCount: results.length, results };
|
|
656
|
+
}
|
|
657
|
+
async function doctorCommand() { const major = Number(process.versions.node.split('.')[0]); return { status: major >= 20 ? 'ok' : 'blocked', node: { version: process.versions.node, supported: major >= 20 }, contracts: { schemaVersion: BUNDLE_MANIFEST_SCHEMA_VERSION, packageVersion: CONTRACTS_VERSION }, paths: { explicitOnly: true }, providers: { default: 'disabled', mock: 'offline', networkProbe: false }, authProbe: { inspected: false } }; }
|
|
658
|
+
function required(args, key) { if (!args[key])
|
|
659
|
+
throw new CliError('ARGUMENT_MISSING', `Missing --${key}.`, EXIT.usage); return args[key]; }
|
|
660
|
+
function help() { return `voce ${CLI_VERSION}\n\nOffline-first explicit-path CLI.\n\nCommands:\n voce pack inspect --source <path> [--json]\n voce pack validate --source <path> [--json]\n voce pack test --source <path> [--json]\n voce case compile --case <file> --scenario <path> --profile <file> --out <dir> [--json]\n voce case run --bundle <dir> --provider mock --out <dir> [--json]\n voce trace render --bundle <dir> --out <html> [--json]\n voce compare --before <dir> --after <dir> [--out <file>] [--json]\n voce doctor [--json]\n\nExit codes: 0 success, 2 usage, 3 input, 4 contract/hash, 5 offline/provider, 6 output, 7 internal.`; }
|
|
661
|
+
function parse(argv) { const command = []; const args = {}; let machine = false; for (let i = 0; i < argv.length; i += 1) {
|
|
662
|
+
const token = argv[i];
|
|
663
|
+
if (token === '--json') {
|
|
664
|
+
machine = true;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (token === '--help' || token === '-h') {
|
|
668
|
+
command.push('--help');
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (token === '--version' || token === '-v') {
|
|
672
|
+
command.push('--version');
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (token.startsWith('--')) {
|
|
676
|
+
const key = token.slice(2);
|
|
677
|
+
const value = argv[i + 1];
|
|
678
|
+
if (!value || value.startsWith('--'))
|
|
679
|
+
throw new CliError('ARGUMENT_MISSING', `Missing --${key}.`, EXIT.usage);
|
|
680
|
+
args[key] = value;
|
|
681
|
+
i += 1;
|
|
682
|
+
}
|
|
683
|
+
else
|
|
684
|
+
command.push(token);
|
|
685
|
+
} return { command, args, machine }; }
|
|
686
|
+
export async function runCli(argv = process.argv.slice(2)) {
|
|
687
|
+
let machine = argv.includes('--json');
|
|
688
|
+
try {
|
|
689
|
+
const parsed = parse(argv);
|
|
690
|
+
machine = parsed.machine;
|
|
691
|
+
if (parsed.command.includes('--help') || parsed.command.length === 0) {
|
|
692
|
+
process.stdout.write(machine ? json({ status: 'ok', version: CLI_VERSION, help: help() }) + '\n' : help() + '\n');
|
|
693
|
+
return EXIT.ok;
|
|
694
|
+
}
|
|
695
|
+
if (parsed.command.includes('--version')) {
|
|
696
|
+
process.stdout.write(machine ? json({ status: 'ok', version: CLI_VERSION }) + '\n' : CLI_VERSION + '\n');
|
|
697
|
+
return EXIT.ok;
|
|
698
|
+
}
|
|
699
|
+
let result;
|
|
700
|
+
const [first, second, third] = parsed.command;
|
|
701
|
+
if (first === 'doctor')
|
|
702
|
+
result = await doctorCommand();
|
|
703
|
+
else if (first === 'pack' && second === 'inspect')
|
|
704
|
+
result = await packInspectCommand(required(parsed.args, 'source'));
|
|
705
|
+
else if (first === 'pack' && second === 'validate')
|
|
706
|
+
result = await packValidateCommand(required(parsed.args, 'source'));
|
|
707
|
+
else if (first === 'pack' && second === 'test')
|
|
708
|
+
result = await packTestCommand(required(parsed.args, 'source'));
|
|
709
|
+
else if (first === 'case' && second === 'compile')
|
|
710
|
+
result = await compileCommand(parsed.args);
|
|
711
|
+
else if (first === 'case' && second === 'run')
|
|
712
|
+
result = await runCommand(parsed.args);
|
|
713
|
+
else if (first === 'trace' && second === 'render')
|
|
714
|
+
result = await traceCommand(parsed.args);
|
|
715
|
+
else if (first === 'compare')
|
|
716
|
+
result = await compareCommand(parsed.args);
|
|
717
|
+
else
|
|
718
|
+
throw new CliError('UNKNOWN_COMMAND', 'Unknown command.', EXIT.usage);
|
|
719
|
+
assertNoUnsafe(result, 'stdout');
|
|
720
|
+
process.stdout.write(machine ? json(result) + '\n' : `${String(result.status)}${result.semanticHash ? ` ${result.semanticHash}` : ''}\n`);
|
|
721
|
+
return result.status === 'failed' ? EXIT.offline : EXIT.ok;
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
const cliError = error instanceof CliError ? error : new CliError('INTERNAL_ERROR', 'The command failed without a public diagnostic.', EXIT.internal);
|
|
725
|
+
process.stderr.write(`${cliError.code}: ${cliError.message}\n`);
|
|
726
|
+
if (machine)
|
|
727
|
+
process.stdout.write(json({ status: 'error', code: cliError.code }) + '\n');
|
|
728
|
+
return cliError.exitCode;
|
|
729
|
+
}
|
|
730
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voce-engine/cli",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Offline-first command line tools for the VOCE v0.1 release candidate.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/index.js",
|
|
13
|
+
"dist/index.d.ts",
|
|
14
|
+
"dist/cli.js",
|
|
15
|
+
"dist/cli.d.ts",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"voce": "dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/windforce19820520-ai/visual-ontology-constraint-engine.git",
|
|
28
|
+
"directory": "packages/cli"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/windforce19820520-ai/visual-ontology-constraint-engine#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/windforce19820520-ai/visual-ontology-constraint-engine/issues"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"tag": "next"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@voce-engine/contracts": "0.1.0-rc.1",
|
|
40
|
+
"@voce-engine/core": "0.1.0-rc.1",
|
|
41
|
+
"@voce-engine/testkit": "0.1.0-rc.1"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -b"
|
|
45
|
+
}
|
|
46
|
+
}
|