@geraldmaron/construct 1.3.2 → 1.4.1
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/README.md +2 -1
- package/bin/construct +61 -15
- package/lib/a11y-audit.mjs +104 -0
- package/lib/artifact-completion-states.mjs +35 -0
- package/lib/artifact-completion.mjs +66 -0
- package/lib/artifact-gate-levels.mjs +69 -0
- package/lib/artifact-manifest.mjs +21 -0
- package/lib/artifact-release-gate.mjs +19 -1
- package/lib/artifact-workflow.mjs +16 -0
- package/lib/brand-contrast.mjs +60 -0
- package/lib/brand-tokens.mjs +14 -0
- package/lib/cli-commands.mjs +5 -3
- package/lib/codex-config.mjs +7 -1
- package/lib/deck-export-pptx.mjs +223 -18
- package/lib/diagram-quality.mjs +161 -0
- package/lib/document-export.mjs +1 -1
- package/lib/env-config.mjs +8 -8
- package/lib/export-validate.mjs +106 -0
- package/lib/gates-audit.mjs +34 -0
- package/lib/health-check.mjs +23 -9
- package/lib/hooks/guard-bash.mjs +3 -1
- package/lib/hooks/session-start.mjs +2 -1
- package/lib/init-unified.mjs +5 -3
- package/lib/mcp/server.mjs +53 -3
- package/lib/mcp/tool-recovery.mjs +56 -0
- package/lib/mcp/tools/web-search.mjs +127 -0
- package/lib/mcp-manager.mjs +11 -4
- package/lib/mcp-platform-config.mjs +18 -1
- package/lib/models/provider-poll.mjs +32 -6
- package/lib/orchestration/readiness.mjs +183 -0
- package/lib/orchestration-policy.mjs +36 -0
- package/lib/output-quality.mjs +90 -0
- package/lib/pixel-regression.mjs +172 -0
- package/lib/providers/op-run.mjs +6 -5
- package/lib/providers/secret-audit-wiring.mjs +32 -0
- package/lib/providers/secret-resolver.mjs +90 -20
- package/lib/publish.mjs +64 -1
- package/lib/render-evidence.mjs +55 -0
- package/lib/render-pipeline.mjs +136 -0
- package/lib/service-manager.mjs +31 -10
- package/lib/templates/doc-presentation.mjs +24 -0
- package/lib/tracking-surfaces.mjs +36 -0
- package/lib/visual-review.mjs +70 -0
- package/package.json +1 -1
- package/scripts/sync-specialists.mjs +107 -11
- package/specialists/artifact-manifest.json +18 -0
- package/specialists/artifact-manifest.schema.json +46 -2
- package/templates/distribution/construct-brand.typ +1 -2
- package/templates/distribution/construct-deck.html +34 -34
- package/templates/distribution/construct-pdf.typ +1 -1
- package/templates/distribution/construct-web.html +8 -10
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* fails) fall back to the curated tier options, flagged source:'curated' so the
|
|
9
9
|
* UI never implies a live result it could not verify. Every poller is
|
|
10
10
|
* best-effort: it resolves to [] on any error and never throws, and results are
|
|
11
|
-
* cached to ~/.cx/provider-catalog-cache.json so repeat opens are instant.
|
|
11
|
+
* cached to ~/.cx/provider-catalog-cache.json so repeat opens are instant. When a
|
|
12
|
+
* fresh catalog (within CACHE_TTL_MS) already covers every configured provider it
|
|
13
|
+
* is served directly without polling, so opening the picker on a warm cache
|
|
14
|
+
* resolves no secret and triggers no 1Password prompt.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import fs from 'node:fs';
|
|
@@ -306,6 +309,14 @@ async function enrichPricing(models, { env }) {
|
|
|
306
309
|
});
|
|
307
310
|
}
|
|
308
311
|
|
|
312
|
+
function sortProviderGroups(groups) {
|
|
313
|
+
return groups.sort((a, b) => {
|
|
314
|
+
const ai = PROVIDER_ORDER.indexOf(a.id);
|
|
315
|
+
const bi = PROVIDER_ORDER.indexOf(b.id);
|
|
316
|
+
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.label.localeCompare(b.label);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
309
320
|
/**
|
|
310
321
|
* Poll every configured provider in parallel and return one group per provider,
|
|
311
322
|
* in render order, each with its live model list. A provider that can't be
|
|
@@ -327,6 +338,25 @@ export async function pollConfiguredProviders({ env = process.env, cwd = process
|
|
|
327
338
|
}
|
|
328
339
|
}
|
|
329
340
|
|
|
341
|
+
// Cache-first: when a fresh catalog (within TTL) already covers every configured
|
|
342
|
+
// provider, serve it without resolving any secret. Opening the picker on a warm
|
|
343
|
+
// cache must not spawn `op read` or trigger a 1Password prompt.
|
|
344
|
+
const freshCached = readProviderCatalogCache({ homeDir, maxAgeMs: CACHE_TTL_MS }) || [];
|
|
345
|
+
const freshById = new Map(freshCached.map((g) => [g.id, g]));
|
|
346
|
+
if (groupIds.length && groupIds.every((gid) => freshById.get(gid)?.models?.length)) {
|
|
347
|
+
const fresh = groupIds.map((gid) => {
|
|
348
|
+
const group = groupForId.get(gid);
|
|
349
|
+
const cachedGroup = freshById.get(gid);
|
|
350
|
+
return {
|
|
351
|
+
id: gid,
|
|
352
|
+
label: group.label,
|
|
353
|
+
models: cachedGroup.models.map((m) => ({ ...m, source: 'cached' })),
|
|
354
|
+
live: false,
|
|
355
|
+
};
|
|
356
|
+
});
|
|
357
|
+
return sortProviderGroups(fresh);
|
|
358
|
+
}
|
|
359
|
+
|
|
330
360
|
const cached = readProviderCatalogCache({ homeDir, maxAgeMs: Infinity }) || [];
|
|
331
361
|
const cachedById = new Map(cached.map((g) => [g.id, g]));
|
|
332
362
|
|
|
@@ -367,11 +397,7 @@ export async function pollConfiguredProviders({ env = process.env, cwd = process
|
|
|
367
397
|
groups.push({ id: gid, label: group.label, models: [unreachableHint(gid, group.label)], live: false });
|
|
368
398
|
}
|
|
369
399
|
|
|
370
|
-
groups
|
|
371
|
-
const ai = PROVIDER_ORDER.indexOf(a.id);
|
|
372
|
-
const bi = PROVIDER_ORDER.indexOf(b.id);
|
|
373
|
-
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.label.localeCompare(b.label);
|
|
374
|
-
});
|
|
400
|
+
sortProviderGroups(groups);
|
|
375
401
|
|
|
376
402
|
if (liveGroupsToCache.length) writeProviderCatalogCache(liveGroupsToCache, { homeDir });
|
|
377
403
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/readiness.mjs — observed orchestration attachment readiness.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from embedded-contract/execution.mjs: execution resolve
|
|
5
|
+
* reports what Construct can plan; readiness reports what a host/session or
|
|
6
|
+
* local probe actually exposed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { getInstalledVersion } from '../version.mjs';
|
|
13
|
+
import { CONTRACT_VERSION, MIN_CLIENT_CONTRACT_VERSION } from '../embedded-contract/contract-version.mjs';
|
|
14
|
+
import { getDeploymentMode } from '../deployment-mode.mjs';
|
|
15
|
+
import { doctorRoot } from '../config/xdg.mjs';
|
|
16
|
+
|
|
17
|
+
export const ORCHESTRATION_READINESS_REASONS = Object.freeze([
|
|
18
|
+
'attached',
|
|
19
|
+
'host_not_attached',
|
|
20
|
+
'server_unreachable',
|
|
21
|
+
'auth_unavailable',
|
|
22
|
+
'profile_mismatch',
|
|
23
|
+
'capability_negotiation_failed',
|
|
24
|
+
'version_mismatch',
|
|
25
|
+
'tool_unlisted',
|
|
26
|
+
'unknown',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_ORCHESTRATION_TOOLS = Object.freeze([
|
|
30
|
+
'orchestration_policy',
|
|
31
|
+
'orchestration_run',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const NEXT_STEPS = Object.freeze({
|
|
35
|
+
attached: 'Proceed: call orchestration_policy, then orchestration_run when policy indicates an orchestrated or focused run.',
|
|
36
|
+
host_not_attached: 'Refresh the host adapter with `construct sync`, restart the host session, then rerun `construct orchestrate preflight --json` inside the target project.',
|
|
37
|
+
server_unreachable: 'Run `construct doctor`, verify the generated MCP command points at this checkout, then rerun `construct sync` if the server path is stale.',
|
|
38
|
+
auth_unavailable: 'Configure the required Construct/provider credential, restart the host so its MCP process inherits it, then rerun preflight.',
|
|
39
|
+
profile_mismatch: 'Check the active Construct scope/profile and host adapter selection, then rerun `construct sync` from the project root.',
|
|
40
|
+
capability_negotiation_failed: 'Restart the host session so MCP initialize/tools.list negotiation runs again; if it persists, attach the diagnostic bundle to GH #323.',
|
|
41
|
+
version_mismatch: 'Upgrade Construct and refresh adapters with `construct sync` so the host and server use compatible contract versions.',
|
|
42
|
+
tool_unlisted: 'Refresh adapters with `construct sync`; if using a reduced local tool surface, ensure orchestration_run is reachable through the `call` gateway enum.',
|
|
43
|
+
unknown: 'Run `construct doctor` and attach the diagnostic bundle to GH #323.',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function normalizeList(values) {
|
|
47
|
+
if (!values) return [];
|
|
48
|
+
const arr = Array.isArray(values) ? values : String(values).split(',');
|
|
49
|
+
return [...new Set(arr.map((v) => String(v || '').trim()).filter(Boolean))].sort();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compareSemver(a, b) {
|
|
53
|
+
const pa = String(a || '').split('.').map((n) => Number(n));
|
|
54
|
+
const pb = String(b || '').split('.').map((n) => Number(n));
|
|
55
|
+
for (let i = 0; i < 3; i += 1) {
|
|
56
|
+
const av = Number.isFinite(pa[i]) ? pa[i] : 0;
|
|
57
|
+
const bv = Number.isFinite(pb[i]) ? pb[i] : 0;
|
|
58
|
+
if (av > bv) return 1;
|
|
59
|
+
if (av < bv) return -1;
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasTool(tool, observed, reachable) {
|
|
65
|
+
return observed.includes(tool) || reachable.includes(tool);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function redactedEnvFacts(env) {
|
|
69
|
+
return {
|
|
70
|
+
hasRemoteOrchestrationUrl: Boolean(env.CONSTRUCT_ORCHESTRATION_URL),
|
|
71
|
+
hasRemoteOrchestrationToken: Boolean(env.CONSTRUCT_ORCHESTRATION_TOKEN || env.CONSTRUCT_DASHBOARD_TOKEN),
|
|
72
|
+
hasOpenRouterKey: Boolean(env.OPENROUTER_API_KEY),
|
|
73
|
+
hasAnthropicKey: Boolean(env.ANTHROPIC_API_KEY),
|
|
74
|
+
hasOpenAiKey: Boolean(env.OPENAI_API_KEY),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildOrchestrationReadiness(input = {}, { env = process.env, cwd = process.cwd() } = {}) {
|
|
79
|
+
const requiredTools = normalizeList(input.requiredTools ?? input.required_tools ?? DEFAULT_ORCHESTRATION_TOOLS);
|
|
80
|
+
const observedTools = normalizeList(input.observedTools ?? input.observed_tools);
|
|
81
|
+
const reachableTools = normalizeList(input.reachableTools ?? input.reachable_tools);
|
|
82
|
+
const host = input.host ?? null;
|
|
83
|
+
const sessionId = input.sessionId ?? input.session_id ?? null;
|
|
84
|
+
const observationScope = input.observationScope ?? input.observation_scope ?? (observedTools.length || reachableTools.length ? 'host-session' : 'local-config');
|
|
85
|
+
const probeError = input.probeError ?? input.probe_error ?? null;
|
|
86
|
+
const clientContractVersion = input.clientContractVersion ?? input.client_contract_version ?? CONTRACT_VERSION;
|
|
87
|
+
const profileExpected = input.expectedProfile ?? input.expected_profile ?? null;
|
|
88
|
+
const profileActual = input.actualProfile ?? input.actual_profile ?? null;
|
|
89
|
+
const authRequired = Boolean(input.authRequired ?? input.auth_required);
|
|
90
|
+
|
|
91
|
+
const missingTools = requiredTools.filter((tool) => !hasTool(tool, observedTools, reachableTools));
|
|
92
|
+
const constructVersion = getInstalledVersion()?.version || 'unknown';
|
|
93
|
+
const diagnosticId = input.diagnosticId ?? input.diagnostic_id ?? `orch-${randomUUID()}`;
|
|
94
|
+
|
|
95
|
+
let reasonCode = 'attached';
|
|
96
|
+
let detail = 'Required orchestration tools are reachable.';
|
|
97
|
+
|
|
98
|
+
if (probeError) {
|
|
99
|
+
reasonCode = 'server_unreachable';
|
|
100
|
+
detail = String(probeError);
|
|
101
|
+
} else if (compareSemver(clientContractVersion, MIN_CLIENT_CONTRACT_VERSION) < 0) {
|
|
102
|
+
reasonCode = 'version_mismatch';
|
|
103
|
+
detail = `Client contract ${clientContractVersion} is older than minimum ${MIN_CLIENT_CONTRACT_VERSION}.`;
|
|
104
|
+
} else if (profileExpected && profileActual && profileExpected !== profileActual) {
|
|
105
|
+
reasonCode = 'profile_mismatch';
|
|
106
|
+
detail = `Expected profile ${profileExpected}, observed ${profileActual}.`;
|
|
107
|
+
} else if (authRequired && !(env.CONSTRUCT_ORCHESTRATION_TOKEN || env.CONSTRUCT_DASHBOARD_TOKEN)) {
|
|
108
|
+
reasonCode = 'auth_unavailable';
|
|
109
|
+
detail = 'A remote/team orchestration token is required but not configured.';
|
|
110
|
+
} else if (observationScope === 'host-session' && observedTools.length === 0 && reachableTools.length === 0) {
|
|
111
|
+
reasonCode = 'host_not_attached';
|
|
112
|
+
detail = 'No observed MCP tools were provided for the active host session.';
|
|
113
|
+
} else if (missingTools.length > 0) {
|
|
114
|
+
reasonCode = 'tool_unlisted';
|
|
115
|
+
detail = `Missing required tool(s): ${missingTools.join(', ')}.`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const verdict = reasonCode === 'attached' ? 'pass' : 'fail';
|
|
119
|
+
const bundle = {
|
|
120
|
+
diagnosticId,
|
|
121
|
+
host,
|
|
122
|
+
sessionId,
|
|
123
|
+
cwd,
|
|
124
|
+
observationScope,
|
|
125
|
+
constructVersion,
|
|
126
|
+
contractVersion: CONTRACT_VERSION,
|
|
127
|
+
minClientContractVersion: MIN_CLIENT_CONTRACT_VERSION,
|
|
128
|
+
clientContractVersion,
|
|
129
|
+
deploymentMode: getDeploymentMode(env, { cwd }),
|
|
130
|
+
requiredTools,
|
|
131
|
+
observedTools,
|
|
132
|
+
reachableTools,
|
|
133
|
+
missingTools,
|
|
134
|
+
reasonCode,
|
|
135
|
+
detail,
|
|
136
|
+
env: redactedEnvFacts(env),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
verdict,
|
|
141
|
+
attached: verdict === 'pass',
|
|
142
|
+
reasonCode,
|
|
143
|
+
nextStep: NEXT_STEPS[reasonCode] || NEXT_STEPS.unknown,
|
|
144
|
+
host,
|
|
145
|
+
sessionId,
|
|
146
|
+
observationScope,
|
|
147
|
+
requiredTools,
|
|
148
|
+
observedTools,
|
|
149
|
+
reachableTools,
|
|
150
|
+
missingTools,
|
|
151
|
+
constructVersion,
|
|
152
|
+
contractVersion: CONTRACT_VERSION,
|
|
153
|
+
minClientContractVersion: MIN_CLIENT_CONTRACT_VERSION,
|
|
154
|
+
diagnosticBundle: bundle,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function summarizeOrchestrationReadiness(readiness) {
|
|
159
|
+
const state = readiness.attached ? 'Orchestration attached' : 'Orchestration unavailable';
|
|
160
|
+
const missing = readiness.missingTools?.length ? ` missing=${readiness.missingTools.join(',')}` : '';
|
|
161
|
+
const host = readiness.host ? ` host=${readiness.host}` : '';
|
|
162
|
+
return `${state} (${readiness.reasonCode}; scope=${readiness.observationScope}${host}${missing})`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function recordOrchestrationReadinessEvent(readiness, { homeDir, at = new Date().toISOString() } = {}) {
|
|
166
|
+
const event = {
|
|
167
|
+
ts: at,
|
|
168
|
+
type: 'orchestration.readiness',
|
|
169
|
+
host: readiness.host ?? null,
|
|
170
|
+
sessionId: readiness.sessionId ?? null,
|
|
171
|
+
observationScope: readiness.observationScope,
|
|
172
|
+
verdict: readiness.verdict,
|
|
173
|
+
attached: readiness.attached,
|
|
174
|
+
reasonCode: readiness.reasonCode,
|
|
175
|
+
requiredTools: readiness.requiredTools ?? [],
|
|
176
|
+
missingTools: readiness.missingTools ?? [],
|
|
177
|
+
diagnosticId: readiness.diagnosticBundle?.diagnosticId ?? null,
|
|
178
|
+
};
|
|
179
|
+
const file = join(doctorRoot(homeDir), 'orchestration-readiness.jsonl');
|
|
180
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
181
|
+
appendFileSync(file, `${JSON.stringify(event)}\n`);
|
|
182
|
+
return { path: file, event };
|
|
183
|
+
}
|
|
@@ -183,6 +183,38 @@ export function classifyResearchShape(request = '') {
|
|
|
183
183
|
return null;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// Live web access is distinct from research-shape: a bare "connect to the
|
|
187
|
+
// internet" or "fetch example.com" carries none of the comparative/landscape
|
|
188
|
+
// vocabulary, so without this it falls to the immediate track and the
|
|
189
|
+
// orchestrator — which holds no network tools — refuses instead of dispatching
|
|
190
|
+
// the web-capable cx-researcher. A literal URL, an explicit online/internet
|
|
191
|
+
// phrase, or a fetch/scrape verb paired with a web object is the signal.
|
|
192
|
+
|
|
193
|
+
const WEB_VERB = '(?:fetch|download|open|load|read|scrape|crawl|retrieve|pull|grab|access|visit|hit|reach)';
|
|
194
|
+
const WEB_OBJECT = '(?:url|link|web\\s?page|web\\s?site|site|page|online|internet|web|endpoint|api)';
|
|
195
|
+
const LIVE_WEB_ACCESS_PATTERNS = [
|
|
196
|
+
/https?:\/\/\S+/i,
|
|
197
|
+
/\bwww\.[a-z0-9-]+\.[a-z]{2,}/i,
|
|
198
|
+
/\b(?:connect|connecting|connection)\s+to\s+the\s+(?:internet|web|network)\b/i,
|
|
199
|
+
/\binternet\s+(?:access|connection|connectivity)\b/i,
|
|
200
|
+
/\b(?:go|get|getting|going)\s+online\b/i,
|
|
201
|
+
/\b(?:browse|browsing|surf)\b[\s\S]{0,20}\b(?:web|internet|site|page|url)\b/i,
|
|
202
|
+
/\b(?:curl|wget|ping)\b/i,
|
|
203
|
+
/\b(?:is|are)\b[\s\S]{0,40}\b(?:online|offline|reachable|unreachable|responding)\b/i,
|
|
204
|
+
new RegExp(`\\b${WEB_VERB}\\b[\\s\\S]{0,40}\\b${WEB_OBJECT}\\b`, 'i'),
|
|
205
|
+
new RegExp(`\\b${WEB_VERB}\\b[\\s\\S]{0,40}\\b[a-z0-9-]+\\.(?:com|org|net|io|dev|ai|gov|edu|co|app|info|xyz)\\b`, 'i'),
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether a request needs live external web/network access the orchestrator
|
|
210
|
+
* cannot perform itself (it is a read-only router with no network tools), so it
|
|
211
|
+
* must be routed to cx-researcher rather than answered immediately.
|
|
212
|
+
*/
|
|
213
|
+
export function requiresLiveWebAccess(request = '') {
|
|
214
|
+
const text = String(request);
|
|
215
|
+
return LIVE_WEB_ACCESS_PATTERNS.some((pattern) => pattern.test(text));
|
|
216
|
+
}
|
|
217
|
+
|
|
186
218
|
/**
|
|
187
219
|
* Returns whether external research is required before scaffolding, with the
|
|
188
220
|
* reason. Triggered by named entities not in the project glossary, by
|
|
@@ -193,6 +225,9 @@ export function requiresExternalResearch({ request = '', workCategory, riskFlags
|
|
|
193
225
|
const entities = extractNamedEntities(request);
|
|
194
226
|
const category = workCategory ?? classifyWorkCategory(request);
|
|
195
227
|
const flags = riskFlags ?? detectRiskFlags(request);
|
|
228
|
+
if (requiresLiveWebAccess(request)) {
|
|
229
|
+
return { required: true, reason: 'web-access' };
|
|
230
|
+
}
|
|
196
231
|
if (entities.length > 0) {
|
|
197
232
|
return { required: true, reason: 'named-entities', entities };
|
|
198
233
|
}
|
|
@@ -507,6 +542,7 @@ export function determineExecutionTrack({
|
|
|
507
542
|
|| isOperationsPlanningRequest(request)
|
|
508
543
|
|| isRdLeadRequest(request)
|
|
509
544
|
|| isExplorerRequest(request)
|
|
545
|
+
|| requiresLiveWebAccess(request)
|
|
510
546
|
) {
|
|
511
547
|
return EXECUTION_TRACKS.focused;
|
|
512
548
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/output-quality.mjs — Post-export output validation for the publish path.
|
|
3
|
+
*
|
|
4
|
+
* After an artifact exports, this runs the checks the source-level release gate cannot reach: the
|
|
5
|
+
* produced file is validated for real (PDF page count, exported-text roundtrip against the source
|
|
6
|
+
* headings, on-disk reference integrity), and the brand text palette is asserted against WCAG AA.
|
|
7
|
+
* At render-smoke or higher gate levels the export is additionally rendered to images and the
|
|
8
|
+
* screenshot evidence recorded. Every check degrades with a typed reason when its tool is absent
|
|
9
|
+
* rather than failing the publish; only a real, document-scoped validity failure (a zero-page PDF,
|
|
10
|
+
* dropped content, a missing local reference) marks the result not-ok.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { validatePdf, contentRoundtrip, referenceIntegrity } from './export-validate.mjs';
|
|
14
|
+
import { validateBrandContrast } from './brand-contrast.mjs';
|
|
15
|
+
import { captureRenderEvidence } from './render-evidence.mjs';
|
|
16
|
+
import { lintDocumentDiagrams } from './diagram-quality.mjs';
|
|
17
|
+
import { auditAccessibility } from './a11y-audit.mjs';
|
|
18
|
+
|
|
19
|
+
const RENDER_GATE_LEVELS = new Set(['render-smoke', 'full-certification', 'human-reviewed']);
|
|
20
|
+
|
|
21
|
+
// render-pipeline keys an export format to an image renderer; deck is HTML under the hood. A
|
|
22
|
+
// format with no renderer skips the screenshot capture rather than guessing one.
|
|
23
|
+
|
|
24
|
+
function renderFormatFor(format) {
|
|
25
|
+
if (format === 'pdf') return 'pdf';
|
|
26
|
+
if (format === 'html' || format === 'deck') return 'html';
|
|
27
|
+
if (format === 'pptx') return 'pptx';
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runOutputQuality({
|
|
32
|
+
exportPath,
|
|
33
|
+
format,
|
|
34
|
+
sourceMarkdown = '',
|
|
35
|
+
baseDir = null,
|
|
36
|
+
gateLevel = 'standard',
|
|
37
|
+
diagramStrict = false,
|
|
38
|
+
rootDir = process.cwd(),
|
|
39
|
+
env = process.env,
|
|
40
|
+
} = {}) {
|
|
41
|
+
const checks = {};
|
|
42
|
+
|
|
43
|
+
if (format === 'pdf') checks.pdf = validatePdf(exportPath, env);
|
|
44
|
+
checks.roundtrip = contentRoundtrip({ exportPath, format, sourceMarkdown, env });
|
|
45
|
+
if (sourceMarkdown && baseDir) checks.references = referenceIntegrity(sourceMarkdown, baseDir);
|
|
46
|
+
|
|
47
|
+
// Diagram legibility is advisory by default; `cx_diagram_quality: strict` escalates it.
|
|
48
|
+
const diagrams = lintDocumentDiagrams(sourceMarkdown);
|
|
49
|
+
|
|
50
|
+
// Per-format accessibility, with an honest checked-vs-uncheckable coverage report.
|
|
51
|
+
const a11y = auditAccessibility({ format, exportPath, sourceMarkdown, env });
|
|
52
|
+
|
|
53
|
+
// Palette contrast is a brand-system invariant, not a property of this document, so a
|
|
54
|
+
// regression is surfaced as advisory rather than failing the user's publish.
|
|
55
|
+
|
|
56
|
+
const contrast = validateBrandContrast();
|
|
57
|
+
|
|
58
|
+
let render = null;
|
|
59
|
+
if (RENDER_GATE_LEVELS.has(gateLevel)) {
|
|
60
|
+
const renderFormat = renderFormatFor(format);
|
|
61
|
+
if (renderFormat) render = captureRenderEvidence({ format: renderFormat, inputPath: exportPath, rootDir, env });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A typed degradation (tool absent) never fails the publish; only a non-degraded,
|
|
65
|
+
// document-scoped check whose ok is explicitly false does.
|
|
66
|
+
|
|
67
|
+
const failures = Object.entries(checks)
|
|
68
|
+
.filter(([, result]) => result && result.ok === false && !result.degradation)
|
|
69
|
+
.map(([name, result]) => `${name}: ${result.message || 'failed'}`);
|
|
70
|
+
|
|
71
|
+
if (diagramStrict && !diagrams.ok) {
|
|
72
|
+
for (const w of diagrams.warnings) failures.push(`diagram ${w.diagram} (${w.kind}): ${w.code}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Accessibility is advisory at standard levels and escalates at full-certification and above;
|
|
76
|
+
// rendered-format limits stay in the uncheckable list, never a false pass.
|
|
77
|
+
const a11yStrict = gateLevel === 'full-certification' || gateLevel === 'human-reviewed';
|
|
78
|
+
if (a11yStrict) for (const f of a11y.failures || []) failures.push(`a11y ${f}`);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
ok: failures.length === 0,
|
|
82
|
+
gateLevel,
|
|
83
|
+
checks,
|
|
84
|
+
contrast: { ok: contrast.ok, failures: contrast.failures },
|
|
85
|
+
diagrams,
|
|
86
|
+
a11y,
|
|
87
|
+
render,
|
|
88
|
+
failures,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/pixel-regression.mjs — true pixel-level regression, gated to full-certification.
|
|
3
|
+
*
|
|
4
|
+
* Compares two PNGs by decoding them to raw pixels and counting pixels whose channels differ
|
|
5
|
+
* beyond a tolerance — not by comparing compressed file bytes, which are meaningless across
|
|
6
|
+
* encoders (PNG is DEFLATE-compressed, so two pixel-identical images can differ byte-for-byte).
|
|
7
|
+
* Decoding uses node:zlib only, honoring the zero-npm-core constraint (ADR-0001); 8-bit
|
|
8
|
+
* non-interlaced PNGs in the grayscale/RGB/GA/RGBA color types are supported and anything else
|
|
9
|
+
* returns a typed `unsupported-encoding` rather than a false pass.
|
|
10
|
+
*
|
|
11
|
+
* Fixture-regeneration policy: a golden is updated only through regenerateGolden (single writer),
|
|
12
|
+
* never edited in place. Pixel regression runs only at the full-certification gate level so its
|
|
13
|
+
* decode-and-compare cost (O(pixels), a few milliseconds per image) never burdens cheaper gates.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import zlib from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
export const PIXEL_REGRESSION_LEVEL = 'full-certification';
|
|
20
|
+
|
|
21
|
+
const CHANNELS_BY_COLOR_TYPE = Object.freeze({ 0: 1, 2: 3, 4: 2, 6: 4 });
|
|
22
|
+
|
|
23
|
+
function paeth(a, b, c) {
|
|
24
|
+
const p = a + b - c;
|
|
25
|
+
const pa = Math.abs(p - a);
|
|
26
|
+
const pb = Math.abs(p - b);
|
|
27
|
+
const pc = Math.abs(p - c);
|
|
28
|
+
if (pa <= pb && pa <= pc) return a;
|
|
29
|
+
if (pb <= pc) return b;
|
|
30
|
+
return c;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Walk the PNG chunk stream for IHDR geometry and the concatenated IDAT payload, then inflate and
|
|
34
|
+
// reverse the per-scanline filter to recover raw pixels. CRCs are skipped; only the decode matters.
|
|
35
|
+
|
|
36
|
+
function decodePng(buffer) {
|
|
37
|
+
if (buffer.length < 8 || buffer[0] !== 0x89 || buffer[1] !== 0x50) return { error: 'invalid-png' };
|
|
38
|
+
let pos = 8;
|
|
39
|
+
let header = null;
|
|
40
|
+
const idat = [];
|
|
41
|
+
while (pos + 8 <= buffer.length) {
|
|
42
|
+
const length = buffer.readUInt32BE(pos);
|
|
43
|
+
const type = buffer.toString('ascii', pos + 4, pos + 8);
|
|
44
|
+
const dataStart = pos + 8;
|
|
45
|
+
if (type === 'IHDR') {
|
|
46
|
+
header = {
|
|
47
|
+
width: buffer.readUInt32BE(dataStart),
|
|
48
|
+
height: buffer.readUInt32BE(dataStart + 4),
|
|
49
|
+
bitDepth: buffer[dataStart + 8],
|
|
50
|
+
colorType: buffer[dataStart + 9],
|
|
51
|
+
interlace: buffer[dataStart + 12],
|
|
52
|
+
};
|
|
53
|
+
} else if (type === 'IDAT') {
|
|
54
|
+
idat.push(buffer.subarray(dataStart, dataStart + length));
|
|
55
|
+
} else if (type === 'IEND') {
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
pos = dataStart + length + 4;
|
|
59
|
+
}
|
|
60
|
+
if (!header) return { error: 'invalid-png' };
|
|
61
|
+
const channels = CHANNELS_BY_COLOR_TYPE[header.colorType];
|
|
62
|
+
if (header.bitDepth !== 8 || header.interlace !== 0 || !channels) return { error: 'unsupported-encoding' };
|
|
63
|
+
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = zlib.inflateSync(Buffer.concat(idat));
|
|
67
|
+
} catch {
|
|
68
|
+
return { error: 'invalid-png' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { width, height } = header;
|
|
72
|
+
const stride = width * channels;
|
|
73
|
+
if (raw.length < height * (stride + 1)) return { error: 'invalid-png' };
|
|
74
|
+
|
|
75
|
+
const pixels = Buffer.alloc(height * stride);
|
|
76
|
+
let prior = Buffer.alloc(stride);
|
|
77
|
+
let rpos = 0;
|
|
78
|
+
for (let y = 0; y < height; y += 1) {
|
|
79
|
+
const filter = raw[rpos];
|
|
80
|
+
rpos += 1;
|
|
81
|
+
const recon = pixels.subarray(y * stride, y * stride + stride);
|
|
82
|
+
for (let x = 0; x < stride; x += 1) {
|
|
83
|
+
const left = x >= channels ? recon[x - channels] : 0;
|
|
84
|
+
const up = prior[x];
|
|
85
|
+
const upLeft = x >= channels ? prior[x - channels] : 0;
|
|
86
|
+
let value = raw[rpos + x];
|
|
87
|
+
if (filter === 1) value += left;
|
|
88
|
+
else if (filter === 2) value += up;
|
|
89
|
+
else if (filter === 3) value += (left + up) >> 1;
|
|
90
|
+
else if (filter === 4) value += paeth(left, up, upLeft);
|
|
91
|
+
recon[x] = value & 0xff;
|
|
92
|
+
}
|
|
93
|
+
rpos += stride;
|
|
94
|
+
prior = recon;
|
|
95
|
+
}
|
|
96
|
+
return { width, height, channels, pixels };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function comparePng(pathA, pathB, { tolerance = 0 } = {}) {
|
|
100
|
+
let bufferA;
|
|
101
|
+
let bufferB;
|
|
102
|
+
try {
|
|
103
|
+
bufferA = fs.readFileSync(pathA);
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false, reason: 'missing-image', identical: false, diffPixels: null, diffRatio: null };
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
bufferB = fs.readFileSync(pathB);
|
|
109
|
+
} catch {
|
|
110
|
+
return { ok: false, reason: 'missing-image', identical: false, diffPixels: null, diffRatio: null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (bufferA.equals(bufferB)) {
|
|
114
|
+
return { ok: true, reason: 'identical', identical: true, diffPixels: 0, diffRatio: 0 };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const a = decodePng(bufferA);
|
|
118
|
+
const b = decodePng(bufferB);
|
|
119
|
+
if (a.error || b.error) {
|
|
120
|
+
return { ok: false, reason: a.error || b.error, identical: false, diffPixels: null, diffRatio: null };
|
|
121
|
+
}
|
|
122
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
123
|
+
return { ok: false, reason: 'dimension-mismatch', identical: false, diffPixels: null, diffRatio: null, dimsA: { width: a.width, height: a.height }, dimsB: { width: b.width, height: b.height } };
|
|
124
|
+
}
|
|
125
|
+
if (a.channels !== b.channels) {
|
|
126
|
+
return { ok: false, reason: 'channel-mismatch', identical: false, diffPixels: null, diffRatio: null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const total = a.width * a.height;
|
|
130
|
+
const threshold = Math.round(tolerance * 255);
|
|
131
|
+
let diffPixels = 0;
|
|
132
|
+
for (let p = 0; p < total; p += 1) {
|
|
133
|
+
let maxChannelDelta = 0;
|
|
134
|
+
for (let k = 0; k < a.channels; k += 1) {
|
|
135
|
+
const delta = Math.abs(a.pixels[p * a.channels + k] - b.pixels[p * a.channels + k]);
|
|
136
|
+
if (delta > maxChannelDelta) maxChannelDelta = delta;
|
|
137
|
+
}
|
|
138
|
+
if (maxChannelDelta > threshold) diffPixels += 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const diffRatio = total ? diffPixels / total : 0;
|
|
142
|
+
const ok = diffPixels === 0;
|
|
143
|
+
return {
|
|
144
|
+
ok,
|
|
145
|
+
reason: ok ? 'identical-pixels' : 'pixel-drift',
|
|
146
|
+
identical: false,
|
|
147
|
+
width: a.width,
|
|
148
|
+
height: a.height,
|
|
149
|
+
totalPixels: total,
|
|
150
|
+
diffPixels,
|
|
151
|
+
diffRatio: Math.round(diffRatio * 1e6) / 1e6,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function pixelRegressionGate({ level, currentPath, goldenPath, tolerance = 0 }) {
|
|
156
|
+
if (level !== PIXEL_REGRESSION_LEVEL) {
|
|
157
|
+
return { skipped: true, reason: 'pixel regression runs only at full-certification', ok: true };
|
|
158
|
+
}
|
|
159
|
+
return { ...comparePng(goldenPath, currentPath, { tolerance }), skipped: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The single sanctioned writer of a golden: copy the freshly-rendered image over it. No code path
|
|
163
|
+
// edits a golden in place, so regeneration stays deterministic and reviewable as one file swap.
|
|
164
|
+
|
|
165
|
+
export function regenerateGolden({ currentPath, goldenPath }) {
|
|
166
|
+
try {
|
|
167
|
+
fs.copyFileSync(currentPath, goldenPath);
|
|
168
|
+
return { ok: true, goldenPath };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { ok: false, reason: `regeneration failed: ${err.message}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
package/lib/providers/op-run.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* When CONSTRUCT_OP_ENV_FILE points to an `op run` env-file (KEY=op://… lines)
|
|
5
5
|
* and the `op` CLI is installed, long-lived Construct services are spawned through
|
|
6
|
-
* `op run --
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* resolved provider keys without a
|
|
6
|
+
* `op run --env-file <file> -- <cmd>`, so op:// references resolve once at startup
|
|
7
|
+
* into the process env. lib/service-manager.mjs calls wrapWithOpRun at each service
|
|
8
|
+
* spawn, so the memory server, OpenCode, the copilot bridge, the doctor daemon, and
|
|
9
|
+
* the oracle daemon all inherit resolved provider keys without a per-invocation
|
|
10
|
+
* shell wrapper. Masking stays on so resolved secrets are not echoed to service logs.
|
|
10
11
|
*
|
|
11
12
|
* Strictly opt-in: with the var unset, the file missing, or `op` absent, the
|
|
12
13
|
* command is returned unchanged and 1Password is never invoked. No other setup
|
|
@@ -51,7 +52,7 @@ export function wrapWithOpRun(command, args = [], { env = process.env, homeDir =
|
|
|
51
52
|
if (!file || !hasOp()) return { command, args, wrapped: false, envFile: null };
|
|
52
53
|
return {
|
|
53
54
|
command: 'op',
|
|
54
|
-
args: ['run',
|
|
55
|
+
args: ['run', `--env-file=${file}`, '--', command, ...args],
|
|
55
56
|
wrapped: true,
|
|
56
57
|
envFile: file,
|
|
57
58
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/providers/secret-audit-wiring.mjs — connect secret resolution to the durable
|
|
3
|
+
* audit trail.
|
|
4
|
+
*
|
|
5
|
+
* enableSecretAuditTrail routes the value-free events from secret-resolver into the
|
|
6
|
+
* append-only audit log (lib/audit-trail.mjs). Only actual op:// resolutions are
|
|
7
|
+
* recorded — the prompt-bearing, security-significant events — not cache hits or
|
|
8
|
+
* high-frequency plain reads, so the resolve path is not serialized behind a file
|
|
9
|
+
* lock. The materialized secret value is never available to this layer and is never
|
|
10
|
+
* written. Wiring is per-process: the CLI entrypoint enables it; worker and daemon
|
|
11
|
+
* processes are a tracked follow-up.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendAuditRecord } from '../audit-trail.mjs';
|
|
15
|
+
import { setSecretAuditSink } from './secret-resolver.mjs';
|
|
16
|
+
|
|
17
|
+
export function enableSecretAuditTrail({ file } = {}) {
|
|
18
|
+
setSecretAuditSink((event) => {
|
|
19
|
+
if (event.event !== 'secret.op_read' || event.cacheHit) return;
|
|
20
|
+
appendAuditRecord(
|
|
21
|
+
{
|
|
22
|
+
ts: new Date().toISOString(),
|
|
23
|
+
tool: 'secret-resolver',
|
|
24
|
+
action: 'op_read',
|
|
25
|
+
ref: event.ref,
|
|
26
|
+
ok: event.ok,
|
|
27
|
+
code: event.code ?? null,
|
|
28
|
+
},
|
|
29
|
+
file ? { file } : {},
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
}
|