@agentvibes/guardrails 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +131 -5
  2. package/dist/astGrep.d.ts +3 -0
  3. package/dist/astGrep.js +15 -2
  4. package/dist/astGrep.js.map +1 -1
  5. package/dist/doctor.js +38 -2
  6. package/dist/doctor.js.map +1 -1
  7. package/dist/hookPostedit.js +18 -1
  8. package/dist/hookPostedit.js.map +1 -1
  9. package/dist/presetDrift.d.ts +31 -0
  10. package/dist/presetDrift.js +226 -0
  11. package/dist/presetDrift.js.map +1 -0
  12. package/dist/presetDriftTest.d.ts +1 -0
  13. package/dist/presetDriftTest.js +136 -0
  14. package/dist/presetDriftTest.js.map +1 -0
  15. package/dist/repoRules.d.ts +27 -0
  16. package/dist/repoRules.js +86 -0
  17. package/dist/repoRules.js.map +1 -0
  18. package/dist/repoRulesTest.d.ts +1 -0
  19. package/dist/repoRulesTest.js +122 -0
  20. package/dist/repoRulesTest.js.map +1 -0
  21. package/dist/screenScope.d.ts +16 -5
  22. package/dist/screenScope.js +66 -21
  23. package/dist/screenScope.js.map +1 -1
  24. package/dist/screenScopeTest.js +67 -7
  25. package/dist/screenScopeTest.js.map +1 -1
  26. package/dist/siblingCloneTest.d.ts +1 -0
  27. package/dist/siblingCloneTest.js +201 -0
  28. package/dist/siblingCloneTest.js.map +1 -0
  29. package/dist/testRules.js +77 -0
  30. package/dist/testRules.js.map +1 -1
  31. package/dist/twinCoverageTest.d.ts +1 -0
  32. package/dist/twinCoverageTest.js +262 -0
  33. package/dist/twinCoverageTest.js.map +1 -0
  34. package/dist/verify.d.ts +1 -1
  35. package/dist/verify.js +33 -8
  36. package/dist/verify.js.map +1 -1
  37. package/dist/verifyDiff.js +6 -5
  38. package/dist/verifyDiff.js.map +1 -1
  39. package/package.json +7 -3
  40. package/rules/__fixtures__/demo-mode-by-default/bad.ts +22 -0
  41. package/rules/__fixtures__/demo-mode-by-default/bad.tsx +22 -0
  42. package/rules/__fixtures__/demo-mode-by-default/good.ts +21 -0
  43. package/rules/__fixtures__/demo-mode-by-default/good.tsx +21 -0
  44. package/rules/__fixtures__/json-roundtrip/bad.ts +6 -0
  45. package/rules/__fixtures__/json-roundtrip/bad.tsx +8 -0
  46. package/rules/__fixtures__/json-roundtrip/good.ts +5 -0
  47. package/rules/__fixtures__/json-roundtrip/good.tsx +7 -0
  48. package/rules/__fixtures__/kind-if-without-match/bad.ts +17 -0
  49. package/rules/__fixtures__/kind-if-without-match/bad.tsx +18 -0
  50. package/rules/__fixtures__/kind-if-without-match/good.ts +14 -0
  51. package/rules/__fixtures__/kind-if-without-match/good.tsx +13 -0
  52. package/rules/__fixtures__/literal-union-in-component/components/bad.tsx +16 -0
  53. package/rules/__fixtures__/literal-union-in-component/components/good.tsx +25 -0
  54. package/rules/__fixtures__/non-exhaustive-match/bad.tsx +12 -0
  55. package/rules/__fixtures__/non-exhaustive-match/good.tsx +14 -0
  56. package/rules/__fixtures__/silent-default-return/bad.ts +9 -0
  57. package/rules/__fixtures__/silent-default-return/bad.tsx +10 -0
  58. package/rules/__fixtures__/silent-default-return/good.ts +17 -0
  59. package/rules/__fixtures__/silent-default-return/good.tsx +11 -0
  60. package/rules/demo-mode-by-default-ts.yml +17 -1
  61. package/rules/demo-mode-by-default.yml +17 -1
  62. package/rules/json-roundtrip-tsx.yml +26 -0
  63. package/rules/kind-if-without-match-tsx.yml +31 -0
  64. package/rules/literal-union-in-component-tsx.yml +81 -0
  65. package/rules/no-local-kit-clone-tsx.yml +16 -0
  66. package/rules/no-local-kit-clone.yml +16 -0
  67. package/rules/non-exhaustive-match-tsx.yml +48 -0
  68. package/rules/silent-default-return-tsx.yml +33 -0
