@tendrilapp/cli 0.1.18 → 0.1.19
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/SKILL.md +22 -0
- package/dist/tendril-mcp.js +10 -2
- package/dist/tendril.js +450 -101
- package/package.json +1 -1
package/dist/SKILL.md
CHANGED
|
@@ -20,6 +20,28 @@ Non-negotiables (the CLI enforces these; do not fight them):
|
|
|
20
20
|
- Sub-bar results are honest, not failures to hide: exit 5 ships the
|
|
21
21
|
bundle with real scores. Report them as they are.
|
|
22
22
|
|
|
23
|
+
First-run friction: if tendril/Figma tool calls are hitting permission
|
|
24
|
+
prompts, tell the user ONE approval can replace them all and offer
|
|
25
|
+
`tendril_permissions` with `write: true` — it merges the pipeline's
|
|
26
|
+
per-tool allowlist into the project's `.claude/settings.local.json`
|
|
27
|
+
(idempotent, touches nothing else; the user reloads the session to
|
|
28
|
+
apply). Never run it unoffered: it edits the user's settings.
|
|
29
|
+
|
|
30
|
+
Batch runs (several components in one session):
|
|
31
|
+
- Cap concurrent recorders at FOUR. All recorders share one Figma
|
|
32
|
+
desktop MCP server; a measured 8-wide batch hit its rate limit
|
|
33
|
+
(per-piece resume recovered, but the stall is avoidable).
|
|
34
|
+
- Batch the questions: plan ALL sets first, then put every
|
|
35
|
+
defaults-to-confirm and the one model question to the user together
|
|
36
|
+
— never one dialog per component.
|
|
37
|
+
- Create candidate directories with a bare `mkdir -p <dir>` — no
|
|
38
|
+
`&&`-compounds. Compound variants each need their own permission
|
|
39
|
+
approval; the bare form is one grant for the whole batch.
|
|
40
|
+
- Trust only tool output for completion state: check
|
|
41
|
+
`tendril_record_status` against disk, never a subagent's prose (a
|
|
42
|
+
measured batch had a recorder report success with zero reps
|
|
43
|
+
recorded).
|
|
44
|
+
|
|
23
45
|
## Recording a new component (needs Figma MCP)
|
|
24
46
|
|
|
25
47
|
1. Fetch the component set's frame metadata via Figma MCP
|
package/dist/tendril-mcp.js
CHANGED
|
@@ -64,6 +64,14 @@ var TOOLS = [
|
|
|
64
64
|
return argvOut;
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
+
{
|
|
68
|
+
name: "tendril_permissions",
|
|
69
|
+
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
|
|
70
|
+
schema: z.object({
|
|
71
|
+
write: z.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
|
|
72
|
+
}),
|
|
73
|
+
argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
|
|
74
|
+
},
|
|
67
75
|
{
|
|
68
76
|
name: "tendril_doctor",
|
|
69
77
|
annotations: { readOnlyHint: true },
|
|
@@ -259,7 +267,7 @@ var TOOLS = [
|
|
|
259
267
|
},
|
|
260
268
|
{
|
|
261
269
|
name: "tendril_verify",
|
|
262
|
-
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means
|
|
270
|
+
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means below the target bar with an honest report \u2014 sub-bar scores, or at bar cert a config demoted by absent-ink clusters.",
|
|
263
271
|
schema: z.object({
|
|
264
272
|
bundleDir: str("bundle directory to verify"),
|
|
265
273
|
bar: optStr("pass (default) or cert"),
|
|
@@ -361,7 +369,7 @@ function toolResult(result) {
|
|
|
361
369
|
const body = (result.stdout.trim() !== "" ? result.stdout : result.stderr) + stale;
|
|
362
370
|
if (result.exitCode === 5) {
|
|
363
371
|
return { content: [{ type: "text", text: `${body}
|
|
364
|
-
{"exitCode":5,"note":"
|
|
372
|
+
{"exitCode":5,"note":"HONEST below-target result \u2014 the report and bundle are valid; this is not a tool failure. Either sub-bar scores, or (at --bar cert) a config demoted by absent-ink clusters despite at-bar numbers \u2014 the report names which."}` }] };
|
|
365
373
|
}
|
|
366
374
|
const text = result.ok ? body : `${body}
|
|
367
375
|
{"exitCode":${result.exitCode},"note":"CLI exit-code contract: 3 input, 4 confirmation required, 6 fonts unproven, 7 recording incomplete"}`;
|
package/dist/tendril.js
CHANGED
|
@@ -762,11 +762,13 @@ function resolveAxisDefaultWithRule(domain, recorded, override) {
|
|
|
762
762
|
if (off !== void 0) return { value: off, rule: "boolean-off" };
|
|
763
763
|
}
|
|
764
764
|
const avoid = (v) => {
|
|
765
|
-
const
|
|
766
|
-
return INTERACTION_STATES.has(
|
|
765
|
+
const tokens = kebab(v).split("-");
|
|
766
|
+
return tokens.some((t) => INTERACTION_STATES.has(t) || BOOLEAN_STATES.has(t) || ENGAGED_STATES.has(t));
|
|
767
767
|
};
|
|
768
768
|
const anyAvoided = recorded.some((v) => avoid(v)) && recorded.some((v) => !avoid(v));
|
|
769
|
-
|
|
769
|
+
let candidates = recorded.some((v) => !avoid(v)) ? recorded.filter((v) => !avoid(v)) : recorded;
|
|
770
|
+
const dotted = candidates.filter((v) => v.split(/\s*-\s*|\s+/).some((part) => part.startsWith(".")));
|
|
771
|
+
if (dotted.length > 0) candidates = dotted;
|
|
770
772
|
const counts = /* @__PURE__ */ new Map();
|
|
771
773
|
for (const v of candidates) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
772
774
|
let best;
|
|
@@ -789,7 +791,7 @@ var init_axis_defaults = __esm({
|
|
|
789
791
|
"use strict";
|
|
790
792
|
INTERACTION_STATES = /* @__PURE__ */ new Set(["hover", "focus", "focus-visible", "active", "pressed"]);
|
|
791
793
|
BOOLEAN_STATES = /* @__PURE__ */ new Set(["disabled", "loading"]);
|
|
792
|
-
ENGAGED_STATES = /* @__PURE__ */ new Set(["selected", "checked", "indeterminate", "open", "expanded"]);
|
|
794
|
+
ENGAGED_STATES = /* @__PURE__ */ new Set(["selected", "checked", "indeterminate", "mixed", "open", "expanded"]);
|
|
793
795
|
INTERACTION_EVIDENCE_VALUES = /* @__PURE__ */ new Set([
|
|
794
796
|
...INTERACTION_STATES,
|
|
795
797
|
...BOOLEAN_STATES,
|
|
@@ -1214,8 +1216,8 @@ var init_src = __esm({
|
|
|
1214
1216
|
function variableNameToPath(name) {
|
|
1215
1217
|
return name.split("/").map(canonicalCssIdentPart).filter((part) => part.length > 0);
|
|
1216
1218
|
}
|
|
1217
|
-
function tokenPathToCssVar(
|
|
1218
|
-
return `--${
|
|
1219
|
+
function tokenPathToCssVar(path38) {
|
|
1220
|
+
return `--${path38.join("-")}`;
|
|
1219
1221
|
}
|
|
1220
1222
|
function toDtcgToken(variable, defaultMode) {
|
|
1221
1223
|
const modes = Object.keys(variable.valuesByMode);
|
|
@@ -1259,11 +1261,11 @@ function toDtcgToken(variable, defaultMode) {
|
|
|
1259
1261
|
}
|
|
1260
1262
|
function mapVariablesToDtcg(variables, defaultMode = "light") {
|
|
1261
1263
|
const entries = variables.map((variable) => {
|
|
1262
|
-
const
|
|
1263
|
-
if (
|
|
1264
|
+
const path38 = variableNameToPath(variable.name);
|
|
1265
|
+
if (path38.length === 0) {
|
|
1264
1266
|
throw new DtcgMappingError("empty-name", `Figma variable ${variable.id} has an empty name`);
|
|
1265
1267
|
}
|
|
1266
|
-
return { variable, path:
|
|
1268
|
+
return { variable, path: path38 };
|
|
1267
1269
|
});
|
|
1268
1270
|
const groupPrefixes = /* @__PURE__ */ new Set();
|
|
1269
1271
|
for (const e of entries) {
|
|
@@ -1284,21 +1286,21 @@ function mapVariablesToDtcg(variables, defaultMode = "light") {
|
|
|
1284
1286
|
}
|
|
1285
1287
|
const tokens = {};
|
|
1286
1288
|
const flat = [];
|
|
1287
|
-
for (const { variable, path:
|
|
1289
|
+
for (const { variable, path: path38 } of entries) {
|
|
1288
1290
|
const token = toDtcgToken(variable, defaultMode);
|
|
1289
1291
|
let group = tokens;
|
|
1290
|
-
for (const segment of
|
|
1292
|
+
for (const segment of path38.slice(0, -1)) {
|
|
1291
1293
|
const existing = group[segment];
|
|
1292
1294
|
group = existing ?? (group[segment] = {});
|
|
1293
1295
|
}
|
|
1294
|
-
const leaf =
|
|
1296
|
+
const leaf = path38[path38.length - 1];
|
|
1295
1297
|
if (group[leaf] !== void 0) {
|
|
1296
|
-
throw new DtcgMappingError("duplicate-path", `Duplicate token path "${
|
|
1298
|
+
throw new DtcgMappingError("duplicate-path", `Duplicate token path "${path38.join(".")}" (variable ${variable.id})`);
|
|
1297
1299
|
}
|
|
1298
1300
|
group[leaf] = token;
|
|
1299
1301
|
flat.push({
|
|
1300
|
-
path:
|
|
1301
|
-
cssVar: tokenPathToCssVar(
|
|
1302
|
+
path: path38.join("."),
|
|
1303
|
+
cssVar: tokenPathToCssVar(path38),
|
|
1302
1304
|
type: token.$type,
|
|
1303
1305
|
value: token.$value
|
|
1304
1306
|
});
|
|
@@ -1487,9 +1489,9 @@ function boundId(value) {
|
|
|
1487
1489
|
return isObject(value) && typeof value["boundVariableId"] === "string" ? value["boundVariableId"] : void 0;
|
|
1488
1490
|
}
|
|
1489
1491
|
function resolveBinding(ctx, id) {
|
|
1490
|
-
const
|
|
1491
|
-
if (
|
|
1492
|
-
return
|
|
1492
|
+
const path38 = ctx.pathById.get(id);
|
|
1493
|
+
if (path38 === void 0) ctx.unresolved.add(id);
|
|
1494
|
+
return path38;
|
|
1493
1495
|
}
|
|
1494
1496
|
function parseVariantProps(name) {
|
|
1495
1497
|
if (!name.includes("=")) return void 0;
|
|
@@ -1524,8 +1526,8 @@ function walk(ctx, raw) {
|
|
|
1524
1526
|
if (!isObject(paint) || paint["visible"] === false) continue;
|
|
1525
1527
|
const id = boundId(paint);
|
|
1526
1528
|
if (id !== void 0) {
|
|
1527
|
-
const
|
|
1528
|
-
if (
|
|
1529
|
+
const path38 = resolveBinding(ctx, id);
|
|
1530
|
+
if (path38 !== void 0) tokens.add(path38);
|
|
1529
1531
|
} else if (typeof paint["color"] === "string") {
|
|
1530
1532
|
ctx.hardcoded.push({ node: name, property, value: paint["color"] });
|
|
1531
1533
|
}
|
|
@@ -1533,8 +1535,8 @@ function walk(ctx, raw) {
|
|
|
1533
1535
|
}
|
|
1534
1536
|
const radiusId = boundId(raw["cornerRadius"]);
|
|
1535
1537
|
if (radiusId !== void 0) {
|
|
1536
|
-
const
|
|
1537
|
-
if (
|
|
1538
|
+
const path38 = resolveBinding(ctx, radiusId);
|
|
1539
|
+
if (path38 !== void 0) tokens.add(path38);
|
|
1538
1540
|
} else if (typeof raw["cornerRadius"] === "number" && raw["cornerRadius"] !== 0) {
|
|
1539
1541
|
ctx.hardcoded.push({ node: name, property: "border-radius", value: `${raw["cornerRadius"]}px` });
|
|
1540
1542
|
}
|
|
@@ -1544,10 +1546,10 @@ function walk(ctx, raw) {
|
|
|
1544
1546
|
layout = { mode: layoutMode === "HORIZONTAL" ? "flex-row" : "flex-column" };
|
|
1545
1547
|
const gapId = boundId(raw["itemSpacing"]);
|
|
1546
1548
|
if (gapId !== void 0) {
|
|
1547
|
-
const
|
|
1548
|
-
if (
|
|
1549
|
-
layout.gap =
|
|
1550
|
-
tokens.add(
|
|
1549
|
+
const path38 = resolveBinding(ctx, gapId);
|
|
1550
|
+
if (path38 !== void 0) {
|
|
1551
|
+
layout.gap = path38;
|
|
1552
|
+
tokens.add(path38);
|
|
1551
1553
|
}
|
|
1552
1554
|
} else if (typeof raw["itemSpacing"] === "number" && raw["itemSpacing"] !== 0) {
|
|
1553
1555
|
ctx.hardcoded.push({ node: name, property: "gap", value: `${raw["itemSpacing"]}px` });
|
|
@@ -1556,10 +1558,10 @@ function walk(ctx, raw) {
|
|
|
1556
1558
|
for (const field of PADDING_FIELDS) {
|
|
1557
1559
|
const id = boundId(raw[field]);
|
|
1558
1560
|
if (id !== void 0) {
|
|
1559
|
-
const
|
|
1560
|
-
if (
|
|
1561
|
-
paddingPaths.push(
|
|
1562
|
-
tokens.add(
|
|
1561
|
+
const path38 = resolveBinding(ctx, id);
|
|
1562
|
+
if (path38 !== void 0) {
|
|
1563
|
+
paddingPaths.push(path38);
|
|
1564
|
+
tokens.add(path38);
|
|
1563
1565
|
}
|
|
1564
1566
|
} else if (typeof raw[field] === "number" && raw[field] !== 0) {
|
|
1565
1567
|
ctx.hardcoded.push({ node: name, property: field, value: `${raw[field]}px` });
|
|
@@ -4378,14 +4380,17 @@ function chooseSeat(cropAt, ref, padW, padH, backdrop, origin, extents) {
|
|
|
4378
4380
|
}
|
|
4379
4381
|
return { comparison, seat, scored: cropAt(origin - seat.x, origin - seat.y) };
|
|
4380
4382
|
}
|
|
4381
|
-
function
|
|
4382
|
-
let root;
|
|
4383
|
+
function metadataRoot(set, rep) {
|
|
4383
4384
|
try {
|
|
4384
4385
|
const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
|
|
4385
|
-
|
|
4386
|
+
return parseMetadataStructure(text);
|
|
4386
4387
|
} catch {
|
|
4387
4388
|
return void 0;
|
|
4388
4389
|
}
|
|
4390
|
+
}
|
|
4391
|
+
function deepestNodeNameAt(set, rep, px, py) {
|
|
4392
|
+
const root = metadataRoot(set, rep);
|
|
4393
|
+
if (root === void 0) return void 0;
|
|
4389
4394
|
let best;
|
|
4390
4395
|
const walk2 = (node, ox, oy, isRoot) => {
|
|
4391
4396
|
if (node.hidden === true) return;
|
|
@@ -4401,7 +4406,35 @@ function deepestNodeNameAt(set, rep, px, py) {
|
|
|
4401
4406
|
for (const child of node.children) walk2(child, nx, ny, false);
|
|
4402
4407
|
};
|
|
4403
4408
|
walk2(root, 0, 0, true);
|
|
4404
|
-
return best
|
|
4409
|
+
if (best !== void 0) return best.name;
|
|
4410
|
+
if (root.name !== "" && px >= 0 && py >= 0 && px < (root.width ?? 0) && py < (root.height ?? 0)) {
|
|
4411
|
+
return `${root.name} (${root.id}) (edge region, no named child at this point)`;
|
|
4412
|
+
}
|
|
4413
|
+
return void 0;
|
|
4414
|
+
}
|
|
4415
|
+
function smallSemanticNodes(set, rep, maxArea = 1024) {
|
|
4416
|
+
const root = metadataRoot(set, rep);
|
|
4417
|
+
if (root === void 0) return [];
|
|
4418
|
+
const found = [];
|
|
4419
|
+
const walk2 = (node, ox, oy, isRoot) => {
|
|
4420
|
+
if (node.hidden === true) return;
|
|
4421
|
+
const nx = isRoot ? 0 : ox + (node.x ?? 0);
|
|
4422
|
+
const ny = isRoot ? 0 : oy + (node.y ?? 0);
|
|
4423
|
+
const w = node.width ?? 0;
|
|
4424
|
+
const h = node.height ?? 0;
|
|
4425
|
+
if (!isRoot && node.name !== "" && w >= 2 && h >= 2 && w * h <= maxArea) {
|
|
4426
|
+
found.push({ name: node.name, id: node.id, x: nx, y: ny, w, h });
|
|
4427
|
+
}
|
|
4428
|
+
for (const child of node.children) walk2(child, nx, ny, false);
|
|
4429
|
+
};
|
|
4430
|
+
walk2(root, 0, 0, true);
|
|
4431
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4432
|
+
return found.sort((a, b) => a.w * a.h - b.w * b.h).filter((n) => {
|
|
4433
|
+
const key = `${n.x}:${n.y}:${n.w}:${n.h}`;
|
|
4434
|
+
if (seen.has(key)) return false;
|
|
4435
|
+
seen.add(key);
|
|
4436
|
+
return true;
|
|
4437
|
+
});
|
|
4405
4438
|
}
|
|
4406
4439
|
function repMeta(set, rep) {
|
|
4407
4440
|
const text = JSON.parse(readFileSync8(path15.join(set, rep, "get_metadata.json"), "utf8")).content.map((c) => c.text ?? "").join("\n");
|
|
@@ -6810,7 +6843,13 @@ var init_bundle = __esm({
|
|
|
6810
6843
|
// insufficient identity (two builds, one path, incomparable
|
|
6811
6844
|
// numbers); optional so pre-epoch bundles keep validating.
|
|
6812
6845
|
chromeVersion: z10.string().nullable().optional(),
|
|
6813
|
-
fontsManifestSha256: z10.string().nullable()
|
|
6846
|
+
fontsManifestSha256: z10.string().nullable(),
|
|
6847
|
+
// Run 11: a bundle scored under a substitute face carried CSS
|
|
6848
|
+
// compensation (font-variation-settings tuned to the substitute)
|
|
6849
|
+
// with nothing marking it conditional — the provenance now names
|
|
6850
|
+
// the families that were substituted at scoring time. Optional so
|
|
6851
|
+
// pre-0.1.19 bundles keep validating; absent means none.
|
|
6852
|
+
substitutedFamilies: z10.array(z10.string()).optional()
|
|
6814
6853
|
}),
|
|
6815
6854
|
/** Coverage denominator (Q2 honesty): recorded vs full-lattice size
|
|
6816
6855
|
* when the lattice is known (null when the kit exposes no lattice). */
|
|
@@ -7746,6 +7785,9 @@ function buildFeedback(scores, behaviors, bar, mode = "fenced") {
|
|
|
7746
7785
|
const reg = s.region !== void 0 ? ` worst-region=[x ${s.region.x0}-${s.region.x1}, y ${s.region.y0}-${s.region.y1}, density ${s.region.density}]` : "";
|
|
7747
7786
|
return base + err + reg;
|
|
7748
7787
|
});
|
|
7788
|
+
const absentLines = scores.flatMap(
|
|
7789
|
+
(s) => (s.absentInk ?? []).map((c) => `MISSING ${s.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}]`)
|
|
7790
|
+
);
|
|
7749
7791
|
return `Scores (bar: sim \u2265${bar.sim} AND ink \u2265${bar.ink} per config):
|
|
7750
7792
|
${lines.join("\n")}${behLines.length > 0 ? `
|
|
7751
7793
|
|
|
@@ -7753,9 +7795,12 @@ BEHAVIORAL invariants (machine-verified, GATING \u2014 fix these with real handl
|
|
|
7753
7795
|
${behLines.join("\n")}` : ""}${preludeLines.length > 0 ? `
|
|
7754
7796
|
|
|
7755
7797
|
PRELUDE parity (computed-style checks, GATING \u2014 fix the named CSS property on the named element):
|
|
7756
|
-
${preludeLines.join("\n")}` : ""}
|
|
7798
|
+
${preludeLines.join("\n")}` : ""}${absentLines.length > 0 ? `
|
|
7757
7799
|
|
|
7758
|
-
|
|
7800
|
+
MISSING FEATURES (absent-ink clusters \u2014 recorded marks your render leaves out or paints invisibly; GATING at the cert bar. Fix the named node's ink \u2014 a config carrying one cannot certify):
|
|
7801
|
+
${absentLines.join("\n")}` : ""}
|
|
7802
|
+
|
|
7803
|
+
Fix the FAIL configs (region coordinates are in the recorded screenshot's frame; ink<1 means recorded foreground pixels your render does not cover). Reading the pair: ink=1 with LOW sim means nothing is missing \u2014 shapes or positions are wrong, do not chase missing ink; LOW ink with high sim means recorded marks are absent or painted invisibly. Do not regress PASS configs. ${mode === "files" ? "Update the files in your candidate directory and re-run the score." : "Reply with the complete corrected files in the same FILE format."}`;
|
|
7759
7804
|
}
|
|
7760
7805
|
function archivePriorRun(outDir) {
|
|
7761
7806
|
if (!existsSync20(path26.join(outDir, "run-log.json")) && !existsSync20(path26.join(outDir, "loop-state.json"))) return void 0;
|
|
@@ -7825,14 +7870,14 @@ async function runEngineLoop(opts) {
|
|
|
7825
7870
|
progress(
|
|
7826
7871
|
`iter ${iter}: pass=${obj[0]}/${total} floor=${obj[1].toFixed(3)} mean=${obj[2].toFixed(3)} ${accepted ? "ACCEPT" : "reject"} $${usd.toFixed(3)} (total $${spentUsd.toFixed(3)})${modelMs !== void 0 ? ` model=${Math.round(modelMs / 1e3)}s` : ""} score=${Math.round(scoreMs / 1e3)}s`
|
|
7827
7872
|
);
|
|
7828
|
-
return { allPass: obj[0] === total && total > 0 };
|
|
7873
|
+
return { allPass: obj[0] === total && total > 0 && (opts.absentInkGates !== true || scores.every((s) => (s.absentInk?.length ?? 0) === 0)) };
|
|
7829
7874
|
};
|
|
7830
7875
|
if (opts.seed !== void 0 && !resuming) {
|
|
7831
7876
|
const { allPass } = await scoreCandidate(opts.seed, 0, 0);
|
|
7832
7877
|
if (allPass) stopReason = "all-pass";
|
|
7833
7878
|
}
|
|
7834
7879
|
const lastLog = log[log.length - 1];
|
|
7835
|
-
if (resuming && lastLog !== void 0 && lastLog.parseError === void 0 && lastLog.scores.length > 0 && lastLog.objective[0] === lastLog.scores.length + (lastLog.behaviors?.length ?? 0)) {
|
|
7880
|
+
if (resuming && lastLog !== void 0 && lastLog.parseError === void 0 && lastLog.scores.length > 0 && lastLog.objective[0] === lastLog.scores.length + (lastLog.behaviors?.length ?? 0) && (opts.absentInkGates !== true || lastLog.scores.every((s) => (s.absentInk?.length ?? 0) === 0))) {
|
|
7836
7881
|
stopReason = "all-pass";
|
|
7837
7882
|
}
|
|
7838
7883
|
const state = () => ({
|
|
@@ -8257,6 +8302,23 @@ function recordedFontNeeds(setDir) {
|
|
|
8257
8302
|
return { family, weights: weights.size === 0 ? [400] : [...weights].sort((a, b) => a - b) };
|
|
8258
8303
|
});
|
|
8259
8304
|
}
|
|
8305
|
+
function symbolFontGlyphCount(setDir, reps) {
|
|
8306
|
+
const PUA = /[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]/u;
|
|
8307
|
+
const glyphs = /* @__PURE__ */ new Set();
|
|
8308
|
+
for (const rep of reps) {
|
|
8309
|
+
const file = path27.join(setDir, rep, "get_metadata.json");
|
|
8310
|
+
if (!existsSync21(file)) continue;
|
|
8311
|
+
try {
|
|
8312
|
+
const text = JSON.parse(readFileSync18(file, "utf8")).content.map((c) => c.text ?? "").join("\n");
|
|
8313
|
+
for (const m of text.matchAll(/<text\s[^>]*name="([^"]*)"/g)) {
|
|
8314
|
+
const name = decodeXmlEntities(m[1]).replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
|
8315
|
+
if (PUA.test(name)) glyphs.add(name);
|
|
8316
|
+
}
|
|
8317
|
+
} catch {
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
8320
|
+
return glyphs.size;
|
|
8321
|
+
}
|
|
8260
8322
|
function recordedFontFamilies(setDir) {
|
|
8261
8323
|
return recordedFontNeeds(setDir).map((n) => n.family);
|
|
8262
8324
|
}
|
|
@@ -8453,6 +8515,12 @@ function authorTaskFromSet(setDir, opts = {}) {
|
|
|
8453
8515
|
return void 0;
|
|
8454
8516
|
}).filter((x) => x !== void 0);
|
|
8455
8517
|
const { behaviors, prelude, disclosures } = authorBehaviors(api, { ...sentinels.length > 0 ? { sentinels } : {} });
|
|
8518
|
+
const puaGlyphs = symbolFontGlyphCount(setDir, manifest.reps.map((r) => r.slug));
|
|
8519
|
+
if (puaGlyphs > 0) {
|
|
8520
|
+
disclosures.push(
|
|
8521
|
+
`SYMBOL-FONT ICONS: ${puaGlyphs} distinct recorded icon glyph(s) come from a symbol font (private-use codepoints) \u2014 the recording carries NO vector geometry for them. Reconstruct each mark against the reference pixels with extreme care, compare magnified crops after every score (run 11 shipped a checkmark where the reference was a dot, and a diamond where it was two chevrons \u2014 both scored above bar), and NEVER give two configs sharing the same recorded glyph different shapes`
|
|
8522
|
+
);
|
|
8523
|
+
}
|
|
8456
8524
|
if (dismissName !== void 0) {
|
|
8457
8525
|
disclosures.push(`dismiss affordance detected from the recording (${dismissName}) \u2014 onDismiss authored; dismiss-notifies-and-commits enforces the notification contract`);
|
|
8458
8526
|
}
|
|
@@ -8825,12 +8893,17 @@ function cssFontFamilies(css) {
|
|
|
8825
8893
|
}
|
|
8826
8894
|
return [...out];
|
|
8827
8895
|
}
|
|
8828
|
-
function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE) {
|
|
8896
|
+
function fontsCssFor(faces, bundleDir, cacheDir = DEFAULT_FONT_CACHE, substitutedFamilies = []) {
|
|
8829
8897
|
const header = [
|
|
8830
8898
|
"/* tendril fonts.css \u2014 the exact faces this bundle was scored with (sha-pinned in",
|
|
8831
8899
|
" component.json requiredFonts). Load it alongside styles.css so the component",
|
|
8832
8900
|
" renders in the recorded typeface \u2014 without it the browser substitutes, which is",
|
|
8833
|
-
" exactly the drift verification exists to rule out. Not read by scoring. */"
|
|
8901
|
+
" exactly the drift verification exists to rule out. Not read by scoring. */",
|
|
8902
|
+
...substitutedFamilies.length > 0 ? [
|
|
8903
|
+
`/* GENERATED UNDER FONT SUBSTITUTION: ${substitutedFamilies.map((f) => `'${f}'`).join(", ")} was/were NOT provisioned \u2014`,
|
|
8904
|
+
" scores measured a substitute face and no config certified. This file covers only",
|
|
8905
|
+
" cache-provided faces; resolve the real families and re-score for the full set. */"
|
|
8906
|
+
] : []
|
|
8834
8907
|
];
|
|
8835
8908
|
const lines = [];
|
|
8836
8909
|
for (const face of faces) {
|
|
@@ -8903,7 +8976,8 @@ function recordingSetHash(setDir, configs) {
|
|
|
8903
8976
|
);
|
|
8904
8977
|
}
|
|
8905
8978
|
function statusOf(s) {
|
|
8906
|
-
|
|
8979
|
+
const tier = tierOf(s, BARS.cert);
|
|
8980
|
+
return tier === "certified" && (s.absentInk?.length ?? 0) > 0 ? "pass" : tier;
|
|
8907
8981
|
}
|
|
8908
8982
|
function emitBundleV1(opts) {
|
|
8909
8983
|
const substituted = (opts.substitutedFamilies ?? []).length > 0;
|
|
@@ -8953,7 +9027,7 @@ function emitBundleV1(opts) {
|
|
|
8953
9027
|
...opts.licenseNote !== void 0 ? { licenseNote: opts.licenseNote } : {},
|
|
8954
9028
|
hash: recordingSetHash(opts.task.set, opts.task.configs)
|
|
8955
9029
|
},
|
|
8956
|
-
environment: opts.environment,
|
|
9030
|
+
environment: { ...opts.environment, ...(opts.substitutedFamilies ?? []).length > 0 ? { substitutedFamilies: opts.substitutedFamilies } : {} },
|
|
8957
9031
|
coverage: { recordedConfigs: statuses.length, latticeConfigs: lattice },
|
|
8958
9032
|
generatedAt: opts.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
8959
9033
|
...opts.spentUsd !== void 0 ? { spentUsd: opts.spentUsd } : {}
|
|
@@ -8986,7 +9060,7 @@ ${stripped}`);
|
|
|
8986
9060
|
const fontsCssPath = path29.join(opts.bundleDir, "fonts.css");
|
|
8987
9061
|
rmSync3(path29.join(opts.bundleDir, "fonts"), { recursive: true, force: true });
|
|
8988
9062
|
rmSync3(fontsCssPath, { force: true });
|
|
8989
|
-
const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir);
|
|
9063
|
+
const fontsCss = fontsCssFor(requiredFontsManifest(families, opts.fontCacheDir), opts.bundleDir, opts.fontCacheDir, opts.substitutedFamilies ?? []);
|
|
8990
9064
|
if (fontsCss !== null) {
|
|
8991
9065
|
writeFileSync11(fontsCssPath, fontsCss);
|
|
8992
9066
|
written.push(fontsCssPath);
|
|
@@ -9187,6 +9261,22 @@ function unprovisionedFamilies(setDir, cacheDir) {
|
|
|
9187
9261
|
const provided = new Set(verifiedFontFamilies(cacheDir).map((f) => f.family.toLowerCase()));
|
|
9188
9262
|
return declared.filter((f) => !provided.has(f.toLowerCase()));
|
|
9189
9263
|
}
|
|
9264
|
+
function missingWeights(setDir, cacheDir) {
|
|
9265
|
+
let needs;
|
|
9266
|
+
try {
|
|
9267
|
+
needs = recordedFontNeeds(setDir);
|
|
9268
|
+
} catch {
|
|
9269
|
+
return [];
|
|
9270
|
+
}
|
|
9271
|
+
const provided = new Map(verifiedFontFamilies(cacheDir).map((f) => [f.family.toLowerCase(), f.weights]));
|
|
9272
|
+
const gaps = [];
|
|
9273
|
+
for (const n of needs) {
|
|
9274
|
+
const have = provided.get(n.family.toLowerCase());
|
|
9275
|
+
if (have === void 0) continue;
|
|
9276
|
+
if (n.weights.some((w) => !have.includes(w))) gaps.push({ family: n.family, declared: n.weights, provided: have });
|
|
9277
|
+
}
|
|
9278
|
+
return gaps;
|
|
9279
|
+
}
|
|
9190
9280
|
var init_font_guidance = __esm({
|
|
9191
9281
|
"packages/cli/src/font-guidance.ts"() {
|
|
9192
9282
|
"use strict";
|
|
@@ -9198,6 +9288,7 @@ var init_font_guidance = __esm({
|
|
|
9198
9288
|
// packages/cli/src/commands/verify.ts
|
|
9199
9289
|
var verify_exports = {};
|
|
9200
9290
|
__export(verify_exports, {
|
|
9291
|
+
foldConfigStatus: () => foldConfigStatus,
|
|
9201
9292
|
interactionCoverage: () => interactionCoverage,
|
|
9202
9293
|
runVerify: () => runVerify
|
|
9203
9294
|
});
|
|
@@ -9212,6 +9303,26 @@ function interactionCoverage(behaviors) {
|
|
|
9212
9303
|
operability: interaction.length > 0 && interaction.every((b) => b.pass) ? "verified" : "unverified"
|
|
9213
9304
|
};
|
|
9214
9305
|
}
|
|
9306
|
+
function foldConfigStatus(s, failDemotions, substitutedFamilies) {
|
|
9307
|
+
const { exact: _exact, ...reported } = s;
|
|
9308
|
+
let status = tierOf(s, BARS2.cert);
|
|
9309
|
+
const certDemote = [];
|
|
9310
|
+
let absentInkDemoted = false;
|
|
9311
|
+
if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
|
|
9312
|
+
status = "pass";
|
|
9313
|
+
absentInkDemoted = true;
|
|
9314
|
+
certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
|
|
9315
|
+
}
|
|
9316
|
+
if (status === "certified" && substitutedFamilies.length > 0) {
|
|
9317
|
+
status = "pass";
|
|
9318
|
+
certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
|
|
9319
|
+
}
|
|
9320
|
+
const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
|
|
9321
|
+
return {
|
|
9322
|
+
row: failDemotions === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote, ...failDemotions] },
|
|
9323
|
+
absentInkDemoted
|
|
9324
|
+
};
|
|
9325
|
+
}
|
|
9215
9326
|
function taskFromManifest(opts, manifest, setDir) {
|
|
9216
9327
|
const recordedSlugs = loadManifest(setDir).reps.map((r) => r.slug);
|
|
9217
9328
|
const adapterSlugs = Object.keys(manifest.propAdapter);
|
|
@@ -9357,6 +9468,9 @@ async function runVerify(opts) {
|
|
|
9357
9468
|
} else if (opts.bar === "cert" && taskFontFamilies(task.set) === null) {
|
|
9358
9469
|
warn(opts, "family coverage could not be established from this recording (no declared families) \u2014 the certification gate covers only cache non-emptiness here");
|
|
9359
9470
|
}
|
|
9471
|
+
for (const g of missingWeights(task.set)) {
|
|
9472
|
+
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
9473
|
+
}
|
|
9360
9474
|
const missing = task.configs.filter(
|
|
9361
9475
|
(c) => !existsSync25(path31.join(task.set, c.rep, "get_screenshot.json")) || !existsSync25(path31.join(task.set, c.rep, "get_metadata.json"))
|
|
9362
9476
|
);
|
|
@@ -9387,23 +9501,10 @@ async function runVerify(opts) {
|
|
|
9387
9501
|
if (crops !== void 0) {
|
|
9388
9502
|
for (const c of crops) if (!c.pass) demote(c.id.split(":")[1] ?? "", "composition crop failed (A1.4)");
|
|
9389
9503
|
}
|
|
9504
|
+
const folded = scores.map((s) => foldConfigStatus(s, demotions.get(s.rep), substitutedFamilies));
|
|
9505
|
+
const absentInkDemoted = scores.filter((_, i) => folded[i].absentInkDemoted).map((s) => s.rep);
|
|
9390
9506
|
const statuses = [
|
|
9391
|
-
...
|
|
9392
|
-
const { exact: _exact, ...reported } = s;
|
|
9393
|
-
let status = tierOf(s, BARS2.cert);
|
|
9394
|
-
const certDemote = [];
|
|
9395
|
-
if (status === "certified" && s.absentInk !== void 0 && s.absentInk.length > 0) {
|
|
9396
|
-
status = "pass";
|
|
9397
|
-
certDemote.push(...s.absentInk.map((c) => `absent ink: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with no render ink within reach (missing or invisible feature)`));
|
|
9398
|
-
}
|
|
9399
|
-
if (status === "certified" && substitutedFamilies.length > 0) {
|
|
9400
|
-
status = "pass";
|
|
9401
|
-
certDemote.push(`substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 certification never measures a substitute face`);
|
|
9402
|
-
}
|
|
9403
|
-
const base = certDemote.length > 0 ? { ...reported, status, demotedBy: certDemote } : { ...reported, status };
|
|
9404
|
-
const reasons = demotions.get(s.rep);
|
|
9405
|
-
return reasons === void 0 ? base : { ...base, status: "fail", demotedBy: [...certDemote.length > 0 ? certDemote : [], ...reasons] };
|
|
9406
|
-
}),
|
|
9507
|
+
...folded.map((f) => f.row),
|
|
9407
9508
|
// ADR-010 §2 anti-gaming: recorded configs the adapter does not map
|
|
9408
9509
|
// are FAILs, never silently absent.
|
|
9409
9510
|
...unmapped.map((rep) => ({ rep, similarity: 0, inkRecall: 0, pass: false, status: "fail", error: "not mapped by the bundle's prop adapter" }))
|
|
@@ -9416,7 +9517,9 @@ async function runVerify(opts) {
|
|
|
9416
9517
|
const occlusionFailures = occlusion.filter((o) => !o.pass);
|
|
9417
9518
|
const coverage = interactionCoverage(behaviors);
|
|
9418
9519
|
const evidenceUnverified = interactionEvidence.length > 0 && coverage.interactionChecks === 0;
|
|
9419
|
-
const
|
|
9520
|
+
const okExceptDemotion = pixelFailures.length === 0 && behaviorFailures.length === 0 && structuralFailures.length === 0 && cropFailures.length === 0 && occlusionFailures.length === 0 && !evidenceUnverified;
|
|
9521
|
+
const certBlockedByAbsentInk = opts.bar === "cert" ? absentInkDemoted : [];
|
|
9522
|
+
const ok = okExceptDemotion && certBlockedByAbsentInk.length === 0;
|
|
9420
9523
|
const report = {
|
|
9421
9524
|
bundle: opts.bundleDir,
|
|
9422
9525
|
...opts.task !== void 0 ? { task: opts.task } : {},
|
|
@@ -9473,7 +9576,11 @@ async function runVerify(opts) {
|
|
|
9473
9576
|
crops: crops ?? { unavailable: regionsOut !== void 0 && "unavailable" in regionsOut ? regionsOut.unavailable : "no role manifest" }
|
|
9474
9577
|
}
|
|
9475
9578
|
} : {},
|
|
9476
|
-
verdict: ok ? "verified" : "verification-failed"
|
|
9579
|
+
verdict: ok ? "verified" : "verification-failed",
|
|
9580
|
+
// Machine-readable cause (adversarial review): a cert-bar failure
|
|
9581
|
+
// with zero pixel/behavior/composition failures was only
|
|
9582
|
+
// explainable from stderr prose.
|
|
9583
|
+
...certBlockedByAbsentInk.length > 0 ? { certBlockedByAbsentInk } : {}
|
|
9477
9584
|
};
|
|
9478
9585
|
emitData(opts, report, () => {
|
|
9479
9586
|
for (const s of statuses) {
|
|
@@ -9533,7 +9640,7 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
9533
9640
|
}
|
|
9534
9641
|
if (evidenceUnverified) {
|
|
9535
9642
|
process.stdout.write(
|
|
9536
|
-
`FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component.
|
|
9643
|
+
`FAIL interaction-evidence \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and ZERO interaction behaviors were verified. The authoring vocabulary did not map them; this is an instrument failure, not a verified component. Mappable today: ${[...INTERACTION_STATES, ...BOOLEAN_STATES].join("/")} \u2014 on an axis named State (checked/selected state axes are recorded as evidence but not yet authorable \u2014 widening is on the roadmap; this is not fixable from component code).
|
|
9537
9644
|
`
|
|
9538
9645
|
);
|
|
9539
9646
|
} else if (ic.interactionChecks === 0) {
|
|
@@ -9572,21 +9679,29 @@ ${certified}/${statuses.length} certified \xB7 ${report.coverage.pass}/${statuse
|
|
|
9572
9679
|
});
|
|
9573
9680
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
9574
9681
|
const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
|
|
9682
|
+
const alsoAbsent = absentInkDemoted.length > 0 ? ` \u2014 and ${absentInkDemoted.length} config(s) also carry absent-ink demotions (named in the report); certification requires fixing those too` : "";
|
|
9575
9683
|
if (opts.json) {
|
|
9576
|
-
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
9684
|
+
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
9577
9685
|
`);
|
|
9578
9686
|
} else {
|
|
9579
|
-
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
|
|
9687
|
+
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
|
|
9580
9688
|
\u2192 ${fontsUnprovenRemediation(task.set)}
|
|
9581
9689
|
`);
|
|
9582
9690
|
}
|
|
9583
9691
|
process.exitCode = ExitCode.FontsUnproven;
|
|
9584
9692
|
return;
|
|
9585
9693
|
}
|
|
9586
|
-
if (
|
|
9694
|
+
if (certBlockedByAbsentInk.length > 0) {
|
|
9587
9695
|
warn(
|
|
9588
9696
|
opts,
|
|
9589
|
-
`${
|
|
9697
|
+
`${certBlockedByAbsentInk.length} config(s) demoted by absent-ink clusters (${certBlockedByAbsentInk.join(", ")}) \u2014 certification never ships a missing or invisible feature; each cluster is named in the report above. Fix the component and re-verify, or verify at --bar pass for the disclosed result.`
|
|
9698
|
+
);
|
|
9699
|
+
process.exitCode = ExitCode.VerificationFailed;
|
|
9700
|
+
}
|
|
9701
|
+
if (!okExceptDemotion) {
|
|
9702
|
+
warn(
|
|
9703
|
+
opts,
|
|
9704
|
+
`${pixelFailures.length} config(s), ${behaviorFailures.length} behavior(s), ${structuralFailures.length + cropFailures.length + occlusionFailures.length} composition check(s) below the ${opts.bar} bar${evidenceUnverified ? "; PLUS the interaction-evidence instrument gate failed (operability could not be verified \u2014 see FAIL interaction-evidence above)" : ""} \u2014 bundle written, verdict honest (Q4)`
|
|
9590
9705
|
);
|
|
9591
9706
|
process.exitCode = ExitCode.VerificationFailed;
|
|
9592
9707
|
}
|
|
@@ -9616,18 +9731,18 @@ __export(engine_exports, {
|
|
|
9616
9731
|
runEngineBrief: () => runEngineBrief,
|
|
9617
9732
|
runEngineScore: () => runEngineScore
|
|
9618
9733
|
});
|
|
9619
|
-
import { existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
|
|
9734
|
+
import { appendFileSync, existsSync as existsSync26, mkdirSync as mkdirSync8, readFileSync as readFileSync23, writeFileSync as writeFileSync12 } from "node:fs";
|
|
9620
9735
|
import path32 from "node:path";
|
|
9621
9736
|
function resolveEngineTask(opts, callerCwd) {
|
|
9622
9737
|
const asPath = path32.resolve(callerCwd, opts.taskOrSet);
|
|
9623
9738
|
const isSet = existsSync26(path32.join(asPath, "recording-set.json"));
|
|
9624
9739
|
const registry = TASKS[opts.taskOrSet];
|
|
9625
|
-
if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [] };
|
|
9740
|
+
if (registry !== void 0 && !isSet) return { task: registry, name: opts.taskOrSet, disclosures: [], interactionEvidence: [] };
|
|
9626
9741
|
if (isSet) {
|
|
9627
9742
|
try {
|
|
9628
9743
|
const authored = authorTaskFromSet(asPath);
|
|
9629
9744
|
for (const d of authored.disclosures) warn(opts, d);
|
|
9630
|
-
return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
|
|
9745
|
+
return { task: authored.task, name: path32.basename(asPath), disclosures: authored.disclosures, interactionEvidence: authored.api.interactionEvidence, apiPin: { props: authored.api.apiPin.props, forcedStates: authored.api.apiPin.forcedStates } };
|
|
9631
9746
|
} catch (err) {
|
|
9632
9747
|
fail(opts, ExitCode.InputValidation, {
|
|
9633
9748
|
error: `cannot author a task from ${asPath}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
@@ -9669,12 +9784,14 @@ ${notRecorded}` : "";
|
|
|
9669
9784
|
const unprovided = recordedFontFamilies(task.set).filter((r) => !provided.some((p) => p.toLowerCase() === r.toLowerCase()));
|
|
9670
9785
|
if (unprovided.length > 0) {
|
|
9671
9786
|
const names = unprovided.map((f) => `'${f}'`).join(", ");
|
|
9787
|
+
const PROPRIETARY = /^(sf pro|sf compact|sf mono|new york|pingfang|segoe ui|helvetica neue|proxima nova|avenir)/i;
|
|
9788
|
+
const allProprietary = unprovided.every((f) => PROPRIETARY.test(f.trim()));
|
|
9672
9789
|
fontProvisioning = {
|
|
9673
9790
|
unprovided,
|
|
9674
9791
|
question: {
|
|
9675
9792
|
prompt: `This design's text uses ${names}, which is not in the local font kit. How should it be handled?`,
|
|
9676
9793
|
options: [
|
|
9677
|
-
`(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
|
|
9794
|
+
allProprietary ? `(Recommended) ${names} ${unprovided.length === 1 ? "is a proprietary face" : "are proprietary faces"} \u2014 Google Fonts cannot serve ${unprovided.length === 1 ? "it" : "them"}, so skip \`fonts resolve\`: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold a licence for (the file never leaves this machine), then re-run this brief \u2014 exact text fidelity.` : `(Recommended) Run \`tendril fonts resolve --set ${task.set}\` \u2014 open-source families are fetched automatically. Any face that fails there is one you license privately: run \`tendril fonts add "<Family>" <weight> <file>\` with the .woff2/.ttf/.otf you hold (the file never leaves this machine). Then re-run this brief \u2014 exact text fidelity.`,
|
|
9678
9795
|
'Continue with a substitute face: generation proceeds in a provided family, no config can score "certified" under it (cert-bar runs exit fonts-unproven after their report), the substitution is disclosed, and small text differences are expected and not fixable from CSS.'
|
|
9679
9796
|
]
|
|
9680
9797
|
},
|
|
@@ -9733,7 +9850,7 @@ async function runEngineScore(opts) {
|
|
|
9733
9850
|
requireEntitlement(opts);
|
|
9734
9851
|
const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
|
|
9735
9852
|
const candidateDir = path32.resolve(callerCwd, opts.candidateDir);
|
|
9736
|
-
const { task, name, apiPin } = resolveEngineTask(opts, callerCwd);
|
|
9853
|
+
const { task, name, apiPin, interactionEvidence } = resolveEngineTask(opts, callerCwd);
|
|
9737
9854
|
if (!existsSync26(candidateDir)) {
|
|
9738
9855
|
fail(opts, ExitCode.InputValidation, {
|
|
9739
9856
|
error: `candidate directory not found: ${candidateDir}`,
|
|
@@ -9752,6 +9869,9 @@ async function runEngineScore(opts) {
|
|
|
9752
9869
|
if (substitutedFamilies.length > 0) {
|
|
9753
9870
|
warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
|
|
9754
9871
|
}
|
|
9872
|
+
for (const g of missingWeights(task.set)) {
|
|
9873
|
+
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
9874
|
+
}
|
|
9755
9875
|
if (opts.rebind !== true && existsSync26(path32.join(candidateDir, "component.json"))) {
|
|
9756
9876
|
const prior = (() => {
|
|
9757
9877
|
try {
|
|
@@ -9792,12 +9912,13 @@ ${[
|
|
|
9792
9912
|
...quality.findings.map((f) => `- ${f.kind} ${f.file}${f.line === void 0 ? "" : `:${f.line}`} \u2014 ${f.message}`),
|
|
9793
9913
|
...quality.tokensAbsent ? ["- no design tokens: no tokens.css and no var(--\u2026) reference; every value is hardcoded"] : []
|
|
9794
9914
|
].join("\n")}`;
|
|
9795
|
-
const
|
|
9915
|
+
const absentAtCertBar = opts.bar === "cert" ? scores.filter((sc) => (sc.absentInk?.length ?? 0) > 0).map((sc) => sc.rep) : [];
|
|
9916
|
+
const allPass = obj[0] === total && total > 0 && absentAtCertBar.length === 0;
|
|
9796
9917
|
const certBar = BARS3["cert"];
|
|
9797
9918
|
const parityDemoted = new Set(parity.filter((p) => !p.pass).map((p) => p.id.replace(/^parity:/, "")));
|
|
9798
9919
|
const certifiedReps = substitutedFamilies.length > 0 ? [] : scores.filter((sc) => tierOf(sc, certBar) === "certified" && !parityDemoted.has(sc.rep) && (sc.absentInk === void 0 || sc.absentInk.length === 0)).map((sc) => sc.rep);
|
|
9799
9920
|
const certifiedSet = new Set(certifiedReps);
|
|
9800
|
-
const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass.`));
|
|
9921
|
+
const absentFindings = scores.flatMap((sc) => (sc.absentInk ?? []).map((c) => `- ${sc.rep}: ${c.name ?? "unnamed region"} \u2014 ${c.px}px of recorded ink with NO render ink within reach at [x ${c.x}-${c.x + c.w}, y ${c.y}-${c.y + c.h}] (missing or invisible feature; magnified crops in the evidence dir; coordinates are reference-space \u2014 subtract the config's seat for box-space). A certified-tier config with such a finding is demoted to pass, and at --bar cert it FAILS the run (exit 5).`));
|
|
9801
9922
|
const absentBlock = absentFindings.length > 0 ? `
|
|
9802
9923
|
|
|
9803
9924
|
MISSING FEATURES (absent-ink clusters \u2014 recorded ink your render leaves nowhere near covered; fix these first, the global numbers cannot see them):
|
|
@@ -9819,6 +9940,11 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9819
9940
|
environment: environmentStamp(taskFontFamilies(task.set)),
|
|
9820
9941
|
substitutedFamilies
|
|
9821
9942
|
});
|
|
9943
|
+
appendFileSync(
|
|
9944
|
+
path32.join(candidateDir, "score-history.jsonl"),
|
|
9945
|
+
`${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), bar: opts.bar, pass: obj[0], total, certified: certifiedReps.length, floor: obj[1], mean: obj[2] })}
|
|
9946
|
+
`
|
|
9947
|
+
);
|
|
9822
9948
|
emitData(
|
|
9823
9949
|
opts,
|
|
9824
9950
|
{
|
|
@@ -9838,6 +9964,10 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9838
9964
|
bundleManifest: emitted.written[0],
|
|
9839
9965
|
note: "styles.css was stamped with the bundle provenance comment on line 1 \u2014 preserve it in any post-score edit",
|
|
9840
9966
|
allPass,
|
|
9967
|
+
// Run 11: generators read allPass:true and reported success on
|
|
9968
|
+
// bundles verify then FAILED on the interaction-evidence gate —
|
|
9969
|
+
// the oracle must say what verify will say, including this.
|
|
9970
|
+
...interactionEvidence.length > 0 && interactionCoverage(behaviors).interactionChecks === 0 ? { interactionEvidenceUnverified: true, verifyWillFail: "interaction-evidence \u2014 the recording proves interactive poses none of the authored behaviors cover; not fixable from component code; REPORT it, do not iterate on it" } : {},
|
|
9841
9971
|
certification: { certified: certifiedReps.length, total: scores.length, bar: certBar, note: "tierOf on exact values + parity and absent-ink demotion; verify's composition checks can demote further" }
|
|
9842
9972
|
},
|
|
9843
9973
|
() => {
|
|
@@ -9849,12 +9979,18 @@ METRIC DEADBAND (read before iterating on near-misses): the scored similarity/in
|
|
|
9849
9979
|
${obj[0]}/${total} \xB7 certified ${certifiedReps.length}/${total} \xB7 floor=${obj[1].toFixed(3)} \xB7 mean=${obj[2].toFixed(3)} \xB7 evidence: ${evidenceDir}
|
|
9850
9980
|
`);
|
|
9851
9981
|
const ic = interactionCoverage(behaviors);
|
|
9852
|
-
if (ic.interactionChecks === 0) {
|
|
9982
|
+
if (interactionEvidence.length > 0 && ic.interactionChecks === 0) {
|
|
9983
|
+
process.stdout.write(`INSTRUMENT GAP \u2014 the recording proves interactive poses (${interactionEvidence.join(", ")}) and zero interaction behaviors were authored; verify WILL fail this bundle (interaction-evidence). Not fixable from component code \u2014 report it, do not iterate on it.
|
|
9984
|
+
`);
|
|
9985
|
+
} else if (ic.interactionChecks === 0) {
|
|
9853
9986
|
process.stdout.write(`UNVERIFIED operability \u2014 0 interaction checks; the behaviour passes above are page-level style hygiene
|
|
9854
9987
|
`);
|
|
9855
9988
|
}
|
|
9856
9989
|
}
|
|
9857
9990
|
);
|
|
9991
|
+
if (absentAtCertBar.length > 0) {
|
|
9992
|
+
warn(opts, `${absentAtCertBar.length} config(s) carry absent-ink clusters (${absentAtCertBar.join(", ")}) \u2014 at --bar cert these fail the run; the MISSING FEATURES block above names each one`);
|
|
9993
|
+
}
|
|
9858
9994
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
9859
9995
|
const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
|
|
9860
9996
|
if (opts.json) {
|
|
@@ -10135,6 +10271,14 @@ var init_server = __esm({
|
|
|
10135
10271
|
return argvOut;
|
|
10136
10272
|
}
|
|
10137
10273
|
},
|
|
10274
|
+
{
|
|
10275
|
+
name: "tendril_permissions",
|
|
10276
|
+
description: "Install the Claude Code permission allowlist for the Tendril pipeline (write: true \u2014 merges per-tool entries into the project's .claude/settings.local.json, idempotent, never touches other settings), or list the entries without writing. USE THIS when tendril/Figma tool calls keep hitting permission prompts: offer it to the user once \u2014 ONE approval here replaces a prompt per pipeline call (run 8 measured 16+ prompts for a single component's recording). The user must reload the session for new settings to apply.",
|
|
10277
|
+
schema: z12.object({
|
|
10278
|
+
write: z12.boolean().optional().describe("true = install into .claude/settings.local.json (the point of this tool); false/absent = just return the entries")
|
|
10279
|
+
}),
|
|
10280
|
+
argv: (i) => ["permissions", "--claude", ...i["write"] === true ? ["--write"] : []]
|
|
10281
|
+
},
|
|
10138
10282
|
{
|
|
10139
10283
|
name: "tendril_doctor",
|
|
10140
10284
|
annotations: { readOnlyHint: true },
|
|
@@ -10330,7 +10474,7 @@ var init_server = __esm({
|
|
|
10330
10474
|
},
|
|
10331
10475
|
{
|
|
10332
10476
|
name: "tendril_verify",
|
|
10333
|
-
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means
|
|
10477
|
+
description: "Verify/check that a component matches its Figma design \u2014 use when the user asks whether an implementation is faithful to the design, or to re-certify an existing Tendril bundle. Recomputes full verification (per-config status, behaviors, composition, evidence artifacts). Free, account-less, network-less \u2014 the trust anchor. Exit 5 means below the target bar with an honest report \u2014 sub-bar scores, or at bar cert a config demoted by absent-ink clusters.",
|
|
10334
10478
|
schema: z12.object({
|
|
10335
10479
|
bundleDir: str("bundle directory to verify"),
|
|
10336
10480
|
bar: optStr("pass (default) or cert"),
|
|
@@ -10378,8 +10522,33 @@ var permissions_exports = {};
|
|
|
10378
10522
|
__export(permissions_exports, {
|
|
10379
10523
|
PERMISSIONS_DESCRIPTION: () => PERMISSIONS_DESCRIPTION,
|
|
10380
10524
|
buildPermissions: () => buildPermissions,
|
|
10525
|
+
mergeAllowlist: () => mergeAllowlist,
|
|
10381
10526
|
runPermissions: () => runPermissions
|
|
10382
10527
|
});
|
|
10528
|
+
import { existsSync as existsSync29, mkdirSync as mkdirSync9, readFileSync as readFileSync26, writeFileSync as writeFileSync15 } from "node:fs";
|
|
10529
|
+
import os7 from "node:os";
|
|
10530
|
+
import path35 from "node:path";
|
|
10531
|
+
function mergeAllowlist(file, entries) {
|
|
10532
|
+
let settings = {};
|
|
10533
|
+
if (existsSync29(file) && readFileSync26(file, "utf8").trim() !== "") {
|
|
10534
|
+
settings = JSON.parse(readFileSync26(file, "utf8"));
|
|
10535
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) throw new Error("settings root is not an object");
|
|
10536
|
+
}
|
|
10537
|
+
const permissions = settings["permissions"] ??= {};
|
|
10538
|
+
if (permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) throw new Error("permissions is not an object");
|
|
10539
|
+
const allow = permissions["allow"] ??= [];
|
|
10540
|
+
if (!Array.isArray(allow)) throw new Error("permissions.allow is not an array");
|
|
10541
|
+
const present = new Set(allow.filter((x) => typeof x === "string"));
|
|
10542
|
+
const added = entries.filter((e) => !present.has(e));
|
|
10543
|
+
const alreadyPresent = entries.filter((e) => present.has(e));
|
|
10544
|
+
if (added.length > 0) {
|
|
10545
|
+
allow.push(...added);
|
|
10546
|
+
mkdirSync9(path35.dirname(file), { recursive: true });
|
|
10547
|
+
writeFileSync15(file, `${JSON.stringify(settings, null, 2)}
|
|
10548
|
+
`);
|
|
10549
|
+
}
|
|
10550
|
+
return { added, alreadyPresent };
|
|
10551
|
+
}
|
|
10383
10552
|
async function buildPermissions(options) {
|
|
10384
10553
|
const LEGAL_TOOL_NAME = /^[A-Za-z0-9_-]+$/;
|
|
10385
10554
|
const pipeline = new Set(FIGMA_TOOL_FALLBACK);
|
|
@@ -10408,11 +10577,44 @@ async function runPermissions(flags) {
|
|
|
10408
10577
|
return;
|
|
10409
10578
|
}
|
|
10410
10579
|
const result = await buildPermissions({ ...flags.mcpUrl ? { mcpUrl: flags.mcpUrl } : {} });
|
|
10580
|
+
if (flags.write) {
|
|
10581
|
+
const base = process.env["INIT_CWD"] ?? process.cwd();
|
|
10582
|
+
const file = flags.user ? path35.join(os7.homedir(), ".claude", "settings.json") : path35.join(base, ".claude", "settings.local.json");
|
|
10583
|
+
if (flags.dryRun) {
|
|
10584
|
+
emitData(flags, { file, wouldAdd: result.toolEntries }, () => {
|
|
10585
|
+
process.stdout.write(`dry-run: would merge ${result.toolEntries.length} per-tool entries into ${file}
|
|
10586
|
+
`);
|
|
10587
|
+
});
|
|
10588
|
+
return;
|
|
10589
|
+
}
|
|
10590
|
+
try {
|
|
10591
|
+
const { added, alreadyPresent } = mergeAllowlist(file, result.toolEntries);
|
|
10592
|
+
emitData(flags, { ...result, written: { file, added, alreadyPresent } }, () => {
|
|
10593
|
+
process.stdout.write(
|
|
10594
|
+
added.length === 0 ? `already installed: all ${alreadyPresent.length} Tendril pipeline entries present in ${file}
|
|
10595
|
+
` : `installed: ${added.length} allowlist entr${added.length === 1 ? "y" : "ies"} added to ${file}${alreadyPresent.length > 0 ? ` (${alreadyPresent.length} already present)` : ""}
|
|
10596
|
+
restart or reopen the Claude Code session to pick up settings changes
|
|
10597
|
+
`
|
|
10598
|
+
);
|
|
10599
|
+
});
|
|
10600
|
+
} catch (err) {
|
|
10601
|
+
fail(flags, ExitCode.InputValidation, {
|
|
10602
|
+
error: `cannot rewrite ${file}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
10603
|
+
code: "settings-unwritable",
|
|
10604
|
+
remediation: "The file is not plain JSON this command can faithfully re-emit (comments or an unexpected shape). Add the entries by hand: run without --write for the paste-ready block."
|
|
10605
|
+
});
|
|
10606
|
+
}
|
|
10607
|
+
return;
|
|
10608
|
+
}
|
|
10411
10609
|
emitData(flags, result, () => {
|
|
10412
10610
|
const quoted = (xs) => xs.map((x) => ` ${JSON.stringify(x)}`).join(",\n");
|
|
10413
10611
|
process.stdout.write(
|
|
10414
10612
|
`Claude Code allowlist for the Tendril pipeline.
|
|
10415
|
-
|
|
10613
|
+
One command installs it (project-local, idempotent):
|
|
10614
|
+
|
|
10615
|
+
tendril permissions --claude --write
|
|
10616
|
+
|
|
10617
|
+
Or paste into .claude/settings.json under permissions.allow:
|
|
10416
10618
|
|
|
10417
10619
|
${quoted(result.toolEntries)}
|
|
10418
10620
|
|
|
@@ -10431,6 +10633,7 @@ var FIGMA_TOOL_FALLBACK, PERMISSIONS_DESCRIPTION, TENDRIL_PLUGIN_PREFIX, FIGMA_P
|
|
|
10431
10633
|
var init_permissions = __esm({
|
|
10432
10634
|
"packages/cli/src/commands/permissions.ts"() {
|
|
10433
10635
|
"use strict";
|
|
10636
|
+
init_src3();
|
|
10434
10637
|
init_src();
|
|
10435
10638
|
init_server();
|
|
10436
10639
|
init_describe();
|
|
@@ -10439,10 +10642,12 @@ var init_permissions = __esm({
|
|
|
10439
10642
|
FIGMA_TOOL_FALLBACK = ["get_metadata", "get_design_context", "get_screenshot", "get_variable_defs", "get_motion_context", "get_figjam"];
|
|
10440
10643
|
PERMISSIONS_DESCRIPTION = {
|
|
10441
10644
|
name: "permissions",
|
|
10442
|
-
summary: "
|
|
10645
|
+
summary: "Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools).",
|
|
10443
10646
|
args: [],
|
|
10444
10647
|
flags: [
|
|
10445
|
-
{ flag: "--claude", description: "Claude Code settings
|
|
10648
|
+
{ flag: "--claude", description: "Claude Code settings format (the default and currently only format)" },
|
|
10649
|
+
{ flag: "--write", description: "Merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; creates the file; never touches other keys)" },
|
|
10650
|
+
{ flag: "--user", description: "With --write: target ~/.claude/settings.json (every project) instead of the project-local file" },
|
|
10446
10651
|
{ flag: "--mcp-url <url>", description: "Figma MCP endpoint to list live tool names from", default: DEFAULT_MCP_URL },
|
|
10447
10652
|
{ flag: "--json", description: "Machine-readable output" }
|
|
10448
10653
|
],
|
|
@@ -10450,16 +10655,134 @@ var init_permissions = __esm({
|
|
|
10450
10655
|
host: '"claude"',
|
|
10451
10656
|
serverEntries: "string[] \u2014 one entry per MCP server (allows every tool it serves)",
|
|
10452
10657
|
toolEntries: "string[] \u2014 per-tool entries for selective allowlists",
|
|
10453
|
-
directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs"
|
|
10658
|
+
directConfigNote: "string \u2014 prefix rewrite for non-plugin (claude mcp add) installs",
|
|
10659
|
+
written: "with --write: { file, added, alreadyPresent }"
|
|
10454
10660
|
},
|
|
10455
|
-
exitCodes: { 0: "
|
|
10456
|
-
examples: ["tendril permissions --claude", "tendril permissions --claude --json"]
|
|
10661
|
+
exitCodes: { 0: "printed or written", 3: "with --write: the target file exists but is not JSON this command can safely rewrite" },
|
|
10662
|
+
examples: ["tendril permissions --claude --write", "tendril permissions --claude", "tendril permissions --claude --json"]
|
|
10457
10663
|
};
|
|
10458
10664
|
TENDRIL_PLUGIN_PREFIX = "mcp__plugin_tendril_tendril";
|
|
10459
10665
|
FIGMA_PLUGIN_PREFIX = "mcp__plugin_figma_figma";
|
|
10460
10666
|
}
|
|
10461
10667
|
});
|
|
10462
10668
|
|
|
10669
|
+
// packages/cli/src/commands/inspect.ts
|
|
10670
|
+
var inspect_exports = {};
|
|
10671
|
+
__export(inspect_exports, {
|
|
10672
|
+
INSPECT_DESCRIPTION: () => INSPECT_DESCRIPTION,
|
|
10673
|
+
runInspect: () => runInspect
|
|
10674
|
+
});
|
|
10675
|
+
import { existsSync as existsSync30, readFileSync as readFileSync27, writeFileSync as writeFileSync16 } from "node:fs";
|
|
10676
|
+
import path36 from "node:path";
|
|
10677
|
+
async function runInspect(opts) {
|
|
10678
|
+
if (opts.describe) {
|
|
10679
|
+
printDescription(INSPECT_DESCRIPTION);
|
|
10680
|
+
return;
|
|
10681
|
+
}
|
|
10682
|
+
const bundleDir = path36.resolve(opts.bundleDir);
|
|
10683
|
+
const evidenceDir = path36.join(bundleDir, "verify-evidence");
|
|
10684
|
+
const manifestPath2 = path36.join(bundleDir, "component.json");
|
|
10685
|
+
if (!existsSync30(evidenceDir) || !existsSync30(manifestPath2)) {
|
|
10686
|
+
fail(opts, ExitCode.InputValidation, {
|
|
10687
|
+
error: `nothing to inspect in ${bundleDir} \u2014 ${existsSync30(manifestPath2) ? "no verify-evidence directory" : "no component.json"}`,
|
|
10688
|
+
code: "no-evidence",
|
|
10689
|
+
remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
|
|
10690
|
+
});
|
|
10691
|
+
}
|
|
10692
|
+
const { manifest } = readBundleManifest(readFileSync27(manifestPath2, "utf8"));
|
|
10693
|
+
if (manifest === void 0) {
|
|
10694
|
+
fail(opts, ExitCode.InputValidation, {
|
|
10695
|
+
error: "component.json did not parse as a bundle manifest",
|
|
10696
|
+
code: "no-mount-contract",
|
|
10697
|
+
remediation: "Re-emit the bundle (score/verify rewrite component.json), then re-run inspect."
|
|
10698
|
+
});
|
|
10699
|
+
}
|
|
10700
|
+
const setDir = path36.resolve(opts.set ?? manifest.provenance.recordingSet.path);
|
|
10701
|
+
const reps = Object.keys(manifest.propAdapter).filter((rep) => existsSync30(path36.join(evidenceDir, `${rep}-ref.png`)) && existsSync30(path36.join(evidenceDir, `${rep}-render.png`)));
|
|
10702
|
+
if (reps.length === 0) {
|
|
10703
|
+
fail(opts, ExitCode.InputValidation, {
|
|
10704
|
+
error: "verify-evidence holds no ref/render pairs for this bundle's configs",
|
|
10705
|
+
code: "no-evidence",
|
|
10706
|
+
remediation: "Run `tendril verify <bundleDir>` first \u2014 inspect reads the ref/render evidence that run writes."
|
|
10707
|
+
});
|
|
10708
|
+
}
|
|
10709
|
+
let crops = 0;
|
|
10710
|
+
const sections = [];
|
|
10711
|
+
for (const rep of reps) {
|
|
10712
|
+
const ref = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-ref.png`)));
|
|
10713
|
+
const render = new Uint8Array(readFileSync27(path36.join(evidenceDir, `${rep}-render.png`)));
|
|
10714
|
+
const nodes = smallSemanticNodes(setDir, rep, opts.maxArea ?? 1024).slice(0, 12);
|
|
10715
|
+
const cells = [];
|
|
10716
|
+
for (const [i, n] of nodes.entries()) {
|
|
10717
|
+
const rect = { x: n.x, y: n.y, w: n.w, h: n.h };
|
|
10718
|
+
try {
|
|
10719
|
+
writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-ref.png`), zoomCrop(ref, rect));
|
|
10720
|
+
writeFileSync16(path36.join(evidenceDir, `${rep}-inspect-${i}-render.png`), zoomCrop(render, rect));
|
|
10721
|
+
} catch {
|
|
10722
|
+
continue;
|
|
10723
|
+
}
|
|
10724
|
+
crops += 1;
|
|
10725
|
+
cells.push(
|
|
10726
|
+
`<figure><figcaption>${esc(n.name)} <code>${esc(n.id)}</code> \xB7 ${n.w}\xD7${n.h}</figcaption><div class="pair"><span><em>recorded</em><img src="./${rep}-inspect-${i}-ref.png" alt="recorded ${esc(n.name)}"></span><span><em>rendered</em><img src="./${rep}-inspect-${i}-render.png" alt="rendered ${esc(n.name)}"></span></div></figure>`
|
|
10727
|
+
);
|
|
10728
|
+
}
|
|
10729
|
+
sections.push(
|
|
10730
|
+
`<section><h2>${esc(rep)}</h2><div class="full"><span><em>recorded</em><img src="./${rep}-ref.png"></span><span><em>rendered</em><img src="./${rep}-render.png"></span><span><em>diff</em><img src="./${rep}-diff.png"></span></div>` + (cells.length > 0 ? `<div class="grid">${cells.join("")}</div>` : `<p class="none">no small recorded nodes in this config's sweep</p>`) + `</section>`
|
|
10731
|
+
);
|
|
10732
|
+
}
|
|
10733
|
+
const sheet = path36.join(evidenceDir, "inspect.html");
|
|
10734
|
+
writeFileSync16(
|
|
10735
|
+
sheet,
|
|
10736
|
+
`<!doctype html><meta charset="utf-8"><title>${esc(manifest.name)} \u2014 tendril inspect</title><style>
|
|
10737
|
+
body{font:14px/1.45 system-ui,sans-serif;margin:24px;background:#fff;color:#111}
|
|
10738
|
+
h1{font-size:20px} h2{font-size:16px;border-top:1px solid #ddd;padding-top:16px}
|
|
10739
|
+
.pair,.full{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
|
|
10740
|
+
.full img{max-width:400px;border:1px solid #ccc} .pair img{border:1px solid #ccc;image-rendering:pixelated}
|
|
10741
|
+
figure{margin:0 0 16px} figcaption{margin-bottom:4px} em{display:block;color:#666;font-style:normal;font-size:12px}
|
|
10742
|
+
.grid{display:flex;flex-wrap:wrap;gap:20px;margin-top:12px} .none{color:#666}
|
|
10743
|
+
</style><h1>${esc(manifest.name)} \u2014 detail sheet (small recorded nodes, recorded vs rendered)</h1>
|
|
10744
|
+
<p>Every crop is a recorded node small enough that global metrics weight it as a rounding error.
|
|
10745
|
+
Scan the pairs: anything present on the left and missing/invisible on the right is a defect,
|
|
10746
|
+
whatever the scores said. Verdicts come from <code>tendril verify</code> \u2014 this sheet only shows.</p>
|
|
10747
|
+
${sections.join("\n")}
|
|
10748
|
+
`
|
|
10749
|
+
);
|
|
10750
|
+
emitData(opts, { sheet, configs: reps.length, crops }, () => {
|
|
10751
|
+
process.stdout.write(`inspect sheet: ${sheet}
|
|
10752
|
+
${reps.length} config(s), ${crops} detail crop pair(s) \u2014 open the sheet and scan recorded vs rendered
|
|
10753
|
+
`);
|
|
10754
|
+
});
|
|
10755
|
+
}
|
|
10756
|
+
var INSPECT_DESCRIPTION, esc;
|
|
10757
|
+
var init_inspect = __esm({
|
|
10758
|
+
"packages/cli/src/commands/inspect.ts"() {
|
|
10759
|
+
"use strict";
|
|
10760
|
+
init_src3();
|
|
10761
|
+
init_src6();
|
|
10762
|
+
init_src4();
|
|
10763
|
+
init_describe();
|
|
10764
|
+
init_output();
|
|
10765
|
+
INSPECT_DESCRIPTION = {
|
|
10766
|
+
name: "inspect",
|
|
10767
|
+
summary: "Build an eye-verifiable detail sheet from verify evidence: magnified ref-vs-render crops of every small recorded node (icons, controls, marks).",
|
|
10768
|
+
args: [{ name: "bundleDir", required: true, description: "bundle directory (must carry component.json and a verify-evidence dir from a prior `tendril verify`)" }],
|
|
10769
|
+
flags: [
|
|
10770
|
+
{ flag: "--set <dir>", description: "recording set override (default: the bundle's provenance path)" },
|
|
10771
|
+
{ flag: "--max-area <px2>", description: "node area ceiling for the detail sweep", default: "1024" },
|
|
10772
|
+
{ flag: "--json", description: "Machine-readable output" }
|
|
10773
|
+
],
|
|
10774
|
+
output: {
|
|
10775
|
+
sheet: "string \u2014 path to the generated inspect.html",
|
|
10776
|
+
configs: "number \u2014 configs with evidence found",
|
|
10777
|
+
crops: "number \u2014 detail crop pairs written"
|
|
10778
|
+
},
|
|
10779
|
+
exitCodes: { 0: "sheet written", 3: "no verify evidence to inspect (run `tendril verify` first)" },
|
|
10780
|
+
examples: ["tendril inspect ./src/components/Banner", "tendril inspect ./bundle --set ./tendril/recordings/banner"]
|
|
10781
|
+
};
|
|
10782
|
+
esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
10783
|
+
}
|
|
10784
|
+
});
|
|
10785
|
+
|
|
10463
10786
|
// packages/cli/src/commands/generate-route.ts
|
|
10464
10787
|
var generate_route_exports = {};
|
|
10465
10788
|
__export(generate_route_exports, {
|
|
@@ -10484,17 +10807,17 @@ __export(generate_recorded_exports, {
|
|
|
10484
10807
|
runGenerateRecorded: () => runGenerateRecorded
|
|
10485
10808
|
});
|
|
10486
10809
|
import { confirm as confirm3, isCancel as isCancel3 } from "@clack/prompts";
|
|
10487
|
-
import { existsSync as
|
|
10488
|
-
import
|
|
10810
|
+
import { existsSync as existsSync31, readFileSync as readFileSync28 } from "node:fs";
|
|
10811
|
+
import path37 from "node:path";
|
|
10489
10812
|
async function runGenerateRecorded(opts) {
|
|
10490
10813
|
const callerCwd = process.env["INIT_CWD"] ?? process.cwd();
|
|
10491
|
-
const outDirAbs =
|
|
10492
|
-
const recordedAsPath =
|
|
10814
|
+
const outDirAbs = path37.resolve(callerCwd, opts.out);
|
|
10815
|
+
const recordedAsPath = path37.resolve(callerCwd, opts.recorded);
|
|
10493
10816
|
let task;
|
|
10494
10817
|
let taskName;
|
|
10495
10818
|
let authoredApi;
|
|
10496
10819
|
let composition;
|
|
10497
|
-
const isSet =
|
|
10820
|
+
const isSet = existsSync31(path37.join(recordedAsPath, "recording-set.json"));
|
|
10498
10821
|
const registry = TASKS[opts.recorded];
|
|
10499
10822
|
if (registry !== void 0 && !isSet) {
|
|
10500
10823
|
task = registry;
|
|
@@ -10503,7 +10826,7 @@ async function runGenerateRecorded(opts) {
|
|
|
10503
10826
|
try {
|
|
10504
10827
|
const authored = authorTaskFromSet(recordedAsPath);
|
|
10505
10828
|
task = authored.task;
|
|
10506
|
-
taskName =
|
|
10829
|
+
taskName = path37.basename(recordedAsPath);
|
|
10507
10830
|
authoredApi = authored.api;
|
|
10508
10831
|
const roles = RolesSchema.safeParse(loadManifest(recordedAsPath).roles);
|
|
10509
10832
|
if (roles.success) composition = roles.data;
|
|
@@ -10533,8 +10856,11 @@ async function runGenerateRecorded(opts) {
|
|
|
10533
10856
|
if (substitutedFamilies.length > 0) {
|
|
10534
10857
|
warn(opts, `substituted fonts: cache lacks ${substitutedFamilies.map((f) => `"${f}"`).join(", ")} \u2014 scores measure a substitute face and no config can be certified under one (the stamp covers only the provisioned subset); resolve the families to certify`);
|
|
10535
10858
|
}
|
|
10859
|
+
for (const g of missingWeights(task.set)) {
|
|
10860
|
+
warn(opts, `font weights (advisory): the recording implies weights ${g.declared.join(", ")} for "${g.family}" \u2014 cache provides ${g.provided.join(", ")}; nearest-weight rendering may shift stroke density (certification stays family-gated; implied weights can leak across families)`);
|
|
10861
|
+
}
|
|
10536
10862
|
const missing = task.configs.filter(
|
|
10537
|
-
(c) => !
|
|
10863
|
+
(c) => !existsSync31(path37.join(task.set, c.rep, "get_screenshot.json")) || !existsSync31(path37.join(task.set, c.rep, "get_metadata.json")) || !existsSync31(path37.join(task.set, c.rep, "get_design_context.json"))
|
|
10538
10864
|
);
|
|
10539
10865
|
if (missing.length > 0) {
|
|
10540
10866
|
fail(opts, ExitCode.RecordingIncomplete, {
|
|
@@ -10604,8 +10930,8 @@ async function runGenerateRecorded(opts) {
|
|
|
10604
10930
|
` : `${line}
|
|
10605
10931
|
`);
|
|
10606
10932
|
if (opts.dryRun) {
|
|
10607
|
-
emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite:
|
|
10608
|
-
process.stdout.write(`dry-run: nothing sent, nothing written (would write ${
|
|
10933
|
+
emitData(opts, { dryRun: true, task: taskName, model: modelId, consent, wouldWrite: path37.join(outDirAbs, taskName) }, () => {
|
|
10934
|
+
process.stdout.write(`dry-run: nothing sent, nothing written (would write ${path37.join(outDirAbs, taskName)})
|
|
10609
10935
|
`);
|
|
10610
10936
|
});
|
|
10611
10937
|
return;
|
|
@@ -10628,10 +10954,10 @@ async function runGenerateRecorded(opts) {
|
|
|
10628
10954
|
});
|
|
10629
10955
|
}
|
|
10630
10956
|
}
|
|
10631
|
-
const bundleDir =
|
|
10632
|
-
if (
|
|
10957
|
+
const bundleDir = path37.join(outDirAbs, taskName);
|
|
10958
|
+
if (existsSync31(path37.join(bundleDir, "component.json"))) {
|
|
10633
10959
|
try {
|
|
10634
|
-
const prior = readBundleManifest(
|
|
10960
|
+
const prior = readBundleManifest(readFileSync28(path37.join(bundleDir, "component.json"), "utf8")).manifest;
|
|
10635
10961
|
if (prior !== void 0 && prior.provenance.recordingSet.hash !== recordingSetHash(task.set, task.configs)) {
|
|
10636
10962
|
fail(opts, ExitCode.InputValidation, {
|
|
10637
10963
|
error: `${bundleDir} already holds a bundle bound to recording set "${prior.provenance.recordingSet.path}" \u2014 generating here against a different set would silently rewrite its verification identity`,
|
|
@@ -10644,6 +10970,7 @@ async function runGenerateRecorded(opts) {
|
|
|
10644
10970
|
}
|
|
10645
10971
|
}
|
|
10646
10972
|
const result = await runEngineLoop({
|
|
10973
|
+
absentInkGates: opts.bar === "cert",
|
|
10647
10974
|
engine,
|
|
10648
10975
|
segments,
|
|
10649
10976
|
brief,
|
|
@@ -10663,12 +10990,13 @@ async function runGenerateRecorded(opts) {
|
|
|
10663
10990
|
});
|
|
10664
10991
|
const statuses = result.finalScores.map((s) => ({
|
|
10665
10992
|
...s,
|
|
10666
|
-
status: s.similarity >= BARS4.cert.sim && s.inkRecall >= BARS4.cert.ink && substitutedFamilies.length === 0 ? "certified" : s.pass ? "pass" : "fail"
|
|
10993
|
+
status: (s.exact?.similarity ?? s.similarity) >= BARS4.cert.sim && (s.exact?.inkRecall ?? s.inkRecall) >= BARS4.cert.ink && substitutedFamilies.length === 0 && (s.absentInk?.length ?? 0) === 0 ? "certified" : s.pass ? "pass" : "fail"
|
|
10667
10994
|
}));
|
|
10668
10995
|
const behaviorFailures = result.finalBehaviors.filter((b) => !b.pass);
|
|
10669
10996
|
const pixelFailures = statuses.filter((s) => s.status === "fail");
|
|
10670
10997
|
const certified = statuses.filter((s) => s.status === "certified").length;
|
|
10671
|
-
const
|
|
10998
|
+
const absentAtCertBar = opts.bar === "cert" ? result.finalScores.filter((s) => (s.absentInk?.length ?? 0) > 0).map((s) => s.rep) : [];
|
|
10999
|
+
const ok = result.best !== void 0 && pixelFailures.length === 0 && behaviorFailures.length === 0 && absentAtCertBar.length === 0;
|
|
10672
11000
|
let trustStatement;
|
|
10673
11001
|
if (result.best !== void 0) {
|
|
10674
11002
|
const emitted = emitBundleV1({
|
|
@@ -10732,18 +11060,23 @@ ${certified}/${statuses.length} certified \xB7 ${statuses.length - pixelFailures
|
|
|
10732
11060
|
);
|
|
10733
11061
|
if (opts.bar === "cert" && substitutedFamilies.length > 0) {
|
|
10734
11062
|
const names = substitutedFamilies.map((f) => `"${f}"`).join(", ");
|
|
11063
|
+
const alsoAbsent = absentAtCertBar.length > 0 ? ` \u2014 and ${absentAtCertBar.length} config(s) also carry absent-ink clusters; certification requires fixing those too` : "";
|
|
10735
11064
|
if (opts.json) {
|
|
10736
|
-
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
11065
|
+
process.stderr.write(`${JSON.stringify({ error: `font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}`, code: "fonts-unproven", remediation: fontsUnprovenRemediation(task.set) })}
|
|
10737
11066
|
`);
|
|
10738
11067
|
} else {
|
|
10739
|
-
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)
|
|
11068
|
+
process.stderr.write(`error (fonts-unproven): font cache lacks ${names} \u2014 certification never measures a substitute face (scores above are pass-capped)${alsoAbsent}
|
|
10740
11069
|
\u2192 ${fontsUnprovenRemediation(task.set)}
|
|
10741
11070
|
`);
|
|
10742
11071
|
}
|
|
10743
11072
|
process.exitCode = ExitCode.FontsUnproven;
|
|
10744
11073
|
return;
|
|
10745
11074
|
}
|
|
10746
|
-
if (
|
|
11075
|
+
if (absentAtCertBar.length > 0) {
|
|
11076
|
+
warn(opts, `${absentAtCertBar.length} config(s) carry absent-ink clusters (${absentAtCertBar.join(", ")}) \u2014 certification never ships a missing or invisible feature (1B); fix the component or run at --bar pass for the disclosed result`);
|
|
11077
|
+
process.exitCode = ExitCode.VerificationFailed;
|
|
11078
|
+
}
|
|
11079
|
+
if (!ok && (pixelFailures.length > 0 || behaviorFailures.length > 0 || result.best === void 0)) {
|
|
10747
11080
|
warn(opts, `${pixelFailures.length} config(s) and ${behaviorFailures.length} behavior(s) below the ${opts.bar} bar \u2014 bundle written, verdict honest (Q4)`);
|
|
10748
11081
|
process.exitCode = ExitCode.VerificationFailed;
|
|
10749
11082
|
}
|
|
@@ -11789,11 +12122,27 @@ function buildProgram() {
|
|
|
11789
12122
|
...local["out"] !== void 0 ? { out: local["out"] } : {}
|
|
11790
12123
|
});
|
|
11791
12124
|
});
|
|
11792
|
-
program.command("permissions").description("
|
|
12125
|
+
program.command("permissions").description("Install (or print) the Claude Code permission allowlist for the Tendril pipeline (tendril + Figma MCP tools) \u2014 no more transcribing tool names from prompts.").option("--claude", "Claude Code settings format (the default and currently only format)").option("--write", "merge the per-tool entries into .claude/settings.local.json in the current project (idempotent; never touches other keys)").option("--user", "with --write: target ~/.claude/settings.json (every project) instead").option("--mcp-url <url>", "Figma MCP endpoint to list live tool names from").action(async (_o, cmd) => {
|
|
11793
12126
|
const flags = globalFlags(cmd.parent);
|
|
11794
12127
|
const local = cmd.opts();
|
|
11795
12128
|
const { runPermissions: runPermissions2 } = await Promise.resolve().then(() => (init_permissions(), permissions_exports));
|
|
11796
|
-
await runPermissions2({
|
|
12129
|
+
await runPermissions2({
|
|
12130
|
+
...flags,
|
|
12131
|
+
...local["write"] !== void 0 ? { write: local["write"] } : {},
|
|
12132
|
+
...local["user"] !== void 0 ? { user: local["user"] } : {},
|
|
12133
|
+
...local["mcpUrl"] !== void 0 ? { mcpUrl: local["mcpUrl"] } : {}
|
|
12134
|
+
});
|
|
12135
|
+
});
|
|
12136
|
+
program.command("inspect").description("Build an eye-verifiable detail sheet from verify evidence: magnified recorded-vs-rendered crops of every small recorded node (icons, controls, marks) plus full-frame triples, in one static HTML page.").argument("<bundleDir>", "bundle directory with component.json and verify-evidence").option("--set <dir>", "recording set override (default: the bundle's provenance path)").option("--max-area <px2>", "node area ceiling for the detail sweep", "1024").action(async (bundleDir, _o, cmd) => {
|
|
12137
|
+
const flags = globalFlags(cmd.parent);
|
|
12138
|
+
const local = cmd.opts();
|
|
12139
|
+
const { runInspect: runInspect2 } = await Promise.resolve().then(() => (init_inspect(), inspect_exports));
|
|
12140
|
+
await runInspect2({
|
|
12141
|
+
...flags,
|
|
12142
|
+
bundleDir,
|
|
12143
|
+
...local["set"] !== void 0 ? { set: local["set"] } : {},
|
|
12144
|
+
...local["maxArea"] !== void 0 ? { maxArea: Number(local["maxArea"]) } : {}
|
|
12145
|
+
});
|
|
11797
12146
|
});
|
|
11798
12147
|
program.command("verify").description("Recompute verification for a generated bundle against its recording set (per-config status, behaviors, honest exit codes).").argument("<bundleDir>", "bundle directory to verify").option("--task <name>", "legacy task adapter for bundles WITHOUT component.json (bundle v1 carries its own prop manifest)").option("--set <dir>", "recording set directory (overrides the bundle's provenance path)").option("--bar <bar>", "target bar: pass (0.95) or cert (0.97)", "pass").action(async (bundleDir, _opts, cmd) => {
|
|
11799
12148
|
const flags = globalFlags(cmd);
|