devguard-scanner 0.1.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 (67) hide show
  1. devguard/__init__.py +5 -0
  2. devguard/ai/__init__.py +0 -0
  3. devguard/ai/investigator.py +380 -0
  4. devguard/ai/prompts.py +113 -0
  5. devguard/ai/providers/__init__.py +0 -0
  6. devguard/ai/providers/anthropic.py +163 -0
  7. devguard/ai/providers/openai_compat.py +157 -0
  8. devguard/ai/registry.py +160 -0
  9. devguard/ai/run.py +119 -0
  10. devguard/ai/sdk.py +102 -0
  11. devguard/ai/testing.py +126 -0
  12. devguard/ai/tools.py +389 -0
  13. devguard/analyzers/__init__.py +0 -0
  14. devguard/analyzers/base.py +135 -0
  15. devguard/analyzers/carriers.py +345 -0
  16. devguard/analyzers/command.py +343 -0
  17. devguard/analyzers/content.py +240 -0
  18. devguard/analyzers/filesystem.py +142 -0
  19. devguard/analyzers/hidden.py +273 -0
  20. devguard/analyzers/imports.py +102 -0
  21. devguard/analyzers/jsonc.py +221 -0
  22. devguard/analyzers/surfaces/__init__.py +0 -0
  23. devguard/analyzers/surfaces/ai_assistants.py +318 -0
  24. devguard/analyzers/surfaces/autoload.py +142 -0
  25. devguard/analyzers/surfaces/common.py +242 -0
  26. devguard/analyzers/surfaces/deps.py +159 -0
  27. devguard/analyzers/surfaces/devcontainer.py +195 -0
  28. devguard/analyzers/surfaces/env.py +53 -0
  29. devguard/analyzers/surfaces/git.py +236 -0
  30. devguard/analyzers/surfaces/npm.py +304 -0
  31. devguard/analyzers/surfaces/python_pkg.py +138 -0
  32. devguard/analyzers/surfaces/vscode.py +515 -0
  33. devguard/cli.py +302 -0
  34. devguard/core/__init__.py +0 -0
  35. devguard/core/config.py +147 -0
  36. devguard/core/filetype.py +313 -0
  37. devguard/core/models.py +195 -0
  38. devguard/core/redact.py +72 -0
  39. devguard/core/safefs.py +280 -0
  40. devguard/core/scoring.py +60 -0
  41. devguard/core/service.py +211 -0
  42. devguard/correlate/__init__.py +0 -0
  43. devguard/correlate/chains.py +160 -0
  44. devguard/mcp/__init__.py +0 -0
  45. devguard/mcp/server.py +442 -0
  46. devguard/report/__init__.py +0 -0
  47. devguard/report/escape.py +31 -0
  48. devguard/report/markdown.py +242 -0
  49. devguard/report/sarif.py +96 -0
  50. devguard/report/terminal.py +47 -0
  51. devguard/report/writer.py +52 -0
  52. devguard/rules/__init__.py +0 -0
  53. devguard/rules/catalog.py +96 -0
  54. devguard/rules/content/ai_instructions.yaml +111 -0
  55. devguard/rules/content/cross_language.yaml +228 -0
  56. devguard/rules/content/docs.yaml +63 -0
  57. devguard/rules/content/javascript.yaml +171 -0
  58. devguard/rules/content/obfuscation.yaml +138 -0
  59. devguard/rules/content/python.yaml +144 -0
  60. devguard/rules/content/shell.yaml +109 -0
  61. devguard/rules/content/windows.yaml +95 -0
  62. devguard/rules/data/vscode_settings.yaml +81 -0
  63. devguard_scanner-0.1.0.dist-info/METADATA +84 -0
  64. devguard_scanner-0.1.0.dist-info/RECORD +67 -0
  65. devguard_scanner-0.1.0.dist-info/WHEEL +4 -0
  66. devguard_scanner-0.1.0.dist-info/entry_points.txt +7 -0
  67. devguard_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
