@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.
- package/CHANGELOG.md +507 -1
- package/MANIFEST +40 -0
- package/NOTICE +152 -0
- package/README.de.md +41 -20
- package/README.es.md +39 -19
- package/README.fr.md +41 -20
- package/README.md +18 -10
- package/README.tr.md +39 -21
- package/bin/codegen +9 -2
- package/bin/consult +6 -0
- package/bin/tezgah-adhd +3 -0
- package/bin/tezgah-agents +3 -0
- package/bin/tezgah-capture +3 -0
- package/bin/tezgah-design +329 -54
- package/bin/tezgah-docs +158 -42
- package/bin/tezgah-dsh +1 -1
- package/bin/tezgah-gate +7 -2
- package/bin/tezgah-import-ai-research +16 -4
- package/bin/tezgah-index +3 -0
- package/bin/tezgah-lessons +13 -4
- package/bin/tezgah-mcp +4 -0
- package/bin/tezgah-migrate +3 -0
- package/bin/tezgah-ocr-review +2 -1
- package/bin/tezgah-optimize +4 -1
- package/bin/tezgah-pony +3 -0
- package/bin/tezgah-research +51 -10
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +1741 -95
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +58 -17
- package/bin/tezgah.js +10 -3
- package/commands/adhd.md +1 -1
- package/docs/README.md +19 -8
- package/docs/architecture.md +2 -2
- package/docs/citations-baseline.json +11 -17
- package/docs/clarity-baseline.json +12 -6
- package/docs/contract.md +83 -34
- package/docs/environment.md +59 -24
- package/docs/evidence.md +128 -25
- package/docs/gate.md +77 -23
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +343 -48
- package/docs/layers.md +2 -2
- package/docs/models.md +6 -0
- package/docs/operations.md +83 -29
- package/docs/research.md +37 -11
- package/docs/skills.md +31 -8
- package/docs/status-line.md +39 -23
- package/docs/testing.md +50 -17
- package/docs/vision.md +6 -2
- package/hooks/hooks.json +1 -1
- package/hooks/lang/en.json +16 -2
- package/hooks/lang/tr.json +14 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +3 -2
- package/hooks/tezgah_agents.py +27 -3
- package/hooks/tezgah_attest.py +79 -10
- package/hooks/tezgah_context.py +61 -31
- package/hooks/tezgah_gate.py +480 -126
- package/hooks/tezgah_integrity.py +577 -133
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +60 -8
- package/hooks/tezgah_policy.py +48 -18
- package/hooks/tezgah_replay.py +8 -2
- package/hooks/tezgah_research.py +167 -24
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stack.py +236 -1
- package/hooks/tezgah_stopspec.py +2 -1
- package/hooks/tezgah_task.py +2 -1
- package/hosts/amp/hook.py +217 -0
- package/hosts/amp/tezgah-plugin.ts.in +396 -0
- package/hosts/antigravity/hook.py +255 -0
- package/hosts/codex/hook.py +3 -2
- package/hosts/commandcode/hook.py +239 -0
- package/hosts/copilot/hook.py +284 -0
- package/hosts/cursor/hook.py +4 -2
- package/hosts/droid/hook.py +259 -0
- package/hosts/dsh/hooks.json +1 -1
- package/hosts/gemini/hook.py +213 -0
- package/hosts/goose/hook.py +145 -0
- package/hosts/omp/hook.py +5 -2
- package/hosts/omp/tezgah-hook.ts.in +157 -42
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +206 -0
- package/output-styles/tezgah.md +11 -6
- package/package.json +6 -3
- package/packaging/build.sh +4 -4
- package/packaging/install.sh +11 -0
- package/packaging/upgrade.sh +3 -0
- package/skills/ai-research/LICENSE +21 -0
- package/skills/design-contract/SKILL.md +27 -6
- package/skills/design-library/LICENSE +21 -0
- package/skills/harness/SKILL.md +2 -0
- package/skills/i-have-adhd/SKILL.md +10 -1
- package/skills/plan-add/SKILL.md +5 -1
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/research/SKILL.md +22 -9
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +21 -8
- package/statusline.py +20 -9
- package/third_party/open-code-review/LICENSE +201 -0
- package/third_party/open-code-review/README.md +15 -0
- package/third_party/open-code-review/prompts/main_task_system.md +26 -0
- package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
- package/workflows/graph-map.js +14 -5
- 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
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
357
|
-
"""The
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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
|
|
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
|
|
384
|
-
and `fontSize` blocks.
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
for
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
for
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
953
|
-
out["
|
|
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")))
|