@adia-ai/mcp 0.8.37 → 0.8.39

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.
@@ -0,0 +1,396 @@
1
+ #!/usr/bin/env python3
2
+ """adia-lint — advisory authoring-smell checker for adia-ui apps (the mechanizable slice).
3
+
4
+ SCOPE (be honest): pattern-matchable STRUCTURAL smells in component/page source — shadow-DOM use,
5
+ raw colors, raw px ≥ 3, the dead `--a-font` token, ::slotted, width on :scope, the SSR context traps
6
+ (top-level kit import + double route-owner — detected only in files carrying a framework signal, so a
7
+ bare registration module won't trip it), hardcoded overlay `open`, native-primitive leaks (raw
8
+ `<button>`/`<input>` where a `*-ui` exists), and retired shell shapes (the ADR-0024 data-attribute
9
+ forms). Foundation/token sheets (under a
10
+ styles/ or tokens/ dir, named tokens/theme/host/palette/…, or marked `/* adia-lint: foundation */`) are
11
+ exempt from the color/px checks. It does NOT judge whether the UI is good, on-spec, or accessible — that
12
+ lives in the skills (screen-composition / host-wiring / surface-qa) and the a2ui MCP's check_anti_patterns.
13
+ A clean adia-lint says "no structural tells," never "this is right."
14
+
15
+ Shared core: the regex bank below (RAW-COLOR/PX, SCOPE-EXTENT, NATIVE-PRIMITIVE, LEGACY-SHELL,
16
+ the _is_foundation_css exemption) is mirrored in the sibling MAINTAINER plugin's
17
+ adia-ui-forge/scripts/forge-lint. They are deliberate VENDORED copies — the catalog forbids
18
+ cross-plugin imports (each plugin installs copy-alone) — so any change to a shared rule must be
19
+ reconciled in BOTH files. adia-lint keeps the consumer/app traps (SSR double-router, top-level
20
+ import, hardcoded overlay open) that forge-lint drops; that divergence is the point.
21
+
22
+ Usage:
23
+ adia-lint <file>... # lint files; exit 1 if any smell found, else 0
24
+ adia-lint - # lint stdin as a generic source file
25
+ adia-lint --hook # PostToolUse hook mode: read event JSON on stdin, lint the written
26
+ # source file, print advisory findings, ALWAYS exit 0 (never blocks)
27
+ adia-lint selftest # run built-in fixtures (seeded smells + a clean file + the hook exit-0 invariant)
28
+ Stdlib only (Python 3.8+).
29
+ """
30
+ import json
31
+ import os
32
+ import re
33
+ import sys
34
+
35
+ CODE_EXT = (".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")
36
+ STYLE_EXT = (".css",)
37
+ MARKUP_EXT = (".html", ".htm", ".vue", ".svelte", ".astro", ".tsx", ".jsx")
38
+ LINT_EXT = tuple(sorted(set(CODE_EXT + STYLE_EXT + MARKUP_EXT)))
39
+
40
+ SSR_SIGNAL = re.compile(
41
+ r"""['"]use client['"]|\buseEffect\b|\bonMounted\b|\bonMount\b|"""
42
+ r"""from\s+['"](?:react|vue|svelte|next|nuxt|@sveltejs|astro)|"""
43
+ r"""getServerSideProps|defineNuxtComponent""", re.I)
44
+
45
+ HEXCOLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b")
46
+ FUNCCOLOR = re.compile(r"\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\s*\(")
47
+ DEAD_FONT = re.compile(r"var\(\s*--a-font\s*[,)]")
48
+ SCOPE_EXTENT = re.compile(
49
+ r":scope(?:\[[^\]]*\])?\s*\{[^{}]*?\b(?:width|height|inline-size|block-size)\s*:", re.S)
50
+ BOOL_TRUE = re.compile(r"\bdefault:\s*true\b")
51
+ ATTR_TYPO = re.compile(r"\battr:\s*['\"]")
52
+ OVERLAY_OPEN = re.compile(r"<(?:modal|drawer)-ui\b[^>]*?(?<![:.\w])\bopen\b(?!\s*=\s*\{)")
53
+ NATIVE_PRIMITIVE = re.compile(r"<(?:button|input|select|textarea|dialog)(?![\w-])") # raw native, not a *-ui
54
+ LEGACY_SHELL = re.compile(
55
+ r"data-chat-(?:messages|input|empty|name)|data-editor-body|data-canvas\b|data-sidebar="
56
+ r"|data-pane-(?:side|grow)|<aside-ui\b|<dialog\s+data-command") # retired shell shapes (ADR-0024)
57
+ TOPLEVEL_IMPORT = re.compile(r"""^\s*import\s+['"]@adia-ai/web-components['"]\s*;?\s*$""")
58
+ PX_GE = re.compile(r"(?<![\w.-])(\d+)px\b") # integer px values; fractional (1.5px) deliberately not matched
59
+ FOUNDATION_OPT_IN = re.compile(r"adia-lint:\s*foundation", re.I)
60
+ # Genuine token/foundation SHEETS are exempt from color/px — matched by exact stem or a styles/ dir,
61
+ # NOT a path substring (so a `color-picker` / `theme-toggle` component is still linted).
62
+ FOUNDATION_STEMS = {"tokens", "token", "theme", "themes", "foundation", "foundations",
63
+ "palette", "palettes", "host", "reset", "resets", "scheme", "schemes",
64
+ "color", "colors"}
65
+
66
+
67
+ def _ext(path):
68
+ return os.path.splitext(path or "")[1].lower()
69
+
70
+
71
+ def _is_foundation_css(path, text):
72
+ """A genuine token/foundation sheet — exempt from color/px (component sheets are not)."""
73
+ segs = (path or "").replace("\\", "/").split("/")
74
+ if "styles" in segs or "tokens" in segs:
75
+ return True
76
+ stem = os.path.splitext(segs[-1])[0].lower() if segs else ""
77
+ if stem in FOUNDATION_STEMS:
78
+ return True
79
+ return bool(FOUNDATION_OPT_IN.search(text[:1000]))
80
+
81
+
82
+ def lint_text(text, path=""):
83
+ """Return a list of (NAME, line, snippet, why) advisory findings."""
84
+ ext = _ext(path)
85
+ findings = []
86
+ is_ssr = bool(SSR_SIGNAL.search(text)) or ext in (".vue", ".svelte", ".astro")
87
+ is_tokenish = _is_foundation_css(path, text)
88
+
89
+ for i, line in enumerate(text.splitlines(), 1):
90
+ s = line.strip()[:90]
91
+ if "attachShadow" in line:
92
+ findings.append(("SHADOW-DOM", i, s,
93
+ "adia-ui is light-DOM — never attachShadow; it breaks the token cascade + @scope"))
94
+ if "::slotted(" in line:
95
+ findings.append(("SLOTTED", i, s,
96
+ "light DOM has no ::slotted — style projected content via :scope > [slot=\"x\"]"))
97
+ if ext in STYLE_EXT:
98
+ if DEAD_FONT.search(line):
99
+ findings.append(("DEAD-FONT-TOKEN", i, s,
100
+ "--a-font is not a real token (resolves to UA serif) — floor to var(--a-font-family-ui)"))
101
+ if not is_tokenish:
102
+ for decl in line.split(";"): # per-declaration: a literal on a line that also has a var() still counts
103
+ d = decl.strip()
104
+ if not d or d.startswith(("//", "/*", "*")) or "var(" in decl or "light-dark(" in decl:
105
+ continue
106
+ if HEXCOLOR.search(decl) or FUNCCOLOR.search(decl):
107
+ findings.append(("RAW-COLOR", i, s,
108
+ "component CSS is token-only — replace the literal with var(--a-*) (foundation/token files excepted)"))
109
+ break
110
+ if not is_tokenish and not line.lstrip().startswith("@"): # skip @media/@container/@scope at-rules
111
+ for decl in line.split(";"):
112
+ if "/*" in decl: # author-annotated carve-out
113
+ continue
114
+ if any(int(v) >= 3 for v in PX_GE.findall(decl)):
115
+ findings.append(("RAW-PX", i, s,
116
+ "no raw px ≥ 3 in component CSS — use var(--a-space-*); 1–2px hairlines exempt, annotate a deliberate exception with a comment"))
117
+ break
118
+ if ext in CODE_EXT:
119
+ if BOOL_TRUE.search(line):
120
+ findings.append(("BOOL-DEFAULT-TRUE", i, s,
121
+ "a boolean prop defaulting true can't be turned off by absence — flip the name so absent = false"))
122
+ if ATTR_TYPO.search(line):
123
+ findings.append(("ATTR-TYPO", i, s,
124
+ "did you mean `attribute:`? `attr:` is silently ignored in a property definition"))
125
+ if is_ssr:
126
+ if "<router-ui" in line:
127
+ findings.append(("SSR-DOUBLE-ROUTER", i, s,
128
+ "in SSR the framework router owns routing — don't also mount <router-ui> (two route owners)"))
129
+ if TOPLEVEL_IMPORT.search(line):
130
+ findings.append(("SSR-TOPLEVEL-IMPORT", i, s,
131
+ "a top-level kit import throws `HTMLElement is not defined` on the server — defer it into a client hook"))
132
+ if ext in MARKUP_EXT and OVERLAY_OPEN.search(line):
133
+ findings.append(("HARDCODED-OPEN", i, s,
134
+ "drive overlays via the .open property, not a hardcoded `open` attribute (it bricks the page)"))
135
+ if (ext in MARKUP_EXT and "slot=" not in line and not s.startswith(("<!--", "//", "*", "/*"))
136
+ and NATIVE_PRIMITIVE.search(line)):
137
+ findings.append(("NATIVE-PRIMITIVE", i, s,
138
+ "use the *-ui primitive (button-ui / input-ui / select-ui / textarea-ui / modal-ui) — raw natives skip focus rings, theming, and form association; a deliberate slotted trigger (with slot=) is the exception"))
139
+ if LEGACY_SHELL.search(line):
140
+ findings.append(("LEGACY-SHELL", i, s,
141
+ "retired shell shape (ADR-0024, v0.4.0) — use the bespoke tag (chat-thread / chat-composer / chat-empty · admin-sidebar / admin-command · editor-canvas · pane-ui)"))
142
+
143
+ if ext in STYLE_EXT:
144
+ for m in SCOPE_EXTENT.finditer(text):
145
+ ln = text.count("\n", 0, m.start()) + 1
146
+ findings.append(("SCOPE-EXTENT", ln, ":scope { … width/height … }",
147
+ "the component is size-agnostic — let the consumer own width/height; don't set extent on :scope"))
148
+
149
+ # ── File-level shell/llm/genui checks (factory-audit Wave 2, gh#259).
150
+ # Consumer-side rules by design — NOT mirrored into forge-lint (the
151
+ # vendored-divergence note at the top of this file: adia-lint keeps the
152
+ # consumer/app traps). Each promotes a previously prose-only gate from
153
+ # the shell-selection / llm-wiring / gen-ui-wiring skills.
154
+ if ext in MARKUP_EXT or ext in (".js", ".mjs", ".ts", ".jsx", ".tsx", ".vue", ".svelte", ".astro"):
155
+ # Tag-boundary anchored (gh#1258): a bare prefix count also matches
156
+ # <admin-page-header>/<admin-page-body>/<admin-page-footer>, false-positiving
157
+ # the canonical admin skeleton itself. [\s/>] admits only the real tags.
158
+ _pages = len(re.findall(r"<admin-page[\s/>]", text))
159
+ _scrolls = len(re.findall(r"<admin-scroll[\s/>]", text))
160
+ if _scrolls and _pages > _scrolls:
161
+ findings.append(("SHELL-NESTING", 1, "<admin-page> × N inside <admin-scroll>",
162
+ "each <admin-scroll> hosts exactly one <admin-page> — multiple pages need multiple scroll regions"))
163
+ for m in re.finditer(r"<(?:col|row)-ui[^>]*>\s*<(?:admin|chat|editor)-", text):
164
+ ln = text.count("\n", 0, m.start()) + 1
165
+ findings.append(("SHELL-NESTING", ln, text[m.start():m.start() + 60].strip()[:90],
166
+ "shell children are positioned by tag selectors — wrapping them in <col-ui>/<row-ui> breaks the shell grid; generics go inside admin-content/admin-page-body"))
167
+ if re.search(r"<admin-sidebar[^>]*\bresizable\b", text) and "data-resize" not in text:
168
+ findings.append(("SHELL-RESIZE", 1, "<admin-sidebar resizable> without [data-resize]",
169
+ "[resizable] needs a child <div data-resize> or there is no drag handle"))
170
+ # server-side files legitimately hold keys — exempt any path whose
171
+ # segments mention server/api/proxy (the smart-proxy's own home).
172
+ _segs = path.replace(os.sep, "/").lower().split("/")
173
+ if not any(("server" in s or "proxy" in s or s == "api") for s in _segs):
174
+ for m in re.finditer(r"\bapi[-_]?[Kk]ey\b\s*[:=]", text):
175
+ ln = text.count("\n", 0, m.start()) + 1
176
+ findings.append(("LLM-KEY-IN-CLIENT", ln, text[m.start():m.start() + 50].strip()[:90],
177
+ "a provider key in client source ships to the browser — production uses a same-origin smart proxy (proxy-url); keys live server-side (llm-wiring)"))
178
+ doc_assign = re.search(r"\.doc\s*=", text)
179
+ if doc_assign:
180
+ ln = text.count("\n", 0, doc_assign.start()) + 1
181
+ if "validate_schema" not in text and "check_anti_patterns" not in text:
182
+ findings.append(("GENUI-UNVALIDATED", ln, ".doc = … with no validate in file",
183
+ "A2UI fed to .doc must pass validate_schema + check_anti_patterns first (gen-ui-wiring's hard gate) — validate in the same module or cite where it happened"))
184
+ if re.search(r"<(?:a2ui-root|gen-root)[^>]*\b(?:src|transport)=", text):
185
+ findings.append(("GENUI-DOC-CONFLICT", ln, ".doc = … alongside src=/transport=",
186
+ "two feed paths fight — a root is fed EITHER declaratively (src/transport) OR programmatically (.doc), never both"))
187
+
188
+ findings.sort(key=lambda f: (f[1], f[0]))
189
+ return findings
190
+
191
+
192
+ def _render(path, findings):
193
+ out = [f"adia-lint: {len(findings)} structural smell(s) in {path or '<stdin>'}"]
194
+ for name, ln, snip, why in findings:
195
+ out.append(f" [{name}] line {ln}: {snip}")
196
+ out.append(f" → {why}")
197
+ return "\n".join(out)
198
+
199
+
200
+ def _lint_path(path):
201
+ try:
202
+ with open(path, encoding="utf-8", errors="replace") as f:
203
+ return lint_text(f.read(), path)
204
+ except OSError as e:
205
+ return [("READ-ERROR", 0, path, f"failed to read file: {e}")]
206
+
207
+
208
+ def _hook():
209
+ try:
210
+ event = json.load(sys.stdin)
211
+ except (json.JSONDecodeError, ValueError):
212
+ return 0
213
+ ti = event.get("tool_input", {}) or {}
214
+ path = ti.get("file_path", "") or ""
215
+ segs = (path or "").replace("\\", "/").split("/")
216
+ if any(s in ("node_modules", "dist", "build", ".next", "coverage") for s in segs):
217
+ return 0 # generated/vendored trees are never consumer-authored source
218
+ if _ext(path) not in LINT_EXT:
219
+ return 0 # only component/page source; stay quiet otherwise
220
+ text = ti.get("content")
221
+ if text is None:
222
+ if not os.path.isfile(path):
223
+ return 0
224
+ try:
225
+ with open(path, encoding="utf-8", errors="replace") as f:
226
+ text = f.read()
227
+ except OSError:
228
+ return 0
229
+ findings = lint_text(text, path)
230
+ if findings:
231
+ # PostToolUse exit-0 stdout is NOT fed to the model — only structured
232
+ # JSON reaches it. additionalContext delivers the advisory repair loop
233
+ # while preserving the never-block invariant.
234
+ context = (
235
+ _render(path, findings)
236
+ + "\n (adia-lint advisory — adia-ui authoring smells; the skills + a2ui MCP own the judgment)"
237
+ )
238
+ print(json.dumps({
239
+ "hookSpecificOutput": {
240
+ "hookEventName": "PostToolUse",
241
+ "additionalContext": context,
242
+ }
243
+ }))
244
+ return 0 # NEVER block
245
+
246
+
247
+ def _selftest():
248
+ """Built-in fixtures: each seeded smell must fire, clean files must stay quiet, --hook must exit 0."""
249
+ import io
250
+ cases = [
251
+ ("components/x/x.css",
252
+ "@scope (x) {\n"
253
+ " :scope { color:#f00; width:100%; font-family:var(--a-font); padding:24px; }\n"
254
+ " :scope > [slot=a]::slotted(p) { margin:0; }\n"
255
+ "}",
256
+ {"RAW-COLOR", "SCOPE-EXTENT", "DEAD-FONT-TOKEN", "SLOTTED", "RAW-PX"}),
257
+ ("styles/theme.css", ":root { --a-bg: light-dark(#fff, #000); }", set()),
258
+ ("components/color-picker/color-picker.css",
259
+ "@scope (color-picker) {\n :scope { background:#abc; padding:40px; }\n}",
260
+ {"RAW-COLOR", "RAW-PX"}),
261
+ ("providers/p.tsx",
262
+ "'use client';\nimport '@adia-ai/web-components';\n"
263
+ "export default () => (<admin-shell><router-ui/><modal-ui open>x</modal-ui></admin-shell>);",
264
+ {"SSR-TOPLEVEL-IMPORT", "SSR-DOUBLE-ROUTER", "HARDCODED-OPEN"}),
265
+ ("core/clean.js",
266
+ "import { UIElement } from '@adia-ai/web-components/core/element';\n"
267
+ "class UIX extends UIElement { connected() { this.innerHTML = '<col-ui></col-ui>'; } }",
268
+ set()),
269
+ ("markup/natives.html",
270
+ "<col-ui>\n <button>Save</button>\n <input type=\"text\">\n <button-ui slot=\"trigger\">ok</button-ui>\n</col-ui>",
271
+ {"NATIVE-PRIMITIVE"}),
272
+ ("markup/legacy.html",
273
+ "<chat-shell>\n <section data-chat-messages></section>\n <chat-input-ui data-chat-input></chat-input-ui>\n</chat-shell>",
274
+ {"LEGACY-SHELL"}),
275
+ # Wave-2 shell/llm/genui rules (gh#259)
276
+ ("markup/shell-bad.html",
277
+ "<admin-shell>\n<col-ui> <admin-sidebar resizable></admin-sidebar></col-ui>\n"
278
+ "<admin-scroll><admin-page>a</admin-page><admin-page>b</admin-page></admin-scroll>\n</admin-shell>",
279
+ {"SHELL-NESTING", "SHELL-RESIZE"}),
280
+ ("markup/shell-good.html",
281
+ "<admin-shell>\n<admin-sidebar resizable><div data-resize></div></admin-sidebar>\n"
282
+ "<admin-scroll><admin-page>a</admin-page></admin-scroll>\n</admin-shell>",
283
+ set()),
284
+ # gh#1258 regression: the CANONICAL admin skeleton (shell-patterns.md's admin
285
+ # cluster — admin-page-header/body/footer are distinct CSS-only children whose
286
+ # tag names share the <admin-page prefix) must stay clean.
287
+ ("markup/shell-canonical.html",
288
+ "<admin-shell>\n<admin-content>\n<admin-scroll>\n <admin-page>\n"
289
+ " <admin-page-header><header-ui><span slot=\"heading\">Page Title</span></header-ui></admin-page-header>\n"
290
+ " <admin-page-body><section-ui>content</section-ui></admin-page-body>\n"
291
+ " <admin-page-footer><footer-ui>legal</footer-ui></admin-page-footer>\n"
292
+ " </admin-page>\n</admin-scroll>\n</admin-content>\n</admin-shell>",
293
+ set()),
294
+ # gh#1258 negative control: a REAL extra <admin-page> nested inside the same
295
+ # <admin-scroll> must still fire even with -header/-body suffix tags present.
296
+ ("markup/shell-nested-bad.html",
297
+ "<admin-scroll>\n <admin-page>\n <admin-page-header>h</admin-page-header>\n"
298
+ " <admin-page-body><admin-page>nested</admin-page></admin-page-body>\n"
299
+ " </admin-page>\n</admin-scroll>",
300
+ {"SHELL-NESTING"}),
301
+ ("app/chat-boot.js",
302
+ "const s = document.querySelector('chat-shell');\ns.apiKey = 'sk-live';\n",
303
+ {"LLM-KEY-IN-CLIENT"}),
304
+ ("api/llm-proxy.js",
305
+ "const key = { apiKey: process.env.ANTHROPIC_API_KEY };\n",
306
+ set()),
307
+ ("app/genui-boot.js",
308
+ "root.doc = generated;\n",
309
+ {"GENUI-UNVALIDATED"}),
310
+ ("app/genui-ok.js",
311
+ "await validate_schema(generated); await check_anti_patterns(generated);\nroot.doc = generated;\n",
312
+ set()),
313
+ ("app/genui-conflict.html",
314
+ "<a2ui-root src=\"/gen.json\"></a2ui-root>\n<script>await validate_schema(d); await check_anti_patterns(d); root.doc = d;</script>",
315
+ {"GENUI-DOC-CONFLICT"}),
316
+ ]
317
+ ok = True
318
+ for name, text, expected in cases:
319
+ got = {f[0] for f in lint_text(text, name)}
320
+ if expected - got:
321
+ ok = False
322
+ print(f"selftest: {name} MISSING {sorted(expected - got)} (got {sorted(got)})", file=sys.stderr)
323
+ if not expected and got:
324
+ ok = False
325
+ print(f"selftest: {name} expected clean, got {sorted(got)}", file=sys.stderr)
326
+ saved_in, saved_out = sys.stdin, sys.stdout # never-block invariant: --hook exits 0 even on smelly input
327
+ try:
328
+ sys.stdin = io.StringIO(json.dumps({"tool_input": {"file_path": "x.css",
329
+ "content": "@scope (x) {\n :scope { color:#f00; }\n}"}}))
330
+ sys.stdout = io.StringIO()
331
+ rc = _hook()
332
+ hook_out = sys.stdout.getvalue()
333
+ finally:
334
+ sys.stdin, sys.stdout = saved_in, saved_out
335
+ if rc != 0:
336
+ ok = False
337
+ print("selftest: --hook did not exit 0 on smelly input", file=sys.stderr)
338
+ if rc == 0 and hook_out and '"hookSpecificOutput"' not in hook_out:
339
+ ok = False
340
+ print("selftest: --hook stdout is not structured hookSpecificOutput JSON", file=sys.stderr)
341
+
342
+ # REQ-05 (gh#1136): -h/--help exits 0 and prints usage — never a data payload.
343
+ for flag in ("-h", "--help"):
344
+ saved_out = sys.stdout
345
+ sys.stdout = io.StringIO()
346
+ try:
347
+ rc_help = main([flag])
348
+ help_out = sys.stdout.getvalue()
349
+ finally:
350
+ sys.stdout = saved_out
351
+ if rc_help != 0:
352
+ ok = False
353
+ print(f"selftest: {flag} did not exit 0", file=sys.stderr)
354
+ if not help_out.startswith("usage:"):
355
+ ok = False
356
+ print(f"selftest: {flag} did not print usage", file=sys.stderr)
357
+
358
+ print("selftest: PASS" if ok else "selftest: FAIL")
359
+ return 0 if ok else 1
360
+
361
+
362
+ _USAGE = "usage: adia-lint <file>... | adia-lint - | adia-lint --hook | adia-lint selftest"
363
+
364
+
365
+ def main(argv):
366
+ if argv and argv[0] == "selftest":
367
+ return _selftest()
368
+ if "--hook" in argv:
369
+ return _hook()
370
+ # REQ-05 (gh#1136, factory-dx-ws5-consumer-verify): -h/--help must print
371
+ # usage and exit 0 — checked BEFORE positional-arg resolution so it can
372
+ # never fall through into "no files given" (a different contract point,
373
+ # exit 2). Previously -h/--help fell through to the no-args branch below
374
+ # and exited 2 with a docstring fragment instead of usage text.
375
+ if "-h" in argv or "--help" in argv:
376
+ print(_USAGE)
377
+ return 0
378
+ args = [a for a in argv if a == "-" or not a.startswith("-")]
379
+ if not args:
380
+ print(_USAGE, file=sys.stderr)
381
+ return 2
382
+ total = 0
383
+ for path in args:
384
+ if path == "-":
385
+ findings = lint_text(sys.stdin.read(), "<stdin>")
386
+ path = "<stdin>"
387
+ else:
388
+ findings = _lint_path(path)
389
+ if findings:
390
+ total += len(findings)
391
+ print(_render(path, findings))
392
+ return 1 if total else 0
393
+
394
+
395
+ if __name__ == "__main__":
396
+ sys.exit(main(sys.argv[1:]))