@@ -0,0 +1,13 @@
1
+ // NOTHING in this file may be reported by `kind-if-without-match-tsx`.
2
+ import { match } from "ts-pattern"
3
+
4
+ export const A = (r: R) =>
5
+ match(r)
6
+ .with({ kind: "idle" }, () => <Spinner />)
7
+ .with({ kind: "ready" }, () => <List />)
8
+ .exhaustive()
9
+
10
+ export const B = (r: R) => {
11
+ if (r.count === 0) return <Empty />
12
+ return <List />
13
+ }
@@ -0,0 +1,16 @@
1
+ // Every type here MUST be reported by `literal-union-in-component-tsx`.
2
+ // Path matters: the rule is scoped to component/screen/app directories.
3
+ //
4
+ // This is the file the rule was named for and never saw. Under
5
+ // `language: typescript` the `**/components/**/*.tsx` glob in the rule could
6
+ // not match anything — the `.tsx` arm makes those glob lines real.
7
+
8
+ // Enum-style UI state redeclared in the file that renders it.
9
+ export type Tab = "tasks" | "habits" | "stream"
10
+ export type LoadPhase = "idle" | "loading" | "ready" | "error"
11
+ type Align = "start" | "center" | "end"
12
+ export type Tone = "neutral" | "danger" | "success" | "warning"
13
+
14
+ export const TabBar = ({ tab, align }: { tab: Tab; align: Align }) => (
15
+ <nav data-align={align}>{tab}</nav>
16
+ )
@@ -0,0 +1,25 @@
1
+ // NOTHING in this file may be reported by `literal-union-in-component-tsx`.
2
+
3
+ // The fix: consume the shared type, do not restate it.
4
+ import type { Tab } from "../../stores/uiStore"
5
+ import type { Tone } from "../../lib/tone"
6
+
7
+ export interface TabBarProps {
8
+ tab: Tab
9
+ tone: Tone
10
+ }
11
+
12
+ // A single-member alias is not an enum-style union.
13
+ export type Only = "one"
14
+
15
+ // A union of non-literals is a different thing entirely.
16
+ export type Id = string | number
17
+ export type Handler = (() => void) | undefined
18
+
19
+ // A discriminated union of OBJECT shapes is the pattern this codebase wants
20
+ // everywhere — it is not a bare string enum and must not be flagged.
21
+ export type Resource<T> =
22
+ | { status: "idle" }
23
+ | { status: "ready"; data: T }
24
+
25
+ export const TabBar = ({ tab, tone }: TabBarProps) => <nav data-tone={tone}>{tab}</nav>
@@ -0,0 +1,12 @@
1
+ // Every chain here MUST be reported by `non-exhaustive-match-tsx`.
2
+ // The .ts arm (`non-exhaustive-match`) does NOT see this file: ast-grep's
3
+ // `typescript` and `tsx` languages are disjoint.
4
+ import { match } from "ts-pattern"
5
+
6
+ export const A = (r: R) => match(r).with({ kind: "idle" }, () => <Spinner />).otherwise(() => null)
7
+
8
+ export const B = (r: R) =>
9
+ match(r)
10
+ .with({ kind: "idle" }, () => <Spinner />)
11
+ .with({ kind: "ready" }, () => <List />)
12
+ .otherwise(() => <Empty />)
@@ -0,0 +1,14 @@
1
+ // NOTHING in this file may be reported by `non-exhaustive-match-tsx`.
2
+ import { match } from "ts-pattern"
3
+
4
+ export const A = (r: R) =>
5
+ match(r)
6
+ .with({ kind: "idle" }, () => <Spinner />)
7
+ .with({ kind: "ready" }, () => <List />)
8
+ .exhaustive()
9
+
10
+ // The false-positive pins carried over from good.ts: the rule anchors to a
11
+ // ts-pattern `match()` chain, so a method merely NAMED `otherwise` is not a
12
+ // finding. A revert to the old bare `$X.otherwise($$$)` body fails here.
13
+ export const q = someBuilder.otherwise(() => 1)
14
+ export const r = config.fallback.otherwise
@@ -0,0 +1,9 @@
1
+ // Every return here MUST be reported by `silent-default-return`.
2
+ export const a = (x?: T) => { if (!x) return null; return use(x) }
3
+ export const b = (x?: T[]) => { if (!x) return []; return x }
4
+ export const c = (x?: number) => { if (!x) return 0; return x }
5
+ export const d = (x?: string) => { if (!x) return ""; return x }
6
+ export const e = (x?: T) => { if (x === undefined) return null; return use(x) }
7
+ export const f = (x?: T[]) => { if (x === undefined) return []; return x }
8
+ export const g = (x?: T) => { if (x == null) return null; return use(x) }
9
+ export const h = (x?: T[]) => { if (x == null) return []; return x }
@@ -0,0 +1,10 @@
1
+ // Every return here MUST be reported by `silent-default-return-tsx`.
2
+ // The .ts arm (`silent-default-return`) does NOT see this file.
3
+ export const A = (x?: T) => { if (!x) return null; return <Use v={x} /> }
4
+ export const b = (x?: T[]) => { if (!x) return []; return x }
5
+ export const c = (x?: number) => { if (!x) return 0; return x }
6
+ export const d = (x?: string) => { if (!x) return ""; return x }
7
+ export const E = (x?: T) => { if (x === undefined) return null; return <Use v={x} /> }
8
+ export const f = (x?: T[]) => { if (x === undefined) return []; return x }
9
+ export const G = (x?: T) => { if (x == null) return null; return <Use v={x} /> }
10
+ export const h = (x?: T[]) => { if (x == null) return []; return x }
@@ -0,0 +1,17 @@
1
+ // NOTHING in this file may be reported by `silent-default-return`.
2
+ export const a = (x?: T) => {
3
+ if (!x) throw new Error("a(): x is required; caller passed nothing")
4
+ return use(x)
5
+ }
6
+
7
+ // An explicit discriminated failure variant is the sanctioned shape.
8
+ export const b = (x?: T) => {
9
+ if (!x) return { kind: "failure", reason: "missing-x" } as const
10
+ return { kind: "success", value: use(x) } as const
11
+ }
12
+
13
+ // The rule is the SILENT default, not every early return.
14
+ export const c = (x?: T) => {
15
+ if (!x) return fallbackFor(x)
16
+ return use(x)
17
+ }
@@ -0,0 +1,11 @@
1
+ // NOTHING in this file may be reported by `silent-default-return-tsx`.
2
+ export const A = (x?: T) => {
3
+ if (!x) throw new Error("A(): x is required; the caller rendered it with nothing")
4
+ return <Use v={x} />
5
+ }
6
+
7
+ // Rendering an explicit empty state is not a silent default.
8
+ export const B = (x?: T) => {
9
+ if (!x) return <Empty reason="no-x" />
10
+ return <Use v={x} />
11
+ }
@@ -35,6 +35,12 @@
35
35
  # Validated against the real pre-fix store.tsx: 1 hit at the exact declaration,
