@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.
- package/LICENSE +5 -0
- package/README.md +75 -0
- package/bin/install.js +45 -0
- package/package.json +29 -0
- package/skills/architecture-walkthrough/SKILL.md +223 -0
- package/skills/architecture-walkthrough/references/sections.md +29 -0
- package/skills/architecture-walkthrough/scripts/check_structure.py +200 -0
- package/skills/autotest-webapp-ui/SKILL.md +58 -0
- package/skills/backend-code-review/SKILL.md +386 -0
- package/skills/backend-code-review/references/report-format.md +333 -0
- package/skills/backend-code-review/scripts/list_routes.py +269 -0
- package/skills/backend-code-review/scripts/sweep.py +550 -0
- package/skills/backend-code-review/scripts/verify_citations.py +201 -0
- package/skills/be-brief/SKILL.md +18 -0
- package/skills/clarke-list-excel/SKILL.md +51 -0
- package/skills/clarke-list-excel/references/output-schema.md +125 -0
- package/skills/clarke-list-excel/scripts/clarke_common.py +251 -0
- package/skills/clarke-list-excel/scripts/clarke_extract.py +487 -0
- package/skills/clarke-list-excel/scripts/load_clarke.py +322 -0
- package/skills/clarke-list-excel/scripts/run_all.py +63 -0
- package/skills/datalab-api/SKILL.md +163 -0
- package/skills/datalab-api/references/parameters-and-payload.md +121 -0
- package/skills/datalab-api/references/table-selection.md +35 -0
- package/skills/datalab-api/scripts/datalab_tables.py +365 -0
- package/skills/find-test-seam/SKILL.md +41 -0
- package/skills/frontend-code-review/SKILL.md +247 -0
- package/skills/frontend-code-review-2/SKILL.md +192 -0
- package/skills/frontend-code-review-2/scripts/fetch_pr_comments.py +65 -0
- package/skills/frontend-code-review-2/scripts/render_report.py +139 -0
- package/skills/murtaza-breif/SKILL.md +143 -0
- package/skills/murtaza-breif/scripts/save_brief.py +128 -0
- package/skills/pdf-to-json/SKILL.md +42 -0
- package/skills/pdf-to-json/references/output-schema.md +168 -0
- package/skills/pdf-to-json/scripts/extract_figures.py +319 -0
- package/skills/pdf-to-json/scripts/load_mongo.py +287 -0
- package/skills/pdf-to-json/scripts/pdf_extract.py +1313 -0
- package/skills/record-api-traffic/SKILL.md +434 -0
- package/skills/record-api-traffic/references/reading-recordings.md +224 -0
- package/skills/record-api-traffic/scripts/check-schema.mjs +184 -0
- package/skills/record-api-traffic/scripts/dump-quotation.mjs +67 -0
- package/skills/record-api-traffic/scripts/dump-source-excel.mjs +75 -0
- package/skills/record-api-traffic/scripts/lib/repo.mjs +109 -0
- package/skills/record-api-traffic/scripts/preflight.py +528 -0
- package/skills/record-api-traffic/scripts/record-api-traffic.py +720 -0
- package/skills/refac-wrt-business-goal/SKILL.md +305 -0
- package/skills/refac-wrt-business-goal/references/critic.md +170 -0
- package/skills/system-resource-triage/SKILL.md +180 -0
- package/skills/system-resource-triage/scripts/reap.sh +116 -0
- package/skills/system-resource-triage/scripts/triage.sh +111 -0
- 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()
|