@freetesh/canon 0.16.0 → 0.18.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -31
- package/cli/dist/api.d.ts +1 -2
- package/cli/dist/api.js +1 -2
- package/cli/dist/check.d.ts +7 -3
- package/cli/dist/check.js +18 -7
- package/cli/dist/checks/commands.d.ts +1 -1
- package/cli/dist/checks/commands.js +1 -1
- package/cli/dist/checks/designs.d.ts +7 -0
- package/cli/dist/checks/designs.js +46 -0
- package/cli/dist/checks/index.js +3 -0
- package/cli/dist/checks/refs.js +8 -4
- package/cli/dist/commands/add.d.ts +1 -1
- package/cli/dist/commands/add.js +1 -4
- package/cli/dist/commands/check.js +4 -4
- package/cli/dist/commands/copies.js +1 -1
- package/cli/dist/commands/hooks.js +4 -4
- package/cli/dist/commands/learn.js +7 -5
- package/cli/dist/commands/pipeline.js +2 -2
- package/cli/dist/commands/scripts.d.ts +3 -0
- package/cli/dist/commands/scripts.js +32 -0
- package/cli/dist/commands/standards.js +1 -1
- package/cli/dist/commands/sync.js +1 -1
- package/cli/dist/commands/ui.js +3 -3
- package/cli/dist/config/load.d.ts +5 -11
- package/cli/dist/config/load.js +21 -25
- package/cli/dist/config/schema.d.ts +6 -2
- package/cli/dist/config/schema.js +9 -5
- package/cli/dist/doctor.js +2 -1
- package/cli/dist/index.js +4 -2
- package/cli/dist/learn.d.ts +2 -2
- package/cli/dist/learn.js +6 -6
- package/cli/dist/lib/env.d.ts +3 -3
- package/cli/dist/lib/env.js +4 -4
- package/cli/dist/packs.d.ts +1 -3
- package/cli/dist/packs.js +4 -7
- package/cli/dist/standards.d.ts +11 -6
- package/cli/dist/standards.js +37 -17
- package/cli/dist/status.js +1 -1
- package/cli/dist/sync.d.ts +0 -1
- package/cli/dist/sync.js +1 -1
- package/package.json +4 -1
- package/scripts/arch-set.py +140 -0
- package/scripts/check-designs.py +709 -0
- package/scripts/flow.py +313 -0
- package/skills/ingest/SKILL.md +1 -1
- package/standards/README.md +23 -6
- package/standards/agent-contract.md +21 -6
- package/standards/context-layout.md +42 -7
- package/standards/library/design-sets.md +857 -0
- package/standards/library/expo.md +54 -0
- package/standards/library/frontend.md +44 -0
- package/standards/library/performance.md +45 -0
- package/standards/library/pipeline.md +42 -0
- package/standards/library/secrets.md +37 -0
- package/standards/library/store-listing.md +122 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The checks from the design-sets standard §12, over a design surface.
|
|
3
|
+
|
|
4
|
+
python3 scripts/check-designs.py .context/designs/mobile
|
|
5
|
+
|
|
6
|
+
There is no lint and no build for a design set, so these are it:
|
|
7
|
+
|
|
8
|
+
inventory every file on disk is declared in routes.js, and vice versa
|
|
9
|
+
well-formed every <div> is closed exactly once, in order
|
|
10
|
+
links every local href points at a file that exists
|
|
11
|
+
tokens every var(--x) and every colour class resolves against
|
|
12
|
+
shared.css + tailwind.config.js
|
|
13
|
+
click-through every declared edge is a real link in the screen it leaves
|
|
14
|
+
flow-complete every link between screens is a declared edge
|
|
15
|
+
acyclic the forward graph ranks by depth, so it must be a DAG
|
|
16
|
+
routes every app screen is drawn, and every drawing has an app screen
|
|
17
|
+
app-flow the app and the chart navigate alike, in both directions
|
|
18
|
+
copy every word a dialog or a toast puts on screen is drawn somewhere
|
|
19
|
+
|
|
20
|
+
The last two are a pair and the set needs both. Without the first the chart is
|
|
21
|
+
a diagram of an intention; without the second it quietly under-reports the
|
|
22
|
+
product. Exits non-zero on any failure.
|
|
23
|
+
"""
|
|
24
|
+
import re, os, sys, json, subprocess, glob
|
|
25
|
+
|
|
26
|
+
SURF = sys.argv[1] if len(sys.argv) > 1 else '.context/designs/mobile'
|
|
27
|
+
ROOT = os.path.abspath(os.path.join(SURF, '..', '..', '..'))
|
|
28
|
+
# A surface is checked against the app that RENDERS it. Every surface used to be
|
|
29
|
+
# read against the Expo app, so a web set was told that all of its screens had
|
|
30
|
+
# no route and that every mobile screen "ships and no screen draws it" — 190
|
|
31
|
+
# failures that said nothing about the web set at all.
|
|
32
|
+
WEB = os.path.basename(os.path.normpath(SURF)) == 'web'
|
|
33
|
+
# A monorepo keeps its Expo app in apps/mobile/; a single-package app at the root.
|
|
34
|
+
# The site is Next.js and only ever lives in a monorepo.
|
|
35
|
+
CANDIDATES = ('apps/website/src', 'apps/web/src') if WEB else ('apps/mobile/src', 'src')
|
|
36
|
+
SRC_DIR = next((d for d in (os.path.join(ROOT, c) for c in CANDIDATES)
|
|
37
|
+
if os.path.isdir(os.path.join(d, 'app'))), os.path.join(ROOT, CANDIDATES[-1]))
|
|
38
|
+
APP_DIR = os.path.join(SRC_DIR, 'app')
|
|
39
|
+
# expo-router makes every .tsx under app/ a screen except a layout; the Next.js
|
|
40
|
+
# app router makes a screen of `page.tsx` and of nothing else in the folder.
|
|
41
|
+
def is_screen(name):
|
|
42
|
+
return name == 'page.tsx' if WEB else name.endswith('.tsx') and name != '_layout.tsx'
|
|
43
|
+
os.chdir(SURF)
|
|
44
|
+
fail = 0
|
|
45
|
+
def head(t): print('\n== ' + t)
|
|
46
|
+
def bad(m):
|
|
47
|
+
global fail; fail += 1; print(' FAIL ' + m)
|
|
48
|
+
|
|
49
|
+
# ---- the manifest, read through node so routes.js stays the only parser -----
|
|
50
|
+
R = json.loads(subprocess.run(
|
|
51
|
+
['node','-e','global.window={};require("./routes.js");console.log(JSON.stringify(window.ROUTES))'],
|
|
52
|
+
capture_output=True, text=True, check=True).stdout)
|
|
53
|
+
|
|
54
|
+
# `order` is declaration order; `declared_files` is a set and must never be
|
|
55
|
+
# iterated where the ORDER reaches output. See owners below.
|
|
56
|
+
screens, states, files, order = [], {}, {}, []
|
|
57
|
+
for fl in R['flows']:
|
|
58
|
+
for s in fl['screens']:
|
|
59
|
+
screens.append(s['file']); files[s['file']] = s
|
|
60
|
+
order.append(s['file'])
|
|
61
|
+
for w, sf in (s.get('states') or {}).items():
|
|
62
|
+
states[sf] = s['file']; order.append(sf)
|
|
63
|
+
declared_files = set(screens) | set(states)
|
|
64
|
+
on_disk = {f for f in glob.glob('*/**/*.html', recursive=True)}
|
|
65
|
+
|
|
66
|
+
def hrefs(f):
|
|
67
|
+
s = open(f).read()
|
|
68
|
+
out = []
|
|
69
|
+
for m in re.finditer(r'href="([^"]+)"', s):
|
|
70
|
+
h = m.group(1)
|
|
71
|
+
# tel: and sms: leave the set the same way mailto: does — a wireframe that
|
|
72
|
+
# draws a helpline number as a real link is correct, not a dead path.
|
|
73
|
+
if h.startswith(('http','#','mailto:','tel:','sms:')): continue
|
|
74
|
+
out.append((h, os.path.normpath(os.path.join(os.path.dirname(f), h.split('?')[0].split('#')[0]))))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
# ---- 1. inventory ----------------------------------------------------------
|
|
78
|
+
head('inventory — every file declared, every declaration on disk')
|
|
79
|
+
for f in sorted(on_disk - declared_files): bad(f + ' is on disk and in no manifest entry')
|
|
80
|
+
for f in sorted(declared_files - on_disk): bad(f + ' is declared and not on disk')
|
|
81
|
+
print(' %d screens + %d states = %d files' % (len(screens), len(states), len(declared_files)))
|
|
82
|
+
|
|
83
|
+
# ---- 2. well-formed --------------------------------------------------------
|
|
84
|
+
# A browser silently repairs a stray or missing </div>, so a screen can be
|
|
85
|
+
# malformed for months and still LOOK right — which is exactly what happened to
|
|
86
|
+
# food/barcode.html, where a lost opening tag took the protein figure and the
|
|
87
|
+
# whole calorie total off the screen with it, and to gallery/photo-empty.html,
|
|
88
|
+
# which closed the phone frame two blocks early. Every other check here passed
|
|
89
|
+
# on both. Divs only: they are what the layout is built from, and the void and
|
|
90
|
+
# self-closing tags around them are not worth a real parser.
|
|
91
|
+
head('well-formed — every <div> is closed exactly once, in order')
|
|
92
|
+
for f in sorted(declared_files & on_disk):
|
|
93
|
+
# Comments are stripped from the WHOLE text, not line by line: several
|
|
94
|
+
# screens carry a rationale comment that says "<div>" in prose, and one of
|
|
95
|
+
# them spans two lines. Newlines are kept so the line numbers still point at
|
|
96
|
+
# the real fault.
|
|
97
|
+
src = re.sub(r'<!--.*?-->', lambda m: '\n' * m.group(0).count('\n'), open(f).read(), flags=re.S)
|
|
98
|
+
depth, stray = 0, []
|
|
99
|
+
for i, line in enumerate(src.split('\n'), 1):
|
|
100
|
+
for m in re.finditer(r'</?div\b', line):
|
|
101
|
+
depth += 1 if m.group(0) == '<div' else -1
|
|
102
|
+
if depth < 0:
|
|
103
|
+
stray.append(i); depth = 0
|
|
104
|
+
if stray: bad('%s: </div> with nothing open at line %s' % (f, ', '.join(map(str, stray))))
|
|
105
|
+
elif depth: bad('%s: %d <div> never closed' % (f, depth))
|
|
106
|
+
print(' %d screens parse' % len(declared_files & on_disk))
|
|
107
|
+
|
|
108
|
+
# ---- 3. links --------------------------------------------------------------
|
|
109
|
+
head('links — every local href points at a file that exists')
|
|
110
|
+
n = 0
|
|
111
|
+
for f in sorted(declared_files):
|
|
112
|
+
for raw, t in hrefs(f):
|
|
113
|
+
n += 1
|
|
114
|
+
if not os.path.exists(t): bad('%s -> %s (%s)' % (f, t, raw))
|
|
115
|
+
print(' %d local hrefs resolved' % n)
|
|
116
|
+
|
|
117
|
+
# ---- 4. tokens -------------------------------------------------------------
|
|
118
|
+
head('tokens — every var(--x) and every component class resolves')
|
|
119
|
+
css = open('shared.css').read()
|
|
120
|
+
declared_vars = set(re.findall(r"(--[a-z0-9-]+)\s*:", css))
|
|
121
|
+
declared_vars |= {'--chrome-h','--stage-pad'} # published at runtime by _chrome.js
|
|
122
|
+
declared_vars |= {'--tile-w','--tile-h','--tile-scale'} # published at runtime by screenshots.html, from the frame
|
|
123
|
+
declared_cls = set(re.findall(r'\.([a-zA-Z][a-zA-Z0-9_-]*)', css))
|
|
124
|
+
tw = subprocess.run(['node','-e','''
|
|
125
|
+
const fs=require("fs"); const tailwind={}; eval(fs.readFileSync("tailwind.config.js","utf8"));
|
|
126
|
+
const out=[]; (function walk(o,p){for(const k in o){const v=o[k];
|
|
127
|
+
if(typeof v==="string"||Array.isArray(v)) out.push(p.concat(k==="DEFAULT"?[]:[k]).join("-"));
|
|
128
|
+
else walk(v,p.concat(k==="DEFAULT"?[]:[k]));}})(tailwind.config.theme.extend.colors,[]);
|
|
129
|
+
console.log(JSON.stringify(out));'''], capture_output=True, text=True, check=True)
|
|
130
|
+
tw_colors = set(json.loads(tw.stdout))
|
|
131
|
+
|
|
132
|
+
missing_vars, missing_cls = set(), set()
|
|
133
|
+
for f in sorted(declared_files) + ['index.html','screenshots.html']:
|
|
134
|
+
s = open(f).read()
|
|
135
|
+
for v in re.findall(r'var\((--[a-z0-9-]+)', s):
|
|
136
|
+
if v not in declared_vars: missing_vars.add((f, v))
|
|
137
|
+
for attr in re.findall(r'class="([^"]*)"', s):
|
|
138
|
+
for c in attr.split():
|
|
139
|
+
c = c.split(':')[-1] # strip sm:/hover: prefixes
|
|
140
|
+
if re.match(r'^(bg|text|border|ring|fill|stroke|from|to|via|decoration|shadow|outline)-', c):
|
|
141
|
+
stem = c.split('-',1)[1].split('/')[0]
|
|
142
|
+
if re.match(r'^(brand|accent|conf|macro|ink|page|frame|surface|muted|sunken|line|success|warn|danger|info|camera)\b', stem):
|
|
143
|
+
if stem not in tw_colors: missing_cls.add((f, c))
|
|
144
|
+
# A MODIFIER MUST BE DECLARED. Only colour utilities were checked
|
|
145
|
+
# here, so `phone--camera` — used by both camera screens and
|
|
146
|
+
# declared nowhere — resolved to nothing for as long as the set has
|
|
147
|
+
# existed. Both got away with it by painting their own dark area
|
|
148
|
+
# inline; anything outside that block rendered as dark text on white.
|
|
149
|
+
#
|
|
150
|
+
# `--` is the precise test: every component modifier in this system
|
|
151
|
+
# uses it (card--muted, opt--ask, phone--page) and no Tailwind
|
|
152
|
+
# utility ever contains it, so there is nothing to allow-list.
|
|
153
|
+
elif '--' in c and c not in declared_cls:
|
|
154
|
+
missing_cls.add((f, c))
|
|
155
|
+
for f,v in sorted(missing_vars): bad('%s uses %s — declared nowhere in shared.css' % (f, v))
|
|
156
|
+
for f,c in sorted(missing_cls):
|
|
157
|
+
bad('%s uses %s — declared nowhere in shared.css' % (f, c) if '--' in c
|
|
158
|
+
else '%s uses %s — no such token in tailwind.config.js' % (f, c))
|
|
159
|
+
print(' %d CSS variables and %d classes declared' % (len(declared_vars), len(declared_cls)))
|
|
160
|
+
|
|
161
|
+
# ---- 5+6. click-through and flow-complete ----------------------------------
|
|
162
|
+
head('click-through — every declared edge is a real link in the screen it leaves')
|
|
163
|
+
head2 = 'flow-complete — every link between screens is a declared edge'
|
|
164
|
+
real = set()
|
|
165
|
+
for f in sorted(declared_files):
|
|
166
|
+
for raw, t in hrefs(f):
|
|
167
|
+
if t in ('index.html','screenshots.html') or t == f: continue
|
|
168
|
+
# A link that leaves the surface — the mobile drawer opening the
|
|
169
|
+
# website — is not a link between two screens of THIS set. Its target
|
|
170
|
+
# is in another manifest, so no edge here could name it; check 3 has
|
|
171
|
+
# already proved the file is there.
|
|
172
|
+
if t.startswith('..'): continue
|
|
173
|
+
if t.endswith('.html'): real.add((f, t))
|
|
174
|
+
decl = {(e['from'], e['to']) for e in R['flow']['edges'] if not e.get('state')}
|
|
175
|
+
state_edges = {(e['from'], e['to']) for e in R['flow']['edges'] if e.get('state')}
|
|
176
|
+
for a,b in sorted(decl - real): bad('edge %s -> %s is declared and is not a link' % (a,b))
|
|
177
|
+
print(' %d declared edges, all present as links' % len(decl))
|
|
178
|
+
print('\n== ' + head2)
|
|
179
|
+
for a,b in sorted(real - decl - state_edges): bad('link %s -> %s is not a declared edge' % (a,b))
|
|
180
|
+
print(' %d real links, all declared' % len(real))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---- 7. acyclic ------------------------------------------------------------
|
|
184
|
+
# The fifth check in the style guide, and the one it calls worst to fail: the
|
|
185
|
+
# chart ranks nodes by longest-path depth from the start, which only terminates
|
|
186
|
+
# on a DAG. A forward cycle makes the layout either hang or silently pick an
|
|
187
|
+
# arbitrary column, and unlike the other four this cannot be eyeballed on a
|
|
188
|
+
# thirty-screen set. It was missing from this script entirely.
|
|
189
|
+
#
|
|
190
|
+
# States collapse into their base screen and `state`/`back`/`side` edges are
|
|
191
|
+
# dropped first, exactly as _chrome.js does when it builds the ranked graph —
|
|
192
|
+
# a return edge is a cycle by definition and is not what this is looking for.
|
|
193
|
+
head('acyclic — the forward graph ranks, so it must be a DAG')
|
|
194
|
+
|
|
195
|
+
base = {}
|
|
196
|
+
for fl in R['flows']:
|
|
197
|
+
for sc in fl['screens']:
|
|
198
|
+
base[sc['file']] = sc['file']
|
|
199
|
+
for st in (sc.get('states') or {}).values():
|
|
200
|
+
base[st] = sc['file']
|
|
201
|
+
|
|
202
|
+
fwd = {}
|
|
203
|
+
for e in R['flow']['edges']:
|
|
204
|
+
if e.get('state') or e.get('back') or e.get('side'):
|
|
205
|
+
continue
|
|
206
|
+
a, b = base.get(e['from'], e['from']), base.get(e['to'], e['to'])
|
|
207
|
+
if a != b:
|
|
208
|
+
fwd.setdefault(a, set()).add(b)
|
|
209
|
+
|
|
210
|
+
WHITE, GREY, BLACK = 0, 1, 2
|
|
211
|
+
colour, cycles = {}, []
|
|
212
|
+
|
|
213
|
+
def walk(n, path):
|
|
214
|
+
colour[n] = GREY
|
|
215
|
+
path.append(n)
|
|
216
|
+
for m in sorted(fwd.get(n, ())):
|
|
217
|
+
c = colour.get(m, WHITE)
|
|
218
|
+
if c == GREY:
|
|
219
|
+
cycles.append(' -> '.join(path[path.index(m):] + [m]))
|
|
220
|
+
elif c == WHITE:
|
|
221
|
+
walk(m, path)
|
|
222
|
+
path.pop()
|
|
223
|
+
colour[n] = BLACK
|
|
224
|
+
|
|
225
|
+
for n in sorted(set(fwd) | {b for v in fwd.values() for b in v}):
|
|
226
|
+
if colour.get(n, WHITE) == WHITE:
|
|
227
|
+
walk(n, [])
|
|
228
|
+
|
|
229
|
+
for c in cycles:
|
|
230
|
+
bad('forward cycle: %s' % c)
|
|
231
|
+
print(' %d forward edges over %d screens, no cycles' % (
|
|
232
|
+
sum(len(v) for v in fwd.values()), len(base)))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---- 8. routes — the set and the app describe the same product -------------
|
|
236
|
+
# The style guide's §11 rule ("the designs describe the app that exists") is the
|
|
237
|
+
# one broken most often, and the other six checks cannot see it: they compare
|
|
238
|
+
# the set against itself. This one compares it against src/app.
|
|
239
|
+
#
|
|
240
|
+
# Each screen declares the route that renders it. Several screens may share one
|
|
241
|
+
# route — food/item.tsx wears three portion faces, scan/correct.tsx two scopes —
|
|
242
|
+
# because which face it wears is a fact about the data. A `route: null` is a
|
|
243
|
+
# specimen page that nothing in the app renders, which patterns pages are.
|
|
244
|
+
# ---- architecture ------------------------------------------------------------
|
|
245
|
+
# Every set carries an architecture flow, opened from the bar's chip (decided
|
|
246
|
+
# 2026-10-23): the manifest names its overview, the flow declares it, and each
|
|
247
|
+
# screen frames an Archify diagram from ../architecture/ whose .json source sits
|
|
248
|
+
# beside it. The links check above reads href only; a framed diagram is an
|
|
249
|
+
# iframe src, so a deleted diagram would otherwise go unnoticed.
|
|
250
|
+
head('architecture — the overview is declared and every framed diagram has its source')
|
|
251
|
+
arch_page = R.get('architecture')
|
|
252
|
+
arch_flow = next((fl for fl in R['flows'] if fl.get('flow') == 'architecture'), None)
|
|
253
|
+
if not arch_page:
|
|
254
|
+
bad('routes.js names no `architecture` page — run bin/arch-set with this set\'s spec')
|
|
255
|
+
elif arch_page not in declared_files:
|
|
256
|
+
bad('`architecture: "%s"` is not a declared screen' % arch_page)
|
|
257
|
+
if arch_flow is None:
|
|
258
|
+
bad('no `architecture` flow in routes.js')
|
|
259
|
+
framed = set()
|
|
260
|
+
for f in sorted(x for x in declared_files if x.startswith('architecture/')):
|
|
261
|
+
for src in re.findall(r'<iframe[^>]*\ssrc="([^"]+)"', open(f).read()):
|
|
262
|
+
t = os.path.normpath(os.path.join(os.path.dirname(f), src.split('?')[0].split('#')[0]))
|
|
263
|
+
framed.add(t)
|
|
264
|
+
if not os.path.exists(t): bad('%s frames %s, which does not exist' % (f, t))
|
|
265
|
+
elif not os.path.exists(t[:-5] + '.json'): bad('%s has no %s.json source beside it' % (t, os.path.basename(t)[:-5]))
|
|
266
|
+
assets = os.path.normpath('../architecture')
|
|
267
|
+
for d in sorted(glob.glob(os.path.join(assets, '*.html'))):
|
|
268
|
+
if os.path.normpath(d) not in framed: bad('%s is drawn and no architecture screen frames it' % d)
|
|
269
|
+
print(' %d diagrams framed' % len(framed))
|
|
270
|
+
|
|
271
|
+
head('routes — every app screen is drawn, and every drawing has a screen')
|
|
272
|
+
|
|
273
|
+
app_dir = APP_DIR
|
|
274
|
+
if not os.path.isdir(app_dir):
|
|
275
|
+
print(' skipped — no app at %s' % app_dir)
|
|
276
|
+
else:
|
|
277
|
+
on_disk_routes = set()
|
|
278
|
+
for root, _dirs, fs in os.walk(app_dir):
|
|
279
|
+
for f in fs:
|
|
280
|
+
if not is_screen(f):
|
|
281
|
+
continue
|
|
282
|
+
rel = os.path.relpath(os.path.join(root, f), app_dir)
|
|
283
|
+
on_disk_routes.add(rel)
|
|
284
|
+
|
|
285
|
+
declared_routes, undeclared = set(), []
|
|
286
|
+
for f in sorted(declared_files):
|
|
287
|
+
sc = files.get(f) or files.get(states.get(f))
|
|
288
|
+
if sc is None:
|
|
289
|
+
continue
|
|
290
|
+
if 'route' not in sc:
|
|
291
|
+
undeclared.append(f)
|
|
292
|
+
elif sc['route']:
|
|
293
|
+
declared_routes.add(sc['route'])
|
|
294
|
+
|
|
295
|
+
for f in undeclared:
|
|
296
|
+
bad('%s declares no `route` — say which app screen renders it, or null' % f)
|
|
297
|
+
for r in sorted(declared_routes - on_disk_routes):
|
|
298
|
+
bad('routes.js points at src/app/%s, which does not exist' % r)
|
|
299
|
+
# A route that ships with no drawing is allowed ONLY while it is written
|
|
300
|
+
# down. `missing` is the set's record of what the app has and the wireframes
|
|
301
|
+
# do not, and a divergence nobody recorded is the one that rots — §11 says a
|
|
302
|
+
# deliberate departure is fine and must be stated.
|
|
303
|
+
recorded = " ".join((m.get('what', '') + ' ' + m.get('why', ''))
|
|
304
|
+
for m in (R.get('missing') or []))
|
|
305
|
+
for r in sorted(on_disk_routes - declared_routes):
|
|
306
|
+
if r in recorded:
|
|
307
|
+
print(' pending: src/app/%s ships and is recorded in `missing`' % r)
|
|
308
|
+
else:
|
|
309
|
+
bad('src/app/%s ships and no screen draws it, and `missing` does not say why' % r)
|
|
310
|
+
print(' %d app screens, all drawn by %d wireframes' % (
|
|
311
|
+
len(on_disk_routes), len([f for f in declared_files
|
|
312
|
+
if (files.get(f) or {}).get('route')])))
|
|
313
|
+
|
|
314
|
+
# ---- 9. app-flow — the app navigates where the chart says it does ----------
|
|
315
|
+
# Checks 5 and 6 prove the design set agrees with ITSELF. This one is the pair
|
|
316
|
+
# they were missing: that the APP agrees with it too. A chart can be internally
|
|
317
|
+
# perfect and still describe a product that navigates somewhere else entirely,
|
|
318
|
+
# and until this check existed nothing would have said so.
|
|
319
|
+
#
|
|
320
|
+
# Comments are stripped first. Three of the first seven "findings" were prose
|
|
321
|
+
# in a comment explaining a navigation that had been REMOVED — the same trap the
|
|
322
|
+
# well-formed check fell into, and the reason both now strip before matching.
|
|
323
|
+
head('app-flow — every navigation the app performs is a declared edge')
|
|
324
|
+
|
|
325
|
+
# A design file may be the SOURCE of a navigation whether it is the screen or
|
|
326
|
+
# one of its states: food/barcode-locked is reached in barcode.tsx and links to
|
|
327
|
+
# search from there, and the edge is declared on the state.
|
|
328
|
+
# DECLARATION ORDER, and it has to be a list rather than whatever a set hands
|
|
329
|
+
# back. `declared_files` is a set, so `froms[0]` below was a different one of
|
|
330
|
+
# food/item.tsx's three portion faces on every run — and the `missing` entry
|
|
331
|
+
# naming one of them therefore matched about a third of the time. The check
|
|
332
|
+
# passed or failed on PYTHONHASHSEED, which is the worst thing a check can do.
|
|
333
|
+
owners = {}
|
|
334
|
+
for f in order:
|
|
335
|
+
r = (files.get(f) or {}).get('route') or (files.get(states.get(f, '')) or {}).get('route')
|
|
336
|
+
if r: owners.setdefault(r, []).append(f)
|
|
337
|
+
|
|
338
|
+
def app_route(href):
|
|
339
|
+
"""A route and a filename, reduced to the same thing.
|
|
340
|
+
|
|
341
|
+
`routes.js` names a FILE — "(tabs)/today.tsx" — and the app navigates to a
|
|
342
|
+
ROUTE — "/(tabs)/today". Both lose the extension, the leading slash, any
|
|
343
|
+
query string and every route group, so the two can be compared at all.
|
|
344
|
+
"""
|
|
345
|
+
h = href.split('?')[0].strip('/')
|
|
346
|
+
# EVERY group, not only a leading one. `(app)/(tabs)/tax/index.tsx` is
|
|
347
|
+
# reached as "/tax"; stripping one group left "(tabs)/tax", which no
|
|
348
|
+
# navigation in the app ever names.
|
|
349
|
+
h = re.sub(r'\([^)/]+\)/', '', h + '/').rstrip('/')
|
|
350
|
+
h = re.sub(r'\.tsx$', '', h)
|
|
351
|
+
# A dynamic segment is the same segment whatever it is called: the file says
|
|
352
|
+
# `inbox/[docId]`, a template says `/inbox/${id}`, and both mean "one of them".
|
|
353
|
+
h = re.sub(r'\[[^\]/]*\]', '[]', h)
|
|
354
|
+
# `group/[id]/index.tsx` is reached as "/group/[id]", and `(tabs)/index.tsx`
|
|
355
|
+
# as "/". Without this the first screen after entry matched nothing. On the
|
|
356
|
+
# web the same file is called `page.tsx`.
|
|
357
|
+
return re.sub(r'(^|/)(?:index|page)$' if WEB else r'(^|/)index$', '', h)
|
|
358
|
+
|
|
359
|
+
# A route literal, in whichever quote the app writes them. Reading only `"` and
|
|
360
|
+
# backticks found NO navigation at all in an app that single-quotes its strings:
|
|
361
|
+
# "0 navigations checked", and every forward edge reported as never performed.
|
|
362
|
+
ROUTE_LIT = r'(?:["\'](/[^"\'`$]*)|`(/[^`]*)`)'
|
|
363
|
+
def route_lits(text, before=''):
|
|
364
|
+
"""Every route literal in `text`, optionally only those following `before`.
|
|
365
|
+
|
|
366
|
+
A template literal used to be read up to its first `${`, so
|
|
367
|
+
router.push(`/inbox/${id}`) was "a navigation to /inbox/" — the LIST, from
|
|
368
|
+
the list — and the document screen it really opens was reported as a
|
|
369
|
+
drawing the app never reaches. The interpolation is a dynamic segment.
|
|
370
|
+
"""
|
|
371
|
+
return {quoted or re.sub(r'\$\{[^}]*\}', '[]', template)
|
|
372
|
+
for quoted, template in re.findall(before + ROUTE_LIT, text)}
|
|
373
|
+
# The site also navigates from the server, with next/navigation's redirect().
|
|
374
|
+
NAV_CALL = r'(?:router\.(?:push|replace)|\bredirect)\(' if WEB else r'router\.(?:push|replace)\('
|
|
375
|
+
|
|
376
|
+
def strip_comments(src):
|
|
377
|
+
src = re.sub(r'/\*.*?\*/', '', src, flags=re.S)
|
|
378
|
+
return re.sub(r'^\s*//.*$', '', src, flags=re.M)
|
|
379
|
+
|
|
380
|
+
def strip_guards(src):
|
|
381
|
+
"""Drop the redirects that recover from a state that should not happen.
|
|
382
|
+
|
|
383
|
+
`if (!plate) router.replace("/scan/camera")` is not a route a user can take
|
|
384
|
+
— it is what a screen does when the thing it was opened to edit has gone.
|
|
385
|
+
The chart describes the product, and a guard is not part of it; demanding an
|
|
386
|
+
edge for one would demand a LINK for it, and a "go to Today" control on the
|
|
387
|
+
cooking-fat screen is not a thing any of these wireframes should grow.
|
|
388
|
+
"""
|
|
389
|
+
return re.sub(r'if\s*\(\s*\(?\s*![^;{]*?\)\s*\{?[^;}]*?router\.replace\([^;]*?\);',
|
|
390
|
+
'', src, flags=re.S)
|
|
391
|
+
|
|
392
|
+
def routes_in(src):
|
|
393
|
+
"""Every app route this source navigates to.
|
|
394
|
+
|
|
395
|
+
Deliberately blunt: any "/..." string inside a router call, a <Redirect>, or
|
|
396
|
+
a ternary in either. Reading them individually missed
|
|
397
|
+
`router.replace(ask ? "/scan/clarify" : "/scan/result")` — both arms — and
|
|
398
|
+
the splash screen's <Redirect>, and reported five screens as unbuilt that
|
|
399
|
+
had been built for weeks. A false POSITIVE here costs a declared edge; a
|
|
400
|
+
false negative costs the trust in the whole check.
|
|
401
|
+
"""
|
|
402
|
+
out = set()
|
|
403
|
+
for m in re.finditer(NAV_CALL, src):
|
|
404
|
+
# Balance the parens rather than stopping at the first one. The
|
|
405
|
+
# non-greedy `(.*?)\)` this replaced stopped at the `)` of any call
|
|
406
|
+
# INSIDE the argument, so `router.push(isFat(c) ? A : B)` was read as
|
|
407
|
+
# `isFat(c` and both destinations went unseen.
|
|
408
|
+
i, depth = m.end(), 1
|
|
409
|
+
while i < len(src) and depth:
|
|
410
|
+
depth += (src[i] == '(') - (src[i] == ')')
|
|
411
|
+
i += 1
|
|
412
|
+
out |= route_lits(src[m.end():i])
|
|
413
|
+
for m in re.finditer(r'<Redirect\b[^>]*>', src, re.S):
|
|
414
|
+
out |= route_lits(m.group(0))
|
|
415
|
+
# A route handed to a component as a prop — <Link href>, a settings row's
|
|
416
|
+
# path — is a navigation too; the component makes the call, not this file.
|
|
417
|
+
out |= route_lits(src, r'\b(?:href|path|to)=\{?\s*')
|
|
418
|
+
# ...and a route handed over by NAME: `const bookHref = signedIn ? `/ca/hire…`
|
|
419
|
+
# : `/sign-in…`` and then `href={bookHref}`. The literal is one line up from
|
|
420
|
+
# the prop, and reading only the prop called the Book button a dead end.
|
|
421
|
+
for name in set(re.findall(r'\b(?:href|path|to)=\{\s*([A-Za-z_]\w*)\s*\}', src)):
|
|
422
|
+
for m in re.finditer(r'\bconst\s+%s\s*=(.*?);' % re.escape(name), src, re.S):
|
|
423
|
+
out |= route_lits(m.group(1))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
# How many files import each hook. One means the hook belongs to that file.
|
|
427
|
+
HOOK_USERS = {}
|
|
428
|
+
if not WEB:
|
|
429
|
+
for _f in glob.glob(os.path.join(SRC_DIR, '**', '*.ts*'), recursive=True):
|
|
430
|
+
for _h in set(re.findall(r"""from\s+["']@/(hooks/[^"']+)["']""", open(_f).read())):
|
|
431
|
+
HOOK_USERS[_h] = HOOK_USERS.get(_h, 0) + 1
|
|
432
|
+
|
|
433
|
+
def screen_source(path):
|
|
434
|
+
"""The source that renders one screen.
|
|
435
|
+
|
|
436
|
+
In Expo that is the route file. In Next.js `page.tsx` is usually a shell —
|
|
437
|
+
thirteen lines that mount `<VerifyForm />` — and the form beside it is where
|
|
438
|
+
router.push lives. Reading the shell alone found 42 navigations in a
|
|
439
|
+
109-page site and called the KYC flow a drawing the app never performs. So
|
|
440
|
+
on the web a screen is its folder: every file under it, stopping at any
|
|
441
|
+
folder that is a page of its own.
|
|
442
|
+
"""
|
|
443
|
+
if not WEB:
|
|
444
|
+
# A route is thin: it reads params and mounts one screen component, and
|
|
445
|
+
# that component is where router.push lives. Reading the route alone
|
|
446
|
+
# found 0 navigations in chess and imposter. So in Expo a screen is its
|
|
447
|
+
# route file and the components that file mounts.
|
|
448
|
+
#
|
|
449
|
+
# One step further, and only inside the feature: a screen keeps its
|
|
450
|
+
# state and navigation in a hook and its sections in the same folder,
|
|
451
|
+
# so those are read too. Splitting aakalan's screens that way took it
|
|
452
|
+
# from 33 navigations to 15. A component from ANOTHER folder is not
|
|
453
|
+
# followed — a shared back button is not this screen's navigation —
|
|
454
|
+
# and neither is a hook two files use: imposter's useHomeActions
|
|
455
|
+
# serves Home and History, and reading it into History charged that
|
|
456
|
+
# screen with every route Home can reach.
|
|
457
|
+
def resolve(spec):
|
|
458
|
+
return next((p for p in (os.path.join(SRC_DIR, spec + e) for e in ('.tsx', '.ts'))
|
|
459
|
+
if os.path.isfile(p)), None)
|
|
460
|
+
def imports(src, prefix):
|
|
461
|
+
return re.findall(r"""from\s+["']@/(%s[^"']+)["']""" % prefix, src)
|
|
462
|
+
src, seen = open(path).read(), []
|
|
463
|
+
for spec in imports(src, 'components/'):
|
|
464
|
+
hit = resolve(spec)
|
|
465
|
+
if not hit or hit in seen: continue
|
|
466
|
+
seen.append(hit)
|
|
467
|
+
inner = open(hit).read()
|
|
468
|
+
feature = os.path.dirname(spec) + '/'
|
|
469
|
+
own = [h for h in imports(inner, 'hooks/') if HOOK_USERS.get(h, 0) == 1]
|
|
470
|
+
for more in own + imports(inner, feature):
|
|
471
|
+
deep = resolve(more)
|
|
472
|
+
if deep and deep not in seen: seen.append(deep)
|
|
473
|
+
return '\n'.join([src] + [open(p).read() for p in seen])
|
|
474
|
+
here, parts = os.path.dirname(path), []
|
|
475
|
+
for root, dirs, fs in os.walk(here):
|
|
476
|
+
if root != here and 'page.tsx' in fs:
|
|
477
|
+
dirs[:] = []
|
|
478
|
+
continue
|
|
479
|
+
parts += [open(os.path.join(root, f)).read() for f in sorted(fs) if f.endswith(('.tsx', '.ts'))]
|
|
480
|
+
return '\n'.join(parts)
|
|
481
|
+
|
|
482
|
+
# A screen navigates through its components too, and through the tab bar.
|
|
483
|
+
# WaysInTiles is where Today reaches search and barcode; (tabs)/_layout is where
|
|
484
|
+
# every tab screen reaches every other one and the shutter. Attributing those to
|
|
485
|
+
# the file that happens to contain the call would mean the chart could never
|
|
486
|
+
# describe a tab bar at all.
|
|
487
|
+
shared_targets = set()
|
|
488
|
+
KNOWN_ROUTES = {'/' + app_route(r) for r in owners}
|
|
489
|
+
for pattern in ('components/**/*.tsx', 'components/**/*.ts', 'hooks/**/*.ts'):
|
|
490
|
+
for path in glob.glob(os.path.join(SRC_DIR, pattern), recursive=True):
|
|
491
|
+
src_shared = strip_comments(open(path).read())
|
|
492
|
+
shared_targets |= routes_in(src_shared)
|
|
493
|
+
# Any route-shaped literal, because a component that names one is going
|
|
494
|
+
# there — useGo says `go("/food/search")` and hands the result to
|
|
495
|
+
# router.push, which reading router calls alone will never see.
|
|
496
|
+
shared_targets |= {t for t in re.findall(r'["\'`](/[a-z(][^"\'`$]*)', src_shared)
|
|
497
|
+
if '/' + app_route(t) in KNOWN_ROUTES}
|
|
498
|
+
# The tab group sits at the top of app/ in a small app and inside an auth group
|
|
499
|
+
# in a larger one — `(app)/(tabs)/` — so it is found, not assumed.
|
|
500
|
+
TABS_LAYOUT = next(iter(sorted(glob.glob(os.path.join(APP_DIR, '**', '(tabs)', '_layout.tsx'), recursive=True))),
|
|
501
|
+
os.path.join(APP_DIR, '(tabs)/_layout.tsx'))
|
|
502
|
+
TABS_DIR = os.path.dirname(TABS_LAYOUT)
|
|
503
|
+
TABS_REL = os.path.relpath(TABS_DIR, APP_DIR)
|
|
504
|
+
tab_targets = routes_in(strip_comments(open(TABS_LAYOUT).read())) if os.path.isfile(TABS_LAYOUT) else set()
|
|
505
|
+
# Every screen directly in (tabs)/ is a tab, reachable from every other one. A
|
|
506
|
+
# tab that owns a stack of its own is a FOLDER there, entered at its index.
|
|
507
|
+
tab_routes = {'/(tabs)/' + os.path.splitext(f)[0] for f in os.listdir(TABS_DIR)
|
|
508
|
+
if (f.endswith('.tsx') and not f.startswith('_'))
|
|
509
|
+
or os.path.isfile(os.path.join(TABS_DIR, f, 'index.tsx'))} if os.path.isdir(TABS_DIR) else set()
|
|
510
|
+
|
|
511
|
+
nav_n, undeclared = 0, []
|
|
512
|
+
for path in sorted(glob.glob(os.path.join(APP_DIR, '**/*.tsx'), recursive=True)):
|
|
513
|
+
rel = os.path.relpath(path, APP_DIR)
|
|
514
|
+
froms = owners.get(rel, [])
|
|
515
|
+
if not froms: continue
|
|
516
|
+
targets = routes_in(strip_guards(strip_comments(screen_source(path))))
|
|
517
|
+
for t in sorted(targets):
|
|
518
|
+
tos = [f for r, fs in owners.items() if app_route(r) == app_route(t) for f in fs]
|
|
519
|
+
if not tos: continue
|
|
520
|
+
nav_n += 1
|
|
521
|
+
if not any(e['from'] in froms and e['to'] in tos for e in R['flow']['edges']):
|
|
522
|
+
undeclared.append('%s -> %s (%s navigates to %s)' % (froms[0], tos[0], rel, t))
|
|
523
|
+
recorded_flow = ' '.join((m.get('what', '') + ' ' + m.get('why', ''))
|
|
524
|
+
for m in (R.get('missing') or []))
|
|
525
|
+
for u in sorted(set(undeclared)):
|
|
526
|
+
# Same rule check 8 uses: a deliberate departure is fine and must be STATED.
|
|
527
|
+
key = u.split(' (')[0]
|
|
528
|
+
if all(part in recorded_flow for part in key.split(' -> ')):
|
|
529
|
+
print(' pending: %s — recorded in `missing`' % key)
|
|
530
|
+
else:
|
|
531
|
+
bad(u)
|
|
532
|
+
|
|
533
|
+
# ---- and the other direction ------------------------------------------------
|
|
534
|
+
# The check above finds navigations the chart does not know about. This finds
|
|
535
|
+
# edges the chart promises and the app never performs, which is the half that
|
|
536
|
+
# let onboarding/welcome's "see what costs money" link to the paywall sit in the
|
|
537
|
+
# design, and nowhere in the app, through every previous pass.
|
|
538
|
+
#
|
|
539
|
+
# FORWARD edges only. A `back` is usually the system gesture or a header control
|
|
540
|
+
# that resolves to router.back(), a `side` is often a tab bar, and a `state` is
|
|
541
|
+
# reached by circumstance — none of those appear as a literal route in the
|
|
542
|
+
# source, and demanding them would be demanding the wrong thing.
|
|
543
|
+
performed = set()
|
|
544
|
+
for path in sorted(glob.glob(os.path.join(APP_DIR, '**/*.tsx'), recursive=True)):
|
|
545
|
+
rel = os.path.relpath(path, APP_DIR)
|
|
546
|
+
froms = owners.get(rel, [])
|
|
547
|
+
if not froms: continue
|
|
548
|
+
reach = routes_in(strip_comments(screen_source(path))) | shared_targets
|
|
549
|
+
# THE TAB BAR IS ON EVERY TAB SCREEN. It navigates by route NAME rather than
|
|
550
|
+
# by href, so no amount of reading for a "/..." string will find it — and
|
|
551
|
+
# the chart is right to draw those edges, because a user really can tap
|
|
552
|
+
# Trends from Today. Every tab and the shutter, available from all of them.
|
|
553
|
+
if rel.startswith(TABS_REL + '/'):
|
|
554
|
+
reach |= tab_targets | tab_routes
|
|
555
|
+
for f in froms:
|
|
556
|
+
for t in reach:
|
|
557
|
+
performed.add((f, app_route(t)))
|
|
558
|
+
|
|
559
|
+
unbuilt = not_built = 0
|
|
560
|
+
for e in R['flow']['edges']:
|
|
561
|
+
if e.get('back') or e.get('side') or e.get('state'): continue
|
|
562
|
+
src_route = (files.get(e['from']) or {}).get('route')
|
|
563
|
+
dst_route = (files.get(e['to']) or {}).get('route')
|
|
564
|
+
if not src_route or not dst_route: continue # a screen with no app yet
|
|
565
|
+
# A correction returns to the screen it was pushed from, which then renders
|
|
566
|
+
# the corrected state. There is no literal route for that and there should
|
|
567
|
+
# not be, so the edge says so rather than the check pretending not to notice.
|
|
568
|
+
if e.get('viaBack'): continue
|
|
569
|
+
key = '%s -> %s' % (e['from'], e['to'])
|
|
570
|
+
# THROUGH A FILE THE SET DOES NOT DRAW. Consent leads to the plan picker by
|
|
571
|
+
# way of a details form nobody drew, and a verified sign-in reaches consent
|
|
572
|
+
# because the root layout's gate sends it there — the screen itself only
|
|
573
|
+
# ever asks for "/". Neither is a navigation `from` performs, and both are
|
|
574
|
+
# true of the app, so the edge names the file in between and BOTH hops are
|
|
575
|
+
# read out of the source: `from` reaches it, and it reaches `to`. A layout
|
|
576
|
+
# wraps the screen rather than being navigated to, so it owes only the
|
|
577
|
+
# second hop.
|
|
578
|
+
via = e.get('via')
|
|
579
|
+
if via:
|
|
580
|
+
via_path = os.path.join(APP_DIR, via)
|
|
581
|
+
via_reach = ({app_route(t) for t in routes_in(strip_comments(screen_source(via_path)))}
|
|
582
|
+
if os.path.isfile(via_path) else set())
|
|
583
|
+
wraps = os.path.basename(via) in ('_layout.tsx', 'layout.tsx')
|
|
584
|
+
if app_route(dst_route) in via_reach and (wraps or (e['from'], app_route(via)) in performed):
|
|
585
|
+
continue
|
|
586
|
+
unbuilt += 1
|
|
587
|
+
bad('%s is drawn as going through src/app/%s, and the app does not go that way' % (key, via))
|
|
588
|
+
continue
|
|
589
|
+
# Two faces of one route — a countdown becoming the round, a modal over the
|
|
590
|
+
# screen that opened it — change state, not route. No call can exist.
|
|
591
|
+
if app_route(src_route) == app_route(dst_route): continue
|
|
592
|
+
if (e['from'], app_route(dst_route)) not in performed:
|
|
593
|
+
unbuilt += 1
|
|
594
|
+
# The same rule as the other direction, and as check 8: a departure is
|
|
595
|
+
# fine and must be STATED. Here the set and the app part ways — it draws
|
|
596
|
+
# a step the app has not built, or takes by another road — and `missing`
|
|
597
|
+
# is where the gallery prints that. The entry has to name this edge
|
|
598
|
+
# exactly, so one sentence cannot excuse two.
|
|
599
|
+
if any(m.get('what') == key for m in (R.get('missing') or [])):
|
|
600
|
+
not_built += 1
|
|
601
|
+
print(' pending: %s is drawn and the app does not go there — recorded in `missing`' % key)
|
|
602
|
+
else:
|
|
603
|
+
bad('%s is drawn and the app never navigates there%s'
|
|
604
|
+
% (key, ' (%s)' % e['label'] if e.get('label') else ''))
|
|
605
|
+
|
|
606
|
+
print(' %d navigations checked, %d forward edges honoured%s'
|
|
607
|
+
% (nav_n, sum(1 for e in R['flow']['edges']
|
|
608
|
+
if not (e.get('back') or e.get('side') or e.get('state'))) - unbuilt,
|
|
609
|
+
', %d recorded as departures' % not_built if not_built else ''))
|
|
610
|
+
|
|
611
|
+
# ---- 10. copy — a string on screen is drawn, wherever it is raised from ----
|
|
612
|
+
# The other nine checks compare structure: files, links, edges, routes. None of
|
|
613
|
+
# them can see COPY, and that is how nine Alert.alert calls — four on the money
|
|
614
|
+
# path, one on an irreversible delete — shipped with no wireframe holding a word
|
|
615
|
+
# of them. A dialog drawn by the OS has no route and no layout of ours to get
|
|
616
|
+
# wrong, so it slips past every structural check there is.
|
|
617
|
+
head('copy — every string a dialog or a toast shows appears in the set')
|
|
618
|
+
|
|
619
|
+
# Straight vs curly, entity vs character, one line vs wrapped: the same sentence
|
|
620
|
+
# is written three ways across .tsx and .html, so both sides are flattened before
|
|
621
|
+
# they are compared.
|
|
622
|
+
import html as _html
|
|
623
|
+
def flat(t):
|
|
624
|
+
t = _html.unescape(t)
|
|
625
|
+
for a, b in (('\u2019', "'"), ('\u2018', "'"), ('\u201c', '"'), ('\u201d', '"'),
|
|
626
|
+
('\u2014', '-'), ('\u2013', '-'), ('\u00a0', ' ')):
|
|
627
|
+
t = t.replace(a, b)
|
|
628
|
+
return re.sub(r'\s+', ' ', t).strip()
|
|
629
|
+
|
|
630
|
+
drawn = flat(' '.join(open(f).read() for f in sorted(on_disk)))
|
|
631
|
+
|
|
632
|
+
# An option's `style`, and the mimeType/UTI an onPress hands to the share sheet,
|
|
633
|
+
# sit inside the same parentheses without being copy. A word someone reads either
|
|
634
|
+
# contains a space or is a bare word; "image/png" and "public.png" are neither.
|
|
635
|
+
# Toasts are here for the same reason and were the same story: the set drew their
|
|
636
|
+
# titles in a table with no Detail column, so 12 detail lines — "Nothing was
|
|
637
|
+
# charged", "Your scans are back" — were undrawn, and one title was missing.
|
|
638
|
+
# A browser has no Alert.alert: the site raises its own toast() and the
|
|
639
|
+
# browser's confirm box, and their words are as undrawn as a native dialog's.
|
|
640
|
+
RAISERS = re.compile(r'(?:\btoast|window\.confirm)\(' if WEB else r'(?:Alert\.alert|showToast)\(')
|
|
641
|
+
KEYWORDS = {'cancel', 'destructive', 'default', 'plain',
|
|
642
|
+
'success', 'error', 'warning', 'warn', 'info', 'ask', 'neutral'}
|
|
643
|
+
STR = re.compile(r'"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\'')
|
|
644
|
+
# `outcome.status === 'invalid-vpa'` sits inside the call and is a code, not copy.
|
|
645
|
+
# So is the name of a callable an option's onPress invokes — "removeFamilyMember"
|
|
646
|
+
# is one bare word, which is otherwise exactly what a button label looks like.
|
|
647
|
+
CODE = re.compile(r'[a-z0-9]+(?:-[a-z0-9]+)+|[a-z]+(?:[A-Z][a-z0-9]*)+')
|
|
648
|
+
|
|
649
|
+
# AN APP THAT TRANSLATES RAISES NO LITERALS. charades writes
|
|
650
|
+
# `Alert.alert(t("history.clearTitle"), t("history.clearBody"))`, and a key has
|
|
651
|
+
# a dot in it and no space, so it was never copy: "0 dialog and toast strings
|
|
652
|
+
# checked, all drawn" for an app with a confirm on an irreversible delete. The
|
|
653
|
+
# key is resolved through the English dictionary — the one the other languages
|
|
654
|
+
# fall back to — and the sentence it names is what gets checked.
|
|
655
|
+
I18N = {}
|
|
656
|
+
I18N_FILE = os.path.join(SRC_DIR, 'i18n', 'en.ts')
|
|
657
|
+
if os.path.isfile(I18N_FILE):
|
|
658
|
+
for key, double, single in re.findall(
|
|
659
|
+
r'["\']([\w.-]+)["\']\s*:\s*(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\')',
|
|
660
|
+
open(I18N_FILE).read()):
|
|
661
|
+
I18N[key] = double or single
|
|
662
|
+
T_CALL = re.compile(r'\bt\(\s*["\']([\w.-]+)["\']\s*\)')
|
|
663
|
+
def translated(call):
|
|
664
|
+
"""The call with every `t("key")` replaced by the sentence it resolves to."""
|
|
665
|
+
return T_CALL.sub(lambda m: json.dumps(I18N[m.group(1)]) if m.group(1) in I18N else m.group(0), call)
|
|
666
|
+
# A template literal is not read for copy — it is mostly interpolation — but it
|
|
667
|
+
# has to be REMOVED before single-quoted strings are, or the apostrophe in
|
|
668
|
+
# `${x} isn't valid` opens a string that runs to the next one.
|
|
669
|
+
TEMPLATE = re.compile(r'`(?:[^`\\]|\\.)*`')
|
|
670
|
+
def is_copy(t):
|
|
671
|
+
if t.lower() in KEYWORDS or not re.search(r'[A-Za-z]', t) or len(t) < 2:
|
|
672
|
+
return False
|
|
673
|
+
if CODE.fullmatch(t):
|
|
674
|
+
return False
|
|
675
|
+
return ' ' in t or not re.search(r'[/:._]', t)
|
|
676
|
+
|
|
677
|
+
dialog_n, before = 0, fail
|
|
678
|
+
if not os.path.isdir(app_dir):
|
|
679
|
+
print(' skipped — no app at %s' % app_dir)
|
|
680
|
+
else:
|
|
681
|
+
for root, _dirs, fs in os.walk(app_dir if False else os.path.join(app_dir, '..')):
|
|
682
|
+
for fn in sorted(fs):
|
|
683
|
+
if not fn.endswith(('.tsx', '.ts')):
|
|
684
|
+
continue
|
|
685
|
+
path = os.path.join(root, fn)
|
|
686
|
+
src = open(path).read()
|
|
687
|
+
for m in RAISERS.finditer(src):
|
|
688
|
+
i, depth = m.end() - 1, 0
|
|
689
|
+
while i < len(src):
|
|
690
|
+
if src[i] == '(': depth += 1
|
|
691
|
+
elif src[i] == ')':
|
|
692
|
+
depth -= 1
|
|
693
|
+
if depth == 0: break
|
|
694
|
+
i += 1
|
|
695
|
+
call = src[m.end():i]
|
|
696
|
+
rel = os.path.relpath(path, os.path.join(app_dir, '..'))
|
|
697
|
+
for double, single in STR.findall(TEMPLATE.sub('', translated(call))):
|
|
698
|
+
lit = double or single
|
|
699
|
+
piece = flat(lit.replace('\\"', '"').replace("\\'", "'"))
|
|
700
|
+
if not is_copy(piece): continue
|
|
701
|
+
dialog_n += 1
|
|
702
|
+
if piece not in drawn:
|
|
703
|
+
bad('src/%s puts "%s" on screen and no wireframe draws it'
|
|
704
|
+
% (rel, piece if len(piece) < 60 else piece[:57] + '...'))
|
|
705
|
+
print(' %d dialog and toast strings checked%s'
|
|
706
|
+
% (dialog_n, ', all drawn' if fail == before else ', %d undrawn' % (fail - before)))
|
|
707
|
+
|
|
708
|
+
print('\n' + ('ALL CHECKS PASS' if not fail else '%d FAILURES' % fail))
|
|
709
|
+
sys.exit(1 if fail else 0)
|