36
36
  # 0 on the fixed version, 0 across substrate-app, tg-gallery webapp and
37
37
  # agent-workbench web.
38
+ #
39
+ # 2026-09-06 (is-9e66ec5a): the value matcher was widened to accept
40
+ # `as const` / `as <Type>` / `satisfies <Type>` around the literal. This is an
41
+ # error-tier rule, so the widening was measured before landing: 0 new findings
42
+ # in the seven repos that run this gate, and 0 across every repo under
43
+ # ~/gits/github — the only hits corpus-wide are this package's own fixtures.
38
44
  id: demo-mode-by-default-ts
39
45
  language: typescript
40
46
  severity: error
@@ -52,9 +58,19 @@ rule:
52
58
  - has:
53
59
  field: key
54
60
  regex: '^(kind|mode|backend|source|dataSource|driver|adapter|transport|client)$'
61
+ # The literal, optionally wrapped in `as const` / `as Mode` /
62
+ # `satisfies Mode` (is-9e66ec5a). Without the tail, `{ kind: "demo" }` was
63
+ # a finding and `{ kind: "demo" as const }` was not — and `as const` is the
64
+ # spelling people reach for precisely when the object has no type
65
+ # annotation to carry the literal type. DataSpool's retired local
66
+ # `no-fake-defaults` caught it, so adopting the canon had been a coverage
67
+ # regression; the reviewer sent both gate PRs back over it.
68
+ #
69
+ # The leading and trailing `.` are the quote character, so `"demo-ish"`
70
+ # still does not match: the alternation must be the WHOLE string body.
55
71
  - has:
