-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathrunner.py
More file actions
802 lines (672 loc) · 30.2 KB
/
Copy pathrunner.py
File metadata and controls
802 lines (672 loc) · 30.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Graph invocation helpers for batch scanning.
Thin wrappers over ``skillspector.graph.graph`` — build initial state,
invoke the graph, and transform the raw result dict into a structured
batch entry suitable for downstream reporting.
Compatibility patches (DeepSeek / non-OpenAI providers)
-------------------------------------------------------
Call :func:`setup_deepseek_compat` before any LLM activity to apply
seven targeted monkey-patches that make the core analyzers work with
providers that lack structured-output (``response_format``) support.
The patches must be applied exactly once, before the first
``graph.invoke`` call. Importing this module does NOT apply them
automatically — the caller controls when they take effect.
"""
from __future__ import annotations
import json
import os
import shutil
from datetime import UTC, datetime
from pathlib import Path
from skillspector.graph import graph
from skillspector.llm_analyzer_base import LLMAnalyzerBase, LLMAnalysisResult
from skillspector.logging_config import get_logger
from skillspector.nodes.meta_analyzer import LLMMetaAnalyzer, MetaAnalyzerResult
from .annotation import annotate_findings
logger = get_logger(__name__)
# ═══════════════════════════════════════════════════════════════════════════
# API Key Pool — shared across graph-internal and gap-fill LLM calls
# ═══════════════════════════════════════════════════════════════════════════
_api_pool: "ApiKeyPool | None" = None
_original_get_chat_model = None # saved on first set_api_pool call
def set_api_pool(pool: "ApiKeyPool | None") -> None:
"""Replace the LLM chat-model factory with a pooled version.
When *pool* is set, every call to :func:`skillspector.llm_utils.get_chat_model`
returns a :class:`~.api_pool.PooledChatModel` instance backed by the shared
key pool. This covers both graph-internal analyzers (20 per skill) and the
gap-fill pass — every LLM call in the batch scan goes through the pool.
Call ``set_api_pool(None)`` to restore the original factory.
"""
global _api_pool, _original_get_chat_model
import skillspector.llm_utils as _llm_utils
import skillspector.llm_analyzer_base as _llm_analyzer_base
if pool is None:
_api_pool = None
if _original_get_chat_model is not None:
_llm_utils.get_chat_model = _original_get_chat_model
_llm_analyzer_base.get_chat_model = _original_get_chat_model
_original_get_chat_model = None
logger.info("API key pool removed — restored original get_chat_model")
return
_api_pool = pool
if _original_get_chat_model is None:
_original_get_chat_model = _llm_utils.get_chat_model
def _pooled_get_chat_model(model=None):
if _api_pool:
from .api_pool import PooledChatModel
pooled_model = PooledChatModel(_api_pool)
_llm_utils.register_chat_model_provider(pooled_model, "openai")
return pooled_model
return _original_get_chat_model(model)
_llm_utils.get_chat_model = _pooled_get_chat_model
_llm_analyzer_base.get_chat_model = _pooled_get_chat_model
logger.info("API key pool wired — all LLM calls will use PooledChatModel")
# ═══════════════════════════════════════════════════════════════════════════
# HTTP timeout — stop hung connections from blocking workers forever
# ═══════════════════════════════════════════════════════════════════════════
_DEFAULT_REQUEST_TIMEOUT = 30.0 # total request ceiling
_DEFAULT_CONNECT_TIMEOUT = 8.0 # TCP / TLS handshake
# ═══════════════════════════════════════════════════════════════════════════
# Compatibility patches (DeepSeek / non-OpenAI providers)
# ═══════════════════════════════════════════════════════════════════════════
#
# These patches are NOT applied at import time. Call :func:`setup_deepseek_compat`
# before any LLM activity to activate them. Each patch can only be applied once;
# subsequent calls are no-ops.
_patches_depth: int = 0 # nesting counter — safe for re-entrant context managers
# -- Patch 1: inject response_schema=None as instance attribute ------------
# We set response_schema=None on the *instance* dict before the original
# __init__ runs. Python MRO always checks instance.__dict__ before
# class.__dict__ — this is a language-level guarantee (not a library
# internal). The instance dict takes precedence regardless of how the
# upstream class hierarchy evolves, so this patch is safe against
# upstream refactors.
_original_base_init = LLMAnalyzerBase.__init__
def _patched_base_init(self, base_prompt, model, *, node="llm_analyzer"):
"""Set response_schema=None on the instance dict BEFORE original init.
Relies on Python MRO guarantee: instance.__dict__ is always checked
before any class-level attribute. This is language semantics, not
a library internal.
"""
self.response_schema = None
_original_base_init(self, base_prompt, model, node=node)
# -- Patch 2: LLMAnalyzerBase.parse_response handles raw JSON --------------
_original_base_parse = LLMAnalyzerBase.parse_response
def _patched_base_parse(self, response, batch):
"""Parse raw LLM text into Findings via manual JSON + Pydantic."""
if isinstance(response, LLMAnalysisResult):
return _original_base_parse(self, response, batch)
text = _strip_markdown_fences(str(response))
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
logger.warning(
"LLMAnalyzerBase.parse_response: invalid JSON for %s: %s",
batch.file_label,
exc,
)
return []
try:
result = LLMAnalysisResult.model_validate(data)
return [f.to_finding(batch.file_path) for f in result.findings]
except Exception as exc:
logger.warning(
"LLMAnalyzerBase.parse_response: schema validation failed for %s: %s",
batch.file_label,
exc,
)
return []
# -- Patch 3: LLMMetaAnalyzer.parse_response handles raw JSON ---------------
_original_meta_parse = LLMMetaAnalyzer.parse_response
def _sanitize_meta_finding(d: dict) -> dict:
"""Fix common LLM output quirks that break downstream consumers."""
for key in ("remediation", "explanation"):
if d.get(key) is None:
d[key] = ""
if d.get("impact") not in ("critical", "high", "medium", "low"):
d["impact"] = "low"
return d
def _patched_meta_parse(self, response, batch):
"""Parse raw LLM text into meta-analyzer dicts via manual JSON + Pydantic."""
if isinstance(response, MetaAnalyzerResult):
return _original_meta_parse(self, response, batch)
text = _strip_markdown_fences(str(response))
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
logger.warning(
"LLMMetaAnalyzer.parse_response: invalid JSON for %s: %s",
batch.file_label,
exc,
)
return []
try:
result = MetaAnalyzerResult.model_validate(data)
items = []
for f in result.findings:
d = _sanitize_meta_finding(f.model_dump())
d["_file"] = batch.file_path
items.append(d)
return items
except Exception as exc:
logger.warning(
"LLMMetaAnalyzer.parse_response: schema validation failed for %s: %s",
batch.file_label,
exc,
)
return []
# -- Patch 4: append JSON output format to base prompt ---------------------
_JSON_OUTPUT_INSTRUCTION = (
"\n\nRespond with ONLY a JSON object (no markdown, no explanation):\n"
'{"findings": [{"rule_id": "...", "message": "...", '
'"severity": "LOW|MEDIUM|HIGH|CRITICAL", "start_line": 1, '
'"end_line": null, "confidence": 0.0-1.0, '
'"explanation": "...", "remediation": "..."}]}\n'
"If no issues found, return: {\"findings\": []}"
)
_original_base_build_prompt = LLMAnalyzerBase.build_prompt
def _patched_base_build_prompt(self, batch, **kwargs):
prompt = _original_base_build_prompt(self, batch, **kwargs)
return prompt + _JSON_OUTPUT_INSTRUCTION
# -- Patch 5: append JSON format to meta-analyzer prompt -------------------
_original_meta_build_prompt = LLMMetaAnalyzer.build_prompt
_META_JSON_PROMPT = (
"\n\nRespond with ONLY a JSON object (no markdown):\n"
'{"findings": [{"pattern_id": "...", "is_vulnerability": true|false, '
'"confidence": 0.0-1.0, "intent": "malicious|negligent|benign", '
'"impact": "critical|high|medium|low", '
'"explanation": "...", "remediation": "..."}], '
'"overall_assessment": {"risk_level": "LOW|MEDIUM|HIGH|CRITICAL", '
'"summary": "..."}}\n'
'Rules: never use null — use "" for empty strings. '
'Never use "none" for impact — use "low" for negligible. '
'If no findings: {"findings": [], '
'"overall_assessment": {"risk_level": "LOW", "summary": "No issues found"}}'
)
def _patched_meta_build_prompt(self, batch, **kwargs):
prompt = _original_meta_build_prompt(self, batch, **kwargs)
return prompt + _META_JSON_PROMPT
# -- Patch 6: enforce HTTP-level timeouts on all ChatOpenAI instances ------
# Capture at module-load time to avoid order-dependency (any prior import that
# patches ChatOpenAI would corrupt the capture inside _apply_patches).
try:
from langchain_openai import ChatOpenAI as _CO_for_original
_original_chatopenai_init = _CO_for_original.__init__
except ImportError:
_original_chatopenai_init = None
def _patched_chatopenai_init(self, **kwargs):
import httpx
_to = httpx.Timeout(
_DEFAULT_REQUEST_TIMEOUT,
connect=_DEFAULT_CONNECT_TIMEOUT,
)
# Set both the Pydantic alias AND the canonical field name so we don't
# depend on alias-precedence behaviour (which is a Pydantic v2 internal).
kwargs["timeout"] = _to
kwargs["request_timeout"] = _to
_original_chatopenai_init(self, **kwargs)
# -- Patch 7: silence "Event loop is closed" noise from httpx cleanup ------
import asyncio as _asyncio
_original_asyncio_run = _asyncio.run
def _patched_asyncio_run(main, *, debug=None, loop_factory=None):
def _make_quiet_loop():
loop = (loop_factory or _asyncio.new_event_loop)()
def _handler(loop, context):
exc = context.get("exception")
if isinstance(exc, RuntimeError) and "Event loop is closed" in str(exc):
return
loop.default_exception_handler(context)
loop.set_exception_handler(_handler)
return loop
return _original_asyncio_run(main, debug=debug, loop_factory=_make_quiet_loop)
def setup_deepseek_compat() -> None:
"""Apply DeepSeek compatibility patches permanently (convenience wrapper).
Prefer :func:`deepseek_compat` context manager for scoped, reversible
patching. This function is a one-way door — patches stay for the
process lifetime.
"""
_apply_patches()
def _verify_patch_targets() -> None:
"""Verify that all patch targets have expected signatures / attributes.
Raises :class:`RuntimeError` with a specific message if an upstream
change has broken one of the assumptions our patches depend on.
This turns a silent, hard-to-debug failure into an immediate, clear
error at patch-application time.
Covers both surface-level (function signatures) and deep dependencies
(methods called inside try/except that could silently degrade).
"""
import dataclasses
import inspect
from skillspector.llm_analyzer_base import Batch, LLMFinding
# -- Patch 1: LLMAnalyzerBase.__init__(..., *, node=...) -----------------
_check_signature(
LLMAnalyzerBase.__init__,
["self", "base_prompt", "model"],
"LLMAnalyzerBase.__init__",
1,
)
_node_param = inspect.signature(LLMAnalyzerBase.__init__).parameters.get("node")
if _node_param is None or _node_param.kind != inspect.Parameter.KEYWORD_ONLY:
raise RuntimeError(
"Patch 1 target changed: LLMAnalyzerBase.__init__ must retain its "
"keyword-only 'node' parameter."
)
if not hasattr(LLMAnalyzerBase, "response_schema"):
raise RuntimeError(
"Patch 1 target lost: LLMAnalyzerBase no longer has "
"'response_schema' class attribute. Upstream may have renamed "
"or removed it."
)
# -- Patch 2: LLMAnalyzerBase.parse_response(self, response, batch) ------
_check_signature(
LLMAnalyzerBase.parse_response,
["self", "response", "batch"],
"LLMAnalyzerBase.parse_response",
2,
)
# Deep deps (called inside try/except — silent degradation if broken):
if not hasattr(LLMAnalysisResult, "model_validate"):
raise RuntimeError(
"Patch 2 deep dependency lost: LLMAnalysisResult.model_validate "
"no longer exists. Upstream may have switched from Pydantic v2 "
"to a different validation library."
)
if not hasattr(LLMFinding, "to_finding"):
raise RuntimeError(
"Patch 2 deep dependency lost: LLMFinding.to_finding method "
"no longer exists. Upstream may have renamed or removed it."
)
# Batch is a @dataclass — file_path is a field, file_label is a @property
_batch_field_names = {f.name for f in dataclasses.fields(Batch)}
if "file_path" not in _batch_field_names:
raise RuntimeError(
"Patch 2 deep dependency lost: Batch dataclass no longer has "
"'file_path' field. Upstream may have changed the Batch dataclass."
)
if "file_label" not in {n for n in dir(Batch) if isinstance(getattr(Batch, n, None), property)}:
raise RuntimeError(
"Patch 2 deep dependency lost: Batch no longer has 'file_label' "
"property. Upstream may have renamed or removed it."
)
# -- Patch 3: LLMMetaAnalyzer.parse_response(self, response, batch) ------
_check_signature(
LLMMetaAnalyzer.parse_response,
["self", "response", "batch"],
"LLMMetaAnalyzer.parse_response",
3,
)
if not hasattr(MetaAnalyzerResult, "model_validate"):
raise RuntimeError(
"Patch 3 deep dependency lost: MetaAnalyzerResult.model_validate "
"no longer exists. Upstream may have switched from Pydantic v2."
)
# Pydantic models don't expose fields as class attributes — use
# model_fields (v2) or __fields__ (v1 fallback).
_mr_fields = getattr(MetaAnalyzerResult, "model_fields", None) or getattr(
MetaAnalyzerResult, "__fields__", {}
)
if "findings" not in _mr_fields:
raise RuntimeError(
"Patch 3 deep dependency lost: MetaAnalyzerResult no longer has "
"'findings' field. Upstream may have changed the Pydantic schema."
)
# -- Patch 4: LLMAnalyzerBase.build_prompt(self, batch, **kwargs) --------
sig4 = inspect.signature(LLMAnalyzerBase.build_prompt)
if "batch" not in sig4.parameters:
raise RuntimeError(
"Patch 4 target changed: LLMAnalyzerBase.build_prompt no longer "
"accepts 'batch' parameter. Upstream may have changed the API."
)
if not any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig4.parameters.values()):
raise RuntimeError(
"Patch 4 target changed: LLMAnalyzerBase.build_prompt no longer "
"accepts **kwargs. Upstream may have changed the API."
)
# -- Patch 5: LLMMetaAnalyzer.build_prompt(self, batch, **kwargs) --------
sig5 = inspect.signature(LLMMetaAnalyzer.build_prompt)
if "batch" not in sig5.parameters:
raise RuntimeError(
"Patch 5 target changed: LLMMetaAnalyzer.build_prompt no longer "
"accepts 'batch' parameter. Upstream may have changed the API."
)
# -- Patch 6: ChatOpenAI.__init__ — must accept **kwargs -----------------
try:
from langchain_openai import ChatOpenAI as _ChatOpenAI
sig6 = inspect.signature(_ChatOpenAI.__init__)
if not any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig6.parameters.values()
):
raise RuntimeError(
"Patch 6 target changed: ChatOpenAI.__init__ no longer "
"accepts **kwargs. Upstream may have removed the Pydantic "
"alias or switched to a non-Pydantic model."
)
except ImportError:
pass # langchain_openai not available — Patch 6 is skipped anyway
# -- Patch 7: asyncio.run(main, *, debug=None, loop_factory=None) --------
# Only 'main' is positional; debug/loop_factory are keyword-only by design.
_check_signature(
_original_asyncio_run,
["main"],
"asyncio.run",
7,
)
# Deep dep: new_event_loop() is used inside _make_quiet_loop
if not callable(getattr(_asyncio, "new_event_loop", None)):
raise RuntimeError(
"Patch 7 deep dependency lost: asyncio.new_event_loop is no "
"longer available. Python version may have changed the API."
)
logger.debug("All 7 patch targets verified — upstream API matches expectations")
def _check_signature(
func: object,
expected_params: list[str],
label: str,
patch_num: int,
) -> None:
"""Raise :class:`RuntimeError` if *func* doesn't accept *expected_params*."""
import inspect
try:
sig = inspect.signature(func)
except (ValueError, TypeError) as exc:
raise RuntimeError(
f"Patch {patch_num} target unavailable: cannot inspect {label} "
f"signature. Upstream may have changed the API. ({exc})"
) from exc
for param in expected_params:
if param not in sig.parameters:
raise RuntimeError(
f"Patch {patch_num} target changed: {label} no longer has "
f"'{param}' parameter. Upstream may have changed the API."
)
# Guard against keyword-only migration: if a parameter we pass
# positionally becomes keyword-only, our call sites break.
_kind = sig.parameters[param].kind
if _kind == inspect.Parameter.KEYWORD_ONLY:
raise RuntimeError(
f"Patch {patch_num} target changed: {label} parameter "
f"'{param}' is now keyword-only (was positional). Upstream "
f"may have changed the API."
)
def _apply_patches() -> None:
"""Apply all 7 compatibility patches (idempotent — safe to nest).
Uses a nesting counter instead of a boolean flag so that nested
``with deepseek_compat()`` blocks don't restore on the inner exit.
"""
global _patches_depth
if _patches_depth > 0:
_patches_depth += 1
return
_verify_patch_targets()
LLMAnalyzerBase.__init__ = _patched_base_init
LLMAnalyzerBase.parse_response = _patched_base_parse
LLMAnalyzerBase.build_prompt = _patched_base_build_prompt
LLMMetaAnalyzer.parse_response = _patched_meta_parse
LLMMetaAnalyzer.build_prompt = _patched_meta_build_prompt
try:
import httpx
from langchain_openai import ChatOpenAI as _ChatOpenAI
_ChatOpenAI.__init__ = _patched_chatopenai_init
except ImportError:
logger.debug("httpx not available — skipping ChatOpenAI timeout patch")
_asyncio.run = _patched_asyncio_run
_patches_depth = 1
logger.debug("DeepSeek compatibility patches applied (7 patches)")
def _restore_patches() -> None:
"""Restore all original class methods / functions (nesting-aware).
Only actually restores when the outermost context manager exits
(_patches_depth reaches 0).
"""
global _patches_depth
if _patches_depth == 0:
return # not active
_patches_depth -= 1
if _patches_depth > 0:
return # still nested — don't restore yet
LLMAnalyzerBase.__init__ = _original_base_init
LLMAnalyzerBase.parse_response = _original_base_parse
LLMAnalyzerBase.build_prompt = _original_base_build_prompt
LLMMetaAnalyzer.parse_response = _original_meta_parse
LLMMetaAnalyzer.build_prompt = _original_meta_build_prompt
if _original_chatopenai_init is not None:
try:
from langchain_openai import ChatOpenAI as _ChatOpenAI
_ChatOpenAI.__init__ = _original_chatopenai_init
except ImportError:
pass
_asyncio.run = _original_asyncio_run
logger.debug("DeepSeek compatibility patches restored to originals")
# ---------------------------------------------------------------------------
# Context manager — scoped, reversible patching (Python best practice)
# ---------------------------------------------------------------------------
# Pattern: Save → Patch → Yield → Restore (finally-guaranteed)
# Reference: unittest.mock.patch, pytest.monkeypatch.context(), gevent.monkey
from contextlib import contextmanager
@contextmanager
def deepseek_compat():
"""Context manager that applies DeepSeek compatibility patches and
restores original state on exit — even if an exception occurs.
Usage::
with deepseek_compat():
# All 7 patches active inside this block
batch_scan(tests/fixtures)
# Outside the block: everything restored to original
Patches applied (same 7 as :func:`setup_deepseek_compat`):
1. ``LLMAnalyzerBase.__init__`` — inject ``response_schema=None``
2. ``LLMAnalyzerBase.parse_response`` — manual JSON parsing
3. ``LLMMetaAnalyzer.parse_response`` — manual JSON + field sanitize
4. ``LLMAnalyzerBase.build_prompt`` — append JSON output instruction
5. ``LLMMetaAnalyzer.build_prompt`` — append JSON output instruction
6. ``ChatOpenAI.__init__`` — enforce HTTP-level timeouts
7. ``asyncio.run`` — suppress "Event loop is closed" noise
"""
_apply_patches()
try:
yield
finally:
_restore_patches()
def _strip_markdown_fences(text: str) -> str:
"""Remove ```json ... ``` wrappers from LLM output."""
text = text.strip()
if text.startswith("```"):
nl = text.find("\n")
if nl != -1:
text = text[nl + 1:]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3].rstrip()
return text.strip()
def scan_state(skill_dir: Path, use_llm: bool) -> dict[str, object]:
"""Build the initial LangGraph state for a single skill directory."""
return {
"input_path": str(skill_dir),
"output_format": "json",
"use_llm": use_llm,
}
def cleanup_result(result: dict[str, object]) -> None:
"""Remove the temporary directory created by the graph, if any."""
temp_dir = result.get("temp_dir_for_cleanup")
if not temp_dir or not isinstance(temp_dir, str):
return
shutil.rmtree(temp_dir, ignore_errors=True)
# Number of English-keyword static rules that lose recall for non-English skills.
# These 25 rules are documented in annotation._ENGLISH_KEYWORD_RULES.
_ENGLISH_KEYWORD_RULE_COUNT = 25
def entry_from_result(
result: dict[str, object],
skill_dir: Path,
root: Path,
*,
detected_language: str = "en",
gap_fill_applied: bool = False,
gap_fill_findings: int = 0,
) -> dict[str, object]:
"""Convert a raw ``graph.invoke()`` result into a batch-report entry.
Extracts findings, manifest metadata, component metadata, and builds
the canonical ``skill / risk_assessment / components / issues`` shape
used by report formatters. Adds ``source_group``, ``language``,
``scan_mode``, and ``enhancements`` fields for provenance tracking
and comparability with the standard single-skill scan.
Parameters
----------
result :
Raw dict returned by ``graph.invoke(state)``.
skill_dir :
The skill directory that was scanned.
root :
Root directory for relative-path computation.
detected_language :
Language detected for this skill (``"en"``, ``"zh"``, etc.).
gap_fill_applied :
``True`` when the gap-fill LLM pass has been applied.
gap_fill_findings :
Number of gap-fill findings appended to the issues list.
"""
findings = result.get("filtered_findings", result.get("findings", []))
manifest = result.get("manifest") or {}
component_metadata = result.get("component_metadata") or []
skill_name = (
(manifest.get("name") or skill_dir.name) if manifest else skill_dir.name
)
try:
rel_path = str(skill_dir.relative_to(root))
except ValueError:
rel_path = str(skill_dir)
source_group = rel_path.split("/")[0] if "/" in rel_path else "."
raw_issues: list[dict[str, object]]
if findings and hasattr(findings[0], "to_dict"):
raw_issues = [f.to_dict() for f in findings] # type: ignore[union-attr]
elif findings:
raw_issues = list(findings) # type: ignore[assignment]
else:
raw_issues = []
issues = annotate_findings(raw_issues, detected_language)
is_non_en = detected_language != "en"
return {
"skill": {
"name": skill_name,
"source": rel_path,
"source_group": source_group,
"language": detected_language,
"scanned_at": datetime.now(UTC).isoformat(),
},
"risk_assessment": {
"score": result.get("risk_score", 0),
"severity": result.get("risk_severity", "LOW"),
"recommendation": (result.get("risk_recommendation") or "SAFE").replace(
"_", " "
),
},
"components": [
{
"path": c.get("path"),
"type": c.get("type"),
"lines": c.get("lines"),
"executable": c.get("executable"),
"size_bytes": c.get("size_bytes"),
}
for c in component_metadata # type: ignore[union-attr]
],
"issues": issues,
"analysis_completeness": result.get("analysis_completeness") or {},
"execution_successful": bool(result.get("execution_successful", True)),
"scan_mode": "multilingual-enhanced",
"enhancements": {
"gap_fill_applied": gap_fill_applied,
"gap_fill_findings": gap_fill_findings,
"english_keyword_rules_skipped": (
_ENGLISH_KEYWORD_RULE_COUNT if is_non_en else 0
),
},
}
def run_one(
skill_dir: Path,
root: Path,
*,
use_llm: bool,
detected_language: str = "en",
gap_fill_applied: bool = False,
gap_fill_findings: int = 0,
) -> tuple[dict[str, object], str | None]:
"""Scan a single skill through the full graph pipeline.
Parameters
----------
skill_dir :
Path to the skill directory.
root :
Root directory for relative-path computation in reports.
use_llm :
Passed through to the graph as ``state["use_llm"]``.
detected_language :
Language tag for annotation and reporting.
gap_fill_applied :
``True`` when the caller has applied gap-fill (set by
:func:`~.batch_scan._scan_skill` after the graph returns).
gap_fill_findings :
Number of gap-fill findings appended post-graph.
Returns
-------
``(entry, error_message_or_None)`` — on success *error_message*
is ``None``; on failure *entry* is a stub error entry and
*error_message* carries the exception text.
"""
result = None
try:
state = scan_state(skill_dir, use_llm=use_llm)
result = graph.invoke(state)
entry = entry_from_result(
result,
skill_dir,
root,
detected_language=detected_language,
gap_fill_applied=gap_fill_applied,
gap_fill_findings=gap_fill_findings,
)
return entry, None
except Exception as exc:
rel_name = _rel_name(skill_dir, root)
error_entry: dict[str, object] = {
"skill": {
"name": rel_name,
"source": str(skill_dir),
"source_group": rel_name.split("/")[0] if "/" in rel_name else ".",
"language": detected_language,
"scanned_at": datetime.now(UTC).isoformat(),
},
"risk_assessment": {
"score": 0,
"severity": "ERROR",
"recommendation": "ERROR",
},
"components": [],
"issues": [],
"scan_mode": "multilingual-enhanced",
"enhancements": {
"gap_fill_applied": False,
"gap_fill_findings": 0,
"english_keyword_rules_skipped": 0,
},
"error": str(exc),
}
return error_entry, str(exc)
finally:
if result is not None:
cleanup_result(result)
def _rel_name(skill_dir: Path, root: Path) -> str:
"""Best-effort relative name for display in progress lines."""
try:
return str(skill_dir.relative_to(root))
except ValueError:
return skill_dir.name