Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package skillspector for openSUSE:Factory 
checked in at 2026-10-31 23:09:19
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/skillspector (Old)
 and      /work/SRC/openSUSE:Factory/.skillspector.new.1255 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "skillspector"

Sat Oct 31 23:09:19 2026 rev:12 rq:1394073 version:2.13.2

Changes:
--------
--- /work/SRC/openSUSE:Factory/skillspector/skillspector.changes        
2026-10-26 10:05:51.514447927 +0100
+++ /work/SRC/openSUSE:Factory/.skillspector.new.1255/skillspector.changes      
2026-10-31 23:11:40.121119571 +0100
@@ -1,0 +2,18 @@
+Thu Oct 29 10:14:59 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to version 2.13.2:
+  * No more false parser-limit reports on bare command
+    wrappers (timeout, sudo, nice, xargs) at clause
+    boundaries in tables and prose references
+  * sudo -n parsed as flag; descriptor redirections
+    (e.g. 2>&1) count as complete; MCP batch cap
+    aligned with the 128-file limit
+  * Direct template messages on verified built-in Node
+    assertion imports no longer trip shell-substitution
+    limits; .mjs/.cjs classified consistently
+- Deselect two wall-clock-bound tests on aarch64 (pre-existing,
+  untouched by this update): the 10 s subprocess and 1 s worker
+  bounds overrun slow interpreter teardown, with all detection
+  assertions passing.
+
+-------------------------------------------------------------------

Old:
----
  skillspector-2.13.1.tar.gz

New:
----
  skillspector-2.13.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ skillspector.spec ++++++
--- /var/tmp/diff_new_pack.tnXnpi/_old  2026-10-31 23:11:43.018239847 +0100
+++ /var/tmp/diff_new_pack.tnXnpi/_new  2026-10-31 23:11:43.020239930 +0100
@@ -21,7 +21,7 @@
 # %%{primary_python} so it stays correct as the primary interpreter moves.
 %define pythons %{primary_python}
 Name:           skillspector
-Version:        2.13.1
+Version:        2.13.2
 Release:        0
 Summary:        Security scanner for AI agent skills
 License:        Apache-2.0
@@ -205,13 +205,25 @@
 #     scan finishes in under 1.0 s; aarch64 needs 1.28 s. Pure wall-clock
 #     bound with no detection content (its zero-PE3-finding assertion holds).
 #
+# 2.13.2 adds two more of the same wall-clock family (both pre-existing
+# tests, untouched by the update; upstream x86_64 CI is green):
+#   * test_typescript_declaration_near_misses... spawns a fresh interpreter
+#     with a hardcoded 10 s timeout. The child completes all 15 assertions
+#     (it prints the completion marker) -- only interpreter teardown past a
+#     ~10 s cold import overruns the bound on aarch64.
+#   * test_worker_startup_has_a_separate_bound joins a spawned worker 1 s
+#     after startup. The worker's whole job is setting an event and writing
+#     a 100-byte result file -- correct by construction -- but teardown of
+#     the spawned cone import overruns the 1 s bound on aarch64 (cold
+#     "import skillspector" alone measures 9.8 s here).
+#
 # Deselecting keeps the build gate meaningful without hiding a detection
 # failure: the same resource bounds are asserted deterministically by the
 # tests that monkeypatch the budget rather than racing it (e.g.
 # test_static_runtime_limit_is_reported_as_partial,
 # test_static_output_limit_is_reported_as_partial, and the build_context
 # deadline tests), and those do run.
-%pytest --deselect 
tests/unit/test_mcp_server.py::test_mcp_stdio_initialize_registers_scan_skill 
--deselect 
tests/unit/test_baseline_mcp.py::test_stdio_mcp_preserves_compact_verdicts_and_ignores_shipped_baseline
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_github_url_allowed
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_gitlab_url_allowed
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_raw_githubusercontent_allowed
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_download_does_not_follow_redirects
 --deselect 
tests/unit/test_input_handler.py::test_validate_url_host_scp_extracts_github 
--deselect tests/unit/test_input_handler.py::test_scp_valid_host_clones 
--deselect tests/unit/test_input_handler.py::test_https_url_unchanged 
--deselect 
"tests/nodes/analyzers/test_bundled_execution_surface.py::test_discovery_parser_bounds_and_ledger_table[malformed_schema]"
 --deselect tests/test_bund
 led_execution_surface_acceptance.py::test_public_cli_exit_contract --deselect 
tests/test_bundled_execution_surface_acceptance.py::test_recursive_single_child_routes_execution_surfaces_from_child_root
 --deselect 
tests/test_bundled_execution_surface_acceptance.py::test_cli_fail_on_incomplete_is_opt_in
 --deselect 
tests/nodes/test_security_end_to_end.py::test_rd04_large_file_pair_detects_start_boundary_and_end
 --deselect 
tests/nodes/test_security_end_to_end.py::test_nine_case_contract_across_public_surfaces
 --deselect 
tests/nodes/test_security_remediation.py::test_five_megabyte_normalized_static_scan_stays_below_memory_ceiling
 --deselect 
tests/nodes/test_security_end_to_end.py::test_cross_window_separator_pair_across_public_surfaces
 --deselect 
tests/unit/test_patterns.py::TestPrivilegeEscalation::test_pe3_repeated_nouns_have_bounded_qualifier_cost
 --deselect 
tests/unit/test_cli_subprocess.py::test_registry_unsupported_options_stop_before_input_or_output_access
 --deselect tests/unit/test
 _cli_subprocess.py::test_registry_default_options_still_scan --deselect 
tests/unit/test_cli_subprocess.py::test_recursive_medium_recommendation_matches_direct_scan
+%pytest --deselect 
tests/unit/test_mcp_server.py::test_mcp_stdio_initialize_registers_scan_skill 
--deselect 
tests/unit/test_baseline_mcp.py::test_stdio_mcp_preserves_compact_verdicts_and_ignores_shipped_baseline
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_github_url_allowed
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_gitlab_url_allowed
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_raw_githubusercontent_allowed
 --deselect 
tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_download_does_not_follow_redirects
 --deselect 
tests/unit/test_input_handler.py::test_validate_url_host_scp_extracts_github 
--deselect tests/unit/test_input_handler.py::test_scp_valid_host_clones 
--deselect tests/unit/test_input_handler.py::test_https_url_unchanged 
--deselect 
"tests/nodes/analyzers/test_bundled_execution_surface.py::test_discovery_parser_bounds_and_ledger_table[malformed_schema]"
 --deselect tests/test_bund
 led_execution_surface_acceptance.py::test_public_cli_exit_contract --deselect 
tests/test_bundled_execution_surface_acceptance.py::test_recursive_single_child_routes_execution_surfaces_from_child_root
 --deselect 
tests/test_bundled_execution_surface_acceptance.py::test_cli_fail_on_incomplete_is_opt_in
 --deselect 
tests/nodes/test_security_end_to_end.py::test_rd04_large_file_pair_detects_start_boundary_and_end
 --deselect 
tests/nodes/test_security_end_to_end.py::test_nine_case_contract_across_public_surfaces
 --deselect 
tests/nodes/test_security_remediation.py::test_five_megabyte_normalized_static_scan_stays_below_memory_ceiling
 --deselect 
tests/nodes/test_security_end_to_end.py::test_cross_window_separator_pair_across_public_surfaces
 --deselect 
tests/unit/test_patterns.py::TestPrivilegeEscalation::test_pe3_repeated_nouns_have_bounded_qualifier_cost
 --deselect 
tests/unit/test_cli_subprocess.py::test_registry_unsupported_options_stop_before_input_or_output_access
 --deselect tests/unit/test
 _cli_subprocess.py::test_registry_default_options_still_scan --deselect 
tests/unit/test_cli_subprocess.py::test_recursive_medium_recommendation_matches_direct_scan
 --deselect 
tests/nodes/test_nested_artifacts.py::test_typescript_declaration_near_misses_are_bounded_and_stay_executable
 --deselect 
tests/test_batch_scan_security.py::test_worker_startup_has_a_separate_bound
 
 %files
 %license LICENSE