56
72
  field: value
57
- regex: '^.(demo|mock|fixture|fake|stub|sample|seed).$'
73
+ regex: '^.(demo|mock|fixture|fake|stub|sample|seed).( +(as|satisfies) +.+)?$'
58
74
  - not:
59
75
  inside:
60
76
  stopBy: end
@@ -15,6 +15,12 @@
15
15
  # Validated against the real pre-fix store.tsx: 1 hit at the exact declaration,
16
16
  # 0 on the fixed version, 0 across substrate-app, tg-gallery webapp and
17
17
  # agent-workbench web.
18
+ #
19
+ # 2026-09-06 (is-9e66ec5a): the value matcher was widened to accept
20
+ # `as const` / `as <Type>` / `satisfies <Type>` around the literal. This is an
21
+ # error-tier rule, so the widening was measured before landing: 0 new findings
22
+ # in the seven repos that run this gate, and 0 across every repo under
23
+ # ~/gits/github — the only hits corpus-wide are this package's own fixtures.
18
24
  id: demo-mode-by-default
19
25
  language: tsx
20
26
  severity: error
@@ -30,9 +36,19 @@ rule:
30
36
  - has:
31
37
  field: key
32
38
  regex: '^(kind|mode|backend|source|dataSource|driver|adapter|transport|client)$'
39
+ # The literal, optionally wrapped in `as const` / `as Mode` /
40
+ # `satisfies Mode` (is-9e66ec5a). Without the tail, `{ kind: "demo" }` was
41
+ # a finding and `{ kind: "demo" as const }` was not — and `as const` is the
42
+ # spelling people reach for precisely when the object has no type
43
+ # annotation to carry the literal type. DataSpool's retired local
44
+ # `no-fake-defaults` caught it, so adopting the canon had been a coverage
45
+ # regression; the reviewer sent both gate PRs back over it.
46
+ #
47
+ # The leading and trailing `.` are the quote character, so `"demo-ish"`
48
+ # still does not match: the alternation must be the WHOLE string body.
33
49
  - has:
34
50
  field: value
35
- regex: '^.(demo|mock|fixture|fake|stub|sample|seed).$'
51
+ regex: '^.(demo|mock|fixture|fake|stub|sample|seed).( +(as|satisfies) +.+)?$'
36
52
  - not:
37
53
  inside:
38
54
  stopBy: end
