@entrorove/skill 0.1.64 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY-MAP.md +23 -0
- package/CHANGELOG.md +15 -0
- package/DELIVERY-BOUNDARIES.md +2 -0
- package/DESIGN-GATES.md +160 -0
- package/REUSE-DISCIPLINE.md +15 -1
- package/SKILL.md +4 -2
- package/blueprint-criterion-runner.mjs +1 -1
- package/cli.mjs +11 -4
- package/design-browser-checks.mjs +86 -0
- package/design-browser-identity.mjs +13 -0
- package/design-browser-state.mjs +41 -0
- package/design-browser.mjs +181 -0
- package/design-context.mjs +34 -0
- package/design-contracts.d.mts +4 -0
- package/design-contracts.mjs +77 -0
- package/design-declaration.mjs +17 -0
- package/design-frameworks.mjs +4 -0
- package/design-gate.mjs +19 -0
- package/design-lint.mjs +115 -0
- package/design-local-contracts.mjs +15 -0
- package/design-operations.mjs +81 -0
- package/design-policy.d.mts +5 -0
- package/design-policy.mjs +137 -0
- package/design-release.mjs +25 -0
- package/design-research.mjs +99 -0
- package/design-runner.mjs +31 -0
- package/design-store.mjs +116 -0
- package/design-ui.mjs +24 -0
- package/install-identity.mjs +16 -0
- package/local-cli.mjs +3 -1
- package/local-contracts.mjs +2 -0
- package/local-discipline-actions.mjs +5 -0
- package/local-discipline-batch.mjs +12 -3
- package/local-discipline-contracts.mjs +15 -3
- package/local-discipline-gate.mjs +9 -2
- package/local-error.mjs +18 -1
- package/local-execution.mjs +2 -1
- package/local-mutation-authority.mjs +2 -0
- package/local-runner-discipline.mjs +17 -3
- package/local-scope-zip.mjs +21 -0
- package/manifest.json +1 -1
- package/package-archive.mjs +54 -0
- package/package-replacement.mjs +48 -0
- package/package.json +34 -6
- package/release-state.json +50 -24
- package/release.mjs +3 -1
- package/releases.json +18 -0
- package/reuse-discipline-release.mjs +3 -1
- package/rollback-history.mjs +51 -0
- package/rollback-runner.mjs +77 -0
- package/version.mjs +20 -3
- package/workflow-local-runner.mjs +9 -0
|
@@ -0,0 +1,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
|
+
}
|
package/design-store.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { inflateSync } from 'node:zlib';
|
|
4
|
+
import { mutationRoot14, mutationStateRoot14, mutationTarget14, mutationRead14, privateMutationDirectory14, readMutationJson14,
|
|
5
|
+
writeMutationFile14, atomicMutationJson14, mutationId14 } from './local-mutation-files.mjs';
|
|
6
|
+
import { regular } from './local-task-handoff-storage.mjs';
|
|
7
|
+
import { sha256, canonical } from './local-integrity.mjs';
|
|
8
|
+
import { designFail, validateDesignSite } from './design-policy.mjs';
|
|
9
|
+
|
|
10
|
+
export async function designDirectory(input) {
|
|
11
|
+
const root = await mutationRoot14(input.repositoryRoot), state = await mutationStateRoot14(input);
|
|
12
|
+
mutationId14(input.flowId, 'flowId'); mutationId14(input.designId, 'designId');
|
|
13
|
+
if (!Number.isSafeInteger(input.revision) || input.revision < 1) designFail('INVALID', 'A positive design revision is required');
|
|
14
|
+
const directory = path.join(state, 'design', sha256(root).slice(0, 32), input.flowId, input.designId, `r${input.revision}`);
|
|
15
|
+
await privateMutationDirectory14(directory, { privateFrom: state });
|
|
16
|
+
return directory;
|
|
17
|
+
}
|
|
18
|
+
export async function designSave(directory, name, value) {
|
|
19
|
+
const file = path.join(directory, name);
|
|
20
|
+
await atomicMutationJson14(file, value);
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
export async function designLoad(directory, name) { return readMutationJson14(path.join(directory, name)); }
|
|
24
|
+
export async function designArtifact(file, expected) {
|
|
25
|
+
const { bytes, stat } = await regular(file, 8_388_608);
|
|
26
|
+
if (stat.nlink !== 1 || stat.mode & 0o111 || expected && (sha256(bytes) !== expected.sha256 || bytes.length !== expected.bytes)) designFail('ARTIFACT_CHANGED', 'Design evidence must retain its exact non-executable, single-link bytes');
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
29
|
+
export async function designCopy(directory, name, file) {
|
|
30
|
+
const bytes = await designArtifact(file), target = path.join(directory, name);
|
|
31
|
+
await writeMutationFile14(target, bytes);
|
|
32
|
+
return { path: target, sha256: sha256(bytes), bytes: bytes.length };
|
|
33
|
+
}
|
|
34
|
+
function crc32(bytes) {
|
|
35
|
+
let crc = 0xffffffff;
|
|
36
|
+
for (const byte of bytes) { crc ^= byte; for (let i = 0; i < 8; i++) crc = crc & 1 ? 0xedb88320 ^ crc >>> 1 : crc >>> 1; }
|
|
37
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
38
|
+
}
|
|
39
|
+
export function designPng(bytes) {
|
|
40
|
+
if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) designFail('IMAGE', 'Generated design images must be complete PNG files');
|
|
41
|
+
let offset = 8, header, end = false, data = [];
|
|
42
|
+
while (offset + 12 <= bytes.length) {
|
|
43
|
+
const length = bytes.readUInt32BE(offset), type = bytes.toString('ascii', offset + 4, offset + 8), next = offset + length + 12;
|
|
44
|
+
if (next > bytes.length || crc32(bytes.subarray(offset + 4, next - 4)) !== bytes.readUInt32BE(next - 4)) designFail('IMAGE', 'PNG bounds or checksum failed');
|
|
45
|
+
if (offset === 8 && type !== 'IHDR' || type === 'IHDR' && (header || length !== 13)) designFail('IMAGE', 'PNG header is invalid');
|
|
46
|
+
if (type === 'IHDR') header = bytes.subarray(offset + 8, next - 4);
|
|
47
|
+
else if (type === 'IDAT') data.push(bytes.subarray(offset + 8, next - 4));
|
|
48
|
+
else if (type === 'IEND') { end = length === 0 && next === bytes.length; break; }
|
|
49
|
+
else if (/^[A-Z]/.test(type) && type !== 'PLTE') designFail('IMAGE', 'Unsupported critical PNG chunk');
|
|
50
|
+
offset = next;
|
|
51
|
+
}
|
|
52
|
+
const width = header?.readUInt32BE(0), height = header?.readUInt32BE(4), channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[header?.[9]];
|
|
53
|
+
if (!end || !width || !height || width > 4096 || height > 4096 || header[8] !== 8 || !channels
|
|
54
|
+
|| header[10] || header[11] || header[12] || !data.length) designFail('IMAGE', 'Unsupported or incomplete generated PNG; use non-interlaced 8-bit PNG');
|
|
55
|
+
let expanded;
|
|
56
|
+
try { expanded = inflateSync(Buffer.concat(data), { maxOutputLength: 68_000_000 }); }
|
|
57
|
+
catch { designFail('IMAGE', 'Generated PNG data cannot be decoded'); }
|
|
58
|
+
const row = width * channels + 1;
|
|
59
|
+
if (expanded.length !== row * height || Array.from({ length: height }, (_, i) => expanded[i * row]).some(filter => filter > 4)) designFail('IMAGE', 'Generated PNG scanlines are invalid');
|
|
60
|
+
return { width, height };
|
|
61
|
+
}
|
|
62
|
+
export function designMermaid(raw) {
|
|
63
|
+
const site = validateDesignSite(raw), ids = new Map(site.pages.map((page, index) => [page.id, `p${index}`]));
|
|
64
|
+
const label = value => `"${value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>').replace(/\|/g, '|').replace(/[\r\n]/g, ' ')}"`;
|
|
65
|
+
return ['flowchart TD', ...site.pages.map(page => ` ${ids.get(page.id)}[${label(`${page.title} (${page.id}) · ${page.roles.join('/')} · ${page.states.join('/')}`)}]`),
|
|
66
|
+
...site.edges.map(edge => ` ${ids.get(edge.from)} -->|${label(`${edge.id}: ${edge.label} (${edge.kind})`)}| ${ids.get(edge.to)}`)].join('\n') + '\n';
|
|
67
|
+
}
|
|
68
|
+
export async function assertDesignArtifacts(policy, input) {
|
|
69
|
+
const directory = input ? await designDirectory({ ...input, designId: policy.designId, revision: policy.revision }) : path.dirname(policy.flow.path);
|
|
70
|
+
if (policy.flow.path !== path.join(directory, 'site-flow.mmd') || policy.mockups.some((mockup, index) =>
|
|
71
|
+
mockup.image.path !== path.join(directory, `mockup-${index}.png`)
|
|
72
|
+
|| mockup.generation.receipt.path !== path.join(directory, `generation-${index}.json`))) designFail('ARTIFACT_LOCATION', 'Design evidence must remain in its original repository, flow and revision storage');
|
|
73
|
+
const diagram = await designArtifact(policy.flow.path, policy.flow);
|
|
74
|
+
if (diagram.toString('utf8') !== designMermaid(policy.site)) designFail('FLOW', 'The displayed whole-site diagram differs from its page/action inventory');
|
|
75
|
+
for (const [index, source] of policy.sourceBaseline.entries()) if (source.original) {
|
|
76
|
+
if (source.original.path !== path.join(directory, `source-${index}.original`)) designFail('ARTIFACT_LOCATION', 'Original UI bytes must remain in their approved design revision');
|
|
77
|
+
await designArtifact(source.original.path, source.original);
|
|
78
|
+
}
|
|
79
|
+
for (const mockup of policy.mockups) {
|
|
80
|
+
designPng(await designArtifact(mockup.image.path, mockup.image));
|
|
81
|
+
let receipt;
|
|
82
|
+
try { receipt = JSON.parse((await designArtifact(mockup.generation.receipt.path, mockup.generation.receipt)).toString('utf8')); }
|
|
83
|
+
catch { designFail('GENERATION', 'The original image generation record is unavailable or invalid'); }
|
|
84
|
+
if (Object.keys(receipt).sort().join('|') !== 'generatedAt|imageSha256|promptSha256|requestId|schemaVersion|tool') designFail('GENERATION', 'Image generation records contain only tool identity, prompt/image hashes and the actual generation time');
|
|
85
|
+
if (receipt.schemaVersion !== 'entrorove.image-generation-record/1.0' || receipt.tool !== mockup.generation.tool
|
|
86
|
+
|| receipt.requestId !== mockup.generation.requestId || receipt.promptSha256 !== mockup.generation.promptSha256
|
|
87
|
+
|| receipt.imageSha256 !== mockup.image.sha256 || !Number.isFinite(Date.parse(receipt.generatedAt))
|
|
88
|
+
|| Date.parse(receipt.generatedAt) < Date.parse(policy.researchRecordedAt)
|
|
89
|
+
|| Date.parse(receipt.generatedAt) > Date.parse(policy.mockupsRecordedAt)) designFail('GENERATION', 'Record the actual image/design tool result after research, bound to these image bytes');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function captureDesignSourceBaseline(input, directory, names) {
|
|
93
|
+
const baseline = [];
|
|
94
|
+
for (const [index, name] of names.entries()) {
|
|
95
|
+
const file = await mutationTarget14(input.repositoryRoot, name, true);
|
|
96
|
+
if (!file.exists) { baseline.push({ path: name, original: null }); continue; }
|
|
97
|
+
const { content } = await mutationRead14(input, input.repositoryRoot, name), target = path.join(directory, `source-${index}.original`);
|
|
98
|
+
if (content.length > 2_097_152) designFail('LINT_LIMIT', 'Split approved UI source inspection before capturing a source larger than 2 MiB');
|
|
99
|
+
await writeMutationFile14(target, content);
|
|
100
|
+
baseline.push({ path: name, original: { path: target, sha256: sha256(content), bytes: content.length } });
|
|
101
|
+
}
|
|
102
|
+
return baseline;
|
|
103
|
+
}
|
|
104
|
+
export async function designOriginalSource(policy, name) {
|
|
105
|
+
const source = policy.sourceBaseline.find(file => file.path === name);
|
|
106
|
+
if (!source) designFail('BASELINE', 'UI source has no original approved baseline');
|
|
107
|
+
return source.original ? (await designArtifact(source.original.path, source.original)).toString('utf8') : '';
|
|
108
|
+
}
|
|
109
|
+
export async function designSourceManifest(input, policy) {
|
|
110
|
+
const { mutationRead14 } = await import('./local-mutation-files.mjs');
|
|
111
|
+
const root = await mutationRoot14(input.repositoryRoot), files = [];
|
|
112
|
+
const paths = new Set([...policy.rules.verificationPaths, ...policy.frameworks.flatMap(f => [f.packagePath, f.lockPath])]);
|
|
113
|
+
for (const name of [...paths].sort()) files.push({ path: name, sha256: (await mutationRead14(input, root, name)).sha256 });
|
|
114
|
+
return files;
|
|
115
|
+
}
|
|
116
|
+
export const sameDesignBytes = (a, b) => canonical(a) === canonical(b);
|
package/design-ui.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
|
|
3
|
+
const uiExtension = /\.(?:[jt]sx|vue|svelte|html?|xhtml|css|scss|sass|less|styl|wxml|wxss|axml|acss)$/i;
|
|
4
|
+
export const designUiAsset = name => /\.(?:png|jpe?g|webp|avif|gif|ico|woff2?|ttf|otf)$/i.test(name);
|
|
5
|
+
const markup = /<\/?(?:html|head|body|main|div|section|form|button|input|table|dialog|template|script|style|[A-Z][A-Za-z0-9]*)\b[^>]*>/;
|
|
6
|
+
function scriptUi(source, name) {
|
|
7
|
+
const kind = /\.[cm]?ts$/i.test(name) ? ts.ScriptKind.TS : ts.ScriptKind.TSX;
|
|
8
|
+
const tree = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true, kind);
|
|
9
|
+
let found = false;
|
|
10
|
+
const walk = node => {
|
|
11
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)
|
|
12
|
+
|| ts.isPropertyAccessExpression(node) && ['createElement', 'createRoot', 'innerHTML', 'outerHTML', 'insertAdjacentHTML',
|
|
13
|
+
'querySelector', 'querySelectorAll', 'getElementById', 'style'].includes(node.name.text)
|
|
14
|
+
|| (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isTemplateHead(node)) && markup.test(node.text)) found = true;
|
|
15
|
+
if (!found) ts.forEachChild(node, walk);
|
|
16
|
+
};
|
|
17
|
+
walk(tree); return found;
|
|
18
|
+
}
|
|
19
|
+
export function designUiCandidate(name, before = '', after = '') {
|
|
20
|
+
if (uiExtension.test(name) || /\.svg$/i.test(name)
|
|
21
|
+
|| /(?:^|\/)(?:public|assets|images|icons)\//i.test(name) && designUiAsset(name)) return true;
|
|
22
|
+
if (/\.(?:[cm]?[jt]s)$/i.test(name)) return Boolean(before || after) && (scriptUi(before, name) || scriptUi(after, name));
|
|
23
|
+
return /\.(?:php|txt)$/i.test(name) && markup.test(`${before}\n${after}`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function installPackageIdentity(directory, metadata) {
|
|
6
|
+
const files = [...new Set(['package.json', ...metadata.files])].sort(), digest = createHash('sha256');
|
|
7
|
+
for (const name of files) {
|
|
8
|
+
if (typeof name !== 'string' || path.isAbsolute(name) || name.includes('\\')
|
|
9
|
+
|| name.split('/').some(part => !part || part === '.' || part === '..')) throw new Error('Invalid packaged source inventory');
|
|
10
|
+
const file = path.join(directory, name), stat = await fs.lstat(file);
|
|
11
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Packaged source identity requires regular files');
|
|
12
|
+
const bytes = await fs.readFile(file);
|
|
13
|
+
digest.update(`${name}\0${createHash('sha256').update(bytes).digest('hex')}\n`);
|
|
14
|
+
}
|
|
15
|
+
return digest.digest('hex');
|
|
16
|
+
}
|
package/local-cli.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { ARCHGUARD_LOCAL_OPERATIONS } from './local-archguard-operations.mjs';
|
|
|
25
25
|
import { LOCAL_BUDGET_HANDOFF_OPERATIONS } from './local-budget-handoff.mjs';
|
|
26
26
|
import { LOCAL_CHAIN_OPERATIONS } from './local-chain-operations.mjs';
|
|
27
27
|
import { DISCIPLINE_LOCAL_OPERATIONS } from './local-discipline-operations.mjs';
|
|
28
|
+
import { DESIGN_LOCAL_OPERATIONS } from './design-operations.mjs';
|
|
28
29
|
import { assertIdeExecution, settleBreakGlassSession } from './ide-session.mjs';
|
|
29
30
|
import { runManagedWorkflowLocalStep } from './workflow-local-client.mjs';
|
|
30
31
|
import { localErrorEnvelope } from './local-error.mjs';
|
|
@@ -47,6 +48,7 @@ const operations = {
|
|
|
47
48
|
'mutation-consumption-attest': attestMutationConsumption14,
|
|
48
49
|
'mutation-delegate-prepare': preparePreservedMutationSigner,
|
|
49
50
|
...DISCIPLINE_LOCAL_OPERATIONS,
|
|
51
|
+
...DESIGN_LOCAL_OPERATIONS,
|
|
50
52
|
'snapshot-plan': snapshotPlan, 'snapshot-verify': snapshotVerify, 'mutation-snapshot-attest': attestMutationSnapshot14, 'mutate-gate': mutateGate,
|
|
51
53
|
'gate-issue': issueMutationPass, 'gate-verify': verifyMutationPass, 'guarded-write': guardedWrite,
|
|
52
54
|
'budget-init': budgetInit, 'budget-context': budgetContext, 'budget-read': budgetRead, 'budget-status': budgetStatus,
|
|
@@ -87,7 +89,7 @@ function capabilitiesFor(group) {
|
|
|
87
89
|
async function checkedLocalInput(operation, input) {
|
|
88
90
|
try {
|
|
89
91
|
const validated = validateLocalInput(operation, input);
|
|
90
|
-
if (['mutation-snapshot-attest', 'mutation-consumption-attest', 'workflow-run-step', 'mutate-gate', 'gate-issue', 'gate-verify', 'guarded-write', 'guarded-delete', 'guarded-move', 'discipline-review', 'discipline-check'].includes(operation)) await hydrateCloudMutationAuthority(input.home, new URL(apiUrl).origin);
|
|
92
|
+
if (['mutation-snapshot-attest', 'mutation-consumption-attest', 'workflow-run-step', 'design-verify', 'mutate-gate', 'gate-issue', 'gate-verify', 'guarded-write', 'guarded-delete', 'guarded-move', 'discipline-review', 'discipline-check'].includes(operation)) await hydrateCloudMutationAuthority(input.home, new URL(apiUrl).origin);
|
|
91
93
|
return validated;
|
|
92
94
|
}
|
|
93
95
|
catch (cause) {
|
package/local-contracts.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { WORKFLOW_LOCAL_OPERATION_SCHEMA } from './workflow-local-contracts.mjs'
|
|
|
9
9
|
import { HANDOFF_WRITE_PASS_SCHEMA, HANDOFF_PROVISIONAL_LEASE_SCHEMA, HANDOFF_SNAPSHOT_CANDIDATE_SCHEMA } from './local-task-handoff-contracts.mjs';
|
|
10
10
|
import { LOCAL_CHAIN_OPERATION_SCHEMAS } from './local-chain-contracts.mjs';
|
|
11
11
|
import { DISCIPLINE_DECLARATION_SCHEMA } from './local-discipline-contracts.mjs';
|
|
12
|
+
import { DESIGN_LOCAL_OPERATION_SCHEMAS } from './design-local-contracts.mjs';
|
|
12
13
|
import { array, binding, change, confirmation, enumeration, evidence, evidenceArray, executionContext, executionPlan,
|
|
13
14
|
formula, formulaDefinitions, formulaEntry, decimalValues, identifier, integer, ledgerIdentifier, modes, nullable, object, paths, record, relativePath, sha256, text } from './local-contract-types.mjs';
|
|
14
15
|
|
|
@@ -85,6 +86,7 @@ add('chain-resume', { ...chain, stepId: ledgerIdentifier, status: enumeration(['
|
|
|
85
86
|
add('chain-status|chain-ledger-status', chain, ['repositoryRoot', 'chainId']);
|
|
86
87
|
add('chain-answer', { ...chain, stepId: ledgerIdentifier, answer: { anyOf: [text, object({ id: text }, ['id'])] }, accepted: { type: 'boolean' }, actorId: nullable(text) }, ['repositoryRoot', 'chainId', 'answer']);
|
|
87
88
|
Object.assign(schemas, LOCAL_CHAIN_OPERATION_SCHEMAS);
|
|
89
|
+
Object.assign(schemas, DESIGN_LOCAL_OPERATION_SCHEMAS);
|
|
88
90
|
const renewal = { ...flow, requestId: identifier, scope: object({ goal: text, allowedPaths: paths, forbiddenPaths: paths }, ['goal', 'allowedPaths']), policy: object({ intervalMs: integer(1), maxRenewals: integer(1, 100) }, ['intervalMs', 'maxRenewals']) };
|
|
89
91
|
add('budget-auto-renew-request', renewal, ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy']);
|
|
90
92
|
add('budget-auto-renew', { ...renewal, confirmation, grant: record }, ['repositoryRoot', 'flowId', 'requestId', 'scope', 'policy'], ['grant', 'confirmation']);
|
|
@@ -9,6 +9,8 @@ import { assertDisciplineRepository } from './local-discipline-repository.mjs';
|
|
|
9
9
|
import { saveMutationConsumption14 } from './local-mutation-pass-store.mjs';
|
|
10
10
|
import { auditMutation14, withMutationAudit14 } from './local-mutation-audit.mjs';
|
|
11
11
|
import { mutationFail14, removeMutationProjectFile14, moveMutationProjectFile14 } from './local-mutation-files.mjs';
|
|
12
|
+
import { assertDesignWrite } from './design-gate.mjs';
|
|
13
|
+
import { readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
|
|
12
14
|
|
|
13
15
|
function actionIntent(input, value, kind, review) {
|
|
14
16
|
if (value.pass.writeDelegation) mutationFail14('ENTROROVE_MUTATION_DELEGATION_CHANGE', 'Preserved mutation forbids structural file actions');
|
|
@@ -38,6 +40,9 @@ async function perform(input, kind, original, state) {
|
|
|
38
40
|
const review = await readDisciplineSnapshot(snapshot), intent = actionIntent(input, value, kind, review);
|
|
39
41
|
await assertDisciplineRepository(input, binding.root, review, ledger);
|
|
40
42
|
const source = snapshot.manifest.files.find(file => file.path === value.target);
|
|
43
|
+
const sourceBytes = (await readSnapshotCopy14(input, snapshot.snapshotRoot, snapshot.manifest, value.target)).content.toString('utf8');
|
|
44
|
+
await assertDesignWrite(input, pass, value.target, sourceBytes, kind === 'move' ? sourceBytes : '');
|
|
45
|
+
if (kind === 'move') await assertDesignWrite(input, pass, intent.destinationPath, '', sourceBytes);
|
|
41
46
|
const at = new Date().toISOString();
|
|
42
47
|
const writes = { ...ledger.writes, [value.target]: { status: 'pending', kind: 'delete', contentDigest: source.sha256, mode: source.mode, at } };
|
|
43
48
|
let destination;
|
|
@@ -3,6 +3,10 @@ import { mutationFail14 } from './local-mutation-files.mjs';
|
|
|
3
3
|
import { disciplineWritePreflight } from './local-discipline-gate.mjs';
|
|
4
4
|
import { readDisciplineSnapshot } from './local-discipline-snapshot.mjs';
|
|
5
5
|
import { assertDisciplineRepository } from './local-discipline-repository.mjs';
|
|
6
|
+
import { designUiCandidate } from './design-ui.mjs';
|
|
7
|
+
import { archiveFormat } from './package-archive.mjs';
|
|
8
|
+
import { assertRollbackCandidate, rememberMutationBaselines } from './rollback-history.mjs';
|
|
9
|
+
import { readSnapshotCopy14 } from './local-mutation-snapshot.mjs';
|
|
6
10
|
|
|
7
11
|
export async function disciplineBatchBefore(input, value, options, before, after, ledger) {
|
|
8
12
|
const review = await readDisciplineSnapshot(value.snapshot);
|
|
@@ -11,14 +15,18 @@ export async function disciplineBatchBefore(input, value, options, before, after
|
|
|
11
15
|
// by mutationBatchReceiptBinding14. It restores that known image, rather than
|
|
12
16
|
// proposing a new definition or reusing a forward mutation permission.
|
|
13
17
|
const restoration = options.contract?.schemaVersion === 'entrorove.merge-rollback-authorization/1.0';
|
|
14
|
-
if (restoration) return review;
|
|
18
|
+
if (restoration) { await rememberMutationBaselines(input, value, before); return review; }
|
|
15
19
|
for (const file of after) {
|
|
16
20
|
const original = before.find(item => item.path === file.path);
|
|
17
21
|
if (canonical(original) === canonical(file)) continue;
|
|
18
22
|
if (!file.exists) mutationFail14('ENTROROVE_DISCIPLINE_NEEDS_HUMAN', 'Forward batch deletion requires its separate guarded deletion declaration');
|
|
23
|
+
await assertRollbackCandidate(input, value.binding.root, file.path, original.sha256, file.sha256);
|
|
19
24
|
const source = options.contents?.find(item => item.path === file.path);
|
|
20
25
|
if (!source) {
|
|
21
|
-
|
|
26
|
+
const bytes = original.exists ? (await readSnapshotCopy14(input, value.snapshot.snapshotRoot, value.snapshot.manifest, file.path)).content : Buffer.alloc(0);
|
|
27
|
+
if (designUiCandidate(file.path) || archiveFormat(file.path, bytes)
|
|
28
|
+
|| /(?:^|\/)(?:package|manifest)\.json$/i.test(file.path)
|
|
29
|
+
|| /\.(?:[cm]?[jt]sx?|php|py|java|rs|vue|svelte|css)$/iu.test(file.path)) mutationFail14('ENTROROVE_DISCIPLINE_REVIEW_REQUIRED', 'Source or package batch needs actual candidate bytes before any write');
|
|
22
30
|
continue;
|
|
23
31
|
}
|
|
24
32
|
const bytes = Buffer.from(source.contentBase64, 'base64');
|
|
@@ -26,7 +34,8 @@ export async function disciplineBatchBefore(input, value, options, before, after
|
|
|
26
34
|
const intent = value.pass.disciplinePolicy?.writes.find(write => write.targetPath === file.path);
|
|
27
35
|
await disciplineWritePreflight({ ...input, targetPath: file.path,
|
|
28
36
|
...(intent ? { declaration: { ...intent, usageReviewDigest: value.snapshot.manifest.disciplineReviewDigest } } : {}) },
|
|
29
|
-
{ ...value, target: file.path, ledger }, bytes
|
|
37
|
+
{ ...value, target: file.path, ledger }, bytes);
|
|
30
38
|
}
|
|
39
|
+
await rememberMutationBaselines(input, value, before);
|
|
31
40
|
return review;
|
|
32
41
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import Ajv from 'ajv';
|
|
2
2
|
import { canonical, sha256 } from './local-integrity.mjs';
|
|
3
3
|
import { object, array, text, identifier, relativePath, sha256 as shaSchema, integer } from './local-contract-types.mjs';
|
|
4
|
+
import { DESIGN_POLICY_SCHEMA } from './design-contracts.mjs';
|
|
5
|
+
import { assertDesignDeclaration } from './design-declaration.mjs';
|
|
4
6
|
|
|
5
7
|
const refs = { ...array(identifier, 1), uniqueItems: true };
|
|
6
8
|
const defect = object({ path: relativePath, line: integer(1) }, ['path', 'line']);
|
|
@@ -13,7 +15,7 @@ export const DISCIPLINE_WRITE_SCHEMA = object({ targetPath: relativePath,
|
|
|
13
15
|
const approval = object({ interactionId: identifier, interactionDigest: shaSchema, auditId: identifier,
|
|
14
16
|
proposalDigest: shaSchema }, ['interactionId', 'interactionDigest', 'auditId', 'proposalDigest']);
|
|
15
17
|
const mutablePolicy = object({ schemaVersion: { const: 'entrorove.discipline-policy/1.0' },
|
|
16
|
-
requirementIds: refs, writes: array(DISCIPLINE_WRITE_SCHEMA, 1), approval,
|
|
18
|
+
requirementIds: refs, writes: array(DISCIPLINE_WRITE_SCHEMA, 1), approval, designApproval: approval, design: DESIGN_POLICY_SCHEMA,
|
|
17
19
|
dataOperations: array(object({ operation: { enum: ['delete', 'migrate', 'rebuild'] }, target: text,
|
|
18
20
|
reason: text, requirementRefs: refs, snapshotArtifactDigest: shaSchema }, ['operation', 'target', 'reason', 'requirementRefs', 'snapshotArtifactDigest'])),
|
|
19
21
|
}, ['schemaVersion', 'requirementIds', 'writes']);
|
|
@@ -37,10 +39,14 @@ export function disciplinePolicy(value) {
|
|
|
37
39
|
if (value.dataOperations?.some(action => action.requirementRefs.some(id => !requirements.has(id)))) {
|
|
38
40
|
throw Object.assign(new Error('Data operations must reference original requirements'), { code: 'ENTROROVE_DISCIPLINE_POLICY_INVALID' });
|
|
39
41
|
}
|
|
42
|
+
assertDesignDeclaration(value, value.writes.flatMap(write => [write.targetPath, ...(write.destinationPath ? [write.destinationPath] : [])]));
|
|
43
|
+
if (value.design && value.design.site.requirementIds.some(id => !requirements.has(id))) {
|
|
44
|
+
throw Object.assign(new Error('Design must retain its original task requirements'), { code: 'ENTROROVE_DESIGN_BINDING' });
|
|
45
|
+
}
|
|
40
46
|
return structuredClone(value);
|
|
41
47
|
}
|
|
42
48
|
export function disciplineProposalDigest(value) {
|
|
43
|
-
const { approval, ...proposal } = disciplinePolicy(value);
|
|
49
|
+
const { approval, designApproval, ...proposal } = disciplinePolicy(value);
|
|
44
50
|
return sha256(canonical(proposal));
|
|
45
51
|
}
|
|
46
52
|
export function disciplineHumanTriggers(value) {
|
|
@@ -50,11 +56,17 @@ export function disciplineHumanTriggers(value) {
|
|
|
50
56
|
if (['delete', 'move'].includes(write.kind) || write.deletedSymbols?.length) triggers.add(write.kind === 'move' ? 'move' : 'delete');
|
|
51
57
|
}
|
|
52
58
|
if (policy.dataOperations?.length) triggers.add('existing-data');
|
|
59
|
+
if (policy.design) triggers.add('interface-design');
|
|
53
60
|
return [...triggers].sort();
|
|
54
61
|
}
|
|
55
62
|
export function disciplineApprovedPolicy(value) {
|
|
56
63
|
const policy = disciplinePolicy(value);
|
|
57
|
-
|
|
64
|
+
const triggers = disciplineHumanTriggers(policy), fullReview = triggers.some(trigger => trigger !== 'interface-design')
|
|
65
|
+
|| Boolean(policy.design && !policy.designApproval);
|
|
66
|
+
if (policy.designApproval && (!policy.design || policy.designApproval.proposalDigest !== policy.design.baselineDigest)) {
|
|
67
|
+
throw Object.assign(new Error('The exact design revision needs its original human confirmation'), { code: 'ENTROROVE_DESIGN_APPROVAL_REQUIRED' });
|
|
68
|
+
}
|
|
69
|
+
if (fullReview && (!policy.approval || policy.approval.proposalDigest !== disciplineProposalDigest(policy))) {
|
|
58
70
|
throw Object.assign(new Error('This exact change proposal needs the original persisted human approval'), { code: 'ENTROROVE_DISCIPLINE_NEEDS_HUMAN' });
|
|
59
71
|
}
|
|
60
72
|
return policy;
|