Skip to main content

bynk_ts/
lint.rs

1//! The textual lint over [`crate::TsStmt::verbatim`] content (Q2, `design/
2//! tracks/the-typescript-tree.md` §3.2's "real gap this settling pass found
3//! and closes"). A byte-golden fixture is blind to what's *inside* an
4//! opaque `Verbatim` block — this scans the wrapped text directly for the
5//! six constructs R7.1 forbids the tree from ever representing (`enum`,
6//! `namespace`, a decorator, a constructor parameter property, `: any`/
7//! `as any`), so a `Verbatim` block smuggling one of them in stays visible
8//! even while every golden fixture stays green.
9//!
10//! Pattern match over text, not a real TS parser — same posture `xtask`'s
11//! own `ts_any` probe (`xtask/src/greenfield_status.rs`) already takes for
12//! the identical `any` patterns, reused here rather than re-derived.
13//!
14//! `bynk-emit` builds no `Verbatim` content in this slice (#1307's Decision
15//! C), so nothing calls this over real output yet — it exists, tested
16//! against one positive and one negative case per construct, ready for Arc C
17//! (Decision F: wiring it into a real CI-visible check over compiled output
18//! is meaningful only once real `Verbatim` content exists to check).
19
20/// One construct [`verbatim_violations`] found, and the line it was on.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Violation {
23    pub construct: &'static str,
24    pub line: String,
25}
26
27/// Scan `text` (a `Verbatim` statement's own wrapped TypeScript) for every
28/// line matching one of the six banned constructs. Order of the checks
29/// within a line matters only for which `construct` label a line already
30/// matching two patterns gets — real emitted lines don't do that in
31/// practice, so the first match wins and the rest of that line isn't
32/// checked further.
33pub fn verbatim_violations(text: &str) -> Vec<Violation> {
34    let mut out = Vec::new();
35    for line in text.lines() {
36        if let Some(construct) = detect(line) {
37            out.push(Violation {
38                construct,
39                line: line.to_string(),
40            });
41        }
42    }
43    out
44}
45
46fn detect(line: &str) -> Option<&'static str> {
47    if is_any(line) {
48        return Some("TsType::Any");
49    }
50    if contains_keyword(line, "enum") {
51        return Some("enum");
52    }
53    if contains_keyword(line, "namespace") {
54        return Some("namespace");
55    }
56    if is_decorator(line) {
57        return Some("decorator");
58    }
59    if is_constructor_parameter_property(line) {
60        return Some("constructor parameter property");
61    }
62    None
63}
64
65/// The same five shapes `xtask`'s own `ts_any` probe (`line_violates_ts_any`)
66/// checks against Rust source — `as any`, bare `: any`, generic-position
67/// `<any`/`any>`/`any[]` — but *not* that probe's own plain-substring
68/// matching (review of #1308, finding 4): that probe scans Bynk's own Rust
69/// source, a corpus the team can rename around a false positive; this scans
70/// generated TypeScript carrying arbitrary Bynk-author identifiers nobody
71/// here controls (`Company[]`, `Record<string, Company>`, `Map<anything,
72/// …>` all contain one of the five substrings and none is `Any`), so a
73/// false positive here is a user-facing build failure with no user-side
74/// fix. `any` is matched as its own word first (reusing [`contains_keyword`]'s
75/// boundary rule), then classified by what's immediately around it.
76fn is_any(line: &str) -> bool {
77    let bytes = line.as_bytes();
78    let mut start = 0;
79    while let Some(rel) = line[start..].find("any") {
80        let i = start + rel;
81        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
82        let after_ok = i + 3 >= bytes.len() || !is_ident_byte(bytes[i + 3]);
83        if before_ok && after_ok && is_any_type_position(&line[..i], &line[i + 3..]) {
84            return true;
85        }
86        start = i + 3;
87    }
88    false
89}
90
91/// Whether a word-bounded `any` sits in a type position: `as any`, `: any`
92/// (with or without the space — `const x:any` is real, emitted-output-
93/// unlikely but still a live pattern to catch), a generic open (`<any`), a
94/// generic close (`any>`), or an array (`any[]`). `before`/`after` are the
95/// line's text on each side of the matched word.
96fn is_any_type_position(before: &str, after: &str) -> bool {
97    let trimmed = before.trim_end();
98    if trimmed.ends_with("as") {
99        let as_start_ok =
100            trimmed.len() == 2 || !is_ident_byte(trimmed.as_bytes()[trimmed.len() - 3]);
101        if as_start_ok {
102            return true;
103        }
104    }
105    if trimmed.ends_with(':') || trimmed.ends_with('<') {
106        return true;
107    }
108    after.starts_with('>') || after.starts_with("[]")
109}
110
111/// Whether `keyword` appears in `line` as a real word — not as a substring
112/// of a longer identifier (`enum` inside `enumerate`, `namespace` inside
113/// `MyNamespaceThing`).
114fn contains_keyword(line: &str, keyword: &str) -> bool {
115    let bytes = line.as_bytes();
116    let klen = keyword.len();
117    let mut start = 0;
118    while let Some(rel) = line[start..].find(keyword) {
119        let i = start + rel;
120        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
121        let after_ok = i + klen >= bytes.len() || !is_ident_byte(bytes[i + klen]);
122        if before_ok && after_ok {
123            return true;
124        }
125        start = i + klen;
126    }
127    false
128}
129
130fn is_ident_byte(b: u8) -> bool {
131    b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
132}
133
134/// A TypeScript decorator: `@Identifier` at the start of a (trimmed) line —
135/// `@Injectable()`, `@Component({ ... })`. Emitted output never has a
136/// legitimate `@` at line-start otherwise (no JSDoc `@param` lines survive
137/// into `Verbatim` text; those live in comments this scan doesn't need to
138/// special-case since a `@param` line's next character is a space, not an
139/// identifier start).
140fn is_decorator(line: &str) -> bool {
141    let trimmed = line.trim_start();
142    trimmed
143        .strip_prefix('@')
144        .and_then(|rest| rest.chars().next())
145        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
146}
147
148/// A constructor parameter property: `private`/`public`/`protected`/
149/// `readonly` inside a `constructor(...)`'s own parameter list — the one
150/// type-directed construct pure strip-only stripping cannot erase (ADR
151/// 0136's own strip-only rationale, already the reason `emitter/emit.rs`'s
152/// own provider constructor de-sugars away from this shape by hand). Scoped
153/// to *only* the text between `constructor(` and its matching close paren
154/// (review of #1308, finding 5: the first version scanned to end of line,
155/// so `constructor(deps: Deps) { this.mode = "readonly access"; }` — a
156/// `readonly`-shaped *string literal* in the constructor's own body —
157/// false-positived, contradicting this doc comment's own claim).
158/// Paren-depth tracked, not `{}`-depth: a parameter's own object type
159/// (`constructor(deps: { Log: unknown })`) carries braces the scan must
160/// walk straight through, so only `(`/`)` count.
161fn is_constructor_parameter_property(line: &str) -> bool {
162    let Some(after) = line
163        .find("constructor(")
164        .map(|i| &line[i + "constructor(".len()..])
165    else {
166        return false;
167    };
168    let mut depth = 1i32;
169    let mut end = after.len();
170    for (idx, c) in after.char_indices() {
171        match c {
172            '(' => depth += 1,
173            ')' => {
174                depth -= 1;
175                if depth == 0 {
176                    end = idx;
177                    break;
178                }
179            }
180            _ => {}
181        }
182    }
183    let params = &after[..end];
184    ["private ", "public ", "protected ", "readonly "]
185        .iter()
186        .any(|kw| params.contains(kw))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn catches_as_any_and_bare_colon_any() {
195        assert_eq!(
196            verbatim_violations("const x = (value as any).field;"),
197            vec![Violation {
198                construct: "TsType::Any",
199                line: "const x = (value as any).field;".to_string(),
200            }]
201        );
202        assert!(verbatim_violations("const x: unknown = value;").is_empty());
203    }
204
205    #[test]
206    fn catches_generic_position_any() {
207        assert!(!verbatim_violations("const h: Record<string, any[]> = {};").is_empty());
208        assert!(!verbatim_violations("type T = Array<any>;").is_empty());
209    }
210
211    #[test]
212    fn catches_colon_any_with_no_space() {
213        assert!(!verbatim_violations("function f(x:any) {}").is_empty());
214    }
215
216    /// Review of #1308, finding 4: `is_any`'s original plain-substring match
217    /// flagged `any[]`/`any>`/`<any` wherever they appeared, including
218    /// inside an unrelated identifier — a real hazard here specifically,
219    /// since this scans generated TypeScript carrying Bynk-author schema
220    /// names nobody on this team can rename to dodge a false positive.
221    #[test]
222    fn does_not_false_positive_on_any_as_a_substring_of_a_real_identifier() {
223        assert!(verbatim_violations("type Fleet = Company[];").is_empty());
224        assert!(verbatim_violations("const x: Record<string, Company> = {};").is_empty());
225        assert!(verbatim_violations("type T = Map<anything, string>;").is_empty());
226    }
227
228    #[test]
229    fn catches_enum_as_a_real_keyword_not_a_substring() {
230        assert_eq!(
231            verbatim_violations("enum Colour { Red, Green }")[0].construct,
232            "enum"
233        );
234        assert!(verbatim_violations("function enumerate(x: string) {}").is_empty());
235        assert!(verbatim_violations("const myEnum = 1;").is_empty());
236    }
237
238    #[test]
239    fn catches_namespace_as_a_real_keyword_not_a_substring() {
240        assert_eq!(
241            verbatim_violations("namespace Foo { export const x = 1; }")[0].construct,
242            "namespace"
243        );
244        assert!(verbatim_violations("const namespaced = true;").is_empty());
245    }
246
247    #[test]
248    fn catches_a_leading_decorator() {
249        assert_eq!(
250            verbatim_violations("  @Injectable()")[0].construct,
251            "decorator"
252        );
253        assert_eq!(
254            verbatim_violations("@Component({ selector: \"x\" })")[0].construct,
255            "decorator"
256        );
257        // A bare `@` with no following identifier (an email-shaped string
258        // literal fragment, say) isn't a decorator.
259        assert!(verbatim_violations("const s = \"a@b.com\";").is_empty());
260    }
261
262    #[test]
263    fn catches_constructor_parameter_properties() {
264        assert_eq!(
265            verbatim_violations("constructor(private deps: Deps) {}")[0].construct,
266            "constructor parameter property"
267        );
268        assert_eq!(
269            verbatim_violations("constructor(a: A, readonly b: B) {}")[0].construct,
270            "constructor parameter property"
271        );
272        // The de-sugared shape `bynk-emit` actually emits — a plain param,
273        // assigned in the body — is not a parameter property.
274        assert!(
275            verbatim_violations("constructor(deps: { Log: unknown }) { this.deps = deps; }")
276                .is_empty()
277        );
278    }
279
280    /// Review of #1308, finding 5: the original scan ran to end of line, so
281    /// a `readonly`-shaped string *inside the constructor's own body* (not
282    /// its parameter list) false-positived — contradicting the function's
283    /// own doc comment, which already claimed the scan was parameter-list-
284    /// scoped.
285    #[test]
286    fn does_not_false_positive_on_the_constructor_body() {
287        assert!(
288            verbatim_violations("constructor(deps: Deps) { this.mode = \"readonly access\"; }")
289                .is_empty()
290        );
291    }
292
293    #[test]
294    fn clean_typescript_produces_no_violations() {
295        let text = "export function add(a: number, b: number): number {\n  return a + b;\n}\n";
296        assert!(verbatim_violations(text).is_empty());
297    }
298
299    #[test]
300    fn scans_every_offending_line_not_just_the_first() {
301        let text = "enum A { X }\nconst y: any = 1;\n";
302        let violations = verbatim_violations(text);
303        assert_eq!(violations.len(), 2);
304        assert_eq!(violations[0].construct, "enum");
305        assert_eq!(violations[1].construct, "TsType::Any");
306    }
307}