@entrorove/skill 0.1.64 → 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 +23 -0
- package/CHANGELOG.md +15 -0
- package/DELIVERY-BOUNDARIES.md +2 -0
- package/DESIGN-GATES.md +160 -0
- package/REUSE-DISCIPLINE.md +15 -1
- package/SKILL.md +4 -2
- package/blueprint-criterion-runner.mjs +1 -1
- package/cli.mjs +11 -4
- 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/install-identity.mjs +16 -0
- package/local-cli.mjs +3 -1
- package/local-contracts.mjs +2 -0
- 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 +9 -2
- package/local-error.mjs +18 -1
- package/local-execution.mjs +2 -1
- package/local-mutation-authority.mjs +2 -0
- package/local-runner-discipline.mjs +17 -3
- package/local-scope-zip.mjs +21 -0
- package/manifest.json +1 -1
- package/package-archive.mjs +54 -0
- package/package-replacement.mjs +48 -0
- package/package.json +34 -6
- package/release-state.json +50 -24
- package/release.mjs +3 -1
- package/releases.json +18 -0
- package/reuse-discipline-release.mjs +3 -1
- package/rollback-history.mjs +51 -0
- package/rollback-runner.mjs +77 -0
- package/version.mjs +20 -3
- package/workflow-local-runner.mjs +9 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export const FRAMEWORK_DOC_HOSTS = Object.freeze({ react: ['react.dev'], 'react-dom': ['react.dev'], vue: ['vuejs.org'], svelte: ['svelte.dev'],
|
|
2
|
+
next: ['nextjs.org'], nuxt: ['nuxt.com'], '@angular/core': ['angular.dev'], 'element-plus': ['element-plus.org'],
|
|
3
|
+
antd: ['ant.design'], '@ant-design/icons': ['ant.design'], bootstrap: ['getbootstrap.com'],
|
|
4
|
+
tailwindcss: ['tailwindcss.com'], '@mui/material': ['mui.com'], '@dcloudio/uni-app': ['uniapp.dcloud.net.cn'] });
|
package/design-gate.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { designFail, validateDesignPolicy } from './design-policy.mjs';
|
|
2
|
+
import { designUiCandidate, assertDesignLint } from './design-lint.mjs';
|
|
3
|
+
import { assertDesignArtifacts, designOriginalSource } from './design-store.mjs';
|
|
4
|
+
import { assertFrozenDesignFrameworks } from './design-research.mjs';
|
|
5
|
+
|
|
6
|
+
import { assertDesignDeclaration } from './design-declaration.mjs';
|
|
7
|
+
export { assertDesignDeclaration } from './design-declaration.mjs';
|
|
8
|
+
export async function assertDesignWrite(input, pass, name, before = '', after = '') {
|
|
9
|
+
const policy = pass.disciplinePolicy;
|
|
10
|
+
if (!designUiCandidate(name, before, after) && !policy?.design?.rules.uiPaths.includes(name)) return null;
|
|
11
|
+
assertDesignDeclaration(policy, [name]);
|
|
12
|
+
const design = validateDesignPolicy(policy?.design);
|
|
13
|
+
if (design.flowId !== pass.flowId || design.flowId !== input.flowId || !policy.approval && !policy.designApproval
|
|
14
|
+
|| !design.rules.uiPaths.includes(name)) designFail('APPROVAL_REQUIRED', 'This UI path needs the exact original account-bound design confirmation in its signed write pass');
|
|
15
|
+
await assertDesignArtifacts(design, input);
|
|
16
|
+
await assertFrozenDesignFrameworks(input, design);
|
|
17
|
+
assertDesignLint(design, name, before, after, await designOriginalSource(design, name));
|
|
18
|
+
return { baselineDigest: design.baselineDigest, lint: 'passed' };
|
|
19
|
+
}
|
package/design-lint.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import postcss from 'postcss';
|
|
3
|
+
import { parse as parseVue } from '@vue/compiler-sfc';
|
|
4
|
+
import { designFail, validateDesignPolicy } from './design-policy.mjs';
|
|
5
|
+
import { designUiAsset } from './design-ui.mjs';
|
|
6
|
+
|
|
7
|
+
export { designUiCandidate } from './design-ui.mjs';
|
|
8
|
+
const issue = (rule, detail) => ({ rule, detail });
|
|
9
|
+
function styleIssues(source, name, rules) {
|
|
10
|
+
let tree; try { tree = postcss.parse(source, { from: name }); }
|
|
11
|
+
catch { return [issue('css-parse', 'Use supported CSS; uncompiled preprocessors require a separately reviewed compiled CSS source')]; }
|
|
12
|
+
const found = [];
|
|
13
|
+
tree.walkAtRules(rule => { if (['import', 'tailwind', 'apply'].includes(rule.name)) found.push(issue('style-indirection', rule.toString())); });
|
|
14
|
+
tree.walkDecls(declaration => {
|
|
15
|
+
const { prop, value } = declaration;
|
|
16
|
+
if (declaration.important) found.push(issue('important', declaration.toString()));
|
|
17
|
+
if (prop.startsWith('--')) {
|
|
18
|
+
if (name !== rules.tokenPath || rules.tokens[prop] !== value) found.push(issue('unapproved-token', declaration.toString()));
|
|
19
|
+
} else if (/^(?:color|background(?:-color)?|font(?:-size|-family|-weight)?|padding.*|margin.*|gap|.*-gap|border(?:-color|-radius)?|box-shadow)$/.test(prop)
|
|
20
|
+
&& !/^(?:0|none|inherit|initial|unset|transparent|currentColor|auto)$/.test(value)
|
|
21
|
+
&& !/^var\(--[a-z0-9-]+\)$/.test(value)) found.push(issue('design-token-required', declaration.toString()));
|
|
22
|
+
for (const [, token] of value.matchAll(/var\((--[a-z0-9-]+)/g)) if (!Object.hasOwn(rules.tokens, token)) found.push(issue('unknown-token', token));
|
|
23
|
+
});
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
function markupIssues(markup, rules, imports) {
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const match of markup.matchAll(/<([A-Za-z][\w.-]*)([^<>]*?)\/?\s*>/g)) {
|
|
29
|
+
const [, tag, attributes] = match;
|
|
30
|
+
if (/\b(?:style|v-html|dangerouslySetInnerHTML)\s*=|\{\s*\.\.\./.test(attributes)) found.push(issue('unsafe-markup-projection', match[0]));
|
|
31
|
+
const replacement = rules.replacements.find(item => item.native === tag.toLowerCase());
|
|
32
|
+
if (replacement && tag !== replacement.component) found.push(issue('framework-component-required', match[0]));
|
|
33
|
+
if (tag[0] === tag[0].toLowerCase()) {
|
|
34
|
+
if (!rules.nativeTags.includes(tag)) found.push(issue('unapproved-native', tag));
|
|
35
|
+
} else if (!imports.has(tag) && !rules.componentModules.some(module => module.exports.includes(tag))) found.push(issue('unapproved-component', tag));
|
|
36
|
+
}
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
function scriptIssues(source, name, rules) {
|
|
40
|
+
const tree = ts.createSourceFile(name.replace(/\.[^.]+$/, '.tsx'), source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
41
|
+
if (tree.parseDiagnostics.length) return { found: [issue('script-parse', 'Unsupported UI source syntax')], imports: new Set() };
|
|
42
|
+
const found = [], imports = new Set();
|
|
43
|
+
const walk = node => {
|
|
44
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
45
|
+
const module = node.moduleSpecifier.text, approved = rules.componentModules.find(item => item.module === module);
|
|
46
|
+
const bindings = node.importClause?.namedBindings;
|
|
47
|
+
if (approved && bindings && ts.isNamedImports(bindings)) for (const element of bindings.elements) {
|
|
48
|
+
const exported = (element.propertyName ?? element.name).text;
|
|
49
|
+
if (!approved.exports.includes(exported)) found.push(issue('unapproved-component-import', node.getText(tree)));
|
|
50
|
+
else imports.add(element.name.text);
|
|
51
|
+
}
|
|
52
|
+
if (/\.(?:css|scss|less|sass|styl)$/.test(module) && !rules.frameworkStyleImports?.includes(module)
|
|
53
|
+
&& !rules.stylePaths.some(file => module.endsWith(file) || file.endsWith(module.replace(/^\.\//, '')))) found.push(issue('unapproved-style-import', node.getText(tree)));
|
|
54
|
+
if (!module.startsWith('.') && !approved && !rules.frameworkStyleImports?.includes(module)
|
|
55
|
+
&& !['react', 'react-dom', 'react-dom/client', 'vue', 'svelte', 'svelte/store', 'next/link', 'next/image'].includes(module)) found.push(issue('unapproved-ui-import', node.getText(tree)));
|
|
56
|
+
}
|
|
57
|
+
if (ts.isJsxSpreadAttribute(node) || ts.isJsxAttribute(node) && ['style', 'dangerouslySetInnerHTML'].includes(node.name.getText(tree))) found.push(issue('unsafe-markup-projection', node.getText(tree)));
|
|
58
|
+
if (ts.isPropertyAccessExpression(node) && ['innerHTML', 'outerHTML', 'style', 'insertAdjacentHTML', 'createElement'].includes(node.name.text)) found.push(issue('dynamic-ui-bypass', node.getText(tree)));
|
|
59
|
+
if (ts.isIdentifier(node) && ['eval', 'Function', 'styled'].includes(node.text) || ts.isCallExpression(node)
|
|
60
|
+
&& /^(?:Object\.(?:entries|values|keys)|JSON\.stringify)$/.test(node.expression.getText(tree))
|
|
61
|
+
&& (!ts.isObjectLiteralExpression(node.arguments[0] ?? tree)
|
|
62
|
+
|| node.arguments[0].properties.some(property => ts.isSpreadAssignment(property) || ts.isComputedPropertyName(property.name)))) found.push(issue('unbounded-ui-projection', node.getText(tree)));
|
|
63
|
+
ts.forEachChild(node, walk);
|
|
64
|
+
};
|
|
65
|
+
walk(tree);
|
|
66
|
+
return { found, imports };
|
|
67
|
+
}
|
|
68
|
+
export function designLint(name, source, rules) {
|
|
69
|
+
if (designUiAsset(name)) return [];
|
|
70
|
+
if (typeof source !== 'string' || Buffer.byteLength(source) > 2_097_152) designFail('LINT_LIMIT', 'UI lint requires bounded UTF-8 source');
|
|
71
|
+
if (/\.(?:scss|sass|less|styl|wxml|wxss|axml|acss)$/i.test(name)) return [issue('unsupported-ui-language', name)];
|
|
72
|
+
if (/\.css$/i.test(name)) return styleIssues(source, name, rules);
|
|
73
|
+
let markup = source, script = source, styles = [];
|
|
74
|
+
if (/\.vue$/i.test(name)) {
|
|
75
|
+
const { descriptor, errors } = parseVue(source);
|
|
76
|
+
if (errors.length) return [issue('vue-parse', name)];
|
|
77
|
+
markup = descriptor.template?.content ?? ''; script = [descriptor.script?.content, descriptor.scriptSetup?.content].filter(Boolean).join('\n');
|
|
78
|
+
styles = descriptor.styles;
|
|
79
|
+
} else if (/\.(?:html?|svelte)$/i.test(name)) {
|
|
80
|
+
script = [...source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)].map(match => match[1]).join('\n');
|
|
81
|
+
styles = [...source.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/g)].map(match => ({ content: match[1] }));
|
|
82
|
+
markup = source.replace(/<(?:script|style)\b[^>]*>[\s\S]*?<\/(?:script|style)>/g, '');
|
|
83
|
+
}
|
|
84
|
+
const parsed = scriptIssues(script, name, rules), found = [...parsed.found, ...markupIssues(markup, rules, parsed.imports)];
|
|
85
|
+
if (styles.length) found.push(issue('central-style-source-required', name));
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
export function assertDesignLint(policy, name, before, after, approvedBefore = before) {
|
|
89
|
+
validateDesignPolicy(policy);
|
|
90
|
+
const retained = new Map();
|
|
91
|
+
const inspect = source => [...designLint(name, source, policy.rules), ...privateFieldIssues(source, name, policy)];
|
|
92
|
+
const approved = new Map();
|
|
93
|
+
for (const problem of inspect(approvedBefore)) { const key = JSON.stringify(problem); approved.set(key, (approved.get(key) ?? 0) + 1); }
|
|
94
|
+
for (const problem of inspect(before)) { const key = JSON.stringify(problem); retained.set(key, Math.min((retained.get(key) ?? 0) + 1, approved.get(key) ?? 0)); }
|
|
95
|
+
const introduced = inspect(after).filter(problem => {
|
|
96
|
+
const key = JSON.stringify(problem), count = retained.get(key) ?? 0;
|
|
97
|
+
if (count) { retained.set(key, count - 1); return false; } return true;
|
|
98
|
+
});
|
|
99
|
+
if (introduced.length) designFail('LINT', `UI write refused: ${introduced.map(item => item.rule).join(', ')}. Follow the approved framework, component, token and field projection rules`);
|
|
100
|
+
return { introduced: 0 };
|
|
101
|
+
}
|
|
102
|
+
function privateFieldIssues(source, name, policy) {
|
|
103
|
+
if (designUiAsset(name)) return [];
|
|
104
|
+
const fields = new Set(policy.site.pages.flatMap(page => page.internalFields)), found = [];
|
|
105
|
+
if (!fields.size) return found;
|
|
106
|
+
const tree = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
107
|
+
const walk = node => {
|
|
108
|
+
const field = ts.isPropertyAccessExpression(node) ? node.name.text
|
|
109
|
+
: ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) ? node.argumentExpression.text
|
|
110
|
+
: (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) && (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) ? node.name.text : null;
|
|
111
|
+
if (fields.has(field)) found.push(issue('private-field-source', field));
|
|
112
|
+
ts.forEachChild(node, walk);
|
|
113
|
+
};
|
|
114
|
+
walk(tree); return found;
|
|
115
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { object, text, identifier, integer, array, record, sha256 } from './local-contract-types.mjs';
|
|
2
|
+
import { DESIGN_SITE_SCHEMA, DESIGN_RULES_SCHEMA } from './design-contracts.mjs';
|
|
3
|
+
|
|
4
|
+
const base = { repositoryRoot: text, home: text, flowId: identifier, designId: identifier, revision: integer(1) };
|
|
5
|
+
const required = ['repositoryRoot', 'home', 'flowId', 'designId', 'revision'];
|
|
6
|
+
export const DESIGN_LOCAL_OPERATION_SCHEMAS = {
|
|
7
|
+
'design-browser-info': object({ browserExecutablePath: text }, ['browserExecutablePath']),
|
|
8
|
+
'design-flow': object({ ...base, site: DESIGN_SITE_SCHEMA }, [...required, 'site']),
|
|
9
|
+
'design-research': object({ ...base, layoutReferences: { ...array(object({ url: text, rationale: text }, ['url', 'rationale']), 2), maxItems: 8 },
|
|
10
|
+
frameworks: { ...array(record), maxItems: 16 } }, [...required, 'layoutReferences', 'frameworks']),
|
|
11
|
+
'design-propose': object({ ...base, mockups: { ...array(record, 2), maxItems: 512 }, rules: DESIGN_RULES_SCHEMA }, [...required, 'mockups', 'rules']),
|
|
12
|
+
'design-status': object(base, required),
|
|
13
|
+
'design-verify': object({ repositoryRoot: text, home: text, flowId: identifier, acceptedPlan: record, validationContext: record, baselineDigest: sha256,
|
|
14
|
+
baseUrl: text, browserExecutablePath: text }, ['repositoryRoot', 'home', 'flowId', 'acceptedPlan', 'validationContext', 'baselineDigest', 'baseUrl', 'browserExecutablePath']),
|
|
15
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { sha256, canonical } from './local-integrity.mjs';
|
|
4
|
+
import { writeMutationFile14 } from './local-mutation-files.mjs';
|
|
5
|
+
import { designSchema } from './design-contracts.mjs';
|
|
6
|
+
import { validateDesignSite, validateDesignPolicy, designDigest, designBaselineDigest, designFail } from './design-policy.mjs';
|
|
7
|
+
import { designDirectory, designLoad, designSave, designMermaid, designCopy, assertDesignArtifacts, captureDesignSourceBaseline } from './design-store.mjs';
|
|
8
|
+
import { fetchDesignReference, frameworkBinding, FRAMEWORK_DOC_HOSTS, assertFrozenDesignFrameworks } from './design-research.mjs';
|
|
9
|
+
import { assertDesignDeclaration } from './design-gate.mjs';
|
|
10
|
+
import { verifyDesignBrowser } from './design-browser.mjs';
|
|
11
|
+
import { readDesignBrowserIdentity } from './design-browser-identity.mjs';
|
|
12
|
+
|
|
13
|
+
export async function recordDesignFlow(input) {
|
|
14
|
+
const site = validateDesignSite(input.site), directory = await designDirectory(input), diagram = designMermaid(site);
|
|
15
|
+
const file = path.join(directory, 'site-flow.mmd'); await writeMutationFile14(file, Buffer.from(diagram));
|
|
16
|
+
const flow = { site, flow: { path: file, sha256: sha256(diagram), bytes: Buffer.byteLength(diagram) },
|
|
17
|
+
flowDigest: designDigest(site), flowRecordedAt: new Date().toISOString() };
|
|
18
|
+
await designSave(directory, 'flow.json', flow);
|
|
19
|
+
return { status: 'recorded', ...flow, nextStep: 'design-research', grantsWriteAuthority: false };
|
|
20
|
+
}
|
|
21
|
+
export async function recordDesignResearch(input) {
|
|
22
|
+
const directory = await designDirectory(input), flow = await designLoad(directory, 'flow.json'), sources = [], frameworks = [];
|
|
23
|
+
if (!Array.isArray(input.layoutReferences) || input.layoutReferences.length < 2 || input.layoutReferences.length > 8
|
|
24
|
+
|| !Array.isArray(input.frameworks) || input.frameworks.length > 16) designFail('RESEARCH', 'Provide two to eight actual layout references and the declared frameworks');
|
|
25
|
+
for (const reference of input.layoutReferences) {
|
|
26
|
+
if (!reference.rationale?.trim()) designFail('RESEARCH', 'Explain why each mainstream layout fits these pages; popularity cannot be assumed');
|
|
27
|
+
sources.push({ url: reference.url, ...await fetchDesignReference(reference.url), purpose: 'layout', rationale: reference.rationale });
|
|
28
|
+
}
|
|
29
|
+
for (const supplied of input.frameworks) {
|
|
30
|
+
const framework = await frameworkBinding(input, supplied); frameworks.push(framework);
|
|
31
|
+
for (const url of framework.officialUrls) {
|
|
32
|
+
const fetched = await fetchDesignReference(url);
|
|
33
|
+
if (!FRAMEWORK_DOC_HOSTS[framework.packageName].includes(new URL(fetched.finalUrl).hostname)) designFail('FRAMEWORK', 'Official documentation redirected outside its official host');
|
|
34
|
+
sources.push({ url, ...fetched, purpose: 'framework', rationale: `Official conventions for ${framework.packageName} ${framework.version}` });
|
|
35
|
+
framework.officialUrls[framework.officialUrls.indexOf(url)] = fetched.finalUrl;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const result = { ...flow, sources, frameworks, researchRecordedAt: new Date().toISOString() };
|
|
39
|
+
await designSave(directory, 'research.json', result);
|
|
40
|
+
return { status: 'recorded', sources, frameworks, nextStep: 'generate-images-then-design-propose', grantsWriteAuthority: false };
|
|
41
|
+
}
|
|
42
|
+
export async function proposeDesign(input) {
|
|
43
|
+
const directory = await designDirectory(input), research = await designLoad(directory, 'research.json');
|
|
44
|
+
if (Date.now() - Date.parse(research.researchRecordedAt) > 30 * 86400_000) designFail('RESEARCH_STALE', 'Refresh current internet references in a new design revision');
|
|
45
|
+
const rules = designSchema('rules', input.rules), mockups = [];
|
|
46
|
+
const browser = await readDesignBrowserIdentity({ browserExecutablePath: rules.browser.executablePath });
|
|
47
|
+
if (canonical(browser) !== canonical(rules.browser)) designFail('BROWSER_CHANGED', 'Use the actual configured browser identity before confirming this design');
|
|
48
|
+
if (!Array.isArray(input.mockups) || input.mockups.length < 2 || input.mockups.length > 512) designFail('MOCKUPS', 'Create actual page/state mockups or prototypes after research');
|
|
49
|
+
for (const [index, item] of input.mockups.entries()) {
|
|
50
|
+
const image = await designCopy(directory, `mockup-${index}.png`, item.image.path);
|
|
51
|
+
const receipt = await designCopy(directory, `generation-${index}.json`, item.generation.receipt.path);
|
|
52
|
+
mockups.push({ ...item, image, generation: { ...item.generation, receipt } });
|
|
53
|
+
}
|
|
54
|
+
const body = { schemaVersion: 'entrorove.design-policy/1.0', designId: input.designId, flowId: input.flowId,
|
|
55
|
+
revision: input.revision, ...research, mockups, rules, sourceBaseline: await captureDesignSourceBaseline(input, directory, rules.uiPaths),
|
|
56
|
+
mockupsRecordedAt: new Date().toISOString() };
|
|
57
|
+
const design = validateDesignPolicy({ ...body, baselineDigest: designBaselineDigest(body) });
|
|
58
|
+
await assertDesignArtifacts(design, input);
|
|
59
|
+
await assertFrozenDesignFrameworks(input, design);
|
|
60
|
+
assertDesignDeclaration({ design }, design.rules.uiPaths);
|
|
61
|
+
await designSave(directory, 'proposal.json', design);
|
|
62
|
+
return { status: 'needs-human-confirmation', design, proposalPath: path.join(directory, 'proposal.json'),
|
|
63
|
+
nextStep: 'workflow-design-review', grantsWriteAuthority: false,
|
|
64
|
+
instruction: 'Show the whole-site flow, reference links, generated images and exact component/token/interaction rules to the user. Obtain the original design-only human confirmation before UI code preparation, then carry its designApproval in the original discipline policy. All original scope, source, plan and mutation gates still apply.' };
|
|
65
|
+
}
|
|
66
|
+
export async function readDesignStatus(input) {
|
|
67
|
+
const directory = await designDirectory(input), records = {};
|
|
68
|
+
for (const name of ['flow', 'research', 'proposal']) {
|
|
69
|
+
try { records[name] = await designLoad(directory, `${name}.json`); }
|
|
70
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
71
|
+
}
|
|
72
|
+
const entries = await fs.readdir(directory), attempts = entries.filter(name => /^browser-[a-f0-9-]+-start\.json$/.test(name)).sort().map(name => {
|
|
73
|
+
const id = name.replace(/-start\.json$/, '');
|
|
74
|
+
return { attemptId: id, startPath: path.join(directory, name), status: entries.includes(`${id}.json`) ? 'reported'
|
|
75
|
+
: entries.includes(`${id}-launch-failed.json`) ? 'launch-failed' : 'outcome-unknown',
|
|
76
|
+
...(entries.includes(`${id}.json`) ? { reportPath: path.join(directory, `${id}.json`) } : {}) };
|
|
77
|
+
});
|
|
78
|
+
return { status: records.proposal ? 'awaiting-cloud-confirmation' : records.research ? 'awaiting-generated-images' : 'awaiting-research', records, attempts, grantsWriteAuthority: false };
|
|
79
|
+
}
|
|
80
|
+
export const DESIGN_LOCAL_OPERATIONS = Object.freeze({ 'design-browser-info': readDesignBrowserIdentity, 'design-flow': recordDesignFlow, 'design-research': recordDesignResearch,
|
|
81
|
+
'design-propose': proposeDesign, 'design-status': readDesignStatus, 'design-verify': verifyDesignBrowser });
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export function designFail(code: string, message: string): never;
|
|
2
|
+
export function designDigest(value: unknown): string;
|
|
3
|
+
export function validateDesignSite(value: unknown): Record<string, any>;
|
|
4
|
+
export function validateDesignPolicy(value: unknown): Record<string, any>;
|
|
5
|
+
export function validateDesignReport(value: unknown, policy: unknown): Record<string, any>;
|