@@ -0,0 +1,26 @@
1
+ # ── the .tsx half of `json-roundtrip` ──
2
+ #
3
+ # ast-grep's `typescript` and `tsx` languages are DISJOINT, not nested: a rule
4
+ # at `language: typescript` matches `.ts` and NEVER `.tsx`. `json-roundtrip`
5
+ # was therefore blind to every React component file. The mechanism, and the
6
+ # three alternatives that were tried and rejected on measurement, are written
7
+ # out once in `catch-empty-tsx.yml`; the short version is that ast-grep has no
8
+ # way to say "both languages", so a twin id per language is the only option.
9
+ #
10
+ # Measured 2026-09-06 across the same five repos: 0 findings. A clean zero,
11
+ # not a broken one — the rule fires on a known positive in its fixture. Closing
12
+ # a language gap at zero cost is the same call `demo-mode-by-default-ts` made.
13
+ #
14
+ # Keep this file's `rule:` block IDENTICAL to `json-roundtrip.yml`.
15
+ #
16
+ id: json-roundtrip-tsx
17
+ language: tsx
18
+ severity: warning
19
+ message: |
20
+ JSON.parse(JSON.stringify(x)) loses Maps, Sets, TypedArrays, class instances, circular refs.
21
+ Use a typed serializer with Zod validation at the boundary.
22
+ See defensive-errors Rule 5.
23
+ Suppressing this one needs the id you see above — `ast-grep-ignore: json-roundtrip-tsx`.
24
+ `json-roundtrip` is a DIFFERENT rule (the .ts arm) and does not cover it.
25
+ rule:
26
+ pattern: "JSON.parse(JSON.stringify($X))"
@@ -0,0 +1,31 @@
1
+ # ── the .tsx half of `kind-if-without-match` ──
2
+ #
3
+ # ast-grep's `typescript` and `tsx` languages are DISJOINT, not nested: a rule
4
+ # at `language: typescript` matches `.ts` and NEVER `.tsx`. `kind-if-without-match`
5
+ # was therefore blind to every React component file. The mechanism, and the
6
+ # three alternatives that were tried and rejected on measurement, are written
7
+ # out once in `catch-empty-tsx.yml`; the short version is that ast-grep has no
8
+ # way to say "both languages", so a twin id per language is the only option.
9
+ #
10
+ # Measured 2026-09-06 across the same five repos: 29 findings in `.tsx`
11
+ # (SiteCraft 1, faceless 2, observatory 6, tg-gallery 2, merkle-substrate 18).
12
+ # `if (x.kind === …)` branching lives in components at least as often as in
13
+ # plain modules, which is precisely where the union grows a variant unnoticed.
14
+ #
15
+ # Keep this file's `rule:` block IDENTICAL to `kind-if-without-match.yml`.
16
+ #
17
+ id: kind-if-without-match-tsx
18
+ language: tsx
19
+ severity: warning
20
+ message: |
21
+ Discriminated-union branching with if/else silently ignores new variants.
22
+ Use match($X).with({kind: ...}, ...).exhaustive() from ts-pattern.
23
+ See defensive-errors Rule 2 and the pattern-matching skill.
24
+ Suppressing this one needs the id you see above — `ast-grep-ignore: kind-if-without-match-tsx`.
25
+ `kind-if-without-match` is a DIFFERENT rule (the .ts arm) and does not cover it.
26
+ rule:
27
+ any:
28
+ - pattern: "if ($X.kind === $_) $_"
29
+ - pattern: "if ($X.type === $_) $_"
30
+ - pattern: "if ($X.status === $_) $_"
31
+ - pattern: "if ($X._obj === $_) $_"
@@ -0,0 +1,81 @@
1
+ # ── the .tsx half of `literal-union-in-component` ──
2
+ #
3
+ # ast-grep's `typescript` and `tsx` languages are DISJOINT, not nested: a rule
4
+ # at `language: typescript` matches `.ts` and NEVER `.tsx`. `literal-union-in-component`
5
+ # was therefore blind to every React component file. The mechanism, and the
6
+ # three alternatives that were tried and rejected on measurement, are written
7
+ # out once in `catch-empty-tsx.yml`; the short version is that ast-grep has no
8
+ # way to say "both languages", so a twin id per language is the only option.
9
+ #
10
+ # Measured 2026-09-06 across the same five repos: 41 findings in `.tsx`
11
+ # (SiteCraft 1, faceless 7, observatory 9, tg-gallery 6, merkle-substrate 18).
12
+ # This twin restores intent rather than extending it: the `files:` globs below
13
+ # already list `**/components/**/*.tsx` and `**/screens/**/*.tsx`, and under
14
+ # `language: typescript` those three lines could never match anything. The rule
15
+ # is named `-in-component` and was blind to components.
16
+ #
17
+ # Keep this file's `rule:` block IDENTICAL to `literal-union-in-component.yml`.
18
+ #
19
+ id: literal-union-in-component-tsx
20
+ language: tsx
21
+ severity: warning
22
+ files:
23
+ - '**/components/**/*.ts'
24
+ - '**/components/**/*.tsx'
25
+ - '**/screens/**/*.ts'
26
+ - '**/screens/**/*.tsx'
27
+ - '**/app/**/*.ts'
28
+ - '**/app/**/*.tsx'
29
+ ignores:
30
+ - '**/__tests__/**'
31
+ - '**/*.test.ts'
32
+ - '**/*.test.tsx'
33
+ - '**/_fixture.ts'
34
+ - '**/_fixtures.ts'
35
+ message: |
36
+ A string-literal union declared inside a component file. Every component that
37
+ touches the same concept redeclares it, and the copies drift — one file grows
38
+ a `"archived"` member the others never hear about, and nothing fails to build
39
+ because each copy is internally consistent.
40
+ Move it to where the state it describes lives:
41
+ - a UI-tier store, next to the actions that mutate it, when it IS state
42
+ (`type Tab = "tasks" | "habits"` belongs to the store that owns the tab), or
43
+ - a shared module as a typed constant + alias when it is purely lexical
44
+ (file kinds, palette names) with no state attached.
45
+ Component prop types then consume the shared type instead of restating it.
46
+ See the mobx-models skill and report §8.
47
+ Suppressing this one needs the id you see above — `ast-grep-ignore: literal-union-in-component-tsx`.
48
+ `literal-union-in-component` is a DIFFERENT rule (the .ts arm) and does not cover it.
49
+ rule:
50
+ all:
51
+ - kind: type_alias_declaration
52
+ # Two or more STRING members on the union's own spine. The spine matters:
53
+ # tree-sitter nests unions left-associatively, so a 3-member union is
54
+ # `union_type(union_type(lit, lit), lit)` and a 2-member one is
55
+ # `union_type(lit, lit)` — hence the two arms. Searching with `stopBy: end`
56
+ # instead would reach literals nested INSIDE object members and flag
57
+ # `{ status: "idle" } | { status: "ready" }`, the discriminated union this
58
+ # codebase wants everywhere.
59
+ - has:
60
+ field: value
61
+ kind: union_type
62
+ any:
63
+ # three or more members
64
+ - has:
65
+ kind: union_type
66
+ has:
67
+ kind: literal_type
68
+ has:
69
+ kind: string
70
+ # exactly two members
71
+ - has:
72
+ all:
73
+ - kind: literal_type
74
+ - has:
75
+ kind: string
76
+ - follows:
77
+ stopBy: end
78
+ all:
79
+ - kind: literal_type
80
+ - has:
81
+ kind: string
@@ -34,7 +34,23 @@
34
34
  # `Resource` must carry type parameters to match. Without that the rule hits
