web2cli 0.2.0__py3-none-any.whl

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 (44) hide show
  1. web2cli/__init__.py +3 -0
  2. web2cli/__main__.py +5 -0
  3. web2cli/adapter/__init__.py +0 -0
  4. web2cli/adapter/lint.py +667 -0
  5. web2cli/adapter/loader.py +157 -0
  6. web2cli/adapter/validator.py +127 -0
  7. web2cli/adapters/discord.com/web2cli.yaml +476 -0
  8. web2cli/adapters/mail.google.com/parsers/inbox.py +200 -0
  9. web2cli/adapters/mail.google.com/web2cli.yaml +52 -0
  10. web2cli/adapters/news.ycombinator.com/web2cli.yaml +356 -0
  11. web2cli/adapters/reddit.com/web2cli.yaml +233 -0
  12. web2cli/adapters/slack.com/web2cli.yaml +445 -0
  13. web2cli/adapters/stackoverflow.com/web2cli.yaml +257 -0
  14. web2cli/adapters/x.com/providers/x_graphql.py +299 -0
  15. web2cli/adapters/x.com/web2cli.yaml +449 -0
  16. web2cli/auth/__init__.py +0 -0
  17. web2cli/auth/browser_login.py +820 -0
  18. web2cli/auth/manager.py +166 -0
  19. web2cli/auth/store.py +68 -0
  20. web2cli/cli.py +1286 -0
  21. web2cli/executor/__init__.py +0 -0
  22. web2cli/executor/http.py +113 -0
  23. web2cli/output/__init__.py +0 -0
  24. web2cli/output/formatter.py +116 -0
  25. web2cli/parser/__init__.py +0 -0
  26. web2cli/parser/custom.py +21 -0
  27. web2cli/parser/html_parser.py +111 -0
  28. web2cli/parser/transforms.py +127 -0
  29. web2cli/pipe.py +10 -0
  30. web2cli/providers/__init__.py +6 -0
  31. web2cli/providers/base.py +22 -0
  32. web2cli/providers/registry.py +86 -0
  33. web2cli/runtime/__init__.py +1 -0
  34. web2cli/runtime/cache.py +42 -0
  35. web2cli/runtime/engine.py +743 -0
  36. web2cli/runtime/parser.py +398 -0
  37. web2cli/runtime/template.py +52 -0
  38. web2cli/types.py +71 -0
  39. web2cli-0.2.0.dist-info/METADATA +467 -0
  40. web2cli-0.2.0.dist-info/RECORD +44 -0
  41. web2cli-0.2.0.dist-info/WHEEL +5 -0
  42. web2cli-0.2.0.dist-info/entry_points.txt +2 -0
  43. web2cli-0.2.0.dist-info/licenses/LICENSE +202 -0
  44. web2cli-0.2.0.dist-info/top_level.txt +1 -0
