Skip to content

fix(harness): give a browser scan its limits cut a second look (TH-9512) - #281

Open
khushalsonawat wants to merge 12 commits into
feat/TH-8804-source-subfolderfrom
feat/TH-9512-past-the-parse-total
Open

khushalsonawat wants to merge 12 commits into
feat/TH-8804-source-subfolderfrom
feat/TH-9512-past-the-parse-total

Conversation

@khushalsonawat

@khushalsonawat khushalsonawat commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

This PR goes into feat/TH-8804-source-subfolder. #276 and #283, which it was built on, have merged there, so its diff is now only its own commits and the base merges.

Summary

The browser scan stops reading an agent's code once one of its limits is reached, and returns what it found so far. Until now the reading then went through on that, with nothing logged. So padding read first, or an honest repository bigger than the limits, hid a plain import playwright. For example, three 126 KB files that name playwright in a comment spent the 256 KB parse total, and the agent's real import was never parsed.

Now, whenever one of the browser scan's own limits or failures leaves a code file unread or unparsed, the scan says it was cut. A cut reading gets one second look, with its own message, before the model's answer stands. It is never a refusal on its own.

The import walk, which follows the agent's imports into modules outside its folder, now counts too (Dileep's C2). When one of its totals runs out, the files it reached but couldn't parse are read with a cheap import-line search, for browser names and for further imports. The reading is cut (import_walk) only if that search hits its own cap, so it is never silent (see "The import walk" below).

Ticket: TH-9512.

