@penpair/core 0.1.3 → 0.2.0
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/dist/adapters.d.ts +16 -1
- package/dist/adapters.js +8 -12
- package/dist/approvals.d.ts +32 -0
- package/dist/approvals.js +99 -0
- package/dist/atomic-output.d.ts +5 -0
- package/dist/atomic-output.js +28 -0
- package/dist/audit-rules.d.ts +60 -0
- package/dist/audit-rules.js +177 -0
- package/dist/backup-note.d.ts +4 -0
- package/dist/backup-note.js +28 -0
- package/dist/baseline-coverage.d.ts +25 -0
- package/dist/baseline-coverage.js +114 -0
- package/dist/baselines-config.d.ts +25 -0
- package/dist/baselines-config.js +52 -3
- package/dist/baselines.d.ts +37 -8
- package/dist/baselines.js +132 -52
- package/dist/cli-arguments.d.ts +7 -0
- package/dist/cli-arguments.js +35 -0
- package/dist/cli-main.d.ts +13 -1
- package/dist/cli-main.js +770 -139
- package/dist/client.d.ts +26 -5
- package/dist/client.js +46 -20
- package/dist/command-result.d.ts +159 -0
- package/dist/command-result.js +179 -0
- package/dist/coordination.d.ts +15 -0
- package/dist/coordination.js +122 -0
- package/dist/credentials.d.ts +21 -0
- package/dist/credentials.js +61 -0
- package/dist/deep-check.js +9 -5
- package/dist/design-spec.d.ts +55 -0
- package/dist/design-spec.js +135 -0
- package/dist/endpoint.d.ts +8 -1
- package/dist/endpoint.js +48 -4
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +8 -4
- package/dist/export.d.ts +18 -0
- package/dist/export.js +42 -7
- package/dist/file-archive.d.ts +16 -0
- package/dist/file-archive.js +32 -0
- package/dist/file-guard.d.ts +8 -0
- package/dist/file-guard.js +40 -0
- package/dist/formats.d.ts +4 -0
- package/dist/formats.js +28 -0
- package/dist/index.d.ts +33 -1
- package/dist/index.js +33 -1
- package/dist/inspection.d.ts +106 -0
- package/dist/inspection.js +344 -0
- package/dist/instance/config-schema.d.ts +422 -0
- package/dist/instance/config-schema.js +76 -0
- package/dist/instance/config.d.ts +29 -0
- package/dist/instance/config.js +102 -11
- package/dist/instance/connect.d.ts +10 -1
- package/dist/instance/connect.js +53 -12
- package/dist/instance/connection.d.ts +14 -0
- package/dist/instance/connection.js +13 -2
- package/dist/instance/init.js +1 -1
- package/dist/instance/setup.js +1 -1
- package/dist/instance/tty.js +3 -1
- package/dist/journal.d.ts +77 -0
- package/dist/journal.js +260 -0
- package/dist/kit-smoke.d.ts +31 -0
- package/dist/kit-smoke.js +83 -0
- package/dist/kit-source.d.ts +2 -0
- package/dist/kit-source.js +309 -0
- package/dist/kit.d.ts +25 -0
- package/dist/kit.js +90 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +79 -0
- package/dist/live-smoke.d.ts +8 -0
- package/dist/live-smoke.js +14 -0
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +102 -0
- package/dist/native-api.d.ts +15 -0
- package/dist/native-api.js +42 -0
- package/dist/onboard.d.ts +10 -0
- package/dist/onboard.js +15 -0
- package/dist/operation.d.ts +20 -1
- package/dist/operation.js +113 -13
- package/dist/png.d.ts +5 -0
- package/dist/png.js +7 -0
- package/dist/portable-tokens.d.ts +22 -0
- package/dist/portable-tokens.js +134 -0
- package/dist/probe.d.ts +6 -2
- package/dist/probe.js +19 -3
- package/dist/project-input.d.ts +6 -0
- package/dist/project-input.js +51 -0
- package/dist/project-snippets.js +12 -7
- package/dist/project-status.d.ts +24 -0
- package/dist/project-status.js +79 -0
- package/dist/registry.d.ts +2 -1
- package/dist/registry.js +17 -3
- package/dist/run-file.d.ts +26 -0
- package/dist/run-file.js +100 -0
- package/dist/shots.d.ts +43 -0
- package/dist/shots.js +90 -0
- package/dist/skill-instructions.d.ts +13 -0
- package/dist/skill-instructions.js +75 -0
- package/dist/skills.d.ts +7 -0
- package/dist/skills.js +59 -32
- package/dist/snippet.d.ts +3 -2
- package/dist/snippet.js +12 -8
- package/dist/snippets/archive.d.ts +9 -0
- package/dist/snippets/archive.js +10 -4
- package/dist/snippets/read.d.ts +1 -1
- package/dist/snippets/read.js +71 -40
- package/dist/structure-snapshot.d.ts +73 -0
- package/dist/structure-snapshot.js +171 -0
- package/dist/token-edit.d.ts +53 -0
- package/dist/token-edit.js +197 -0
- package/dist/tokens-diff.d.ts +19 -6
- package/dist/tokens-diff.js +89 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +12 -40
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +56 -0
- package/skills/penpair-design/references/project-facts.md +1 -1
- package/skills/penpair-design/references/write-protocol.md +15 -3
- package/skills/penpair-handoff/SKILL.md +14 -0
- package/skills/penpair-handoff/references/handoff-flow.md +11 -0
- package/skills/penpair-recipes/SKILL.md +16 -0
- package/skills/penpair-recipes/references/build-flow.md +11 -0
- package/skills/penpair-setup/SKILL.md +6 -2
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { runOnPage } from "./operation.js";
|
|
3
|
+
import { runSnippet, snippet, requirePage } from "./snippet.js";
|
|
4
|
+
import { PenpairError } from "./errors.js";
|
|
5
|
+
import { maxSnippetBytes } from "./client.js";
|
|
6
|
+
import { observationHash } from "./journal.js";
|
|
7
|
+
import { version } from "./version.js";
|
|
8
|
+
export const inspectionFields = ["geometry", "text", "layout", "component", "tokens", "style", "metadata"];
|
|
9
|
+
// A deterministic change detector, not an authentication hash. Final reassembled evidence receives SHA-256 on the host.
|
|
10
|
+
function fingerprint(text) {
|
|
11
|
+
let a = 0xdeadbeef, b = 0x41c6ce57;
|
|
12
|
+
for (let i = 0; i < text.length; i++) {
|
|
13
|
+
const code = text.charCodeAt(i);
|
|
14
|
+
a = Math.imul(a ^ code, 2654435761);
|
|
15
|
+
b = Math.imul(b ^ code, 1597334677);
|
|
16
|
+
}
|
|
17
|
+
a = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909);
|
|
18
|
+
b = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(a ^ (a >>> 13), 3266489909);
|
|
19
|
+
return (4294967296 * (2097151 & b) + (a >>> 0)).toString(16);
|
|
20
|
+
}
|
|
21
|
+
function utf8Bytes(text) {
|
|
22
|
+
let count = 0;
|
|
23
|
+
for (let i = 0; i < text.length; i++) {
|
|
24
|
+
const code = text.charCodeAt(i);
|
|
25
|
+
if (code < 128)
|
|
26
|
+
count++;
|
|
27
|
+
else if (code < 2048)
|
|
28
|
+
count += 2;
|
|
29
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length && text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) {
|
|
30
|
+
count += 4;
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
else
|
|
34
|
+
count += 3;
|
|
35
|
+
}
|
|
36
|
+
return count;
|
|
37
|
+
}
|
|
38
|
+
const body = `
|
|
39
|
+
const started = Date.now(), f = penpot.currentFile;
|
|
40
|
+
const stale = () => ({ok:true,stale:true,message:"Inspection cursor is stale; start a fresh capture"});
|
|
41
|
+
if (CFG.expected && f.id !== CFG.expected.fileId) return stale();
|
|
42
|
+
${requirePage}
|
|
43
|
+
const fileId = f.id, pageId = CFG.kind === "shape" ? penpot.currentPage.id : null, revision = f.revn;
|
|
44
|
+
if (CFG.expected && (CFG.expected.pageId !== pageId || CFG.expected.revision !== revision || CFG.expected.queryHash !== CFG.queryHash)) return stale();
|
|
45
|
+
const fingerprint = (${fingerprint.toString()}), utf8Bytes = (${utf8Bytes.toString()});
|
|
46
|
+
const reasons = new Set(), counts = {visited:0,returned:0,total:null,pendingAtLeast:0,depthOmitted:0,unknownFields:0};
|
|
47
|
+
const document = {schemaVersion:1,kind:CFG.kind === "shape" ? "shape-inspection" : "token-inspection",fileId,pageId,revision,complete:true,producer:{name:"penpair",version:CFG.producerVersion},counts,reasons:[],penpotApiVersion:typeof penpot.version === "string"?penpot.version:null};
|
|
48
|
+
const overTime = () => Date.now()-started > CFG.timeBudgetMs;
|
|
49
|
+
const clone = (value, unknowns, key) => {
|
|
50
|
+
try {
|
|
51
|
+
if (value === undefined) { unknowns.push(key); return null; }
|
|
52
|
+
if (typeof value === "string" && value.length > CFG.documentBytes) { reasons.add("document-byte-budget"); unknowns.push(key); return null; }
|
|
53
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
54
|
+
if (typeof value === "number") { if (Number.isFinite(value)) return value; unknowns.push(key);return null; }
|
|
55
|
+
const encoded = JSON.stringify(value, (_key, entry) => {
|
|
56
|
+
if (["function","symbol","bigint"].includes(typeof entry) || typeof entry === "number" && !Number.isFinite(entry)) throw new Error("non-JSON data");
|
|
57
|
+
return entry === undefined ? null : entry;
|
|
58
|
+
});
|
|
59
|
+
if (encoded === undefined) { unknowns.push(key); return null; }
|
|
60
|
+
if (encoded.length > CFG.documentBytes) { reasons.add("document-byte-budget"); unknowns.push(key); return null; }
|
|
61
|
+
return JSON.parse(encoded);
|
|
62
|
+
} catch { unknowns.push(key); return null; }
|
|
63
|
+
};
|
|
64
|
+
const read = (object,key,unknowns,label=key) => { try { return clone(object[key],unknowns,label); } catch { unknowns.push(label); return null; } };
|
|
65
|
+
const pick = (object,keys,unknowns,prefix) => Object.fromEntries(keys.map(key=>[key,read(object,key,unknowns,prefix+"."+key)]));
|
|
66
|
+
let estimatedBytes = 1024;
|
|
67
|
+
if (CFG.kind === "shape") {
|
|
68
|
+
const root = CFG.shapeId === "@page-root" ? penpot.currentPage.root : penpot.currentPage.getShapeById(CFG.shapeId);
|
|
69
|
+
if (!root) return {ok:false,message:"No shape with the requested ID on the selected page"};
|
|
70
|
+
document.scope = {shapeId:root.id,selector:CFG.shapeId,fields:CFG.fields,depth:CFG.depth,maxNodes:CFG.maxNodes};
|
|
71
|
+
const nodes = document.nodes = [], stack = [{shape:root,parentId:null,index:0,depth:0}], seen = new Set();
|
|
72
|
+
let skipped = 0;
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
if (counts.visited >= CFG.maxNodes) { reasons.add("node-budget"); break; }
|
|
75
|
+
if (overTime()) { reasons.add("time-budget"); break; }
|
|
76
|
+
const item = stack.pop(), s = item.shape;
|
|
77
|
+
if (!s || typeof s.id !== "string" || !s.id || seen.has(s.id)) { reasons.add("invalid-or-repeated-identity"); break; }
|
|
78
|
+
seen.add(s.id); counts.visited++;
|
|
79
|
+
const children = Array.isArray(s.children) ? s.children : [];
|
|
80
|
+
if (item.depth <= CFG.depth) {
|
|
81
|
+
const unknowns = [], row = {id:s.id,parentId:item.parentId,index:item.index,depth:item.depth,type:read(s,"type",unknowns),name:read(s,"name",unknowns),childIds:children.slice(0,CFG.maxNodes).map(c=>c.id),childrenTotal:children.length,unknowns};
|
|
82
|
+
if (children.length > CFG.maxNodes) reasons.add("node-budget");
|
|
83
|
+
if (CFG.fields.includes("geometry")) row.geometry = pick(s,["x","y","width","height","rotation","hidden","blocked"],unknowns,"geometry");
|
|
84
|
+
if (CFG.fields.includes("text")) row.text = s.type === "text" ? {characters:read(s,"characters",unknowns),typography:pick(s,["fontId","fontFamily","fontVariantId","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","growType","direction","align","verticalAlign"],unknowns,"typography")} : null;
|
|
85
|
+
if (row.text && Object.values(row.text.typography).includes("mixed")) unknowns.push("text.mixedRangeStyles");
|
|
86
|
+
if (CFG.fields.includes("layout")) {
|
|
87
|
+
row.layout = {};
|
|
88
|
+
for (const [key,keys] of [["flex",["dir","wrap","alignItems","alignContent","justifyContent","rowGap","columnGap","topPadding","rightPadding","bottomPadding","leftPadding","horizontalSizing","verticalSizing"]],["layoutChild",["absolute","zIndex","horizontalSizing","verticalSizing","alignSelf","topMargin","rightMargin","bottomMargin","leftMargin","minWidth","maxWidth","minHeight","maxHeight"]]]) {
|
|
89
|
+
try { row.layout[key] = s[key] ? pick(s[key],keys,unknowns,"layout."+key) : null; } catch { row.layout[key]=null; unknowns.push("layout."+key); }
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const grid=s.grid;
|
|
93
|
+
row.layout.grid=grid?pick(grid,["dir","alignItems","alignContent","justifyItems","justifyContent","rowGap","columnGap","topPadding","rightPadding","bottomPadding","leftPadding","horizontalSizing","verticalSizing"],unknowns,"layout.grid"):null;
|
|
94
|
+
if(grid)for(const key of ["rows","columns"])row.layout.grid[key]=(grid[key]||[]).map((track,index)=>pick(track,["type","value"],unknowns,"layout.grid."+key+"."+index));
|
|
95
|
+
} catch {row.layout.grid=null;unknowns.push("layout.grid");}
|
|
96
|
+
try {row.layout.cell=s.layoutCell?pick(s.layoutCell,["row","rowSpan","column","columnSpan","areaName","position"],unknowns,"layout.cell"):null;} catch {row.layout.cell=null;unknowns.push("layout.cell");}
|
|
97
|
+
row.layout.clipContent = read(s,"clipContent",unknowns,"layout.clipContent");
|
|
98
|
+
}
|
|
99
|
+
if (CFG.fields.includes("tokens")) row.tokens = read(s,"tokens",unknowns);
|
|
100
|
+
if (CFG.fields.includes("style")) row.style = pick(s,["fills","strokes","shadows","opacity","blendMode","borderRadiusTopLeft","borderRadiusTopRight","borderRadiusBottomRight","borderRadiusBottomLeft"],unknowns,"style");
|
|
101
|
+
if (CFG.fields.includes("component")) {
|
|
102
|
+
row.component = {};
|
|
103
|
+
for (const key of ["isComponentInstance","isComponentMainInstance","isComponentCopyInstance","isComponentRoot"]) {
|
|
104
|
+
try { if (typeof s[key] !== "function") throw new Error(); row.component[key]=clone(s[key](),unknowns,"component."+key); } catch { row.component[key]=null; unknowns.push("component."+key); }
|
|
105
|
+
}
|
|
106
|
+
try { if (typeof s.component !== "function") throw new Error(); const c=s.component(); row.component.source=c?pick(c,["id","libraryId","name","path"],unknowns,"component.source"):null; } catch { row.component.source=null; unknowns.push("component.source"); }
|
|
107
|
+
for (const key of ["componentRefShape","componentRoot","componentHead"]) {
|
|
108
|
+
try { if (typeof s[key] !== "function") throw new Error(); const ref=s[key](); row.component[key]=ref?ref.id:null; } catch { row.component[key]=null; unknowns.push("component."+key); }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (CFG.fields.includes("metadata")) {
|
|
112
|
+
row.metadata = {interactions:read(s,"interactions",unknowns)};
|
|
113
|
+
try { if (typeof s.getSharedPluginData !== "function") throw new Error(); row.metadata.penpairSpec=s.getSharedPluginData("penpair","spec") || null; } catch { row.metadata.penpairSpec=null; unknowns.push("metadata.penpairSpec"); }
|
|
114
|
+
}
|
|
115
|
+
const size = utf8Bytes(JSON.stringify(row));
|
|
116
|
+
if (estimatedBytes + size > CFG.documentBytes) { reasons.add("document-byte-budget"); break; }
|
|
117
|
+
estimatedBytes += size+1; nodes.push(row); counts.returned++; counts.unknownFields += unknowns.length;
|
|
118
|
+
} else { counts.depthOmitted++; reasons.add("depth"); }
|
|
119
|
+
const room = Math.max(0,CFG.maxNodes-counts.visited-stack.length), take=Math.min(children.length,room);
|
|
120
|
+
skipped += children.length-take;
|
|
121
|
+
if (take < children.length) reasons.add("node-budget");
|
|
122
|
+
for (let i=take-1;i>=0;i--) stack.push({shape:children[i],parentId:s.id,index:i,depth:item.depth+1});
|
|
123
|
+
}
|
|
124
|
+
counts.pendingAtLeast=stack.length+skipped;
|
|
125
|
+
if (!stack.length && skipped===0 && !reasons.has("document-byte-budget") && !reasons.has("invalid-or-repeated-identity")) counts.total=counts.visited;
|
|
126
|
+
} else {
|
|
127
|
+
document.sets=[]; document.themes=[];
|
|
128
|
+
const tokens=penpot.library.local.tokens;
|
|
129
|
+
outer: for (const [index,set] of tokens.sets.entries()) {
|
|
130
|
+
if (counts.visited >= CFG.maxNodes || overTime()) { reasons.add(overTime()?"time-budget":"node-budget"); break; }
|
|
131
|
+
counts.visited++;
|
|
132
|
+
const setUnknowns=[],target={order:index,name:read(set,"name",setUnknowns),active:!!set.active,tokens:[],tokensTotal:set.tokens.length,unknowns:setUnknowns};
|
|
133
|
+
const setSize=utf8Bytes(JSON.stringify(target));
|
|
134
|
+
if (estimatedBytes+setSize>CFG.documentBytes) {reasons.add("document-byte-budget");break;}
|
|
135
|
+
estimatedBytes+=setSize+1;counts.unknownFields+=setUnknowns.length;document.sets.push(target);
|
|
136
|
+
for (const token of set.tokens) {
|
|
137
|
+
if (counts.visited >= CFG.maxNodes || overTime()) { reasons.add(overTime()?"time-budget":"node-budget"); break outer; }
|
|
138
|
+
const unknowns=[], value={name:read(token,"name",unknowns),type:read(token,"type",unknowns),value:read(token,"value",unknowns),description:read(token,"description",unknowns),resolvedValue:read(token,"resolvedValue",unknowns),unknowns};
|
|
139
|
+
const size=utf8Bytes(JSON.stringify(value));
|
|
140
|
+
if (estimatedBytes+size>CFG.documentBytes) { reasons.add("document-byte-budget"); break outer; }
|
|
141
|
+
target.tokens.push(value); estimatedBytes+=size+1; counts.visited++; counts.returned++; counts.unknownFields+=unknowns.length;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const theme of tokens.themes) {
|
|
145
|
+
if (counts.visited >= CFG.maxNodes || overTime()) { reasons.add(overTime()?"time-budget":"node-budget"); break; }
|
|
146
|
+
const themeUnknowns=[],value={group:read(theme,"group",themeUnknowns),name:read(theme,"name",themeUnknowns),active:!!theme.active,sets:clone((theme.activeSets||[]).map(s=>typeof s==="string"?s:s.name),themeUnknowns,"sets"),unknowns:themeUnknowns};
|
|
147
|
+
const size=utf8Bytes(JSON.stringify(value));
|
|
148
|
+
if (estimatedBytes+size>CFG.documentBytes) { reasons.add("document-byte-budget"); break; }
|
|
149
|
+
document.themes.push(value); estimatedBytes+=size+1; counts.unknownFields+=themeUnknowns.length; counts.visited++;
|
|
150
|
+
}
|
|
151
|
+
if (!reasons.size) counts.total=counts.visited;
|
|
152
|
+
}
|
|
153
|
+
if (f.id!==fileId || f.revn!==revision || CFG.kind === "shape" && penpot.currentPage.id!==pageId) return stale();
|
|
154
|
+
if (overTime()) reasons.add("time-budget");
|
|
155
|
+
document.complete=reasons.size===0; document.reasons=[...reasons];
|
|
156
|
+
const serialized=JSON.stringify(document), digest=fingerprint(serialized);
|
|
157
|
+
if (CFG.expected && CFG.expected.digest!==digest) return stale();
|
|
158
|
+
const offset=CFG.expected?CFG.expected.offset:0;
|
|
159
|
+
if (offset<0 || offset>=serialized.length || offset>0 && /[\\uDC00-\\uDFFF]/.test(serialized[offset]) && /[\\uD800-\\uDBFF]/.test(serialized[offset-1])) return {ok:false,message:"Invalid chunk offset"};
|
|
160
|
+
const header={ok:true,schemaVersion:1,kind:"inspection-chunk",fileId,pageId,revision,queryHash:CFG.queryHash,digest,digestAlgorithm:"cyrb53-change-detector",sourceComplete:document.complete,counts,offset,totalCodeUnits:serialized.length,observationMs:0};
|
|
161
|
+
const response=(end)=>({...header,chunk:serialized.slice(offset,end),nextOffset:end<serialized.length?end:null,complete:document.complete&&offset===0&&end===serialized.length});
|
|
162
|
+
let low=offset+1,high=Math.min(serialized.length,offset+CFG.responseBytes),best=null;
|
|
163
|
+
while (low<=high) {
|
|
164
|
+
const middle=Math.floor((low+high)/2); let end=middle;
|
|
165
|
+
if (end<serialized.length && /[\\uDC00-\\uDFFF]/.test(serialized[end]) && /[\\uD800-\\uDBFF]/.test(serialized[end-1])) end--;
|
|
166
|
+
if (end<=offset) {low=middle+1;continue;}
|
|
167
|
+
const candidate=response(end), envelope={result:candidate,log:""};
|
|
168
|
+
const size=Math.max(utf8Bytes(JSON.stringify(envelope)),utf8Bytes(JSON.stringify({result:JSON.stringify(candidate),log:""})))+utf8Bytes(JSON.stringify(envelope));
|
|
169
|
+
if (size<=CFG.responseBytes-128) {best=candidate;low=middle+1;} else high=middle-1;
|
|
170
|
+
}
|
|
171
|
+
if (!best) return {ok:false,message:"Response budget is too small for inspection metadata"};
|
|
172
|
+
best.observationMs=Date.now()-started;
|
|
173
|
+
return best;`;
|
|
174
|
+
export function validateInspectionQuery(query) {
|
|
175
|
+
const kind = query.kind ?? "shape";
|
|
176
|
+
if (!["shape", "tokens"].includes(kind) || kind === "shape" && (typeof query.shapeId !== "string" || !query.shapeId || query.shapeId.length > 200 || typeof query.page !== "string" || !query.page || query.page.length > 500))
|
|
177
|
+
throw new Error("Shape inspection requires an ID and explicit page");
|
|
178
|
+
if (kind === "tokens" && (query.shapeId !== undefined || query.page !== undefined || query.fields !== undefined || query.depth !== undefined))
|
|
179
|
+
throw new Error("Token inspection is file-scoped and does not accept shape field/depth selectors");
|
|
180
|
+
const requested = query.fields ?? inspectionFields;
|
|
181
|
+
if (!Array.isArray(requested) || requested.some(field => !inspectionFields.includes(field)))
|
|
182
|
+
throw new Error("Unknown inspection field group; supported: " + inspectionFields.join(", "));
|
|
183
|
+
const fields = [...new Set(requested)].sort();
|
|
184
|
+
const config = { kind, shapeId: query.shapeId, page: query.page, fields, depth: query.depth ?? 64, maxNodes: query.maxNodes ?? 20000, responseBytes: query.responseBytes ?? 65536, timeBudgetMs: query.timeBudgetMs ?? 5000, documentBytes: query.documentBytes ?? 16 * 1024 * 1024 };
|
|
185
|
+
for (const [key, min, max] of [["depth", 0, 10000], ["maxNodes", 1, 50000], ["responseBytes", 4096, 262144], ["timeBudgetMs", 1, 30000], ["documentBytes", 4096, 128 * 1024 * 1024]])
|
|
186
|
+
if (!Number.isInteger(config[key]) || config[key] < min || config[key] > max)
|
|
187
|
+
throw new Error(`${key} must be an integer from ${min} to ${max}`);
|
|
188
|
+
const queryHash = observationHash({ kind, shapeId: config.shapeId, page: config.page, fields, depth: config.depth, maxNodes: config.maxNodes, documentBytes: config.documentBytes, timeBudgetMs: config.timeBudgetMs });
|
|
189
|
+
return { ...config, queryHash, producerVersion: version };
|
|
190
|
+
}
|
|
191
|
+
function decodeCursor(cursor) {
|
|
192
|
+
try {
|
|
193
|
+
if (cursor.length > 8192 || !/^[A-Za-z0-9_-]+$/.test(cursor))
|
|
194
|
+
throw new Error();
|
|
195
|
+
const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
196
|
+
if (value.schemaVersion !== 1 || typeof value.fileId !== "string" || !value.fileId || value.pageId !== null && typeof value.pageId !== "string" || !Number.isInteger(value.revision) || !/^[a-f0-9]{64}$/.test(value.queryHash) || !/^[a-f0-9]+$/.test(value.digest) || !Number.isInteger(value.offset) || value.offset < 0)
|
|
197
|
+
throw new Error();
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
throw new Error("Invalid inspection cursor; start a fresh capture");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function cursorAt(chunk, offset) {
|
|
205
|
+
return Buffer.from(JSON.stringify({ schemaVersion: 1, fileId: chunk.fileId, pageId: chunk.pageId, revision: chunk.revision, queryHash: chunk.queryHash, digest: chunk.digest, offset })).toString("base64url");
|
|
206
|
+
}
|
|
207
|
+
export async function inspectChunk(client, query, requestTimeoutMs) {
|
|
208
|
+
const config = validateInspectionQuery(query), expected = query.cursor ? decodeCursor(query.cursor) : undefined;
|
|
209
|
+
if (expected && expected.queryHash !== config.queryHash)
|
|
210
|
+
throw new Error("Inspection query changed; discard the cursor and start fresh");
|
|
211
|
+
const source = snippet("inspect-chunk", body, { ...config, expected }, "read");
|
|
212
|
+
const options = { effect: "read", retries: 0, timeoutMs: Math.max(1, Math.min(requestTimeoutMs ?? 120000, config.timeBudgetMs + 2000)) };
|
|
213
|
+
const record = config.kind === "shape" ? await runOnPage(client, source, config.page, options) : await runSnippet(client, source, options);
|
|
214
|
+
if (record.stale === true)
|
|
215
|
+
throw new PenpairError("stale-inspection", "Inspection cursor is stale; start a fresh capture");
|
|
216
|
+
const value = record;
|
|
217
|
+
if (record.ok !== true || value.schemaVersion !== 1 || value.kind !== "inspection-chunk" || typeof value.fileId !== "string" || value.pageId !== null && typeof value.pageId !== "string" || !Number.isInteger(value.revision) || value.queryHash !== config.queryHash || typeof value.digest !== "string" || !/^[a-f0-9]{1,16}$/.test(value.digest) || typeof value.sourceComplete !== "boolean" || typeof value.complete !== "boolean" || !Number.isInteger(value.offset) || !Number.isInteger(value.totalCodeUnits) || value.totalCodeUnits < 1 || value.digestAlgorithm !== "cyrb53-change-detector" || !value.counts || typeof value.counts !== "object" || Object.values(value.counts).some(count => count !== null && (!Number.isInteger(count) || count < 0)) || !Number.isFinite(value.observationMs) || value.observationMs < 0 || typeof value.chunk !== "string" || !value.chunk.length || value.offset !== (expected?.offset ?? 0) || value.offset + value.chunk.length > value.totalCodeUnits || value.nextOffset !== (value.offset + value.chunk.length < value.totalCodeUnits ? value.offset + value.chunk.length : null))
|
|
218
|
+
throw new Error("Malformed inspection chunk evidence");
|
|
219
|
+
if (value.complete !== (value.sourceComplete && value.offset === 0 && value.nextOffset === null) || value.offset > 0 && /^[\uDC00-\uDFFF]/.test(value.chunk) || value.nextOffset !== null && /[\uD800-\uDBFF]$/.test(value.chunk))
|
|
220
|
+
throw new Error("Malformed inspection completeness or Unicode boundary");
|
|
221
|
+
const metrics = record.execution?.metrics ?? { requestSnippetBytes: Buffer.byteLength(source.code), responsePayloadBytes: Buffer.byteLength(JSON.stringify(record)) };
|
|
222
|
+
if (!Number.isInteger(metrics.requestSnippetBytes) || metrics.requestSnippetBytes <= 0 || metrics.requestSnippetBytes > maxSnippetBytes || !Number.isInteger(metrics.responsePayloadBytes) || metrics.responsePayloadBytes <= 0)
|
|
223
|
+
throw new Error("Inspection metrics are missing or invalid");
|
|
224
|
+
if (metrics.responsePayloadBytes > config.responseBytes)
|
|
225
|
+
throw new PenpairError("incomplete-output", "Inspection response exceeded the configured payload budget");
|
|
226
|
+
const { execution: _execution, ok: _ok, ...data } = record;
|
|
227
|
+
const result = { ...data, metrics, nextCursor: null };
|
|
228
|
+
result.nextCursor = result.nextOffset === null ? null : cursorAt(result, result.nextOffset);
|
|
229
|
+
if (result.offset === 0 && result.nextOffset === null)
|
|
230
|
+
reassembleInspection([result]);
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
/** Validate structural evidence independently of the transport envelope. Unknown API fields remain explicit nulls. */
|
|
234
|
+
export function validateInspectionDocument(input) {
|
|
235
|
+
if (!input || typeof input !== "object")
|
|
236
|
+
throw new Error("Malformed inspection document");
|
|
237
|
+
const value = input;
|
|
238
|
+
const counts = value.counts;
|
|
239
|
+
if (value.schemaVersion !== 1 || !["shape-inspection", "token-inspection"].includes(value.kind) || typeof value.fileId !== "string" || !value.fileId || !Number.isInteger(value.revision) || value.revision < 0 || typeof value.complete !== "boolean" || !Array.isArray(value.reasons) || value.reasons.some(reason => typeof reason !== "string") || value.complete && value.reasons.length > 0 || !value.complete && !value.reasons.length || !counts || typeof counts !== "object" || Object.values(counts).some(count => count !== null && (!Number.isInteger(count) || count < 0)) || !Number.isInteger(counts.visited) || !Number.isInteger(counts.returned) || value.producer?.name !== "penpair" || typeof value.producer?.version !== "string")
|
|
240
|
+
throw new Error("Malformed inspection provenance/counts");
|
|
241
|
+
if (value.kind === "shape-inspection") {
|
|
242
|
+
const scope = value.scope;
|
|
243
|
+
if (typeof value.pageId !== "string" || !value.pageId || !scope || typeof scope.shapeId !== "string" || !Array.isArray(scope.fields) || scope.fields.some(field => !inspectionFields.includes(field)) || !Number.isInteger(scope.depth) || !Array.isArray(value.nodes) || counts.returned !== value.nodes.length)
|
|
244
|
+
throw new Error("Malformed shape inspection scope");
|
|
245
|
+
const nodes = new Map();
|
|
246
|
+
for (const row of value.nodes) {
|
|
247
|
+
if (!row || typeof row.id !== "string" || !row.id || nodes.has(row.id) || row.parentId !== null && typeof row.parentId !== "string" || !Number.isInteger(row.index) || Number(row.index) < 0 || !Number.isInteger(row.depth) || Number(row.depth) < 0 || row.type !== null && typeof row.type !== "string" || row.name !== null && typeof row.name !== "string" || !Array.isArray(row.childIds) || row.childIds.some(id => typeof id !== "string") || !Number.isInteger(row.childrenTotal) || Number(row.childrenTotal) < row.childIds.length || !Array.isArray(row.unknowns) || row.unknowns.some(key => typeof key !== "string"))
|
|
248
|
+
throw new Error("Malformed shape node evidence");
|
|
249
|
+
for (const field of scope.fields)
|
|
250
|
+
if (!(field in row))
|
|
251
|
+
throw new Error("Inspection omitted a requested field group");
|
|
252
|
+
if (row.geometry && typeof row.geometry === "object")
|
|
253
|
+
for (const key of ["x", "y", "width", "height", "rotation"]) {
|
|
254
|
+
const entry = row.geometry[key];
|
|
255
|
+
if (entry !== null && (typeof entry !== "number" || !Number.isFinite(entry)))
|
|
256
|
+
throw new Error("Malformed shape geometry");
|
|
257
|
+
}
|
|
258
|
+
nodes.set(row.id, row);
|
|
259
|
+
}
|
|
260
|
+
if (value.nodes.length && value.nodes[0].id !== scope.shapeId)
|
|
261
|
+
throw new Error("Inspection root identity does not match its scope");
|
|
262
|
+
for (const row of value.nodes) {
|
|
263
|
+
if (row === value.nodes[0] && (row.parentId !== null || row.depth !== 0 || row.index !== 0) || row !== value.nodes[0] && row.parentId === null)
|
|
264
|
+
throw new Error("Inspection has an invalid subtree root");
|
|
265
|
+
if (row.parentId !== null) {
|
|
266
|
+
const parent = nodes.get(String(row.parentId));
|
|
267
|
+
if (!parent || parent.childIds[Number(row.index)] !== row.id || Number(row.depth) !== Number(parent.depth) + 1)
|
|
268
|
+
throw new Error("Inspection parent/child evidence is inconsistent");
|
|
269
|
+
}
|
|
270
|
+
if (value.complete && (row.childIds.length !== row.childrenTotal || row.childIds.some((id, index) => !nodes.has(id) || nodes.get(id).parentId !== row.id || nodes.get(id).index !== index)))
|
|
271
|
+
throw new Error("Complete inspection is missing a child");
|
|
272
|
+
}
|
|
273
|
+
if (value.complete && (!value.nodes.length || counts.total !== value.nodes.length || counts.visited !== value.nodes.length))
|
|
274
|
+
throw new Error("Complete inspection node counts disagree");
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
if (value.pageId !== null || !Array.isArray(value.sets) || !Array.isArray(value.themes))
|
|
278
|
+
throw new Error("Malformed token inspection scope");
|
|
279
|
+
let tokens = 0;
|
|
280
|
+
for (const set of value.sets) {
|
|
281
|
+
if (!set || !Number.isInteger(set.order) || !Array.isArray(set.tokens))
|
|
282
|
+
throw new Error("Malformed token set evidence");
|
|
283
|
+
tokens += set.tokens.length;
|
|
284
|
+
}
|
|
285
|
+
if (counts.returned !== tokens || value.complete && counts.visited !== tokens + value.sets.length + value.themes.length)
|
|
286
|
+
throw new Error("Token inspection counts disagree");
|
|
287
|
+
}
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
export function reassembleInspection(chunks) {
|
|
291
|
+
if (!chunks.length || chunks[0].offset !== 0 || chunks.at(-1).nextOffset !== null)
|
|
292
|
+
throw new Error("Inspection is missing its beginning or final chunk");
|
|
293
|
+
const first = chunks[0];
|
|
294
|
+
let offset = 0;
|
|
295
|
+
for (const chunk of chunks) {
|
|
296
|
+
if (chunk.offset !== offset || chunk.fileId !== first.fileId || chunk.pageId !== first.pageId || chunk.revision !== first.revision || chunk.queryHash !== first.queryHash || chunk.digest !== first.digest || chunk.totalCodeUnits !== first.totalCodeUnits || chunk.sourceComplete !== first.sourceComplete)
|
|
297
|
+
throw new Error("Inspection chunks are mixed, missing or stale");
|
|
298
|
+
offset += chunk.chunk.length;
|
|
299
|
+
}
|
|
300
|
+
const serialized = chunks.map(chunk => chunk.chunk).join("");
|
|
301
|
+
if (serialized.length !== first.totalCodeUnits || fingerprint(serialized) !== first.digest)
|
|
302
|
+
throw new Error("Inspection chunk content does not match its fingerprint");
|
|
303
|
+
const value = validateInspectionDocument(JSON.parse(serialized));
|
|
304
|
+
if (value.schemaVersion !== 1 || !["shape-inspection", "token-inspection"].includes(value.kind) || value.fileId !== first.fileId || value.pageId !== first.pageId || value.revision !== first.revision || value.complete !== first.sourceComplete)
|
|
305
|
+
throw new Error("Inspection document lacks matching provenance");
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
export function validateCaptureBudget(budget = {}) {
|
|
309
|
+
const maxCalls = budget.maxCalls ?? 256, maxTotalBytes = budget.maxTotalBytes ?? 16 * 1024 * 1024, timeoutMs = budget.timeoutMs ?? 60000;
|
|
310
|
+
if (!Number.isInteger(maxCalls) || maxCalls < 1 || maxCalls > 2048 || !Number.isInteger(maxTotalBytes) || maxTotalBytes < 4096 || maxTotalBytes > 128 * 1024 * 1024 || !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300000)
|
|
311
|
+
throw new Error("Invalid inspection capture budgets");
|
|
312
|
+
return { maxCalls, maxTotalBytes, timeoutMs };
|
|
313
|
+
}
|
|
314
|
+
export async function captureInspection(client, query, budget = {}) {
|
|
315
|
+
if (query.cursor)
|
|
316
|
+
throw new Error("Full capture starts at offset zero; use inspectChunk for manual cursor continuation");
|
|
317
|
+
const { maxCalls, maxTotalBytes, timeoutMs } = validateCaptureBudget(budget);
|
|
318
|
+
validateInspectionQuery(query);
|
|
319
|
+
const started = Date.now(), chunks = [];
|
|
320
|
+
const metrics = { calls: 0, requestSnippetBytes: 0, responsePayloadBytes: 0, maxRequestSnippetBytes: 0, maxResponsePayloadBytes: 0, assembledBytes: 0, durationMs: 0 };
|
|
321
|
+
let cursor;
|
|
322
|
+
const partial = (reason, nextCursor = cursor ?? null) => ({ schemaVersion: 1, kind: "inspection-partial", fileId: chunks[0]?.fileId ?? null, pageId: chunks[0]?.pageId ?? null, complete: false, reason, chunks, nextCursor, metrics: { ...metrics, durationMs: Date.now() - started } });
|
|
323
|
+
while (true) {
|
|
324
|
+
if (metrics.calls >= maxCalls || Date.now() - started >= timeoutMs)
|
|
325
|
+
return partial("Capture call/time budget exhausted");
|
|
326
|
+
const chunk = await inspectChunk(client, { ...query, cursor }, Math.max(1, timeoutMs - (Date.now() - started)));
|
|
327
|
+
metrics.calls++;
|
|
328
|
+
metrics.requestSnippetBytes += chunk.metrics.requestSnippetBytes;
|
|
329
|
+
metrics.responsePayloadBytes += chunk.metrics.responsePayloadBytes;
|
|
330
|
+
metrics.maxRequestSnippetBytes = Math.max(metrics.maxRequestSnippetBytes, chunk.metrics.requestSnippetBytes);
|
|
331
|
+
metrics.maxResponsePayloadBytes = Math.max(metrics.maxResponsePayloadBytes, chunk.metrics.responsePayloadBytes);
|
|
332
|
+
const size = Buffer.byteLength(chunk.chunk, "utf8");
|
|
333
|
+
if (metrics.assembledBytes + size > maxTotalBytes)
|
|
334
|
+
return partial("Capture total-byte budget exhausted", cursorAt(chunk, chunk.offset));
|
|
335
|
+
metrics.assembledBytes += size;
|
|
336
|
+
chunks.push(chunk);
|
|
337
|
+
if (!chunk.nextCursor) {
|
|
338
|
+
const document = reassembleInspection(chunks);
|
|
339
|
+
metrics.durationMs = Date.now() - started;
|
|
340
|
+
return { ...document, capturedAt: new Date().toISOString(), sha256: createHash("sha256").update(chunks.map(entry => entry.chunk).join(""), "utf8").digest("hex"), metrics };
|
|
341
|
+
}
|
|
342
|
+
cursor = chunk.nextCursor;
|
|
343
|
+
}
|
|
344
|
+
}
|