@hanzo/design 0.5.4 → 0.5.6

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 (2) hide show
  1. package/package.json +1 -1
  2. package/scripts/lint.mjs +120 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "packageManager": "pnpm@11.17.0",
5
5
  "description": "Hanzo Design System \u2014 monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
6
6
  "license": "MIT OR Apache-2.0",
package/scripts/lint.mjs CHANGED
@@ -194,6 +194,64 @@ function lintFile(abs, root) {
194
194
  for (const m of src.matchAll(/['"]@hanzoai\/design/g))
195
195
  flag(rel, lineOf(src, m.index), 'wrong-package', '@hanzoai/design',
196
196
  'the package is @hanzo/design — @hanzoai/design is a 404 and resolves nothing')
197
+
198
+ // ── the four layers ────────────────────────────────────────────────────
199
+ // Rules 1-8 catch a surface writing a VALUE it should have named. These four
200
+ // catch a surface OWNING something it should have imported, which is the
201
+ // failure that costs a fleet its coherence rather than a component its
202
+ // colour. The estate has exactly four layers and every surface is meant to
203
+ // be pure composition over them:
204
+ //
205
+ // values -> @hanzo/design tokens, the ramp, the ladder
206
+ // structure -> @hanzo/gui the primitives things are built from
207
+ // chrome -> @hanzogui/shell header, search, nav, launcher, footer
208
+ // components -> @hanzo/ui everything assembled from the above
209
+ //
210
+ // A surface holds data and layout. Nothing else. Each rule below is one of
211
+ // those four layers being re-implemented locally, and each has shipped:
212
+ // hanzo.ai carried a 244-line token table that disagreed with the canon on
213
+ // nearly every rung; hanzo.app loaded @hanzo/brand's sheet ahead of the one
214
+ // that owns the same names; both drew their own search control.
215
+
216
+ // 9. ONE table binds the ramp. `createGui`/`createTokens`/`createFont` is
217
+ // that binding, it lives in @hanzo/ui, and a second one is not a second
218
+ // opinion — every @hanzo/ui component asks the HOST's table for its
219
+ // sizes, so one library renders at two sizes depending on the site.
220
+ for (const m of src.matchAll(/\b(createGui|createTokens|createFont|createThemes)\s*\(/g))
221
+ flag(rel, lineOf(src, m.index), 'local-token-table', `${m[1]}()`,
222
+ "import the fleet's table — `export { config as default } from '@hanzo/ui/gui-config'`")
223
+
224
+ // 10. ONE publisher per token name. A surface that declares a name design
225
+ // already declares has entered a fight decided by LOAD ORDER, which is
226
+ // not a decision anyone made. (Declaring your own new name is fine —
227
+ // that is what a surface-specific token IS. This is only collision.)
228
+ //
229
+ // A DECLARATION, never a mention. In a stylesheet that is `--x:` at the
230
+ // top of a declaration; in JS it is a QUOTED key, which is the only way a
231
+ // custom property can be set from there. Matching the bare name in JS
232
+ // read every comment explaining WHICH token to use as a redeclaration of
233
+ // it — this package's own Button and Card, whose comments are the
234
+ // reasoning for reaching one rung over another.
235
+ for (const m of src.matchAll(isStyle ? /(?:^|[\s;{])--([A-Za-z0-9-]+)\s*:/g : /['"]--([A-Za-z0-9-]+)['"]\s*:/g))
236
+ if (TOKENS.has(m[1]))
237
+ flag(rel, lineOf(src, m.index), 'redeclared-token', `--${m[1]}`,
238
+ 'the name is @hanzo/design\'s — read it with var(), or pick a name of your own')
239
+
240
+ // 11. a SECOND sheet of the same tokens. Two publishers is how a ramp drifts:
241
+ // the loser is silent, and which one loses depends on import order.
242
+ for (const m of src.matchAll(
243
+ /from\s+['"]([^'"]*(?:@hanzo\/brand\/styles|@hanzogui\/themes|tailwindcss\/theme|cdn\.tailwindcss\.com|fonts\.googleapis)[^'"]*)['"]|import\s+['"]([^'"]*(?:@hanzo\/brand\/styles|cdn\.tailwindcss\.com|fonts\.googleapis)[^'"]*)['"]/g))
244
+ flag(rel, lineOf(src, m.index), 'second-publisher', m[1] ?? m[2],
245
+ '@hanzo/design is the token layer; @hanzo/ui/theme.css or /glass.css carries the material')
246
+
247
+ // 12. the chrome is the shell's. A header, a search control, a product menu,
248
+ // an org switcher, an app launcher and a footer are ONE set of controls
249
+ // across every Hanzo surface — that is what makes them recognisable as
250
+ // one product. A local copy is a control that drifts by a release.
251
+ for (const m of src.matchAll(
252
+ /\b(?:function|const)\s+(Hanzo?(?:Header|Footer)|(?:Site|App|Global|Main|Top)(?:Header|Nav|Footer|Bar)|(?:Header|Nav|Command|Search)(?:Search|Palette|Trigger|Bar)|OrgSwitcher|OrgHeader|AppLauncher|MegaMenu)\b/g))
253
+ flag(rel, lineOf(src, m.index), 'shell-owned-chrome', m[1],
254
+ 'import it from @hanzogui/shell — the chrome is one set of controls, fleet-wide')
197
255
  }
198
256
 
199
257
  // ── run ──────────────────────────────────────────────────────────────────
@@ -202,17 +260,73 @@ const roots = targets.length ? targets : [process.cwd()]
202
260
  let scanned = 0
203
261
  for (const r of roots) for (const f of walk(r)) { scanned++; lintFile(f, r === f ? dirname(r) : r) }
204
262
 
205
- const RULES = ['unresolved-token', 'raw-color', 'raw-z-index', 'raw-font-size', 'all-caps', 'inline-style', 'icon-set', 'wrong-package']
206
- if (!findings.length) {
207
- console.log(`hanzo-design-lint: ${scanned} files, clean`)
208
- process.exit(0)
209
- }
263
+ const RULES = [
264
+ 'unresolved-token', 'raw-color', 'raw-z-index', 'raw-font-size', 'all-caps',
265
+ 'inline-style', 'icon-set', 'wrong-package',
266
+ 'local-token-table', 'redeclared-token', 'second-publisher', 'shell-owned-chrome',
267
+ ]
268
+
269
+ /**
270
+ * THE RATCHET — `hanzo-design.allow.json`, beside the code being linted.
271
+ *
272
+ * A gate that can only pass on a perfect tree cannot be turned on, and a gate
273
+ * nobody turns on prevents nothing. So a surface declares what it currently
274
+ * owes, per rule, and the number MAY ONLY SHRINK. Every new violation fails the
275
+ * build on the day it is written; the backlog burns down on its own schedule.
276
+ *
277
+ * { "raw-color": 412, "redeclared-token": 6 }
278
+ *
279
+ * A count that comes in UNDER its allowance fails too, and that is the ratchet
280
+ * rather than a threshold: the fix has to delete its own exemption in the same
281
+ * commit, or the next author inherits room to regress into. The same discipline
282
+ * hanzo.ai's `audit-catalog.mjs` holds its KNOWN_UNSERVED list to.
283
+ *
284
+ * A rule with no entry is allowed ZERO, so a new rule is live everywhere the
285
+ * day it ships and no surface has to opt in.
286
+ *
287
+ * It is read from the WORKING DIRECTORY, not from the paths being linted. Every
288
+ * real invocation is a package script (`hanzo-design-lint components app`), so
289
+ * the cwd is the surface's root — one place, whatever subtrees are named, and
290
+ * nothing to derive from a list of arguments that may be files or directories.
291
+ */
292
+ const RATCHET = 'hanzo-design.allow.json'
293
+ let allow = {}
294
+ try { allow = JSON.parse(readFileSync(join(process.cwd(), RATCHET), 'utf8')) } catch { /* none: everything is zero */ }
295
+
296
+ const count = Object.fromEntries(RULES.map((r) => [r, findings.filter((f) => f.rule === r).length]))
297
+ const over = RULES.filter((r) => count[r] > (allow[r] ?? 0))
298
+ const under = RULES.filter((r) => count[r] < (allow[r] ?? 0))
299
+
210
300
  for (const rule of RULES) {
211
301
  const hits = findings.filter((f) => f.rule === rule)
212
302
  if (!hits.length) continue
213
- console.log(`\n${rule} (${hits.length}) — ${hits[0].fix}`)
303
+ const owed = allow[rule] ?? 0
304
+ const verdict = count[rule] > owed ? `NEW — allowed ${owed}` : `at the allowance (${owed})`
305
+ console.log(`\n${rule} (${hits.length}, ${verdict}) — ${hits[0].fix}`)
214
306
  for (const h of hits.slice(0, 20)) console.log(` ${h.file}:${h.line} ${h.detail}`)
215
307
  if (hits.length > 20) console.log(` … ${hits.length - 20} more`)
216
308
  }
309
+
310
+ // A key that is not a rule allows NOTHING, and the author who wrote it believes
311
+ // they filed an exemption — the same silent failure this whole file exists to
312
+ // end. Keys starting with `_` are prose and are skipped, so the file can explain
313
+ // itself without inventing a second format.
314
+ const unknown = Object.keys(allow).filter((k) => !k.startsWith('_') && !RULES.includes(k))
315
+ if (unknown.length) {
316
+ console.log(`\n ${RATCHET} names ${unknown.length} thing(s) that are not rules: ${unknown.join(', ')}`)
317
+ console.log(` the rules are: ${RULES.join(', ')}`)
318
+ process.exit(1)
319
+ }
320
+
321
+ if (!over.length && !under.length) {
322
+ const owed = RULES.reduce((a, r) => a + (allow[r] ?? 0), 0)
323
+ console.log(`hanzo-design-lint: ${scanned} files, clean${owed ? ` (${owed} allowed by ${RATCHET})` : ''}`)
324
+ process.exit(0)
325
+ }
326
+ for (const r of over)
327
+ console.log(`\n ${r}: ${count[r]} found, ${allow[r] ?? 0} allowed — a NEW violation. Fix it.`)
328
+ for (const r of under)
329
+ console.log(`\n ${r}: ${count[r]} found, ${allow[r] ?? 0} allowed — you fixed ${(allow[r] ?? 0) - count[r]}. ` +
330
+ `Lower it in ${RATCHET}; the allowance only shrinks.`)
217
331
  console.log(`\n${findings.length} violation(s) across ${scanned} files`)
218
332
  process.exit(1)