@entrorove/skill 0.1.71 → 0.1.78

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.
Files changed (72) hide show
  1. package/CAPABILITY-MAP.md +5 -0
  2. package/CHANGELOG.md +47 -0
  3. package/DESIGN-GATES.md +5 -9
  4. package/DESIGN-STAGED-ACCEPTANCE.md +103 -0
  5. package/IDE-EXECUTION.md +25 -4
  6. package/REUSE-DISCIPLINE.md +3 -3
  7. package/SKILL.md +15 -4
  8. package/cli.mjs +8 -4
  9. package/cloud-mutation-authority.mjs +8 -2
  10. package/cloud-task-client.mjs +10 -0
  11. package/cloud-watch-client.mjs +8 -2
  12. package/design-browser-checks.mjs +18 -5
  13. package/design-browser-pages.mjs +96 -0
  14. package/design-browser-state.mjs +1 -0
  15. package/design-browser.mjs +36 -40
  16. package/design-contracts.mjs +4 -1
  17. package/design-declaration.mjs +3 -0
  18. package/design-frameworks.mjs +2 -1
  19. package/design-gate.mjs +3 -0
  20. package/design-operations.mjs +4 -1
  21. package/design-policy.mjs +12 -4
  22. package/design-release.mjs +6 -0
  23. package/design-research-network.mjs +82 -0
  24. package/design-research.mjs +8 -25
  25. package/design-runner.mjs +3 -0
  26. package/design-stage-contracts.mjs +12 -0
  27. package/design-stage.mjs +78 -0
  28. package/design-storage.mjs +63 -0
  29. package/fs-request-trace.mjs +67 -0
  30. package/local-contracts.mjs +1 -1
  31. package/local-discipline-repository.mjs +4 -2
  32. package/local-invocations.mjs +6 -0
  33. package/local-mutation-files.mjs +13 -5
  34. package/local-probe.mjs +20 -7
  35. package/local-recovery-budget.mjs +31 -0
  36. package/local-scope-repository.mjs +18 -3
  37. package/local-scope-signature.mjs +2 -2
  38. package/local-source-execution.mjs +47 -0
  39. package/manifest.json +1 -1
  40. package/mutation-challenge-refresh.mjs +3 -0
  41. package/mutation-client.mjs +17 -5
  42. package/native-client.mjs +4 -1
  43. package/package.json +19 -2
  44. package/release-source-groups.mjs +7 -0
  45. package/release-state.json +70 -53
  46. package/release.mjs +9 -3
  47. package/releases.json +42 -0
  48. package/remote-capability-cache.mjs +20 -0
  49. package/remote-client.mjs +25 -5
  50. package/remote-transport.mjs +14 -0
  51. package/scope-amendment-client.mjs +90 -22
  52. package/scope-amendment-observer.mjs +9 -3
  53. package/scope-amendment-reference.mjs +22 -4
  54. package/scope-amendment-remote.mjs +20 -6
  55. package/scope-amendment-retirement.mjs +4 -2
  56. package/scope-amendment-state.mjs +10 -6
  57. package/scope-context-client.mjs +24 -8
  58. package/scope-context-journal.mjs +13 -6
  59. package/scope-context-remote.mjs +20 -9
  60. package/scope-context-state.mjs +4 -1
  61. package/scope-context-wire.mjs +9 -1
  62. package/scope-evidence-local.mjs +52 -0
  63. package/scope-evidence-remote.mjs +87 -0
  64. package/scope-evidence-wire.mjs +120 -0
  65. package/scope-runner-client.mjs +92 -35
  66. package/scope-runner-journal.mjs +109 -0
  67. package/scope-write-observation.d.mts +4 -0
  68. package/scope-write-observation.mjs +43 -0
  69. package/service-prerequisite.mjs +87 -0
  70. package/task-entry-client.mjs +3 -0
  71. package/workflow-observation-client.mjs +2 -0
  72. package/workflow-observation-remote.mjs +13 -5
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { designFail } from './design-policy.mjs';
3
+ import { installSuccessMonitor, checkBrowserMonitors } from './design-browser-checks.mjs';
4
+ import { beginBrowserAction } from './design-browser-state.mjs';
5
+ import { browserStorage, browserStepHandoffs } from './design-storage.mjs';
6
+
7
+ export function matchesDesignPage(url, pageContract, base) {
8
+ const approved = new URL(pageContract.route, base);
9
+ const route = approved.pathname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/:[\w]+/g, '[^/]+');
10
+ const query = search => { const value = new URLSearchParams(search); value.sort(); return value.toString(); };
11
+ return approved.origin === base.origin && url.origin === base.origin && !url.username && !url.password
12
+ && new RegExp(`^${route}$`).test(url.pathname)
13
+ && (!pageContract.route.split('#')[0].includes('?') || query(approved.search) === query(url.search))
14
+ && (!approved.hash || approved.hash === url.hash);
15
+ }
16
+ function remaining(deadlineAt, limit = 5000) {
17
+ const value = deadlineAt - Date.now();
18
+ if (value <= 0) designFail('TIME_LIMIT', 'Browser acceptance reached its original bounded deadline');
19
+ return Math.min(limit, value);
20
+ }
21
+ export async function browserPages(context, scenario, base, failures, deadlineAt, settleMs, site) {
22
+ const pages = [], observed = {}, mutations = [], requests = new Map(), parents = new WeakMap(), writes = [];
23
+ const storage = browserStorage(base, parents);
24
+ const binding = `__entroroveDesignRecord_${randomUUID().replaceAll('-', '')}`;
25
+ await context.exposeBinding(binding, (source, event) => {
26
+ if (event?.kind === 'storage') {
27
+ observed.storage = true;
28
+ if (writes.length < 64) writes.push({ ...event, page: source.page }); else failures.push('storage-observation-limit');
29
+ }
30
+ else if (event?.kind === 'success') {
31
+ observed.success = true;
32
+ if (event.successfulAcknowledgement !== true) observed.unacknowledged = true;
33
+ } else failures.push('browser-monitor-invalid');
34
+ });
35
+ const keys = scenario.steps.flatMap(step => browserStepHandoffs(step, site.edges.find(edge => edge.id === step.edgeId)))
36
+ .map(({ area, key }) => ({ area, key }));
37
+ await installSuccessMonitor(context, scenario.successSelector, binding, keys);
38
+ await context.route('**/*', async handler => {
39
+ if (new URL(handler.request().url()).origin !== base.origin) { failures.push('external-network'); await handler.abort(); }
40
+ else await handler.continue();
41
+ });
42
+ context.on('request', request => {
43
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
44
+ const event = { method: request.method(), path: new URL(request.url()).pathname, status: null };
45
+ mutations.push(event); requests.set(request, event);
46
+ }
47
+ });
48
+ context.on('response', response => { const event = requests.get(response.request()); if (event) event.status = response.status(); });
49
+ context.on('page', page => {
50
+ pages.push(page); page.setDefaultTimeout(Math.max(1, Math.min(5000, deadlineAt - Date.now())));
51
+ page.on('pageerror', () => failures.push('browser-error'));
52
+ page.on('dialog', async dialog => { failures.push('unexpected-dialog'); await dialog.dismiss(); });
53
+ });
54
+ return {
55
+ mutations,
56
+ mark: () => pages.length,
57
+ async begin(page) {
58
+ remaining(deadlineAt);
59
+ await storage.begin(context, page);
60
+ for (const page of context.pages()) await beginBrowserAction(page);
61
+ for (const key of Object.keys(observed)) delete observed[key];
62
+ writes.length = 0;
63
+ },
64
+ async check(commits, canSucceed, handoffs = [], page) {
65
+ remaining(deadlineAt);
66
+ for (const page of context.pages()) await checkBrowserMonitors(page, commits || handoffs.length > 0, canSucceed, failures);
67
+ await storage.check(context, page, handoffs, writes, commits, failures);
68
+ if (observed.success && (!canSucceed || observed.unacknowledged)) failures.push('premature-success-message');
69
+ },
70
+ async next(current, mark, pageContract) {
71
+ remaining(deadlineAt);
72
+ const added = pages.slice(mark);
73
+ if (added.length > 1) { failures.push('ambiguous-popup'); designFail('PREVIEW', 'An action opened multiple unapproved windows'); }
74
+ let next = current;
75
+ if (added.length) {
76
+ next = added[0];
77
+ if (next.isClosed() || await next.opener() !== current) {
78
+ failures.push('unverified-popup'); designFail('PREVIEW', 'A popup lost its original triggering page');
79
+ }
80
+ parents.set(next, current);
81
+ await next.waitForURL(url => url.href !== 'about:blank', { timeout: remaining(deadlineAt, 15_000) });
82
+ if (!matchesDesignPage(new URL(next.url()), pageContract, base)) {
83
+ failures.push('flow-page-transition'); designFail('PREVIEW', 'The popup did not reach its exact approved page');
84
+ }
85
+ await next.waitForLoadState('networkidle', { timeout: remaining(deadlineAt, 15_000) });
86
+ if (deadlineAt - Date.now() < settleMs) designFail('TIME_LIMIT', 'The popup cannot settle within its original authority window');
87
+ await next.waitForTimeout(settleMs);
88
+ } else if (current.isClosed()) {
89
+ next = parents.get(current);
90
+ if (!next || next.isClosed()) designFail('PREVIEW', 'The action closed its page without an original live opener');
91
+ }
92
+ if (!matchesDesignPage(new URL(next.url()), pageContract, base)) failures.push('flow-page-transition');
93
+ return next;
94
+ }
95
+ };
96
+ }
@@ -24,6 +24,7 @@ export async function checkBrowserState(page, proof, phase, fields, baseUrl, fai
24
24
  actual = value === null ? null : projected(JSON.parse(value), proof.jsonPointer);
25
25
  }
