Repository navigation
Expand file tree
/
Copy pathcheck-source-conventions.ts
More file actions
1021 lines (949 loc) · 41.8 KB
/
Copy pathcheck-source-conventions.ts
File metadata and controls
1021 lines (949 loc) · 41.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bun
/**
* Ratchets written source conventions that no other check enforces, file by file, so an agent
* copying a nearby violation gets a failing check instead of a precedent.
*
* - `relative-import` (`.claude/rules/sim-imports.md`): an `apps/sim` module imports, re-exports,
* dynamically imports or `require`s a relative path. The one exception is a barrel `index.ts(x)`
* re-exporting from inside its own folder, written either as `export { X } from './x'` or as an
* import whose every binding the barrel then exports (`export { X }`, or renamed with
* `export const Y = X`). A path that leaves the folder (`../x`,
* `./../x`), a side-effect import, and an import the barrel keeps for itself are all flagged.
* A verbatim Mothership contract copy in `lib/mothership/generated/` is exempt: its imports mirror
* the worker source it is synced from, so it cannot use the app's aliases.
* - `text-size-literal` (`.claude/rules/sim-styling.md` "Tailwind" 3): an `apps/sim` string or
* template literal sets type size with `text-[Npx]` where the documented text scale has a token
* for that size (`text-[13px]` → `text-small`). The literal also drops the token's line-height.
* Sizes the scale does not cover (display headings such as `text-[32px]`) are a design-system
* gap, not a rename, so they are not counted. Comments are not code and are not counted.
* - `static-inline-style` (`sim-styling.md` "Tailwind" 1): a JSX `style={{ … }}` whose every
* property is a literal that is not a CSS variable, so the whole object could be classes. A
* style with any dynamic value, spread, computed key or custom property is left alone, and so is
* a file that renders without the Tailwind stylesheet (react-email templates, `next/og` images).
* - `api-db-import` (CLAUDE.md "Application Operation Boundary"): a module under `app/api/`
* imports a value from `@sim/db`. Surface adapters never query protected data; the query belongs
* in an application use case or manager. Type-only imports are allowed.
* - `bare-route-handler` (CLAUDE.md "API Contracts and Routes"): an `app/api/**` `route.ts` HTTP
* verb export is a handler body written in the route file — a function declaration, a function
* expression, an async or block-bodied arrow, or an expression arrow that does not delegate to a
* wrapped handler — directly or through a local alias. Allowed: the result of a call (a route
* builder, `withRouteHandler`, or a factory that composes them in another module), an alias or
* member of such a result, and a synchronous expression arrow that delegates to one, such as
* `(req, ctx) => withSpan(…, () => handler(req, ctx))`. Whether a factory in another module
* applies `withRouteHandler` is that module's concern; this rule only sees the route file.
* - `log-message-tag` (CLAUDE.md "Logging"): a `@sim/logger` message opens with an interpolated tag
* (`[${requestId}] Started`, also after static tags such as `[Cascade] [${id}]`) or interpolates a
* request id anywhere (`Polling failed (${requestId})`). Correlation ids belong in metadata, where
* queries can filter on them, and a message whose text varies per request cannot be grouped. The
* request context already stamps `requestId` on every line inside `withRouteHandler` and the
* background tasks that bind one; elsewhere pass it as a field. A bracketed list mid-message
* (`columns [${ids.join(', ')}]`) is not a tag. Unlike the rules above, this one covers every
* `apps/*` and `packages/*` module that imports `@sim/logger`, not only `apps/sim`.
*
* Existing hits are counted per rule and file in `scripts/check-source-conventions.baseline.json`.
* A file whose count rises, or that gains its first hit, fails. A count that drops also fails until
* the baseline is rewritten, so the baseline only shrinks and a fix cannot be spent on a new hit in
* the same file. Regenerate after removing hits: `bun run scripts/check-source-conventions.ts --update`.
* A rule added to this file later is admitted once with `--update --init-rule <rule>`, which only
* works while the baseline has no entry for that rule at all.
*
* Run: `bun run check:source-conventions`
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from '@babel/parser'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const BASELINE = path.join(ROOT, 'scripts/check-source-conventions.baseline.json')
const RULES = [
'relative-import',
'text-size-literal',
'static-inline-style',
'api-db-import',
'bare-route-handler',
'log-message-tag',
] as const
export type Rule = (typeof RULES)[number]
type Counts = Record<string, number>
export type Baseline = Partial<Record<Rule, Counts>>
export type CurrentCounts = Record<Rule, Counts>
interface Violation {
rule: Rule
file: string
line: number
detail: string
}
export interface CountChange {
rule: Rule
file: string
count: number
allowed: number
}
const HOW_TO_FIX: Record<Rule, string> = {
'relative-import':
'Import by absolute path (`@/…` or the `@sim/*` package); only a barrel index.ts may re-export its own folder relatively (.claude/rules/sim-imports.md).',
'text-size-literal':
'Replace the literal with the token named on each line; type size is always a named token (.claude/rules/sim-styling.md "Text Scale").',
'static-inline-style':
'Every value in this style object is static: replace it with Tailwind classes (on an SVG element, its presentation attribute, e.g. `stopColor=`). Inline `style` is only for a genuinely dynamic value or a CSS variable (.claude/rules/sim-styling.md "Tailwind" 1).',
'api-db-import':
'Move the query into an application use case or manager and call it from the route; surface adapters never query protected data (CLAUDE.md "Application Operation Boundary"). Type-only imports are fine.',
'bare-route-handler':
'Export the verb as the result of a shared route builder (`defineInternalJsonRoute`, `defineV2JsonRoute`, …) or `withRouteHandler`, never a bare handler function (CLAUDE.md "API Contracts and Routes").',
'log-message-tag':
'Keep the message static and move the id into metadata: `logger.info(\'Started\', { requestId })`. Inside `withRouteHandler`, a `generateRequestId()` id is already on every line, so drop it (CLAUDE.md "Logging").',
}
const HTTP_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'])
/** A type-size arbitrary value, alone or behind variants (`md:`, `!`). Colors and vars don't match. */
const TEXT_SIZE_LITERAL = /(?:^|[\s'"`:!{(])text-\[(\d+(?:\.\d+)?)px\]/g
/** The documented scale (`sim-styling.md` "Text Scale"), from the `@theme` block in globals.css. */
const TEXT_SIZE_TOKENS: Record<string, string> = {
'10': 'text-micro',
'11': 'text-xs',
'12': 'text-caption',
'13': 'text-small',
'14': 'text-sm',
'15': 'text-base',
'16': 'text-md',
'18': 'text-lg',
}
/** Imports that mean the file renders without the app stylesheet, so inline style is the only option. */
const NO_TAILWIND_RENDERERS = /^(?:@react-email\/|next\/og$)/
/** Wrappers that leave the wrapped expression's value unchanged. */
const TRANSPARENT_WRAPPERS = new Set([
'ParenthesizedExpression',
'TSAsExpression',
'TSSatisfiesExpression',
'TSNonNullExpression',
'TSTypeAssertion',
])
type Node = { type: string; loc?: { start: { line: number } }; [key: string]: unknown }
const LOG_LEVELS = new Set(['debug', 'info', 'warn', 'error'])
/** Names a logger is bound to: `logger`, `log`, `log2`, `routeLogger`, `childLog`, `this.logger`. */
const LOGGER_NAME = /^(?:log|logger|[a-z][A-Za-z0-9]*(?:Log|Logger))\d*$/
/** Helpers that log a message for their caller, handed its logger first: `logFailureOnce`. */
const LOGGING_HELPER = /^log[A-Z]/
/** Message text before its first interpolation that leaves a tag open: `[`, `[Cascade] [`. */
const OPEN_TAG = /^(?:\[[^\]]*\]\s*)*\[$/
const REQUEST_ID_NAMES = new Set(['requestId', 'reqId'])
const FUNCTION_NODES = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
'ObjectMethod',
'ClassMethod',
'ClassPrivateMethod',
])
function walk(node: unknown, visit: (node: Node) => void): void {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) walk(child, visit)
return
}
const current = node as Node
if (typeof current.type === 'string') visit(current)
for (const [key, value] of Object.entries(current)) {
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue
walk(value, visit)
}
}
function parseSource(file: string, source: string): Node {
try {
return parse(source, {
sourceType: 'module',
plugins: [
'typescript',
...(file.endsWith('x') ? (['jsx'] as const) : []),
'topLevelAwait',
'decorators',
],
errorRecovery: true,
}).program as unknown as Node
} catch (error) {
throw new Error(`${file}: cannot parse, so its conventions cannot be counted: ${error}`)
}
}
function lineOf(node: Node): number {
return node.loc?.start.line ?? 0
}
function unwrap(node: Node | undefined): Node | undefined {
let current = node
while (current && TRANSPARENT_WRAPPERS.has(current.type)) current = current.expression as Node
return current
}
function stringValue(node: unknown): string | undefined {
const candidate = node as Node | undefined
if (candidate?.type === 'StringLiteral') return candidate.value as string
if (candidate?.type === 'TemplateLiteral' && (candidate.expressions as unknown[]).length === 0) {
return ((candidate.quasis as Node[])[0].value as { cooked: string }).cooked
}
return undefined
}
function isRelative(specifier: string | undefined): specifier is string {
return specifier !== undefined && (specifier.startsWith('./') || specifier.startsWith('../'))
}
/** `./x` and `./a/b` stay inside the importing file's folder; `../x` and `./../x` leave it. */
function staysInFolder(specifier: string): boolean {
const normalized = path.posix.normalize(specifier)
return normalized !== '..' && !normalized.startsWith('../')
}
function isBarrel(file: string): boolean {
return /(?:^|\/)index\.tsx?$/.test(file)
}
/** The specifier of `import('x')` or `require('x')`, in either AST shape Babel produces. */
function dynamicSpecifier(node: Node): string | undefined {
if (node.type === 'ImportExpression') return stringValue(node.source)
if (node.type !== 'CallExpression') return undefined
const callee = node.callee as Node
const isImportOrRequire =
callee.type === 'Import' || (callee.type === 'Identifier' && callee.name === 'require')
return isImportOrRequire ? stringValue((node.arguments as Node[])[0]) : undefined
}
/** The specifier of a static `import`, `import x = require()`, or `export … from`. */
function declarationSpecifier(node: Node): string | undefined {
if (node.type === 'TSImportEqualsDeclaration') {
return stringValue((node.moduleReference as Node | undefined)?.expression)
}
const isModuleDeclaration =
node.type === 'ImportDeclaration' ||
node.type === 'ExportNamedDeclaration' ||
node.type === 'ExportAllDeclaration'
return isModuleDeclaration ? stringValue(node.source) : undefined
}
/**
* Local names a module passes straight through: `export { a, b as c }`, `export default a`, and the
* renaming alias `export const c = a`.
*/
function exportedLocals(program: Node): Set<string> {
const names = new Set<string>()
for (const statement of program.body as Node[]) {
if (statement.type === 'ExportNamedDeclaration' && !statement.source) {
for (const specifier of (statement.specifiers as Node[] | undefined) ?? []) {
const local = specifier.local as Node | undefined
if (local?.type === 'Identifier') names.add(local.name as string)
}
const declaration = statement.declaration as Node | null
if (declaration?.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations as Node[]) {
const init = unwrap(declarator.init as Node | undefined)
if (init?.type === 'Identifier') names.add(init.name as string)
}
}
}
if (statement.type === 'ExportDefaultDeclaration') {
const declaration = statement.declaration as Node
if (declaration.type === 'Identifier') names.add(declaration.name as string)
}
}
return names
}
/** A barrel line that only passes a sibling module through: the one allowed relative form. */
function isBarrelReExport(node: Node, specifier: string, reExported: Set<string>): boolean {
if (!staysInFolder(specifier)) return false
if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') return true
if (node.type !== 'ImportDeclaration') return false
const specifiers = node.specifiers as Node[]
return (
specifiers.length > 0 &&
specifiers.every((imported) => reExported.has((imported.local as Node).name as string))
)
}
/** The specifier of a type-position `import('./x').T`. */
function importTypeSpecifier(node: Node): string | undefined {
if (node.type !== 'TSImportType') return undefined
const argument = node.argument as Node
return stringValue(argument.type === 'TSLiteralType' ? argument.literal : argument)
}
function findRelativeImports(file: string, program: Node): Violation[] {
const violations: Violation[] = []
const reExported = isBarrel(file) ? exportedLocals(program) : undefined
walk(program, (node) => {
const declared = declarationSpecifier(node)
const specifier = declared ?? dynamicSpecifier(node) ?? importTypeSpecifier(node)
if (!isRelative(specifier)) return
if (reExported && isBarrelReExport(node, specifier, reExported)) return
violations.push({ rule: 'relative-import', file, line: lineOf(node), detail: specifier })
})
return violations
}
/** Every string and template chunk in the file with its first line; comments are not included. */
function stringChunks(program: Node): Array<{ text: string; line: number }> {
const chunks: Array<{ text: string; line: number }> = []
walk(program, (node) => {
if (node.type === 'StringLiteral') {
chunks.push({ text: node.value as string, line: lineOf(node) })
} else if (node.type === 'TemplateElement') {
chunks.push({ text: (node.value as { raw: string }).raw, line: lineOf(node) })
}
})
return chunks
}
function findTextSizeLiterals(file: string, program: Node): Violation[] {
const violations: Violation[] = []
for (const { text, line } of stringChunks(program)) {
for (const match of text.matchAll(TEXT_SIZE_LITERAL)) {
const size = String(Number(match[1]))
const token = TEXT_SIZE_TOKENS[size]
if (!token) continue
const linesBefore = text.slice(0, match.index).split('\n').length - 1
violations.push({
rule: 'text-size-literal',
file,
line: line + linesBefore,
detail: `text-[${match[1]}px] → ${token}`,
})
}
}
return violations
}
/** A value that is the same on every render and is not a CSS variable reference. */
function isStaticNonVariableValue(node: Node): boolean {
if (node.type === 'NumericLiteral' || node.type === 'BooleanLiteral') return true
if (
node.type === 'UnaryExpression' &&
node.operator === '-' &&
(node.argument as Node).type === 'NumericLiteral'
) {
return true
}
const text = stringValue(node)
return text !== undefined && !text.includes('var(')
}
function propertyKeyName(property: Node): string | undefined {
if (property.computed) return undefined
const key = property.key as Node
if (key.type === 'Identifier') return key.name as string
return stringValue(key)
}
function isFullyStaticStyle(object: Node): boolean {
const properties = object.properties as Node[]
if (properties.length === 0) return false
return properties.every((property) => {
if (property.type !== 'ObjectProperty') return false
const name = propertyKeyName(property)
if (name === undefined || name.startsWith('--')) return false
const value = unwrap(property.value as Node)
return value !== undefined && isStaticNonVariableValue(value)
})
}
function findStaticInlineStyles(file: string, program: Node): Violation[] {
const rendersWithoutTailwind = (program.body as Node[]).some(
(statement) =>
statement.type === 'ImportDeclaration' &&
NO_TAILWIND_RENDERERS.test(stringValue(statement.source) ?? '')
)
if (rendersWithoutTailwind) return []
const violations: Violation[] = []
walk(program, (node) => {
if (node.type !== 'JSXAttribute') return
const name = node.name as Node
if (name.type !== 'JSXIdentifier' || name.name !== 'style') return
const value = node.value as Node | null
if (value?.type !== 'JSXExpressionContainer') return
const expression = unwrap(value.expression as Node)
if (expression?.type === 'ObjectExpression' && isFullyStaticStyle(expression)) {
violations.push({ rule: 'static-inline-style', file, line: lineOf(node), detail: 'style' })
}
})
return violations
}
function isDbModule(specifier: string | undefined): specifier is string {
return specifier === '@sim/db' || (specifier?.startsWith('@sim/db/') ?? false)
}
function isTypeOnlyDeclaration(node: Node): boolean {
if (node.importKind === 'type' || node.exportKind === 'type') return true
const specifiers = (node.specifiers as Node[] | undefined) ?? []
return (
specifiers.length > 0 &&
specifiers.every(
(specifier) => specifier.importKind === 'type' || specifier.exportKind === 'type'
)
)
}
function findApiDbImports(file: string, program: Node): Violation[] {
const violations: Violation[] = []
walk(program, (node) => {
const declared = declarationSpecifier(node)
const specifier = declared ?? dynamicSpecifier(node)
if (!isDbModule(specifier)) return
if (declared !== undefined && isTypeOnlyDeclaration(node)) return
violations.push({ rule: 'api-db-import', file, line: lineOf(node), detail: specifier })
})
return violations
}
/** Top-level function declarations and `const`/`let` declarators by name. */
function topLevelBindings(program: Node): Map<string, Node> {
const bindings = new Map<string, Node>()
for (const statement of program.body as Node[]) {
const declaration =
statement.type === 'ExportNamedDeclaration'
? (statement.declaration as Node | null)
: statement
if (declaration?.type === 'FunctionDeclaration' && declaration.id) {
bindings.set((declaration.id as Node).name as string, declaration)
}
if (declaration?.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations as Node[]) {
const id = declarator.id as Node
if (id.type === 'Identifier') bindings.set(id.name as string, declarator)
}
}
}
return bindings
}
/**
* Classifies a route verb's value. A handler is `wrapped` when it is the result of a call, or an
* alias or member of one; `bare` when it is a function written in this file (directly or through
* an alias); `opaque` when it comes from outside the file (an import), which this rule cannot see.
*/
function classifyHandler(
value: Node | undefined,
bindings: Map<string, Node>,
seen: Set<string> = new Set()
): 'wrapped' | 'bare' | 'opaque' {
const node = unwrap(value)
if (!node) return 'opaque'
switch (node.type) {
case 'CallExpression':
return 'wrapped'
case 'FunctionExpression':
return 'bare'
case 'ArrowFunctionExpression':
if (node.async || (node.body as Node).type === 'BlockStatement') return 'bare'
return delegatesToWrapped(node.body as Node, bindings, seen) ? 'wrapped' : 'bare'
case 'ConditionalExpression': {
const branches = [node.consequent, node.alternate].map((branch) =>
classifyHandler(branch as Node, bindings, new Set(seen))
)
return branches.includes('bare')
? 'bare'
: branches.includes('wrapped')
? 'wrapped'
: 'opaque'
}
case 'MemberExpression': {
const member = objectLiteralMember(node, bindings)
if (member === undefined) return classifyHandler(node.object as Node, bindings, seen)
if (member === 'unknown' || member.type === 'ObjectMethod') return 'bare'
return classifyHandler(member.value as Node, bindings, seen)
}
case 'Identifier': {
const name = node.name as string
const binding = bindings.get(name)
if (!binding || seen.has(name)) return 'opaque'
seen.add(name)
if (binding.type === 'FunctionDeclaration') return 'bare'
return classifyHandler(binding.init as Node | undefined, bindings, seen)
}
default:
return 'bare'
}
}
/**
* The property `handlers.GET` selects when `handlers` is a local object literal, so the selected
* value is classified rather than the object. Later entries win, as they do at runtime; a spread
* or computed key after the last match (or with no match) could supply the value, so the member is
* `unknown` and cannot be shown to be wrapped. Undefined when the object is not a local literal.
*/
function objectLiteralMember(
member: Node,
bindings: Map<string, Node>
): Node | 'unknown' | undefined {
const object = unwrap(member.object as Node)
const property = member.property as Node
if (object?.type !== 'Identifier' || member.computed || property.type !== 'Identifier') {
return undefined
}
const binding = bindings.get(object.name as string)
const literal = binding?.type === 'VariableDeclarator' ? unwrap(binding.init as Node) : undefined
if (literal?.type !== 'ObjectExpression') return undefined
let selected: Node | 'unknown' = 'unknown'
for (const entry of literal.properties as Node[]) {
const name = entry.type === 'SpreadElement' ? undefined : propertyKeyName(entry)
if (name === undefined) selected = 'unknown'
else if (name === property.name) selected = entry
}
return selected
}
/** Whether an arrow's expression body calls through a local binding that is a wrapped handler. */
function delegatesToWrapped(body: Node, bindings: Map<string, Node>, seen: Set<string>): boolean {
let delegates = false
walk(body, (node) => {
if (delegates || node.type !== 'Identifier') return
const name = node.name as string
const binding = bindings.get(name)
if (binding?.type !== 'VariableDeclarator' || seen.has(name)) return
delegates =
classifyHandler(binding.init as Node | undefined, bindings, new Set([...seen, name])) ===
'wrapped'
})
return delegates
}
function findBareRouteHandlers(file: string, program: Node): Violation[] {
const violations: Violation[] = []
const bindings = topLevelBindings(program)
const report = (node: Node, verb: string) =>
violations.push({ rule: 'bare-route-handler', file, line: lineOf(node), detail: verb })
for (const statement of program.body as Node[]) {
if (statement.type !== 'ExportNamedDeclaration' || statement.source) continue
const declaration = statement.declaration as Node | null
if (declaration?.type === 'FunctionDeclaration') {
const verb = (declaration.id as Node | undefined)?.name as string | undefined
if (verb && HTTP_VERBS.has(verb)) report(statement, verb)
continue
}
if (declaration?.type === 'VariableDeclaration') {
for (const declarator of declaration.declarations as Node[]) {
const id = declarator.id as Node
if (id.type !== 'Identifier' || !HTTP_VERBS.has(id.name as string)) continue
if (classifyHandler(declarator.init as Node | undefined, bindings) === 'bare') {
report(statement, id.name as string)
}
}
continue
}
for (const specifier of (statement.specifiers as Node[] | undefined) ?? []) {
const exported = specifier.exported as Node
const verb =
exported.type === 'Identifier' ? (exported.name as string) : stringValue(exported)
if (!verb || !HTTP_VERBS.has(verb)) continue
if (classifyHandler(specifier.local as Node, bindings) === 'bare') report(statement, verb)
}
}
return violations
}
const CONTRACT_COPY_DIR = 'apps/sim/lib/mothership/generated/'
const CONTRACT_COPY_HEADER =
'// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/'
/** A file `contracts:sync` copies byte for byte from the Mothership worker. */
function isContractCopy(file: string, source: string): boolean {
return file.startsWith(CONTRACT_COPY_DIR) && source.startsWith(CONTRACT_COPY_HEADER)
}
function isMemberExpression(node: Node): boolean {
return node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression'
}
function isCallExpression(node: Node): boolean {
return node.type === 'CallExpression' || node.type === 'OptionalCallExpression'
}
/** `logger` for `logger`, `this.logger` and `a?.logger`; undefined for anything else. */
function boundName(node: Node): string | undefined {
if (node.type === 'Identifier') return node.name as string
const property = node.property as Node | undefined
return isMemberExpression(node) && !node.computed && property?.type === 'Identifier'
? (property.name as string)
: undefined
}
/** A logger by name, or one derived from it with `withMetadata(…)`. */
function isLogger(node: Node | undefined): boolean {
const current = unwrap(node)
if (!current) return false
if (isCallExpression(current)) {
const callee = unwrap(current.callee as Node)
return (
callee !== undefined &&
isMemberExpression(callee) &&
boundName(callee) === 'withMetadata' &&
isLogger(callee.object as Node)
)
}
const name = boundName(current)
return name !== undefined && LOGGER_NAME.test(name)
}
/**
* The message a call logs: the first argument of `logger.info(…)`, `logger?.warn(…)` or
* `logger[level](…)`, or the second of `logFn.call(logger, message)` and of a `log*` helper handed
* the caller's logger, such as `logFailureOnce(logger, message, error)`.
*/
function loggedMessage(call: Node): Node | undefined {
const callee = unwrap(call.callee as Node)
const args = call.arguments as Node[]
if (!callee) return undefined
if (isMemberExpression(callee) && isLogger(callee.object as Node)) {
const level = boundName(callee)
return callee.computed || (level !== undefined && LOG_LEVELS.has(level)) ? args[0] : undefined
}
const handsLogger = isLogger(args[0])
if (isMemberExpression(callee) && boundName(callee) === 'call')
return handsLogger ? args[1] : undefined
return callee.type === 'Identifier' && LOGGING_HELPER.test(callee.name as string) && handsLogger
? args[1]
: undefined
}
/** `${requestId}`, `${ctx.requestId}`, `${requestId ?? 'unknown'}`: an interpolated request id. */
function isRequestIdReference(expression: Node): boolean {
let current = unwrap(expression)
while (
current?.type === 'LogicalExpression' &&
(current.operator === '??' || current.operator === '||')
) {
current = unwrap(current.left as Node)
}
const name = current && boundName(current)
return name !== undefined && REQUEST_ID_NAMES.has(name)
}
/** A message's parts in order: static text as strings, interpolations as nodes. */
function messageParts(message: Node): Array<string | Node> | undefined {
if (message.type === 'TemplateLiteral') {
const quasis = message.quasis as Node[]
const expressions = message.expressions as Node[]
return quasis.flatMap((quasi, index) => {
const text = (quasi.value as { cooked: string | null }).cooked ?? ''
return index < expressions.length ? [text, expressions[index]] : [text]
})
}
if (message.type === 'BinaryExpression' && message.operator === '+') {
const left = messageParts(message.left as Node) ?? [message.left as Node]
const right = messageParts(message.right as Node) ?? [message.right as Node]
return [...left, ...right]
}
const text = stringValue(message)
return text === undefined ? undefined : [text]
}
/**
* Local bindings by function, so a message or prefix built before the call is checked where it is
* logged. A parameter is recorded without a value: it shadows an outer binding of the same name.
*/
type Bindings = Map<string, Node[]>
class MessageResolver {
private readonly bindings = new Map<Node, Bindings>()
/** `let` bindings by function; one reassigned anywhere in the file has no single value to follow. */
private readonly lets = new Map<Node, Set<string>>()
private readonly reassigned = new Set<string>()
constructor(program: Node) {
this.collect(program, program)
}
private declare(scope: Node, name: string, init?: Node): void {
const names = this.bindings.get(scope) ?? new Map<string, Node[]>()
names.set(name, [...(names.get(name) ?? []), ...(init ? [init] : [])])
this.bindings.set(scope, names)
}
private collect(node: unknown, scope: Node): void {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) this.collect(child, scope)
return
}
const current = node as Node
let inner = scope
if (FUNCTION_NODES.has(current.type)) {
inner = current
for (const param of (current.params as Node[]) ?? []) {
if (param.type === 'Identifier') this.declare(inner, param.name as string)
}
}
if (current.type === 'VariableDeclaration' && current.kind !== 'var') {
for (const declarator of current.declarations as Node[]) {
const id = declarator.id as Node
if (id.type === 'Identifier') {
this.declare(scope, id.name as string, (declarator.init as Node | null) ?? undefined)
if (current.kind === 'let') {
this.lets.set(scope, (this.lets.get(scope) ?? new Set()).add(id.name as string))
}
}
}
}
const target = (current.type === 'AssignmentExpression' ? current.left : undefined) as
| Node
| undefined
if (target?.type === 'Identifier') this.reassigned.add(target.name as string)
for (const [key, value] of Object.entries(current)) {
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue
this.collect(value, inner)
}
}
/** What `name` holds where it is used: the nearest enclosing function that declares it wins. */
private resolve(name: string, scopes: Node[]): Node[] {
for (let index = scopes.length - 1; index >= 0; index--) {
const values = this.bindings.get(scopes[index])?.get(name)
if (!values) continue
return this.lets.get(scopes[index])?.has(name) && this.reassigned.has(name) ? [] : values
}
return []
}
/** The message's possible values: both arms of a ternary, and the local binding it names. */
candidates(message: Node, scopes: Node[], seen = new Set<string>()): Node[] {
const current = unwrap(message) ?? message
if (current.type === 'ConditionalExpression') {
return [
...this.candidates(current.consequent as Node, scopes, seen),
...this.candidates(current.alternate as Node, scopes, seen),
]
}
if (current.type === 'Identifier' && !seen.has(current.name as string)) {
seen.add(current.name as string)
return this.resolve(current.name as string, scopes).flatMap((init) =>
this.candidates(init, scopes, seen)
)
}
return [current]
}
/**
* Whether a message opens with an interpolated tag or interpolates a request id. An interpolated
* local binding counts as what it holds, so `${logPrefix}Saved` is checked as the
* `[${requestId}] ` it was built from.
*/
isTagged(message: Node, scopes: Node[], seen: Set<string>): boolean {
const parts = messageParts(message)
if (!parts) return false
const firstInterpolation = parts.findIndex((part) => typeof part !== 'string')
if (firstInterpolation === -1) return false
const lead = parts
.slice(0, firstInterpolation)
.filter((part): part is string => typeof part === 'string')
.join('')
if (OPEN_TAG.test(lead)) return true
return parts.some(
(part) =>
typeof part !== 'string' &&
(isRequestIdReference(part) ||
(part.type === 'Identifier' &&
this.candidates(part, scopes, seen).some(
(candidate) => candidate !== part && this.isTagged(candidate, scopes, seen)
)))
)
}
}
function findLogMessageTags(file: string, source: string, program: Node): Violation[] {
const violations: Violation[] = []
const resolver = new MessageResolver(program)
const visit = (node: unknown, scopes: Node[]): void => {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) {
for (const child of node) visit(child, scopes)
return
}
const current = node as Node
const inner = FUNCTION_NODES.has(current.type) ? [...scopes, current] : scopes
const message = isCallExpression(current) ? loggedMessage(current) : undefined
if (message) {
const seen = new Set<string>()
const tagged = resolver
.candidates(message, inner, seen)
.find((candidate) => resolver.isTagged(candidate, inner, seen))
if (tagged) {
const text = source.slice(tagged.start as number, tagged.end as number).replace(/\s+/g, ' ')
violations.push({
rule: 'log-message-tag',
file,
line: lineOf(current),
detail: text.length > 80 ? `${text.slice(0, 77)}...` : text,
})
}
}
for (const [key, value] of Object.entries(current)) {
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue
visit(value, inner)
}
}
visit(program, [program])
return violations
}
/** Unit, integration and live tests, plus fixtures: they bind real boundaries on purpose. */
function isTestFile(file: string): boolean {
return /\.(?:test|spec|integration)\.tsx?$/.test(file) || file.includes('/__fixtures__/')
}
/** Every convention violation in one file; `file` is repo-relative with forward slashes. */
export function findViolations(file: string, source: string): Violation[] {
if (!/^(?:apps|packages)\//.test(file) || !/\.tsx?$/.test(file) || file.endsWith('.d.ts')) {
return []
}
const inSim = file.startsWith('apps/sim/')
const test = isTestFile(file)
const isApi = file.startsWith('apps/sim/app/api/') && !test
// Cheap textual gates; each is a superset of what its rule can match, and the AST decides.
const wantsRelative = inSim && !isContractCopy(file, source) && /['"`]\.\.?\//.test(source)
const wantsTextSize = inSim && !test && /text-\[\d/.test(source)
const wantsStyle = inSim && !test && file.endsWith('.tsx') && /\bstyle\s*=\s*\{/.test(source)
const wantsDb = isApi && source.includes('@sim/db')
const wantsRoute = isApi && /(?:^|\/)route\.ts$/.test(file)
const wantsLogTag =
!test && source.includes('@sim/logger') && /\[(?:\$\{|['"`]\s*\+)|req(?:uest)?Id/i.test(source)
if (!(wantsRelative || wantsTextSize || wantsStyle || wantsDb || wantsRoute || wantsLogTag)) {
return []
}
const program = parseSource(file, source)
return [
...(wantsRelative ? findRelativeImports(file, program) : []),
...(wantsTextSize ? findTextSizeLiterals(file, program) : []),
...(wantsStyle ? findStaticInlineStyles(file, program) : []),
...(wantsDb ? findApiDbImports(file, program) : []),
...(wantsRoute ? findBareRouteHandlers(file, program) : []),
...(wantsLogTag ? findLogMessageTags(file, source, program) : []),
]
}
/** Files whose count rose past the baseline, and baselined files whose count fell below it. */
export function compareToBaseline(
baseline: Baseline,
current: CurrentCounts
): { raised: CountChange[]; dropped: CountChange[] } {
const raised: CountChange[] = []
const dropped: CountChange[] = []
for (const rule of RULES) {
const before = baseline[rule] ?? {}
const after = current[rule]
for (const [file, count] of Object.entries(after)) {
const allowed = before[file] ?? 0
if (count > allowed) raised.push({ rule, file, count, allowed })
}
for (const [file, allowed] of Object.entries(before)) {
const count = after[file] ?? 0
if (count < allowed) dropped.push({ rule, file, count, allowed })
}
}
return { raised, dropped }
}
/**
* The baseline `--update` writes: every tracked rule shrunk to the current counts, never raised.
* `initRule` admits the current hits of a rule the baseline has never tracked, and nothing else.
*/
export function nextBaseline(
baseline: Baseline,
current: CurrentCounts,
initRule?: string
): { baseline: Baseline } | { errors: string[] } {
if (initRule !== undefined) {
if (!(RULES as readonly string[]).includes(initRule)) {
return {
errors: [`--init-rule ${initRule}: not a rule in this script (${RULES.join(', ')})`],
}
}
if (baseline[initRule as Rule] !== undefined) {
return {
errors: [
`--init-rule ${initRule}: the baseline already tracks this rule, so its debt can only shrink. Fix the new hits instead.`,
],
}
}
}
const errors: string[] = []
const next: Baseline = {}
for (const rule of RULES) {
const before = rule === initRule ? current[rule] : baseline[rule]
if (before === undefined) {
if (Object.keys(current[rule]).length) {
errors.push(
`${rule} is not in the baseline. Admit its existing hits once with --update --init-rule ${rule}.`
)
}
next[rule] = {}
continue
}
const kept: Counts = {}
for (const file of Object.keys(current[rule]).sort()) {
const count = current[rule][file]
const allowed = before[file] ?? 0
if (count > allowed) {
errors.push(
`refused to raise ${rule} ${file}: ${count} (baseline ${allowed}); fix it instead`
)
}
if (Math.min(count, allowed) > 0) kept[file] = Math.min(count, allowed)
}
next[rule] = kept
}
return errors.length ? { errors } : { baseline: next }
}
/** Tracked and untracked-but-not-ignored sources, so a new file is checked before it is staged. */
function sourceFiles(): string[] {
const output = execFileSync(
'git',
['ls-files', '--cached', '--others', '--exclude-standard', '-z', 'apps', 'packages'],
{ cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
)
return [...new Set(output.split('\0'))]
.filter((file) => /\.tsx?$/.test(file) && !file.endsWith('.d.ts'))
.filter((file) => existsSync(path.join(ROOT, file)))
.sort()
}
function collect(): { counts: CurrentCounts; violations: Violation[] } {
const counts = Object.fromEntries(RULES.map((rule) => [rule, {}])) as CurrentCounts
const violations: Violation[] = []
for (const file of sourceFiles()) {
for (const violation of findViolations(file, readFileSync(path.join(ROOT, file), 'utf8'))) {
violations.push(violation)
counts[violation.rule][file] = (counts[violation.rule][file] ?? 0) + 1
}
}
return { counts, violations }
}
function total(counts: Counts | undefined): number {
return Object.values(counts ?? {}).reduce((sum, count) => sum + count, 0)
}
function argValue(flag: string): string | undefined {
const index = process.argv.indexOf(flag)
return index === -1 ? undefined : process.argv[index + 1]
}
/**
* The committed baseline. A missing file fails closed: restore it from git. `--update --init` is
* the only way to create one, and it accepts every current hit.
*/
function readBaseline(current: CurrentCounts): Baseline {
if (existsSync(BASELINE)) return JSON.parse(readFileSync(BASELINE, 'utf8')) as Baseline
if (process.argv.includes('--update') && process.argv.includes('--init')) return current
console.error(
`✗ ${path.relative(ROOT, BASELINE)} is missing. Restore it from git; ` +
'create a new one only with --update --init.'
)
process.exit(1)
}
function main(): void {
const { counts: current, violations } = collect()
const baseline = readBaseline(current)
if (process.argv.includes('--update')) {
const result = nextBaseline(baseline, current, argValue('--init-rule'))
if ('errors' in result) {
for (const error of result.errors) console.error(`✗ ${error}`)
process.exit(1)
}
writeFileSync(BASELINE, `${JSON.stringify(result.baseline, null, 2)}\n`)
console.log(
`Wrote ${path.relative(ROOT, BASELINE)}: ` +
RULES.map((rule) => `${rule} ${total(result.baseline[rule])}`).join(', ')
)
process.exit(0)
}
const { raised, dropped } = compareToBaseline(baseline, current)
for (const rule of RULES) {
const raisedHere = raised.filter((change) => change.rule === rule)
if (raisedHere.length) {
console.error(`✗ ${rule}: ${raisedHere.length} file(s) gained hits`)
for (const { file, count, allowed } of raisedHere) {
console.error(` ${file}: ${count} (baseline ${allowed})`)
for (const violation of violations) {
if (violation.rule === rule && violation.file === file) {
console.error(` ${file}:${violation.line} ${violation.detail}`)
}
}
}
console.error(` ${HOW_TO_FIX[rule]}\n`)
}
const droppedHere = dropped.filter((change) => change.rule === rule)
if (droppedHere.length) {
console.error(`✗ ${rule}: ${droppedHere.length} file(s) dropped below the baseline`)
for (const { file, count, allowed } of droppedHere) {
console.error(` ${file}: ${count} (baseline ${allowed})`)
}
}
}
if (raised.length || dropped.length) {
if (dropped.length) {