@r1z4x/tezgah 1.11.0 → 1.14.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 (109) hide show
  1. package/CHANGELOG.md +507 -1
  2. package/MANIFEST +40 -0
  3. package/NOTICE +152 -0
  4. package/README.de.md +41 -20
  5. package/README.es.md +39 -19
  6. package/README.fr.md +41 -20
  7. package/README.md +18 -10
  8. package/README.tr.md +39 -21
  9. package/bin/codegen +9 -2
  10. package/bin/consult +6 -0
  11. package/bin/tezgah-adhd +3 -0
  12. package/bin/tezgah-agents +3 -0
  13. package/bin/tezgah-capture +3 -0
  14. package/bin/tezgah-design +329 -54
  15. package/bin/tezgah-docs +158 -42
  16. package/bin/tezgah-dsh +1 -1
  17. package/bin/tezgah-gate +7 -2
  18. package/bin/tezgah-import-ai-research +16 -4
  19. package/bin/tezgah-index +3 -0
  20. package/bin/tezgah-lessons +13 -4
  21. package/bin/tezgah-mcp +4 -0
  22. package/bin/tezgah-migrate +3 -0
  23. package/bin/tezgah-ocr-review +2 -1
  24. package/bin/tezgah-optimize +4 -1
  25. package/bin/tezgah-pony +3 -0
  26. package/bin/tezgah-research +51 -10
  27. package/bin/tezgah-rollback +3 -0
  28. package/bin/tezgah-route +1 -1
  29. package/bin/tezgah-setup +1741 -95
  30. package/bin/tezgah-status +11 -1
  31. package/bin/tezgah-task +58 -17
  32. package/bin/tezgah.js +10 -3
  33. package/commands/adhd.md +1 -1
  34. package/docs/README.md +19 -8
  35. package/docs/architecture.md +2 -2
  36. package/docs/citations-baseline.json +11 -17
  37. package/docs/clarity-baseline.json +12 -6
  38. package/docs/contract.md +83 -34
  39. package/docs/environment.md +59 -24
  40. package/docs/evidence.md +128 -25
  41. package/docs/gate.md +77 -23
  42. package/docs/glossary.md +1 -1
  43. package/docs/hosts.md +343 -48
  44. package/docs/layers.md +2 -2
  45. package/docs/models.md +6 -0
  46. package/docs/operations.md +83 -29
  47. package/docs/research.md +37 -11
  48. package/docs/skills.md +31 -8
  49. package/docs/status-line.md +39 -23
  50. package/docs/testing.md +50 -17
  51. package/docs/vision.md +6 -2
  52. package/hooks/hooks.json +1 -1
  53. package/hooks/lang/en.json +16 -2
  54. package/hooks/lang/tr.json +14 -2
  55. package/hooks/projects-posttooluse.py +11 -3
  56. package/hooks/projects-stop.py +3 -2
  57. package/hooks/tezgah_agents.py +27 -3
  58. package/hooks/tezgah_attest.py +79 -10
  59. package/hooks/tezgah_context.py +61 -31
  60. package/hooks/tezgah_gate.py +480 -126
  61. package/hooks/tezgah_integrity.py +577 -133
  62. package/hooks/tezgah_lessons.py +5 -2
  63. package/hooks/tezgah_paths.py +60 -8
  64. package/hooks/tezgah_policy.py +48 -18
  65. package/hooks/tezgah_replay.py +8 -2
  66. package/hooks/tezgah_research.py +167 -24
  67. package/hooks/tezgah_skill_pick.py +1 -1
  68. package/hooks/tezgah_snapshot.py +6 -3
  69. package/hooks/tezgah_stack.py +236 -1
  70. package/hooks/tezgah_stopspec.py +2 -1
  71. package/hooks/tezgah_task.py +2 -1
  72. package/hosts/amp/hook.py +217 -0
  73. package/hosts/amp/tezgah-plugin.ts.in +396 -0
  74. package/hosts/antigravity/hook.py +255 -0
  75. package/hosts/codex/hook.py +3 -2
  76. package/hosts/commandcode/hook.py +239 -0
  77. package/hosts/copilot/hook.py +284 -0
  78. package/hosts/cursor/hook.py +4 -2
  79. package/hosts/droid/hook.py +259 -0
  80. package/hosts/dsh/hooks.json +1 -1
  81. package/hosts/gemini/hook.py +213 -0
  82. package/hosts/goose/hook.py +145 -0
  83. package/hosts/omp/hook.py +5 -2
  84. package/hosts/omp/tezgah-hook.ts.in +157 -42
  85. package/hosts/opencode/plugins/tezgah.js +209 -63
  86. package/hosts/qwen/hook.py +206 -0
  87. package/output-styles/tezgah.md +11 -6
  88. package/package.json +6 -3
  89. package/packaging/build.sh +4 -4
  90. package/packaging/install.sh +11 -0
  91. package/packaging/upgrade.sh +3 -0
  92. package/skills/ai-research/LICENSE +21 -0
  93. package/skills/design-contract/SKILL.md +27 -6
  94. package/skills/design-library/LICENSE +21 -0
  95. package/skills/harness/SKILL.md +2 -0
  96. package/skills/i-have-adhd/SKILL.md +10 -1
  97. package/skills/plan-add/SKILL.md +5 -1
  98. package/skills/plan-add/render_table.py +7 -0
  99. package/skills/pm-frameworks/LICENSE +21 -0
  100. package/skills/research/SKILL.md +22 -9
  101. package/skills/rl-env/LICENSE +201 -0
  102. package/skills/tezgah-contract/SKILL.md +21 -8
  103. package/statusline.py +20 -9
  104. package/third_party/open-code-review/LICENSE +201 -0
  105. package/third_party/open-code-review/README.md +15 -0
  106. package/third_party/open-code-review/prompts/main_task_system.md +26 -0
  107. package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
  108. package/workflows/graph-map.js +14 -5
  109. package/workflows/graph-review.js +14 -2
