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. A `//`
13//! or `/* */` comment line, and the contents of a `"..."`/`'...'`/
14//! `` `...` `` string literal, are blanked before matching — #1538's own
15//! real gap, found wiring this over real compiled output: a message string
16//! naming `namespace` in prose (`"...requires a KV namespace binding..."`)
17//! is not a `namespace` declaration.
18//!
19//! #1538 wires this into `bynkc/tests/tsc_verify.rs`, over every
20//! `Verbatim`/`VerbatimExpr` leaf `TsProgram::verbatim_content` finds by
21//! walking a compiled fixture's tree — real, CI-visible `Verbatim` content
22//! to check, closing Decision F's own deferral ("wiring it into a real
23//! CI-visible check over compiled output is meaningful only once real
24//! `Verbatim` content exists to check").
25
26/// One construct [`verbatim_violations`] found, and the line it was on.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Violation {
29    pub construct: &'static str,
30    pub line: String,
31}
32
33/// Scan `text` (a `Verbatim` statement's own wrapped TypeScript) for every
34/// line matching one of the six banned constructs. Order of the checks
35/// within a line matters only for which `construct` label a line already
36/// matching two patterns gets — real emitted lines don't do that in
37/// practice, so the first match wins and the rest of that line isn't
38/// checked further.
39pub fn verbatim_violations(text: &str) -> Vec<Violation> {
40    let mut out = Vec::new();
41    let mut in_block_comment = false;
42    for line in text.lines() {
43        if in_block_comment {
44            if let Some(end) = line.find("*/") {
45                in_block_comment = false;
46                // Only the text after the closing `*/` is real code — but no
47                // real emitted line today has code following a block
48                // comment's close on the same line, so, matching this
49                // module's own "no inline trailing comment" simplicity for
50                // `//`, the rest of the line is skipped rather than
51                // re-scanned from `end + 2`.
52                let _ = end;
53            }
54            continue;
55        }
56        if is_line_comment(line) {
57            continue;
58        }
59        if let Some(start) = line.find("/*") {
60            if let Some(end) = line[start..].find("*/") {
61                // A block comment that opens and closes on the same line —
62                // blank it out and scan what's left, the same treatment a
63                // string literal gets below.
64                let mut blanked = line.to_string();
65                blank_range(&mut blanked, start, start + end + 2);
66                if let Some(construct) = detect(&blank_strings(&blanked)) {
67                    out.push(Violation {
68                        construct,
69                        line: line.to_string(),
70                    });
71                }
72                continue;
73            }
74            in_block_comment = true;
75            continue;
76        }
77        let scanned = blank_strings(line);
78        if let Some(construct) = detect(&scanned) {
79            out.push(Violation {
80                construct,
81                line: line.to_string(),
82            });
83        }
84    }
85    out
86}
87
88/// True if `line`, trimmed, is a `//` line comment — the same check `xtask`'s
89/// own `ts_any` probe (`is_line_comment`, `xtask/src/greenfield_status.rs`)
90/// already applies to the identical `any`/keyword patterns over Rust source;
91/// this module's own doc claims parity with that probe's posture, but never
92/// actually had this exclusion until the first real invocation over compiled
93/// output (#1538) found it missing — a standalone comment line naming
94/// `namespace` in prose (`// A minimal structural view of the Cloudflare
95/// Durable Object namespace/stub`) is not a `namespace` declaration. Doesn't
96/// attempt an inline `//` after real code on the same line — the same
97/// simplicity `xtask`'s own version accepts, and no real emitted line does
98/// either today.
99fn is_line_comment(line: &str) -> bool {
100    line.trim_start().starts_with("//")
101}
102
103/// Replace `text[start..end]` with spaces, byte-for-byte (so every later
104/// column offset stays valid) — every replaced byte becomes a single-byte
105/// ASCII space, so the result is valid UTF-8 regardless of what multi-byte
106/// characters `text` held there.
107fn blank_range(text: &mut String, start: usize, end: usize) {
108    let mut bytes = std::mem::take(text).into_bytes();
109    for b in &mut bytes[start..end] {
110        *b = b' ';
111    }
112    *text = String::from_utf8(bytes).expect("blanking ASCII bytes keeps the string valid UTF-8");
113}
114
115/// Blank out the contents of every `"..."`/`'...'`/`` `...` `` string literal
116/// on `line` (delimiters kept, so column positions and the surrounding
117/// `detect` patterns' own delimiter-adjacent matches are unaffected) —
118/// #1538's own real gap found wiring this lint over real compiled output:
119/// `"bynk.cloudflare.Kv requires a KV namespace binding …"` is a message
120/// string, not a `namespace` declaration, and nothing before this scan
121/// distinguished the two. A single-pass state machine over one delimiter at
122/// a time (not nested — TypeScript string literals can't nest unescaped),
123/// tracking `\`-escapes so an escaped quote doesn't end the literal early.
124/// Template-literal `${...}` interpolation is not specially handled (a
125/// `namespace`/`any` keyword inside one would still be blanked as literal
126/// text) — no real emitted `Verbatim`/`VerbatimExpr` content uses one today.
127fn blank_strings(line: &str) -> String {
128    let mut out: Vec<u8> = line.as_bytes().to_vec();
129    let mut i = 0;
130    while i < out.len() {
131        let c = out[i];
132        if c == b'"' || c == b'\'' || c == b'`' {
133            let delim = c;
134            let mut j = i + 1;
135            while j < out.len() {
136                if out[j] == b'\\' && j + 1 < out.len() {
137                    j += 2;
138                    continue;
139                }
140                if out[j] == delim {
141                    break;
142                }
143                j += 1;
144            }
145            let end = j.min(out.len());
146            for b in &mut out[i + 1..end] {
147                *b = b' ';
148            }
149            i = j + 1;
150        } else {
151            i += 1;
152        }
153    }
154    // Safe: every byte written above is ASCII (a space), and the rest of
155    // `out` is copied unchanged from `line`'s own valid UTF-8 bytes — a
156    // multi-byte character's continuation bytes are never a `\`/quote/
157    // backtick (all ASCII-range), so this never splits one.
158    String::from_utf8(out).unwrap_or_else(|_| line.to_string())
159}
160
161fn detect(line: &str) -> Option<&'static str> {
162    if is_any(line) {
163        return Some("TsType::Any");
164    }
165    if contains_keyword(line, "enum") {
166        return Some("enum");
167    }
168    if contains_keyword(line, "namespace") {
169        return Some("namespace");
170    }
171    if is_decorator(line) {
172        return Some("decorator");
173    }
174    if is_constructor_parameter_property(line) {
175        return Some("constructor parameter property");
176    }
177    None
178}
179
180/// The same five shapes `xtask`'s own `ts_any` probe (`line_violates_ts_any`)
181/// checks against Rust source — `as any`, bare `: any`, generic-position
182/// `<any`/`any>`/`any[]` — but *not* that probe's own plain-substring
183/// matching (review of #1308, finding 4): that probe scans Bynk's own Rust
184/// source, a corpus the team can rename around a false positive; this scans
185/// generated TypeScript carrying arbitrary Bynk-author identifiers nobody
186/// here controls (`Company[]`, `Record<string, Company>`, `Map<anything,
187/// …>` all contain one of the five substrings and none is `Any`), so a
188/// false positive here is a user-facing build failure with no user-side
189/// fix. `any` is matched as its own word first (reusing [`contains_keyword`]'s
190/// boundary rule), then classified by what's immediately around it.
191fn is_any(line: &str) -> bool {
192    let bytes = line.as_bytes();
193    let mut start = 0;
194    while let Some(rel) = line[start..].find("any") {
195        let i = start + rel;
196        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
197        let after_ok = i + 3 >= bytes.len() || !is_ident_byte(bytes[i + 3]);
198        if before_ok && after_ok && is_any_type_position(&line[..i], &line[i + 3..]) {
199            return true;
200        }
201        start = i + 3;
202    }
203    false
204}
205
206/// Whether a word-bounded `any` sits in a type position: `as any`, `: any`
207/// (with or without the space — `const x:any` is real, emitted-output-
208/// unlikely but still a live pattern to catch), a generic open (`<any`), a
209/// generic close (`any>`), or an array (`any[]`). `before`/`after` are the
210/// line's text on each side of the matched word.
211fn is_any_type_position(before: &str, after: &str) -> bool {
212    let trimmed = before.trim_end();
213    if trimmed.ends_with("as") {
214        let as_start_ok =
215            trimmed.len() == 2 || !is_ident_byte(trimmed.as_bytes()[trimmed.len() - 3]);
216        if as_start_ok {
217            return true;
218        }
219    }
220    if trimmed.ends_with(':') || trimmed.ends_with('<') {
221        return true;
222    }
223    after.starts_with('>') || after.starts_with("[]")
224}
225
226/// Whether `keyword` appears in `line` as a real word — not as a substring
227/// of a longer identifier (`enum` inside `enumerate`, `namespace` inside
228/// `MyNamespaceThing`).
229fn contains_keyword(line: &str, keyword: &str) -> bool {
230    let bytes = line.as_bytes();
231    let klen = keyword.len();
232    let mut start = 0;
233    while let Some(rel) = line[start..].find(keyword) {
234        let i = start + rel;
235        let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
236        let after_ok = i + klen >= bytes.len() || !is_ident_byte(bytes[i + klen]);
237        if before_ok && after_ok {
238            return true;
239        }
240        start = i + klen;
241    }
242    false
243}
244
245fn is_ident_byte(b: u8) -> bool {
246    b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
247}
248
249/// A TypeScript decorator: `@Identifier` at the start of a (trimmed) line —
250/// `@Injectable()`, `@Component({ ... })`. Emitted output never has a
251/// legitimate `@` at line-start otherwise (no JSDoc `@param` lines survive
252/// into `Verbatim` text; those live in comments this scan doesn't need to
253/// special-case since a `@param` line's next character is a space, not an
254/// identifier start).
255fn is_decorator(line: &str) -> bool {
256    let trimmed = line.trim_start();
257    trimmed
258        .strip_prefix('@')
259        .and_then(|rest| rest.chars().next())
260        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
261}
262
263/// A constructor parameter property: `private`/`public`/`protected`/
264/// `readonly` inside a `constructor(...)`'s own parameter list — the one
265/// type-directed construct pure strip-only stripping cannot erase (ADR
266/// 0136's own strip-only rationale, already the reason `emitter/emit.rs`'s
267/// own provider constructor de-sugars away from this shape by hand). Scoped
268/// to *only* the text between `constructor(` and its matching close paren
269/// (review of #1308, finding 5: the first version scanned to end of line,
270/// so `constructor(deps: Deps) { this.mode = "readonly access"; }` — a
271/// `readonly`-shaped *string literal* in the constructor's own body —
272/// false-positived, contradicting this doc comment's own claim).
273/// Paren-depth tracked, not `{}`-depth: a parameter's own object type
274/// (`constructor(deps: { Log: unknown })`) carries braces the scan must
275/// walk straight through, so only `(`/`)` count.
276fn is_constructor_parameter_property(line: &str) -> bool {
277    let Some(after) = line
278        .find("constructor(")
279        .map(|i| &line[i + "constructor(".len()..])
280    else {
281        return false;
282    };
283    let mut depth = 1i32;
284    let mut end = after.len();
285    for (idx, c) in after.char_indices() {
286        match c {
287            '(' => depth += 1,
288            ')' => {
289                depth -= 1;
290                if depth == 0 {
291                    end = idx;
292                    break;
293                }
294            }
295            _ => {}
296        }
297    }
298    let params = &after[..end];
299    ["private ", "public ", "protected ", "readonly "]
300        .iter()
301        .any(|kw| params.contains(kw))
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn catches_as_any_and_bare_colon_any() {
310        assert_eq!(
311            verbatim_violations("const x = (value as any).field;"),
312            vec![Violation {
313                construct: "TsType::Any",
314                line: "const x = (value as any).field;".to_string(),
315            }]
316        );
317        assert!(verbatim_violations("const x: unknown = value;").is_empty());
318    }
319
320    #[test]
321    fn catches_generic_position_any() {
322        assert!(!verbatim_violations("const h: Record<string, any[]> = {};").is_empty());
323        assert!(!verbatim_violations("type T = Array<any>;").is_empty());
324    }
325
326    #[test]
327    fn catches_colon_any_with_no_space() {
328        assert!(!verbatim_violations("function f(x:any) {}").is_empty());
329    }
330
331    /// Review of #1308, finding 4: `is_any`'s original plain-substring match
332    /// flagged `any[]`/`any>`/`<any` wherever they appeared, including
333    /// inside an unrelated identifier — a real hazard here specifically,
334    /// since this scans generated TypeScript carrying Bynk-author schema
335    /// names nobody on this team can rename to dodge a false positive.
336    #[test]
337    fn does_not_false_positive_on_any_as_a_substring_of_a_real_identifier() {
338        assert!(verbatim_violations("type Fleet = Company[];").is_empty());
339        assert!(verbatim_violations("const x: Record<string, Company> = {};").is_empty());
340        assert!(verbatim_violations("type T = Map<anything, string>;").is_empty());
341    }
342
343    #[test]
344    fn catches_enum_as_a_real_keyword_not_a_substring() {
345        assert_eq!(
346            verbatim_violations("enum Colour { Red, Green }")[0].construct,
347            "enum"
348        );
349        assert!(verbatim_violations("function enumerate(x: string) {}").is_empty());
350        assert!(verbatim_violations("const myEnum = 1;").is_empty());
351    }
352
353    #[test]
354    fn catches_namespace_as_a_real_keyword_not_a_substring() {
355        assert_eq!(
356            verbatim_violations("namespace Foo { export const x = 1; }")[0].construct,
357            "namespace"
358        );
359        assert!(verbatim_violations("const namespaced = true;").is_empty());
360    }
361
362    #[test]
363    fn catches_a_leading_decorator() {
364        assert_eq!(
365            verbatim_violations("  @Injectable()")[0].construct,
366            "decorator"
367        );
368        assert_eq!(
369            verbatim_violations("@Component({ selector: \"x\" })")[0].construct,
370            "decorator"
371        );
372        // A bare `@` with no following identifier (an email-shaped string
373        // literal fragment, say) isn't a decorator.
374        assert!(verbatim_violations("const s = \"[email protected]\";").is_empty());
375    }
376
377    #[test]
378    fn catches_constructor_parameter_properties() {
379        assert_eq!(
380            verbatim_violations("constructor(private deps: Deps) {}")[0].construct,
381            "constructor parameter property"
382        );
383        assert_eq!(
384            verbatim_violations("constructor(a: A, readonly b: B) {}")[0].construct,
385            "constructor parameter property"
386        );
387        // The de-sugared shape `bynk-emit` actually emits — a plain param,
388        // assigned in the body — is not a parameter property.
389        assert!(
390            verbatim_violations("constructor(deps: { Log: unknown }) { this.deps = deps; }")
391                .is_empty()
392        );
393    }
394
395    /// Review of #1308, finding 5: the original scan ran to end of line, so
396    /// a `readonly`-shaped string *inside the constructor's own body* (not
397    /// its parameter list) false-positived — contradicting the function's
398    /// own doc comment, which already claimed the scan was parameter-list-
399    /// scoped.
400    #[test]
401    fn does_not_false_positive_on_the_constructor_body() {
402        assert!(
403            verbatim_violations("constructor(deps: Deps) { this.mode = \"readonly access\"; }")
404                .is_empty()
405        );
406    }
407
408    #[test]
409    fn clean_typescript_produces_no_violations() {
410        let text = "export function add(a: number, b: number): number {\n  return a + b;\n}\n";
411        assert!(verbatim_violations(text).is_empty());
412    }
413
414    #[test]
415    fn scans_every_offending_line_not_just_the_first() {
416        let text = "enum A { X }\nconst y: any = 1;\n";
417        let violations = verbatim_violations(text);
418        assert_eq!(violations.len(), 2);
419        assert_eq!(violations[0].construct, "enum");
420        assert_eq!(violations[1].construct, "TsType::Any");
421    }
422
423    /// #1538's own real gap: a message string naming a banned construct in
424    /// prose is not the construct itself.
425    #[test]
426    fn does_not_false_positive_on_a_string_literal_naming_a_construct() {
427        assert!(
428            verbatim_violations(
429                "throw new Error(\"bynk.cloudflare.Kv requires a KV namespace binding\");"
430            )
431            .is_empty()
432        );
433        assert!(verbatim_violations("const msg = 'cast as any if unsure';").is_empty());
434    }
435
436    /// A string literal's own delimiters are blanked-around, not removed —
437    /// a real violation immediately after a string on the same line must
438    /// still be caught.
439    #[test]
440    fn still_catches_a_real_violation_after_a_string_literal_on_the_same_line() {
441        assert_eq!(
442            verbatim_violations("const s: any = \"a namespace-like string\";")[0].construct,
443            "TsType::Any"
444        );
445    }
446
447    /// #1538's own real gap: a `/** ... */` JSDoc block naming a construct
448    /// in prose is not the construct itself, whether the block is single- or
449    /// multi-line.
450    #[test]
451    fn does_not_false_positive_on_a_block_comment() {
452        assert!(verbatim_violations("/** A Durable Object namespace stub. */").is_empty());
453        let multiline =
454            "/**\n * Cast through `any` when the shape is unknown.\n */\nconst x = 1;\n";
455        assert!(verbatim_violations(multiline).is_empty());
456    }
457
458    /// A same-line block comment doesn't swallow a real violation that
459    /// follows it on the same line.
460    #[test]
461    fn still_catches_a_real_violation_after_a_same_line_block_comment() {
462        assert_eq!(
463            verbatim_violations("/* namespace-like prose */ const x: any = 1;")[0].construct,
464            "TsType::Any"
465        );
466    }
467}