@sitar_fiercer4c/skills 0.1.0

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 (50) hide show
  1. package/LICENSE +5 -0
  2. package/README.md +75 -0
  3. package/bin/install.js +45 -0
  4. package/package.json +29 -0
  5. package/skills/architecture-walkthrough/SKILL.md +223 -0
  6. package/skills/architecture-walkthrough/references/sections.md +29 -0
  7. package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
  8. package/skills/autotest-webapp-ui/SKILL.md +58 -0
  9. package/skills/backend-code-review/SKILL.md +386 -0
  10. package/skills/backend-code-review/references/report-format.md +333 -0
  11. package/skills/backend-code-review/scripts/list_routes.py +269 -0
  12. package/skills/backend-code-review/scripts/sweep.py +550 -0
  13. package/skills/backend-code-review/scripts/verify_citations.py +201 -0
  14. package/skills/be-brief/SKILL.md +18 -0
  15. package/skills/clarke-list-excel/SKILL.md +51 -0
  16. package/skills/clarke-list-excel/references/output-schema.md +125 -0
  17. package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
  18. package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
  19. package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
  20. package/skills/clarke-list-excel/scripts/run_all.py +63 -0
  21. package/skills/datalab-api/SKILL.md +163 -0
  22. package/skills/datalab-api/references/parameters-and-payload.md +121 -0
  23. package/skills/datalab-api/references/table-selection.md +35 -0
  24. package/skills/datalab-api/scripts/datalab_tables.py +365 -0
  25. package/skills/find-test-seam/SKILL.md +41 -0
  26. package/skills/frontend-code-review/SKILL.md +247 -0
  27. package/skills/frontend-code-review-2/SKILL.md +192 -0
  28. package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
  29. package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
  30. package/skills/murtaza-breif/SKILL.md +143 -0
  31. package/skills/murtaza-breif/scripts/save_brief.py +128 -0
  32. package/skills/pdf-to-json/SKILL.md +42 -0
  33. package/skills/pdf-to-json/references/output-schema.md +168 -0
  34. package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
  35. package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
  36. package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
  37. package/skills/record-api-traffic/SKILL.md +434 -0
  38. package/skills/record-api-traffic/references/reading-recordings.md +224 -0
  39. package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
  40. package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
  41. package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
  42. package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
  43. package/skills/record-api-traffic/scripts/preflight.py +528 -0
  44. package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
  45. package/skills/refac-wrt-business-goal/SKILL.md +305 -0
  46. package/skills/refac-wrt-business-goal/references/critic.md +170 -0
  47. package/skills/system-resource-triage/SKILL.md +180 -0
  48. package/skills/system-resource-triage/scripts/reap.sh +116 -0
  49. package/skills/system-resource-triage/scripts/triage.sh +111 -0
  50. package/skills/using-git-worktrees/SKILL.md +167 -0