devguard/ai/tools.py ADDED
@@ -0,0 +1,389 @@
1
+ """Read-only investigation tools and the policy layer that executes them (AI-006, SAFE-012).
2
+
3
+ The registry is a fixed allowlist. There is no shell, network, write, or code-evaluation tool. Every call is
4
+ validated against its schema, confined to the scan root through SafeFS, capped in output size, redacted,
5
+ and recorded in the audit log. Tool calls are proposals from an untrusted model, not commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import codecs
13
+ import fnmatch
14
+ import re
15
+ import urllib.parse
16
+ import zlib
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ from devguard.ai.sdk import ToolCall, ToolResult, ToolSpec
22
+ from devguard.analyzers.base import AnalysisContext, FileView
23
+ from devguard.analyzers.content import ContentRuleEngine
24
+ from devguard.analyzers.hidden import shannon_entropy
25
+ from devguard.core.filetype import classify, guess_code_lang
26
+ from devguard.core.redact import make_excerpt, redact, visible
27
+ from devguard.core.safefs import FileRecord, Kind, ReadError, SafeFS, normalize_rel
28
+
29
+ MAX_OUTPUT = 32 * 1024
30
+ MAX_DECODED = 64 * 1024
31
+ MAX_LINES = 400
32
+ MAX_SEARCH_BYTES = 20 * 1024 * 1024
33
+
34
+
35
+ class ToolDenied(Exception):
36
+ pass
37
+
38
+
39
+ @dataclass
40
+ class ToolEnv:
41
+ fs: SafeFS
42
+ ctx: AnalysisContext
43
+ engine: ContentRuleEngine
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class ToolDef:
48
+ spec: ToolSpec
49
+ fn: Callable[[ToolEnv, dict[str, Any]], str]
50
+
51
+
52
+ def _schema(props: dict[str, Any], required: list[str]) -> dict[str, Any]:
53
+ return {"type": "object", "properties": props, "required": required, "additionalProperties": False}
54
+
55
+
56
+ _PATH = {"type": "string", "maxLength": 1024, "description": "Repository-relative path, e.g. src/index.js"}
57
+
58
+
59
+ # ----------------------------------------------------------------------------------------------- helpers
60
+ def _record(env: ToolEnv, path: str) -> FileRecord:
61
+ norm = normalize_rel(path)
62
+ if norm is None:
63
+ raise ToolDenied("path must be relative and inside the repository")
64
+ rec = env.ctx.inventory.get(norm)
65
+ if rec is None:
66
+ raise ToolDenied(f"no such file in the scanned inventory: {norm}")
67
+ return rec
68
+
69
+
70
+ def _text_of(env: ToolEnv, rec: FileRecord) -> tuple[str | None, bytes]:
71
+ try:
72
+ data = env.fs.read(rec.rel)
73
+ except ReadError as exc:
74
+ raise ToolDenied(f"cannot read {rec.rel}: {exc}") from exc
75
+ return classify(rec.rel, data).text, data
76
+
77
+
78
+ def _safe(text: str) -> str:
79
+ return visible(redact(text))
80
+
81
+
82
+ # ----------------------------------------------------------------------------------------------- tools
83
+ def list_files(env: ToolEnv, a: dict[str, Any]) -> str:
84
+ pattern = a.get("glob", "*")
85
+ limit = min(int(a.get("limit", 200)), 500)
86
+ recs = [*env.ctx.inventory.files, *env.ctx.inventory.vendored_files.values()]
87
+ hits = [r for r in recs if fnmatch.fnmatchcase(r.rel, pattern) or fnmatch.fnmatchcase(r.name, pattern)]
88
+ lines = [f"{_safe(r.rel)}\t{r.size} bytes{' (vendored)' if r.vendored else ''}" for r in hits[:limit]]
89
+ more = f"\n… {len(hits) - limit} more" if len(hits) > limit else ""
90
+ return "\n".join(lines) + more if lines else "no matching files"
91
+
92
+
93
+ def read_file(env: ToolEnv, a: dict[str, Any]) -> str:
94
+ rec = _record(env, a["path"])
95
+ text, _ = _text_of(env, rec)
96
+ if text is None:
97
+ raise ToolDenied("binary file; use file_info instead")
98
+ lines = text.split("\n")
99
+ start = max(1, int(a.get("start_line", 1)))
100
+ end = min(len(lines), int(a.get("end_line", start + MAX_LINES - 1)), start + MAX_LINES - 1)
101
+ out = [
102
+ f"{i:>6}| {_safe(lines[i - 1][:2000])}{' …[line truncated]' if len(lines[i - 1]) > 2000 else ''}"
103
+ for i in range(start, end + 1)
104
+ ]
105
+ header = f"{rec.rel} lines {start}-{end} of {len(lines)} (invisible characters shown as <U+XXXX>)"
106
+ return header + "\n" + "\n".join(out)
107
+
108
+
109
+ def search(env: ToolEnv, a: dict[str, Any]) -> str:
110
+ needle = a["text"]
111
+ ci = bool(a.get("case_insensitive", False))
112
+ pattern = a.get("glob", "*")
113
+ limit = min(int(a.get("limit", 100)), 200)
114
+ hits: list[str] = []
115
+ scanned = 0
116
+ for rec in env.ctx.inventory.files:
117
+ if not (fnmatch.fnmatchcase(rec.rel, pattern) or fnmatch.fnmatchcase(rec.name, pattern)):
118
+ continue
119
+ if scanned > MAX_SEARCH_BYTES:
120
+ hits.append("… search byte budget reached")
121
+ break
122
+ try:
123
+ text, data = _text_of(env, rec)
124
+ except ToolDenied:
125
+ continue
126
+ scanned += len(data)
127
+ if text is None:
128
+ continue
129
+ hay, n = (text.lower(), needle.lower()) if ci else (text, needle)
130
+ idx = hay.find(n)
131
+ while idx != -1 and len(hits) < limit:
132
+ line_no = text.count("\n", 0, idx) + 1
133
+ line = text.split("\n")[line_no - 1]
134
+ col = idx - (text.rfind("\n", 0, idx) + 1)
135
+ hits.append(f"{_safe(rec.rel)}:{line_no}: {make_excerpt(line, col, limit=240)}")
136
+ idx = hay.find(n, idx + len(n))
137
+ if len(hits) >= limit:
138
+ break
139
+ return "\n".join(hits) if hits else "no matches"
140
+
141
+
142
+ def _charcodes(s: str) -> bytes:
143
+ nums = [int(x) for x in re.findall(r"\d+", s)]
144
+ return "".join(chr(n) for n in nums if 0 <= n < 0x110000).encode("utf-8", "replace")
145
+
146
+
147
+ def _b64(s: str) -> bytes:
148
+ compact = re.sub(r"\s+", "", s)
149
+ return base64.b64decode(compact + "=" * (-len(compact) % 4))
150
+
151
+
152
+ def _inflate(data: bytes, wbits: int) -> bytes:
153
+ return zlib.decompressobj(wbits).decompress(data, MAX_DECODED) # bounded: decompression-bomb safe
154
+
155
+
156
+ _DECODERS: dict[str, Callable[[str], bytes]] = {
157
+ "base64": _b64,
158
+ "base64url": lambda s: base64.urlsafe_b64decode(s.strip() + "=" * (-len(s.strip()) % 4)),
159
+ "hex": lambda s: binascii.unhexlify(re.sub(r"(\\x|0x|\s|,)", "", s)),
160
+ "url": lambda s: urllib.parse.unquote_to_bytes(s),
161
+ "charcodes": _charcodes,
162
+ "rot13": lambda s: codecs.encode(s, "rot13").encode(),
163
+ "zlib_base64": lambda s: _inflate(_b64(s), zlib.MAX_WBITS),
164
+ "gzip_base64": lambda s: _inflate(_b64(s), 16 + zlib.MAX_WBITS),
165
+ }
166
+
167
+
168
+ def decode(env: ToolEnv, a: dict[str, Any]) -> str:
169
+ scheme = a["scheme"]
170
+ try:
171
+ raw = _DECODERS[scheme](a["data"])[:MAX_DECODED]
172
+ except (ValueError, binascii.Error, zlib.error, OSError, EOFError) as exc:
173
+ raise ToolDenied(f"decode failed: {exc}") from exc
174
+ c = classify("decoded", raw)
175
+ if c.text is None:
176
+ return f"binary result, {len(raw)} bytes, format={c.magic or 'unknown'}, first bytes: {raw[:48].hex()}"
177
+ note = f"decoded {len(raw)} bytes as text ({guess_code_lang(c.text) or 'unknown language'}):\n"
178
+ return note + _safe(c.text)
179
+
180
+
181
+ def file_info(env: ToolEnv, a: dict[str, Any]) -> str:
182
+ rec = _record(env, a["path"])
183
+ _, data = _text_of(env, rec)
184
+ c = classify(rec.rel, data)
185
+ parts = [
186
+ f"path: {_safe(rec.rel)}",
187
+ f"size: {rec.size} bytes",
188
+ f"format (magic): {c.magic or 'none'}",
189
+ f"text: {c.is_text} (encoding {c.encoding}, lossy={c.lossy})",
190
+ f"language: {c.lang or 'unknown'}",
191
+ f"entropy (first 64KB, bits/byte): {shannon_entropy(data[:65536].decode('latin-1')):.2f}",
192
+ f"executable bit: {rec.executable_bit}",
193
+ f"vendored: {rec.vendored}",
194
+ ]
195
+ if not c.is_text:
196
+ strings = re.findall(rb"[\x20-\x7e]{6,}", data[:1_000_000])[:60]
197
+ parts.append("printable strings: " + " | ".join(_safe(s.decode()) for s in strings))
198
+ return "\n".join(parts)
199
+
200
+
201
+ def run_rules(env: ToolEnv, a: dict[str, Any]) -> str:
202
+ rec = _record(env, a["path"])
203
+ text, data = _text_of(env, rec)
204
+ if text is None:
205
+ return "binary file: content rules apply to text only"
206
+ c = classify(rec.rel, data)
207
+ fv = FileView(rec, data, text, c.lang, c.magic, c.lossy)
208
+ return _format_findings(env.engine.analyze(fv, env.ctx))
209
+
210
+
211
+ def scan_text(env: ToolEnv, a: dict[str, Any]) -> str:
212
+ text = a["text"][:MAX_DECODED]
213
+ lang = a.get("language") or guess_code_lang(text)
214
+ rec = FileRecord("<supplied-text>", Kind.FILE, len(text), 0o644)
215
+ fv = FileView(rec, text.encode(), text, lang, None, False)
216
+ return _format_findings(env.engine.analyze(fv, env.ctx))
217
+
218
+
219
+ def _format_findings(findings: list[Any]) -> str:
220
+ if not findings:
221
+ return "no content rules matched"
222
+ return "\n".join(f"{f.rule_id} [{f.severity.value}] {f.title} (line {f.primary_line})" for f in findings)
223
+
224
+
225
+ REGISTRY: dict[str, ToolDef] = {
226
+ "list_files": ToolDef(
227
+ ToolSpec(
228
+ "list_files",
229
+ "List repository files matching a glob (read-only).",
230
+ _schema(
231
+ {
232
+ "glob": {"type": "string", "maxLength": 256},
233
+ "limit": {"type": "integer", "minimum": 1, "maximum": 500},
234
+ },
235
+ [],
236
+ ),
237
+ ),
238
+ list_files,
239
+ ),
240
+ "read_file": ToolDef(
241
+ ToolSpec(
242
+ "read_file",
243
+ "Read up to 400 lines of a repository text file. Secrets are "
244
+ "redacted and invisible characters are shown as <U+XXXX>.",
245
+ _schema(
246
+ {
247
+ "path": _PATH,
248
+ "start_line": {"type": "integer", "minimum": 1},
249
+ "end_line": {"type": "integer", "minimum": 1},
250
+ },
251
+ ["path"],
252
+ ),
253
+ ),
254
+ read_file,
255
+ ),
256
+ "search": ToolDef(
257
+ ToolSpec(
258
+ "search",
259
+ "Literal (non-regex) text search across repository files.",
260
+ _schema(
261
+ {
262
+ "text": {"type": "string", "minLength": 2, "maxLength": 200},
263
+ "glob": {"type": "string", "maxLength": 256},
264
+ "case_insensitive": {"type": "boolean"},
265
+ "limit": {"type": "integer", "minimum": 1, "maximum": 200},
266
+ },
267
+ ["text"],
268
+ ),
269
+ ),
270
+ search,
271
+ ),
272
+ "decode": ToolDef(
273
+ ToolSpec(
274
+ "decode",
275
+ "Decode a supplied string (one layer). Nothing is executed.",
276
+ _schema(
277
+ {
278
+ "data": {"type": "string", "maxLength": 200_000},
279
+ "scheme": {"type": "string", "enum": sorted(_DECODERS)},
280
+ },
281
+ ["data", "scheme"],
282
+ ),
283
+ ),
284
+ decode,
285
+ ),
286
+ "file_info": ToolDef(
287
+ ToolSpec(
288
+ "file_info",
289
+ "Metadata for a file: size, format, entropy, strings sample.",
290
+ _schema({"path": _PATH}, ["path"]),
291
+ ),
292
+ file_info,
293
+ ),
294
+ "run_rules": ToolDef(
295
+ ToolSpec(
296
+ "run_rules", "Run DevGuard's deterministic content rules on one file.", _schema({"path": _PATH}, ["path"])
297
+ ),
298
+ run_rules,
299
+ ),
300
+ "scan_text": ToolDef(
301
+ ToolSpec(
302
+ "scan_text",
303
+ "Run DevGuard's content rules on supplied text, e.g. a decoded payload.",
304
+ _schema(
305
+ {
306
+ "text": {"type": "string", "maxLength": 65536},
307
+ "language": {"type": "string", "enum": ["javascript", "python", "shell", "powershell", "batch"]},
308
+ },
309
+ ["text"],
310
+ ),
311
+ ),
312
+ scan_text,
313
+ ),
314
+ }
315
+
316
+
317
+ def validate_args(schema: dict[str, Any], args: Any) -> str | None:
318
+ """Minimal JSON-Schema subset validator for our own tool schemas. Returns an error or None."""
319
+ if not isinstance(args, dict):
320
+ return "arguments must be an object"
321
+ props = schema.get("properties", {})
322
+ for key in schema.get("required", []):
323
+ if key not in args:
324
+ return f"missing required argument {key!r}"
325
+ for key, value in args.items():
326
+ spec = props.get(key)
327
+ if spec is None:
328
+ return f"unknown argument {key!r}"
329
+ t = spec.get("type")
330
+ if t == "string":
331
+ if not isinstance(value, str):
332
+ return f"{key} must be a string"
333
+ if len(value) > spec.get("maxLength", 10**9) or len(value) < spec.get("minLength", 0):
334
+ return f"{key} has invalid length"
335
+ if "enum" in spec and value not in spec["enum"]:
336
+ return f"{key} must be one of {spec['enum']}"
337
+ elif t == "integer":
338
+ if not isinstance(value, int) or isinstance(value, bool):
339
+ return f"{key} must be an integer"
340
+ if value < spec.get("minimum", -(10**12)) or value > spec.get("maximum", 10**12):
341
+ return f"{key} out of range"
342
+ elif t == "boolean" and not isinstance(value, bool):
343
+ return f"{key} must be a boolean"
344
+ return None
345
+
346
+
347
+ @dataclass
348
+ class ToolPolicy:
349
+ env: ToolEnv
350
+ max_calls: int
351
+ calls: int = 0
352
+ audit: list[dict[str, Any]] = field(default_factory=list)
353
+
354
+ def specs(self) -> list[ToolSpec]:
355
+ return [d.spec for d in REGISTRY.values()]
356
+
357
+ def execute(self, call: ToolCall) -> ToolResult:
358
+ args_preview = make_excerpt(repr(call.arguments), limit=200)
359
+ tool = REGISTRY.get(call.name)
360
+ if tool is None:
361
+ return self._deny(call, args_preview, f"unknown tool {call.name!r}; only read-only tools exist")
362
+ if self.calls >= self.max_calls:
363
+ return self._deny(call, args_preview, "tool-call budget exhausted; give your final answer now")
364
+ error = validate_args(tool.spec.input_schema, call.arguments)
365
+ if error:
366
+ return self._deny(call, args_preview, f"invalid arguments: {error}")
367
+ self.calls += 1
368
+ try:
369
+ output = tool.fn(self.env, call.arguments)
370
+ except ToolDenied as exc:
371
+ return self._deny(call, args_preview, str(exc))
372
+ except Exception as exc:
373
+ return self._deny(call, args_preview, f"tool error: {type(exc).__name__}")
374
+ if len(output) > MAX_OUTPUT:
375
+ output = output[:MAX_OUTPUT] + "\n…[output truncated]"
376
+ self.audit.append(
377
+ {
378
+ "tool": call.name,
379
+ "args": args_preview,
380
+ "allowed": True,
381
+ "summary": f"{len(output)} chars",
382
+ "bytes": len(output),
383
+ }
384
+ )
385
+ return ToolResult(call.id, output)
386
+
387
+ def _deny(self, call: ToolCall, args_preview: str, reason: str) -> ToolResult:
388
+ self.audit.append({"tool": call.name, "args": args_preview, "allowed": False, "summary": reason})
389
+ return ToolResult(call.id, f"DENIED: {reason}", is_error=True)
File without changes
@@ -0,0 +1,135 @@
1
+ """Analyzer protocol, the per-file view handed to analyzers, and the shared analysis context.
2
+
3
+ Analyzers never open files. They receive bytes/text through `FileView` and report findings and triggers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import bisect
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass, field
11
+ from functools import cached_property
12
+ from pathlib import PurePosixPath
13
+ from typing import Protocol
14
+
15
+ from devguard.core.config import ScanConfig
16
+ from devguard.core.models import Evidence, Finding, ScanIssue, Trigger
17
+ from devguard.core.redact import make_excerpt
18
+ from devguard.core.safefs import FileRecord, Inventory
19
+
20
+
21
+ @dataclass
22
+ class FileView:
23
+ record: FileRecord
24
+ data: bytes
25
+ text: str | None
26
+ lang: str | None
27
+ magic: str | None
28
+ lossy: bool
29
+ truncated: bool = False
30
+
31
+ @property
32
+ def rel(self) -> str:
33
+ return self.record.rel
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ return PurePosixPath(self.record.rel).name
38
+
39
+ @property
40
+ def suffix(self) -> str:
41
+ return PurePosixPath(self.record.rel).suffix.lower()
42
+
43
+ @property
44
+ def parent(self) -> str:
45
+ parent = str(PurePosixPath(self.record.rel).parent)
46
+ return "" if parent == "." else parent
47
+
48
+ @cached_property
49
+ def lines(self) -> list[str]:
50
+ return (self.text or "").split("\n")
51
+
52
+ @cached_property
53
+ def _line_starts(self) -> list[int]:
54
+ starts = [0]
55
+ text = self.text or ""
56
+ idx = text.find("\n")
57
+ while idx != -1:
58
+ starts.append(idx + 1)
59
+ idx = text.find("\n", idx + 1)
60
+ return starts
61
+
62
+ def line_col(self, offset: int) -> tuple[int, int]:
63
+ """1-based line and column for a character offset."""
64
+ i = bisect.bisect_right(self._line_starts, offset) - 1
65
+ return i + 1, offset - self._line_starts[i] + 1
66
+
67
+ def evidence_at_offset(self, offset: int, end: int | None = None) -> Evidence:
68
+ line, col = self.line_col(offset)
69
+ end_line = self.line_col(end)[0] if end is not None and end > offset else line
70
+ return self.evidence_at_line(line, col, end_line)
71
+
72
+ def evidence_at_line(self, line: int | None, column: int | None = None, end_line: int | None = None) -> Evidence:
73
+ if line is None or not self.text:
74
+ return Evidence(path=self.rel)
75
+ text_line = self.lines[line - 1] if 0 < line <= len(self.lines) else ""
76
+ return Evidence(
77
+ path=self.rel,
78
+ line_start=line,
79
+ line_end=end_line if end_line and end_line != line else None,
80
+ column=column,
81
+ excerpt=make_excerpt(text_line, column),
82
+ )
83
+
84
+
85
+ @dataclass
86
+ class AnalysisContext:
87
+ config: ScanConfig
88
+ inventory: Inventory
89
+ triggers: list[Trigger] = field(default_factory=list)
90
+ issues: list[ScanIssue] = field(default_factory=list)
91
+ # package.json directory ("" = root) → script name → command
92
+ npm_scripts: dict[str, dict[str, str]] = field(default_factory=dict)
93
+ # files loaded automatically by tools/IDE extensions (config-as-code, SURF-012)
94
+ autoloaded: set[str] = field(default_factory=set)
95
+ # static file → referenced files (imports, sourced scripts, files read by loaders)
96
+ imports: dict[str, list[str]] = field(default_factory=dict)
97
+ _trigger_seq: int = 0
98
+
99
+ def add_trigger(
100
+ self,
101
+ kind: str,
102
+ path: str,
103
+ *,
104
+ line: int | None = None,
105
+ label: str = "",
106
+ command: str | None = None,
107
+ autorun: bool = False,
108
+ references: Iterable[str] = (),
109
+ npm_scripts: Iterable[str] = (),
110
+ ) -> Trigger:
111
+ self._trigger_seq += 1
112
+ trig = Trigger(
113
+ id=f"T{self._trigger_seq:04d}",
114
+ kind=kind,
115
+ path=path,
116
+ line=line,
117
+ label=label,
118
+ command=command,
119
+ autorun=autorun,
120
+ references=sorted(set(references)),
121
+ npm_scripts=sorted(set(npm_scripts)),
122
+ )
123
+ self.triggers.append(trig)
124
+ return trig
125
+
126
+ def warn(self, stage: str, message: str, path: str | None = None) -> None:
127
+ self.issues.append(ScanIssue(kind="warning", stage=stage, message=message, path=path))
128
+
129
+
130
+ class Analyzer(Protocol):
131
+ name: str
132
+
133
+ def applies(self, fv: FileView) -> bool: ...
134
+
135
+ def analyze(self, fv: FileView, ctx: AnalysisContext) -> Iterable[Finding]: ...