++++++ skillspector-2.13.1.tar.gz -> skillspector-2.13.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/skillspector-2.13.1/PKG-INFO 
new/skillspector-2.13.2/PKG-INFO
--- old/skillspector-2.13.1/PKG-INFO    2020-02-02 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/PKG-INFO    2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: skillspector
-Version: 2.13.1
+Version: 2.13.2
 Summary: SkillSpector: Security scanner for AI agent skills (Claude Code, 
Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and 
security risks before installation. Supports Git repos, URLs, zips, and local 
directories; runs static pattern checks and optional LLM semantic analysis; 
outputs terminal, JSON, and Markdown reports with risk scoring.
 Project-URL: Homepage, https://github.com/NVIDIA/skillspector
 Project-URL: Documentation, https://github.com/NVIDIA/skillspector#readme
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/docs/release/skillspector-2.13.2.md 
new/skillspector-2.13.2/docs/release/skillspector-2.13.2.md
--- old/skillspector-2.13.1/docs/release/skillspector-2.13.2.md 1970-01-01 
01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/docs/release/skillspector-2.13.2.md 2020-02-02 
01:00:00.000000000 +0100
@@ -0,0 +1,38 @@
+# SkillSpector 2.13.2
+
+This patch release builds on the published v2.13.1 maintenance release and
+backports three fixes for false incomplete-analysis reports. It preserves the
+existing maintenance fixes, third-party dependency lock, and provider defaults.
+
+## Fixes
+
+- [#779](https://github.com/NVIDIA/SkillSpector/pull/779): Recognize command 
wrappers
+  with no wrapped command at a proven clause boundary. Markdown table cells and
+  ordinary source references to `timeout`, `sudo`, `nice`, and `xargs` no 
longer
+  cause false parser-limit reports. Redirections, unfinished fragments, and
+  commands in subsequent clauses retain their existing checks.
+- [#917](https://github.com/NVIDIA/SkillSpector/pull/917): Treat `sudo -n` as 
a flag
+  and parse descriptor redirections such as `2>&1` as complete operators. Align
+  the MCP TP4 batch limit with its existing 128-file limit so supported inputs
+  are not prematurely truncated after 64 batches. Byte, token, declaration,
+  file, runtime, and provider-error limits remain enforced.
+- [#921](https://github.com/NVIDIA/SkillSpector/pull/921): Recognize direct 
template
+  messages on verified built-in Node assertion imports, avoiding false shell
+  substitution limits in JavaScript helpers. Classify `.mjs` and `.cjs` files
+  consistently, including archive members. Raw security findings and their
+  source locations are preserved. Ambiguous syntax, assertion aliases or
+  mutations, effectful interpolation, and exceeded bounds remain conservative;
+  TypeScript retains its existing parser path.
+
+## Scope and upgrading
+
+The JavaScript correction is a bounded assertion-message proof, not a general
+JavaScript parser or dataflow analysis. It assumes ordinary built-in assertion
+semantics and does not prove the absence of arbitrary runtime mutation or
+object getters. Existing findings and independent inspection limitations remain
+visible; these fixes do not suppress malformed provider responses.
+
+Regenerate and review exact version-bound finding baselines for 2.13.2 rather
+than relabeling baselines from another scanner version. The GitHub release
+provides a wheel and source distribution; the publication workflow does not
+publish to PyPI.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/skillspector-2.13.1/pyproject.toml 
new/skillspector-2.13.2/pyproject.toml
--- old/skillspector-2.13.1/pyproject.toml      2020-02-02 01:00:00.000000000 
+0100
+++ new/skillspector-2.13.2/pyproject.toml      2020-02-02 01:00:00.000000000 
+0100
@@ -4,7 +4,7 @@
 
 [project]
 name = "skillspector"
-version = "2.13.1"
+version = "2.13.2"
 description = "SkillSpector: Security scanner for AI agent skills (Claude 
Code, Cursor, and similar). Scans skills for vulnerabilities, malicious 
patterns, and security risks before installation. Supports Git repos, URLs, 
zips, and local directories; runs static pattern checks and optional LLM 
semantic analysis; outputs terminal, JSON, and Markdown reports with risk 
scoring."
 readme = "README.md"
 license = "Apache-2.0"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/src/skillspector/javascript_tokens.py 
new/skillspector-2.13.2/src/skillspector/javascript_tokens.py
--- old/skillspector-2.13.1/src/skillspector/javascript_tokens.py       
1970-01-01 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/src/skillspector/javascript_tokens.py       
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,381 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. 
All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Bounded lexical ownership for Node assertion template-message delimiters.
+
+This is not a JavaScript parser or a shell-sink/dataflow model. Only a direct
+diagnostic argument of one unaliased built-in assertion import may borrow
+ownership. Unsupported source keeps its original conservative shell reading.
+"""
+
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from dataclasses import dataclass
+
+_MAX_SOURCE_CHARS = 1_000_000
+_MAX_TOKENS = 100_000
+_MAX_NESTING = 128
+_LINE_ENDS = "\r\n\u2028\u2029"
+_IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
+_TEMPLATE_VALUE = re.compile(
+    r"\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*)*\s*\Z"
+)
+_NODE_SHEBANG = re.compile(r"#![ \t]*(?:\S*/)?(?:env[ \t]+)?node(?:js)?(?=[ 
\t\r\n]|$)")
+_ASSERT_MODULES = frozenset({"node:assert", "node:assert/strict"})
+_ASSERT_MODULE_ALIASES = _ASSERT_MODULES | {"assert", "assert/strict"}
+_MESSAGE_ARGUMENTS = {
+    "ok": 2,
+    "equal": 3,
+    "notEqual": 3,
+    "strictEqual": 3,
+    "notStrictEqual": 3,
+    "deepEqual": 3,
+    "notDeepEqual": 3,
+    "deepStrictEqual": 3,
+    "notDeepStrictEqual": 3,
+    "match": 3,
+    "doesNotMatch": 3,
+    "throws": 3,
+    "doesNotThrow": 3,
+}
+_EXPRESSION_PREFIX_WORDS = frozenset(
+    {
+        "return",
+        "throw",
+        "case",
+        "delete",
+        "void",
+        "typeof",
+        "new",
+        "yield",
+        "await",
+        "in",
+        "instanceof",
+        "else",
+        "do",
+    }
+)
+_OPERATORS = frozenset(
+    {
+        "===",
+        "!==",
+        ">>>",
+        "**=",
+        "&&=",
+        "||=",
+        "??=",
+        "...",
+        "=>",
+        "==",
+        "!=",
+        "<=",
+        ">=",
+        "&&",
+        "||",
+        "??",
+        "++",
+        "--",
+        "**",
+        "+=",
+        "-=",
+        "*=",
+        "%=",
+        "&=",
+        "|=",
+        "^=",
+        "<<",
+        ">>",
+        "?.",
+    }
+)
+
+
+@dataclass(frozen=True, slots=True)
+class _Token:
+    kind: str
+    start: int
+    end: int
+    depth: int
+    interpolation_names: tuple[str, ...] = ()
+
+
+def _tokens(
+    content: str, check_runtime: Callable[[], None]
+) -> tuple[list[_Token], dict[int, int]] | None:
+    """Tokenize the limited lexical forms needed to prove message 
boundaries."""
+    tokens: list[_Token] = []
+    pairs: dict[int, int] = {}
+    stack: list[tuple[str, int]] = []
+    cursor = 0
+    expression_start = True
+    next_check = 0
+
+    def check(position: int) -> None:
+        nonlocal next_check
+        if position >= next_check:
+            check_runtime()
+            next_check = position + 4096
+
+    if content.startswith("#!"):
+        if _NODE_SHEBANG.match(content) is None:
+            return None
+        cursor = content.find("\n")
+        if cursor < 0:
+            return None
+    while cursor < len(content):
+        check(cursor)
+        character = content[cursor]
+        if character.isspace():
+            cursor += 1
+            continue
+        if character == "\x00" or len(tokens) >= _MAX_TOKENS:
+            return None
+        if content.startswith("//", cursor):
+            while cursor < len(content) and content[cursor] not in _LINE_ENDS:
+                check(cursor)
+                cursor += 1
+            continue
+        if content.startswith("/*", cursor):
+            cursor += 2
+            while cursor < len(content) and not content.startswith("*/", 
cursor):
+                check(cursor)
+                cursor += 1
+            if cursor == len(content):
+                return None
+            cursor += 2
+            continue
+        start = cursor
+        kind = "punctuation"
+        interpolation_names: list[str] = []
+        if character in "'\"`":
+            kind = "template" if character == "`" else "string"
+            cursor += 1
+            while cursor < len(content):
+                check(cursor)
+                current = content[cursor]
+                if current == "\\":
+                    cursor += 2
+                    if cursor <= len(content) and content[cursor - 1 : cursor 
+ 1] == "\r\n":
+                        cursor += 1
+                    continue
+                if current == character:
+                    cursor += 1
+                    break
+                if kind == "string" and current in _LINE_ENDS:
+                    return None
+                if kind == "template" and content.startswith("${", cursor):
+                    value_start = cursor + 2
+                    cursor = value_start
+                    while cursor < len(content) and content[cursor] != "}":
+                        check(cursor)
+                        # Calls, computed members, comments, nested templates,
+                        # and other effectful expressions never gain ownership.
+                        if not content[cursor].isascii():
+                            return None
+                        cursor += 1
+                    value = content[value_start:cursor]
+                    if cursor == len(content) or 
_TEMPLATE_VALUE.fullmatch(value) is None:
+                        return None
+                    interpolation_names.extend(_IDENTIFIER.findall(value))
+                    cursor += 1
+                    continue
+                cursor += 1
+            else:
+                return None
+            expression_start = False
+        elif character == "/":
+            # Division versus a statement-level regexp needs more grammar.
+            # Only a clear expression prefix establishes regexp ownership.
+            if not expression_start:
+                return None
+            kind = "regexp"
+            cursor += 1
+            in_class = False
+            while cursor < len(content):
+                check(cursor)
+                current = content[cursor]
+                if current in _LINE_ENDS:
+                    return None
+                if current == "\\":
+                    if cursor + 1 == len(content) or content[cursor + 1] in 
_LINE_ENDS:
+                        return None
+                    cursor += 2
+                    continue
+                if current == "[":
+                    if in_class:
+                        # Unicode-set nested classes require a different 
grammar.
+                        return None
+                    in_class = True
+                elif current == "]":
+                    in_class = False
+                elif current == "/" and not in_class:
+                    cursor += 1
+                    break
+                cursor += 1
+            else:
+                return None
+            flags_start = cursor
+            while cursor < len(content) and content[cursor].isascii() and 
content[cursor].isalpha():
+                check(cursor)
+                cursor += 1
+            flags = content[flags_start:cursor]
+            if len(set(flags)) != len(flags) or set(flags) - set("dgimsuy"):
+                return None
+            expression_start = False
+        elif (identifier := _IDENTIFIER.match(content, cursor)) is not None:
+            kind = "identifier"
+            cursor = identifier.end()
+            previous = content[tokens[-1].start : tokens[-1].end] if tokens 
else ""
+            expression_start = identifier[0] in _EXPRESSION_PREFIX_WORDS and 
previous not in {
+                ".",
+                "?.",
+            }
+        elif character.isascii() and character.isdigit():
+            kind = "number"
+            cursor += 1
+            while cursor < len(content) and content[cursor] in 
"0123456789abcdefABCDEFxXoObBn._":
+                check(cursor)
+                cursor += 1
+            expression_start = False
+        elif character in "()[]{};,:.?=<>!~+-*%&|^":
+            width = 1
+            if content[cursor : cursor + 3] in _OPERATORS:
+                width = 3
+            elif content[cursor : cursor + 2] in _OPERATORS:
+                width = 2
+            cursor += width
+            operator = content[start:cursor]
+            if operator == "!" and not expression_start:
+                # TypeScript's postfix assertion leaves division possible.
+                return None
+            if operator in "([{":
+                if len(stack) >= _MAX_NESTING:
+                    return None
+                stack.append((operator, len(tokens)))
+            elif operator in ")]}":
+                if not stack or stack[-1][0] != {"}": "{", ")": "(", "]": 
"["}[operator]:
+                    return None
+                _, opening = stack.pop()
+                pairs[opening] = len(tokens)
+            expression_start = operator not in {")", "]", "}", ".", "?.", 
"++", "--"}
+        else:
+            # Escaped/Unicode identifiers, JSX, and unknown lexical forms are
+            # not proof of a Node assertion receiver or template boundary.
+            return None
+        tokens.append(_Token(kind, start, cursor, len(stack), 
tuple(interpolation_names)))
+    check_runtime()
+    return None if stack else (tokens, pairs)
+
+
+def assertion_template_shell_text(content: str, check_runtime: Callable[[], 
None]) -> str:
+    """Project only proven Node assertion message delimiters for shell 
completeness.
+
+    Literal bodies and interpolations remain byte-for-byte present. Findings
+    must still be produced from the original source. Unknown calls, tagged or
+    concatenated templates, ambiguous imports, and receiver mutation/shadowing
+    receive no exemption. Runtime interruption propagates to the caller.
+
+    This assumes the standard built-in module. It cannot prove the absence of
+    arbitrary runtime monkeypatching or object getters.
+    """
+    if len(content) > _MAX_SOURCE_CHARS or "node:assert" not in content or "`" 
not in content:
+        return content
+    parsed = _tokens(content, check_runtime)
+    if parsed is None:
+        return content
+    tokens, pairs = parsed
+    values = [content[token.start : token.end] for token in tokens]
+    imports: list[tuple[str, int, int]] = []
+    for index, token in enumerate(tokens):
+        if index % 256 == 0:
+            check_runtime()
+        if token.kind == "identifier" and (
+            values[index] == "require"
+            or values[index] == "import"
+            and values[index + 1 : index + 2] == ["("]
+        ):
+            return content
+        if token.kind == "string" and index and values[index - 1] in 
{"import", "from"}:
+            # Escaped module names can alias the same mutable built-in object.
+            if "\\" in values[index]:
+                return content
+        if values[index] != "import" or token.depth:
+            continue
+        following = values[index : index + 7]
+        if len(following) >= 5 and following[2] == "from" and following[4] == 
";":
+            binding_index, module_index = index + 1, index + 3
+        elif (
+            len(following) == 7
+            and following[1:3] == ["*", "as"]
+            and following[4] == "from"
+            and following[6] == ";"
+        ):
+            binding_index, module_index = index + 3, index + 5
+        else:
+            continue
+        if (
+            tokens[binding_index].kind == "identifier"
+            and tokens[module_index].kind == "string"
+            and values[module_index][1:-1] in _ASSERT_MODULES
+        ):
+            imports.append((values[binding_index], binding_index, 
module_index))
+    if len(imports) != 1:
+        return content
+    binding, binding_index, module_index = imports[0]
+    if any(
+        token.kind == "string"
+        and index != module_index
+        and values[index][1:-1] in _ASSERT_MODULE_ALIASES
+        or binding in token.interpolation_names
+        for index, token in enumerate(tokens)
+    ):
+        return content
+    delimiters: list[int] = []
+    for index, token in enumerate(tokens):
+        if index % 256 == 0:
+            check_runtime()
+        if token.kind != "identifier" or values[index] != binding or index == 
binding_index:
+            continue
+        if (index and values[index - 1] in {".", "?.", "new"}) or values[index 
+ 1 : index + 2] != [
+            "."
+        ]:
+            return content
+        if (
+            index + 3 >= len(tokens)
+            or values[index + 2] not in _MESSAGE_ARGUMENTS
+            or values[index + 3] != "("
+        ):
+            return content
+        opening = index + 3
+        closing = pairs.get(opening)
+        if closing is None:
+            return content
+        arguments: list[tuple[int, int]] = []
+        begin = cursor = opening + 1
+        while cursor < closing:
+            check_runtime()
+            if cursor in pairs:
+                cursor = pairs[cursor] + 1
+                continue
+            if values[cursor] == ",":
+                arguments.append((begin, cursor))
+                begin = cursor + 1
+            cursor += 1
+        if begin < closing:
+            arguments.append((begin, closing))
+        if len(arguments) != _MESSAGE_ARGUMENTS[values[index + 2]] or any(
+            start == end or values[start] == "..." for start, end in arguments
+        ):
+            continue
+        start, end = arguments[-1]
+        if end == start + 1 and tokens[start].kind == "template":
+            delimiters.extend((tokens[start].start, tokens[start].end - 1))
+    if not delimiters:
+        return content
+    output = list(content)
+    for position in delimiters:
+        output[position] = " "
+    check_runtime()
+    return "".join(output)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/src/skillspector/nested_artifacts.py 
new/skillspector-2.13.2/src/skillspector/nested_artifacts.py
--- old/skillspector-2.13.1/src/skillspector/nested_artifacts.py        
2020-02-02 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/src/skillspector/nested_artifacts.py        
2020-02-02 01:00:00.000000000 +0100
@@ -770,6 +770,8 @@
         ".toml": "toml",
         ".txt": "text",
         ".js": "javascript",
+        ".mjs": "javascript",
+        ".cjs": "javascript",
         ".ts": "typescript",
         ".rb": "ruby",
         ".go": "go",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py 
new/skillspector-2.13.2/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py
--- 
old/skillspector-2.13.1/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py  
    2020-02-02 01:00:00.000000000 +0100
+++ 
new/skillspector-2.13.2/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py  
    2020-02-02 01:00:00.000000000 +0100
@@ -78,7 +78,7 @@
 TP4_MAX_TOTAL_CODE_BYTES = 4 * 1024 * 1024
 TP4_MAX_TOTAL_INPUT_BYTES = 4 * 1024 * 1024
 TP4_MAX_FILE_CODE_BYTES = 1024 * 1024
-TP4_MAX_BATCHES = 64
+TP4_MAX_BATCHES = TP4_MAX_FILES
 TP4_MAX_BATCH_INPUT_TOKENS = 32_000
 TP4_MIN_CODE_TOKENS = 64
 TP4_MAX_DECLARATION_CHARS = 16_384
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py
 
new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py
--- 
old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py
     2020-02-02 01:00:00.000000000 +0100
+++ 
new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py
     2020-02-02 01:00:00.000000000 +0100
@@ -41,6 +41,7 @@
     normalized_security_view,
     security_text_views,
 )
+from skillspector.javascript_tokens import assertion_template_shell_text
 from skillspector.logging_config import get_logger
 from skillspector.models import AnalyzerFinding, Finding, Location, Severity
 from skillspector.python_ast import ParsedPythonFile, parse_python_source
@@ -164,7 +165,7 @@
 _SHELL_ASSIGNMENT_PREFIX_WORDS = _SHELL_CLAUSE_PREFIX_WORDS | frozenset(
     {"if", "while", "until", "export", "local", "declare", "readonly", 
"typeset"}
 )
-_SHELL_REDIRECTION_PREFIX_RE = 
re.compile(r"(?:[0-9]+)?(?:&>>?|<>|>>?|<<-?|>&|<&|[<>])")
+_SHELL_REDIRECTION_PREFIX_RE = 
re.compile(r"(?:[0-9]+)?(?:&>>?|<>|>&|<&|>>?|<<-?|[<>])")
 _RECURSIVE_OPTION_SOURCE_RE = re.compile(r"-(?:[A-Za-z]*[rR]|-recursive)")
 _FORCE_OPTION_SOURCE_RE = re.compile(r"-(?:[A-Za-z]*f|-force)")
 _MARKDOWN_CONTRACTION_PROSE_RE = re.compile(
@@ -2415,8 +2416,10 @@
             wrapper_seen = True
             while True:
                 option, limited = next_word()
-                if limited or option is None:
+                if limited:
                     return True, None
+                if option is None:
+                    return not _ends_wrapped_clause(content, cursor), None
                 if option == "--":
                     pending, limited = next_word()
                     if limited:
@@ -2425,6 +2428,9 @@
                 if not option.startswith("-"):
                     pending = option
                     break
+                # Unlike nice/xargs, sudo's -n is a flag, not an operand 
option.
+                if command == "sudo" and option == "-n":
+                    continue
                 if option in {
                     "-u",
                     "-g",
@@ -2448,8 +2454,10 @@
             wrapper_seen = True
             while True:
                 option, limited = next_word()
-                if limited or option is None:
+                if limited:
                     return True, None
+                if option is None:
+                    return not _ends_wrapped_clause(content, cursor), None
                 if option in {"-k", "--kill-after", "-s", "--signal"}:
                     _, limited = next_word()
                     if limited:
@@ -2466,6 +2474,24 @@
     return wrapper_seen, None
 
 
+def _ends_wrapped_clause(content: str, cursor: int) -> bool:
+    """Return whether a wrapper's missing command is a proven end of its 
clause.
+
+    When a control operator follows ``sudo``, ``nice``, ``xargs`` or
+    ``timeout`` directly, the clause names no wrapped command, so there is no
+    command string to reconstruct. A shell such a wrapper starts on its own
+    (``sudo -s``) reads its input like ``sh`` in a pipeline, which this check
+    does not model either. Commands after the operator start their own
+    clause and are checked separately. A redirection may still precede the
+    wrapped command, and a fragment may continue past its end, so both
+    remain unresolved.
+    """
+    if cursor >= len(content):
+        return False
+    character = content[cursor]
+    return character in ";|)" or (character == "&" and content[cursor + 1 : 
cursor + 2] != ">")
+
+
 def _command_wrapper_quote(content: str, command_start: int) -> str | None:
     if command_start == 0 or content[command_start - 1] not in "'\"`":
         return None
@@ -5247,10 +5273,18 @@
             complete_context=complete_context,
             word_apostrophes=word_apostrophes,
         )
+    # Diagnostic template delimiters belong to JavaScript only when the whole
+    # source proves their assertion-message owner. Destructive checks stay raw.
+    # TypeScript's postfix/type expressions need a separate lexical proof.
+    shell_word_content = (
+        assertion_template_shell_text(content, check_runtime)
+        if file_type == "javascript" and complete_context
+        else content
+    )
     # Perl ``eval BLOCK`` is recognized from its clause-initial ``eval`` and 
the
     # adjacent brace alone, so unlike quote ownership it needs no full context.
     if _has_shell_command_word_exhaustion(
-        content,
+        shell_word_content,
         check_runtime,
         structural_quote_closers=structural_quote_closers,
         structural_quote_openers=structural_quote_openers,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_runner.py 
new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_runner.py
--- old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_runner.py   
2020-02-02 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_runner.py   
2020-02-02 01:00:00.000000000 +0100
@@ -110,6 +110,8 @@
     ".toml": "toml",
     ".txt": "text",
     ".js": "javascript",
+    ".mjs": "javascript",
+    ".cjs": "javascript",
     ".ts": "typescript",
     ".rb": "ruby",
     ".pl": "perl",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/src/skillspector/nodes/build_context.py 
new/skillspector-2.13.2/src/skillspector/nodes/build_context.py
--- old/skillspector-2.13.1/src/skillspector/nodes/build_context.py     
2020-02-02 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/src/skillspector/nodes/build_context.py     
2020-02-02 01:00:00.000000000 +0100
@@ -157,6 +157,8 @@
     ".toml": "toml",
     ".txt": "text",
     ".js": "javascript",
+    ".mjs": "javascript",
+    ".cjs": "javascript",
     ".ts": "typescript",
     ".rb": "ruby",
     ".go": "go",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/tests/nodes/analyzers/test_command_wrapper_without_command.py
 
new/skillspector-2.13.2/tests/nodes/analyzers/test_command_wrapper_without_command.py
--- 
old/skillspector-2.13.1/tests/nodes/analyzers/test_command_wrapper_without_command.py
       1970-01-01 01:00:00.000000000 +0100
+++ 
new/skillspector-2.13.2/tests/nodes/analyzers/test_command_wrapper_without_command.py
       2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,228 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. 
All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""A command wrapper followed directly by a control operator runs no command 
(#694)."""
+
+from __future__ import annotations
+
+import pytest
+
+from skillspector.inspection_ledger import LedgerOutcome, LedgerReason
+from skillspector.nodes.analyzers import static_patterns_tool_misuse as 
tm_module
+from skillspector.nodes.analyzers import static_runner
+
+_PADDING = "\n# ordinary padding\n" * 400
+
+
+def _exhausted(content: str, file_type: str, *, complete_context: bool = True) 
-> bool:
+    return tm_module.has_bounded_parse_exhaustion(
+        content, lambda: None, file_type=file_type, 
complete_context=complete_context
+    )
+
+
[email protected]("wrapper", ["timeout", "sudo", "nice", "xargs"])
+def test_markdown_table_cell_naming_a_wrapper_is_complete(wrapper: str) -> 
None:
+    content = f"| Name | Description |\n|---|---|\n| {wrapper} | Max wait in 
seconds |\n"
+
+    assert not _exhausted(content, "markdown")
+    assert not _exhausted(content + _PADDING, "markdown")
+
+
+def test_markdown_api_parameter_table_is_complete() -> None:
+    content = (
+        "| Name | Type | Default | Description |\n"
+        "|---|---|---|---|\n"
+        "| timeout | float | 150 | Seconds to wait |\n"
+        "| retries | int | 3 | Attempts |\n"
+    )
+
+    assert not _exhausted(content, "markdown")
+
+
[email protected](
+    ("content", "file_type"),
+    [
+        ("sudo | cat\n", "shell"),
+        ("timeout; echo ok\n", "shell"),
+        ("xargs || true\n", "shell"),
+        ("nice & wait\n", "shell"),
+        ("(nice)\n", "shell"),
+        ("sudo -u | cat\n", "shell"),
+        ("sudo |& cat\n", "shell"),
+        ("timeout && echo ok\n", "shell"),
+        ("case $a in x) nice ;; esac\n", "shell"),
+        ("signal.alarm(timeout)\nvalue = 1\n", "python"),
+        ("if (timeout) { start(); }\n", "javascript"),
+    ],
+    ids=[
+        "pipe",
+        "semicolon",
+        "or-list",
+        "background",
+        "subshell",
+        "option-without-value",
+        "pipe-with-stderr",
+        "and-list",
+        "case-item-end",
+        "python-call",
+        "javascript-condition",
+    ],
+)
+def test_wrapper_before_a_clause_end_is_complete(content: str, file_type: str) 
-> None:
+    assert not _exhausted(content + _PADDING, file_type)
+
+
[email protected](
+    "content",
+    [
+        "sudo >log $CMD -rf /\n",
+        "sudo <in $CMD -rf /\n",
+        "sudo &>log $CMD -rf /\n",
+        "timeout ($CMD) -rf /\n",
+        'x | sudo sh -c "$CMD"\n',
+        'sudo |& sh -c "$CMD"\n',
+        'sudo && eval "$CMD"\n',
+        "case $a in sudo) $CMD -rf / ;; esac\n",
+        'sudo -s; eval "$CMD"\n',
+    ],
+    ids=[
+        "stdout-redirection",
+        "stdin-redirection",
+        "bash-and-redirection",
+        "parenthesis",
+        "command-string",
+        "command-string-after-pipe",
+        "eval-after-and-list",
+        "runtime-command-in-case-item",
+        "eval-after-shell-option",
+    ],
+)
+def test_wrapper_with_an_unresolved_command_stays_partial(content: str) -> 
None:
+    assert _exhausted(content, "shell")
+
+
+def test_wrapper_at_the_end_of_a_fragment_stays_partial() -> None:
+    assert _exhausted("x | sudo", "shell")
+    assert _exhausted("x | timeout", "shell", complete_context=False)
+
+
+def test_markdown_cell_with_a_real_command_keeps_its_finding() -> None:
+    literal = "| Step | Command |\n|---|---|\n| wipe | `sudo rm -rf /` |\n"
+    runtime = "| Step | Command |\n|---|---|\n| wipe | `sudo $CMD -rf /` |\n"
+
+    findings = tm_module.analyze(literal, "SKILL.md", "markdown")
+
+    assert any(finding.rule_id == "TM1" for finding in findings)
+    assert _exhausted(runtime, "markdown")
+
+
+def test_scan_level_parameter_table_is_fully_inspected() -> None:
+    content = "# Tool\n\n| Name | Type |\n|---|---|\n| timeout | float |\n" + 
"\nText.\n" * 400
+
+    result = static_runner.run_static_patterns_with_ledger(
+        {"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}}, 
[tm_module]
+    )
+
+    event = result["inspection_ledger"][0]
+    assert event["outcome"] is LedgerOutcome.COMPLETED
+
+
+def test_scan_level_redirected_wrapper_stays_partial() -> None:
+    result = static_runner.run_static_patterns_with_ledger(
+        {"components": ["SKILL.md"], "file_cache": {"SKILL.md": "sudo >log 
$CMD -rf /\n"}},
+        [tm_module],
+    )
+
+    event = result["inspection_ledger"][0]
+    assert event["outcome"] is LedgerOutcome.PARTIAL
+    assert event["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT
+
+
[email protected]("wrapper", ["timeout", "sudo", "nice", "xargs"])
[email protected]("quote", ["'", '"', '"""'])
[email protected]("newline", ["\n", "\r\n"])
+def test_python_parenthesized_wrapper_name_is_complete(
+    wrapper: str, quote: str, newline: str
+) -> None:
+    content = (
+        '"""Describe a fixture beside its manifest."""\n'
+        f"WRAPPER_NAME = (\n    {quote}{wrapper}{quote}\n)\n"
+        f"def read_option(options):\n    return 
options.get({quote}{wrapper}{quote})\n"
+    ).replace("\n", newline)
+
+    assert not _exhausted(content, "python")
+    result = static_runner.run_static_patterns_with_ledger(
+        {"components": ["scripts/validate.py"], "file_cache": 
{"scripts/validate.py": content}},
+        [tm_module],
+    )
+    assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED
+
+
[email protected](
+    "source",
+    [
+        'sudo -n fuser "$device" >/dev/null 2>&1 && continue\n',
+        "sudo -n true >/dev/null 2>&1\n",
+        "sudo -n true 2>&1 >/dev/null\n",
+        "sudo -n true 0<&3 3>&-\n",
+        "sudo -n true 3>&1-\n",
+        "sudo -n true &>/dev/null\n",
+        "sudo -n true &>>/dev/null\n",
+        "sudo -n -- true 2>&1\n",
+        "sudo -n -u root true 2>&1\n",
+        "sudo -u root -n true 2>&1\n",
+        "sudo --non-interactive true 2>&1\n",
+        "sudo -nS true 2>&1\n",
+        "nice -n 5 true 2>&1\n",
+        "xargs -n 1 true 2>&1\n",
+        "sudo -n sh 2>&1 -c 'echo ready'\n",
+        "eval 2>&1 'echo ready'\n",
+    ],
+)
+def test_noninteractive_sudo_and_descriptor_redirections_are_complete(source: 
str) -> None:
+    assert not _exhausted(source, "shell")
+
+
[email protected](
+    "source",
+    [
+        'sudo -n sh -c "$CMD"\n',
+        'sudo -n sh 2>&1 -c "$CMD"\n',
+        'sudo -n sh >log -c "$CMD"\n',
+        'sudo -n -- sh -c "$CMD"\n',
+        'sudo -n -u root sh -c "$CMD"\n',
+        'nice -n 5 sh -c "$CMD"\n',
+        'xargs -n 1 sh -c "$CMD"\n',
+        "sudo -n sh -c '$CMD -rf /'\n",
+        'eval 2>&1 "$CMD"\n',
+        "sudo -n $CMD -rf /\n",
+        "sudo -n >log $CMD -rf /\n",
+        "sudo -n &>log $CMD -rf /\n",
+        "sudo -n true 2>&\n",
+        "sudo -n true 0<&\n",
+        "sudo -n true 2>\n",
+        "sudo -n true >\n",
+        "sudo -n",
+        "nice -n",
+        "xargs -n",
+        "timeout",
+        'sudo -n sh -c "unterminated',
+    ],
+)
+def test_wrapper_repairs_preserve_unresolved_command_limits(source: str) -> 
None:
+    assert _exhausted(source, "shell")
+
+
+def test_noninteractive_sudo_keeps_destructive_finding() -> None:
+    source = "sudo -n rm -rf / 2>&1\n"
+
+    findings = tm_module.analyze(source, "scripts/run.sh", "shell")
+
+    assert any(finding.rule_id == "TM1" for finding in findings)
+
+
[email protected]("wrapper", ["sudo -n", "nice -n 1", "xargs -n 1"])
+def test_wrapper_chain_budget_stays_partial(wrapper: str) -> None:
+    source = " ".join([wrapper] * 40) + " sh -c 'echo ready'\n"
+
+    assert _exhausted(source, "shell")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/tests/nodes/analyzers/test_host_language_shell_ownership.py
 
new/skillspector-2.13.2/tests/nodes/analyzers/test_host_language_shell_ownership.py
--- 
old/skillspector-2.13.1/tests/nodes/analyzers/test_host_language_shell_ownership.py
 2020-02-02 01:00:00.000000000 +0100
+++ 
new/skillspector-2.13.2/tests/nodes/analyzers/test_host_language_shell_ownership.py
 2020-02-02 01:00:00.000000000 +0100
@@ -1,12 +1,13 @@
 # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. 
All rights reserved.
 # SPDX-License-Identifier: Apache-2.0
 
-"""Valid Python and Perl syntax must not exhaust the bounded shell parser.
+"""Proven host-language syntax must not exhaust the bounded shell parser.
 
 Python string and comment tokens own their bytes, and Perl ``eval BLOCK`` is
 exception handling rather than a string reparse. Neither may turn ordinary
 host source into a ``static_parse_limit`` result, while genuinely unresolved
-shell text keeps its conservative partial outcome.
+shell text keeps its conservative partial outcome. Verified Node assertion
+messages may own template delimiters without hiding executable string contents.
 """
 
 from __future__ import annotations
@@ -451,6 +452,423 @@
     assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED
 
 
+# --- Node assertion diagnostic templates ----------------------------------
+
+
+_JS_ASSERT_IMPORT = 'import check from "node:assert/strict";\n'
+_JS_ASSERT_CALL = 'check.equal(typeof row.name, "string", `${row.key} must be 
present`);\n'
+
+
+def _javascript(content: str, *, complete_context: bool = True) -> bool:
+    return tm.has_bounded_parse_exhaustion(
+        content, lambda: None, file_type="javascript", 
complete_context=complete_context
+    )
+
+
[email protected](
+    "declaration",
+    [
+        'import check from "node:assert";',
+        'import check from "node:assert/strict";',
+        "import * as check from 'node:assert';",
+        "import * as check from 'node:assert/strict';",
+    ],
+)
+def test_imported_node_assert_message_template_is_complete(declaration: str) 
-> None:
+    source = declaration + "\n" + _JS_ASSERT_CALL
+
+    assert _javascript(source) is False
+    assert _shell(source) is True
+
+
[email protected](
+    "method,arguments",
+    [
+        ("ok", "valid"),
+        ("equal", "actual, expected"),
+        ("notEqual", "actual, expected"),
+        ("strictEqual", "actual, expected"),
+        ("notStrictEqual", "actual, expected"),
+        ("deepEqual", "actual, expected"),
+        ("notDeepEqual", "actual, expected"),
+        ("deepStrictEqual", "actual, expected"),
+        ("notDeepStrictEqual", "actual, expected"),
+        ("match", "actual, /value/"),
+        ("doesNotMatch", "actual, /value/"),
+        ("throws", "() => work(), undefined"),
+        ("doesNotThrow", "() => work(), undefined"),
+    ],
+)
+def test_node_assert_owns_only_its_message_argument(method: str, arguments: 
str) -> None:
+    source = _JS_ASSERT_IMPORT + f"check.{method}({arguments}, `${{row.key}} 
needs a value`);\n"
+
+    assert _javascript(source) is False
+
+
[email protected]("suffix", [".js", ".mjs", ".cjs", ".MJS"])
+def test_node_assert_message_ownership_reaches_static_ledger(suffix: str) -> 
None:
+    result = _ledger("scripts/check" + suffix, _JS_ASSERT_IMPORT + 
_JS_ASSERT_CALL)
+
+    assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED
+    assert not any(finding.rule_id == "TM1" for finding in result["findings"])
+
+
[email protected]("newline", ["\n", "\r\n"], ids=["lf", "crlf"])
+def test_node_assert_template_offsets_preserve_dangerous_neighbor(newline: 
str) -> None:
+    source = (
+        _JS_ASSERT_IMPORT
+        + "check.ok(ready, `café ${row.key} is ready`);\n"
+        + 'execSync("rm -rf /");\n'
+    ).replace("\n", newline)
+
+    result = _ledger("scripts/check.mjs", source)
+
+    assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED
+    assert any(
+        finding.rule_id == "TM1" and finding.start_line == 3 for finding in 
result["findings"]
+    )
+
+
[email protected](
+    "source",
+    [
+        pytest.param(_JS_ASSERT_CALL, id="unbound-name"),
+        pytest.param(
+            'import check from "custom-assert";\n' + _JS_ASSERT_CALL, 
id="non-node-import"
+        ),
+        pytest.param(
+            'import { strict as check } from "node:assert";\n' + 
_JS_ASSERT_CALL,
+            id="unsupported-named-import",
+        ),
+        pytest.param(_JS_ASSERT_IMPORT + "check = run;\n" + _JS_ASSERT_CALL, 
id="rebound"),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "function verify(check) {\n" + _JS_ASSERT_CALL 
+ "}\n",
+            id="shadowed-parameter",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const alias = check;\n" + _JS_ASSERT_CALL, 
id="escaped-binding"
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const run = check.equal;\n" + _JS_ASSERT_CALL,
+            id="aliased-method",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const message = `${check}`;\n" + 
_JS_ASSERT_CALL,
+            id="binding-interpolated-elsewhere",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const message = `${check.equal}`;\n" + 
_JS_ASSERT_CALL,
+            id="method-interpolated-elsewhere",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal = run;\n" + _JS_ASSERT_CALL,
+            id="method-replacement",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + 'check["equal"] = run;\n' + _JS_ASSERT_CALL,
+            id="computed-replacement",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "Object.assign(check, { equal: run });\n" + 
_JS_ASSERT_CALL,
+            id="object-mutation",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT
+            + 'import alternate from "node:assert";\nalternate.equal = run;\n'
+            + _JS_ASSERT_CALL,
+            id="second-import-alias",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + 'require("node:assert/strict").equal = run;\n' 
+ _JS_ASSERT_CALL,
+            id="require-alias",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT
+            + r'import alternate from "node:\u0061ssert";'
+            + "\nalternate.equal = run;\n"
+            + _JS_ASSERT_CALL,
+            id="escaped-module-import",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT
+            + r'require("node:\u0061ssert").equal = run;'
+            + "\n"
+            + _JS_ASSERT_CALL,
+            id="escaped-module-require",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT
+            + "const of = 1; const n = of / (check.equal = run) / divisor;\n"
+            + _JS_ASSERT_CALL,
+            id="division-after-contextual-keyword",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT
+            + "const n = holder.return / (check.equal = run) / divisor;\n"
+            + _JS_ASSERT_CALL,
+            id="division-after-keyword-property",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + 'check["equal"](a, b, `${row.key} is 
required`);\n',
+            id="computed-call",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check?.equal(a, b, `${row.key} is 
required`);\n",
+            id="optional-call",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check(a, `${row.key} is required`);\n",
+            id="unsupported-callable-assert",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal(`${row.key}`, b);\n",
+            id="template-is-actual-value",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal(a, b, `${row.key}`, extra);\n",
+            id="extra-argument",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal(...values, `${row.key}`);\n",
+            id="spread-argument",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal(a, b, tag`${row.key}`);\n",
+            id="tagged-message",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal(a, b, (`${row.key}`));\n",
+            id="parenthesized-message",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + 'check.equal(a, b, `${row.key}` + " more");\n',
+            id="concatenated-message",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "check.equal(a, b, message = `${row.key}`);\n",
+            id="assigned-message",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const message = `${row.key}`;\n",
+            id="unrelated-assignment",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "function message() { return `${row.key}`; 
}\n",
+            id="unrelated-return",
+        ),
+        pytest.param("#!/bin/sh\n" + _JS_ASSERT_IMPORT + _JS_ASSERT_CALL, 
id="shell-shebang"),
+        pytest.param(_JS_ASSERT_IMPORT + "check.equal(a, b, `${row.key}\n", 
id="unclosed-template"),
+        pytest.param(_JS_ASSERT_IMPORT + "check.equal(a, b, `${row.key`);\n", 
id="unclosed-field"),
+        pytest.param(_JS_ASSERT_IMPORT + _JS_ASSERT_CALL + "/* unfinished", 
id="unclosed-comment"),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const pattern = /unterminated\n" + 
_JS_ASSERT_CALL,
+            id="unclosed-regexp",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const pattern = /value/gg;\n" + 
_JS_ASSERT_CALL,
+            id="invalid-regexp-flags",
+        ),
+        pytest.param(
+            _JS_ASSERT_IMPORT + "const pattern = /value\\\nmore/;\n" + 
_JS_ASSERT_CALL,
+            id="escaped-newline-in-regexp",
+        ),
+    ],
+)
+def test_unproven_node_assert_binding_or_message_is_not_projected(source: str) 
-> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    assert assertion_template_shell_text(source, lambda: None) == source
+    assert _javascript(source) is _shell(source)
+
+
[email protected](
+    "operand",
+    [
+        "value!",
+        "value !",
+        "value /* gap */ !",
+        "value! /* gap */",
+        "holder.value!",
+        "getValue()!",
+        "(value)!",
+        "values[0]!",
+    ],
+)
[email protected]("file_type", ["javascript", "typescript"])
+def test_postfix_non_null_assertion_cannot_hide_receiver_mutation(
+    operand: str, file_type: str
+) -> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    source = (
+        _JS_ASSERT_IMPORT
+        + f"const n = {operand} / (check.equal = run) / divisor;\n"
+        + _JS_ASSERT_CALL
+    )
+
+    assert assertion_template_shell_text(source, lambda: None) == source
+    assert tm.has_bounded_parse_exhaustion(source, lambda: None, 
file_type=file_type) is True
+
+
[email protected](
+    "expression",
+    ["!value", "!!value", "!/value/.test(input)", "value != other", "value !== 
other"],
+)
+def 
test_prefix_negation_and_inequality_keep_assert_message_ownership(expression: 
str) -> None:
+    source = _JS_ASSERT_IMPORT + f"const ready = {expression};\n" + 
_JS_ASSERT_CALL
+
+    assert _javascript(source) is False
+    assert _shell(source) is True
+
+
[email protected](
+    "statement",
+    [
+        "",
+        "const n = value! / (check.equal = run) / divisor;\n",
+        "const n = factory<number> / (check.equal = run) / divisor;\n",
+    ],
+)
+def test_typescript_does_not_borrow_javascript_assert_ownership(statement: 
str) -> None:
+    source = _JS_ASSERT_IMPORT + statement + _JS_ASSERT_CALL
+
+    assert tm.has_bounded_parse_exhaustion(source, lambda: None, 
file_type="typescript") is True
+    result = _ledger("scripts/check.ts", source)
+    assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL
+    assert result["inspection_ledger"][0]["reason_code"] is 
LedgerReason.STATIC_PARSE_LIMIT
+
+
[email protected](
+    "prefix,message",
+    [
+        ("// check.equal = run;\n", "ready ${row.key}"),
+        ("/* check is a diagnostic receiver */\n", "ready ${row.key}"),
+        (r"""const pattern = /['"]/;""" + "\n", "ready ${row.key}"),
+        ("", r"escaped \` delimiter ${row.key}"),
+        ("", "multiple ${label} and ${row.key} values"),
+    ],
+)
+def test_node_assert_projection_changes_only_proven_delimiters(prefix: str, 
message: str) -> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    source = _JS_ASSERT_IMPORT + prefix + f"check.equal(a, b, `{message}`);\n"
+    opening = source.index("`", len(_JS_ASSERT_IMPORT + prefix))
+    closing = source.rindex("`")
+
+    projected = assertion_template_shell_text(source, lambda: None)
+
+    assert len(projected) == len(source)
+    assert [
+        index
+        for index, pair in enumerate(zip(source, projected, strict=True))
+        if pair[0] != pair[1]
+    ] == [
+        opening,
+        closing,
+    ]
+    assert projected[opening] == projected[closing] == " "
+
+
[email protected](
+    "expression",
+    [
+        "execSync(obj.cmd)",
+        "new Command(obj.cmd)",
+        "(target = obj.cmd)",
+        "counter++",
+        "++counter",
+        "obj[key]",
+        "obj?.cmd",
+        "`nested ${obj.cmd}`",
+    ],
+)
+def 
test_effectful_or_unproven_assert_interpolation_is_not_projected(expression: 
str) -> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    source = _JS_ASSERT_IMPORT + f"check.equal(a, b, `${{{expression}}} 
ready`);\n"
+
+    assert assertion_template_shell_text(source, lambda: None) == source
+    assert _javascript(source) is _shell(source)
+
+
[email protected](
+    "statement",
+    [
+        "execSync(`${obj.cmd}`);",
+        "run(`${obj.cmd}`);",
+        "execSync(`${getCmd()}`);",
+        "execSync(`${cmd} -rf /`);",
+        "execSync(`${obj.cmd} -rf /`);",
+        'execSync(`${obj.cmd}` + " -rf /");',
+    ],
+)
+def 
test_node_assert_message_fix_keeps_unresolved_shell_commands_partial(statement: 
str) -> None:
+    source = _JS_ASSERT_IMPORT + _JS_ASSERT_CALL + statement + "\n"
+
+    assert _javascript(source) is True
+    result = _ledger("scripts/check.mjs", source)
+    assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL
+    assert result["inspection_ledger"][0]["reason_code"] is 
LedgerReason.STATIC_PARSE_LIMIT
+
+
[email protected](
+    "statement",
+    [
+        "run(`${cmd}`);",
+        "sh`${obj.cmd}`;",
+        "sh`${obj.cmd} -rf /`;",
+        "execSync(String.raw`${obj.cmd}`);",
+        'execSync(`${cmd}` + " -rf /");',
+    ],
+)
+def test_unrecognized_template_consumers_keep_existing_behavior(statement: 
str) -> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    source = _JS_ASSERT_IMPORT + statement + "\n"
+
+    assert assertion_template_shell_text(source, lambda: None) == source
+    assert _javascript(source) is _shell(source)
+
+
[email protected](
+    "statement",
+    [
+        "execSync(`rm -rf /`);",
+        "check.equal(a, b, `${execSync('rm -rf /')} ready`);",
+        "check.equal(a, b, `rm -rf / ${row.key}`);",
+    ],
+)
+def test_assert_template_ownership_preserves_destructive_text(statement: str) 
-> None:
+    result = _ledger("scripts/check.mjs", _JS_ASSERT_IMPORT + statement + "\n")
+
+    assert any(finding.rule_id == "TM1" for finding in result["findings"])
+
+
+def test_node_assert_message_ownership_requires_complete_javascript_context() 
-> None:
+    source = _JS_ASSERT_IMPORT + _JS_ASSERT_CALL
+
+    assert _javascript(source, complete_context=False) is True
+    assert _shell(source) is True
+
+
+def test_node_assert_template_scan_propagates_runtime_interruptions() -> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    def check_runtime() -> None:
+        raise TimeoutError("test runtime bound")
+
+    with pytest.raises(TimeoutError, match="test runtime bound"):
+        assertion_template_shell_text(_JS_ASSERT_IMPORT + _JS_ASSERT_CALL * 
1000, check_runtime)
+
+
+def test_oversized_javascript_does_not_gain_template_ownership() -> None:
+    from skillspector.javascript_tokens import assertion_template_shell_text
+
+    source = _JS_ASSERT_IMPORT + "/*" + "x" * 1_100_000 + "*/\n" + 
_JS_ASSERT_CALL
+
+    assert assertion_template_shell_text(source, lambda: None) == source
+
+
 # --- Whole-scan accounting -------------------------------------------------
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/skillspector-2.13.1/tests/nodes/test_nested_artifacts.py 
new/skillspector-2.13.2/tests/nodes/test_nested_artifacts.py
--- old/skillspector-2.13.1/tests/nodes/test_nested_artifacts.py        
2020-02-02 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/tests/nodes/test_nested_artifacts.py        
2020-02-02 01:00:00.000000000 +0100
@@ -1030,6 +1030,26 @@
     assert parsed.is_parseable
 
 
[email protected]("suffix", ["mjs", "cjs"])
+def test_javascript_module_types_and_findings_survive_archive_inventory(
+    tmp_path: Path, suffix: str
+) -> None:
+    (tmp_path / "SKILL.md").write_text("# JavaScript modules\n", 
encoding="utf-8")
+    content = b'const command = "rm -rf /";\n'
+    filename = f"helper.{suffix}"
+    (tmp_path / filename).write_bytes(content)
+    _write_archive(tmp_path / "bundle.zip", {filename: content})
+
+    context = build_context({"skill_path": str(tmp_path)})
+    paths = {filename, f"bundle.zip!/{filename}"}
+    metadata = {item["path"]: item for item in context["component_metadata"]}
+
+    assert all(metadata[path]["type"] == "javascript" for path in paths)
+    assert all(metadata[path]["executable"] for path in paths)
+    findings = analyze_tool_misuse(context)["findings"]
+    assert paths <= {finding.file for finding in findings if finding.rule_id 
== "TM1"}
+
+
 def test_nested_relative_selected_script_remains_partial(tmp_path: Path) -> 
None:
     """Archive provenance cannot establish the invocation working directory."""
     (tmp_path / "SKILL.md").write_text("# Nested Python\n", encoding="utf-8")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/skillspector-2.13.1/tests/test_mcp_tool_poisoning.py 
new/skillspector-2.13.2/tests/test_mcp_tool_poisoning.py
--- old/skillspector-2.13.1/tests/test_mcp_tool_poisoning.py    2020-02-02 
01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/tests/test_mcp_tool_poisoning.py    2020-02-02 
01:00:00.000000000 +0100
@@ -1956,6 +1956,140 @@
 
 
 class TestResourceBounds:
+    @pytest.mark.parametrize(
+        ("fence_count", "expected_calls", "is_partial"),
+        [(64, 64, False), (65, 65, False), (128, 128, False), (129, 128, 
True)],
+    )
+    def test_tp4_batch_boundaries_preserve_late_findings_and_source_ranges(
+        self,
+        monkeypatch: pytest.MonkeyPatch,
+        fence_count: int,
+        expected_calls: int,
+        is_partial: bool,
+    ) -> None:
+        content = "".join(f"```python\nprint({index})\n```\n" for index in 
range(fence_count))
+        responses = [{"is_mismatch": False} for _ in range(expected_calls - 1)]
+        responses.append(
+            {
+                "is_mismatch": True,
+                "confidence": 0.9,
+                "mismatched_capabilities": ["network access"],
+            }
+        )
+        structured = _mock_tp4_structured_llm(monkeypatch, responses)
+
+        result = node(
+            {
+                "manifest": {"name": "bounded", "description": "Runs local 
calculations."},
+                "file_cache": {"guide.md": content},
+                "component_metadata": [{"path": "guide.md", "type": 
"markdown"}],
+                "use_llm": True,
+                "model_config": {"default": "test-model"},
+            }
+        )
+
+        assert structured.calls == expected_calls
+        semantic = [event for event in result["inspection_ledger"] if 
event["phase"] == "semantic"]
+        completed = [event for event in semantic if event["outcome"] is 
LedgerOutcome.COMPLETED]
+        assert [(event["path"], event["start_line"], event["end_line"]) for 
event in completed] == [
+            ("guide.md", index * 3 + 2, index * 3 + 2) for index in 
range(expected_calls)
+        ]
+        finding = result["findings"][0]
+        assert len(result["findings"]) == 1
+        assert finding.evidence["code_path"] == "guide.md"
+        assert finding.evidence["code_start_line"] == (expected_calls - 1) * 3 
+ 2
+        assert finding.evidence["code_end_line"] == (expected_calls - 1) * 3 + 
2
+        assert completed[-1]["emitted_finding_ids"] == [finding.finding_id]
+        partial = [event for event in semantic if event["outcome"] is 
LedgerOutcome.PARTIAL]
+        if is_partial:
+            assert len(partial) == 1
+            assert partial[0]["path"] == "guide.md"
+            assert partial[0]["reason_code"] is LedgerReason.OUTPUT_LIMIT
+            assert partial[0]["observed_records"] == 129
+            assert partial[0]["limit_records"] == 128
+            assert result["analyzer_status_events"][0]["status"] == "degraded"
+        else:
+            assert partial == []
+            assert result["analyzer_status_events"][0]["status"] == "completed"
+
+    @pytest.mark.parametrize("tail", ["oversized", "provider_error", 
"deadline"])
+    def test_tp4_late_incomplete_work_remains_visible(
+        self, monkeypatch: pytest.MonkeyPatch, tail: str
+    ) -> None:
+        content = "```python\nprint('local')\n```\n" * 64
+        responses: list[object] = [{"is_mismatch": False} for _ in range(64)]
+        if tail == "oversized":
+            content += "```python\n" + "x" * 128_000 + "\n```\n"
+            expected_outcome = LedgerOutcome.PARTIAL
+            expected_reason = LedgerReason.SIZE_LIMIT
+        else:
+            content += "```python\nprint('last')\n```\n"
+            if tail == "provider_error":
+                responses.append(RuntimeError("provider failure"))
+                expected_outcome = LedgerOutcome.FAILED
+                expected_reason = LedgerReason.LLM_BATCH_FAILED
+            else:
+                
responses.append(mcp_tool_poisoning.LLMRuntimeLimitError("deadline"))
+                expected_outcome = LedgerOutcome.PARTIAL
+                expected_reason = LedgerReason.RUNTIME_LIMIT
+        structured = _mock_tp4_structured_llm(monkeypatch, responses)
+
+        result = node(
+            {
+                "manifest": {"name": "bounded", "description": "Runs local 
calculations."},
+                "file_cache": {"guide.md": content},
+                "component_metadata": [{"path": "guide.md", "type": 
"markdown"}],
+                "use_llm": True,
+                "model_config": {"default": "test-model"},
+            }
+        )
+
+        assert structured.calls == (64 if tail == "oversized" else 65)
+        semantic = [event for event in result["inspection_ledger"] if 
event["phase"] == "semantic"]
+        assert sum(event["outcome"] is LedgerOutcome.COMPLETED for event in 
semantic) == 64
+        incomplete = [
+            event for event in semantic if event["outcome"] is not 
LedgerOutcome.COMPLETED
+        ]
+        assert len(incomplete) == 1
+        assert incomplete[0]["outcome"] is expected_outcome
+        assert incomplete[0]["reason_code"] is expected_reason
+        assert incomplete[0]["path"] == "guide.md"
+        assert incomplete[0]["start_line"] == incomplete[0]["end_line"] == 194
+        assert result["analyzer_status_events"][0]["status"] == (
+            "failed" if tail == "provider_error" else "degraded"
+        )
+
+    def test_tp4_expanded_batch_allowance_keeps_finding_output_cap(self, 
monkeypatch):
+        structured = _mock_tp4_structured_llm(
+            monkeypatch,
+            [
+                {
+                    "is_mismatch": True,
+                    "confidence": 0.9,
+                    "mismatched_capabilities": [f"undeclared capability 
{index}"],
+                }
+                for index in range(65)
+            ],
+        )
+        result = mcp_tool_poisoning._check_tp4(
+            {
+                "manifest": {"description": "Runs local calculations."},
+                "file_cache": {"guide.md": "```python\nprint('local')\n```\n" 
* 65},
+                "component_metadata": [{"path": "guide.md", "type": 
"markdown"}],
+                "model_config": {"default": "test-model"},
+            }
+        )
+
+        assert structured.calls == 65
+        assert len(result.findings) == 64
+        partial = [event for event in result.ledger if event["outcome"] is 
LedgerOutcome.PARTIAL]
+        assert len(partial) == 1
+        assert partial[0]["reason_code"] is LedgerReason.OUTPUT_LIMIT
+        assert partial[0]["observed_findings"] == 65
+        assert partial[0]["limit_findings"] == 64
+        assert partial[0]["path"] == "guide.md"
+        assert partial[0]["start_line"] == partial[0]["end_line"] == 194
+
     def test_static_finding_cap_is_enforced_during_detector_construction(
         self, monkeypatch: pytest.MonkeyPatch
     ) -> None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/skillspector-2.13.1/uv.lock 
new/skillspector-2.13.2/uv.lock
--- old/skillspector-2.13.1/uv.lock     2020-02-02 01:00:00.000000000 +0100
+++ new/skillspector-2.13.2/uv.lock     2020-02-02 01:00:00.000000000 +0100
@@ -2687,7 +2687,7 @@
 
 [[package]]
 name = "skillspector"
-version = "2.13.1"
+version = "2.13.2"
 source = { editable = "." }
 dependencies = [
     { name = "boto3" },

Reply via email to