35
35
  # `interface Resource { name; url; type; description }` in VolumePhotography — a
36
36
  # page listing conference links, nothing to do with async state.
37
+ # CARVE-OUT 1 IS NOW A REPO DECLARATION, not only a glob (is-086a90ac).
38
+ # `@agentvibes/kit` exists since 2026-09 and lives in its own repo, where verify
39
+ # sees its files as `src/resource/resource.ts` — neither `**/packages/kit/**`
40
+ # nor `**/agentvibes-kit/**` matches that, and no path glob can tell that tree
41
+ # from any other repo's `src/`. So the kit repo says so itself:
42
+ #
43
+ # [verify]
44
+ # kit_source = "^src/"
45
+ #
46
+ # together with the `appliesTo: not-kit-source` marker below. Same mechanism
47
+ # `[verify] screens` uses (is-7067a0b8): the repo states a fact about itself,
48
+ # the canon says which rules stand down for it. The globs stay for monorepos
49
+ # that vendor the kit under `packages/kit/`.
50
+ #
37
51
  id: no-local-kit-clone-tsx
52
+ metadata:
53
+ appliesTo: not-kit-source
38
54
  language: tsx
39
55
  severity: error
40
56
  ignores:
@@ -23,7 +23,23 @@
23
23
  # `Resource` must carry type parameters to match. Without that the rule hits
24
24
  # `interface Resource { name; url; type; description }` in VolumePhotography — a
25
25
  # page listing conference links, nothing to do with async state.
26
+ # CARVE-OUT 1 IS NOW A REPO DECLARATION, not only a glob (is-086a90ac).
27
+ # `@agentvibes/kit` exists since 2026-09 and lives in its own repo, where verify
28
+ # sees its files as `src/resource/resource.ts` — neither `**/packages/kit/**`
29
+ # nor `**/agentvibes-kit/**` matches that, and no path glob can tell that tree
30
+ # from any other repo's `src/`. So the kit repo says so itself:
31
+ #
32
+ # [verify]
33
+ # kit_source = "^src/"
34
+ #
35
+ # together with the `appliesTo: not-kit-source` marker below. Same mechanism
36
+ # `[verify] screens` uses (is-7067a0b8): the repo states a fact about itself,
37
+ # the canon says which rules stand down for it. The globs stay for monorepos
38
+ # that vendor the kit under `packages/kit/`.
39
+ #
26
40
  id: no-local-kit-clone
41
+ metadata:
42
+ appliesTo: not-kit-source
27
43
  language: typescript
28
44
  severity: error
29
45
  ignores:
