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}