Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1172703
fix: avoid false analysis limits in documentation
mohgupta-ship-it Sep 10, 2026
ff25a9d
fix: retain reconstruction coverage in ambiguous documentation
mohgupta-ship-it Sep 10, 2026
4d856c0
fix: preserve literal syntax in list and HTML blocks
mohgupta-ship-it Sep 10, 2026
040498c
fix: retain multiline HTML and list fence boundaries
mohgupta-ship-it Sep 10, 2026
0fa65fd
fix: scan JSON quote candidates in linear time
mohgupta-ship-it Sep 10, 2026
edd66f3
test: cover JSON quote complexity and both scan modes
mohgupta-ship-it Sep 10, 2026
d3dd543
Merge pull request #521 from NVIDIA/codex/linear-json-quote-scan
mohgupta-ship-it Sep 10, 2026
4192f85
test: construct degraded LLM graph after availability setup
mohgupta-ship-it Sep 10, 2026
b5c5d8e
fix: respect JSON strings and Markdown block boundaries
mohgupta-ship-it Sep 11, 2026
9801396
test: cover Markdown table and JSON ownership boundaries
mohgupta-ship-it Sep 11, 2026
6990946
test: cover table separator and empty-item boundaries
mohgupta-ship-it Sep 11, 2026
15504f6
test: verify empty-item transitions through public scan gates
mohgupta-ship-it Sep 11, 2026
ca0ceb7
fix: preserve table cells and tab-indented JSON ownership
mohgupta-ship-it Sep 11, 2026
bce6afa
Merge branch 'main' into codex/fix-documentation-analysis-limits
github-actions[bot] Sep 12, 2026
4659ead
Merge branch 'main' into codex/fix-documentation-analysis-limits
github-actions[bot] Sep 12, 2026
fe51f64
Merge branch 'main' into codex/fix-documentation-analysis-limits
github-actions[bot] Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/skillspector/nodes/analyzers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

# Keep the analyzer and runner fence walkers lexically aligned without sharing
# their state machines, since they consume different coordinate systems.
MARKDOWN_FENCE_OPEN = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})[^\r\n]*$")
MARKDOWN_FENCE_OPEN = re.compile(r"^[ ]{0,3}(`{3,}(?=[^`\r\n]*$)|~{3,})[^\r\n]*$")
MARKDOWN_FENCE_CLOSE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})[ \t]*$")
LOGICAL_LINE_BREAK = re.compile(r"\r\n|[\r\n\v\f\x1c-\x1e\x85\u2028\u2029]")
LINE_BREAK_CHARS = "\r\n\v\f\x1c\x1d\x1e\x85\u2028\u2029"
Expand Down
402 changes: 399 additions & 3 deletions src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/skillspector/nodes/analyzers/static_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,10 @@ def _scan_all_views_detailed(
exhaustion_hook(
full_view.text,
finding_budget.check_runtime,
file_type=_infer_file_type(path),
# A fragment cannot prove surrounding HTML,
# container, or inline delimiter ownership.
complete_context=whole_artifact_window,
)
)
except _StaticResourceLimitError as exc:
Expand Down
292 changes: 292 additions & 0 deletions src/skillspector/security_reconstruction.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from __future__ import annotations

import json
import re
from array import array
from collections import Counter
Expand Down Expand Up @@ -436,6 +437,289 @@ def _compact_spaced_security_word_view(view: SecurityTextView) -> SecurityTextVi
return SecurityTextView(f"marker-{view.name}", output.getvalue(), offsets)


# Only bounded, complete JSON values establish quote ownership. Arbitrary
# key/value-looking prose is not a JSON representation. Keep fence syntax
# aligned with analyzers.common without importing its auto-discovered registry.
_MAX_JSON_QUOTE_CONTAINER_CHARS: Final = 65_536
_JSON_FENCE_OPEN_RE: Final = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})([^\r\n]*)$")
_JSON_FENCE_CLOSE_RE: Final = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})[ \t]*$")
_JSON_LIST_MARKER_RE: Final = re.compile(r"(?:[-+*]|[0-9]{1,9}[.)])")


@dataclass
class _JsonContainerCursor:
"""Read container columns without expanding JSON or losing raw positions.

A consumed tab advances the raw index once; its remaining visual columns
stay in ``pending`` until another container or the validation copy uses
them. Tab stops therefore remain relative to the original line.
"""

line: str
check_runtime: Callable[[], None] | None
index: int = 0
column: int = 0
pending: int = 0

def character(self) -> str:
if self.pending:
return " "
return self.line[self.index] if self.index < len(self.line) else ""

def advance(self) -> None:
if self.check_runtime is not None:
self.check_runtime()
if self.pending:
self.pending -= 1
else:
if self.line[self.index] == "\t":
self.pending = 3 - self.column % 4
self.index += 1
self.column += 1

def spaces(self, limit: int) -> int:
start = self.column
while self.column - start < limit and self.character() in (" ", "\t"):
self.advance()
return self.column - start