package/bin/tezgah-design CHANGED
@@ -2,7 +2,7 @@
2
2
  """tezgah-design derive|check - the per-repo design contract, and the check that
3
3
  can fail.
4
4
 
5
- tezgah-design derive --repo DIR [--out FILE] [--json]
5
+ tezgah-design derive --repo DIR [--out FILE] [--suggest] [--json]
6
6
  tezgah-design check --contract FILE --measured FILE|- [--json]
7
7
  tezgah-design slop --repo DIR [--url URL] [--render] [--measured FILE]
8
8
  [--out FILE] [--json]
@@ -11,7 +11,8 @@ The contract is the floor a UI turn is judged against: the colour roles, the
11
11
  type scale, the spacing unit and the state set a repository already uses, plus
12
12
  the component inventory it owns. `derive` writes a first `.tezgah/design-contract.md`
13
13
  from the repository's own token source (CSS custom properties, a Tailwind config,
14
- a tokens/theme JSON) and exits 2 rather than invent a palette it did not find.
14
+ a tokens/theme JSON, a JS/TS theme module) and exits 2 rather than invent a
15
+ palette it did not find.
15
16
  `slop` runs Hallmark's own gate list (`skills/hallmark/scripts/sloplint.mjs`)
16
17
  over the repository or a running URL, writes the verdict to
17
18
  `.tezgah/design/slop.json` and exits 1 on any Floor FAIL. It is the Hallmark half
@@ -29,7 +30,8 @@ Stdlib only, deterministic, no network and no credential: the same tree and the
29
30
  same measurement print the same bytes, with no clock and no path outside the
30
31
  repository in the artifact.
31
32
 
32
- derive exit 0 the contract was written; 2 no token source; 1 usage or IO
33
+ derive exit 0 the contract was written; 2 no token source, usage or IO
34
+ --suggest: exit 0 candidates printed, nothing written; 2 none found
33
35
  check exit 0 `0 violations` over at least one component judged; 1 one or
34
36
  more violations; 2 usage, IO, a bad contract or nothing judged
35
37
  """
@@ -38,6 +40,7 @@ import math
38
40
  import os
39
41
  import re
40
42
  import shutil
43
+ import stat
41
44
  import subprocess
42
45
  import sys
43
46
 
@@ -110,18 +113,59 @@ MOTION_PROPS = ("transition-duration", "animation-duration")
110
113
 