How it works

  • agent_type_check._tools_in returns the names and the set of limits that cut it. The limits are:
    • file_size: over 128 KB for a .py, over 1 MiB for a script or package.json, and only when the file may name a tool: its bytes hold a browser package's name or a tool marker (the word "computer" alone doesn't count), it is a .py whose coding line names a codec that isn't raw-safe or one Python refuses, it is a package.json, or it can't be read or searched within 4 MiB a file and 8 MiB a scan. The search never decodes or parses. A big data module that names no tool is no cut (Dileep's C3);
    • scan_total, script_total, python_total;
    • parse_total: only a file that names a browser package counts;
    • parse_failed;
    • token_total: both the total and the work limit;
    • decode_total (fix(harness): decode a Python file before searching it when its codec can spell a name (TH-9490) #276's);
    • search_total;
    • skipped;
    • package_unreadable: a manifest we can't read (a read error, or nesting too deep to parse). It's read as Node reads it, with bad bytes replaced and integers of any length, and invalid JSON means no dependencies, as it does for npm (Dileep's C4).
  • browser_scan(root, subfolder, reached) returns both from one walk and the existing cache. browser_tools_found and browser_tools_in_agent are unchanged.
  • decide, SecondLook.verdict, issue and prompt take cut. A cut acts like a found tool for the look, and nothing else changes:
    • a cut chat or voice reading gets the cut text;
    • a cut browser claim gets a cut browser text in place of "no tool was found";
    • cut plus found gives today's found text, unchanged.
  • One WARNING per cut scan, with names and counts only, for example agent type scan cut by file_size 17, parse_total 6.
  • There's no wire change and nothing stored.

What it costs

A cut reading pays one extra model turn, which the customer pays for in authoring credits. That's about $0.10-0.25 a look, or 10-30% of a median $0.88 reading, from 23 cost records.

  • Cut: the kit's own src/, the futureagi backend, and the future-agi and core roots.
  • Not cut: small agents and fixtures. No small kit root, small real agent or subfolder fixture we measured is cut.
  • Scan time: at most about 0.15 s more on a large repository, for the search of files too big to read (about 18 ms a MiB, 8 MiB a scan at most). A crafted coding line can cost about 0.3 s more.

The import walk (Dileep's C2)

  • What changed: when a walk total runs out (the 512 KB parse total first of all), each file the walk reached but couldn't parse gets a line search instead of being left unknown. Its bytes are searched for browser names, as the scan already searches files too big to read, and its import / from ... import lines (each ; segment, and the text after a compound statement's :) are followed with no parse. The search has its own caps: 8 MiB in all, 4 MiB a file, and the walk's file, byte, import and lookup limits. Only when it hits one is the reading cut (import_walk). The agent folder's own files over 1 MB are line-searched too, up to 4 MiB; a larger one is skipped on its size before it is opened, and is a cut. Dileep's repro now finds playwright, with no cut.
  • Measured on 45 real agent-folder candidates on one machine. With the first rule (any stop is a cut): 25 of 45 cut. With this change: 7 of 45, and 0 of the 38 agent-shaped folders. The 7 are the kit's own test and example trees (agent-learning-kit examples/frameworks, oss/simulation-acceptance, tests, tests/harness, tests/live, tests/runtime) and future-agi's futureagi/model_hub. Each is a tree past several limits at once (the 5,000 import-name limit, the 8 MiB search total, the 1,000,000-byte read cap on 1.3-2.1 MB modules, and for model_hub the lookup limit), so a cut and a second look are right for them. Re-measured after the last fix: unchanged.
  • The line search (import_lines.py, after Dileep's second review) strips strings and comments in one pass and reads lines by splitting on whitespace, with no pattern that can backtrack: its time is in proportion to the text. A 100 KB line of spaces, tabs, dots, parentheses, backslashes, quotes, #, :, ;, import or from takes under 40 ms, and a 4 MiB file under 0.4 s (from and 50,000 spaces took over 90 s before, now about 25 ms). It reads \-continued lists, from.x, from .import x, a # or ; inside a string, \r line ends, tabs and form feeds. A code file the walk reaches over the 1,000,000-byte read cap is line-searched up to 4 MiB; over that it is a cut. A .py too big to read is a cut only for a whole package name (not for steelblue), and a read script contributes tool markers only.
  • Where the line search can't be sure, the reading is cut: a string or triple quote that never closes, a parenthesised list that never closes or holds something else, more colons in a header than are tried before an import.
  • Known limits of the line search (what a parse would catch): an import built at run time (importlib.import_module(name), __import__, exec); a name only a decoded codec spells; for a script past its script total, anything the script pattern there misses.

Left alone, on the Coordinator's calls

  • The instrumentation scan has the same padding gap, where tracing turns to none. It's held with TH-9517 for Khushal, because framework-detection.md §7 forbids changing instrumentation_evidence.py.
  • Three stops stay uncut: an unreadable script, a file under the read limit that can't be read or grows mid-read, and a tokenizer error. A small file the scan can't read can't be bundled either, and the tokenizer never raised on any of 14,914 files that parse. An oversize file that can't be read or grows does cut, since its search can't clear it.

Merged with the base

HEAD merges c0e51b7e (#287, #289-#293 and dev). One hunk, the skip handler in agent_type_check.py, was resolved by hand: #291 drops the skip's trace at every level, and this PR counts the skip as the skipped cut; both are kept (no trace, the type once at WARNING, the count in the cut line).

566a4459 merged cde91b3f (#282, 1132192) with no conflicts. The walk now reaches a uv workspace member past the first 256 when a requirement names its folder, so code there is scanned and can bring a look; a folder link back into the agent's folder no longer counts as code left unread, which changes only the walk's WARNING. No cut changes.

By hand (b812a9a1)

Merging the base brought #288 (TH-9448), which changed how the scan logs a file it skips. Three hunks were resolved by hand, keeping both sides:

Review

Each round used two fresh Opus reviewers.

  • Design:

    • r1 not clear (a fallback search only moved the gap);
    • r2 not clear (an import-line search missed legal forms, and the flag missed limits);
    • r3 not clear (the walk's stops).

    Then the Coordinator's call: the flag alone, with no parser imitation.

  • Plan: r1 clear.

  • Change: r1 clear, with every plant run: C1-C11 for each limit, and D1-D11 for the look, the log, the cache, the shield, the walk and the closure.

  • Fixed after change review r1, as the rule allows for a round with nothing above Medium:

    • a subfolder-route test of the look;
    • a package.json that isn't a file (a dangling link) is no cut;
    • the crafted-file subfolder test now expects no look: the file names no tool.
  • Dileep's review: C2 (the walk stop) is fixed, above ("The import walk"); C3 (file_size only when the file may name a tool) and C4 (package.json read as Node reads it) are fixed.

  • Change r2 (those fixes): not clear: a long integer in package.json hid a dependency, and a coding line Python refuses at compile time can still run once its codec is registered. Both fixed, with a test for the 4 MiB search, a narrower computer needle, and the untested branches pinned. The oversize module the search clears now leaves its imports unfollowed: accepted by the Coordinator and stated above.

  • Change r3, the last, with the base merged: clear. Fixed after it: tests for a file exactly at the search bound, a module naming only PlayWrightBrowserToolkit, and a file past what's left of the search total not being opened.

Verification

  • Where the tests run: CI's SDK smoke runs test_agent_type_refusal.py and test_contract_tools_subfolder.py on every push. The new test_browser_scan_cut.py and the other files below ran locally on Python 3.12.3, as listed; adding test_browser_scan_cut.py to the smoke list is pending.

  • Fix-only check r1 (one reviewer) of the C2 fix 868b243e, the tests in da2fba5f and the skip-handler hunk resolved by hand in e174292e: clear, High 0, Medium 2, Low 1. Both Mediums fixed in 18e63950 (a large file in the agent's folder is skipped on its size before it is read; an import after ; or a compound : is followed), and the Low too (the line search runs only for the browser scan, not for bundling or the credential walk).

  • Fix-only check r2 of 18e63950: clear, High 0, Medium 0, Low 2 (a re-measure, done: unchanged; the 8-colon line, fixed in 0e2570af).

  • 0e2570af: an import past the 8 colons the line search tries is now a cut (import_walk), never silent; a long line with no import after its colons stays no cut. Fix-only check: clear, no findings. On 0e2570af: the five files, 1180 passed; ruff check clean; re-measured, 7 of 45, unchanged.

  • 7084b31e (Dileep's second review: the backtracking pattern replaced, the legal import forms, a cut where the search can't be sure, whole names, the cut logged once, the line search moved to import_lines.py): fix-only check by a fresh reviewer, clear, no findings (every pattern linear on adversarial input; 62 of 62 legal import forms match ast; every unsure case cuts). On 7084b31e, Python 3.12.3: test_browser_scan_cut.py 130, test_agent_type_refusal.py 739, test_contract_tools_subfolder.py 180, tests/test_harness_credentials.py 81, test_agent_scan.py 84, test_import_lines.py 140 passed (the last two are now in CI's SDK smoke); ruff check clean; re-measured, 7 of 45, unchanged.

  • On 18e63950, Python 3.12.3: test_browser_scan_cut.py, test_agent_type_refusal.py, test_contract_tools_subfolder.py, tests/test_harness_credentials.py and test_agent_scan.py, 1178 passed; ruff check clean. Dileep's repro test and a two-hop test fail on e174292e.

  • ruff check and ruff format --check on the new test file; no new findings in the edited files against 73e91700; compileall.

  • One test run on Python 3.12.3, a file at a time, on the merged head:

    file passed
    test_browser_scan_cut (new) 85
    test_agent_type_refusal 735
    test_contract_tools_subfolder 180
    test_agent_reading 190
    test_scan_walk 159
    test_hosted_authoring_entrypoint 12
    test_harness_credentials 81
    test_builder_chat 179
    test_hosted_conversation 22
    test_python_source_codecs 156
  • 51 bugs planted one at a time, from the change reviewers' runner, over the new file, test_agent_type_refusal and test_contract_tools_subfolder: every limit's cut removed (C1-C11), the look, the log, the cache, the shield, the walk and the closure (D1-D11), and each moved test's own bug (M1-M11). 50 failed a named test. The 51st, which kept a non-file package.json as unreadable, no longer applies after the fix above; checked by hand, its new test fails under it.

  • After Dileep's review: the worker planted each fix's bug (R1-R9), all caught. Change r3 planted 122 more over the merged tree: R1-R9 again, the merge's walk wires, and rounds 1-2's plants re-run. Three survived (a search that skips its last KiB, no check of what's left of the search total, the toolkit name dropped from the search); each is now caught by a new test. Four change nothing reachable.

  • After merging fix(harness): the save's import walk says when it leaves code unread, and reads more of it (TH-9543) #283: test_the_walks_stop_is_no_cut was re-pointed over its 512 KB walk total (7722f1b5), and plant D8 is caught by it again. The merge reviewer re-ran the six files: 59, 693, 180, 92, 81 and 156 passed.

  • TypeScript: not touched.

Checklist

  • Public API names unchanged
  • No new release claim
  • No hosted-service dependency added
  • Security-sensitive output is redacted or justified: the new log line holds limit names and counts only

The browser scan stopped reading an agent's code at any of its limits
(file size, the scan, script and Python totals, the parse total, the
token total and work, the decode and search totals, a skipped file, a
package.json it couldn't read) and returned what it had, so padding read
first or a large honest repository hid a plain browser import, silently.

The scan now returns the limits that cut it, and a cut reading gets one
second look with its own message before the model's answer stands; a cut
never refuses on its own. One WARNING per cut scan names the limits and
counts. The import walk's own stops are left to its own fix.

Refs: TH-9512
Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… (TH-9512)

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>

@cdileep23 cdileep23 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the comments.

def _scanned_agent(
source_root: Path, subfolder: str, reached: list[Path] | None
) -> tuple[tuple[str, ...], frozenset[str]]:
"""``browser_tools_in_agent``'s scan, and the limits that cut it. The walk's own stops aren't

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2. A stopped import walk isn't a cut, so padding still hides a browser import outside the agent's folder, with no look.

Repro (subfolder agents/a):

  • agents/a/m_0.py to m_5.py, each "import os\n" + "x = 1\n" * (100 * 1024 // 6)
  • agents/a/z_main.py = import common.util
  • common/__init__.py empty, common/util.py = import playwright

browser_scan(root, "agents/a") gives ([], frozenset()) and decide("chat", [], looked_again=False, cut=frozenset()) gives keep. Without the six files it finds playwright. The only signal is the walk's WARNING.

The Summary says "whenever any of the scan's limits or failures leaves a code file unread or unparsed, the scan says it was cut". "Left alone" defers the walk, and test_the_walks_stop_is_no_cut pins this. Bundling's walk has no limit, so common/util.py ships and runs.

Fix: count the walk's stop as a cut only when an import it didn't follow leads to a file whose bytes name a browser package, which avoids a look on most subfolder agents. Or reword the Summary and link the ticket that tracks this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as a stated limit, rather than a cut. Counting the walk's stops would put a paid look on most subfolder readings. #283 made the stops visible: a WARNING, and a 512 KB walk total. The PR's Summary now says a stopped walk isn't a cut, and "Left alone" states this gap and links #283; it's also in our older-findings register as a Medium. test_the_walks_stop_is_no_cut pins today's behaviour.

Comment thread src/fi/alk/harness/agent_type_check.py Resolved
Comment thread src/fi/alk/harness/agent_type_check.py Resolved
…total

Brings in #269, #276 (through #283), #283 and #288. Three hunks against
#288 (TH-9448) were resolved by hand, keeping both intents:
- a skipped file logs its type once at WARNING, and its path and trace
  at DEBUG (#288); it is also counted as the scan's `skipped` cut, and
  that count is in the cut line, so #288's separate "skipped N files"
  line is not repeated (TH-9512);
- #288's skip tests and its NUL-byte test now expect the cut line, which
  holds names and counts only.

Tests on 3.12.3: test_agent_type_refusal 720, test_browser_scan_cut 59,
test_contract_tools_subfolder 180, all passed.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
… read package.json as Node does (TH-9512)

- An oversize .py counts as file_size only when its bytes name a browser package, its coding
  line names a codec that isn't raw-safe or that Python refuses, or the search can't clear it:
  at most 4 MiB a file and 8 MiB a scan are searched, and a file past either bound, unreadable,
  not a file, or grown since its size was taken still cuts. It is never parsed.
- package.json is read as Node reads it, UTF-8 with replacement characters and integers kept as
  text; invalid JSON means no dependencies, since npm refuses it too. Only OSError and
  RecursionError leave it unreadable, a cut.

TH-9512

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…t/TH-9512-past-the-parse-total

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…H-9512)

- A file exactly at the per-file search bound, its needle in the last bytes, cuts.
- An oversize module whose only tool is PlayWrightBrowserToolkit cuts.
- Past what's left of the scan's search total, an oversize file isn't opened.

TH-9512

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…the-parse-total

agent_type_check.py, the skip handler: #291 drops the trace at every level; this branch
counts a skip as the skipped cut. Kept both: no trace, the type once at WARNING, the count
in the cut line.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…and cut only when that stops too (TH-9512)

A file the walk reached but its parse total, parse size or script total kept
from being parsed has its import lines searched (no parse) and followed under
the other limits, with one 8 MiB search total for the walk.
The scan's cut set gets import_walk only when a limit or that search's cap
left code unread; a file the search covered is no cut. The WARNING stays.
Tests pin Dileep's repro, a two-hop line-search chain, and the search cap.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…t after ';' or ':' (TH-9512)

A folder .py over the 4 MiB search cap is no longer opened, and counts as an import_walk cut;
every byte read is charged to the walk. The line search also reads an import after a ';' and
after a compound statement's ':'. The parse-refusal search runs only when a search total is given.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>
…e search tries (TH-9512)

The line search tries a compound statement's first 8 colons. A line with
more before its import wasn't followed and wasn't cut, so the import went
unseen with no second look. It is now a limit cut, like the walk's other
caps; a long line with no import after its colons stays no cut.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>

@cdileep23 cdileep23 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the comments.

Comment thread src/fi/alk/harness/credentials.py Outdated


_IMPORT_LINE_NAME = re.compile(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*")
_FROM_LINE = re.compile(r"from[ \t]+(\.*)[ \t]*([A-Za-z_][\w.]*)?[ \t]+import\b(.*)", re.DOTALL)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1. This pattern has three overlapping whitespace runs around an optional group, so a failed match backtracks in cubic time. A file reaches it exactly in this PR's case, once the walk has spent its 512 KB parse total.

Repro: _FROM_LINE.fullmatch("from" + " " * n + "x") takes 0.03 s at n=400, 0.19 s at 800, 1.5 s at 1600 and about 9 s at 3200. The 8 MiB search total limits bytes, not time, so one 100 KB line runs for days, and asyncio.to_thread keeps burning the sandbox's CPU after the call is cancelled.

Fix: split the line on whitespace instead of using a regex, or make the runs possessive, e.g. from[ \t]++(\.*+)[ \t]*+(?:([A-Za-z_][\w.]*+)[ \t]++)?import\b(.*). Add a test that a line of 50,000 spaces returns at once.

Comment thread src/fi/alk/harness/credentials.py Outdated
hit = None
else:
hit = _file_inside(place, root, deepest, lookups_left, unread)
hit = _file_inside(place, root, deepest, lookups_left, unread, limited)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2. limited goes in as too_big, so an imported code file over 1,000,000 bytes is never queued for the line search and always cuts the reading, whatever it holds. That is the earlier file_size case moved into the walk.

Repro: agents/a/main.py = import openai\nimport common.data, common/data.py = a 1.86 MB list of strings: ([], {'import_walk'}). The same module at 900 KB gives no cut.

Fix: in a walk that searches, let _file_inside return a regular code file up to _LARGEST_UNREAD_SEARCHED so the over-cap branch line-searches it, with a test per case.

Comment thread src/fi/alk/harness/credentials.py Outdated
item is set when a line had an import past the colons tried (``_statements``).
"""
names: list[tuple[int, str]] = []
lines = iter(content.split("\n"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2. The line search silently misses legal import forms, each giving no cut and no look:

  • import os, \ then common.util returns only os
  • from common import \ then util returns common, not common.util
  • from.common import util and from .import util return nothing
  • x = "#"; import common.util returns nothing (the # split)
  • an unclosed from x import ( (e.g. inside a docstring) swallows every later line

Repro end to end: z_main.py = import os, \ then common.util, common/util.py = import playwright: ([], frozenset()).

Fix: join lines ending in \ first, accept from followed by a dot and dots followed by import, and set capped where the search can't be sure, so the reading is cut rather than silent. One test row per form.

Comment thread src/fi/alk/harness/agent_type_check.py Outdated
coding = _coding(raw)
if coding is None or coding[1].name not in _RAW_SAFE_CODECS:
return True
if any(name in raw for name in _PYTHON_NAMES if name != b"omputer"):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. This is a plain substring test, so a 200 KB colors.py full of 'steelblue' matches steel and gives file_size. Scripts are searched for Python import names too, though a read script only contributes tool markers: a 1.1 MB widget.js containing window._selenium gives file_size, while the same text in a small .js finds nothing.

Fix: whole names (_WHOLE_NAMES) for .py, and only _MARKER_START_BYTES for scripts.

Comment thread src/fi/alk/harness/agent_type_check.py Outdated
)
# A budget or limit left code the walk reached unread, so what it would have led to is
# unknown (TH-9512, Dileep's C2). _tools_in has already logged its own cuts.
return names, (cut | {"import_walk"} if walk_cut else cut)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. _tools_in logs "cut by ..." before import_walk is added, so a reading whose only cut is the walk logs no cut line, and the walk's own WARNING fires on any stop. The logs can't tell which readings paid for a look.

Fix: log the cut line once in _scanned_agent, after merging in import_walk.

Comment thread src/fi/alk/harness/agent_type_check.py Outdated
dict.fromkeys((*_agent_files(folder), *above, *shared, *manifests)), local, folder
)
# A budget or limit left code the walk reached unread, so what it would have led to is
# unknown (TH-9512, Dileep's C2). _tools_in has already logged its own cuts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. "(TH-9512, Dileep's C2)" is review history in public code (standard 11); some test comments name the reviewer too. Drop the parenthetical.

Comment thread src/fi/alk/harness/credentials.py Outdated
untried. A file the parse total skipped but the search covered, a name dropped for its length
or depth, a link, a file the parser refuses (syntax error) and a codec refusal are not.
"""
from .agent_type_check import _LARGEST_UNREAD_SEARCHED, _UNREAD_SEARCH_TOTAL

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. About 90 lines of import-line parsing (_statements, _import_lines and their regexes) go into the 2,034-line credentials.py, which imports private constants from agent_type_check inside a function to avoid a cycle. Standard 13 says a new function in a file over 1,000 lines goes in its own module or the PR says why it stays.

Fix: move the line search to python_source.py or a new module and pass the caps in, as search_total already is.

…rms, and cut where it can't be sure (TH-9512)

The line search moves to import_lines.py: strings and comments are taken out in one pass,
then the import lines are read by splitting on whitespace, with no regular expression that
can backtrack. A line continued by a backslash is joined, and from.x, from .import x, a #
inside a string and a docstring holding an open paren are read as Python reads them.
A string, list or colon run it cannot be sure of sets capped, so the reading is cut.
The walk also reaches a code file over the read cap, up to the search cap, and searches it.

Signed-off-by: khushalsonawat <khushalsonawat@gmail.com>

@cdileep23 cdileep23 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check the comments.

for segment in code.split(";"):
segment = segment.strip()
pieces.append(segment)
if _COMPOUND_START.match(segment):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2. The line search misses an import after the colon of a compound statement whose header runs over more than one line. The line that closes the brackets doesn't start with if, with, for, except or class, so its colon is never tried. Nothing is capped, so there's no cut and no look.

Repro (subfolder agents/a, the same six 100 KB padding modules as before):

# agents/a/z_main.py
if (True and
        True): import common.util
# common/util.py
import playwright

browser_scan(root, "agents/a") gives ([], frozenset()) and decide gives keep. On one line, or without the padding, it finds playwright.

import_lines alone misses the same import for with open(\n 'f') as g: import x, for x in (\n 1, 2): import x, except (A,\n B): import x and def k(\n a): import x. A random mix of 54 legal statements checked against ast gives 446 silent misses in 2,757 files. With the two multi-line headers taken out, it gives 0.

Fix: join each line that ends inside an open bracket to the lines that follow until it closes (one pass, after strings and comments are out), then split statements on the joined line. Add a test row for each header keyword.

if piece.startswith("import") and piece[6:7].isspace():
for item in piece[6:].split(","):
if name := _name(item):
names.append((0, name))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3. A file made of import names is linear but slow, and holds every name in memory. On 3.12.11, one 4 MiB file of from x import ( followed by 50 lines of a, repeated, takes 1.9 s. from x import ( with no close followed by a, lines takes 2.1 s, and import a,a,a,... takes 1.3 s. A plain 4 MiB module takes 0.38 s. Peak memory for the first is about 217 MB (1.86 M tuples). The walk searches 8 MiB, so a crafted repository adds about 4 s to the scan the save waits on. The description says "a 4 MiB file under 0.4 s".

The caller keeps only import_limit - imports_tried names (5,000 at most) and cuts on the rest, so every name past that is wasted work.

Fix: pass the names left into import_lines. Stop and set capped once that many are found, so time and memory end at the limit the walk would cut at anyway. Add a test that a 4 MiB file of names returns within a bound.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants