@entrorove/skill 0.1.62 → 0.1.67
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/CAPABILITY-MAP.md +30 -2
- package/CHANGELOG.md +25 -0
- package/DELIVERY-BOUNDARIES.md +2 -0
- package/DESIGN-GATES.md +160 -0
- package/IDE-EXECUTION.md +32 -3
- package/REUSE-DISCIPLINE.md +16 -2
- package/SKILL.md +9 -3
- package/blueprint-criterion-runner.mjs +1 -1
- package/cli-input.mjs +22 -0
- package/cli.mjs +13 -12
- package/cloud-task-client.mjs +10 -2
- package/cloud-task-images.mjs +39 -0
- package/design-browser-checks.mjs +86 -0
- package/design-browser-identity.mjs +13 -0
- package/design-browser-state.mjs +41 -0
- package/design-browser.mjs +181 -0
- package/design-context.mjs +34 -0
- package/design-contracts.d.mts +4 -0
- package/design-contracts.mjs +77 -0
- package/design-declaration.mjs +17 -0
- package/design-frameworks.mjs +4 -0
- package/design-gate.mjs +19 -0
- package/design-lint.mjs +115 -0
- package/design-local-contracts.mjs +15 -0
- package/design-operations.mjs +81 -0
- package/design-policy.d.mts +5 -0
- package/design-policy.mjs +137 -0
- package/design-release.mjs +25 -0
- package/design-research.mjs +99 -0
- package/design-runner.mjs +31 -0
- package/design-store.mjs +116 -0
- package/design-ui.mjs +24 -0
- package/ide-exit.mjs +49 -9
- package/ide-hook.mjs +2 -2
- package/install-identity.mjs +16 -0
- package/local-cli.mjs +3 -1
- package/local-contracts.mjs +6 -1
- package/local-discipline-actions.mjs +5 -0
- package/local-discipline-batch.mjs +12 -3
- package/local-discipline-contracts.mjs +15 -3
- package/local-discipline-gate.mjs +17 -2
- package/local-discipline-repository.mjs +11 -5
- package/local-error.mjs +18 -1
- package/local-execution.mjs +2 -1
- package/local-finder-plist.mjs +87 -0
- package/local-finder-store.mjs +115 -0
- package/local-mutation-authority.mjs +13 -0
- package/local-mutation-completion-evidence.mjs +83 -0
- package/local-mutation-consumption.mjs +33 -10
- package/local-mutation-environment-observation.mjs +85 -0
- package/local-mutation-finder-observation.mjs +58 -0
- package/local-mutation-wire.mjs +26 -8
- package/local-runner-discipline.mjs +17 -3
- package/local-scope-files.mjs +3 -1
- package/local-scope-font.mjs +45 -0
- package/local-scope-repository.mjs +16 -3
- package/local-scope-zip.mjs +114 -0
- package/manifest.json +1 -1
- package/member-producer-repair-authority.mjs +48 -0
- package/model-images.d.mts +9 -0
- package/model-images.mjs +87 -0
- package/mutation-challenge-refresh.mjs +51 -0
- package/mutation-client.mjs +42 -24
- package/mutation-connection-state.mjs +17 -0
- package/package-archive.mjs +54 -0
- package/package-replacement.mjs +48 -0
- package/package.json +50 -6
- package/release-state.json +87 -45
- package/release.mjs +11 -1
- package/releases.json +30 -0
- package/remote-client.mjs +1 -1
- package/remote-transport.mjs +48 -16
- package/reuse-discipline-release.mjs +3 -1
- package/rollback-history.mjs +51 -0
- package/rollback-runner.mjs +77 -0
- package/scope-amendment-client.mjs +1 -1
- package/scope-amendment-consumption.mjs +32 -0
- package/scope-amendment-mutation.mjs +2 -3
- package/scope-amendment-observer.mjs +2 -1
- package/scope-amendment-remote.mjs +12 -2
- package/scope-amendment-retirement.mjs +54 -0
- package/scope-amendment-state.mjs +7 -4
- package/task-entry-client.mjs +1 -0
- package/version.mjs +20 -3
- package/workflow-local-runner.mjs +9 -0
- package/workflow-observation-client.mjs +11 -0
- package/workflow-observation-connection.mjs +2 -1
- package/workflow-observation-remote.mjs +26 -1
- package/workflow-observation-wire.mjs +6 -3
package/cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { compareVersions } from './version.mjs';
|
|
8
|
+
import { installPackageIdentity } from './install-identity.mjs';
|
|
8
9
|
import { credentialPath, readCredential, writeCredential } from './local-credential.mjs';
|
|
9
10
|
import { runLocal } from './local-cli.mjs';
|
|
10
11
|
import { runNative } from './native-client.mjs';
|
|
@@ -23,8 +24,9 @@ import { assertIdeExecution, ideGuardBinding, settleBreakGlassSession } from './
|
|
|
23
24
|
import { writeBreakGlass } from './break-glass.mjs';
|
|
24
25
|
import { exitIdeSession } from './ide-exit.mjs';
|
|
25
26
|
import { installOptions } from './ide-command.mjs';
|
|
27
|
+
import { readCliInput } from './cli-input.mjs';
|
|
26
28
|
const markerName = '.entrorove-managed.json';
|
|
27
|
-
const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md', 'REUSE-DISCIPLINE.md', 'DELIVERY-BOUNDARIES.md'];
|
|
29
|
+
const installFiles = ['SKILL.md', 'CAPABILITY-MAP.md', 'AGREEMENT.md', 'manifest.json', 'IDE-EXECUTION.md', 'REUSE-DISCIPLINE.md', 'DELIVERY-BOUNDARIES.md', 'DESIGN-GATES.md'];
|
|
28
30
|
const markerSchema = 'entrorove.managed-install/1.0';
|
|
29
31
|
const agreementSchema = 'entrorove.agreement-acceptance/1.0';
|
|
30
32
|
const installLockWaitMs = 10_000;
|
|
@@ -62,7 +64,7 @@ async function sourceBundle() {
|
|
|
62
64
|
throw new Error('entrorove skill instructions have a mismatched version');
|
|
63
65
|
}
|
|
64
66
|
const hashes = Object.fromEntries(installFiles.map((name) => [name, sha256(content.get(name))]));
|
|
65
|
-
return { manifest, content, hashes };
|
|
67
|
+
return { manifest, content, hashes, packageDigest: await installPackageIdentity(packageDir, packageMetadata) };
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
function globalInstallPath(home = os.homedir()) { return path.join(home, '.codex', 'skills', 'entrorove'); }
|
|
@@ -134,6 +136,7 @@ async function managedState(directory) {
|
|
|
134
136
|
? Object.keys(marker.files) : [];
|
|
135
137
|
if (marker.schemaVersion !== markerSchema || marker.name !== 'entrorove'
|
|
136
138
|
|| typeof marker.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(marker.version)
|
|
139
|
+
|| marker.packageDigest !== undefined && !/^[a-f0-9]{64}$/.test(marker.packageDigest)
|
|
137
140
|
|| !markerFiles.includes('SKILL.md') || !markerFiles.includes('manifest.json')
|
|
138
141
|
|| markerFiles.some((name) => !installFiles.includes(name) && !(marker.version === '0.1.39' && name === 'DELIVERY-BOUNDARIES.md'))
|
|
139
142
|
|| entries.slice().sort().join(',') !== [...markerFiles, markerName].sort().join(',')) {
|
|
@@ -154,7 +157,7 @@ async function writeStagedInstall(directory, bundle) {
|
|
|
154
157
|
for (const name of installFiles) {
|
|
155
158
|
await fs.writeFile(path.join(directory, name), bundle.content.get(name), { flag: 'wx', mode: 0o600 });
|
|
156
159
|
}
|
|
157
|
-
const marker = { schemaVersion: markerSchema, name: 'entrorove', version: bundle.manifest.version, files: bundle.hashes };
|
|
160
|
+
const marker = { schemaVersion: markerSchema, name: 'entrorove', version: bundle.manifest.version, files: bundle.hashes, packageDigest: bundle.packageDigest };
|
|
158
161
|
await fs.writeFile(path.join(directory, markerName), JSON.stringify(marker) + '\n', { flag: 'wx', mode: 0o600 });
|
|
159
162
|
}
|
|
160
163
|
|
|
@@ -224,7 +227,12 @@ async function updateGlobalInstall(target, bundle) {
|
|
|
224
227
|
const previous = await managedState(target);
|
|
225
228
|
if (previous === false) throw new Error('Global entrorove skill is not pristine and managed; refusing overwrite');
|
|
226
229
|
if (previous && compareVersions(previous.version, bundle.manifest.version) > 0) {
|
|
227
|
-
throw new Error('A newer managed entrorove skill is installed; use the latest package');
|
|
230
|
+
throw Object.assign(new Error('A newer managed entrorove skill is installed; use the latest package'), { code: 'ENTROROVE_PACKAGE_DOWNGRADE' });
|
|
231
|
+
}
|
|
232
|
+
if (previous?.version === bundle.manifest.version
|
|
233
|
+
&& (Object.keys(previous.files).some(name => previous.files[name] !== bundle.hashes[name])
|
|
234
|
+
|| previous.packageDigest && previous.packageDigest !== bundle.packageDigest)) {
|
|
235
|
+
throw Object.assign(new Error('Same-version managed package has different bytes; refusing overwrite'), { code: 'ENTROROVE_PACKAGE_VERSION_CONFLICT' });
|
|
228
236
|
}
|
|
229
237
|
if (previous?.version === bundle.manifest.version
|
|
230
238
|
&& installFiles.every((name) => previous.files[name] === bundle.hashes[name])) return false;
|
|
@@ -288,15 +296,8 @@ export async function uninstall({ home = os.homedir() } = {}) {
|
|
|
288
296
|
}
|
|
289
297
|
|
|
290
298
|
async function stdinJson() {
|
|
291
|
-
let raw = '';
|
|
292
|
-
for await (const chunk of process.stdin) {
|
|
293
|
-
raw += chunk;
|
|
294
|
-
if (raw.length > 1_000_000) throw new Error('Input is too large');
|
|
295
|
-
}
|
|
296
|
-
let value;
|
|
297
|
-
try { value = JSON.parse(raw); } catch { throw new Error('Input must be valid JSON'); }
|
|
298
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Input must be a JSON object');
|
|
299
299
|
const args = process.argv.slice(2);
|
|
300
|
+
const value = await readCliInput(process.stdin, args);
|
|
300
301
|
if (!['configure','agreement','review'].includes(args[0])) await assertIdeExecution(args, value);
|
|
301
302
|
return value;
|
|
302
303
|
}
|
package/cloud-task-client.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { parseTransportResponse, requestTransport, trustedOrigin } from './remot
|
|
|
4
4
|
import { displayIdeEvent, ideClientHeaders, ideTaskIdentity } from './ide-presentation.mjs';
|
|
5
5
|
import { verifyAutomaticFeedback } from './automatic-feedback-client.mjs';
|
|
6
6
|
import { sandboxTaskNumber } from './task-identity.mjs';
|
|
7
|
+
import { prepareCloudImages } from './cloud-task-images.mjs';
|
|
7
8
|
|
|
8
9
|
const CLOUD_TASK_SCHEMA = 'entrorove.cloud-task/1.0';
|
|
9
10
|
const CLOUD_STATUS_SCHEMA = 'entrorove.cloud-status/1.0';
|
|
@@ -38,7 +39,11 @@ function validateInput(input) {
|
|
|
38
39
|
if (input.task !== undefined && (!input.task || typeof input.task !== 'object' || Array.isArray(input.task) || input.task.goal !== input.goal)) {
|
|
39
40
|
throw new Error('Cloud task must preserve the original task goal');
|
|
40
41
|
}
|
|
42
|
+
if (input.images !== undefined && (!Array.isArray(input.images) || input.images.length > 4)) {
|
|
43
|
+
throw new Error('cloud-task images must be an array of at most four images');
|
|
44
|
+
}
|
|
41
45
|
return { goal: input.goal, ...(input.ideTaskId === undefined ? {} : { ideTaskId: input.ideTaskId }),
|
|
46
|
+
...(input.images === undefined ? {} : { images: input.images }),
|
|
42
47
|
...(input.task === undefined ? {} : { task: input.task }) };
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -88,7 +93,7 @@ export async function runCloudTask(input, dependencies) {
|
|
|
88
93
|
const ideTaskId = ideTaskIdentity(task.ideTaskId);
|
|
89
94
|
if (ideTaskId) task.ideTaskId = ideTaskId;
|
|
90
95
|
validateDependencies(dependencies);
|
|
91
|
-
|
|
96
|
+
let id = requestId(task);
|
|
92
97
|
const choice = input.sandboxChoice ?? input.answer;
|
|
93
98
|
await dependencies.install();
|
|
94
99
|
if (!await dependencies.agreementAccepted(dependencies.home)) {
|
|
@@ -99,9 +104,12 @@ export async function runCloudTask(input, dependencies) {
|
|
|
99
104
|
});
|
|
100
105
|
if (choice === 'local') return envelope(task, 'skipped', {
|
|
101
106
|
reason: 'task', executionIsolation: 'local', message: '已退出云端沙箱,采用本地方案继续当前任务。',
|
|
102
|
-
continuation: { goal: task.goal,...(task.ideTaskId ? { ideTaskId: task.ideTaskId } : {})
|
|
107
|
+
continuation: { goal: task.goal,...(task.ideTaskId ? { ideTaskId: task.ideTaskId } : {}),
|
|
108
|
+
...(task.images === undefined ? {} : { images: task.images }) },
|
|
103
109
|
});
|
|
104
110
|
|
|
111
|
+
if (task.images !== undefined) task.images = await prepareCloudImages(task.images);
|
|
112
|
+
id = requestId(task);
|
|
105
113
|
const token = await dependencies.readCredential(dependencies.home);
|
|
106
114
|
const origin = trustedOrigin(dependencies.apiOrigin());
|
|
107
115
|
const metadata = { kind: 'remote', operation: 'cloud-task', requestId: id };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
import { inspectModelImage, MODEL_IMAGE_LIMITS, normalizeModelImages } from './model-images.mjs';
|
|
5
|
+
|
|
6
|
+
async function readImage(path) {
|
|
7
|
+
if (typeof path !== 'string' || !isAbsolute(path)) throw new Error('Image path must be an explicit absolute file path');
|
|
8
|
+
let file;
|
|
9
|
+
try {
|
|
10
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
11
|
+
const before = await file.stat();
|
|
12
|
+
if (!before.isFile() || before.size < 26 || before.size > MODEL_IMAGE_LIMITS.perImageBytes) throw new Error();
|
|
13
|
+
const bytes = Buffer.alloc(before.size);
|
|
14
|
+
let offset = 0;
|
|
15
|
+
while (offset < bytes.length) {
|
|
16
|
+
const result = await file.read(bytes, offset, bytes.length - offset, offset);
|
|
17
|
+
if (result.bytesRead === 0) throw new Error();
|
|
18
|
+
offset += result.bytesRead;
|
|
19
|
+
}
|
|
20
|
+
const after = await file.stat();
|
|
21
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error();
|
|
22
|
+
return bytes;
|
|
23
|
+
} catch { throw new Error('Input image must be an unchanged readable regular file within the image byte limit'); }
|
|
24
|
+
finally { await file?.close(); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Only explicitly attached paths are read, after the task's cloud choice. */
|
|
28
|
+
export async function prepareCloudImages(value = []) {
|
|
29
|
+
if (!Array.isArray(value) || value.length > MODEL_IMAGE_LIMITS.count) throw new Error('At most four input images are supported');
|
|
30
|
+
const inputs = [];
|
|
31
|
+
for (const image of value) {
|
|
32
|
+
if (image && typeof image === 'object' && !Array.isArray(image) && 'path' in image) {
|
|
33
|
+
if (Object.keys(image).some(key => !['path','detail'].includes(key))) throw new Error('Image path input has unsupported fields');
|
|
34
|
+
const bytes = await readImage(image.path), { mimeType } = inspectModelImage(bytes);
|
|
35
|
+
inputs.push({ url: `data:${mimeType};base64,${bytes.toString('base64')}`, ...(image.detail === undefined ? {} : { detail: image.detail }) });
|
|
36
|
+
} else inputs.push(image);
|
|
37
|
+
}
|
|
38
|
+
return normalizeModelImages(inputs).map(({ url, detail }) => ({ url, detail }));
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { designFail } from './design-policy.mjs';
|
|
2
|
+
|
|
3
|
+
export async function browserAssertion(page, check) {
|
|
4
|
+
const locator = page.locator(check.selector);
|
|
5
|
+
if (check.kind === 'count') return await locator.count() === check.count;
|
|
6
|
+
if (check.kind === 'hidden') return !await locator.isVisible();
|
|
7
|
+
if (await locator.count() !== 1 || !await locator.isVisible()) return false;
|
|
8
|
+
if (check.kind === 'visible') return true;
|
|
9
|
+
if (check.kind === 'text') return (await locator.innerText()).trim() === check.value;
|
|
10
|
+
if (check.kind === 'value') return await locator.inputValue() === check.value;
|
|
11
|
+
if (check.kind === 'style') return await locator.evaluate((node, property) => getComputedStyle(node).getPropertyValue(property), check.property) === check.value;
|
|
12
|
+
if (check.kind === 'bounds') {
|
|
13
|
+
const box = await locator.boundingBox();
|
|
14
|
+
return Boolean(box && ['x', 'y', 'width', 'height'].every(key => Math.abs(box[key] - check.bounds[key]) <= check.bounds.tolerance));
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
export async function browserChecks(page, checks, failures) {
|
|
19
|
+
for (const check of checks) {
|
|
20
|
+
try { if (!await browserAssertion(page, check)) failures.push(`assert-${check.kind}`); }
|
|
21
|
+
catch { failures.push(`assert-${check.kind}`); }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function publicFieldCheck(page, pageContract, forbidden, failures) {
|
|
25
|
+
const fields = await page.locator('[data-entrorove-field]').evaluateAll(nodes => nodes.filter(node => {
|
|
26
|
+
const style = getComputedStyle(node); return style.display !== 'none' && style.visibility !== 'hidden' && node.getClientRects().length;
|
|
27
|
+
}).map(node => node.getAttribute('data-entrorove-field')));
|
|
28
|
+
if (fields.some(name => !pageContract.publicFields.includes(name))) failures.push('public-field-projection');
|
|
29
|
+
const text = await page.locator('body').innerText();
|
|
30
|
+
if ([...pageContract.internalFields, ...forbidden].some(value => value && text.includes(value))) failures.push('private-field-visible');
|
|
31
|
+
}
|
|
32
|
+
export function networkCheck(actual, expected, failures) {
|
|
33
|
+
if (actual.length !== expected.reduce((sum, item) => sum + item.count, 0)
|
|
34
|
+
|| expected.some(item => actual.filter(event => event.method === item.method && event.path === item.path && event.status === item.status).length !== item.count)) failures.push('unexpected-http-mutation');
|
|
35
|
+
}
|
|
36
|
+
export async function installSuccessMonitor(page, successSelector) {
|
|
37
|
+
await page.addInitScript(selector => {
|
|
38
|
+
const events = [], acknowledgements = [], storage = [], original = window.fetch.bind(window);
|
|
39
|
+
Object.defineProperty(window, '__entroroveDesignMonitor', { value: { events, acknowledgements, storage } });
|
|
40
|
+
for (const method of ['setItem', 'removeItem', 'clear']) {
|
|
41
|
+
const apply = Storage.prototype[method];
|
|
42
|
+
Storage.prototype[method] = function(...args) {
|
|
43
|
+
const result = apply.apply(this, args); storage.push({ method, at: performance.now() });
|
|
44
|
+
acknowledgements.push({ at: performance.now(), ok: true }); return result;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (typeof IDBObjectStore !== 'undefined') for (const method of ['add', 'put', 'delete', 'clear']) {
|
|
48
|
+
const apply = IDBObjectStore.prototype[method];
|
|
49
|
+
IDBObjectStore.prototype[method] = function(...args) { storage.push({ method: 'indexed-db-write', at: performance.now() }); return apply.apply(this, args); };
|
|
50
|
+
}
|
|
51
|
+
window.fetch = async (...args) => {
|
|
52
|
+
const response = await original(...args);
|
|
53
|
+
const method = String(args[1]?.method ?? (args[0] instanceof Request ? args[0].method : 'GET')).toUpperCase();
|
|
54
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) acknowledgements.push({ at: performance.now(), ok: response.ok });
|
|
55
|
+
return response;
|
|
56
|
+
};
|
|
57
|
+
const open = XMLHttpRequest.prototype.open;
|
|
58
|
+
XMLHttpRequest.prototype.open = function(method, ...args) {
|
|
59
|
+
const mutation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method).toUpperCase());
|
|
60
|
+
this.addEventListener('readystatechange', () => {
|
|
61
|
+
if (mutation && this.readyState === 4) acknowledgements.push({ at: performance.now(), ok: this.status >= 200 && this.status < 300 });
|
|
62
|
+
});
|
|
63
|
+
return open.call(this, method, ...args);
|
|
64
|
+
};
|
|
65
|
+
let visible = false;
|
|
66
|
+
const observe = () => {
|
|
67
|
+
const node = document.querySelector(selector), style = node && getComputedStyle(node);
|
|
68
|
+
const next = Boolean(node && style.display !== 'none' && style.visibility !== 'hidden' && node.getClientRects().length);
|
|
69
|
+
if (next && !visible) events.push({ at: performance.now(), successfulAcknowledgement: acknowledgements.some(item => item.ok) });
|
|
70
|
+
visible = next;
|
|
71
|
+
};
|
|
72
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
73
|
+
new MutationObserver(observe).observe(document.documentElement, { attributes: true, childList: true, subtree: true, characterData: true }); observe();
|
|
74
|
+
});
|
|
75
|
+
}, successSelector);
|
|
76
|
+
}
|
|
77
|
+
export async function monitorSuccess(page, failures, canSucceed) {
|
|
78
|
+
const observed = await page.evaluate(() => window.__entroroveDesignMonitor?.events ?? []);
|
|
79
|
+
if (!canSucceed && observed.length || observed.some(event => !event.successfulAcknowledgement)) failures.push('premature-success-message');
|
|
80
|
+
}
|
|
81
|
+
export function previewUrl(value) {
|
|
82
|
+
let url; try { url = new URL(value); } catch { designFail('PREVIEW', 'Start the approved local preview before browser acceptance'); }
|
|
83
|
+
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.username || url.password
|
|
84
|
+
|| url.pathname !== '/' || url.search || url.hash) designFail('PREVIEW', 'Use an isolated HTTP preview on 127.0.0.1 with a specific port, without credentials');
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { repositoryIntegrityFile } from './local-discipline-repository.mjs';
|
|
4
|
+
import { designFail } from './design-policy.mjs';
|
|
5
|
+
|
|
6
|
+
export async function readDesignBrowserIdentity(input) {
|
|
7
|
+
if (typeof input.browserExecutablePath !== 'string' || !path.isAbsolute(input.browserExecutablePath)) designFail('BROWSER', 'Configure a local Chromium executable before design confirmation');
|
|
8
|
+
const executablePath = await fs.realpath(input.browserExecutablePath), stat = await fs.stat(executablePath);
|
|
9
|
+
if (!stat.isFile() || !(stat.mode & 0o111) || stat.size > 536_870_912
|
|
10
|
+
|| !/^(?:Google Chrome|Chromium|chrome|chromium|chromium-browser|google-chrome|chrome-headless-shell)$/.test(path.basename(executablePath))) designFail('BROWSER', 'Browser checks require an actual configured Chromium executable, without arbitrary command arguments');
|
|
11
|
+
const { sha256 } = await repositoryIntegrityFile(path.dirname(executablePath), path.basename(executablePath));
|
|
12
|
+
return { executablePath, sha256 };
|
|
13
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { canonical } from './local-integrity.mjs';
|
|
2
|
+
|
|
3
|
+
function projected(value, pointer = '') {
|
|
4
|
+
for (const part of pointer.split('/').slice(1).map(part => part.replace(/~1/g, '/').replace(/~0/g, '~'))) {
|
|
5
|
+
if (!value || !Object.hasOwn(value, part)) return undefined;
|
|
6
|
+
value = value[part];
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export async function checkBrowserState(page, proof, phase, fields, baseUrl, failures) {
|
|
11
|
+
const expected = proof[phase]; let actual;
|
|
12
|
+
try {
|
|
13
|
+
if (proof.kind === 'http') {
|
|
14
|
+
const response = await page.request.get(new URL(proof.path, baseUrl).href, { maxRedirects: 0, timeout: 5000 });
|
|
15
|
+
if (response.status() !== proof[phase === 'before' ? 'statusBefore' : 'statusAfter']) failures.push('read-back-http-status');
|
|
16
|
+
if (expected !== null || response.status() >= 200 && response.status() < 300) {
|
|
17
|
+
const bytes = await response.body();
|
|
18
|
+
if (bytes.length > 1_048_576) throw new Error();
|
|
19
|
+
actual = projected(JSON.parse(bytes.toString('utf8')), proof.jsonPointer);
|
|
20
|
+
} else actual = null;
|
|
21
|
+
await response.dispose();
|
|
22
|
+
} else {
|
|
23
|
+
const value = await page.evaluate(key => localStorage.getItem(key), proof.key);
|
|
24
|
+
actual = value === null ? null : projected(JSON.parse(value), proof.jsonPointer);
|
|
25
|
+
}
|
|
26
|
+
if (expected === null) { if (actual !== null) failures.push('read-back-state'); }
|
|
27
|
+
else if (!actual || typeof actual !== 'object' || Array.isArray(actual)
|
|
28
|
+
|| Object.keys(actual).some(key => !fields.includes(key))
|
|
29
|
+
|| Object.keys(expected).some(key => canonical(actual[key] ?? null) !== canonical(expected[key]))) failures.push('read-back-state');
|
|
30
|
+
} catch { failures.push('read-back-unavailable'); }
|
|
31
|
+
}
|
|
32
|
+
export async function beginBrowserAction(page) {
|
|
33
|
+
await page.evaluate(() => {
|
|
34
|
+
const monitor = window.__entroroveDesignMonitor;
|
|
35
|
+
monitor.storage.length = 0; monitor.acknowledgements.length = 0; monitor.events.length = 0;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function checkNonCommitStorage(page, commits, failures) {
|
|
39
|
+
const count = await page.evaluate(() => window.__entroroveDesignMonitor.storage.length);
|
|
40
|
+
if (!commits && count) failures.push('unexpected-browser-storage-write');
|
|
41
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import { chromium } from 'playwright-core';
|
|
5
|
+
import { designFail, validateDesignPolicy, validateDesignReport, designDigest } from './design-policy.mjs';
|
|
6
|
+
import { disciplineApprovedPolicy } from './local-discipline-contracts.mjs';
|
|
7
|
+
import { designDirectory, designSave, assertDesignArtifacts, designSourceManifest, sameDesignBytes, designOriginalSource } from './design-store.mjs';
|
|
8
|
+
import { writeMutationFile14 } from './local-mutation-files.mjs';
|
|
9
|
+
import { sha256 } from './local-integrity.mjs';
|
|
10
|
+
import { assertDesignLint } from './design-lint.mjs';
|
|
11
|
+
import { assertFrozenDesignFrameworks } from './design-research.mjs';
|
|
12
|
+
import { designUiAsset } from './design-ui.mjs';
|
|
13
|
+
import { verifyOriginalWorkflowPlan } from './workflow-local-runner.mjs';
|
|
14
|
+
import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
|
|
15
|
+
import { withStateLock } from './local-state-lock.mjs';
|
|
16
|
+
import { designValidationContext, designContextSources } from './design-context.mjs';
|
|
17
|
+
import { readDesignBrowserIdentity } from './design-browser-identity.mjs';
|
|
18
|
+
import { browserChecks, networkCheck, publicFieldCheck, installSuccessMonitor, monitorSuccess, previewUrl } from './design-browser-checks.mjs';
|
|
19
|
+
import { checkBrowserState, beginBrowserAction, checkNonCommitStorage } from './design-browser-state.mjs';
|
|
20
|
+
|
|
21
|
+
function matchesPage(url, pageContract, base) {
|
|
22
|
+
const route = pageContract.route.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:[\w]+/g, '[^/]+');
|
|
23
|
+
return url.origin === base.origin && new RegExp(`^${route}$`).test(url.pathname);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function verifySources(input, policy) {
|
|
27
|
+
const { mutationRead14 } = await import('./local-mutation-files.mjs');
|
|
28
|
+
for (const name of policy.rules.uiPaths) {
|
|
29
|
+
const { content } = await mutationRead14(input, input.repositoryRoot, name);
|
|
30
|
+
const original = await designOriginalSource(policy, name), source = designUiAsset(name) ? '' : new TextDecoder('utf-8', { fatal: true }).decode(content);
|
|
31
|
+
assertDesignLint(policy, name, original, source, original);
|
|
32
|
+
}
|
|
33
|
+
return designSourceManifest(input, policy);
|
|
34
|
+
}
|
|
35
|
+
async function scenarioRun(browser, input, policy, scenario, directory, runId, deadlineAt) {
|
|
36
|
+
const mockup = policy.mockups[scenario.mockupIndex];
|
|
37
|
+
let pageContract = policy.site.pages.find(item => item.id === scenario.entryPageId), stateChecks = 0, lastReadBack;
|
|
38
|
+
const base = previewUrl(input.baseUrl), target = new URL(scenario.route, base);
|
|
39
|
+
if (!matchesPage(target, pageContract, base)) designFail('PREVIEW', 'Scenario route differs from its approved flow page');
|
|
40
|
+
const context = await browser.newContext({ viewport: mockup.viewport, colorScheme: mockup.theme,
|
|
41
|
+
reducedMotion: 'reduce', serviceWorkers: 'block', acceptDownloads: false });
|
|
42
|
+
const page = await context.newPage(), failures = [], mutations = [], requests = new Map();
|
|
43
|
+
page.setDefaultTimeout(5000);
|
|
44
|
+
await context.route('**/*', async handler => {
|
|
45
|
+
const url = new URL(handler.request().url());
|
|
46
|
+
if (url.origin !== base.origin) { failures.push('external-network'); await handler.abort(); }
|
|
47
|
+
else await handler.continue();
|
|
48
|
+
});
|
|
49
|
+
page.on('request', request => {
|
|
50
|
+
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
|
|
51
|
+
const event = { method: request.method(), path: new URL(request.url()).pathname, status: null };
|
|
52
|
+
mutations.push(event); requests.set(request, event);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
page.on('response', response => { const event = requests.get(response.request()); if (event) event.status = response.status(); });
|
|
56
|
+
page.on('pageerror', () => failures.push('browser-error'));
|
|
57
|
+
page.on('dialog', async dialog => { failures.push('unexpected-dialog'); await dialog.dismiss(); });
|
|
58
|
+
try {
|
|
59
|
+
await installSuccessMonitor(page, scenario.successSelector);
|
|
60
|
+
await page.goto(target.href, { waitUntil: 'networkidle', timeout: 15_000 });
|
|
61
|
+
await page.waitForTimeout(policy.rules.settleMs);
|
|
62
|
+
networkCheck(mutations.splice(0), [], failures);
|
|
63
|
+
await monitorSuccess(page, failures, false);
|
|
64
|
+
await checkNonCommitStorage(page, false, failures);
|
|
65
|
+
await browserChecks(page, scenario.checks, failures);
|
|
66
|
+
await publicFieldCheck(page, pageContract, policy.rules.forbiddenVisibleText, failures);
|
|
67
|
+
for (const step of scenario.steps) {
|
|
68
|
+
if (Date.now() >= deadlineAt) designFail('TIME_LIMIT', 'Browser acceptance reached its original bounded deadline');
|
|
69
|
+
await assertBudgetRunnerAllowed(input);
|
|
70
|
+
const edge = policy.site.edges.find(item => item.id === step.edgeId);
|
|
71
|
+
pageContract = policy.site.pages.find(item => item.id === edge.to);
|
|
72
|
+
await beginBrowserAction(page);
|
|
73
|
+
if (step.readBack) {
|
|
74
|
+
await checkBrowserState(page, step.readBack, 'before', pageContract.publicFields, base, failures); stateChecks++;
|
|
75
|
+
}
|
|
76
|
+
const locator = page.locator(step.selector);
|
|
77
|
+
if (step.action === 'click') await locator.click();
|
|
78
|
+
else if (step.action === 'fill') await locator.fill(step.value ?? '');
|
|
79
|
+
else await locator.press(step.value ?? 'Enter');
|
|
80
|
+
await page.waitForTimeout(policy.rules.settleMs);
|
|
81
|
+
networkCheck(mutations.splice(0), step.mutations, failures);
|
|
82
|
+
const commits = ['save', 'delete'].includes(edge.kind);
|
|
83
|
+
const canSucceed = commits && (step.readBack?.kind === 'local-storage' || step.mutations.some(item => item.status < 300));
|
|
84
|
+
await checkNonCommitStorage(page, commits, failures);
|
|
85
|
+
await monitorSuccess(page, failures, canSucceed);
|
|
86
|
+
if (step.readBack) {
|
|
87
|
+
await checkBrowserState(page, step.readBack, 'after', pageContract.publicFields, base, failures); stateChecks++;
|
|
88
|
+
lastReadBack = step.readBack;
|
|
89
|
+
}
|
|
90
|
+
if (!matchesPage(new URL(page.url()), pageContract, base)) failures.push('flow-page-transition');
|
|
91
|
+
await browserChecks(page, step.checks, failures);
|
|
92
|
+
await publicFieldCheck(page, pageContract, policy.rules.forbiddenVisibleText, failures);
|
|
93
|
+
}
|
|
94
|
+
if (await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1)) failures.push('horizontal-overflow');
|
|
95
|
+
} catch { failures.push('browser-scenario-incomplete'); }
|
|
96
|
+
try {
|
|
97
|
+
const file = path.join(directory, `${runId}-${scenario.mockupIndex}.png`), bytes = await page.screenshot({ fullPage: true, animations: 'disabled', timeout: 5000 });
|
|
98
|
+
await writeMutationFile14(file, bytes);
|
|
99
|
+
if (lastReadBack) {
|
|
100
|
+
try {
|
|
101
|
+
await page.reload({ waitUntil: 'networkidle', timeout: 15_000 });
|
|
102
|
+
await page.waitForTimeout(policy.rules.settleMs);
|
|
103
|
+
networkCheck(mutations.splice(0), [], failures);
|
|
104
|
+
await browserChecks(page, lastReadBack.afterReload, failures);
|
|
105
|
+
await publicFieldCheck(page, pageContract, policy.rules.forbiddenVisibleText, failures); stateChecks++;
|
|
106
|
+
} catch { failures.push('reload-state-unverified'); }
|
|
107
|
+
}
|
|
108
|
+
return { mockupIndex: scenario.mockupIndex,
|
|
109
|
+
checks: scenario.checks.length + scenario.steps.reduce((count, step) => count + step.checks.length, 0),
|
|
110
|
+
steps: scenario.steps.length, stateChecks, failures: [...new Set(failures)], screenshot: { path: file, sha256: sha256(bytes), bytes: bytes.length } };
|
|
111
|
+
} finally { await context.close(); }
|
|
112
|
+
}
|
|
113
|
+
export async function verifyDesignBrowser(input) {
|
|
114
|
+
await assertBudgetRunnerAllowed(input);
|
|
115
|
+
const plan = verifyOriginalWorkflowPlan(input.acceptedPlan);
|
|
116
|
+
const policies = plan.steps.map(step => step.executionPlan.policy.discipline)
|
|
117
|
+
.filter(policy => policy?.design?.baselineDigest === input.baselineDigest);
|
|
118
|
+
if (!policies.length || plan.flowId !== input.flowId) designFail('APPROVAL_REQUIRED', 'Use the exact server-signed live original plan and approved design baseline');
|
|
119
|
+
const discipline = disciplineApprovedPolicy(policies[0]), policy = validateDesignPolicy(discipline.design);
|
|
120
|
+
const context = await designValidationContext(input, plan, policy);
|
|
121
|
+
if (policy.flowId !== input.flowId) designFail('BINDING', 'Browser acceptance must retain its original design flow');
|
|
122
|
+
if (input.baseUrl !== policy.rules.previewOrigin) designFail('PREVIEW', 'Browser tests may use only the exact preview origin approved before UI implementation');
|
|
123
|
+
const browserIdentity = await readDesignBrowserIdentity(input);
|
|
124
|
+
if (!sameDesignBytes(browserIdentity, policy.rules.browser)) designFail('BROWSER_CHANGED', 'The executable differs from the browser frozen in this exact approved design');
|
|
125
|
+
previewUrl(input.baseUrl);
|
|
126
|
+
await assertDesignArtifacts(policy, input);
|
|
127
|
+
await assertFrozenDesignFrameworks(input, policy);
|
|
128
|
+
const sourceManifest = await designContextSources(input, context, await verifySources(input, policy));
|
|
129
|
+
const directory = await designDirectory({ ...input, designId: policy.designId, revision: policy.revision });
|
|
130
|
+
return withStateLock(path.join(directory, 'browser-verification.lock'), () => browserAttempt(input, policy, plan, context, sourceManifest, directory));
|
|
131
|
+
}
|
|
132
|
+
async function browserAttempt(input, policy, plan, context, sourceManifest, directory) {
|
|
133
|
+
const entries = await fs.readdir(directory);
|
|
134
|
+
for (const name of entries.filter(name => /^browser-[a-f0-9-]+-start\.json$/.test(name))) {
|
|
135
|
+
const id = name.replace(/-start\.json$/, '');
|
|
136
|
+
if (!entries.includes(`${id}.json`) && !entries.includes(`${id}-launch-failed.json`)) designFail('OUTCOME_UNKNOWN', 'The original browser attempt has no terminal report. Read design-status and preserve it; do not repeat its preview requests');
|
|
137
|
+
}
|
|
138
|
+
const runId = `browser-${randomUUID()}`, startedAt = new Date().toISOString(), results = [];
|
|
139
|
+
const deadlineAt = Math.min(Date.now() + 300_000, Date.parse(plan.expiresAt), Date.parse(context.expiresAt));
|
|
140
|
+
await designSave(directory, `${runId}-start.json`, { schemaVersion: 'entrorove.design-attempt/1.0', runId, startedAt,
|
|
141
|
+
phase: 'running', callerPid: process.pid, baselineDigest: policy.baselineDigest, sourceManifest,
|
|
142
|
+
planDigest: input.acceptedPlan.authorityReceipt.payloadDigest, deadlineAt: new Date(deadlineAt).toISOString() });
|
|
143
|
+
let browser;
|
|
144
|
+
const environment = Object.fromEntries(['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'TZ', 'SystemRoot', 'WINDIR']
|
|
145
|
+
.filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]]));
|
|
146
|
+
try { browser = await chromium.launch({ executablePath: policy.rules.browser.executablePath, headless: true, env: environment }); }
|
|
147
|
+
catch {
|
|
148
|
+
await designSave(directory, `${runId}-launch-failed.json`, { phase: 'launch-failed', previewRequestsIssued: false, failedAt: new Date().toISOString() });
|
|
149
|
+
designFail('BROWSER_UNAVAILABLE', 'A compatible local Chromium executable is required; install it before acceptance. No preview requests or pass were issued');
|
|
150
|
+
}
|
|
151
|
+
const engine = `playwright-core/1.64.1 chromium/${browser.version()}`;
|
|
152
|
+
try {
|
|
153
|
+
for (const scenario of policy.rules.scenarios) {
|
|
154
|
+
if (Date.now() >= deadlineAt) designFail('TIME_LIMIT', 'Browser acceptance reached its original bounded deadline');
|
|
155
|
+
await assertBudgetRunnerAllowed(input);
|
|
156
|
+
const result = await scenarioRun(browser, input, policy, scenario, directory, runId, deadlineAt);
|
|
157
|
+
results.push(result);
|
|
158
|
+
await designSave(directory, `${runId}-${scenario.mockupIndex}-result.json`, { result, completedAt: new Date().toISOString(), engine });
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await designSave(directory, `${runId}-interrupted.json`, { phase: 'interrupted', effects: 'unknown-preview-effects',
|
|
162
|
+
code: error.code ?? 'ENTROROVE_DESIGN_INTERRUPTED', completedScenarios: results.length, recordedAt: new Date().toISOString() });
|
|
163
|
+
throw error;
|
|
164
|
+
} finally { await browser.close(); }
|
|
165
|
+
let after;
|
|
166
|
+
try {
|
|
167
|
+
await assertBudgetRunnerAllowed(input);
|
|
168
|
+
if (Date.now() > deadlineAt) designFail('TIME_LIMIT', 'Browser acceptance exceeded its original authority window');
|
|
169
|
+
after = await designContextSources(input, context, await designSourceManifest(input, policy));
|
|
170
|
+
} catch { after = null; }
|
|
171
|
+
if (!after || !sameDesignBytes(sourceManifest, after)) for (const result of results) result.failures.push('source-or-authority-changed-during-acceptance');
|
|
172
|
+
const body = { schemaVersion: 'entrorove.design-report/1.0', baselineDigest: policy.baselineDigest, flowId: input.flowId,
|
|
173
|
+
sourceManifest, testsDigest: context.testsDigest, validationContext: input.validationContext,
|
|
174
|
+
startedAt, finishedAt: new Date().toISOString(), engine, results };
|
|
175
|
+
const report = { ...body, reportDigest: designDigest(body) };
|
|
176
|
+
const reportPath = await designSave(directory, `${runId}.json`, report);
|
|
177
|
+
try { validateDesignReport(report, policy); }
|
|
178
|
+
catch (error) { error.reportPath = reportPath; error.reportDigest = report.reportDigest; throw error; }
|
|
179
|
+
return { status: 'needs-visual-confirmation', report, reportPath, nextStep: 'workflow-design-acceptance',
|
|
180
|
+
claimsIndependentVerification: false, instruction: 'Show actual browser screenshots beside the approved mockups or prototypes. Obtain the original user visual acceptance; automated checks alone cannot decide aesthetic quality.' };
|
|
181
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { verifyOriginalWorkflowAuthority } from './workflow-local-runner.mjs';
|
|
2
|
+
import { repositoryIntegrityFile } from './local-discipline-repository.mjs';
|
|
3
|
+
import { mutationPath14, mutationRoot14 } from './local-mutation-files.mjs';
|
|
4
|
+
import { sha256 } from './local-integrity.mjs';
|
|
5
|
+
import { designFail } from './design-policy.mjs';
|
|
6
|
+
|
|
7
|
+
export async function designValidationContext(input, plan, policy) {
|
|
8
|
+
const context = verifyOriginalWorkflowAuthority(input.validationContext), issued = Date.parse(context.issuedAt), expires = Date.parse(context.expiresAt);
|
|
9
|
+
if (context.schemaVersion !== 'entrorove.design-validation-context/1.0' || context.purpose !== 'browser-verify-approved-design'
|
|
10
|
+
|| context.flowId !== input.flowId || context.ownerId !== plan.ownerId || context.taskDigest !== plan.taskDigest
|
|
11
|
+
|| context.chainDigest !== plan.chainDigest || context.planDigest !== input.acceptedPlan.authorityReceipt.payloadDigest
|
|
12
|
+
|| context.baselineDigest !== policy.baselineDigest || context.previewOrigin !== policy.rules.previewOrigin
|
|
13
|
+
|| context.repositoryDigest !== sha256(await mutationRoot14(input.repositoryRoot)).slice(0, 32)
|
|
14
|
+
|| !/^[a-f0-9]{64}$/.test(context.testsDigest ?? '') || context.grantsWriteAuthority !== false
|
|
15
|
+
|| !Number.isFinite(issued) || !Number.isFinite(expires) || issued > Date.now() || expires <= Date.now()
|
|
16
|
+
|| expires <= issued || expires - issued > 300_000 || expires > Date.parse(plan.expiresAt)
|
|
17
|
+
|| !Array.isArray(context.sourceManifest) || !context.sourceManifest.length || context.sourceManifest.length > 2048
|
|
18
|
+
|| new Set(context.sourceManifest.map(file => file.path)).size !== context.sourceManifest.length) designFail('TEST_CONTEXT', 'Use the exact live server-signed context of the original successful tests; no write or independent verifier authority is granted');
|
|
19
|
+
for (const file of context.sourceManifest) {
|
|
20
|
+
mutationPath14(file.path);
|
|
21
|
+
if (Object.keys(file).sort().join('|') !== 'path|sha256' || !/^[a-f0-9]{64}$/.test(file.sha256 ?? '')) designFail('TEST_CONTEXT', 'Original test inputs require exact safe paths and SHA-256 hashes');
|
|
22
|
+
}
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
export async function designContextSources(input, context, required) {
|
|
26
|
+
for (const file of required) if (!context.sourceManifest.some(expected => expected.path === file.path && expected.sha256 === file.sha256)) designFail('TEST_CONTEXT', 'Every UI source, dependency and framework configuration must be part of the successful original test inputs');
|
|
27
|
+
for (const expected of context.sourceManifest) {
|
|
28
|
+
// Unselected test inputs receive integrity-only observation, never source
|
|
29
|
+
// disclosure or new semantic read/data-write permission.
|
|
30
|
+
const actual = await repositoryIntegrityFile(input.repositoryRoot, expected.path);
|
|
31
|
+
if (actual.sha256 !== expected.sha256) designFail('SOURCE_CHANGED', 'An original test input changed before or during browser acceptance');
|
|
32
|
+
}
|
|
33
|
+
return structuredClone(context.sourceManifest);
|
|
34
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import { object, array, text, identifier, relativePath, sha256, integer } from './local-contract-types.mjs';
|
|
3
|
+
|
|
4
|
+
const list = (item, min = 1, max = 128) => ({ ...array(item, min), maxItems: max });
|
|
5
|
+
const names = (min = 1) => ({ ...list(identifier, min), uniqueItems: true });
|
|
6
|
+
const words = { ...text, maxLength: 2000 };
|
|
7
|
+
const artifact = object({ path: text, sha256, bytes: integer(1, 8_388_608) }, ['path', 'sha256', 'bytes']);
|
|
8
|
+
const page = object({ id: identifier, route: { ...text, pattern: '^/(?!/)' }, title: words, roles: names(),
|
|
9
|
+
states: names(), publicFields: names(0), internalFields: names(0), entry: { type: 'boolean' } },
|
|
10
|
+
['id', 'route', 'title', 'roles', 'states', 'publicFields', 'internalFields', 'entry']);
|
|
11
|
+
export const DESIGN_SITE_SCHEMA = object({ id: identifier, requirementIds: names(), roles: names(), pages: list(page),
|
|
12
|
+
edges: list(object({ id: identifier, from: identifier, to: identifier,
|
|
13
|
+
kind: { enum: ['navigate', 'open', 'edit', 'fill', 'validate', 'save', 'cancel', 'delete'] }, label: words },
|
|
14
|
+
['id', 'from', 'to', 'kind', 'label']), 0, 256) }, ['id', 'requirementIds', 'roles', 'pages', 'edges']);
|
|
15
|
+
const source = object({ url: text, finalUrl: text, sha256, bytes: integer(1, 2_097_152), retrievedAt: text,
|
|
16
|
+
purpose: { enum: ['layout', 'framework'] }, rationale: words },
|
|
17
|
+
['url', 'finalUrl', 'sha256', 'bytes', 'retrievedAt', 'purpose', 'rationale']);
|
|
18
|
+
const framework = object({ packageName: text, version: text, packagePath: relativePath, packageSha256: sha256,
|
|
19
|
+
lockPath: relativePath, lockSha256: sha256, officialUrls: list(text, 1, 16) },
|
|
20
|
+
['packageName', 'version', 'packagePath', 'packageSha256', 'lockPath', 'lockSha256', 'officialUrls']);
|
|
21
|
+
const mockup = object({ kind: { enum: ['mockup', 'prototype'] }, pageId: identifier, stateId: identifier, theme: { enum: ['light', 'dark'] },
|
|
22
|
+
viewport: object({ width: integer(240, 3840), height: integer(240, 2160) }, ['width', 'height']),
|
|
23
|
+
image: artifact, generation: object({ tool: words, requestId: words, promptSha256: sha256,
|
|
24
|
+
receipt: artifact }, ['tool', 'requestId', 'promptSha256', 'receipt']) },
|
|
25
|
+
['kind', 'pageId', 'stateId', 'theme', 'viewport', 'image', 'generation']);
|
|
26
|
+
const assertion = object({ kind: { enum: ['visible', 'hidden', 'text', 'value', 'count', 'style', 'bounds'] },
|
|
27
|
+
field: identifier,
|
|
28
|
+
selector: words, value: { type: 'string', maxLength: 2000 }, count: integer(0, 1000), property: identifier,
|
|
29
|
+
bounds: object({ x: integer(0, 3840), y: integer(0, 10000), width: integer(1, 3840), height: integer(1, 10000),
|
|
30
|
+
tolerance: integer(0, 8) }, ['x', 'y', 'width', 'height', 'tolerance']) }, ['kind', 'selector']);
|
|
31
|
+
const network = object({ method: { enum: ['POST', 'PUT', 'PATCH', 'DELETE'] }, path: { ...text, pattern: '^/(?!/)' },
|
|
32
|
+
count: integer(1, 8), status: integer(200, 599) }, ['method', 'path', 'count', 'status']);
|
|
33
|
+
const projection = { anyOf: [{ type: 'object', minProperties: 1, maxProperties: 64 }, { type: 'null' }] };
|
|
34
|
+
const readBack = object({ kind: { enum: ['http', 'local-storage'] }, path: { ...text, pattern: '^/(?!/)' }, key: identifier,
|
|
35
|
+
jsonPointer: { type: 'string', maxLength: 512, pattern: '^(?:/.*)?$' },
|
|
36
|
+
statusBefore: integer(200, 499), statusAfter: integer(200, 499), before: projection, after: projection,
|
|
37
|
+
afterReload: list(assertion) }, ['kind', 'before', 'after', 'afterReload']);
|
|
38
|
+
const step = object({ edgeId: identifier, action: { enum: ['click', 'fill', 'press'] }, selector: words,
|
|
39
|
+
value: { type: 'string', maxLength: 2000 }, checks: list(assertion), mutations: list(network, 0, 8), readBack },
|
|
40
|
+
['edgeId', 'action', 'selector', 'checks', 'mutations']);
|
|
41
|
+
const scenario = object({ mockupIndex: integer(0, 511), entryPageId: identifier, route: { ...text, pattern: '^/(?!/)' }, steps: list(step, 0, 32), checks: list(assertion),
|
|
42
|
+
successSelector: words }, ['mockupIndex', 'entryPageId', 'route', 'steps', 'checks', 'successSelector']);
|
|
43
|
+
export const DESIGN_RULES_SCHEMA = object({ uiPaths: { ...list(relativePath), uniqueItems: true },
|
|
44
|
+
previewOrigin: { ...text, pattern: '^http://127\\.0\\.0\\.1:[1-9][0-9]{0,4}/$' },
|
|
45
|
+
browser: object({ executablePath: text, sha256 }, ['executablePath', 'sha256']),
|
|
46
|
+
verificationPaths: { ...list(relativePath, 1, 2048), uniqueItems: true },
|
|
47
|
+
componentModules: list(object({ module: text, exports: names() }, ['module', 'exports']), 0, 64),
|
|
48
|
+
frameworkStyleImports: { ...list(text, 0, 64), uniqueItems: true },
|
|
49
|
+
nativeTags: names(), replacements: list(object({ native: identifier, component: identifier }, ['native', 'component']), 0, 32),
|
|
50
|
+
tokenPath: relativePath, stylePaths: { ...list(relativePath), uniqueItems: true },
|
|
51
|
+
tokens: { type: 'object', minProperties: 1, maxProperties: 128, patternProperties: { '^--[a-z][a-z0-9-]*$': { ...text, maxLength: 100 } }, additionalProperties: false },
|
|
52
|
+
settleMs: integer(500, 5000),
|
|
53
|
+
forbiddenVisibleText: list(words, 0, 128), scenarios: list(scenario, 2, 512) },
|
|
54
|
+
['uiPaths', 'previewOrigin', 'browser', 'verificationPaths', 'componentModules', 'nativeTags', 'replacements', 'tokenPath', 'stylePaths', 'tokens', 'settleMs', 'forbiddenVisibleText', 'scenarios']);
|
|
55
|
+
export const DESIGN_POLICY_SCHEMA = object({ schemaVersion: { const: 'entrorove.design-policy/1.0' },
|
|
56
|
+
designId: identifier, flowId: identifier, revision: integer(1), site: DESIGN_SITE_SCHEMA, flow: artifact, flowDigest: sha256,
|
|
57
|
+
flowRecordedAt: text, researchRecordedAt: text, mockupsRecordedAt: text,
|
|
58
|
+
sources: list(source, 2, 32), frameworks: list(framework, 0, 16), mockups: list(mockup, 2, 512),
|
|
59
|
+
sourceBaseline: list(object({ path: relativePath, original: { anyOf: [{ ...artifact, properties: { ...artifact.properties, bytes: integer(0, 8_388_608) } }, { type: 'null' }] } }, ['path', 'original'])),
|
|
60
|
+
rules: DESIGN_RULES_SCHEMA, baselineDigest: sha256 },
|
|
61
|
+
['schemaVersion', 'designId', 'flowId', 'revision', 'site', 'flow', 'flowDigest', 'flowRecordedAt', 'researchRecordedAt',
|
|
62
|
+
'mockupsRecordedAt', 'sources', 'frameworks', 'mockups', 'sourceBaseline', 'rules', 'baselineDigest']);
|
|
63
|
+
export const DESIGN_REPORT_SCHEMA = object({ schemaVersion: { const: 'entrorove.design-report/1.0' },
|
|
64
|
+
baselineDigest: sha256, flowId: identifier, testsDigest: sha256, validationContext: { type: 'object' }, sourceManifest: list(object({ path: relativePath, sha256 }, ['path', 'sha256']), 1, 2048),
|
|
65
|
+
startedAt: text, finishedAt: text, engine: text,
|
|
66
|
+
results: list(object({ mockupIndex: integer(0, 511), checks: integer(1), steps: integer(0), stateChecks: integer(0),
|
|
67
|
+
failures: list(identifier, 0), screenshot: artifact }, ['mockupIndex', 'checks', 'steps', 'stateChecks', 'failures', 'screenshot']), 2, 512),
|
|
68
|
+
reportDigest: sha256 }, ['schemaVersion', 'baselineDigest', 'flowId', 'testsDigest', 'validationContext', 'sourceManifest', 'startedAt', 'finishedAt', 'engine', 'results', 'reportDigest']);
|
|
69
|
+
const ajv = new Ajv({ allErrors: true, strict: true });
|
|
70
|
+
const validators = new Map([['site', DESIGN_SITE_SCHEMA], ['rules', DESIGN_RULES_SCHEMA], ['policy', DESIGN_POLICY_SCHEMA],
|
|
71
|
+
['report', DESIGN_REPORT_SCHEMA]].map(([key, schema]) => [key, ajv.compile(schema)]));
|
|
72
|
+
export function designSchema(kind, value) {
|
|
73
|
+
const validate = validators.get(kind);
|
|
74
|
+
if (!validate?.(value)) throw Object.assign(new Error(`Invalid design ${kind}: ${validate?.errors?.map(e => e.instancePath || e.keyword).join(', ')}`),
|
|
75
|
+
{ code: 'ENTROROVE_DESIGN_INVALID' });
|
|
76
|
+
return structuredClone(value);
|
|
77
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { designFail, validateDesignPolicy } from './design-policy.mjs';
|
|
2
|
+
import { designUiCandidate } from './design-ui.mjs';
|
|
3
|
+
|
|
4
|
+
const componentRules = { antd: { button: 'Button', input: 'Input', dialog: 'Modal' },
|
|
5
|
+
'element-plus': { button: 'ElButton', input: 'ElInput', dialog: 'ElDialog' },
|
|
6
|
+
'@mui/material': { button: 'Button', input: 'TextField', dialog: 'Dialog' } };
|
|
7
|
+
export function assertDesignDeclaration(policy, paths) {
|
|
8
|
+
const ui = paths.filter(name => designUiCandidate(name));
|
|
9
|
+
if (!ui.length && !policy?.design) return;
|
|
10
|
+
if (!policy?.design) designFail('REQUIRED', 'UI implementation requires whole-site flow, actual internet research, mockups or prototypes and the original human confirmation');
|
|
11
|
+
const design = validateDesignPolicy(policy.design);
|
|
12
|
+
if (ui.some(name => !design.rules.uiPaths.includes(name))) designFail('SCOPE', 'Every UI write must belong to the exact approved design');
|
|
13
|
+
for (const framework of design.frameworks) for (const [native, component] of Object.entries(componentRules[framework.packageName] ?? {})) {
|
|
14
|
+
if (!design.rules.replacements.some(rule => rule.native === native && rule.component === component)
|
|
15
|
+
|| !design.rules.componentModules.some(module => module.module === framework.packageName && module.exports.includes(component))) designFail('FRAMEWORK_RULES', 'Use the declared component library for its standard controls and dialogs');
|
|
16
|
+
}
|
|
17
|
+
}
|