26
26
  if (expected === null) { if (actual !== null) failures.push('read-back-state'); }
27
+ else if (fields === null) { if (canonical(actual) !== canonical(expected)) failures.push('handoff-storage-state'); }
27
28
  else if (!actual || typeof actual !== 'object' || Array.isArray(actual)
28
29
  || Object.keys(actual).some(key => !fields.includes(key))
29
30
  || Object.keys(expected).some(key => canonical(actual[key] ?? null) !== canonical(expected[key]))) failures.push('read-back-state');
@@ -15,13 +15,11 @@ import { assertBudgetRunnerAllowed } from './local-budget-reads.mjs';
15
15
  import { withStateLock } from './local-state-lock.mjs';
16
16
  import { designValidationContext, designContextSources } from './design-context.mjs';
17
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
- }
18
+ import { browserChecks, networkCheck, publicFieldCheck, previewUrl } from './design-browser-checks.mjs';
19
+ import { checkBrowserState } from './design-browser-state.mjs';
20
+ import { matchesDesignPage, browserPages } from './design-browser-pages.mjs';
21
+ import { assertDesignStageSources, designAcceptanceScope } from './design-stage.mjs';
22
+ import { browserStepHandoffs } from './design-storage.mjs';
25
23
 
26
24
  async function verifySources(input, policy) {
27
25
  const { mutationRead14 } = await import('./local-mutation-files.mjs');
@@ -34,60 +32,51 @@ async function verifySources(input, policy) {
34
32
  }
35
33
  async function scenarioRun(browser, input, policy, scenario, directory, runId, deadlineAt) {
36
34
  const mockup = policy.mockups[scenario.mockupIndex];
37
- let pageContract = policy.site.pages.find(item => item.id === scenario.entryPageId), stateChecks = 0, lastReadBack;
35
+ let pageContract = policy.site.pages.find(item => item.id === scenario.entryPageId), stateChecks = 0, lastReadBack, lastHandoff;
38
36
  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');
37
+ if (!matchesDesignPage(target, pageContract, base)) designFail('PREVIEW', 'Scenario route differs from its approved flow page');
40
38
  const context = await browser.newContext({ viewport: mockup.viewport, colorScheme: mockup.theme,
41
39
  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(); });
40
+ const failures = [], tracked = await browserPages(context, scenario, base, failures, deadlineAt, policy.rules.settleMs, policy.site), { mutations } = tracked;
41
+ let page = await context.newPage();
58
42
  try {
59
- await installSuccessMonitor(page, scenario.successSelector);
60
43
  await page.goto(target.href, { waitUntil: 'networkidle', timeout: 15_000 });
61
44
  await page.waitForTimeout(policy.rules.settleMs);
62
45
  networkCheck(mutations.splice(0), [], failures);
63
- await monitorSuccess(page, failures, false);
64
- await checkNonCommitStorage(page, false, failures);
46
+ await tracked.check(false, false, [], page);
65
47
  await browserChecks(page, scenario.checks, failures);
66
48
  await publicFieldCheck(page, pageContract, policy.rules.forbiddenVisibleText, failures);
67
49
  for (const step of scenario.steps) {
68
50
  if (Date.now() >= deadlineAt) designFail('TIME_LIMIT', 'Browser acceptance reached its original bounded deadline');
69
51
  await assertBudgetRunnerAllowed(input);
70
52
  const edge = policy.site.edges.find(item => item.id === step.edgeId);
53
+ const handoffs = browserStepHandoffs(step, edge), fields = handoffs.length ? null : policy.site.pages.find(item => item.id === edge.to).publicFields;
71
54
  pageContract = policy.site.pages.find(item => item.id === edge.to);
72
- await beginBrowserAction(page);
55
+ await tracked.begin(page);
73
56
  if (step.readBack) {
74
- await checkBrowserState(page, step.readBack, 'before', pageContract.publicFields, base, failures); stateChecks++;
57
+ await checkBrowserState(page, step.readBack, 'before', fields, base, failures); stateChecks++;
58
+ }
59
+ const mark = tracked.mark(), locator = page.locator(step.selector);
60
+ try {
61
+ if (step.action === 'click') await locator.click();
62
+ else if (step.action === 'fill') await locator.fill(step.value ?? '');
63
+ else await locator.press(step.value ?? 'Enter');
64
+ } catch (error) {
65
+ if (step.action !== 'click' || edge.kind !== 'cancel' || !page.isClosed()) throw error;
66
+ page = await tracked.next(page, mark, pageContract);
75
67
  }
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);
68
+ await new Promise(resolve => setTimeout(resolve, policy.rules.settleMs));
69
+ page = await tracked.next(page, mark, pageContract);
81
70
  networkCheck(mutations.splice(0), step.mutations, failures);
82
71
  const commits = ['save', 'delete'].includes(edge.kind);
83
72
  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);
73
+ await tracked.check(commits, canSucceed, handoffs, page);
74
+ if (step.storageHandoffs?.length) stateChecks += 2;
86
75
  if (step.readBack) {
87
- await checkBrowserState(page, step.readBack, 'after', pageContract.publicFields, base, failures); stateChecks++;
88
- lastReadBack = step.readBack;
76
+ await checkBrowserState(page, step.readBack, 'after', fields, base, failures); stateChecks++;
77
+ lastReadBack = step.readBack; lastHandoff = handoffs.length > 0;
89
78
  }
90
- if (!matchesPage(new URL(page.url()), pageContract, base)) failures.push('flow-page-transition');
79
+ if (!matchesDesignPage(new URL(page.url()), pageContract, base)) designFail('PREVIEW', 'The action did not reach its exact approved page');
91
80
  await browserChecks(page, step.checks, failures);
92
81
  await publicFieldCheck(page, pageContract, policy.rules.forbiddenVisibleText, failures);
93
82
  }
@@ -98,10 +87,14 @@ async function scenarioRun(browser, input, policy, scenario, directory, runId, d
98
87
  await writeMutationFile14(file, bytes);
99
88
  if (lastReadBack) {
100
89
  try {
90
+ await tracked.begin(page);
101
91
  await page.reload({ waitUntil: 'networkidle', timeout: 15_000 });
102
92
  await page.waitForTimeout(policy.rules.settleMs);
103
93
  networkCheck(mutations.splice(0), [], failures);
94
+ await tracked.check(false, false, [], page);
95
+ if (!matchesDesignPage(new URL(page.url()), pageContract, base)) failures.push('flow-page-transition');
104
96
  await browserChecks(page, lastReadBack.afterReload, failures);
97
+ if (lastHandoff) await checkBrowserState(page, lastReadBack, 'after', null, base, failures);
105
98
  await publicFieldCheck(page, pageContract, policy.rules.forbiddenVisibleText, failures); stateChecks++;
106
99
  } catch { failures.push('reload-state-unverified'); }
107
100
  }
@@ -125,6 +118,7 @@ export async function verifyDesignBrowser(input) {
125
118
  previewUrl(input.baseUrl);
126
119
  await assertDesignArtifacts(policy, input);
127
120
  await assertFrozenDesignFrameworks(input, policy);
121
+ await assertDesignStageSources(input, policy);
128
122
  const sourceManifest = await designContextSources(input, context, await verifySources(input, policy));
129
123
  const directory = await designDirectory({ ...input, designId: policy.designId, revision: policy.revision });
130
124
  return withStateLock(path.join(directory, 'browser-verification.lock'), () => browserAttempt(input, policy, plan, context, sourceManifest, directory));
@@ -167,6 +161,7 @@ async function browserAttempt(input, policy, plan, context, sourceManifest, dire
167
161
  await assertBudgetRunnerAllowed(input);
168
162
  if (Date.now() > deadlineAt) designFail('TIME_LIMIT', 'Browser acceptance exceeded its original authority window');
169
163
  after = await designContextSources(input, context, await designSourceManifest(input, policy));
164
+ await assertDesignStageSources(input, policy);
170
165
  } catch { after = null; }
171
166
  if (!after || !sameDesignBytes(sourceManifest, after)) for (const result of results) result.failures.push('source-or-authority-changed-during-acceptance');
172
167
  const body = { schemaVersion: 'entrorove.design-report/1.0', baselineDigest: policy.baselineDigest, flowId: input.flowId,
@@ -177,5 +172,6 @@ async function browserAttempt(input, policy, plan, context, sourceManifest, dire
177
172
  try { validateDesignReport(report, policy); }
178
173
  catch (error) { error.reportPath = reportPath; error.reportDigest = report.reportDigest; throw error; }
179
174
  return { status: 'needs-visual-confirmation', report, reportPath, nextStep: 'workflow-design-acceptance',
175
+ acceptanceScope: designAcceptanceScope(policy),
180
176
  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
177
  }
@@ -1,5 +1,6 @@
1
1
  import Ajv from 'ajv';
2
2
  import { object, array, text, identifier, relativePath, sha256, integer } from './local-contract-types.mjs';
3
+ import { DESIGN_STAGE_SCHEMA, DESIGN_HANDOFF_SCHEMA } from './design-stage-contracts.mjs';
3
4
 
4
5
  const list = (item, min = 1, max = 128) => ({ ...array(item, min), maxItems: max });
5
6
  const names = (min = 1) => ({ ...list(identifier, min), uniqueItems: true });
@@ -36,11 +37,13 @@ const readBack = object({ kind: { enum: ['http', 'local-storage'] }, path: { ...
36
37
  statusBefore: integer(200, 499), statusAfter: integer(200, 499), before: projection, after: projection,
37
38
  afterReload: list(assertion) }, ['kind', 'before', 'after', 'afterReload']);
38
39
  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
+ value: { type: 'string', maxLength: 2000 }, checks: list(assertion), mutations: list(network, 0, 8), readBack,
41
+ storageHandoffs: list(DESIGN_HANDOFF_SCHEMA, 0, 8) },
40
42
  ['edgeId', 'action', 'selector', 'checks', 'mutations']);
41
43
  const scenario = object({ mockupIndex: integer(0, 511), entryPageId: identifier, route: { ...text, pattern: '^/(?!/)' }, steps: list(step, 0, 32), checks: list(assertion),
42
44
  successSelector: words }, ['mockupIndex', 'entryPageId', 'route', 'steps', 'checks', 'successSelector']);
43
45
  export const DESIGN_RULES_SCHEMA = object({ uiPaths: { ...list(relativePath), uniqueItems: true },
46
+ stage: DESIGN_STAGE_SCHEMA,
44
47
  previewOrigin: { ...text, pattern: '^http://127\\.0\\.0\\.1:[1-9][0-9]{0,4}/$' },
45
48
  browser: object({ executablePath: text, sha256 }, ['executablePath', 'sha256']),
46
49
  verificationPaths: { ...list(relativePath, 1, 2048), uniqueItems: true },
@@ -9,6 +9,9 @@ export function assertDesignDeclaration(policy, paths) {
9
9
  if (!ui.length && !policy?.design) return;
10
10
  if (!policy?.design) designFail('REQUIRED', 'UI implementation requires whole-site flow, actual internet research, mockups or prototypes and the original human confirmation');
11
11
  const design = validateDesignPolicy(policy.design);
12
+ const frozen = new Set((design.rules.stage?.readOnlyPages ?? []).flatMap(page => page.sources.map(source => source.path)));
13
+ if (policy.writes?.some(write => frozen.has(write.targetPath) || frozen.has(write.destinationPath))) designFail('STAGE_READ_ONLY', 'An approved stage cannot declare writes, deletions or moves of its read-only context sources');
14
+ if (design.rules.stage && policy.writes?.some(write => write.requirementRefs.some(id => !design.rules.stage.requirementIds.includes(id)))) designFail('STAGE', 'Stage writes must retain the exact original requirements selected for this stage');
12
15
  if (ui.some(name => !design.rules.uiPaths.includes(name))) designFail('SCOPE', 'Every UI write must belong to the exact approved design');
13
16
  for (const framework of design.frameworks) for (const [native, component] of Object.entries(componentRules[framework.packageName] ?? {})) {
14
17
  if (!design.rules.replacements.some(rule => rule.native === native && rule.component === component)
@@ -1,4 +1,5 @@
1
1
  export const FRAMEWORK_DOC_HOSTS = Object.freeze({ react: ['react.dev'], 'react-dom': ['react.dev'], vue: ['vuejs.org'], svelte: ['svelte.dev'],
2
2
  next: ['nextjs.org'], nuxt: ['nuxt.com'], '@angular/core': ['angular.dev'], 'element-plus': ['element-plus.org'],
3
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'] });
4
+ tailwindcss: ['tailwindcss.com', 'v3.tailwindcss.com'], '@mui/material': ['mui.com'], '@dcloudio/uni-app': ['uniapp.dcloud.net.cn'],
5
+ 'uview-plus': ['uview-plus.jiangruyi.com', 'uiadmin.net'] });
package/design-gate.mjs CHANGED
@@ -2,11 +2,13 @@ import { designFail, validateDesignPolicy } from './design-policy.mjs';
2
2
  import { designUiCandidate, assertDesignLint } from './design-lint.mjs';
3
3
  import { assertDesignArtifacts, designOriginalSource } from './design-store.mjs';
4
4
  import { assertFrozenDesignFrameworks } from './design-research.mjs';
5
+ import { assertDesignStageSources, assertDesignStageWrite } from './design-stage.mjs';
5
6
 
6
7
  import { assertDesignDeclaration } from './design-declaration.mjs';
7
8
  export { assertDesignDeclaration } from './design-declaration.mjs';
8
9
  export async function assertDesignWrite(input, pass, name, before = '', after = '') {
9
10
  const policy = pass.disciplinePolicy;
11
+ if (policy?.design) assertDesignStageWrite(policy.design, name, after);
10
12
  if (!designUiCandidate(name, before, after) && !policy?.design?.rules.uiPaths.includes(name)) return null;
11
13
  assertDesignDeclaration(policy, [name]);
12
14
  const design = validateDesignPolicy(policy?.design);
@@ -14,6 +16,7 @@ export async function assertDesignWrite(input, pass, name, before = '', after =
14
16
  || !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
17
  await assertDesignArtifacts(design, input);
16
18
  await assertFrozenDesignFrameworks(input, design);
19
+ await assertDesignStageSources(input, design);
17
20
  assertDesignLint(design, name, before, after, await designOriginalSource(design, name));
18
21
  return { baselineDigest: design.baselineDigest, lint: 'passed' };
19
22
  }
@@ -9,6 +9,7 @@ import { fetchDesignReference, frameworkBinding, FRAMEWORK_DOC_HOSTS, assertFroz
9
9
  import { assertDesignDeclaration } from './design-gate.mjs';
10
10
  import { verifyDesignBrowser } from './design-browser.mjs';
11
11
  import { readDesignBrowserIdentity } from './design-browser-identity.mjs';
12
+ import { assertDesignStageSources, designAcceptanceScope } from './design-stage.mjs';
12
13
 
13
14
  export async function recordDesignFlow(input) {
14
15
  const site = validateDesignSite(input.site), directory = await designDirectory(input), diagram = designMermaid(site);
@@ -55,13 +56,15 @@ export async function proposeDesign(input) {
55
56
  revision: input.revision, ...research, mockups, rules, sourceBaseline: await captureDesignSourceBaseline(input, directory, rules.uiPaths),
56
57
  mockupsRecordedAt: new Date().toISOString() };
57
58
  const design = validateDesignPolicy({ ...body, baselineDigest: designBaselineDigest(body) });
59
+ await assertDesignStageSources(input, design);
58
60
  await assertDesignArtifacts(design, input);
59
61
  await assertFrozenDesignFrameworks(input, design);
60
62
  assertDesignDeclaration({ design }, design.rules.uiPaths);
61
63
  await designSave(directory, 'proposal.json', design);
62
64
  return { status: 'needs-human-confirmation', design, proposalPath: path.join(directory, 'proposal.json'),
65
+ acceptanceScope: designAcceptanceScope(design),
63
66
  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.' };
67
+ instruction: 'Show the whole-site flow, reference links, generated images and exact component/token/interaction rules to the user. For a stage, explicitly show its executable pages/edges and hashed read-only context; do not claim whole-site acceptance. 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
68
  }
66
69
  export async function readDesignStatus(input) {
67
70
  const directory = await designDirectory(input), records = {};
package/design-policy.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { canonical, sha256 } from './local-integrity.mjs';
2
2
  import { designSchema } from './design-contracts.mjs';
3
3
  import { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
4
+ import { validateDesignStage, executableDesignPages, executableDesignEdges, validateDesignHandoffs } from './design-stage.mjs';
4
5
 
5
6
  export function designFail(code, message) { throw Object.assign(new Error(message), { code: `ENTROROVE_DESIGN_${code}` }); }
6
7
  export const designDigest = value => sha256(canonical(value));
@@ -38,7 +39,9 @@ function validateDesignSources(policy) {
38
39
  const retrieved = Date.parse(source.retrievedAt), recorded = Date.parse(policy.researchRecordedAt);
39
40
  if (!Number.isFinite(retrieved) || retrieved > recorded || recorded - retrieved > 30 * 86400_000) designFail('RESEARCH_STALE', 'Current layout and framework documentation must be fetched before the research stage closes');
40
41
  }
41
- if (new Set(policy.frameworks.map(framework => framework.packageName)).size !== policy.frameworks.length) designFail('FRAMEWORK', 'Framework versions must be unambiguous');
42
+ if (new Set(policy.frameworks.map(framework => canonical([framework.packagePath, framework.packageName]))).size !== policy.frameworks.length) {
43
+ designFail('FRAMEWORK', 'Each actual package file must declare one unambiguous locked version per framework');
44
+ }
42
45
  for (const framework of policy.frameworks) if (!FRAMEWORK_DOC_HOSTS[framework.packageName]
43
46
  || framework.officialUrls.some(url => !FRAMEWORK_DOC_HOSTS[framework.packageName].includes(new URL(url).hostname))) designFail('FRAMEWORK', 'Use the declared framework official documentation hosts');
44
47
  }
@@ -47,6 +50,7 @@ export function validateDesignPolicy(raw) {
47
50
  let preview; try { preview = new URL(rules.previewOrigin); } catch { designFail('PREVIEW', 'Preview must be an exact isolated local origin'); }
48
51
  if (preview.href !== rules.previewOrigin || preview.hostname !== '127.0.0.1' || !preview.port) designFail('PREVIEW', 'Approve the exact isolated preview before UI implementation');
49
52
  validateDesignSite(site);
53
+ validateDesignStage(policy);
50
54
  if (policy.baselineDigest !== designBaselineDigest(policy) || policy.flowDigest !== designDigest(site)) designFail('BASELINE_CHANGED', 'Design bytes differ from the exact proposal baseline');
51
55
  const times = [policy.flowRecordedAt, policy.researchRecordedAt, policy.mockupsRecordedAt].map(Date.parse);
52
56
  if (times.some(t => !Number.isFinite(t) || t > Date.now() + 5000) || times[0] > times[1] || times[1] > times[2]) designFail('ORDER', 'Record whole-site flow, internet research, then mockups or prototypes in order');
@@ -67,7 +71,7 @@ export function validateDesignPolicy(raw) {
67
71
  const page = site.pages.find(item => item.id === mockup.pageId);
68
72
  if (!page?.states.includes(mockup.stateId)) designFail('MOCKUPS', 'Mockups must map to real flow pages and states');
69
73
  }
70
- for (const page of site.pages) for (const state of page.states) for (const theme of ['light', 'dark']) {
74
+ for (const page of site.pages.filter(page => executableDesignPages(policy).includes(page.id))) for (const state of page.states) for (const theme of ['light', 'dark']) {
71
75
  if (!policy.mockups.some(image => image.pageId === page.id && image.stateId === state && image.theme === theme)) designFail('MOCKUPS', `Missing mockup/prototype: ${page.id}/${state}/${theme}`);
72
76
  }
73
77
  validateDesignScenarios(policy);
@@ -80,6 +84,7 @@ function validateDesignScenarios(policy) {
80
84
  used.add(scenario.mockupIndex);
81
85
  const page = site.pages.find(item => item.id === scenario.entryPageId);
82
86
  if (!page || !scenario.route.startsWith('/') || scenario.route.startsWith('//') || /[\\\r\n]/.test(scenario.route)) designFail('COVERAGE', 'Browser scenarios require a bounded local route');
87
+ if (rules.stage && !executableDesignPages(policy).includes(page.id) && scenario.steps.length) designFail('STAGE', 'Read-only context scenarios cannot execute interactions');
83
88
  const assertions = [...scenario.checks, ...scenario.steps.flatMap(step => [...step.checks, ...(step.readBack?.afterReload ?? [])])];
84
89
  if (!assertions.some(check => check.kind === 'bounds') || !assertions.some(check => check.kind === 'style')) designFail('VISUAL', 'Each scenario needs frozen layout bounds and computed style checks');
85
90
  for (const check of assertions) {
@@ -91,14 +96,17 @@ function validateDesignScenarios(policy) {
91
96
  for (const step of scenario.steps) {
92
97
  const edge = site.edges.find(item => item.id === step.edgeId);
93
98
  if (!edge || edge.from !== cursor) designFail('COVERAGE', 'Browser actions must follow the original whole-site page transitions');
99
+ if (!executableDesignEdges(policy).includes(edge.id)) designFail('STAGE', 'This interaction belongs to a later stage and cannot execute now');
94
100
  cursor = edge.to;
95
101
  edges.add(edge.id);
96
102
  if (!['save', 'delete'].includes(edge.kind) && step.mutations.length) designFail('INTERACTION', 'Open, edit, input, navigation and cancel actions must not save data');
97
103
  if (['save', 'delete'].includes(edge.kind)) validateReadBack(step, edge, site.pages.find(item => item.id === edge.to), policy);
104
+ validateDesignHandoffs(step, edge);
98
105
  }
99
106
  if (cursor !== mockups[scenario.mockupIndex].pageId) designFail('COVERAGE', 'The final screen must correspond to its approved mockup');
100
107
  }
101
- if (used.size !== mockups.length || site.edges.some(edge => !edges.has(edge.id))) designFail('COVERAGE', 'Browser scenarios must cover every approved mockup and flow action');
108
+ const required = mockups.map((mockup, index) => ({ mockup, index })).filter(({ mockup }) => executableDesignPages(policy).includes(mockup.pageId));
109
+ if (required.some(({ index }) => !used.has(index)) || executableDesignEdges(policy).some(id => !edges.has(id))) designFail('COVERAGE', 'Browser scenarios must cover every executable mockup and original interaction in this approved acceptance scope');
102
110
  }
103
111
  function validateReadBack(step, edge, page, policy) {
104
112
  const proof = step.readBack;
@@ -128,7 +136,7 @@ export function validateDesignReport(raw, policy) {
128
136
  const count = scenario && scenario.checks.length + scenario.steps.reduce((sum, step) => sum + step.checks.length, 0);
129
137
  if (!scenario || indexes.has(result.mockupIndex) || result.failures.length || result.checks !== count
130
138
  || result.steps !== scenario.steps.length
131
- || result.stateChecks !== scenario.steps.filter(step => step.readBack).length * 2 + Number(scenario.steps.some(step => step.readBack))) designFail('ACCEPTANCE', 'Failed, incomplete or duplicate functional/visual evidence cannot pass');
139
+ || result.stateChecks !== scenario.steps.filter(step => step.readBack || step.storageHandoffs?.length).length * 2 + Number(scenario.steps.some(step => step.readBack))) designFail('ACCEPTANCE', 'Failed, incomplete or duplicate functional/visual evidence cannot pass');
132
140
  indexes.add(result.mockupIndex);
133
141
  }
134
142
  if (new Set(report.sourceManifest.map(file => file.path)).size !== report.sourceManifest.length
@@ -1,6 +1,7 @@
1
1
  export const DESIGN_GATE_SOURCE_FILES = [
2
2
  "design-browser-checks.mjs",
3
3
  "design-browser-identity.mjs",
4
+ "design-browser-pages.mjs",
4
5
  "design-browser-state.mjs",
5
6
  "design-browser.mjs",
6
7
  "design-context.mjs",
@@ -15,10 +16,15 @@ export const DESIGN_GATE_SOURCE_FILES = [
15
16
  "design-policy.d.mts",
16
17
  "design-policy.mjs",
17
18
  "design-research.mjs",
19
+ "design-research-network.mjs",
18
20
  "design-runner.mjs",
19
21
  "design-store.mjs",
22
+ "design-stage.mjs",
23
+ "design-stage-contracts.mjs",
24
+ "design-storage.mjs",
20
25
  "design-ui.mjs",
21
26
  "DESIGN-GATES.md",
27
+ "DESIGN-STAGED-ACCEPTANCE.md",
22
28
  "design-release.mjs"
23
29
  ];
24
30
  export const designGateReleaseLabels = Object.fromEntries(DESIGN_GATE_SOURCE_FILES.map(file => [file,
@@ -0,0 +1,82 @@
1
+ import https from 'node:https';
2
+ import dns from 'node:dns/promises';
3
+ import net from 'node:net';
4
+ import { designFail } from './design-policy.mjs';
5
+
6
+ export function publicResearchIp(address) {
7
+ if (net.isIP(address) !== 4) return false;
8
+ const [a, b, c] = address.split('.').map(Number);
9
+ return ![0, 10, 127].includes(a) && a < 224 && !(a === 169 && b === 254)
10
+ && !(a === 172 && b >= 16 && b <= 31) && !(a === 192 && (b === 168 || b === 0 && [0, 2].includes(c)))
11
+ && !(a === 198 && ([18, 19].includes(b) || b === 51 && c === 100)) && !(a === 203 && b === 0 && c === 113)
12
+ && !(a === 100 && b >= 64 && b <= 127);
13
+ }
14
+ const timeoutError = () => Object.assign(new Error('Research fetch timed out'), { code: 'ENTROROVE_DESIGN_RESEARCH_TIMEOUT' });
15
+ export function researchResponse(url, addresses, { accept = 'text/html,text/plain,application/json', maximumBytes = 2_097_152 } = {}) {
16
+ return new Promise((resolve, reject) => {
17
+ const chunks = []; let bytes = 0, request, settled = false;
18
+ const fail = error => { if (!settled) { settled = true; clearTimeout(timer); reject(error); request?.destroy(error); } };
19
+ const timer = setTimeout(() => fail(timeoutError()), 15_000);
20
+ try {
21
+ request = https.get(url, { agent: false, rejectUnauthorized: true, headers: { accept, 'user-agent': 'Entrorove design research' },
22
+ lookup: (_host, options, callback) => callback(null, options.all ? [addresses[0]] : addresses[0].address, 4) }, response => {
23
+ response.on('data', chunk => {
24
+ bytes += chunk.length;
25
+ if (bytes > maximumBytes) fail(Object.assign(new Error('Research response exceeds its bounded size'), { code: 'ENTROROVE_DESIGN_RESEARCH_SIZE' }));
26
+ else chunks.push(chunk);
27
+ });
28
+ response.on('end', () => {
29
+ if (settled) return;
30
+ settled = true; clearTimeout(timer);
31
+ resolve({ status: response.statusCode, location: response.headers.location, type: response.headers['content-type'], body: Buffer.concat(chunks) });
32
+ });
33
+ response.on('aborted', () => fail(Object.assign(new Error('Research response was interrupted'), { code: 'ENTROROVE_DESIGN_RESEARCH_HTTP' })));
34
+ response.on('error', fail);
35
+ });
36
+ request.setTimeout(15_000, () => fail(timeoutError())); request.on('error', fail);
37
+ } catch (error) { fail(error); }
38
+ });
39
+ }
40
+ const dnsName = value => typeof value === 'string' ? value.toLowerCase().replace(/\.$/, '') : '';
41
+ function publicDnsAnswers(result, hostname) {
42
+ let body; try { body = JSON.parse(result.body.toString('utf8')); } catch { designFail('RESEARCH_DNS', 'Public DNS returned invalid JSON'); }
43
+ if (!body || typeof body !== 'object' || Array.isArray(body)) designFail('RESEARCH_DNS', 'Public DNS returned an invalid response object');
44
+ const name = dnsName(hostname), answers = body.Answer;
45
+ if (result.status !== 200 || !/^application\/(dns-json|json)\b/i.test(result.type ?? '')
46
+ || body.Status !== 0 || body.TC === true || body.CD === true || !Array.isArray(body.Question) || body.Question.length !== 1
47
+ || body.Question[0]?.type !== 1 || dnsName(body.Question[0]?.name) !== name
48
+ || !Array.isArray(answers) || !answers.length || answers.length > 128
49
+ || answers.some(item => !item || ![1, 5].includes(item.type) || typeof item.name !== 'string' || typeof item.data !== 'string')) {
50
+ designFail('RESEARCH_DNS', 'Public DNS did not return its exact complete original question');
51
+ }
52
+ const names = new Set([name]); let current = name;
53
+ for (let depth = 0; depth < 16; depth++) {
54
+ const aliases = answers.filter(item => item.type === 5 && dnsName(item.name) === current);
55
+ if (!aliases.length) break;
56
+ const alias = dnsName(aliases[0].data);
57
+ if (aliases.length !== 1 || !alias || alias.length > 253 || net.isIP(alias) || names.has(alias)
58
+ || alias.split('.').some(part => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(part))) designFail('RESEARCH_DNS', 'Public DNS returned an invalid alias chain');
59
+ names.add(alias); current = alias;
60
+ if (depth === 15) designFail('RESEARCH_DNS', 'Public DNS alias chain exceeds its bound');
61
+ }
62
+ if (answers.some(item => item.type === 1 && !publicResearchIp(item.data))) designFail('RESEARCH_URL', 'Research hostname resolves outside the public internet');
63
+ const addresses = answers.filter(item => item.type === 1 && names.has(dnsName(item.name)))
64
+ .map(item => ({ address: item.data, family: 4 }));
65
+ if (!addresses.length) designFail('RESEARCH_DNS', 'Public DNS has no bound public IPv4 address');
66
+ return addresses;
67
+ }
68
+ export async function publicResearchAddresses(hostname) {
69
+ let timer;
70
+ const addresses = await Promise.race([dns.lookup(hostname, { all: true, family: 4 }),
71
+ new Promise((_resolve, reject) => { timer = setTimeout(() => reject(timeoutError()), 15_000); })]).finally(() => clearTimeout(timer));
72
+ if (addresses.length && addresses.every(item => publicResearchIp(item.address))) return addresses;
73
+ // Some local DNS proxies return RFC2544 virtual addresses. Never connect to
74
+ // them: obtain real answers over certificate-validated HTTPS at a fixed public
75
+ // resolver IP, validate every A record, then pin the documentation connection.
76
+ if (!addresses.length || !addresses.every(item => net.isIP(item.address) === 4 && /^198\.(18|19)\./.test(item.address))) {
77
+ designFail('RESEARCH_URL', 'Research hostname resolves outside the public internet');
78
+ }
79
+ const resolver = new URL('https://1.1.1.1/dns-query'); resolver.searchParams.set('name', hostname); resolver.searchParams.set('type', 'A');
80
+ const result = await researchResponse(resolver, [{ address: '1.1.1.1', family: 4 }], { accept: 'application/dns-json', maximumBytes: 65_536 });
81
+ return publicDnsAnswers(result, hostname);
82
+ }
@@ -1,43 +1,23 @@
1
- import https from 'node:https';
2
- import dns from 'node:dns/promises';
3
1
  import net from 'node:net';
4
2
  import path from 'node:path';
5
3
  import { parse as yaml } from 'yaml';
6
4
  import { mutationRead14, mutationTarget14 } from './local-mutation-files.mjs';
7
5
  import { sha256 } from './local-integrity.mjs';
8
6
  import { designFail } from './design-policy.mjs';
7
+ import { publicResearchAddresses, researchResponse } from './design-research-network.mjs';
9
8
 
10
9
  import { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
11
10
  export { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
12
11
  export function researchUrl(value) {
13
12
  let url; try { url = new URL(value); } catch { designFail('RESEARCH_URL', 'Research requires an absolute public HTTPS URL'); }
14
13
  if (url.protocol !== 'https:' || url.username || url.password || url.port && url.port !== '443' || url.hash
15
- || net.isIP(url.hostname) || /(^|\.)(localhost|local|internal|test|invalid)$/i.test(url.hostname)
14
+ || net.isIP(url.hostname.replace(/^\[|\]$/g, '')) || /(^|\.)(localhost|local|internal|test|invalid)$/i.test(url.hostname)
16
15
  || [...url.searchParams.keys()].some(key => /token|secret|password|key|signature|credential/i.test(key))) designFail('RESEARCH_URL', 'Research URLs must be public, credential-free HTTPS references');
17
16
  return url;
18
17
  }
19
- function publicIp(address) {
20
- if (net.isIP(address) !== 4) return false;
21
- const [a, b, c] = address.split('.').map(Number);
22
- return ![0, 10, 127].includes(a) && a < 224 && !(a === 169 && b === 254)
23
- && !(a === 172 && b >= 16 && b <= 31) && !(a === 192 && (b === 168 || b === 0 && [0, 2].includes(c)))
24
- && !(a === 198 && ([18, 19].includes(b) || b === 51 && c === 100)) && !(a === 203 && b === 0 && c === 113)
25
- && !(a === 100 && b >= 64 && b <= 127);
26
- }
27
18
  export async function fetchDesignReference(value, redirects = 0) {
28
- const url = researchUrl(value), addresses = await dns.lookup(url.hostname, { all: true, family: 4 });
29
- if (!addresses.length || addresses.some(item => !publicIp(item.address))) designFail('RESEARCH_URL', 'Research hostname resolves outside the public internet');
30
- const result = await new Promise((resolve, reject) => {
31
- const req = https.get(url, { headers: { accept: 'text/html,text/plain,application/json', 'user-agent': 'Entrorove design research' },
32
- lookup: (_host, options, callback) => callback(null, options.all ? [addresses[0]] : addresses[0].address, 4) }, response => {
33
- const chunks = []; let bytes = 0;
34
- response.on('data', chunk => { bytes += chunk.length; if (bytes > 2_097_152) req.destroy(Object.assign(new Error('Research response exceeds 2 MiB'), { code: 'ENTROROVE_DESIGN_RESEARCH_SIZE' })); else chunks.push(chunk); });
35
- response.on('end', () => resolve({ status: response.statusCode, location: response.headers.location, type: response.headers['content-type'], body: Buffer.concat(chunks) }));
36
- response.on('error', reject);
37
- });
38
- req.setTimeout(15_000, () => req.destroy(Object.assign(new Error('Research fetch timed out'), { code: 'ENTROROVE_DESIGN_RESEARCH_TIMEOUT' })));
39
- req.on('error', reject);
40
- });
19
+ const url = researchUrl(value), addresses = await publicResearchAddresses(url.hostname);
20
+ const result = await researchResponse(url, addresses);
41
21
  if ([301, 302, 303, 307, 308].includes(result.status)) {
42
22
  if (!result.location || redirects >= 4) designFail('RESEARCH_REDIRECT', 'Research redirect did not resolve to a bounded public reference');
43
23
  return fetchDesignReference(new URL(result.location, url).href, redirects + 1);
@@ -76,7 +56,10 @@ export async function assertDeclaredDesignFrameworks(input, design) {
76
56
  if (!checked.has(name)) {
77
57
  const actual = JSON.parse((await mutationRead14(input, input.repositoryRoot, name)).content.toString('utf8'));
78
58
  const packages = { ...actual.dependencies, ...actual.devDependencies };
79
- if (Object.keys(packages).some(packageName => Object.hasOwn(FRAMEWORK_DOC_HOSTS, packageName) && !declared.has(packageName))) designFail('FRAMEWORK_MISSING', 'Research every actual declared framework in the nearest UI package using its official documentation and locked version');
59
+ if (Object.keys(packages).some(packageName => Object.hasOwn(FRAMEWORK_DOC_HOSTS, packageName)
60
+ && !design.frameworks.some(framework => framework.packageName === packageName && framework.packagePath === name))) {
61
+ designFail('FRAMEWORK_MISSING', 'Research every actual declared framework in its nearest UI package using that package file and locked version');
62
+ }
80
63
  checked.add(name);
81
64
  }
82
65
  break;
package/design-runner.mjs CHANGED
@@ -3,10 +3,12 @@ import { assertDesignDeclaration } from './design-declaration.mjs';
3
3
  import { assertDesignWrite } from './design-gate.mjs';
4
4
  import { designUiCandidate } from './design-ui.mjs';
5
5
  import { designFail } from './design-policy.mjs';
6
+ import { assertDesignStageSources } from './design-stage.mjs';
6
7
 
7
8
  export async function designRunnerBefore(input, authority) {
8
9
  const policy = authority.plan.policy.discipline, originals = new Map();
9
10
  if (!policy || policy.access === 'read-only') return originals;
11
+ if (policy.design) await assertDesignStageSources(input, policy.design);
10
12
  assertDesignDeclaration(policy, policy.writes.flatMap(write => [write.targetPath, ...(write.destinationPath ? [write.destinationPath] : [])]));
11
13
  for (const write of policy.writes) {
12
14
  const target = await mutationTarget14(authority.root, write.targetPath, true);
@@ -19,6 +21,7 @@ export async function designRunnerBefore(input, authority) {
19
21
  }
20
22
  export async function designRunnerAfter(input, authority, changed, originals) {
21
23
  const policy = authority.plan.policy.discipline;
24
+ if (policy?.design) await assertDesignStageSources(input, policy.design);
22
25
  for (const name of changed) {
23
26
  const target = await mutationTarget14(authority.root, name, true);
24
27
  const source = target.exists && target.stat.size <= 2_097_152
@@ -0,0 +1,12 @@
1
+ import { object, array, text, identifier, relativePath, sha256 } from './local-contract-types.mjs';
2
+
3
+ const names = min => ({ ...array(identifier, min), maxItems: 256, uniqueItems: true });
4
+ const source = object({ path: relativePath, sha256 }, ['path', 'sha256']);
5
+ export const DESIGN_STAGE_SCHEMA = object({ id: identifier, requirementIds: names(1), pageIds: names(1), edgeIds: names(0),
6
+ readOnlyPages: { ...array(object({ pageId: identifier, sources: { ...array(source, 1), maxItems: 128 } }, ['pageId', 'sources']), 0), maxItems: 128 } },
7
+ ['id', 'requirementIds', 'pageIds', 'edgeIds', 'readOnlyPages']);
8
+ const value = { anyOf: [{ type: 'string', maxLength: 8192 }, { type: 'null' }] };
9
+ export const DESIGN_HANDOFF_SCHEMA = object({ area: { enum: ['local-storage', 'session-storage'] },
10
+ key: { ...text, maxLength: 128, pattern: '^[^\\u0000-\\u001f\\u007f]+$' },
11
+ method: { enum: ['setItem', 'removeItem'] }, before: value, after: value, count: { const: 1 } },
12
+ ['area', 'key', 'method', 'before', 'after', 'count']);