111
114
  SKIP_DIRS = frozenset((".git", "node_modules", "dist", "build", ".venv", "venv",
112
115
  "vendor", "__pycache__", ".next", ".cache", "coverage",
113
- ".tezgah", ".codegraph"))
116
+ ".tezgah", ".codegraph", "Pods", "Carthage", ".build"))
117
+ # `derive --suggest`: a file in one of these directories with one of these
118
+ # suffixes is a candidate component; a test, a story, a mock, a theme file
119
+ # (Compose's `ui/theme/Color.kt`) or a Swift extension (`UIColor+Brand`) is not.
120
+ COMPONENT_DIRS = frozenset(("components", "component", "widgets", "widget", "ui"))
121
+ COMPONENT_EXT = (".tsx", ".jsx", ".vue", ".svelte", ".astro", ".swift", ".kt", ".dart")
122
+ NOT_COMPONENT = re.compile(r"\.(?:test|spec|stories|story)\.|(?:^|/)__(?:tests|mocks)__/"
123
+ r"|(?:^|/)theme/[^/]*\.(?:kt|swift)$|\+", re.I)
124
+ # React Native's per-platform files are one component (`Button.ios.tsx`)
125
+ PLATFORM_SUFFIX = re.compile(r"\.(?:ios|android|web|native)$", re.I)
126
+ # The kind a name's head noun says - its last CamelCase, `_` or `-` word, matched
127
+ # whole - and only when it says it: a substring read `FeedbackForm` as data and
128
+ # `DatabaseStatus` as interactive (review of plan 146). Every other kind is the
129
+ # person's.
130
+ INTERACTIVE_HEADS = frozenset((
131
+ "button", "input", "select", "checkbox", "radio", "toggle", "switch", "link",
132
+ "tab", "tabs", "menu", "dialog", "modal", "chip", "slider", "picker", "field",
133
+ "dropdown", "combobox", "textarea", "stepper"))
134
+ DATA_HEADS = frozenset((
135
+ "table", "list", "chart", "card", "tile", "grid", "feed", "stat", "stats",
136
+ "skeleton", "badge", "avatar", "progress"))
137
+ NAME_WORDS = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z0-9]+")
114
138
  CSS_SUFFIXES = (".css", ".scss", ".sass", ".less")
115
139
  TAILWIND_NAMES = ("tailwind.config.js", "tailwind.config.cjs",
116
140
  "tailwind.config.mjs", "tailwind.config.ts",
117
141
  "tailwind.config.json")
118
142
  THEME_NAMES = ("theme.json", "tokens.json", "design-tokens.json")
143
+ # A JS/TS module that holds a React Native / Expo app's floor in code, read
144
+ # after the three stylesheet-shaped sources (`module_tokens`).
145
+ MODULE_NAMES = tuple("%s.%s" % (stem, ext)
146
+ for stem in ("theme", "tokens", "colors", "colours", "palette")
147
+ for ext in ("ts", "js", "mjs"))
148
+ MODULE_CAP = 20
119
149
  FILE_CAP = 4000
120
150
  CSS_CAP = 200
121
151
 
122
152
  DECL = re.compile(r"(--[A-Za-z0-9_-]+)\s*:\s*([^;{}]+)")
123
153
  BLOCK = re.compile(r"\b%s\s*:\s*\{")
124
154
  PAIR = re.compile(r"^\s*['\"]?([A-Za-z0-9_-]+)['\"]?\s*:\s*(.+?)\s*$")
155
+ # A config entry whose value is an object: a colour scale, read through `_pairs`
156
+ # under the flattened name rather than skipped, at most NEST_CAP levels deep.
157
+ NESTED = re.compile(r"^\s*['\"]?([A-Za-z0-9_-]+)['\"]?\s*:\s*\{(.*)\}\s*,?\s*$",
158
+ re.S)
159
+ NEST_CAP = 4
160
+ READ_CAP = 1 << 20
161
+ # `const name = {` / `export const name: Type = {` at a line's start - a module's
162
+ # top-level object in formatted code, not one inside a function. The annotation
163
+ # is bounded and stays on its line: an unbounded one made `var a:` repeated
164
+ # quadratic (128 KiB took 26 s), and `const p: { ink: string } = {` is not read.
165
+ CONST_BLOCK = re.compile(r"^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)"
166
+ r"\s*(?::[^=;{\n]{0,200})?=\s*\{", re.M)
167
+ FONT_SIZE = re.compile(r"\bfontSize\s*:\s*(\d+(?:\.\d+)?)\b")
168
+ NUMBER = re.compile(r"^-?\d*\.?\d+$")
125
169
  HEX3 = re.compile(r"^#([0-9a-f])([0-9a-f])([0-9a-f])$", re.I)
