@entrorove/skill 0.1.62 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY-MAP.md +30 -2
- package/CHANGELOG.md +25 -0
- package/DELIVERY-BOUNDARIES.md +2 -0
- package/DESIGN-GATES.md +160 -0
- package/IDE-EXECUTION.md +32 -3
- package/REUSE-DISCIPLINE.md +16 -2
- package/SKILL.md +9 -3
- package/blueprint-criterion-runner.mjs +1 -1
- package/cli-input.mjs +22 -0
- package/cli.mjs +13 -12
- package/cloud-task-client.mjs +10 -2
- package/cloud-task-images.mjs +39 -0
- package/design-browser-checks.mjs +86 -0
- package/design-browser-identity.mjs +13 -0
- package/design-browser-state.mjs +41 -0
- package/design-browser.mjs +181 -0
- package/design-context.mjs +34 -0
- package/design-contracts.d.mts +4 -0
- package/design-contracts.mjs +77 -0
- package/design-declaration.mjs +17 -0
- package/design-frameworks.mjs +4 -0
- package/design-gate.mjs +19 -0
- package/design-lint.mjs +115 -0
- package/design-local-contracts.mjs +15 -0
- package/design-operations.mjs +81 -0
- package/design-policy.d.mts +5 -0
- package/design-policy.mjs +137 -0
- package/design-release.mjs +25 -0
- package/design-research.mjs +99 -0
- package/design-runner.mjs +31 -0
- package/design-store.mjs +116 -0
- package/design-ui.mjs +24 -0
- package/ide-exit.mjs +49 -9
- package/ide-hook.mjs +2 -2
- package/install-identity.mjs +16 -0
- package/local-cli.mjs +3 -1
- package/local-contracts.mjs +6 -1
- package/local-discipline-actions.mjs +5 -0
- package/local-discipline-batch.mjs +12 -3
- package/local-discipline-contracts.mjs +15 -3
- package/local-discipline-gate.mjs +17 -2
- package/local-discipline-repository.mjs +11 -5
- package/local-error.mjs +18 -1
- package/local-execution.mjs +2 -1
- package/local-finder-plist.mjs +87 -0
- package/local-finder-store.mjs +115 -0
- package/local-mutation-authority.mjs +13 -0
- package/local-mutation-completion-evidence.mjs +83 -0
- package/local-mutation-consumption.mjs +33 -10
- package/local-mutation-environment-observation.mjs +85 -0
- package/local-mutation-finder-observation.mjs +58 -0
- package/local-mutation-wire.mjs +26 -8
- package/local-runner-discipline.mjs +17 -3
- package/local-scope-files.mjs +3 -1
- package/local-scope-font.mjs +45 -0
- package/local-scope-repository.mjs +16 -3
- package/local-scope-zip.mjs +114 -0
- package/manifest.json +1 -1
- package/member-producer-repair-authority.mjs +48 -0
- package/model-images.d.mts +9 -0
- package/model-images.mjs +87 -0
- package/mutation-challenge-refresh.mjs +51 -0
- package/mutation-client.mjs +42 -24
- package/mutation-connection-state.mjs +17 -0
- package/package-archive.mjs +54 -0
- package/package-replacement.mjs +48 -0
- package/package.json +50 -6
- package/release-state.json +87 -45
- package/release.mjs +11 -1
- package/releases.json +30 -0
- package/remote-client.mjs +1 -1
- package/remote-transport.mjs +48 -16
- package/reuse-discipline-release.mjs +3 -1
- package/rollback-history.mjs +51 -0
- package/rollback-runner.mjs +77 -0
- package/scope-amendment-client.mjs +1 -1
- package/scope-amendment-consumption.mjs +32 -0
- package/scope-amendment-mutation.mjs +2 -3
- package/scope-amendment-observer.mjs +2 -1
- package/scope-amendment-remote.mjs +12 -2
- package/scope-amendment-retirement.mjs +54 -0
- package/scope-amendment-state.mjs +7 -4
- package/task-entry-client.mjs +1 -0
- package/version.mjs +20 -3
- package/workflow-local-runner.mjs +9 -0
- package/workflow-observation-client.mjs +11 -0
- package/workflow-observation-connection.mjs +2 -1
- package/workflow-observation-remote.mjs +26 -1
- package/workflow-observation-wire.mjs +6 -3
|
@@ -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>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { canonical, sha256 } from './local-integrity.mjs';
|
|
2
|
+
import { designSchema } from './design-contracts.mjs';
|
|
3
|
+
import { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
|
|
4
|
+
|
|
5
|
+
export function designFail(code, message) { throw Object.assign(new Error(message), { code: `ENTROROVE_DESIGN_${code}` }); }
|
|
6
|
+
export const designDigest = value => sha256(canonical(value));
|
|
7
|
+
export function validateDesignSite(raw) {
|
|
8
|
+
const site = designSchema('site', raw), pages = new Map(site.pages.map(page => [page.id, page]));
|
|
9
|
+
if (pages.size !== site.pages.length || new Set(site.edges.map(edge => edge.id)).size !== site.edges.length
|
|
10
|
+
|| !site.pages.some(page => page.entry) || new Set(site.roles).size !== site.roles.length) designFail('FLOW', 'Whole-site roles, pages, edges and entry points must be unique');
|
|
11
|
+
for (const page of site.pages) {
|
|
12
|
+
if (page.roles.some(role => !site.roles.includes(role)) || !page.states.includes('default')
|
|
13
|
+
|| new Set(page.states).size !== page.states.length || page.publicFields.some(field => page.internalFields.includes(field))) {
|
|
14
|
+
designFail('FLOW', 'Every page needs original roles, a default state and disjoint public/internal field contracts');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const edge of site.edges) if (!pages.has(edge.from) || !pages.has(edge.to)) designFail('FLOW', 'Every action must connect declared pages');
|
|
18
|
+
const reachable = new Set(site.pages.filter(page => page.entry).map(page => page.id));
|
|
19
|
+
for (let i = 0; i < pages.size; i++) for (const edge of site.edges) if (reachable.has(edge.from)) reachable.add(edge.to);
|
|
20
|
+
if (reachable.size !== pages.size) designFail('FLOW', 'Whole-site flow contains an unreachable page');
|
|
21
|
+
for (const page of site.pages) if (site.edges.some(edge => edge.from === page.id && ['save', 'delete'].includes(edge.kind))
|
|
22
|
+
&& (!page.states.includes('error') || !site.edges.some(edge => edge.from === page.id && edge.kind === 'cancel'))) {
|
|
23
|
+
designFail('FLOW', 'Mutation pages require explicit failure and cancel paths');
|
|
24
|
+
}
|
|
25
|
+
return site;
|
|
26
|
+
}
|
|
27
|
+
export function designBaselineDigest(value) { const { baselineDigest, ...body } = value; return designDigest(body); }
|
|
28
|
+
const imageKey = mockup => `${mockup.pageId}/${mockup.stateId}/${mockup.theme}/${mockup.viewport.width}x${mockup.viewport.height}`;
|
|
29
|
+
function validateDesignSources(policy) {
|
|
30
|
+
for (const source of policy.sources) {
|
|
31
|
+
for (const value of [source.url, source.finalUrl]) {
|
|
32
|
+
let url; try { url = new URL(value); } catch { designFail('RESEARCH', 'Research needs actual public HTTPS references'); }
|
|
33
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash || url.port && url.port !== '443'
|
|
34
|
+
|| !/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(url.hostname)
|
|
35
|
+
|| /(^|\.)(localhost|local|internal|test|invalid)$/.test(url.hostname)
|
|
36
|
+
|| [...url.searchParams.keys()].some(key => /token|secret|password|key|signature|credential/i.test(key))) designFail('RESEARCH', 'Keep credentials and private references out of design evidence');
|
|
37
|
+
}
|
|
38
|
+
const retrieved = Date.parse(source.retrievedAt), recorded = Date.parse(policy.researchRecordedAt);
|
|
39
|
+
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
|
+
if (new Set(policy.frameworks.map(framework => framework.packageName)).size !== policy.frameworks.length) designFail('FRAMEWORK', 'Framework versions must be unambiguous');
|
|
42
|
+
for (const framework of policy.frameworks) if (!FRAMEWORK_DOC_HOSTS[framework.packageName]
|
|
43
|
+
|| framework.officialUrls.some(url => !FRAMEWORK_DOC_HOSTS[framework.packageName].includes(new URL(url).hostname))) designFail('FRAMEWORK', 'Use the declared framework official documentation hosts');
|
|
44
|
+
}
|
|
45
|
+
export function validateDesignPolicy(raw) {
|
|
46
|
+
const policy = designSchema('policy', raw), { site, rules } = policy;
|
|
47
|
+
let preview; try { preview = new URL(rules.previewOrigin); } catch { designFail('PREVIEW', 'Preview must be an exact isolated local origin'); }
|
|
48
|
+
if (preview.href !== rules.previewOrigin || preview.hostname !== '127.0.0.1' || !preview.port) designFail('PREVIEW', 'Approve the exact isolated preview before UI implementation');
|
|
49
|
+
validateDesignSite(site);
|
|
50
|
+
if (policy.baselineDigest !== designBaselineDigest(policy) || policy.flowDigest !== designDigest(site)) designFail('BASELINE_CHANGED', 'Design bytes differ from the exact proposal baseline');
|
|
51
|
+
const times = [policy.flowRecordedAt, policy.researchRecordedAt, policy.mockupsRecordedAt].map(Date.parse);
|
|
52
|
+
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');
|
|
53
|
+
validateDesignSources(policy);
|
|
54
|
+
const layouts = policy.sources.filter(source => source.purpose === 'layout');
|
|
55
|
+
if (new Set(layouts.map(source => new URL(source.finalUrl).hostname)).size < 2) designFail('RESEARCH', 'Use current layout references from at least two independent sites with suitability reasons');
|
|
56
|
+
for (const framework of policy.frameworks) for (const url of framework.officialUrls) {
|
|
57
|
+
if (!policy.sources.some(source => source.purpose === 'framework' && source.finalUrl === url)) designFail('FRAMEWORK', 'Every declared framework needs fetched official documentation');
|
|
58
|
+
}
|
|
59
|
+
if (!rules.uiPaths.includes(rules.tokenPath) || rules.stylePaths.some(p => !rules.uiPaths.includes(p))
|
|
60
|
+
|| rules.uiPaths.some(p => !rules.verificationPaths.includes(p))) designFail('RULES', 'Verification covers every UI file; token and style sources belong to the approved UI paths');
|
|
61
|
+
if (new Set(policy.sourceBaseline.map(file => file.path)).size !== rules.uiPaths.length
|
|
62
|
+
|| policy.sourceBaseline.length !== rules.uiPaths.length
|
|
63
|
+
|| rules.uiPaths.some(name => !policy.sourceBaseline.some(file => file.path === name))) designFail('BASELINE', 'Every UI source needs its original existing or absent baseline before implementation');
|
|
64
|
+
const keys = policy.mockups.map(imageKey);
|
|
65
|
+
if (new Set(keys).size !== keys.length) designFail('MOCKUPS', 'Duplicate mockups cannot substitute for page and state coverage');
|
|
66
|
+
for (const mockup of policy.mockups) {
|
|
67
|
+
const page = site.pages.find(item => item.id === mockup.pageId);
|
|
68
|
+
if (!page?.states.includes(mockup.stateId)) designFail('MOCKUPS', 'Mockups must map to real flow pages and states');
|
|
69
|
+
}
|
|
70
|
+
for (const page of site.pages) for (const state of page.states) for (const theme of ['light', 'dark']) {
|
|
71
|
+
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
|
+
}
|
|
73
|
+
validateDesignScenarios(policy);
|
|
74
|
+
return policy;
|
|
75
|
+
}
|
|
76
|
+
function validateDesignScenarios(policy) {
|
|
77
|
+
const { site, rules, mockups } = policy, used = new Set(), edges = new Set();
|
|
78
|
+
for (const scenario of rules.scenarios) {
|
|
79
|
+
if (!mockups[scenario.mockupIndex] || used.has(scenario.mockupIndex)) designFail('COVERAGE', 'Each mockup needs exactly one executable browser scenario');
|
|
80
|
+
used.add(scenario.mockupIndex);
|
|
81
|
+
const page = site.pages.find(item => item.id === scenario.entryPageId);
|
|
82
|
+
if (!page || !scenario.route.startsWith('/') || scenario.route.startsWith('//') || /[\\\r\n]/.test(scenario.route)) designFail('COVERAGE', 'Browser scenarios require a bounded local route');
|
|
83
|
+
const assertions = [...scenario.checks, ...scenario.steps.flatMap(step => [...step.checks, ...(step.readBack?.afterReload ?? [])])];
|
|
84
|
+
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
|
+
for (const check of assertions) {
|
|
86
|
+
if (['text', 'value', 'style'].includes(check.kind) && typeof check.value !== 'string'
|
|
87
|
+
|| check.kind === 'count' && !Number.isInteger(check.count) || check.kind === 'bounds' && !check.bounds
|
|
88
|
+
|| check.kind === 'style' && !check.property) designFail('CHECKS', 'Browser assertions require exact expected values');
|
|
89
|
+
}
|
|
90
|
+
let cursor = page.id;
|
|
91
|
+
for (const step of scenario.steps) {
|
|
92
|
+
const edge = site.edges.find(item => item.id === step.edgeId);
|
|
93
|
+
if (!edge || edge.from !== cursor) designFail('COVERAGE', 'Browser actions must follow the original whole-site page transitions');
|
|
94
|
+
cursor = edge.to;
|
|
95
|
+
edges.add(edge.id);
|
|
96
|
+
if (!['save', 'delete'].includes(edge.kind) && step.mutations.length) designFail('INTERACTION', 'Open, edit, input, navigation and cancel actions must not save data');
|
|
97
|
+
if (['save', 'delete'].includes(edge.kind)) validateReadBack(step, edge, site.pages.find(item => item.id === edge.to), policy);
|
|
98
|
+
}
|
|
99
|
+
if (cursor !== mockups[scenario.mockupIndex].pageId) designFail('COVERAGE', 'The final screen must correspond to its approved mockup');
|
|
100
|
+
}
|
|
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');
|
|
102
|
+
}
|
|
103
|
+
function validateReadBack(step, edge, page, policy) {
|
|
104
|
+
const proof = step.readBack;
|
|
105
|
+
if (!proof || proof.kind === 'http' && (!step.mutations.length || !proof.path || !proof.statusBefore || !proof.statusAfter)
|
|
106
|
+
|| proof.kind === 'local-storage' && (!proof.key || step.mutations.length)) designFail('STATE_PROOF', 'A save/delete needs actual HTTP or local storage state read-back and reload assertions');
|
|
107
|
+
if (proof.kind === 'http') {
|
|
108
|
+
const target = new URL(proof.path, policy.rules.previewOrigin);
|
|
109
|
+
if (target.origin !== new URL(policy.rules.previewOrigin).origin || /[\\\r\n]/.test(proof.path)) designFail('STATE_PROOF', 'Read-back stays inside the approved isolated preview');
|
|
110
|
+
}
|
|
111
|
+
const succeeded = proof.kind === 'local-storage' || step.mutations.some(item => item.status < 300);
|
|
112
|
+
if (succeeded === (canonical(proof.before) === canonical(proof.after))) designFail('STATE_PROOF', 'Successful commits must change the declared state; failed commits must preserve it');
|
|
113
|
+
for (const expected of [proof.before, proof.after]) if (expected && Object.keys(expected).some(key => !page.publicFields.includes(key))) designFail('STATE_PROOF', 'Only the original public field projection can be checked or rendered');
|
|
114
|
+
if (proof.after && Object.keys(proof.after).some(field => !proof.afterReload.some(check => check.field === field && ['text', 'value'].includes(check.kind)))
|
|
115
|
+
|| !proof.after && !proof.afterReload.some(check => check.kind === 'hidden')) designFail('STATE_PROOF', 'Reload must verify every saved public field or the removed record');
|
|
116
|
+
}
|
|
117
|
+
export function validateDesignReport(raw, policy) {
|
|
118
|
+
const report = designSchema('report', raw), { reportDigest, ...body } = report;
|
|
119
|
+
validateDesignPolicy(policy);
|
|
120
|
+
if (designDigest(body) !== reportDigest || report.baselineDigest !== policy.baselineDigest
|
|
121
|
+
|| report.results.length !== policy.rules.scenarios.length || !Number.isFinite(Date.parse(report.startedAt))
|
|
122
|
+
|| !Number.isFinite(Date.parse(report.finishedAt)) || Date.parse(report.finishedAt) < Date.parse(report.startedAt)
|
|
123
|
+
|| Date.parse(report.startedAt) < Date.parse(policy.mockupsRecordedAt)
|
|
124
|
+
|| Date.parse(report.finishedAt) > Date.now() + 5000 || !/^playwright-core\/1\.64\.1 chromium\/[0-9]/.test(report.engine)) designFail('ACCEPTANCE', 'Browser report lost its exact design baseline or coverage');
|
|
125
|
+
const indexes = new Set();
|
|
126
|
+
for (const result of report.results) {
|
|
127
|
+
const scenario = policy.rules.scenarios.find(item => item.mockupIndex === result.mockupIndex);
|
|
128
|
+
const count = scenario && scenario.checks.length + scenario.steps.reduce((sum, step) => sum + step.checks.length, 0);
|
|
129
|
+
if (!scenario || indexes.has(result.mockupIndex) || result.failures.length || result.checks !== count
|
|
130
|
+
|| 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');
|
|
132
|
+
indexes.add(result.mockupIndex);
|
|
133
|
+
}
|
|
134
|
+
if (new Set(report.sourceManifest.map(file => file.path)).size !== report.sourceManifest.length
|
|
135
|
+
|| policy.rules.uiPaths.some(name => !report.sourceManifest.some(file => file.path === name))) designFail('ACCEPTANCE', 'Acceptance must bind every approved UI file');
|
|
136
|
+
return report;
|
|
137
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const DESIGN_GATE_SOURCE_FILES = [
|
|
2
|
+
"design-browser-checks.mjs",
|
|
3
|
+
"design-browser-identity.mjs",
|
|
4
|
+
"design-browser-state.mjs",
|
|
5
|
+
"design-browser.mjs",
|
|
6
|
+
"design-context.mjs",
|
|
7
|
+
"design-contracts.d.mts",
|
|
8
|
+
"design-contracts.mjs",
|
|
9
|
+
"design-declaration.mjs",
|
|
10
|
+
"design-frameworks.mjs",
|
|
11
|
+
"design-gate.mjs",
|
|
12
|
+
"design-lint.mjs",
|
|
13
|
+
"design-local-contracts.mjs",
|
|
14
|
+
"design-operations.mjs",
|
|
15
|
+
"design-policy.d.mts",
|
|
16
|
+
"design-policy.mjs",
|
|
17
|
+
"design-research.mjs",
|
|
18
|
+
"design-runner.mjs",
|
|
19
|
+
"design-store.mjs",
|
|
20
|
+
"design-ui.mjs",
|
|
21
|
+
"DESIGN-GATES.md",
|
|
22
|
+
"design-release.mjs"
|
|
23
|
+
];
|
|
24
|
+
export const designGateReleaseLabels = Object.fromEntries(DESIGN_GATE_SOURCE_FILES.map(file => [file,
|
|
25
|
+
{ zh: "全站流程、设计确认与真实功能视觉验收门禁", en: "whole-site flow, design approval and actual functional/visual acceptance gates" }]));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import dns from 'node:dns/promises';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parse as yaml } from 'yaml';
|
|
6
|
+
import { mutationRead14, mutationTarget14 } from './local-mutation-files.mjs';
|
|
7
|
+
import { sha256 } from './local-integrity.mjs';
|
|
8
|
+
import { designFail } from './design-policy.mjs';
|
|
9
|
+
|
|
10
|
+
import { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
|
|
11
|
+
export { FRAMEWORK_DOC_HOSTS } from './design-frameworks.mjs';
|
|
12
|
+
export function researchUrl(value) {
|
|
13
|
+
let url; try { url = new URL(value); } catch { designFail('RESEARCH_URL', 'Research requires an absolute public HTTPS URL'); }
|
|
14
|
+
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)
|
|
16
|
+
|| [...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
|
+
return url;
|
|
18
|
+
}
|
|
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
|
+
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
|
+
});
|
|
41
|
+
if ([301, 302, 303, 307, 308].includes(result.status)) {
|
|
42
|
+
if (!result.location || redirects >= 4) designFail('RESEARCH_REDIRECT', 'Research redirect did not resolve to a bounded public reference');
|
|
43
|
+
return fetchDesignReference(new URL(result.location, url).href, redirects + 1);
|
|
44
|
+
}
|
|
45
|
+
if (result.status !== 200 || !/^(text\/(html|plain)|application\/json)\b/i.test(result.type ?? '') || result.body.length < 80) designFail('RESEARCH_HTTP', 'Official and layout references must return actual readable documentation');
|
|
46
|
+
return { finalUrl: url.href, sha256: sha256(result.body), bytes: result.body.length, retrievedAt: new Date().toISOString() };
|
|
47
|
+
}
|
|
48
|
+
function lockedVersion(raw, lockPath, packageName, packagePath) {
|
|
49
|
+
if (path.basename(lockPath) === 'package-lock.json') {
|
|
50
|
+
const lock = JSON.parse(raw), prefix = path.posix.dirname(packagePath), base = prefix === '.' ? '' : `${prefix}/`;
|
|
51
|
+
return lock.packages?.[`${base}node_modules/${packageName}`]?.version ?? lock.packages?.[`node_modules/${packageName}`]?.version;
|
|
52
|
+
}
|
|
53
|
+
if (path.basename(lockPath) === 'pnpm-lock.yaml') {
|
|
54
|
+
const lock = yaml(raw), importer = lock.importers?.[path.posix.dirname(packagePath)], entry = importer?.dependencies?.[packageName] ?? importer?.devDependencies?.[packageName];
|
|
55
|
+
return String(entry?.version ?? '').replace(/\(.*/, '');
|
|
56
|
+
}
|
|
57
|
+
designFail('FRAMEWORK_LOCK', 'Resolve the declared framework from package-lock.json or pnpm-lock.yaml; unsupported locks stay unverified');
|
|
58
|
+
}
|
|
59
|
+
export async function frameworkBinding(input, framework) {
|
|
60
|
+
const hosts = FRAMEWORK_DOC_HOSTS[framework.packageName];
|
|
61
|
+
if (!hosts || !framework.officialUrls?.length || framework.officialUrls.some(value => !hosts.includes(researchUrl(value).hostname))) designFail('FRAMEWORK', 'Declared frameworks require recognized official documentation hosts');
|
|
62
|
+
const pkg = await mutationRead14(input, input.repositoryRoot, framework.packagePath), lock = await mutationRead14(input, input.repositoryRoot, framework.lockPath);
|
|
63
|
+
const declared = JSON.parse(pkg.content.toString('utf8'));
|
|
64
|
+
if (!(declared.dependencies?.[framework.packageName] ?? declared.devDependencies?.[framework.packageName])
|
|
65
|
+
|| lockedVersion(lock.content.toString('utf8'), framework.lockPath, framework.packageName, framework.packagePath) !== framework.version) designFail('FRAMEWORK_VERSION', 'Official framework conventions must bind the exact version in the actual dependency lock');
|
|
66
|
+
return { ...framework, packageSha256: pkg.sha256, lockSha256: lock.sha256 };
|
|
67
|
+
}
|
|
68
|
+
export async function assertDeclaredDesignFrameworks(input, design) {
|
|
69
|
+
const checked = new Set(), declared = new Set(design.frameworks.map(framework => framework.packageName));
|
|
70
|
+
for (const source of design.rules.uiPaths) {
|
|
71
|
+
let directory = path.posix.dirname(source);
|
|
72
|
+
while (true) {
|
|
73
|
+
const name = directory === '.' ? 'package.json' : `${directory}/package.json`;
|
|
74
|
+
const file = await mutationTarget14(input.repositoryRoot, name, true);
|
|
75
|
+
if (file.exists) {
|
|
76
|
+
if (!checked.has(name)) {
|
|
77
|
+
const actual = JSON.parse((await mutationRead14(input, input.repositoryRoot, name)).content.toString('utf8'));
|
|
78
|
+
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');
|
|
80
|
+
checked.add(name);
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (directory === '.') break;
|
|
85
|
+
directory = path.posix.dirname(directory);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const module of design.rules.componentModules) if (!module.module.startsWith('.')
|
|
89
|
+
&& !design.frameworks.some(framework => module.module === framework.packageName || module.module.startsWith(`${framework.packageName}/`))) designFail('FRAMEWORK_MISSING', 'External component libraries require their own declared official framework documentation');
|
|
90
|
+
for (const module of design.rules.frameworkStyleImports ?? []) if (!design.frameworks.some(framework => module.startsWith(`${framework.packageName}/`))) designFail('FRAMEWORK_MISSING', 'Framework theme imports must belong to an approved, version-bound framework');
|
|
91
|
+
for (const name of design.rules.uiPaths) if (/\.vue$/i.test(name) && !declared.has('vue') || /\.svelte$/i.test(name) && !declared.has('svelte')) designFail('FRAMEWORK_MISSING', 'Framework-specific UI source cannot skip its official conventions');
|
|
92
|
+
}
|
|
93
|
+
export async function assertFrozenDesignFrameworks(input, design) {
|
|
94
|
+
await assertDeclaredDesignFrameworks(input, design);
|
|
95
|
+
for (const framework of design.frameworks) {
|
|
96
|
+
const bound = await frameworkBinding(input, framework);
|
|
97
|
+
if (bound.packageSha256 !== framework.packageSha256 || bound.lockSha256 !== framework.lockSha256) designFail('FRAMEWORK_CHANGED', 'Framework version or dependency bytes changed after design confirmation');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mutationRead14, mutationTarget14 } from './local-mutation-files.mjs';
|
|
2
|
+
import { assertDesignDeclaration } from './design-declaration.mjs';
|
|
3
|
+
import { assertDesignWrite } from './design-gate.mjs';
|
|
4
|
+
import { designUiCandidate } from './design-ui.mjs';
|
|
5
|
+
import { designFail } from './design-policy.mjs';
|
|
6
|
+
|
|
7
|
+
export async function designRunnerBefore(input, authority) {
|
|
8
|
+
const policy = authority.plan.policy.discipline, originals = new Map();
|
|
9
|
+
if (!policy || policy.access === 'read-only') return originals;
|
|
10
|
+
assertDesignDeclaration(policy, policy.writes.flatMap(write => [write.targetPath, ...(write.destinationPath ? [write.destinationPath] : [])]));
|
|
11
|
+
for (const write of policy.writes) {
|
|
12
|
+
const target = await mutationTarget14(authority.root, write.targetPath, true);
|
|
13
|
+
const source = target.exists ? (await mutationRead14(input, authority.root, write.targetPath)).content.toString('utf8') : '';
|
|
14
|
+
originals.set(write.targetPath, source);
|
|
15
|
+
if (designUiCandidate(write.targetPath, source)) await assertDesignWrite(input,
|
|
16
|
+
{ flowId: input.flowId, disciplinePolicy: policy }, write.targetPath, source, source);
|
|
17
|
+
}
|
|
18
|
+
return originals;
|
|
19
|
+
}
|
|
20
|
+
export async function designRunnerAfter(input, authority, changed, originals) {
|
|
21
|
+
const policy = authority.plan.policy.discipline;
|
|
22
|
+
for (const name of changed) {
|
|
23
|
+
const target = await mutationTarget14(authority.root, name, true);
|
|
24
|
+
const source = target.exists && target.stat.size <= 2_097_152
|
|
25
|
+
? (await mutationRead14(input, authority.root, name)).content.toString('utf8') : '';
|
|
26
|
+
const before = originals.get(name) ?? '';
|
|
27
|
+
if (!designUiCandidate(name, before, source) && !policy?.design?.rules.uiPaths.includes(name)) continue;
|
|
28
|
+
if (!policy?.writes.some(write => write.targetPath === name || write.destinationPath === name)) designFail('UNDECLARED_WRITE', 'Runner changed an undeclared UI source; preserve the failed run and its unknown effects');
|
|
29
|
+
await assertDesignWrite(input, { flowId: input.flowId, disciplinePolicy: policy }, name, before, source);
|
|
30
|
+
}
|
|
31
|
+
}
|