spec-layer 0.7.0 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +4 -1
  2. package/dist/cli.js +776 -90
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,7 +62,10 @@ Module 2025.10 files, rather than `ai/foundation.yaml`, needs 0.4.0 or later.
62
62
  `ai/components/`, `path` in place of `aiPath` in the manifest, and the
63
63
  `outputs` block need 0.6.0 or later. `component-specs/` beside `tokens/`,
64
64
  `componentSpecsDir`, cwd-relative manifest paths, and the `tokens/` directory
65
- need 0.7.0 or later.
65
+ need 0.7.0 or later. The `census` and `config_hash` blocks inside
66
+ `resolver.json`, and the `transform` and `resolved` fields in
67
+ `spec-layer.meta.json`, need 0.8.2 or later; an earlier version pulls the same
68
+ files without those fields.
66
69
 
67
70
  ## Commands
68
71
 
package/dist/cli.js CHANGED
@@ -559,7 +559,7 @@ var require_sha256 = __commonJS({
559
559
  import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
- import { existsSync as existsSync8 } from "node:fs";
562
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "node:fs";
563
563
  import { join as join8, resolve as resolve5 } from "node:path";
564
564
 
565
565
  // ../extractor/src/statesMatrix.ts
@@ -814,8 +814,37 @@ var SUPPORTED_TOKEN_TYPES = [
814
814
  var SUPPORTED_VALUE_KINDS = ["literal", "alias", "missing"];
815
815
  var SUPPORTED_DURATION_UNITS = ["ms", "s"];
816
816
 
817
+ // ../extractor/src/v5/units.ts
818
+ var UNIT_BY_SCOPE = {
819
+ WIDTH_HEIGHT: "px",
820
+ CORNER_RADIUS: "px",
821
+ GAP: "px",
822
+ FONT_SIZE: "px",
823
+ STROKE_FLOAT: "px",
824
+ PARAGRAPH_SPACING: "px",
825
+ PARAGRAPH_INDENT: "px",
826
+ EFFECT_FLOAT: "px",
827
+ FONT_WEIGHT: "number",
828
+ OPACITY: "number"
829
+ };
830
+ function scopesStateUnit(scopes) {
831
+ return (scopes ?? []).some((s) => UNIT_BY_SCOPE[s] !== void 0);
832
+ }
833
+ function scopesStateNumber(scopes) {
834
+ return (scopes ?? []).some((s) => UNIT_BY_SCOPE[s] === "number");
835
+ }
836
+
817
837
  // ../extractor/src/v5/canonical.ts
818
838
  var import_js_sha2562 = __toESM(require_sha256(), 1);
839
+ var SCHEMA_VERSION = "5.1.0";
840
+ function canonicalJson(value) {
841
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
842
+ if (value && typeof value === "object") {
843
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => compareCodeUnits(a, b)).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
844
+ return `{${entries.join(",")}}`;
845
+ }
846
+ return JSON.stringify(value);
847
+ }
819
848
 
820
849
  // ../extractor/src/v5/validate.ts
821
850
  var ROOT = "<artifact>";
@@ -1316,7 +1345,27 @@ function validateLevel1(artifact) {
1316
1345
  }
1317
1346
  }
1318
1347
 
1348
+ // ../extractor/src/v5/fonts.ts
1349
+ function fontRequirements(artifact) {
1350
+ const byFamily = /* @__PURE__ */ new Map();
1351
+ for (const style of artifact.styles.typography) {
1352
+ const family = style.properties.font_family.resolved;
1353
+ if (family === null || family.type !== "font_family") continue;
1354
+ const bucket = byFamily.get(family.value) ?? { weights: /* @__PURE__ */ new Set(), used: /* @__PURE__ */ new Set() };
1355
+ const weight = style.properties.font_weight.resolved;
1356
+ if (weight !== null && weight.type === "number") bucket.weights.add(weight.value);
1357
+ bucket.used.add(style.name);
1358
+ byFamily.set(family.value, bucket);
1359
+ }
1360
+ return [...byFamily.entries()].sort((a, b) => compareCodeUnits(a[0], b[0])).map(([family, bucket]) => ({
1361
+ family,
1362
+ weights: [...bucket.weights].sort((a, b) => a - b),
1363
+ used_by: [...bucket.used].sort(compareCodeUnits)
1364
+ }));
1365
+ }
1366
+
1319
1367
  // ../extractor/src/v5/dtcg.ts
1368
+ var import_js_sha2563 = __toESM(require_sha256(), 1);
1320
1369
  function dtcgSegments(name) {
1321
1370
  const segments = [];
1322
1371
  const notes = [];
@@ -1334,6 +1383,9 @@ function dtcgSegments(name) {
1334
1383
  }
1335
1384
  return { segments, notes };
1336
1385
  }
1386
+ function dtcgPathOf(collectionName, tokenName) {
1387
+ return [...dtcgSegments(collectionName).segments, ...dtcgSegments(tokenName).segments].join(".");
1388
+ }
1337
1389
  function trimDashes(s) {
1338
1390
  let start = 0;
1339
1391
  let end = s.length;
@@ -1435,6 +1487,15 @@ function sortTree(value) {
1435
1487
  const keys = Object.keys(value).sort((a, b) => rank(a) - rank(b) || compareCodeUnits(a, b));
1436
1488
  return Object.fromEntries(keys.map((k) => [k, sortTree(value[k])]));
1437
1489
  }
1490
+ function recordFact(p, tokenId, mode, transform, resolved2) {
1491
+ let facts = p.factsById.get(tokenId);
1492
+ if (!facts) {
1493
+ facts = { transform: {}, resolved: {} };
1494
+ p.factsById.set(tokenId, facts);
1495
+ }
1496
+ facts.transform[mode] = transform;
1497
+ if (resolved2 !== void 0) facts.resolved[mode] = resolved2;
1498
+ }
1438
1499
  function reportOnce(p, entry2) {
1439
1500
  const key = JSON.stringify([entry2.code, entry2.path, entry2.mode ?? null, entry2.details]);
1440
1501
  if (p.reportKeys.has(key)) return;
@@ -1495,20 +1556,117 @@ function unitOverrideFor(p, token, collection) {
1495
1556
  }
1496
1557
  return void 0;
1497
1558
  }
1498
- var STATED_NUMBER_SCOPES = ["FONT_WEIGHT", "OPACITY"];
1499
- function projectedLiteral(p, token, resolved2, onOverrideConflict) {
1559
+ function reportPathOf(p, token) {
1560
+ const path = p.pathById.get(token.id) ?? p.segmentsById.get(token.id)?.join(".") ?? token.name;
1561
+ return p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path;
1562
+ }
1563
+ function ownerFor(p, token) {
1564
+ const path = reportPathOf(p, token);
1565
+ return {
1566
+ overrideConflict: (override) => {
1567
+ reportOnce(p, {
1568
+ code: "unit_override_conflicts_with_scope",
1569
+ severity: "warning",
1570
+ path,
1571
+ message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
1572
+ details: { id: token.id, override, scopes: [...token.scopes] }
1573
+ });
1574
+ },
1575
+ // Reported without a mode, like the override conflict above: the evidence
1576
+ // is a fact about the token, not about one of its values, so a token in
1577
+ // three modes earns one entry rather than three.
1578
+ derivedUnit: (evidence) => {
1579
+ reportOnce(p, {
1580
+ code: "unit_derived_from_usage",
1581
+ severity: "info",
1582
+ path,
1583
+ // "its own variable", not "no scope": for `via: 'alias-scope'` a scope
1584
+ // is exactly what stated the unit, and this same sentence goes on to
1585
+ // name it. What is true of both kinds of evidence is that the token's
1586
+ // OWN variable states nothing. The CSS header that points a reader at
1587
+ // this entry says it the same way, for the same reason.
1588
+ message: `This token's own variable states no unit, so ${evidence.unit} was taken from how the library uses it: ${evidence.source} ${evidence.via === "binding" ? "binds it to" : "is scoped"} ${evidence.reason}.`,
1589
+ details: {
1590
+ id: token.id,
1591
+ unit: evidence.unit,
1592
+ via: evidence.via,
1593
+ source: evidence.source,
1594
+ reason: evidence.reason
1595
+ }
1596
+ });
1597
+ }
1598
+ };
1599
+ }
1600
+ function projectedLiteral(p, token, resolved2, owner) {
1500
1601
  const collection = p.collectionById.get(token.collection_id);
1501
1602
  const override = collection ? unitOverrideFor(p, token, collection) : void 0;
1502
1603
  let literal = resolved2;
1604
+ let overrode = false;
1605
+ let derived;
1503
1606
  if (override !== void 0 && literal.type === "number") {
1504
- if (token.scopes.some((s) => STATED_NUMBER_SCOPES.includes(s))) onOverrideConflict?.(override);
1505
- else literal = { type: "dimension", number: literal.value, unit: override };
1607
+ if (scopesStateNumber(token.scopes)) owner?.overrideConflict(override);
1608
+ else {
1609
+ literal = { type: "dimension", number: literal.value, unit: override };
1610
+ overrode = true;
1611
+ }
1612
+ } else if (literal.type === "number" && !scopesStateUnit(token.scopes)) {
1613
+ const evidence = p.derivedUnits.get(token.id);
1614
+ if (evidence !== void 0) {
1615
+ literal = { type: "dimension", number: literal.value, unit: evidence.unit };
1616
+ derived = evidence;
1617
+ owner?.derivedUnit(evidence);
1618
+ }
1619
+ }
1620
+ const converted2 = dtcgLiteral(literal, token.scopes, p.options.values);
1621
+ if ("omit" in converted2) return { converted: converted2, transform: null };
1622
+ let transform = literalTransform(literal, token.scopes);
1623
+ if (overrode) transform = "number-unit-override";
1624
+ else if (derived !== void 0) transform = "number-unit-usage";
1625
+ return { converted: converted2, transform };
1626
+ }
1627
+ function literalTransform(value, scopes) {
1628
+ switch (value.type) {
1629
+ case "color":
1630
+ return "color";
1631
+ case "dimension":
1632
+ return "dimension";
1633
+ case "duration":
1634
+ return "duration";
1635
+ case "number":
1636
+ return scopes.includes("FONT_WEIGHT") ? "font-weight" : "number";
1637
+ case "cubic_bezier":
1638
+ return "cubic-bezier";
1639
+ case "font_family":
1640
+ return "font-family";
1641
+ case "string":
1642
+ case "boolean":
1643
+ return null;
1644
+ default: {
1645
+ const exhaustive = value;
1646
+ return exhaustive;
1647
+ }
1506
1648
  }
1507
- return dtcgLiteral(literal, token.scopes, p.options.values);
1508
1649
  }
1509
1650
  function aliasLeafType(p, token, chain, resolved2) {
1510
1651
  const terminal = chain.length > 0 ? p.tokenById.get(chain[chain.length - 1].token_id) : void 0;
1511
- return projectedLiteral(p, terminal ?? token, resolved2);
1652
+ const subject = terminal ?? token;
1653
+ return projectedLiteral(p, subject, resolved2, ownerFor(p, subject)).converted;
1654
+ }
1655
+ function terminalOwnType(p, chain) {
1656
+ const hop = chain.length > 0 ? chain[chain.length - 1] : void 0;
1657
+ const terminal = hop ? p.tokenById.get(hop.token_id) : void 0;
1658
+ const value = terminal && hop ? terminal.values[hop.mode_id] : void 0;
1659
+ if (!terminal || !value || value.kind !== "literal") return void 0;
1660
+ return projectedLiteral(p, terminal, value.value, ownerFor(p, terminal)).converted;
1661
+ }
1662
+ function reportAliasTypeMismatch(p, path, targetPath, ownType, targetType) {
1663
+ reportOnce(p, {
1664
+ code: "alias_type_mismatch",
1665
+ severity: "error",
1666
+ path,
1667
+ message: `This token is "${ownType}" but its alias target ${targetPath} is "${targetType}"; a consumer reading the declared type gets a value the target cannot carry.`,
1668
+ details: { target: targetPath, own_type: ownType, target_type: targetType }
1669
+ });
1512
1670
  }
1513
1671
  function modeLabels(collection) {
1514
1672
  const counts = /* @__PURE__ */ new Map();
@@ -1547,6 +1705,17 @@ function reportCollectionNameCollisions(p) {
1547
1705
  function asJson(value) {
1548
1706
  return JSON.parse(JSON.stringify(value));
1549
1707
  }
1708
+ function transformField(p, token) {
1709
+ const facts = p.factsById.get(token.id);
1710
+ if (!facts) return {};
1711
+ const sorted = (source) => {
1712
+ const keys = Object.keys(source).sort(compareCodeUnits);
1713
+ return keys.length === 0 ? void 0 : Object.fromEntries(keys.map((k) => [k, source[k]]));
1714
+ };
1715
+ const transform = sorted(facts.transform);
1716
+ const resolved2 = sorted(facts.resolved);
1717
+ return { ...transform ? { transform } : {}, ...resolved2 ? { resolved: resolved2 } : {} };
1718
+ }
1550
1719
  function metaEntry(p, token, collection) {
1551
1720
  const labels = p.modeLabelsById.get(collection.id) ?? modeLabels(collection);
1552
1721
  const omitted = p.omittedIds.has(token.id);
@@ -1559,6 +1728,7 @@ function metaEntry(p, token, collection) {
1559
1728
  collection_id: token.collection_id,
1560
1729
  type: token.type,
1561
1730
  scopes: [...token.scopes],
1731
+ ...omitted ? {} : transformField(p, token),
1562
1732
  ...token.code_syntax ? { code_syntax: token.code_syntax } : {},
1563
1733
  ...token.publication ? { publication: token.publication } : {},
1564
1734
  ...omitted ? {
@@ -1857,10 +2027,69 @@ function annotateGroups(p, tree, collection) {
1857
2027
  }
1858
2028
  }
1859
2029
  }
1860
- function foundationDtcg(artifact, options = {}) {
2030
+ var newAccumulator = () => ({
2031
+ tokens: 0,
2032
+ types: /* @__PURE__ */ new Map(),
2033
+ present: 0,
2034
+ missing: 0,
2035
+ aliases: 0,
2036
+ literals: 0,
2037
+ scopes: /* @__PURE__ */ new Map(),
2038
+ codeSyntaxPresent: 0,
2039
+ codeSyntaxMissing: 0,
2040
+ published: 0,
2041
+ hiddenFromPublishing: 0,
2042
+ unstated: 0,
2043
+ omitted: 0,
2044
+ collided: 0
2045
+ });
2046
+ var bump = (counts, key) => {
2047
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2048
+ };
2049
+ var histogram = (counts) => Object.fromEntries([...counts.keys()].sort(compareCodeUnits).map((k) => [k, counts.get(k)]));
2050
+ function censusEntry(a) {
2051
+ return {
2052
+ tokens: a.tokens,
2053
+ types: histogram(a.types),
2054
+ descriptions: { present: a.present, missing: a.missing },
2055
+ aliases: a.aliases,
2056
+ literals: a.literals,
2057
+ scopes: histogram(a.scopes),
2058
+ code_syntax: { present: a.codeSyntaxPresent, missing: a.codeSyntaxMissing },
2059
+ publication: {
2060
+ published: a.published,
2061
+ hidden_from_publishing: a.hiddenFromPublishing,
2062
+ unstated: a.unstated
2063
+ },
2064
+ ...a.omitted > 0 ? { omitted: a.omitted } : {},
2065
+ ...a.collided > 0 ? { collided: a.collided } : {}
2066
+ };
2067
+ }
2068
+ function styleCensus(tree) {
2069
+ const a = newAccumulator();
2070
+ const walk = (node) => {
2071
+ if (typeof node !== "object" || node === null || Array.isArray(node)) return;
2072
+ const record = node;
2073
+ if ("$value" in record) {
2074
+ a.tokens += 1;
2075
+ bump(a.types, typeof record.$type === "string" ? record.$type : "unknown");
2076
+ if (typeof record.$description === "string" && record.$description.length > 0) a.present += 1;
2077
+ else a.missing += 1;
2078
+ return;
2079
+ }
2080
+ for (const [key, value] of Object.entries(record)) {
2081
+ if (key.startsWith("$")) continue;
2082
+ walk(value);
2083
+ }
2084
+ };
2085
+ walk(tree);
2086
+ return { tokens: a.tokens, types: histogram(a.types), descriptions: { present: a.present, missing: a.missing } };
2087
+ }
2088
+ function foundationDtcg(artifact, options = {}, derivedUnits) {
1861
2089
  const p = {
1862
2090
  artifact,
1863
2091
  options: { values: options.values ?? "standard", ...options.units ? { units: options.units } : {} },
2092
+ derivedUnits: derivedUnits ?? /* @__PURE__ */ new Map(),
1864
2093
  tokenById: new Map(artifact.tokens.map((t) => [t.id, t])),
1865
2094
  tokenIds: new Set(artifact.tokens.map((t) => t.id)),
1866
2095
  collectionById: new Map(artifact.collections.map((c) => [c.id, c])),
@@ -1871,7 +2100,8 @@ function foundationDtcg(artifact, options = {}) {
1871
2100
  omittedIds: /* @__PURE__ */ new Set(),
1872
2101
  collidedIds: /* @__PURE__ */ new Set(),
1873
2102
  report: [],
1874
- reportKeys: /* @__PURE__ */ new Set()
2103
+ reportKeys: /* @__PURE__ */ new Set(),
2104
+ factsById: /* @__PURE__ */ new Map()
1875
2105
  };
1876
2106
  indexPaths(p);
1877
2107
  omitInexpressibleTypes(p);
@@ -1879,23 +2109,46 @@ function foundationDtcg(artifact, options = {}) {
1879
2109
  reportCollectionNameCollisions(p);
1880
2110
  const files = {};
1881
2111
  const plans = [];
2112
+ const census = {};
1882
2113
  const taken = new Set(RESERVED_FILE_NAMES);
1883
2114
  for (const collection of artifact.collections) {
1884
2115
  for (const mode of collection.modes) {
1885
2116
  const tree = {};
2117
+ const a = newAccumulator();
1886
2118
  for (const token of artifact.tokens) {
1887
- if (token.collection_id !== collection.id || p.omittedIds.has(token.id)) continue;
2119
+ if (token.collection_id !== collection.id) continue;
2120
+ if (p.omittedIds.has(token.id)) {
2121
+ a.omitted += 1;
2122
+ if (p.collidedIds.has(token.id)) a.collided += 1;
2123
+ continue;
2124
+ }
1888
2125
  const leaf = tokenLeaf(p, token, collection, mode.id);
1889
- if (leaf) setLeaf(tree, p.segmentsById.get(token.id) ?? [], leaf);
2126
+ if (!leaf) continue;
2127
+ setLeaf(tree, p.segmentsById.get(token.id) ?? [], leaf);
2128
+ a.tokens += 1;
2129
+ bump(a.types, typeof leaf.$type === "string" ? leaf.$type : "unknown");
2130
+ const modeLabel = modeLabelOf(p, collection, mode.id);
2131
+ if (p.factsById.get(token.id)?.transform[modeLabel] === "alias") a.aliases += 1;
2132
+ else a.literals += 1;
2133
+ if (token.description.length > 0) a.present += 1;
2134
+ else a.missing += 1;
2135
+ for (const scope of token.scopes) bump(a.scopes, scope);
2136
+ if (token.code_syntax) a.codeSyntaxPresent += 1;
2137
+ else a.codeSyntaxMissing += 1;
2138
+ if (token.publication?.published) a.published += 1;
2139
+ if (token.publication?.hidden_from_publishing) a.hiddenFromPublishing += 1;
2140
+ if (!token.publication) a.unstated += 1;
1890
2141
  }
1891
2142
  annotateGroups(p, tree, collection);
1892
2143
  const file = fileNameFor(collection, mode, taken);
1893
2144
  plans.push({ collection, modeId: mode.id, file });
1894
2145
  files[file] = sortTree(tree);
2146
+ census[file] = censusEntry(a);
1895
2147
  }
1896
2148
  }
1897
2149
  const styles = styleFiles(p);
1898
2150
  Object.assign(files, styles);
2151
+ for (const [file, tree] of Object.entries(styles)) census[file] = styleCensus(tree);
1899
2152
  const resolver = buildResolver(p, plans, Object.keys(styles).sort(compareCodeUnits));
1900
2153
  p.report.sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
1901
2154
  const meta = {};
@@ -1906,7 +2159,25 @@ function foundationDtcg(artifact, options = {}) {
1906
2159
  meta[p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path] = metaEntry(p, token, collection);
1907
2160
  }
1908
2161
  const sortedMeta = Object.fromEntries(Object.entries(meta).sort(([a], [b]) => compareCodeUnits(a, b)));
1909
- return { files, resolver, meta: sortedMeta, report: p.report };
2162
+ const codeSyntax = {};
2163
+ for (const [path, entry2] of Object.entries(sortedMeta)) {
2164
+ if (entry2.code_syntax) codeSyntax[path] = entry2.code_syntax;
2165
+ }
2166
+ const sourceFileName = artifact.spec_layer.source.file_name;
2167
+ const extension = {
2168
+ schema_version: SCHEMA_VERSION,
2169
+ content_hash: artifact.spec_layer.export.content_hash,
2170
+ config_hash: `sha256:${(0, import_js_sha2563.sha256)(canonicalJson(p.options))}`,
2171
+ source: {
2172
+ provider: "figma",
2173
+ ...typeof sourceFileName === "string" && sourceFileName.length > 0 ? { file_name: sourceFileName } : {}
2174
+ },
2175
+ completeness: artifact.completeness,
2176
+ code_syntax: codeSyntax,
2177
+ census: Object.fromEntries(Object.keys(census).sort(compareCodeUnits).map((k) => [k, census[k]])),
2178
+ report: p.report
2179
+ };
2180
+ return { files, resolver, meta: sortedMeta, report: p.report, extension };
1910
2181
  }
1911
2182
  function omitInexpressibleTypes(p) {
1912
2183
  for (const token of p.artifact.tokens) {
@@ -2007,17 +2278,18 @@ function tokenLeaf(p, token, collection, modeId) {
2007
2278
  });
2008
2279
  return null;
2009
2280
  }
2281
+ const terminalType = terminalOwnType(p, value.resolved.chain);
2282
+ if (terminalType && !("omit" in terminalType) && terminalType.$type !== typed.$type) {
2283
+ reportAliasTypeMismatch(p, path, targetPath, typed.$type, terminalType.$type);
2284
+ const transform = literalTransform(value.resolved.value, token.scopes);
2285
+ if (transform !== null) recordFact(p, token.id, mode, transform);
2286
+ return { $type: typed.$type, $value: typed.$value, ...description };
2287
+ }
2288
+ recordFact(p, token.id, mode, "alias", typed.$value);
2010
2289
  return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2011
2290
  }
2012
- const converted2 = projectedLiteral(p, token, value.value, (override) => {
2013
- reportOnce(p, {
2014
- code: "unit_override_conflicts_with_scope",
2015
- severity: "warning",
2016
- path,
2017
- message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
2018
- details: { id: token.id, override, scopes: [...token.scopes] }
2019
- });
2020
- });
2291
+ const projected = projectedLiteral(p, token, value.value, ownerFor(p, token));
2292
+ const converted2 = projected.converted;
2021
2293
  if ("omit" in converted2) {
2022
2294
  reportOnce(p, {
2023
2295
  code: converted2.omit,
@@ -2029,6 +2301,7 @@ function tokenLeaf(p, token, collection, modeId) {
2029
2301
  });
2030
2302
  return null;
2031
2303
  }
2304
+ if (projected.transform !== null) recordFact(p, token.id, mode, projected.transform);
2032
2305
  return { $type: converted2.$type, $value: converted2.$value, ...description };
2033
2306
  }
2034
2307
  function dtcgExportFiles(out) {
@@ -2036,12 +2309,133 @@ function dtcgExportFiles(out) {
2036
2309
  `;
2037
2310
  const files = {};
2038
2311
  for (const name of Object.keys(out.files).sort(compareCodeUnits)) files[name] = text(out.files[name]);
2039
- files["resolver.json"] = text(out.resolver);
2312
+ files["resolver.json"] = text({
2313
+ ...out.resolver,
2314
+ $extensions: { "com.spec-layer": out.extension }
2315
+ });
2040
2316
  files["spec-layer.meta.json"] = text(out.meta);
2041
2317
  files["report.json"] = text(out.report);
2042
2318
  return files;
2043
2319
  }
2044
2320
 
2321
+ // ../extractor/src/v5/usageUnits.ts
2322
+ var LENGTH_SCOPES = ["CORNER_RADIUS", "WIDTH_HEIGHT", "GAP", "FONT_SIZE", "STROKE_FLOAT"];
2323
+ var LENGTH_PROPERTIES = [
2324
+ "border-radius",
2325
+ "border-top-left-radius",
2326
+ "border-top-right-radius",
2327
+ "border-bottom-left-radius",
2328
+ "border-bottom-right-radius",
2329
+ "gap",
2330
+ "height",
2331
+ "padding-x",
2332
+ "padding-y",
2333
+ "width"
2334
+ ];
2335
+ var MAX_ALIAS_DEPTH = 16;
2336
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2337
+ function foundationOf(bundle) {
2338
+ const artifact = bundle.foundation?.artifact;
2339
+ if (!isRecord2(artifact)) return null;
2340
+ if (!Array.isArray(artifact.tokens) || !Array.isArray(artifact.collections)) return null;
2341
+ return artifact;
2342
+ }
2343
+ function precedes(a, b) {
2344
+ return (compareCodeUnits(a.via, b.via) || compareCodeUnits(a.source, b.source) || compareCodeUnits(a.reason, b.reason)) < 0;
2345
+ }
2346
+ function bindingsOf(artifact) {
2347
+ if (!isRecord2(artifact) || !isRecord2(artifact.references)) return [];
2348
+ const bindings = artifact.references.bindings;
2349
+ if (!Array.isArray(bindings)) return [];
2350
+ return bindings.filter((b) => isRecord2(b) && b.kind === "variable" && typeof b.source_id === "string" && typeof b.property === "string");
2351
+ }
2352
+ function aliasTargets(token) {
2353
+ const out = [];
2354
+ for (const value of Object.values(token.values)) {
2355
+ if (value.kind !== "alias" || value.reference.external) continue;
2356
+ const id = value.reference.target_id;
2357
+ if (id !== null && !out.includes(id)) out.push(id);
2358
+ }
2359
+ return out;
2360
+ }
2361
+ function usageUnits(bundle) {
2362
+ const evidence = /* @__PURE__ */ new Map();
2363
+ const artifact = foundationOf(bundle);
2364
+ if (artifact === null) return evidence;
2365
+ const tokenById = new Map(artifact.tokens.map((t) => [t.id, t]));
2366
+ const collectionById = new Map(artifact.collections.map((c) => [c.id, c]));
2367
+ const lengthUse = /* @__PURE__ */ new Map();
2368
+ const nonLengthUse = [];
2369
+ for (const component of bundle.components) {
2370
+ for (const binding of bindingsOf(component.artifact)) {
2371
+ if (!LENGTH_PROPERTIES.includes(binding.property)) {
2372
+ nonLengthUse.push(binding.source_id);
2373
+ continue;
2374
+ }
2375
+ const found = {
2376
+ unit: "px",
2377
+ via: "binding",
2378
+ source: component.name,
2379
+ reason: binding.property
2380
+ };
2381
+ const prior = lengthUse.get(binding.source_id);
2382
+ if (prior === void 0 || precedes(found, prior)) lengthUse.set(binding.source_id, found);
2383
+ }
2384
+ }
2385
+ const vetoedIds = /* @__PURE__ */ new Set();
2386
+ const walkChain = (startId, includeStart, visit) => {
2387
+ const seen = /* @__PURE__ */ new Set([startId]);
2388
+ let frontier = [startId];
2389
+ for (let depth = 0; depth <= MAX_ALIAS_DEPTH && frontier.length > 0; depth += 1) {
2390
+ const next = [];
2391
+ for (const id of frontier) {
2392
+ const token = tokenById.get(id);
2393
+ if (id !== startId && token !== void 0 && scopesStateUnit(token.scopes)) continue;
2394
+ if (id !== startId || includeStart) visit(id);
2395
+ if (token === void 0) continue;
2396
+ for (const target of aliasTargets(token)) {
2397
+ if (seen.has(target)) continue;
2398
+ seen.add(target);
2399
+ next.push(target);
2400
+ }
2401
+ }
2402
+ frontier = next;
2403
+ }
2404
+ };
2405
+ for (const id of nonLengthUse) walkChain(id, true, (reached) => vetoedIds.add(reached));
2406
+ for (const token of artifact.tokens) {
2407
+ if (!scopesStateNumber(token.scopes)) continue;
2408
+ walkChain(token.id, false, (reached) => vetoedIds.add(reached));
2409
+ }
2410
+ const isCandidate = (id) => {
2411
+ const token = tokenById.get(id);
2412
+ return token !== void 0 && token.type === "number" && !scopesStateUnit(token.scopes);
2413
+ };
2414
+ const record = (id, found) => {
2415
+ if (!isCandidate(id) || vetoedIds.has(id)) return;
2416
+ const prior = evidence.get(id);
2417
+ if (prior === void 0 || precedes(found, prior)) evidence.set(id, found);
2418
+ };
2419
+ const pin = (startId, found, includeStart) => walkChain(startId, includeStart, (id) => record(id, found));
2420
+ for (const token of artifact.tokens) {
2421
+ const scopes = token.scopes.filter((s) => LENGTH_SCOPES.includes(s)).sort(compareCodeUnits);
2422
+ if (scopes.length === 0) continue;
2423
+ const collection = collectionById.get(token.collection_id);
2424
+ if (collection === void 0) continue;
2425
+ pin(token.id, {
2426
+ unit: "px",
2427
+ via: "alias-scope",
2428
+ source: dtcgPathOf(collection.name, token.name),
2429
+ reason: scopes[0]
2430
+ }, false);
2431
+ }
2432
+ for (const token of artifact.tokens) {
2433
+ const found = lengthUse.get(token.id);
2434
+ if (found !== void 0) pin(token.id, found, true);
2435
+ }
2436
+ return evidence;
2437
+ }
2438
+
2045
2439
  // ../extractor/src/v5/outputs/naming.ts
2046
2440
  var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
2047
2441
  function splitWords(segment) {
@@ -2255,10 +2649,22 @@ function cssValue(ctx, type, value, property) {
2255
2649
  if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
2256
2650
  break;
2257
2651
  }
2258
- case "number":
2259
2652
  case "fontWeight":
2260
2653
  if (typeof value === "number") return String(value);
2261
2654
  break;
2655
+ case "number":
2656
+ if (typeof value === "number") {
2657
+ if (property !== "lineHeight") {
2658
+ report(ctx, {
2659
+ code: "unitless_number",
2660
+ severity: "warning",
2661
+ message: ctx.statesNumber ? "This token has no unit, because its Figma scopes state it is a unitless number. CSS reads it as a number, not a length, which is what those scopes ask for. Do not use it where a length is expected." : "This token has no unit, because its Figma variable states none. CSS reads it as a number, not a length. Narrow the variable's scopes in Figma and pull again.",
2662
+ details: { value, ...member }
2663
+ });
2664
+ }
2665
+ return String(value);
2666
+ }
2667
+ break;
2262
2668
  case "cubicBezier":
2263
2669
  if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
2264
2670
  return `cubic-bezier(${value.join(", ")})`;
@@ -2413,22 +2819,59 @@ function shadowDecl(ctx, leaf, name) {
2413
2819
  return `${name}: ${parts.join(", ")};`;
2414
2820
  }
2415
2821
  var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
2416
- function headerText(header, nameCase) {
2417
- return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
2418
- Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
2822
+ function headerText(header, nameCase, unitlessCount = 0, derivedCount = 0) {
2823
+ const lines = [
2824
+ `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.`,
2825
+ " Do not edit. Change the design in Figma, republish, and run spec-layer pull."
2826
+ ];
2827
+ if (unitlessCount > 0) {
2828
+ const reportFile = `${header.platform}-${header.format}.report.json`;
2829
+ if (unitlessCount === 1) {
2830
+ lines.push(
2831
+ " 1 property in this file has no unit, because its Figma variable states none.",
2832
+ ` CSS reads it as a number, not a length. See outputs/${reportFile} under your pull's output directory, or narrow the variable's scopes in Figma.`
2833
+ );
2834
+ } else {
2835
+ lines.push(
2836
+ ` ${unitlessCount} properties in this file have no unit, because their Figma variables state none.`,
2837
+ ` CSS reads them as numbers, not lengths. See outputs/${reportFile} under your pull's output directory, or narrow the variables' scopes in Figma.`
2838
+ );
2839
+ }
2840
+ }
2841
+ if (derivedCount > 0) {
2842
+ lines.push(
2843
+ derivedCount === 1 ? " 1 property in this file has a unit its own Figma variable does not state, taken from how the library uses the token." : ` ${derivedCount} properties in this file have a unit their own Figma variables do not state, taken from how the library uses those tokens.`,
2844
+ ` See tokens/report.json under your pull's output directory for what pinned ${derivedCount === 1 ? "it" : "each one"}.`
2845
+ );
2846
+ }
2847
+ return `${lines.join("\n")} */`;
2419
2848
  }
2420
- function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2849
+ function emitPass(sources, leavesByFile, names, alive, root, template, modes, facts) {
2421
2850
  const entries = [];
2422
2851
  const declared = /* @__PURE__ */ new Set();
2423
2852
  const firstFile = /* @__PURE__ */ new Map();
2424
2853
  const blocks = /* @__PURE__ */ new Map();
2854
+ const unitlessByFile = /* @__PURE__ */ new Map();
2855
+ const derivedByFile = /* @__PURE__ */ new Map();
2856
+ const note = (by, file, path) => {
2857
+ const set = by.get(file) ?? /* @__PURE__ */ new Set();
2858
+ set.add(path);
2859
+ by.set(file, set);
2860
+ };
2425
2861
  for (const s of sources) {
2426
2862
  const perCollection = modes?.[s.collection];
2427
2863
  const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2428
2864
  const decls = [];
2429
2865
  const declaredHere = [];
2430
2866
  for (const leaf of leavesByFile.get(s.file) ?? []) {
2431
- const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2867
+ const ctx = {
2868
+ names,
2869
+ alive,
2870
+ report: entries,
2871
+ path: leaf.path,
2872
+ statesNumber: facts.statesNumber.has(leaf.path),
2873
+ ...s.mode !== null ? { mode: s.mode } : {}
2874
+ };
2432
2875
  if (leaf.type === "typography") {
2433
2876
  const t = typographyDecls(ctx, leaf, names);
2434
2877
  decls.push(...t.decls);
@@ -2447,12 +2890,17 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2447
2890
  declaredHere.push(leaf.path);
2448
2891
  }
2449
2892
  } else {
2893
+ const before = entries.length;
2450
2894
  const v = cssValue(ctx, leaf.type, leaf.value);
2451
2895
  if (v !== null) {
2452
2896
  decls.push(`${name}: ${v};`);
2453
2897
  declared.add(leaf.path);
2454
2898
  declaredHere.push(leaf.path);
2455
2899
  }
2900
+ if (entries.length > before && entries[entries.length - 1].code === "unitless_number" && !ctx.statesNumber) {
2901
+ note(unitlessByFile, s.file, leaf.path);
2902
+ }
2903
+ if (v !== null && facts.derived.has(leaf.path)) note(derivedByFile, s.file, leaf.path);
2456
2904
  }
2457
2905
  }
2458
2906
  }
@@ -2463,7 +2911,7 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2463
2911
  if (existing) existing.decls.push(...decls);
2464
2912
  else blocks.set(s.file, { selector, comment, decls });
2465
2913
  }
2466
- return { blocks, entries, declared, firstFile };
2914
+ return { blocks, entries, declared, firstFile, unitlessByFile, derivedByFile };
2467
2915
  }
2468
2916
  function cssOutput(exp, header, options = {}) {
2469
2917
  const nameCase = options.case ?? CSS_DEFAULTS.case;
@@ -2497,7 +2945,11 @@ function cssOutput(exp, header, options = {}) {
2497
2945
  nameCase
2498
2946
  });
2499
2947
  const names = resolved2.names;
2500
- const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
2948
+ const facts = {
2949
+ statesNumber: new Set(Object.entries(exp.meta).filter(([, entry2]) => scopesStateNumber(entry2.scopes)).map(([path]) => path)),
2950
+ derived: new Set(exp.report.filter((entry2) => entry2.code === "unit_derived_from_usage").map((entry2) => entry2.path))
2951
+ };
2952
+ const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes, facts);
2501
2953
  let alive = new Set(names.keys());
2502
2954
  let pass = emit(alive);
2503
2955
  for (; ; ) {
@@ -2524,11 +2976,16 @@ function cssOutput(exp, header, options = {}) {
2524
2976
  });
2525
2977
  }
2526
2978
  }
2527
- const head = headerText(header, nameCase);
2528
2979
  const files = {};
2529
2980
  const imports = [];
2530
2981
  for (const [source, block] of pass.blocks) {
2531
2982
  const name = fileNames.get(source);
2983
+ const head = headerText(
2984
+ header,
2985
+ nameCase,
2986
+ pass.unitlessByFile.get(source)?.size ?? 0,
2987
+ pass.derivedByFile.get(source)?.size ?? 0
2988
+ );
2532
2989
  files[name] = `${head}
2533
2990
 
2534
2991
  ${block.selector} {
@@ -2538,7 +2995,7 @@ ${block.decls.map((d) => ` ${d}`).join("\n")}
2538
2995
  `;
2539
2996
  imports.push(block.comment, `@import "./${name}";`);
2540
2997
  }
2541
- if (imports.length > 0) files[CSS_INDEX_FILE] = `${head}
2998
+ if (imports.length > 0) files[CSS_INDEX_FILE] = `${headerText(header, nameCase)}
2542
2999
 
2543
3000
  ${imports.join("\n")}
2544
3001
  `;
@@ -2546,7 +3003,7 @@ ${imports.join("\n")}
2546
3003
  }
2547
3004
 
2548
3005
  // ../extractor/src/v5/componentContext.ts
2549
- var import_js_sha2563 = __toESM(require_sha256(), 1);
3006
+ var import_js_sha2564 = __toESM(require_sha256(), 1);
2550
3007
 
2551
3008
  // ../extractor/src/libraryBundle.ts
2552
3009
  var LIBRARY_BUNDLE_SCHEMA = "spec-layer-library-bundle";
@@ -2557,14 +3014,14 @@ var LibraryBundleError = class extends Error {
2557
3014
  this.code = code2;
2558
3015
  }
2559
3016
  };
2560
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3017
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2561
3018
  function hasContentHash(artifact) {
2562
- if (!isRecord2(artifact) || !isRecord2(artifact.spec_layer)) return false;
3019
+ if (!isRecord3(artifact) || !isRecord3(artifact.spec_layer)) return false;
2563
3020
  const exp = artifact.spec_layer.export;
2564
- return isRecord2(exp) && typeof exp.content_hash === "string";
3021
+ return isRecord3(exp) && typeof exp.content_hash === "string";
2565
3022
  }
2566
3023
  function entry(v, where) {
2567
- if (!isRecord2(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
3024
+ if (!isRecord3(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
2568
3025
  throw new LibraryBundleError("malformed", `The ${where} entry in this bundle is malformed.`);
2569
3026
  }
2570
3027
  return { name: v.name, ai: v.ai, artifact: v.artifact };
@@ -2581,7 +3038,7 @@ function parseLibraryBundle(input) {
2581
3038
  throw new LibraryBundleError("not_json", "This is not valid JSON.");
2582
3039
  }
2583
3040
  }
2584
- if (!isRecord2(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
3041
+ if (!isRecord3(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
2585
3042
  throw new LibraryBundleError("not_bundle", "This is not a Spec Layer library bundle.");
2586
3043
  }
2587
3044
  if (!supportedVersion(parsed.version)) {
@@ -2597,7 +3054,7 @@ function parseLibraryBundle(input) {
2597
3054
  const foundationRaw = parsed.foundation ?? null;
2598
3055
  let foundation = null;
2599
3056
  if (foundationRaw !== null) {
2600
- if (!isRecord2(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
3057
+ if (!isRecord3(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
2601
3058
  throw new LibraryBundleError("malformed", "The foundation entry in this bundle is malformed.");
2602
3059
  }
2603
3060
  foundation = { ai: foundationRaw.ai, artifact: foundationRaw.artifact };
@@ -2614,7 +3071,7 @@ function parseLibraryBundle(input) {
2614
3071
  }
2615
3072
 
2616
3073
  // ../extractor/src/libraryBundleHash.ts
2617
- var import_js_sha2564 = __toESM(require_sha256(), 1);
3074
+ var import_js_sha2565 = __toESM(require_sha256(), 1);
2618
3075
 
2619
3076
  // src/bundle.ts
2620
3077
  function parseBundle(raw) {
@@ -2682,8 +3139,7 @@ var CODE_SYNTAX_KEY = {
2682
3139
  var PLATFORMS = ["web", "ios", "android", "flutter"];
2683
3140
  var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2684
3141
  var uniq = (xs) => [...new Set(xs)];
2685
- function readPackageJson(cwd) {
2686
- const path = join2(cwd, "package.json");
3142
+ function readJsonObject(path) {
2687
3143
  if (!existsSync2(path)) return null;
2688
3144
  let parsed;
2689
3145
  try {
@@ -2692,7 +3148,11 @@ function readPackageJson(cwd) {
2692
3148
  return null;
2693
3149
  }
2694
3150
  if (typeof parsed !== "object" || parsed === null) return null;
2695
- const record = parsed;
3151
+ return parsed;
3152
+ }
3153
+ function readPackageJson(cwd) {
3154
+ const record = readJsonObject(join2(cwd, "package.json"));
3155
+ if (!record) return null;
2696
3156
  const deps = {};
2697
3157
  for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2698
3158
  const block = record[field];
@@ -2818,6 +3278,97 @@ function isPlatform(value) {
2818
3278
  function isAgentHost(value) {
2819
3279
  return AGENT_HOSTS.includes(value);
2820
3280
  }
3281
+ function fontPackageSlug(family) {
3282
+ return family.toLowerCase().split(" ").join("-");
3283
+ }
3284
+ function dependencyNamesFamily(dependencyName, slug2) {
3285
+ const lower = dependencyName.toLowerCase();
3286
+ return lower === slug2 || lower.endsWith(`/${slug2}`);
3287
+ }
3288
+ function cssNamesFamily(cssText, family) {
3289
+ if (!cssText.includes("@font-face")) return false;
3290
+ return cssText.includes(`"${family}"`) || cssText.includes(`'${family}'`);
3291
+ }
3292
+ function googleFontsLinkNamesFamily(htmlText, family) {
3293
+ for (const quoted of htmlText.split('"')) {
3294
+ for (const fragment of quoted.split("'")) {
3295
+ const trimmed = fragment.trim();
3296
+ const candidate = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
3297
+ if (!candidate.startsWith("http")) continue;
3298
+ let url;
3299
+ try {
3300
+ url = new URL(candidate);
3301
+ } catch {
3302
+ continue;
3303
+ }
3304
+ if (url.hostname !== "fonts.googleapis.com") continue;
3305
+ for (const value of url.searchParams.getAll("family")) {
3306
+ if (value === family || value.startsWith(`${family}:`)) return true;
3307
+ }
3308
+ }
3309
+ }
3310
+ return false;
3311
+ }
3312
+ function missingFontSources(families, repo) {
3313
+ const dependencyNames = Object.keys({ ...repo.packageJson.dependencies, ...repo.packageJson.devDependencies });
3314
+ return families.filter((family) => {
3315
+ const slug2 = fontPackageSlug(family);
3316
+ if (dependencyNames.some((name) => dependencyNamesFamily(name, slug2))) return false;
3317
+ if (cssNamesFamily(repo.cssText, family)) return false;
3318
+ if (googleFontsLinkNamesFamily(repo.htmlText, family)) return false;
3319
+ return true;
3320
+ });
3321
+ }
3322
+ function dependencyField(record, field) {
3323
+ const block = record?.[field];
3324
+ if (typeof block !== "object" || block === null) return {};
3325
+ const out = {};
3326
+ for (const [name, range] of Object.entries(block)) {
3327
+ if (typeof range === "string") out[name] = range;
3328
+ }
3329
+ return out;
3330
+ }
3331
+ function readRootCssText(cwd) {
3332
+ let names = [];
3333
+ try {
3334
+ names = readdirSync(cwd);
3335
+ } catch {
3336
+ names = [];
3337
+ }
3338
+ const chunks = [];
3339
+ for (const name of names) {
3340
+ if (!name.endsWith(".css")) continue;
3341
+ try {
3342
+ chunks.push(readFileSync2(join2(cwd, name), "utf8"));
3343
+ } catch {
3344
+ }
3345
+ }
3346
+ return chunks.join("\n");
3347
+ }
3348
+ function readEntryHtmlText(cwd) {
3349
+ const chunks = [];
3350
+ for (const path of [join2(cwd, "index.html"), join2(cwd, "public", "index.html")]) {
3351
+ try {
3352
+ if (existsSync2(path)) chunks.push(readFileSync2(path, "utf8"));
3353
+ } catch {
3354
+ }
3355
+ }
3356
+ return chunks.join("\n");
3357
+ }
3358
+ function readFontRepoSignals(cwd) {
3359
+ const record = readJsonObject(join2(cwd, "package.json"));
3360
+ return {
3361
+ packageJson: {
3362
+ dependencies: dependencyField(record, "dependencies"),
3363
+ devDependencies: dependencyField(record, "devDependencies")
3364
+ },
3365
+ cssText: readRootCssText(cwd),
3366
+ htmlText: readEntryHtmlText(cwd)
3367
+ };
3368
+ }
3369
+ function missingFontSourcesInRepo(families, cwd) {
3370
+ return missingFontSources(families, readFontRepoSignals(cwd));
3371
+ }
2821
3372
 
2822
3373
  // src/outputs.ts
2823
3374
  import { readFileSync as readFileSync3 } from "node:fs";
@@ -3148,7 +3699,7 @@ async function fetchBundle(opts) {
3148
3699
  }
3149
3700
 
3150
3701
  // src/files.ts
3151
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3702
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3152
3703
  import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
3153
3704
 
3154
3705
  // src/selection.ts
@@ -3182,6 +3733,17 @@ Available: ${available || "none"}.`
3182
3733
  return bundle.components.map((c) => wanted.some((name) => matchesName(name, c.name)));
3183
3734
  }
3184
3735
 
3736
+ // src/version.ts
3737
+ import { readFileSync as readFileSync5 } from "node:fs";
3738
+ function cliVersion() {
3739
+ try {
3740
+ const parsed = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
3741
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
3742
+ } catch {
3743
+ return "unknown";
3744
+ }
3745
+ }
3746
+
3185
3747
  // src/files.ts
3186
3748
  function slugify(name) {
3187
3749
  const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3192,7 +3754,7 @@ function readManifest(outDir) {
3192
3754
  const path = join5(outDir, "manifest.json");
3193
3755
  if (!existsSync5(path)) return null;
3194
3756
  try {
3195
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
3757
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
3196
3758
  parsed.artifacts = parsed.artifacts.map((artifact) => {
3197
3759
  const { aiPath, ...rest } = artifact;
3198
3760
  return {
@@ -3209,7 +3771,7 @@ function readLocalBundle(outDir) {
3209
3771
  const path = join5(outDir, "bundle.json");
3210
3772
  if (!existsSync5(path)) return null;
3211
3773
  try {
3212
- return parseBundle(readFileSync5(path, "utf8"));
3774
+ return parseBundle(readFileSync6(path, "utf8"));
3213
3775
  } catch {
3214
3776
  throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
3215
3777
  }
@@ -3289,7 +3851,12 @@ function writeBundleFiles(opts) {
3289
3851
  if (validateLevel1(artifact).some((d) => d.severity === "error")) {
3290
3852
  throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
3291
3853
  }
3292
- const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3854
+ put("fonts.json", json(fontRequirements(artifact)));
3855
+ const exp = foundationDtcg(
3856
+ artifact,
3857
+ opts.dtcg ?? {},
3858
+ usageUnits(opts.bundle)
3859
+ );
3293
3860
  for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3294
3861
  path = `${outDirRel}/tokens/resolver.json`;
3295
3862
  const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
@@ -3321,6 +3888,7 @@ function writeBundleFiles(opts) {
3321
3888
  bundleHash: opts.bundleHash,
3322
3889
  pluginVersion: opts.bundle.pluginVersion,
3323
3890
  extractorVersion: opts.bundle.extractorVersion,
3891
+ cliVersion: cliVersion(),
3324
3892
  selection,
3325
3893
  componentSpecsDir,
3326
3894
  artifacts,
@@ -3344,7 +3912,7 @@ function writeBundleFiles(opts) {
3344
3912
  }
3345
3913
 
3346
3914
  // src/gitignore.ts
3347
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
3915
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
3348
3916
  import { spawnSync } from "node:child_process";
3349
3917
  import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
3350
3918
  var COMMENT = "# Spec Layer pull key, not for committing";
@@ -3381,7 +3949,7 @@ function ensureIgnored(cwd, fileName) {
3381
3949
  ${fileName}
3382
3950
  `);
3383
3951
  } else {
3384
- const body = readFileSync6(path, "utf8");
3952
+ const body = readFileSync7(path, "utf8");
3385
3953
  if (!hasEntryLine(body, fileName)) {
3386
3954
  const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
3387
3955
  writeFileSync5(path, `${body}${lead}${COMMENT}
@@ -3398,12 +3966,16 @@ ${fileName}
3398
3966
  }
3399
3967
 
3400
3968
  // src/skill.ts
3401
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3969
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
3402
3970
  import { dirname as dirname3, join as join7 } from "node:path";
3403
3971
 
3404
3972
  // src/tools.ts
3405
3973
  var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
3406
3974
  var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
3975
+ var PULL_EXITS = {
3976
+ "0": "success, even when tokens/report.json or an outputs/*.report.json holds an error-severity entry (pass --strict to fail on that instead)",
3977
+ "1": "usage error, bad key or id, a network or server failure, or --strict with an error-severity entry in tokens/report.json or an outputs/*.report.json, including on a cached (304) pull"
3978
+ };
3407
3979
  var TOOLS = [
3408
3980
  {
3409
3981
  name: "setup",
@@ -3434,9 +4006,9 @@ var TOOLS = [
3434
4006
  },
3435
4007
  {
3436
4008
  name: "pull",
3437
- usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3438
- summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path.",
3439
- when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks.",
4009
+ usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]... [--strict]",
4010
+ summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path. Prints a severity summary to stderr, even on a cached pull, when tokens/report.json or an outputs/*.report.json holds an error or warning.",
4011
+ when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks. Add --strict in CI to fail the build on an error-severity report entry.",
3440
4012
  network: true,
3441
4013
  needsKey: true,
3442
4014
  writes: [
@@ -3444,7 +4016,7 @@ var TOOLS = [
3444
4016
  "outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
3445
4017
  "componentSpecsDir from speclayer.json (default component-specs/), written in place"
3446
4018
  ],
3447
- exits: OK_OR_ERROR
4019
+ exits: PULL_EXITS
3448
4020
  },
3449
4021
  {
3450
4022
  name: "status",
@@ -3531,25 +4103,43 @@ function toolsJson(version) {
3531
4103
 
3532
4104
  // src/skill.ts
3533
4105
  var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
3534
- function countNumberTokens(tree) {
3535
- if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return 0;
4106
+ function collectNumberTokenPaths(tree, path, legitimatelyUnitless, out) {
4107
+ if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return;
3536
4108
  const record = tree;
3537
- if (record.$type === "number" && "$value" in record) return 1;
3538
- let n = 0;
4109
+ if (record.$type === "number" && "$value" in record) {
4110
+ const dotted = path.join(".");
4111
+ if (!legitimatelyUnitless.has(dotted)) out.add(dotted);
4112
+ return;
4113
+ }
3539
4114
  for (const [key, value] of Object.entries(record)) {
3540
4115
  if (key.startsWith("$")) continue;
3541
- n += countNumberTokens(value);
4116
+ collectNumberTokenPaths(value, [...path, key], legitimatelyUnitless, out);
3542
4117
  }
3543
- return n;
3544
4118
  }
3545
4119
  function readJson(path) {
3546
4120
  if (!existsSync7(path)) return null;
3547
4121
  try {
3548
- return JSON.parse(readFileSync7(path, "utf8"));
4122
+ return JSON.parse(readFileSync8(path, "utf8"));
3549
4123
  } catch {
3550
4124
  return null;
3551
4125
  }
3552
4126
  }
4127
+ function unitlessScopedPaths(tokensDir) {
4128
+ const meta = readJson(join7(tokensDir, "spec-layer.meta.json"));
4129
+ const out = /* @__PURE__ */ new Set();
4130
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return out;
4131
+ for (const [path, entry2] of Object.entries(meta)) {
4132
+ if (typeof entry2 !== "object" || entry2 === null) continue;
4133
+ const { type, scopes } = entry2;
4134
+ if (type === "number" && Array.isArray(scopes) && scopesStateNumber(scopes)) out.add(path);
4135
+ }
4136
+ return out;
4137
+ }
4138
+ function isFontRequirement(v) {
4139
+ if (typeof v !== "object" || v === null) return false;
4140
+ const r = v;
4141
+ return typeof r.family === "string" && Array.isArray(r.weights) && r.weights.every((w) => typeof w === "number") && Array.isArray(r.used_by) && r.used_by.every((u) => typeof u === "string");
4142
+ }
3553
4143
  function summarizePull(cwd, outDir, manifest) {
3554
4144
  if (!manifest) return null;
3555
4145
  const absOut = join7(cwd, outDir);
@@ -3566,17 +4156,37 @@ function summarizePull(cwd, outDir, manifest) {
3566
4156
  } catch {
3567
4157
  tokenFiles = [];
3568
4158
  }
3569
- let unitlessNumbers = 0;
4159
+ const legitimatelyUnitless = unitlessScopedPaths(tokensDir);
4160
+ const unitlessPaths = /* @__PURE__ */ new Set();
3570
4161
  for (const file of tokenFiles) {
3571
4162
  if (file.startsWith("styles.")) continue;
3572
- unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
4163
+ collectNumberTokenPaths(readJson(join7(tokensDir, file)), [], legitimatelyUnitless, unitlessPaths);
3573
4164
  }
4165
+ const unitlessNumbers = unitlessPaths.size;
3574
4166
  const reportCounts = {};
3575
4167
  if (Array.isArray(report2)) {
3576
4168
  for (const entry2 of report2) {
3577
4169
  if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
3578
4170
  }
3579
4171
  }
4172
+ const fontsPath = join7(absOut, "fonts.json");
4173
+ let fontsStatus = "missing";
4174
+ let fonts = [];
4175
+ if (existsSync7(fontsPath)) {
4176
+ let parsed;
4177
+ try {
4178
+ parsed = JSON.parse(readFileSync8(fontsPath, "utf8"));
4179
+ } catch {
4180
+ parsed = void 0;
4181
+ }
4182
+ if (Array.isArray(parsed)) {
4183
+ fontsStatus = "ok";
4184
+ fonts = parsed.filter(isFontRequirement);
4185
+ } else {
4186
+ fontsStatus = "unreadable";
4187
+ }
4188
+ }
4189
+ const missingFontFamilies = fontsStatus === "ok" && fonts.length > 0 ? missingFontSourcesInRepo(fonts.map((f) => f.family), cwd) : [];
3580
4190
  foundation = {
3581
4191
  written: foundationEntry.path !== null && resolver !== null,
3582
4192
  sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
@@ -3587,7 +4197,10 @@ function summarizePull(cwd, outDir, manifest) {
3587
4197
  })) : [],
3588
4198
  tokenFiles,
3589
4199
  unitlessNumbers,
3590
- reportCounts
4200
+ reportCounts,
4201
+ fontsStatus,
4202
+ fonts,
4203
+ missingFontFamilies
3591
4204
  };
3592
4205
  }
3593
4206
  return {
@@ -3651,12 +4264,49 @@ function stackSection(input) {
3651
4264
  const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
3652
4265
  lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
3653
4266
  }
4267
+ const tokensDir = `${input.outDir}/tokens/`;
4268
+ if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
4269
+ const n = pull.foundation.unitlessNumbers;
4270
+ const cssReport = pull.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
4271
+ lines.push(
4272
+ `**${n} token${n === 1 ? " has" : "s have"} no unit.** ${n === 1 ? "Its own Figma variable states" : "Their own Figma variables state"} none at all, not even that ${n === 1 ? "it is" : "they are"} a unitless number the way an opacity or a font weight would. Those are already excluded from this count, because Figma states that for them. So ${n === 1 ? "it is" : "they are"} written as ${code('$type: "number"')}: a bare number, not usable as a CSS length. ${code("height: 36")} is invalid CSS and the browser drops the declaration. ${n === 1 ? "Narrow the variable's" : "Narrow each variable's"} scope in Figma to a length (${code("CORNER_RADIUS")}, ${code("GAP")}, ${code("WIDTH_HEIGHT")}, ${code("FONT_SIZE")}, or ${code("STROKE_FLOAT")}), or to ${code("OPACITY")}/${code("FONT_WEIGHT")} if it genuinely carries none, and pull again. Only add ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')} in ${code("speclayer.json")} once you already know the token is a length: the override applies to anything the glob matches that Figma has not already scoped as unitless, so a glob that is too broad can turn a genuine opacity or font weight into a fake length. Nothing is inferred from a name; ${code(`${tokensDir}spec-layer.meta.json`)} names each token's own Figma scopes` + (cssReport ? `, and ${code(`${input.outDir}/outputs/${cssReport.platform}-${cssReport.format}.report.json`)} names every one under ${code("unitless_number")}. Do not read that file's entry count as this number: it carries one entry per mode a token appears in, and it also names the tokens Figma does scope as a unitless number, which this count leaves out.` : "."),
4273
+ ""
4274
+ );
4275
+ }
3654
4276
  for (const platform of platforms) {
3655
4277
  const key = CODE_SYNTAX_KEY[platform];
3656
- const tokensDir = `${input.outDir}/tokens/`;
3657
4278
  if (platform === "web") {
3658
4279
  lines.push("### Web", "");
3659
4280
  const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
4281
+ if (pull?.foundation?.written) {
4282
+ const { fontsStatus, fonts, missingFontFamilies } = pull.foundation;
4283
+ if (fontsStatus === "ok" && fonts.length > 0) {
4284
+ const fontList = fonts.map((f) => `${f.family} at ${f.weights.join(", ")}`).join("; ");
4285
+ lines.push(
4286
+ `**Fonts.** This library's type is ${fontList}. Load every weight listed; a missing weight renders as a synthesised bold that matches nothing in the design.`
4287
+ );
4288
+ for (const family of missingFontFamilies) {
4289
+ lines.push(
4290
+ `${family}: nothing in this repository loads it. Add a font source before building UI, or every component that uses it renders in the browser default.`
4291
+ );
4292
+ }
4293
+ const fallbackTarget = cssOut ? `${cssOut.path}/` : `${input.outDir}/`;
4294
+ lines.push(
4295
+ `The token holds a family name and no fallback stack. Never write ${code("font-family")} from a token without appending a generic fallback, and never keep that fallback inside ${code(fallbackTarget)}: the next pull replaces it.`,
4296
+ ""
4297
+ );
4298
+ } else if (fontsStatus === "ok") {
4299
+ lines.push(
4300
+ `${code("fonts.json")} names no font family. Either this library's typography styles reference none, or a reference failed to resolve one; ${code("npx spec-layer show foundation")} prints the styles themselves.`,
4301
+ ""
4302
+ );
4303
+ } else {
4304
+ lines.push(
4305
+ fontsStatus === "missing" ? `${code("fonts.json")} is missing, so the font requirement for this library is unknown here. Run ${code("npx spec-layer pull")} again: every pull that includes the Foundation writes this file, and a pull that finds it gone re-projects instead of reporting no change.` : `${code("fonts.json")} is not valid JSON, so the font requirement for this library is unknown here. Delete ${code(`${input.outDir}/fonts.json`)} and run ${code("npx spec-layer pull")} again, which rewrites it.`,
4306
+ ""
4307
+ );
4308
+ }
4309
+ }
3660
4310
  if (cssOut) {
3661
4311
  const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3662
4312
  lines.push(
@@ -3736,13 +4386,6 @@ function stackSection(input) {
3736
4386
  );
3737
4387
  }
3738
4388
  }
3739
- if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
3740
- const n = pull.foundation.unitlessNumbers;
3741
- lines.push(
3742
- `${n} token${n === 1 ? " is" : "s are"} exported as ${code('$type: "number"')} because the Figma scopes state no unit. If your code needs them as px or rem, declare it in ${code("speclayer.json")}: ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')}, then run ${code("spec-layer pull")}. Nothing is inferred from a name; an override that contradicts a stated scope is ignored and listed in ${code("report.json")}.`,
3743
- ""
3744
- );
3745
- }
3746
4389
  return lines;
3747
4390
  }
3748
4391
  function pullSection(input) {
@@ -3921,7 +4564,7 @@ ${BLOCK_END}
3921
4564
  function installSkill(cwd, host, guide) {
3922
4565
  const target = installTarget(host);
3923
4566
  const abs = join7(cwd, target.path);
3924
- const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
4567
+ const existing = existsSync7(abs) ? readFileSync8(abs, "utf8") : null;
3925
4568
  const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
3926
4569
  if (existing === next) return { path: target.path, result: "unchanged" };
3927
4570
  mkdirSync3(dirname3(abs), { recursive: true });
@@ -3929,17 +4572,6 @@ function installSkill(cwd, host, guide) {
3929
4572
  return { path: target.path, result: existing === null ? "created" : "updated" };
3930
4573
  }
3931
4574
 
3932
- // src/version.ts
3933
- import { readFileSync as readFileSync8 } from "node:fs";
3934
- function cliVersion() {
3935
- try {
3936
- const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
3937
- return typeof parsed.version === "string" ? parsed.version : "unknown";
3938
- } catch {
3939
- return "unknown";
3940
- }
3941
- }
3942
-
3943
4575
  // src/commands.ts
3944
4576
  var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
3945
4577
  function manifestReader() {
@@ -4146,11 +4778,59 @@ git rm --cached ${ignored.line}`);
4146
4778
  return 0;
4147
4779
  }
4148
4780
  function outputFilesOnDisk(cwd, outDir, o) {
4149
- const mapPath = join8(cwd, outDir, "outputs", `${outputId(o)}.map.json`);
4150
- if (!existsSync8(mapPath)) return false;
4781
+ const id = outputId(o);
4782
+ for (const rel of [`${id}.map.json`, `${id}.report.json`]) {
4783
+ if (!existsSync8(join8(cwd, outDir, "outputs", rel))) return false;
4784
+ }
4151
4785
  const imports = readIndexImports(cwd, o);
4152
4786
  return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
4153
4787
  }
4788
+ function foundationFilesOnDisk(cwd, outDir) {
4789
+ return ["fonts.json", join8("tokens", "report.json"), join8("tokens", "resolver.json")].every((rel) => existsSync8(join8(cwd, outDir, rel)));
4790
+ }
4791
+ function readJsonArray(path) {
4792
+ if (!existsSync8(path)) return [];
4793
+ try {
4794
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
4795
+ return Array.isArray(parsed) ? parsed : [];
4796
+ } catch {
4797
+ return [];
4798
+ }
4799
+ }
4800
+ function readReportSeverities(path) {
4801
+ return readJsonArray(path).map((entry2) => entry2.severity).filter((s) => s === "error" || s === "warning" || s === "info");
4802
+ }
4803
+ function readFontFamilies(cwd, outDir) {
4804
+ return readJsonArray(join8(cwd, outDir, "fonts.json")).map((entry2) => entry2.family).filter((f) => typeof f === "string");
4805
+ }
4806
+ function plural(n, word) {
4807
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
4808
+ }
4809
+ function printReportSummary(cwd, outDir, outputs, io2) {
4810
+ const reportPaths = [];
4811
+ let errors = 0;
4812
+ let warnings = 0;
4813
+ const add = (severities, path) => {
4814
+ if (severities.length === 0) return;
4815
+ errors += severities.filter((s) => s === "error").length;
4816
+ warnings += severities.filter((s) => s === "warning").length;
4817
+ reportPaths.push(path);
4818
+ };
4819
+ add(readReportSeverities(join8(cwd, outDir, "tokens", "report.json")), `${outDir}/tokens/report.json`);
4820
+ for (const o of outputs) {
4821
+ add(readReportSeverities(join8(cwd, outDir, "outputs", `${outputId(o)}.report.json`)), `${outDir}/outputs/${outputId(o)}.report.json`);
4822
+ }
4823
+ if (errors > 0 || warnings > 0) {
4824
+ io2.err(`${plural(errors, "error")}, ${plural(warnings, "warning")} in the token output. See ${reportPaths.join(", ")}.`);
4825
+ }
4826
+ const fontFamilies = readFontFamilies(cwd, outDir);
4827
+ if (fontFamilies.length > 0) {
4828
+ for (const family of missingFontSourcesInRepo(fontFamilies, cwd)) {
4829
+ io2.err(`This library needs ${family}, and nothing in this repository loads it. See fonts.json.`);
4830
+ }
4831
+ }
4832
+ return errors;
4833
+ }
4154
4834
  async function runPull(cwd, flags, env, io2, fetcher) {
4155
4835
  const manifestAt = manifestReader();
4156
4836
  const opts = resolved(cwd, flags, env, io2, manifestAt);
@@ -4170,10 +4850,10 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4170
4850
  const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4171
4851
  const willWriteFoundation = selection.foundation && foundationOnDisk;
4172
4852
  const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
4173
- const etag = manifest && sameOutput(
4853
+ const etag = manifest && manifest.cliVersion === cliVersion() && sameOutput(
4174
4854
  { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
4175
4855
  { selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
4176
- ) && briefsOnDisk && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
4856
+ ) && briefsOnDisk && (!willWriteFoundation || foundationFilesOnDisk(cwd, opts.outDir)) && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
4177
4857
  const result = await fetchBundle({
4178
4858
  api: opts.api,
4179
4859
  libraryId: opts.libraryId,
@@ -4187,6 +4867,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4187
4867
  }
4188
4868
  if (result.kind === "not_modified") {
4189
4869
  io2.out(`Already up to date (published ${manifest?.publishedAt ?? "unknown"}).`);
4870
+ const cachedErrors = printReportSummary(cwd, opts.outDir, outputs, io2);
4871
+ if (flags.strict && cachedErrors > 0) return 1;
4190
4872
  return 0;
4191
4873
  }
4192
4874
  let written;
@@ -4241,6 +4923,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4241
4923
  const missing = platformsMissingFormat(platforms);
4242
4924
  if (missing.length > 0) io2.out(missingFormatNote(missing));
4243
4925
  }
4926
+ const errors = printReportSummary(cwd, opts.outDir, outputs, io2);
4927
+ if (flags.strict && errors > 0) return 1;
4244
4928
  return 0;
4245
4929
  }
4246
4930
  async function runStatus(cwd, flags, env, io2, fetcher) {
@@ -4420,8 +5104,9 @@ Commands:
4420
5104
  store the key, then pull
4421
5105
  init --id lib_... [--out DIR] [selection] [--platform P]...
4422
5106
  write speclayer.json
4423
- pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
4424
- fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
5107
+ pull [--id lib_...] [--key sl_...] [selection] [--platform P]... [--strict]
5108
+ fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/;
5109
+ --strict exits 1 when tokens/report.json or an outputs/*.report.json holds an error-severity entry, even on a cached pull (default exit stays 0)
4425
5110
  status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
4426
5111
  list list every artifact in the last pull
4427
5112
  show foundation | component NAME [--canonical]
@@ -4462,6 +5147,7 @@ async function main() {
4462
5147
  canonical: { type: "boolean" },
4463
5148
  json: { type: "boolean" },
4464
5149
  install: { type: "boolean" },
5150
+ strict: { type: "boolean" },
4465
5151
  agent: { type: "string", multiple: true },
4466
5152
  platform: { type: "string", multiple: true }
4467
5153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-layer",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Pull design-system context published by the Spec Layer Figma plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",