126
170
  HEX6 = re.compile(r"^#([0-9a-f]{6})$", re.I)
127
171
  RGB = re.compile(r"^rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)"
@@ -309,12 +353,41 @@ def walk(root):
309
353
 
310
354
 
311
355
  def read_text(root, rel):
356
+ """The file's text, or "" for one a token reader will not open: a symlink
357
+ that resolves outside `root`, anything that is not a regular file (a FIFO
358
+ blocks `read` forever), or one over READ_CAP - a token source is a small
359
+ text file, and every Tailwind config in the tree is read. The checks are
360
+ made on what was opened, not on the path: each component under `root` is
361
+ opened relative to its parent's descriptor with `O_NOFOLLOW`, so a file or
362
+ a parent directory swapped for a link or a FIFO after `realpath` is refused
363
+ too. A platform with no `dir_fd` support opens the resolved path whole."""
364
+ real, base = os.path.realpath(os.path.join(root, rel)), os.path.realpath(root)
365
+ if not real.startswith(base + os.sep):
366
+ return ""
367
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
368
+ flags = os.O_RDONLY | nofollow | getattr(os, "O_NONBLOCK", 0)
312
369
  try:
313
- with open(os.path.join(root, rel), encoding="utf-8",
314
- errors="replace") as fh:
315
- return fh.read()
370
+ if os.open in os.supports_dir_fd:
371
+ parts = os.path.relpath(real, base).split(os.sep)
372
+ parent = os.open(base, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
373
+ try:
374
+ for part in parts[:-1]:
375
+ child = os.open(part, os.O_RDONLY | nofollow
376
+ | getattr(os, "O_DIRECTORY", 0), dir_fd=parent)
377
+ os.close(parent)
378
+ parent = child
379
+ fd = os.open(parts[-1], flags, dir_fd=parent)
380
+ finally:
381
+ os.close(parent)
382
+ else:
383
+ fd = os.open(real, flags)
316
384
  except OSError:
317
385
  return ""
386
+ with os.fdopen(fd, "rb") as fh:
387
+ st = os.fstat(fd)
388
+ if not stat.S_ISREG(st.st_mode) or st.st_size > READ_CAP:
389
+ return ""
390
+ return fh.read(READ_CAP).decode("utf-8", errors="replace")
318
391
 
319
392
 
320
393
  def css_declarations(root, files):
@@ -340,8 +413,17 @@ def css_declarations(root, files):
340
413
 
341
414
  def _block_body(text, key):
342
415
  """Every `key: { ... }` body in `text`, brace-balanced, in file order."""
343
- out = []
344
- for m in re.finditer(BLOCK.pattern % re.escape(key), text):
416
+ return [body for _, body in _bodies(text, BLOCK.pattern % re.escape(key))]
417
+
418
+
419
+ def _bodies(text, pattern):
420
+ """(match, body) for every `pattern` that opens a `{`, brace-balanced, in
421
+ file order. A match inside a body already taken is skipped: each body is
422
+ scanned once, so `colors:{` repeated n times costs n steps, not n squared."""
423
+ out, end = [], 0
424
+ for m in re.finditer(pattern, text):
425
+ if m.start() < end:
426
+ continue
345
427
  i, depth = m.end(), 1
346
428
  while i < len(text) and depth:
347
429
  if text[i] == "{":
@@ -349,73 +431,119 @@ def _block_body(text, key):
349
431
  elif text[i] == "}":
350
432
  depth -= 1
351
433
  i += 1
352
- out.append(text[m.end():i - 1])
434
+ out.append((m, text[m.end():i - 1]))
435
+ end = i
353
436
  return out
354
437
 
355
438
 
356
- def _pairs(body):
357
- """The `key: value` pairs at the top level of a config block. A nested
358
- object is skipped, not flattened: its leaves are a scale of their own and
359
- this reader does not claim to know its shape."""
360
- items, depth, buf = [], 0, []
439
+ def _items(body):
440
+ """The comma-separated entries at the top level of a config block. A
441
+ bracket or a parenthesis opens a level, so `rgba(0, 0, 0, 0.5)` is one
442
+ value; one inside a quoted string (`':)'`, `'a, b'`) opens nothing."""
443
+ items, depth, buf, quote, escaped = [], 0, [], None, False
361
444
  for ch in body:
362
- if ch in "{[":
445
+ if quote:
446
+ if escaped:
447
+ escaped = False
448
+ elif ch == "\\":
449
+ escaped = True
450
+ elif ch == quote:
451
+ quote = None
452
+ elif ch in "'\"`":
453
+ quote = ch
454
+ elif ch in "{[(":
363
455
  depth += 1
364
- elif ch in "}]":
456
+ elif ch in "}])":
365
457
  depth -= 1
366
- if ch == "," and depth == 0:
458
+ if ch == "," and depth == 0 and not quote:
367
459
  items.append("".join(buf))
368
460
  buf = []
369
461
  else:
370
462
  buf.append(ch)
371
463
  items.append("".join(buf))
464
+ return items
465
+
466
+
467
+ def _pairs(body, prefix="", nested=False, depth=0):
468
+ """The `key: value` pairs at the top level of a config block. With `nested`
469
+ (the `colors` block, the one Tailwind documents as nestable) a nested object
470
+ is flattened under the class name Tailwind generates for it: `brand: {50:
471
+ ..., 500: ..., DEFAULT: ...}` is `brand-50`, `brand-500` and `brand`. That
472
+ is how a palette with more than one step is written, and skipping it left
473
+ two of the four UI repositories on this machine with no derivable token.
474
+ Elsewhere a nested object is skipped, as its leaves are not a scale this
475
+ reader knows; past NEST_CAP levels it is skipped too."""
372
476
  out = []
373
- for item in items:
477
+ for item in _items(body):
478
+ inner = NESTED.match(item) if nested and depth < NEST_CAP else None
479
+ if inner:
480
+ out.extend(_pairs(inner.group(2), _name(prefix, inner.group(1)),
481
+ nested, depth + 1))
482
+ continue
374
483
  if "{" in item:
375
484
  continue
376
485
  m = PAIR.match(item)
377
486
  if m:
378
- out.append((m.group(1), m.group(2).strip().strip("'\"")))
487
+ out.append((_name(prefix, m.group(1)), m.group(2).strip().strip("'\"")))
379
488
  return out
380
489
 
381
490
 
491
+ def _name(prefix, key):
492
+ if not prefix:
493
+ return key
494
+ return prefix if key == "DEFAULT" else prefix + "-" + key
495
+
496
+
382
497
  def tailwind_tokens(root, files):
383
- """The tokens a Tailwind config declares, read from its `colors`, `spacing`
384
- and `fontSize` blocks."""
385
- rel = next((f for f in files if os.path.basename(f) in TAILWIND_NAMES), None)
386
- if rel is None:
498
+ """The tokens the Tailwind configs declare, read from their `colors`,
499
+ `spacing` and `fontSize` blocks. Every config is read, not the first one
500
+ found: a monorepo keeps one per app (`apps/<name>/tailwind.config.js`), and
501
+ the floor has to hold for each of them. So a colour name a second app gives
502
+ another value is kept under `<name>@<config>` rather than overwritten (the
503
+ palette is judged by value), and the space unit is the one every app's unit
504
+ is a multiple of."""
505
+ rels = [f for f in files if os.path.basename(f) in TAILWIND_NAMES]
506
+ if not rels:
387
507
  return {}, []
388
- text = read_text(root, rel)
389
- colours, space, scale = {}, [], []
390
- for body in _block_body(text, "colors"):
391
- for key, value in _pairs(body):
392
- hexed = normalise_colour(value)
393
- if hexed:
508
+ colours, units, scale = {}, [], []
509
+ for rel in rels:
510
+ text = read_text(root, rel)
511
+ for body in _block_body(text, "colors"):
512
+ for key, value in _pairs(body, nested=True):
513
+ hexed = normalise_colour(value)
514
+ if not hexed:
515
+ continue
516
+ if colours.get(key, hexed) != hexed:
517
+ key = "%s@%s" % (key, rel)
394
518
  colours[key] = hexed
395
- for body in _block_body(text, "spacing"):
396
- for key, value in _pairs(body):
397
- px = length_px(value)
398
- if px:
399
- space.append((key in ("unit", "base"), px))
400
- for body in _block_body(text, "fontSize"):
401
- for _, value in _pairs(body):
402
- for token in re.findall(r"['\"]?([\d.]+(?:px|rem|pt))", value):
403
- px = length_px(token)
519
+ space = []
520
+ for body in _block_body(text, "spacing"):
521
+ for key, value in _pairs(body):
522
+ px = length_px(value)
404
523
  if px:
405
- scale.append(int(round(px)))
524
+ space.append((key in ("unit", "base"), px))
525
+ # A key named `unit`/`base` states the unit; otherwise the greatest
526
+ # common divisor of the scale the config declares is the unit it is
527
+ # built on.
528
+ named = sorted(px for explicit, px in space if explicit)
529
+ if named:
530
+ units.append(named[0])
531
+ elif space:
532
+ units.append(_gcd([px for _, px in space]))
533
+ for body in _block_body(text, "fontSize"):
534
+ for _, value in _pairs(body):
535
+ for token in re.findall(r"['\"]?([\d.]+(?:px|rem|pt))", value):
536
+ px = length_px(token)
537
+ if px:
538
+ scale.append(int(round(px)))
406
539
  out = {}
407
- # A key named `unit`/`base` states the unit; otherwise the greatest common
408
- # divisor of the scale the config declares is the unit it is built on.
409
- named = sorted((px for explicit, px in space if explicit))
410
- if named:
411
- out["space_unit"] = int(named[0])
412
- elif space:
413
- out["space_unit"] = int(_gcd([px for _, px in space]))
540
+ if units:
541
+ out["space_unit"] = int(_gcd(units))
414
542
  if scale:
415
543
  out["type_scale"] = sorted(set(scale))
416
544
  if colours:
417
545
  out["colors"] = colours
418
- return out, [rel]
546
+ return out, rels
419
547
 
420
548
 
421
549
  def theme_tokens(root, files):
@@ -467,8 +595,83 @@ def theme_tokens(root, files):
467
595
  return out, [rel]
468
596
 
469
597
 
598
+ def module_tokens(root, files):
599
+ """The tokens a JS/TS theme module declares - the floor a React Native or
600
+ Expo app keeps in code (`src/config/theme.ts`: `export const palette =
601
+ {...}`, `spacing`, `typography`), where there is no stylesheet to read.
602
+ Every top-level object's colour literals are the palette (by value, under
603
+ `<object>-<key>`; a name two modules give different values keeps both, as
604
+ `<name>@<module>`); the `spacing`/`space` object's numbers are the scale, a
605
+ unitless number read as px (React Native's dp); every non-zero `fontSize:`
606
+ number is a type step. Comments are dropped first, so a commented-out
607
+ example is not a token and a commented key does not hide the next one."""
608
+ rels = [f for f in files if os.path.basename(f) in MODULE_NAMES][:MODULE_CAP]
609
+ colours, space, scale, read = {}, [], [], []
610
+ for rel in rels:
611
+ text = _strip_comments(read_text(root, rel))
612
+ found = False
613
+ for m, body in _bodies(text, CONST_BLOCK):
614
+ name = m.group(1)
615
+ for key, value in _pairs(body, name, nested=True):
616
+ hexed = normalise_colour(value)
617
+ if not hexed:
618
+ continue
619
+ if colours.get(key, hexed) != hexed:
620
+ key = "%s@%s" % (key, rel)
621
+ colours[key] = hexed
622
+ found = True
623
+ if name.lower() in ("spacing", "space"):
624
+ for _, value in _pairs(body):
625
+ px = length_px(value + "px" if NUMBER.match(value) else value)
626
+ if px and px > 0:
627
+ space.append(px)
628
+ scale.extend(int(round(float(v))) for v in FONT_SIZE.findall(text) if float(v) > 0)
629
+ if found:
630
+ read.append(rel)
631
+ out = {}
632
+ if space:
633
+ out["space_unit"] = int(_gcd(space))
634
+ if scale:
635
+ out["type_scale"] = sorted(set(scale))
636
+ if colours:
637
+ out["colors"] = colours
638
+ return out, read
639
+
640
+
641
+ def _strip_comments(text):
642
+ """`text` with its `/* */` and `//` comments removed, in one linear pass
643
+ that knows a quoted string: `'a // b'`, `'src/**/*.ts'` and `https://` in a
644
+ string are kept. An unclosed `/*` drops the rest; a regex for the block form
645
+ re-scanned the tail from every `/*` (quadratic on a run of them)."""
646
+ out, i, n, quote = [], 0, len(text), None
647
+ while i < n:
648
+ ch = text[i]
649
+ if quote:
650
+ out.append(ch)
651
+ if ch == "\\" and i + 1 < n:
652
+ out.append(text[i + 1])
653
+ i += 1
654
+ elif ch == quote:
655
+ quote = None
656
+ elif ch in "'\"`":
657
+ quote = ch
658
+ out.append(ch)
659
+ elif text.startswith("//", i):
660
+ end = text.find("\n", i)
661
+ i = n if end < 0 else end
662
+ continue
663
+ elif text.startswith("/*", i):
664
+ end = text.find("*/", i + 2)
665
+ i = n if end < 0 else end + 2
666
+ continue
667
+ else:
668
+ out.append(ch)
669
+ i += 1
670
+ return "".join(out)
671
+
672
+
470
673
  def _gcd(values):
471
- values = [int(round(v)) for v in values if v]
674
+ values = [abs(int(round(v))) for v in values if v]
472
675
  if not values:
473
676
  return 0
474
677
  out = values[0]
@@ -578,7 +781,7 @@ def design_md_tokens(root):
578
781
  path = os.path.join(root, name)
579
782
  if not os.path.isfile(path):
580
783
  continue
581
- text = open(path, encoding="utf-8", errors="replace").read()
784
+ text = read_text(root, name)
582
785
  block = re.search(r"```css\s*(.*?)```", text, re.S)
583
786
  if not block:
584
787
  continue
@@ -612,12 +815,14 @@ def derive(root):
612
815
  files = walk(root)
613
816
  for name, reader in (("css-custom-properties", css_tokens),
614
817
  ("tailwind-config", tailwind_tokens),
615
- ("theme-json", theme_tokens)):
818
+ ("theme-json", theme_tokens),
819
+ ("theme-module", module_tokens)):
616
820
  tokens, sources = reader(root, files)
617
821
  if tokens.get("colors"):
618
822
  return tokens, name, sources
619
823
  return None, ("no token source: looked for CSS custom properties, a "
620
- "Tailwind config and a tokens/theme JSON"), []
824
+ "Tailwind config, a tokens/theme JSON and a JS/TS theme "
825
+ "module"), []
621
826
 