@@ -0,0 +1,48 @@
1
+ # ── the .tsx half of `non-exhaustive-match` ──
2
+ #
3
+ # ast-grep's `typescript` and `tsx` languages are DISJOINT, not nested: a rule
4
+ # at `language: typescript` matches `.ts` and NEVER `.tsx`. `non-exhaustive-match`
5
+ # was therefore blind to every React component file. The mechanism, and the
6
+ # three alternatives that were tried and rejected on measurement, are written
7
+ # out once in `catch-empty-tsx.yml`; the short version is that ast-grep has no
8
+ # way to say "both languages", so a twin id per language is the only option.
9
+ #
10
+ # Measured 2026-09-06 across the five repos that consume guardrails
11
+ # (SiteCraftMonorepo 40, faceless-photo-lib 28, agent-session-observatory 18,
12
+ # tg-gallery 47, merkle-substrate 52): 185 `.otherwise()` chains in `.tsx` the
13
+ # `.ts` arm never saw. Adding them was the owner's call — decision D4 in
14
+ # doc-e4526f1a parks the question of banning `.otherwise()` outright, so the
15
+ # owner was asked whether to surface the .tsx half before D4 lands and answered
16
+ # «1» (add the twin now, severity warning) on 2026-09-06, is-8385d5dd.
17
+ #
18
+ # Keep this file's `rule:` block IDENTICAL to `non-exhaustive-match.yml`.
19
+ #
20
+ id: non-exhaustive-match-tsx
21
+ language: tsx
22
+ severity: warning
23
+ message: |
24
+ `.otherwise()` catches unmatched variants silently. Prefer `.exhaustive()` so adding a
25
+ new union variant becomes a compile error everywhere it's branched on.
26
+ See defensive-errors Rule 2 and pattern-matching skill.
27
+ Suppressing this one needs the id you see above — `ast-grep-ignore: non-exhaustive-match-tsx`.
28
+ `non-exhaustive-match` is a DIFFERENT rule (the .ts arm) and does not cover it.
29
+ rule:
30
+ all:
31
+ - kind: call_expression
32
+ has:
33
+ field: function
34
+ kind: identifier
35
+ regex: "^match$"
36
+ - inside:
37
+ stopBy:
38
+ not:
39
+ any:
40
+ - kind: member_expression
41
+ - kind: call_expression
42
+ kind: call_expression
43
+ has:
44
+ field: function
45
+ kind: member_expression
46
+ has:
47
+ field: property
48
+ regex: "^otherwise$"
@@ -0,0 +1,33 @@
1
+ # ── the .tsx half of `silent-default-return` ──
2
+ #
3
+ # ast-grep's `typescript` and `tsx` languages are DISJOINT, not nested: a rule
4
+ # at `language: typescript` matches `.ts` and NEVER `.tsx`. `silent-default-return`
5
+ # was therefore blind to every React component file. The mechanism, and the
6
+ # three alternatives that were tried and rejected on measurement, are written
7
+ # out once in `catch-empty-tsx.yml`; the short version is that ast-grep has no
8
+ # way to say "both languages", so a twin id per language is the only option.
9
+ #
10
+ # Measured 2026-09-06 across the same five repos: 24 findings in `.tsx`
11
+ # (SiteCraft 3, faceless 0, observatory 9, tg-gallery 0, merkle-substrate 12).
12
+ #
13
+ # Keep this file's `rule:` block IDENTICAL to `silent-default-return.yml`.
14
+ #
15
+ id: silent-default-return-tsx
16
+ language: tsx
17
+ severity: warning
18
+ message: |
19
+ Silent-default return on negation. Hides upstream bugs.
20
+ Throw with a contextual message, or return an explicit discriminated failure variant.
21
+ See defensive-errors Rule 1.
22
+ Suppressing this one needs the id you see above — `ast-grep-ignore: silent-default-return-tsx`.
23
+ `silent-default-return` is a DIFFERENT rule (the .ts arm) and does not cover it.
24
+ rule:
25
+ any:
26
+ - pattern: "if (!$X) return null"
27
+ - pattern: "if (!$X) return []"
28
+ - pattern: "if (!$X) return 0"
29
+ - pattern: "if (!$X) return \"\""
30
+ - pattern: "if ($X === undefined) return null"
31
+ - pattern: "if ($X === undefined) return []"
32
+ - pattern: "if ($X == null) return null"
33
+ - pattern: "if ($X == null) return []"