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}