622
827
 
623
828
  def contract_data(tokens, source):
@@ -949,8 +1154,8 @@ def parse(argv, names):
949
1154
  arg = argv[i]
950
1155
  if arg == "--json":
951
1156
  out["json"] = True
952
- elif arg == "--render":
953
- out["render"] = True
1157
+ elif arg in ("--render", "--suggest"):
1158
+ out[arg.lstrip("-")] = True
954
1159
  elif arg in names:
955
1160
  if i + 1 >= len(argv):
956
1161
  raise Usage("%s wants a value" % arg)
@@ -966,6 +1171,71 @@ def _print_json(payload):
966
1171
  sys.stdout.write(json.dumps(payload, indent=2, sort_keys=True) + "\n")
967
1172
 
968
1173
 
1174
+ def suggest_components(files):
1175
+ """[(name, kind, path)]: the candidate inventory rows a repository's
1176
+ component files suggest - a `.tsx/.jsx/.vue/.svelte/.astro/.swift/.kt/
1177
+ .dart` file under a `components`/`widgets`/`ui` directory, named by its
1178
+ file (an `index` file by its directory, `Button.ios.tsx` as `Button`), first
1179
+ path per name. `kind` is `interactive` or `data` only when the name's head
1180
+ noun says so (`IconButton`, `StatCard`), else None. A suggestion, never the
1181
+ inventory: which of these the repository owns, and every kind, is the
1182
+ person's call (skills/design-contract/SKILL.md, the inventory)."""
1183
+ out, seen = [], set()
1184
+ for rel in files:
1185
+ parts = rel.split("/")
1186
+ stem, ext = os.path.splitext(parts[-1])
1187
+ if (ext.lower() not in COMPONENT_EXT or NOT_COMPONENT.search(rel)
1188
+ or not COMPONENT_DIRS.intersection(p.lower() for p in parts[:-1])):
1189
+ continue
1190
+ stem = PLATFORM_SUFFIX.sub("", stem)
1191
+ name = parts[-2] if stem.lower() == "index" else stem
1192
+ if name.lower() in COMPONENT_DIRS or name in seen:
1193
+ continue
1194
+ seen.add(name)
1195
+ words = [w for chunk in re.split(r"[_\-]", name) for w in NAME_WORDS.findall(chunk)]
1196
+ head = words[-1].lower() if words else ""
1197
+ kind = ("interactive" if head in INTERACTIVE_HEADS else
1198
+ "data" if head in DATA_HEADS else None)
1199
+ out.append((name, kind, rel))
1200
+ return out
1201
+
1202
+
1203
+ def suggest_cli(root, contract, opts):
1204
+ """Print the candidate rows, minus the names the contract already lists.
1205
+ Writes nothing: the inventory is edited by the person who owns it."""
1206
+ listed = set()
1207
+ if os.path.exists(contract):
1208
+ data, reason = read_contract(contract)
1209
+ comps = (data or {}).get("components") or []
1210
+ if reason or not isinstance(comps, list):
1211
+ # a contract the reader cannot use would make every name look new
1212
+ sys.stderr.write("tezgah-design: %s: %s\n" % (
1213
+ os.path.abspath(contract),
1214
+ reason or "its `components` is not a list"))
1215
+ return 2
1216
+ listed = {str(c.get("name")) for c in comps if isinstance(c, dict)}
1217
+ rows = [r for r in suggest_components(walk(root)) if r[0] not in listed]
1218
+ if opts.get("json"):
1219
+ _print_json({"ok": bool(rows), "candidates": [
1220
+ {"name": n, "kind": k, "path": p} for n, k, p in rows]})
1221
+ elif rows:
1222
+ # absolute: from a worktree the contract is the main checkout's
1223
+ sys.stdout.write(
1224
+ "%d candidate(s) for the component inventory; nothing was written. Add "
1225
+ "the ones this repository owns to `components` in %s. Every `kind` is "
1226
+ "a guess from the name's last word - check each one, and set the "
1227
+ "`null` ones (interactive|data):\n" % (len(rows), os.path.abspath(contract)))
1228
+ for name, kind, rel in rows:
1229
+ sys.stdout.write(" %-28s %-12s %s\n" % (name, kind or "?", rel))
1230
+ sys.stdout.write("[\n%s\n]\n" % ",\n".join(
1231
+ " " + json.dumps({"name": n, "kind": k}) for n, k, _ in rows))
1232
+ else:
1233
+ sys.stderr.write("tezgah-design: no candidate component: no new "
1234
+ ".tsx/.jsx/.vue/.svelte/.astro/.swift/.kt/.dart file "
1235
+ "under a components/widgets/ui directory\n")
1236
+ return 0 if rows else 2
1237
+
1238
+
969
1239
  def derive_cli(opts):
970
1240
  root = project_top(os.path.abspath(opts.get("repo") or ""))
971
1241
  if not os.path.isdir(root):
@@ -975,6 +1245,8 @@ def derive_cli(opts):
975
1245
  # removal does not take with it
976
1246
  ws = linked_main(root) or root
977
1247
  out = opts.get("out") or os.path.join(ws, ".tezgah", "design-contract.md")
1248
+ if opts.get("suggest"):
1249
+ return suggest_cli(root, out, opts)
978
1250
  if os.path.exists(out):
979
1251
  # A contract that exists is the repository's own decision: it carries
980
1252
  # the component inventory someone filled in, so overwriting it would
@@ -1239,6 +1511,9 @@ def main(argv):
1239
1511
  if verb in ("derive", "check") and "--render" in rest:
1240
1512
  sys.stderr.write("tezgah-design: --render belongs to `slop`, not %s\n" % verb)
1241
1513
  return 2
1514
+ if verb != "derive" and "--suggest" in rest:
1515
+ sys.stderr.write("tezgah-design: --suggest belongs to `derive`, not %s\n" % verb)
1516
+ return 2
1242
1517
  try:
1243
1518
  if verb == "derive":
1244
1519
  return derive_cli(parse(rest, ("--repo", "--out")))