def quote(self) -> bool:
self.spaces(3)
if self.character() != ">":
return False
self.advance()
self.spaces(1)
return True

def remainder(self) -> str:
# Fence recognition needs at most four leading columns: the fourth
# proves overindentation. Only that bounded prefix is normalized;
# literal tabs after the JSON's first token remain untouched.
start = self.column
self.spaces(4)
return " " * (self.column - start + self.pending) + self.line[self.index :]


def _json_fence_prefix(
line: str, check_runtime: Callable[[], None] | None
) -> tuple[str, tuple[tuple[str, int], ...]]:
"""Recognize explicit container prefixes without changing source text."""
context: list[tuple[str, int]] = []
cursor = _JsonContainerCursor(line, check_runtime)
while True:
if check_runtime is not None:
check_runtime()
start = cursor.index, cursor.column, cursor.pending
cursor.spaces(3)
if cursor.character() == ">":
context.append(("quote", 0))
cursor.advance()
cursor.spaces(1)
continue
item = None if cursor.pending else _JSON_LIST_MARKER_RE.match(line, cursor.index)
if item is not None:
# The marker is bounded ASCII, so raw and visual widths agree.
cursor.column += item.end() - cursor.index
cursor.index = item.end()
padding = cursor.spaces(5)
if 1 <= padding <= 4:
context.append(("indent", cursor.column - start[1]))
continue
cursor.index, cursor.column, cursor.pending = start
return cursor.remainder(), tuple(context)


def _json_fence_body(
line: str,
context: tuple[tuple[str, int], ...],
check_runtime: Callable[[], None] | None,
*,
last_quote_index: int,
) -> str | None:
"""Remove only the opener's proven container prefixes for JSON validation."""
cursor = _JsonContainerCursor(line, check_runtime)
for index, (kind, width) in enumerate(context):
if check_runtime is not None:
check_runtime()
if kind == "quote":
if not cursor.quote():
return None
else:
if cursor.spaces(width) != width:
# Empty list-body lines may omit indentation. A missing quote
# prefix is different: it ends that explicit container.
return (
"\n" if index > last_quote_index and not line[cursor.index :].strip() else None
)
return cursor.remainder()


def _json_body_after_frontmatter(text: str, check_runtime: Callable[[], None] | None) -> int | None:
"""Recognize a bounded, explicitly delimited metadata prefix at offset zero.

This only identifies the JSON body's boundary. Manifest parsing continues
to validate metadata independently; none of its quotes acquires ownership.
"""
prefix = text[:_MAX_JSON_QUOTE_CONTAINER_CHARS]
opening = re.match(r"\A---[ \t]*\r?\n", prefix)
if opening is None:
return None
offset = opening.end()
for line in prefix[offset:].splitlines(keepends=True):
if check_runtime is not None:
check_runtime()
# A complete delimiter line prevents a bounded prefix ending in the
# middle of a longer line from manufacturing a closing delimiter.
if line.endswith("\n") and line.rstrip("\r\n").rstrip(" \t") in {"---", "..."}:
return offset + len(line)
offset += len(line)
return None


def _validated_json_ranges(
text: str, check_runtime: Callable[[], None] | None
) -> list[tuple[int, int]]:
"""Return raw source ranges whose complete JSON syntax has been validated.

List and blockquote prefixes are removed only in a bounded validation copy.
They contain no string delimiters, so quote offsets in the original ranges
remain exact. Invalid, incomplete, oversized and deeply nested containers
grant no ownership. Non-JSON fences also establish block boundaries.
"""

def check() -> None:
if check_runtime is not None:
check_runtime()

def reject_constant(value: str) -> None:
raise ValueError("Non-JSON numeric constant")

def valid(start: int, end: int, body: str | None = None) -> bool:
check()
if end - start > _MAX_JSON_QUOTE_CONTAINER_CHARS:
return False
try:
json.loads(text[start:end] if body is None else body, parse_constant=reject_constant)
except (ValueError, RecursionError):
result = False
else:
result = True
check()
return result