@@ -0,0 +1,550 @@
1
+ #!/usr/bin/env python3
2
+ """Whole-tree sweeps for findings that only exist in aggregate.
3
+
4
+ Reading a codebase depth-first — route to controller to service to util — is how
5
+ you find what a single file does wrong. It is structurally blind to a second
6
+ class of finding that exists only across files: an env var nothing consumes, a
7
+ helper defined identically in two modules, an export with no importer, a block
8
+ of code pasted in three places. Nobody sees those by reading carefully, because
9
+ each individual file looks fine. You see them by counting.
10
+
11
+ That is what this does. Run it before writing, and treat every row as a lead to
12
+ confirm by opening the file — the parsing is regex-based and will mis-handle
13
+ dynamic access, re-exports, and string-built identifiers.
14
+
15
+ Usage:
16
+ python3 sweep.py <src-dir> [--repo-root DIR] [--json] [--section NAME]
17
+
18
+ Sections: env, exports, dupes, blocks, request-inputs
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import sys
25
+ from collections import defaultdict
26
+ from pathlib import Path
27
+
28
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", "coverage", ".next", "__pycache__"}
29
+
30
+ ENV_READ = re.compile(r"process\.env\.([A-Za-z_][A-Za-z0-9_]*)")
31
+ ENV_READ_BRACKET = re.compile(r"process\.env\[\s*['\"]([A-Za-z_][A-Za-z0-9_]*)['\"]\s*\]")
32
+ ENV_DECL = re.compile(r"^\s*(?:#\s*)?([A-Z_][A-Z0-9_]*)\s*=")
33
+
34
+ EXPORT_NAMED = re.compile(
35
+ r"^export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)"
36
+ )
37
+ EXPORT_BLOCK = re.compile(r"^export\s*\{([^}]*)\}", re.MULTILINE)
38
+ EXPORT_DEFAULT = re.compile(r"^export\s+default\b")
39
+
40
+ TOPLEVEL_DEF = re.compile(
41
+ r"^(?:export\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)"
42
+ )
43
+
44
+ IMPORT_FROM = re.compile(r"""import\s+([^;]*?)\s+from\s+['"]([^'"]+)['"]""", re.S)
45
+ REQUIRE_DESTRUCTURE = re.compile(
46
+ r"""(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)""", re.S
47
+ )
48
+
49
+ SOURCE_SUFFIXES = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts")
50
+
51
+ REQ_CONTAINERS = ("query", "body", "headers", "params")
52
+ # `req.query.t` / `req.query['t']` — a key read straight off the request.
53
+ REQ_DOT = re.compile(
54
+ r"\breq(?:uest)?\.(" + "|".join(REQ_CONTAINERS) + r")\.([A-Za-z_$][\w$]*)"
55
+ )
56
+ REQ_BRACKET = re.compile(
57
+ r"\breq(?:uest)?\.(" + "|".join(REQ_CONTAINERS) + r")\[\s*['\"]([A-Za-z_$][\w$]*)['\"]\s*\]"
58
+ )
59
+ # `const { code, state } = req.query`
60
+ REQ_DESTRUCTURE = re.compile(
61
+ r"\{([^}]*)\}\s*=\s*req(?:uest)?\.(" + "|".join(REQ_CONTAINERS) + r")\b"
62
+ )
63
+ # `const { code, state } = query` — the same keys one layer down, after the whole
64
+ # container was handed to a service. Name-based, so it is a lead, not proof.
65
+ PROXY_DESTRUCTURE = re.compile(
66
+ r"\{([^}]*)\}\s*=\s*([A-Za-z_$][\w$]*(?:[Qq]uer(?:y|ies)|[Bb]ody|[Pp]arams?|[Hh]eaders?))\b"
67
+ )
68
+ # `req.query` used whole: passed as an argument, or spread into an object.
69
+ REQ_WHOLE = re.compile(r"\breq(?:uest)?\.(" + "|".join(REQ_CONTAINERS) + r")\b(?!\s*[.\[])")
70
+ # `emailService.handleOutlookCallback(req.query)` — the container crosses into a
71
+ # function, and the keys are named in that function's parameter list, not here.
72
+ REQ_PASSED_TO = re.compile(
73
+ r"([A-Za-z_$][\w$]*)\s*\(\s*req(?:uest)?\.(" + "|".join(REQ_CONTAINERS) + r")\s*[,)]"
74
+ )
75
+ # `const handleOutlookCallback = async ({ state, code, error }) => {`
76
+ FUNC_DESTRUCT_ARROW = re.compile(
77
+ r"(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\s*)?\(\s*\{([^}]*)\}"
78
+ )
79
+ FUNC_DESTRUCT_DECL = re.compile(
80
+ r"(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(\s*\{([^}]*)\}"
81
+ )
82
+ MASK_LIST = re.compile(
83
+ r"(?:const|let|var)\s+([A-Za-z_$][\w$]*(?:SENSITIVE|REDACT|MASK|Sensitive|Redact|Mask)[\w$]*"
84
+ r"|[A-Za-z_$]*(?:SENSITIVE|REDACT|MASK)[\w$]*)\s*=\s*(?:new\s+Set\s*\(\s*)?\[([^\]]*)\]",
85
+ re.S,
86
+ )
87
+
88
+
89
+ def resolve_spec(spec: str, importer: Path):
90
+ """Resolve a relative import specifier to the file it actually names.
91
+
92
+ Matching on the specifier's stem alone conflates `utils/round.js` with
93
+ `services/round.js`, and that conflation is exactly what made the export
94
+ sweep report live-but-unimported identifiers as used.
95
+ """
96
+ if not spec.startswith("."):
97
+ return None
98
+ base = importer.parent / spec
99
+ candidates = [base]
100
+ for suffix in SOURCE_SUFFIXES:
101
+ candidates.append(Path(str(base) + suffix))
102
+ candidates.append(base / ("index" + suffix))
103
+ for c in candidates:
104
+ try:
105
+ if c.is_file():
106
+ return c.resolve()
107
+ except OSError:
108
+ continue
109
+ return None
110
+
111
+
112
+ def import_bindings(clause: str):
113
+ """Split an import clause into (named, namespace?, default-name).
114
+
115
+ `import { a as b }` imports the export named `a`, so the name before `as`
116
+ is the one that matters here — the mirror of an export block, where the
117
+ name after `as` is what leaves the module.
118
+ """
119
+ named = set()
120
+ m = re.search(r"\{([^}]*)\}", clause)
121
+ if m:
122
+ for piece in m.group(1).split(","):
123
+ piece = piece.strip()
124
+ if not piece:
125
+ continue
126
+ orig = re.split(r"\s+as\s+", piece)[0].strip()
127
+ if re.fullmatch(r"[A-Za-z_$][\w$]*", orig):
128
+ named.add(orig)
129
+ namespace = bool(re.search(r"\*\s+as\s+[A-Za-z_$][\w$]*", clause))
130
+ head = clause.split("{")[0].strip().rstrip(",").strip()
131
+ default = head if head and not head.startswith("*") else None
132
+ return named, namespace, default
133
+
134
+
135
+ def split_names(blob: str):
136
+ out = set()
137
+ for piece in blob.split(","):
138
+ piece = piece.strip()
139
+ if not piece or piece.startswith("..."):
140
+ continue
141
+ # `{ code: authCode }` and `{ code = '' }` both still read the key `code`.
142
+ key = re.split(r"[:=]", piece)[0].strip()
143
+ if re.fullmatch(r"[A-Za-z_$][\w$]*", key):
144
+ out.add(key)
145
+ return out
146
+
147
+
148
+ def source_files(root: Path):
149
+ for p in sorted(root.rglob("*")):
150
+ if p.suffix not in SOURCE_SUFFIXES:
151
+ continue
152
+ if any(part in SKIP_DIRS for part in p.parts):
153
+ continue
154
+ yield p
155
+
156
+
157
+ def strip_comments(text: str) -> str:
158
+ # Replace block comments with their own newlines rather than deleting them, so
159
+ # every line keeps its original number. The line:col this script prints ends up
160
+ # cited verbatim in the review, and a citation that lands two lines off the thing
161
+ # it claims to show is worse than no citation.
162
+ text = re.sub(r"/\*.*?\*/", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.S)
163
+ # `\s` would match newlines, so a blank line before a comment gets swallowed with it.
164
+ text = re.sub(r"^[^\S\n]*//.*$", "", text, flags=re.M)
165
+ return text
166
+
167
+
168
+ def rel(p: Path, root: Path) -> str:
169
+ try:
170
+ return str(p.relative_to(root))
171
+ except ValueError:
172
+ return str(p)
173
+
174
+
175
+ # ---------------------------------------------------------------- env
176
+
177
+ def sweep_env(src: Path, repo_root: Path):
178
+ reads = defaultdict(list)
179
+ for p in source_files(src):
180
+ body = p.read_text(encoding="utf-8", errors="replace")
181
+ for i, line in enumerate(body.splitlines(), 1):
182
+ if line.lstrip().startswith("//"):
183
+ continue
184
+ for m in list(ENV_READ.finditer(line)) + list(ENV_READ_BRACKET.finditer(line)):
185
+ reads[m.group(1)].append(f"{rel(p, repo_root)}:{i}")
186
+
187
+ declared = {}
188
+ for name in (".env", ".env.example", ".env.sample", ".env.local"):
189
+ f = repo_root / name
190
+ if not f.exists():
191
+ continue
192
+ for i, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
193
+ m = ENV_DECL.match(line)
194
+ if m:
195
+ declared.setdefault(m.group(1), []).append(f"{name}:{i}")
196
+
197
+ all_names = sorted(set(reads) | set(declared))
198
+ rows = []
199
+ for n in all_names:
200
+ sites = reads.get(n, [])
201
+ outside = [s for s in sites if "/config/" not in s.replace("\\", "/")]
202
+ rows.append({
203
+ "name": n,
204
+ "read_sites": sites,
205
+ "declared_in": declared.get(n, []),
206
+ "read_count": len(sites),
207
+ "reads_outside_config": outside,
208
+ "declared_never_read": bool(declared.get(n)) and not sites,
209
+ "read_never_declared": bool(sites) and not declared.get(n),
210
+ })
211
+ return rows
212
+
213
+
214
+ # ---------------------------------------------------------------- exports
215
+
216
+ def sweep_exports(src: Path, repo_root: Path, extra_roots):
217
+ files = list(source_files(src))
218
+ for r in extra_roots:
219
+ if r.exists():
220
+ files += list(source_files(r))
221
+
222
+ bodies = {p: strip_comments(p.read_text(encoding="utf-8", errors="replace")) for p in files}
223
+
224
+ exports = {}
225
+ for p, body in bodies.items():
226
+ names = set()
227
+ for line in body.splitlines():
228
+ m = EXPORT_NAMED.match(line)
229
+ if m:
230
+ names.add(m.group(1))
231
+ for m in EXPORT_BLOCK.finditer(body):
232
+ for piece in m.group(1).split(","):
233
+ piece = piece.strip()
234
+ if not piece:
235
+ continue
236
+ piece = re.split(r"\s+as\s+", piece)[-1].strip()
237
+ if re.fullmatch(r"[A-Za-z_$][\w$]*", piece):
238
+ names.add(piece)
239
+ if names:
240
+ exports[p] = names
241
+ if EXPORT_DEFAULT.search(body):
242
+ exports.setdefault(p, set())
243
+
244
+ # Index every import once: which file it resolves to, and what it pulls out.
245
+ # Doing this per (file, name) pair instead meant scanning every body for every
246
+ # exported identifier, which is both slow and how the stem collision crept in.
247
+ imports = defaultdict(list) # target path -> [(importer, named, namespace, default)]
248
+ for q, qbody in bodies.items():
249
+ for m in IMPORT_FROM.finditer(qbody):
250
+ clause, spec = m.group(1), m.group(2)
251
+ target = resolve_spec(spec, q)
252
+ if target is None:
253
+ continue
254
+ named, namespace, default = import_bindings(clause)
255
+ imports[target].append((q, named, namespace, default))
256
+ for m in REQUIRE_DESTRUCTURE.finditer(qbody):
257
+ target = resolve_spec(m.group(2), q)
258
+ if target is None:
259
+ continue
260
+ imports[target].append((q, split_names(m.group(1)), False, None))
261
+
262
+ rows = []
263
+ for p, names in exports.items():
264
+ body = bodies[p]
265
+ key = p.resolve()
266
+ incoming = imports.get(key, [])
267
+
268
+ if EXPORT_DEFAULT.search(body):
269
+ default_importers = sorted({
270
+ rel(q, repo_root) for q, _, ns, default in incoming
271
+ if q != p and (default or ns)
272
+ })
273
+ rows.append({
274
+ "file": rel(p, repo_root),
275
+ "name": "(default export)",
276
+ "in_file_refs": None,
277
+ "external_importers": default_importers,
278
+ "same_name_elsewhere": [],
279
+ })
280
+
281
+ for n in sorted(names):
282
+ in_file = len(re.findall(rf"\b{re.escape(n)}\b", body)) - 1
283
+ external = sorted({
284
+ rel(q, repo_root) for q, named, ns, _ in incoming
285
+ if q != p and (n in named or ns)
286
+ })
287
+ # The old bare-name signal, kept but demoted. A name occurring in a file
288
+ # that never imports this module is usually a second definition of the
289
+ # same word, not a caller — worth seeing next to DUPES, not counted as use.
290
+ elsewhere = sorted({
291
+ rel(q, repo_root) for q, qbody in bodies.items()
292
+ if q != p and rel(q, repo_root) not in external
293
+ and re.search(rf"\b{re.escape(n)}\b", qbody)
294
+ })
295
+ rows.append({
296
+ "file": rel(p, repo_root),
297
+ "name": n,
298
+ "in_file_refs": max(in_file, 0),
299
+ "external_importers": external,
300
+ "same_name_elsewhere": elsewhere,
301
+ })
302
+ return rows
303
+
304
+
305
+ # ---------------------------------------------------------------- request inputs
306
+
307
+ def sweep_request_inputs(src: Path, repo_root: Path):
308
+ """Every key read off the request, against whatever the codebase masks.
309
+
310
+ A request logger that dumps a whole container leaks whichever keys happen to
311
+ ride in it, and the keys are spread across controllers and the services they
312
+ hand the container to — so no single grep finds them all. Counting them is the
313
+ only way the list comes out complete.
314
+ """
315
+ keys = defaultdict(list) # key -> ["file:line (container)"]
316
+ whole = [] # sites handing a container on intact
317
+ masked = {} # lowercased key -> declaring site
318
+ passed_to = {} # callee name -> "file:line (container)"
319
+ destructuring = {} # function name -> (keys, "file:line")
320
+
321
+ for p in source_files(src):
322
+ raw = p.read_text(encoding="utf-8", errors="replace")
323
+ body = strip_comments(raw)
324
+ for m in MASK_LIST.finditer(body):
325
+ line = body[: m.start()].count("\n") + 1
326
+ for name in split_names(re.sub(r"['\"]", "", m.group(2))):
327
+ masked.setdefault(name.lower(), f"{rel(p, repo_root)}:{line} ({m.group(1)})")
328
+
329
+ for i, line in enumerate(body.splitlines(), 1):
330
+ site = f"{rel(p, repo_root)}:{i}"
331
+ for m in list(REQ_DOT.finditer(line)) + list(REQ_BRACKET.finditer(line)):
332
+ keys[m.group(2)].append(f"{site} (req.{m.group(1)})")
333
+ for m in REQ_DESTRUCTURE.finditer(line):
334
+ for k in split_names(m.group(1)):
335
+ keys[k].append(f"{site} (req.{m.group(2)} destructure)")
336
+ for m in PROXY_DESTRUCTURE.finditer(line):
337
+ for k in split_names(m.group(1)):
338
+ keys[k].append(f"{site} ({m.group(2)} destructure)")
339
+ for m in REQ_WHOLE.finditer(line):
340
+ whole.append(f"{site} (req.{m.group(1)})")
341
+ for m in REQ_PASSED_TO.finditer(line):
342
+ passed_to.setdefault(m.group(1), f"{site} (req.{m.group(2)})")
343
+
344
+ for pattern in (FUNC_DESTRUCT_ARROW, FUNC_DESTRUCT_DECL):
345
+ for m in pattern.finditer(body):
346
+ line = body[: m.start()].count("\n") + 1
347
+ destructuring.setdefault(
348
+ m.group(1), (split_names(m.group(2)), f"{rel(p, repo_root)}:{line}")
349
+ )
350
+
351
+ # One hop: a container handed to a function whose parameter list names its keys.
352
+ # Without this the OAuth callback keys are invisible — the controller forwards
353
+ # req.query whole and only the service ever writes `code` or `state` down.
354
+ for callee, call_site in passed_to.items():
355
+ if callee not in destructuring:
356
+ continue
357
+ param_keys, def_site = destructuring[callee]
358
+ for k in param_keys:
359
+ keys[k].append(f"{def_site} (via {callee}() param, called at {call_site})")
360
+
361
+ rows = [
362
+ {
363
+ "key": k,
364
+ "sites": sorted(set(v)),
365
+ "masked": k.lower() in masked,
366
+ "masked_by": masked.get(k.lower()),
367
+ }
368
+ for k, v in sorted(keys.items())
369
+ ]
370
+ return {
371
+ "keys": rows,
372
+ "whole_container_sites": sorted(set(whole)),
373
+ "mask_lists": sorted(set(masked.values())),
374
+ }
375
+
376
+
377
+ # ---------------------------------------------------------------- duplicate definitions
378
+
379
+ def sweep_dupes(src: Path, repo_root: Path):
380
+ defs = defaultdict(list)
381
+ for p in source_files(src):
382
+ body = strip_comments(p.read_text(encoding="utf-8", errors="replace"))
383
+ for i, line in enumerate(body.splitlines(), 1):
384
+ m = TOPLEVEL_DEF.match(line)
385
+ if m:
386
+ defs[m.group(1)].append(f"{rel(p, repo_root)}:{i}")
387
+ return [
388
+ {"name": n, "sites": s}
389
+ for n, s in sorted(defs.items())
390
+ if len({x.split(":")[0] for x in s}) > 1
391
+ ]
392
+
393
+
394
+ # ---------------------------------------------------------------- repeated blocks
395
+
396
+ def norm(line: str) -> str:
397
+ return re.sub(r"\s+", " ", line.strip())
398
+
399
+
400
+ def sweep_blocks(src: Path, repo_root: Path, window: int, min_len: int):
401
+ seen = defaultdict(list)
402
+ for p in source_files(src):
403
+ raw = strip_comments(p.read_text(encoding="utf-8", errors="replace")).splitlines()
404
+ lines = [(i + 1, norm(l)) for i, l in enumerate(raw)]
405
+ lines = [(i, l) for i, l in lines if len(l) >= min_len]
406
+ for j in range(len(lines) - window + 1):
407
+ chunk = lines[j:j + window]
408
+ if chunk[-1][0] - chunk[0][0] > window * 3:
409
+ continue
410
+ key = "\n".join(c[1] for c in chunk)
411
+ seen[key].append(f"{rel(p, repo_root)}:{chunk[0][0]}-{chunk[-1][0]}")
412
+
413
+ out = []
414
+ for key, sites in seen.items():
415
+ uniq = sorted(set(sites))
416
+ if len(uniq) > 1:
417
+ out.append({"sites": uniq, "lines": key.split("\n")})
418
+ out.sort(key=lambda r: (-len(r["sites"]), r["sites"][0]))
419
+ return out[:40]
420
+
421
+
422
+ # ---------------------------------------------------------------- render
423
+
424
+ def render(res):
425
+ o = []
426
+ if "env" in res:
427
+ o.append("=== ENV VARS ===")
428
+ o.append("Every process.env read, against what the .env files declare.\n")
429
+ for r in res["env"]:
430
+ flags = []
431
+ if r["declared_never_read"]:
432
+ flags.append("DECLARED-NEVER-READ")
433
+ if r["read_never_declared"]:
434
+ flags.append("READ-NEVER-DECLARED")
435
+ if r["reads_outside_config"]:
436
+ flags.append(f"{len(r['reads_outside_config'])}-READS-OUTSIDE-CONFIG")
437
+ o.append(f"{r['name']} [{' '.join(flags) or 'ok'}]")
438
+ if r["declared_in"]:
439
+ o.append(f" declared: {', '.join(r['declared_in'])}")
440
+ for s in r["read_sites"]:
441
+ o.append(f" read: {s}")
442
+ o.append("")
443
+
444
+ if "exports" in res:
445
+ o.append("\n=== EXPORTS ===")
446
+ o.append("in-file refs vs external importers. An export with 0 external importers")
447
+ o.append("but >0 in-file refs is over-exported, NOT dead — narrowing the export list")
448
+ o.append("is safe, deleting the definition is not.")
449
+ o.append(f"Scanned {res.get('_files_scanned', '?')} files for importers. A DEAD verdict is")
450
+ o.append("only as good as that number: if a whole file type went unscanned, its importers")
451
+ o.append("are invisible and live exports will read as dead. Check the count looks right")
452
+ o.append("for the tree before believing any DEAD row.\n")
453
+ o.append("An importer is a file whose import specifier resolves to this module and")
454
+ o.append("which names this identifier. A file that merely contains the same word is")
455
+ o.append("listed as same-name-elsewhere — usually a second definition, not a caller.\n")
456
+ for r in res["exports"]:
457
+ ext = r["external_importers"]
458
+ n = r["in_file_refs"]
459
+ if ext:
460
+ tag = f"used by {len(ext)}"
461
+ elif n:
462
+ tag = f"OVER-EXPORTED (0 external, {n} in-file)"
463
+ else:
464
+ tag = "DEAD (0 external, 0 in-file)"
465
+ o.append(f"{r['file']} {r['name']} [{tag}]")
466
+ if ext:
467
+ o.append(f" importers: {', '.join(ext)}")
468
+ if r.get("same_name_elsewhere"):
469
+ o.append(f" same-name-elsewhere: {', '.join(r['same_name_elsewhere'])}")
470
+ o.append("")
471
+
472
+ if "request-inputs" in res:
473
+ ri = res["request-inputs"]
474
+ o.append("\n=== REQUEST INPUT KEYS ===")
475
+ o.append("Every key read off req.query/body/headers/params, plus keys destructured")
476
+ o.append("from a same-named parameter one layer down, against the codebase's own mask")
477
+ o.append("list. UNMASKED matters only where a container is logged or forwarded whole —")
478
+ o.append("those sites are listed underneath.\n")
479
+ if ri["mask_lists"]:
480
+ o.append(f"Mask lists found: {', '.join(ri['mask_lists'])}\n")
481
+ else:
482
+ o.append("No mask list found — nothing is being redacted.\n")
483
+ for r in ri["keys"]:
484
+ tag = "MASKED" if r["masked"] else "UNMASKED"
485
+ o.append(f"{r['key']} [{tag}]")
486
+ for s in r["sites"]:
487
+ o.append(f" {s}")
488
+ o.append("")
489
+ o.append(f"Containers passed or logged whole ({len(ri['whole_container_sites'])} sites):")
490
+ for s in ri["whole_container_sites"]:
491
+ o.append(f" {s}")
492
+ o.append("")
493
+
494
+ if "dupes" in res:
495
+ o.append("\n=== DUPLICATE TOP-LEVEL DEFINITIONS ===")
496
+ o.append("Same identifier defined in more than one file.\n")
497
+ for r in res["dupes"]:
498
+ o.append(f"{r['name']}")
499
+ for s in r["sites"]:
500
+ o.append(f" {s}")
501
+ o.append("")
502
+
503
+ if "blocks" in res:
504
+ o.append("\n=== REPEATED CODE BLOCKS ===")
505
+ o.append("Normalised line windows appearing verbatim in more than one place.\n")
506
+ for r in res["blocks"]:
507
+ o.append(f"x{len(r['sites'])} {', '.join(r['sites'])}")
508
+ for l in r["lines"][:4]:
509
+ o.append(f" | {l[:100]}")
510
+ o.append("")
511
+ return "\n".join(o)
512
+
513
+
514
+ def main():
515
+ ap = argparse.ArgumentParser()
516
+ ap.add_argument("src")
517
+ ap.add_argument("--repo-root", default=None)
518
+ ap.add_argument("--json", action="store_true")
519
+ ap.add_argument("--section", action="append",
520
+ choices=["env", "exports", "dupes", "blocks", "request-inputs"])
521
+ ap.add_argument("--window", type=int, default=4)
522
+ ap.add_argument("--min-line-len", type=int, default=12)
523
+ a = ap.parse_args()
524
+
525
+ src = Path(a.src).resolve()
526
+ if not src.is_dir():
527
+ sys.exit(f"not a directory: {src}")
528
+ repo_root = Path(a.repo_root).resolve() if a.repo_root else src.parent
529
+
530
+ want = a.section or ["env", "exports", "dupes", "blocks", "request-inputs"]
531
+ res = {}
532
+ if "env" in want:
533
+ res["env"] = sweep_env(src, repo_root)
534
+ if "request-inputs" in want:
535
+ res["request-inputs"] = sweep_request_inputs(src, repo_root)
536
+ if "exports" in want:
537
+ extra = [repo_root / "scripts", repo_root / "tests"]
538
+ res["exports"] = sweep_exports(src, repo_root, extra)
539
+ res["_files_scanned"] = len(list(source_files(src))) + sum(
540
+ len(list(source_files(r))) for r in extra if r.exists())
541
+ if "dupes" in want:
542
+ res["dupes"] = sweep_dupes(src, repo_root)
543
+ if "blocks" in want:
544
+ res["blocks"] = sweep_blocks(src, repo_root, a.window, a.min_line_len)
545
+
546
+ print(json.dumps(res, indent=2) if a.json else render(res))
547
+
548
+
549
+ if __name__ == "__main__":
550
+ main()