web2cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """web2cli — Every website is a command."""
2
+
3
+ __version__ = "0.2.0"
web2cli/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Support `python -m web2cli`."""
2
+
3
+ from web2cli.cli import app
4
+
5
+ app()
File without changes
@@ -0,0 +1,667 @@
1
+ """Semantic adapter linter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from web2cli.types import AdapterSpec
10
+ from web2cli.providers import get_provider
11
+
12
+ _TPL_RE = re.compile(r"\{\{([^{}]+)\}\}")
13
+
14
+ _VALID_AUTH_TYPES = {"cookies", "token"}
15
+ _VALID_AUTH_INJECT_TARGETS = {"header", "query", "cookie", "form"}
16
+ _VALID_AUTH_CAPTURE_SOURCES = {"request.header", "request.form"}
17
+ _VALID_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
18
+ _VALID_BODY_ENCODINGS = {"json", "form", "text", "bytes"}
19
+ _VALID_PARSE_FORMATS = {"json", "json_list", "html"}
20
+ _VALID_FIELD_OPS = {"map_lookup", "regex_replace", "append_urls", "join", "add", "template"}
21
+ _VALID_ITEM_OPS = {"flatten_tree"}
22
+ _VALID_POST_OPS = {"reverse", "sort", "limit", "filter_not_empty", "concat"}
23
+ _VALID_TRANSFORMS = {
24
+ "round",
25
+ "int",
26
+ "lowercase",
27
+ "uppercase",
28
+ "strip_html",
29
+ "timestamp",
30
+ "x_datetime",
31
+ "x_date",
32
+ }
33
+
34
+
35
+ @dataclass
36
+ class LintIssue:
37
+ level: str # error | warning
38
+ path: str
39
+ message: str
40
+
41
+
42
+ def lint_adapter(spec: AdapterSpec) -> list[LintIssue]:
43
+ """Run semantic lint checks for a loaded adapter."""
44
+ issues: list[LintIssue] = []
45
+
46
+ _lint_meta(spec, issues)
47
+ _lint_auth(spec, issues)
48
+ _lint_resources_structure(spec, issues)
49
+ _lint_commands(spec, issues)
50
+ return issues
51
+
52
+
53
+ def _lint_meta(spec: AdapterSpec, issues: list[LintIssue]) -> None:
54
+ if not spec.meta.spec_version.startswith("0.2"):
55
+ _err(
56
+ issues,
57
+ "meta.spec_version",
58
+ f"expected spec_version 0.2, got '{spec.meta.spec_version}'",
59
+ )
60
+
61
+
62
+ def _lint_auth(spec: AdapterSpec, issues: list[LintIssue]) -> None:
63
+ if not spec.auth:
64
+ return
65
+
66
+ methods = spec.auth.get("methods", [])
67
+ if not isinstance(methods, list):
68
+ _err(issues, "auth.methods", "must be a list")
69
+ return
70
+
71
+ for idx, method in enumerate(methods):
72
+ path = f"auth.methods[{idx}]"
73
+ if not isinstance(method, dict):
74
+ _err(issues, path, "must be an object")
75
+ continue
76
+
77
+ mtype = str(method.get("type", "")).lower()
78
+ if mtype and mtype not in _VALID_AUTH_TYPES:
79
+ _err(issues, f"{path}.type", f"unsupported type '{mtype}'")
80
+
81
+ inject = method.get("inject")
82
+ if inject is not None:
83
+ if not isinstance(inject, dict):
84
+ _err(issues, f"{path}.inject", "must be an object")
85
+ else:
86
+ target = str(inject.get("target", "")).lower()
87
+ if target not in _VALID_AUTH_INJECT_TARGETS:
88
+ _err(
89
+ issues,
90
+ f"{path}.inject.target",
91
+ f"unsupported target '{target}', expected one of "
92
+ f"{sorted(_VALID_AUTH_INJECT_TARGETS)}",
93
+ )
94
+ if not inject.get("key"):
95
+ _err(issues, f"{path}.inject.key", "is required when inject is set")
96
+
97
+ capture = method.get("capture")
98
+ if capture is None:
99
+ continue
100
+ if not isinstance(capture, dict):
101
+ _err(issues, f"{path}.capture", "must be an object")
102
+ continue
103
+ if mtype != "token":
104
+ _err(issues, f"{path}.capture", "is supported only for token auth methods")
105
+ continue
106
+
107
+ source = str(capture.get("from", "")).lower()
108
+ if source not in _VALID_AUTH_CAPTURE_SOURCES:
109
+ _err(
110
+ issues,
111
+ f"{path}.capture.from",
112
+ f"unsupported source '{source}', expected one of "
113
+ f"{sorted(_VALID_AUTH_CAPTURE_SOURCES)}",
114
+ )
115
+ key = capture.get("key")
116
+ if not isinstance(key, str) or not key.strip():
117
+ _err(issues, f"{path}.capture.key", "is required")
118
+
119
+ match = capture.get("match")
120
+ if match is not None and not isinstance(match, dict):
121
+ _err(issues, f"{path}.capture.match", "must be an object")
122
+ continue
123
+ if isinstance(match, dict):
124
+ host = match.get("host")
125
+ if host is not None and (not isinstance(host, str) or not host.strip()):
126
+ _err(issues, f"{path}.capture.match.host", "must be a non-empty string")
127
+
128
+ path_regex = match.get("path_regex")
129
+ if path_regex is not None:
130
+ if not isinstance(path_regex, str) or not path_regex.strip():
131
+ _err(
132
+ issues,
133
+ f"{path}.capture.match.path_regex",
134
+ "must be a non-empty string",
135
+ )
136
+ else:
137
+ try:
138
+ re.compile(path_regex)
139
+ except re.error:
140
+ _err(
141
+ issues,
142
+ f"{path}.capture.match.path_regex",
143
+ "must be a valid regex",
144
+ )
145
+
146
+ method_name = match.get("method")
147
+ if method_name is not None:
148
+ normalized_method = str(method_name).upper()
149
+ if normalized_method not in _VALID_HTTP_METHODS:
150
+ _err(
151
+ issues,
152
+ f"{path}.capture.match.method",
153
+ f"unsupported HTTP method '{method_name}'",
154
+ )
155
+
156
+
157
+ def _lint_resources_structure(spec: AdapterSpec, issues: list[LintIssue]) -> None:
158
+ for resource_name, resource_spec in spec.resources.items():
159
+ path = f"resources.{resource_name}"
160
+ if not isinstance(resource_spec, dict):
161
+ _err(issues, path, "must be an object")
162
+ continue
163
+
164
+ request_spec = resource_spec.get("request")
165
+ if isinstance(request_spec, dict):
166
+ _lint_provider(request_spec, f"{path}.request", issues)
167
+ _lint_request_encoding(request_spec, f"{path}.request", issues)
168
+
169
+ paginate = resource_spec.get("paginate")
170
+ if isinstance(paginate, dict):
171
+ location = paginate.get("cursor_location")
172
+ if location and str(location).lower() not in {"params", "body"}:
173
+ _err(
174
+ issues,
175
+ f"{path}.paginate.cursor_location",
176
+ "must be params or body",
177
+ )
178
+
179
+
180
+ def _lint_commands(spec: AdapterSpec, issues: list[LintIssue]) -> None:
181
+ for cmd_name, cmd in spec.commands.items():
182
+ cmd_path = f"commands.{cmd_name}"
183
+ arg_names = set(cmd.args.keys())
184
+ known_steps: set[str] = set()
185
+ all_steps: set[str] = set()
186
+
187
+ for idx, raw_step in enumerate(cmd.pipeline):
188
+ step_path = f"{cmd_path}.pipeline[{idx}]"
189
+ if not isinstance(raw_step, dict):
190
+ continue
191
+
192
+ step_type = _step_type(raw_step)
193
+ if step_type is None:
194
+ continue
195
+ step_spec = raw_step.get(step_type) or {}
196
+ if not isinstance(step_spec, dict):
197
+ continue
198
+
199
+ step_name = str(step_spec.get("name") or raw_step.get("name") or f"{step_type}_{idx}")
200
+ if step_name in all_steps:
201
+ _err(issues, f"{step_path}.{step_type}.name", f"duplicate step name '{step_name}'")
202
+
203
+ if step_type == "request":
204
+ _lint_request_spec(
205
+ step_spec, f"{step_path}.request", issues, arg_names, known_steps
206
+ )
207
+ elif step_type == "resolve":
208
+ _lint_resolve_step(
209
+ spec,
210
+ step_spec,
211
+ f"{step_path}.resolve",
212
+ issues,
213
+ arg_names,
214
+ known_steps,
215
+ )
216
+ elif step_type == "fanout":
217
+ _lint_fanout_step(
218
+ step_spec,
219
+ f"{step_path}.fanout",
220
+ issues,
221
+ arg_names,
222
+ known_steps,
223
+ )
224
+ elif step_type == "parse":
225
+ _lint_parse_step(
226
+ step_spec,
227
+ f"{step_path}.parse",
228
+ issues,
229
+ arg_names,
230
+ known_steps,
231
+ )
232
+ elif step_type == "transform":
233
+ _lint_transform_step(
234
+ step_spec,
235
+ f"{step_path}.transform",
236
+ issues,
237
+ arg_names,
238
+ known_steps,
239
+ )
240
+
241
+ all_steps.add(step_name)
242
+ known_steps.add(step_name)
243
+
244
+ from_step = cmd.output.get("from_step")
245
+ if from_step and from_step not in all_steps:
246
+ _err(
247
+ issues,
248
+ f"{cmd_path}.output.from_step",
249
+ f"unknown step '{from_step}'",
250
+ )
251
+
252
+
253
+ def _lint_resolve_step(
254
+ spec: AdapterSpec,
255
+ step_spec: dict[str, Any],
256
+ path: str,
257
+ issues: list[LintIssue],
258
+ arg_names: set[str],
259
+ known_steps: set[str],
260
+ ) -> None:
261
+ _lint_templates(step_spec, path, issues, arg_names, known_steps)
262
+
263
+ resource_name = step_spec.get("resource")
264
+ if not isinstance(resource_name, str) or not resource_name:
265
+ return
266
+
267
+ resource_spec = spec.resources.get(resource_name)
268
+ if not isinstance(resource_spec, dict):
269
+ return
270
+
271
+ response_spec = resource_spec.get("response") or resource_spec.get("parse")
272
+ if isinstance(response_spec, dict):
273
+ field_names = _field_names(response_spec)
274
+ by = step_spec.get("by")
275
+ value = step_spec.get("value")
276
+ if field_names and isinstance(by, str) and by not in field_names:
277
+ _err(
278
+ issues,
279
+ f"{path}.by",
280
+ f"field '{by}' is not produced by resource '{resource_name}'",
281
+ )
282
+ if field_names and isinstance(value, str) and value not in field_names:
283
+ _err(
284
+ issues,
285
+ f"{path}.value",
286
+ f"field '{value}' is not produced by resource '{resource_name}'",
287
+ )
288
+
289
+ _lint_parse_spec(
290
+ response_spec,
291
+ f"resources.{resource_name}.response",
292
+ issues,
293
+ arg_names,
294
+ known_steps,
295
+ )
296
+
297
+ request_spec = resource_spec.get("request")
298
+ if isinstance(request_spec, dict):
299
+ _lint_request_spec(
300
+ request_spec,
301
+ f"resources.{resource_name}.request",
302
+ issues,
303
+ arg_names,
304
+ known_steps,
305
+ )
306
+
307
+
308
+ def _lint_fanout_step(
309
+ step_spec: dict[str, Any],
310
+ path: str,
311
+ issues: list[LintIssue],
312
+ arg_names: set[str],
313
+ known_steps: set[str],
314
+ ) -> None:
315
+ _lint_templates(step_spec, path, issues, arg_names, known_steps)
316
+ request_spec = step_spec.get("request")
317
+ if isinstance(request_spec, dict):
318
+ _lint_request_spec(
319
+ request_spec, f"{path}.request", issues, arg_names, known_steps
320
+ )
321
+
322
+
323
+ def _lint_parse_step(
324
+ step_spec: dict[str, Any],
325
+ path: str,
326
+ issues: list[LintIssue],
327
+ arg_names: set[str],
328
+ known_steps: set[str],
329
+ ) -> None:
330
+ from_step = step_spec.get("from")
331
+ if from_step and from_step not in known_steps:
332
+ _err(
333
+ issues,
334
+ f"{path}.from",
335
+ f"unknown step '{from_step}'",
336
+ )
337
+ _lint_parse_spec(step_spec, path, issues, arg_names, known_steps)
338
+
339
+
340
+ def _lint_transform_step(
341
+ step_spec: dict[str, Any],
342
+ path: str,
343
+ issues: list[LintIssue],
344
+ arg_names: set[str],
345
+ known_steps: set[str],
346
+ ) -> None:
347
+ from_step = step_spec.get("from")
348
+ if from_step and from_step not in known_steps:
349
+ _err(
350
+ issues,
351
+ f"{path}.from",
352
+ f"unknown step '{from_step}'",
353
+ )
354
+ _lint_templates(step_spec, path, issues, arg_names, known_steps)
355
+ _lint_post_ops(step_spec.get("ops"), f"{path}.ops", issues, known_steps)
356
+
357
+
358
+ def _lint_request_spec(
359
+ request_spec: dict[str, Any],
360
+ path: str,
361
+ issues: list[LintIssue],
362
+ arg_names: set[str],
363
+ known_steps: set[str],
364
+ ) -> None:
365
+ _lint_provider(request_spec, path, issues)
366
+ _lint_request_encoding(request_spec, path, issues)
367
+ _lint_templates(request_spec, path, issues, arg_names, known_steps)
368
+
369
+
370
+ def _lint_provider(
371
+ request_spec: dict[str, Any],
372
+ path: str,
373
+ issues: list[LintIssue],
374
+ ) -> None:
375
+ provider_name = request_spec.get("provider")
376
+ if not provider_name:
377
+ return
378
+ try:
379
+ get_provider(str(provider_name))
380
+ except Exception as e:
381
+ _err(issues, f"{path}.provider", str(e))
382
+
383
+
384
+ def _lint_request_encoding(
385
+ request_spec: dict[str, Any],
386
+ path: str,
387
+ issues: list[LintIssue],
388
+ ) -> None:
389
+ body = request_spec.get("body")
390
+ if not isinstance(body, dict):
391
+ return
392
+ if "encoding" not in body:
393
+ return
394
+ encoding = str(body.get("encoding", "")).lower()
395
+ if encoding not in _VALID_BODY_ENCODINGS:
396
+ _err(
397
+ issues,
398
+ f"{path}.body.encoding",
399
+ f"unsupported encoding '{encoding}'",
400
+ )
401
+
402
+
403
+ def _lint_parse_spec(
404
+ parse_spec: dict[str, Any],
405
+ path: str,
406
+ issues: list[LintIssue],
407
+ arg_names: set[str],
408
+ known_steps: set[str],
409
+ ) -> None:
410
+ parser = parse_spec.get("parser")
411
+ if parser == "custom":
412
+ _warn(
413
+ issues,
414
+ f"{path}.parser",
415
+ "custom parser reduces portability; prefer declarative parse",
416
+ )
417
+ return
418
+
419
+ fmt = str(parse_spec.get("format", "json")).lower()
420
+ if fmt not in _VALID_PARSE_FORMATS:
421
+ _err(issues, f"{path}.format", f"unsupported format '{fmt}'")
422
+
423
+ _lint_templates(parse_spec, path, issues, arg_names, known_steps)
424
+ _lint_item_ops(parse_spec.get("item_ops"), f"{path}.item_ops", issues)
425
+ _lint_fields(parse_spec.get("fields"), f"{path}.fields", issues, known_steps)
426
+ _lint_post_ops(parse_spec.get("post_ops"), f"{path}.post_ops", issues, known_steps)
427
+
428
+
429
+ def _lint_fields(
430
+ fields: Any,
431
+ path: str,
432
+ issues: list[LintIssue],
433
+ known_steps: set[str],
434
+ ) -> None:
435
+ if fields is None:
436
+ return
437
+ if not isinstance(fields, list):
438
+ _err(issues, path, "must be a list")
439
+ return
440
+
441
+ for idx, field in enumerate(fields):
442
+ fpath = f"{path}[{idx}]"
443
+ if not isinstance(field, dict):
444
+ _err(issues, fpath, "must be an object")
445
+ continue
446
+
447
+ transform = field.get("transform")
448
+ if isinstance(transform, str) and not _is_known_transform(transform):
449
+ _err(
450
+ issues,
451
+ f"{fpath}.transform",
452
+ f"unsupported transform '{transform}'",
453
+ )
454
+
455
+ ops = field.get("ops")
456
+ if ops is not None:
457
+ if not isinstance(ops, list):
458
+ _err(issues, f"{fpath}.ops", "must be a list")
459
+ else:
460
+ for op_idx, op in enumerate(ops):
461
+ _lint_field_op(op, f"{fpath}.ops[{op_idx}]", issues, known_steps)
462
+
463
+
464
+ def _lint_field_op(
465
+ op: Any,
466
+ path: str,
467
+ issues: list[LintIssue],
468
+ known_steps: set[str],
469
+ ) -> None:
470
+ if isinstance(op, str):
471
+ if not _is_known_transform(op):
472
+ _err(issues, path, f"unsupported transform '{op}'")
473
+ return
474
+
475
+ if not isinstance(op, dict) or len(op) != 1:
476
+ _err(issues, path, "must be either a transform name or single-key object")
477
+ return
478
+
479
+ op_name, cfg = next(iter(op.items()))
480
+ if op_name not in _VALID_FIELD_OPS:
481
+ _err(issues, path, f"unsupported field op '{op_name}'")
482
+ return
483
+
484
+ if op_name == "map_lookup" and isinstance(cfg, dict):
485
+ expr = cfg.get("from")
486
+ if isinstance(expr, str):
487
+ _lint_ctx_expression(expr, f"{path}.map_lookup.from", issues, known_steps)
488
+
489
+
490
+ def _lint_item_ops(
491
+ ops: Any,
492
+ path: str,
493
+ issues: list[LintIssue],
494
+ ) -> None:
495
+ if ops is None:
496
+ return
497
+ if not isinstance(ops, list):
498
+ _err(issues, path, "must be a list")
499
+ return
500
+
501
+ for idx, op in enumerate(ops):
502
+ ipath = f"{path}[{idx}]"
503
+ if not isinstance(op, dict) or len(op) != 1:
504
+ _err(issues, ipath, "must be a single-key object")
505
+ continue
506
+ name = next(iter(op.keys()))
507
+ if name not in _VALID_ITEM_OPS:
508
+ _err(issues, ipath, f"unsupported item op '{name}'")
509
+
510
+
511
+ def _lint_post_ops(
512
+ ops: Any,
513
+ path: str,
514
+ issues: list[LintIssue],
515
+ known_steps: set[str],
516
+ ) -> None:
517
+ if ops is None:
518
+ return
519
+ if not isinstance(ops, list):
520
+ _err(issues, path, "must be a list")
521
+ return
522
+
523
+ for idx, op in enumerate(ops):
524
+ opath = f"{path}[{idx}]"
525
+ if isinstance(op, str):
526
+ if op != "reverse":
527
+ _err(issues, opath, f"unsupported post op '{op}'")
528
+ continue
529
+
530
+ if not isinstance(op, dict) or len(op) != 1:
531
+ _err(issues, opath, "must be 'reverse' or single-key object")
532
+ continue
533
+
534
+ name, cfg = next(iter(op.items()))
535
+ if name not in _VALID_POST_OPS:
536
+ _err(issues, opath, f"unsupported post op '{name}'")
537
+ continue
538
+
539
+ if name == "concat":
540
+ if not isinstance(cfg, dict):
541
+ _err(issues, f"{opath}.concat", "must be an object")
542
+ continue
543
+ step_names = cfg.get("steps", [])
544
+ if isinstance(step_names, str):
545
+ step_names = [step_names]
546
+ if not isinstance(step_names, list):
547
+ _err(issues, f"{opath}.concat.steps", "must be a list of step names")
548
+ continue
549
+ for sid, step_name in enumerate(step_names):
550
+ if not isinstance(step_name, str):
551
+ _err(issues, f"{opath}.concat.steps[{sid}]", "must be a string")
552
+ continue
553
+ if step_name not in known_steps:
554
+ _err(
555
+ issues,
556
+ f"{opath}.concat.steps[{sid}]",
557
+ f"unknown step '{step_name}'",
558
+ )
559
+
560
+
561
+ def _lint_templates(
562
+ value: Any,
563
+ path: str,
564
+ issues: list[LintIssue],
565
+ arg_names: set[str],
566
+ known_steps: set[str],
567
+ ) -> None:
568
+ for expr, expr_path in _iter_templates(value, path):
569
+ _lint_template_expr(expr, expr_path, issues, arg_names, known_steps)
570
+
571
+
572
+ def _iter_templates(value: Any, path: str):
573
+ if isinstance(value, str):
574
+ for match in _TPL_RE.finditer(value):
575
+ yield match.group(1).strip(), path
576
+ return
577
+
578
+ if isinstance(value, list):
579
+ for idx, item in enumerate(value):
580
+ yield from _iter_templates(item, f"{path}[{idx}]")
581
+ return
582
+
583
+ if isinstance(value, dict):
584
+ for key, item in value.items():
585
+ key_path = f"{path}.{key}" if path else str(key)
586
+ yield from _iter_templates(item, key_path)
587
+
588
+
589
+ def _lint_template_expr(
590
+ expr: str,
591
+ path: str,
592
+ issues: list[LintIssue],
593
+ arg_names: set[str],
594
+ known_steps: set[str],
595
+ ) -> None:
596
+ root = _expr_root(expr)
597
+ if root is None:
598
+ return
599
+
600
+ if root == "args":
601
+ match = re.match(r"^\s*args\.([A-Za-z_][A-Za-z0-9_]*)", expr)
602
+ if match and match.group(1) not in arg_names:
603
+ _err(issues, path, f"template references unknown arg '{match.group(1)}'")
604
+ return
605
+
606
+ if root == "steps":
607
+ match = re.match(r"^\s*steps\.([A-Za-z_][A-Za-z0-9_]*)", expr)
608
+ if match and match.group(1) not in known_steps:
609
+ _err(issues, path, f"template references unknown step '{match.group(1)}'")
610
+ return
611
+
612
+ if root in {"item", "index", "auth", "value"}:
613
+ return
614
+
615
+ # Short-form arg expression: {{query}}
616
+ if root in arg_names:
617
+ return
618
+
619
+
620
+ def _lint_ctx_expression(
621
+ expr: str,
622
+ path: str,
623
+ issues: list[LintIssue],
624
+ known_steps: set[str],
625
+ ) -> None:
626
+ match = re.match(r"^\s*steps\.([A-Za-z_][A-Za-z0-9_]*)", expr)
627
+ if match and match.group(1) not in known_steps:
628
+ _err(issues, path, f"references unknown step '{match.group(1)}'")
629
+
630
+
631
+ def _expr_root(expr: str) -> str | None:
632
+ match = re.match(r"^\s*([A-Za-z_][A-Za-z0-9_]*)", expr)
633
+ return match.group(1) if match else None
634
+
635
+
636
+ def _is_known_transform(name: str) -> bool:
637
+ if name in _VALID_TRANSFORMS:
638
+ return True
639
+ if name.startswith("truncate:"):
640
+ return True
641
+ return False
642
+
643
+
644
+ def _field_names(parse_spec: dict[str, Any]) -> set[str]:
645
+ fields = parse_spec.get("fields")
646
+ if not isinstance(fields, list):
647
+ return set()
648
+ names = set()
649
+ for field in fields:
650
+ if isinstance(field, dict) and isinstance(field.get("name"), str):
651
+ names.add(field["name"])
652
+ return names
653
+
654
+
655
+ def _step_type(raw_step: dict[str, Any]) -> str | None:
656
+ for key in ("request", "resolve", "fanout", "parse", "transform"):
657
+ if key in raw_step:
658
+ return key
659
+ return None
660
+
661
+
662
+ def _err(issues: list[LintIssue], path: str, message: str) -> None:
663
+ issues.append(LintIssue(level="error", path=path, message=message))
664
+
665
+
666
+ def _warn(issues: list[LintIssue], path: str, message: str) -> None:
667
+ issues.append(LintIssue(level="warning", path=path, message=message))