if valid(0, len(text)):
return [(0, len(text))]
body_start = _json_body_after_frontmatter(text, check_runtime)
if body_start is not None and valid(body_start, len(text)):
return [(body_start, len(text))]
ranges: list[tuple[int, int]] = []
fence: tuple[str, int, tuple[tuple[str, int], ...], bool] | None = None
body_lines: list[str] = []
last_quote_index = -1
start = 0
offset = 0
for line in text.splitlines(keepends=True):
check()
body = None
if fence is not None:
body = _json_fence_body(
line, fence[2], check_runtime, last_quote_index=last_quote_index
)
if body is None:
# The explicit container ended. Discard its partial payload,
# then consider this same line once as a new fence opener.
# No recursion, rewind or repeated suffix scan is needed.
fence = None
body_lines = []
if fence is None:
body, context = _json_fence_prefix(line.rstrip("\r\n"), check_runtime)
opening = _JSON_FENCE_OPEN_RE.fullmatch(body)
if opening and not (opening[1][0] == "`" and "`" in opening[2]):
language = opening[2].strip().split(maxsplit=1)
last_quote_index = -1
for index, (kind, _) in enumerate(context):
check()
if kind == "quote":
last_quote_index = index
fence = (
opening[1][0],
len(opening[1]),
context,
bool(language and language[0].lower() == "json"),
)
start = offset + len(line)
body_lines = []
else:
assert body is not None
closing = _JSON_FENCE_CLOSE_RE.fullmatch(body.rstrip("\r\n"))
if closing and closing[1][0] == fence[0] and len(closing[1]) >= fence[1]:
if fence[3] and valid(start, offset, "".join(body_lines)):
ranges.append((start, offset))
fence = None
body_lines = []
elif offset + len(line) - start <= _MAX_JSON_QUOTE_CONTAINER_CHARS:
if fence[3]:
body_lines.append(body)
else:
body_lines = []
offset += len(line)
return ranges


def _json_string_spans(
text: str, check_runtime: Callable[[], None] | None
) -> Iterator[tuple[int, int]]:
"""Lex all complete JSON strings in one forward, escape-aware pass.

This intentionally includes array elements and object keys, unlike the old
key/string-value-pair grammar. Only the caller's validated ranges establish
ownership. Malformed prose still cannot grant structural quote ownership.
"""
cursor = 0
limit = len(text)
start: int | None = None
next_check = 0
while cursor < limit:
if cursor >= next_check:
if check_runtime is not None:
check_runtime()
next_check = cursor + 256
character = text[cursor]
if start is None:
if character == '"':
start = cursor
elif character == "\\":
# JSON escapes consume the following character, including quotes.
# Unicode escape digits contain no delimiters; validation proves
# their syntax before these lexical spans can establish ownership.
cursor += 1
elif character == '"':
yield start, cursor + 1
start = None
elif character in "\r\n":
start = None
cursor += 1
if check_runtime is not None:
check_runtime()


def validated_json_string_spans(
text: str, check_runtime: Callable[[], None] | None
) -> list[tuple[int, int]]:
"""Return exact string spans owned by complete JSON values, including both quotes."""
ranges = _validated_json_ranges(text, check_runtime)
spans: list[tuple[int, int]] = []
for start, end in ranges:
for string_start, string_end in _json_string_spans(text[start:end], check_runtime):
spans.append((start + string_start, start + string_end))
return spans


def validated_json_string_closers(text: str, check_runtime: Callable[[], None] | None) -> set[int]:
"""Return exact closing-quote offsets owned by complete JSON values."""
return {end - 1 for _, end in validated_json_string_spans(text, check_runtime)}


def _quoted_directives(
text: str,
check_runtime: Callable[[], None] | None,
Expand All @@ -444,9 +728,17 @@ def _quoted_directives(
pattern: re.Pattern[str] = _QUOTED_DIRECTIVE_START_RE,
unsupported_header: bool = False,
) -> Iterator[_Directive]:
# A complete JSON representation owns its closing quotes; JSON-looking
# fragments in prose and instructions do not. Only structural closing
# delimiters are excluded; instruction contents remain available below.
json_value_closers = (
validated_json_string_closers(text, check_runtime) if unsupported_header else set()
)
for match in pattern.finditer(text):
if check_runtime is not None:
check_runtime()
if match.end() - 1 in json_value_closers:
continue
quote = _QUOTE_OPEN_TO_CLOSE[match.group("quote")]
marker_start = match.end()
marker_end_limit = min(len(text), marker_start + MAX_MARKER_LOOKAHEAD_CHARS)
Expand Down
12 changes: 10 additions & 2 deletions tests/integration/test_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
"""Tests for the Skillspector LangGraph workflow."""

import json
from importlib import import_module
from pathlib import Path

import pytest

from skillspector.graph import graph
from skillspector.graph import create_graph, graph


def test_graph_invoke_with_output_format_json(tmp_path: Path) -> None:
Expand Down Expand Up @@ -189,7 +190,14 @@ def run_batches_detailed(self, _batches: object) -> object:
"skillspector.nodes.analyzers.mcp_tool_poisoning._TP4Analyzer", FailingTP4Analyzer
)

result = graph.invoke({"skill_path": str(tmp_path), "use_llm": True, "output_format": "json"})
# Build after configuring availability so the mocked semantic transports
# are exercised even when the test environment has no provider credentials.
monkeypatch.setattr(
import_module("skillspector.graph"), "is_llm_available", lambda: (True, None)
)
result = create_graph().invoke(
{"skill_path": str(tmp_path), "use_llm": True, "output_format": "json"}
)

log = result["llm_call_log"]
assert log, "expected LLM telemetry records"
Expand Down
Loading
Loading