squackit 0.8.0__tar.gz → 0.8.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. {squackit-0.8.0 → squackit-0.8.2}/CHANGELOG.md +89 -0
  2. {squackit-0.8.0 → squackit-0.8.2}/PKG-INFO +3 -3
  3. {squackit-0.8.0 → squackit-0.8.2}/pyproject.toml +9 -3
  4. {squackit-0.8.0 → squackit-0.8.2}/squackit/defaults.py +82 -14
  5. {squackit-0.8.0 → squackit-0.8.2}/squackit/runtime.py +16 -2
  6. {squackit-0.8.0 → squackit-0.8.2}/squackit/server.py +22 -4
  7. {squackit-0.8.0 → squackit-0.8.2}/squackit/tools.py +112 -9
  8. {squackit-0.8.0 → squackit-0.8.2}/squackit/workflows.py +8 -6
  9. {squackit-0.8.0 → squackit-0.8.2}/tests/test_defaults.py +27 -8
  10. squackit-0.8.2/tests/test_sandbox_anchoring.py +223 -0
  11. {squackit-0.8.0 → squackit-0.8.2}/tests/test_truncation.py +5 -0
  12. {squackit-0.8.0 → squackit-0.8.2}/tests/test_workflows.py +38 -13
  13. {squackit-0.8.0 → squackit-0.8.2}/.github/workflows/release.yml +0 -0
  14. {squackit-0.8.0 → squackit-0.8.2}/.gitignore +0 -0
  15. {squackit-0.8.0 → squackit-0.8.2}/.mcp.json +0 -0
  16. {squackit-0.8.0 → squackit-0.8.2}/.readthedocs.yaml +0 -0
  17. {squackit-0.8.0 → squackit-0.8.2}/.readthedocs.yml +0 -0
  18. {squackit-0.8.0 → squackit-0.8.2}/CLAUDE.md +0 -0
  19. {squackit-0.8.0 → squackit-0.8.2}/LICENSE +0 -0
  20. {squackit-0.8.0 → squackit-0.8.2}/README.md +0 -0
  21. {squackit-0.8.0 → squackit-0.8.2}/docs/agent-system-prompt.md +0 -0
  22. {squackit-0.8.0 → squackit-0.8.2}/docs/architecture.md +0 -0
  23. {squackit-0.8.0 → squackit-0.8.2}/docs/configuration.md +0 -0
  24. {squackit-0.8.0 → squackit-0.8.2}/docs/index.md +0 -0
  25. {squackit-0.8.0 → squackit-0.8.2}/docs/prompts.md +0 -0
  26. {squackit-0.8.0 → squackit-0.8.2}/docs/proposals/2026-06-04-response-type-improvements.md +0 -0
  27. {squackit-0.8.0 → squackit-0.8.2}/docs/quickstart.md +0 -0
  28. {squackit-0.8.0 → squackit-0.8.2}/docs/resources.md +0 -0
  29. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
  30. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
  31. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
  32. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
  33. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
  34. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
  35. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-16-session-handoff.md +0 -0
  36. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-26-named-fts-collections.md +0 -0
  37. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/plans/2026-04-27-kibitzer-fts-doc-retrieval.md +0 -0
  38. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/skills/exploring-unfamiliar-code/SKILL.md +0 -0
  39. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/skills/exploring-unfamiliar-docs/SKILL.md +0 -0
  40. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/skills/pluck-chains/SKILL.md +0 -0
  41. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/skills/refactoring-with-squackit/SKILL.md +0 -0
  42. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/skills/using-squackit/SKILL.md +0 -0
  43. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
  44. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
  45. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
  46. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/specs/2026-04-16-training-data-pipeline-design.md +0 -0
  47. {squackit-0.8.0 → squackit-0.8.2}/docs/superpowers/specs/2026-04-26-named-fts-collections-design.md +0 -0
  48. {squackit-0.8.0 → squackit-0.8.2}/docs/tools.md +0 -0
  49. {squackit-0.8.0 → squackit-0.8.2}/mkdocs.yml +0 -0
  50. {squackit-0.8.0 → squackit-0.8.2}/scripts/EXPERIMENT_LOG.md +0 -0
  51. {squackit-0.8.0 → squackit-0.8.2}/scripts/benchmark_models.py +0 -0
  52. {squackit-0.8.0 → squackit-0.8.2}/scripts/cascade_prototype.py +0 -0
  53. {squackit-0.8.0 → squackit-0.8.2}/scripts/combo_eval.py +0 -0
  54. {squackit-0.8.0 → squackit-0.8.2}/scripts/generate_examples.py +0 -0
  55. {squackit-0.8.0 → squackit-0.8.2}/scripts/grid_generate_examples.py +0 -0
  56. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/deepseek-coder-v2_16b.json +0 -0
  57. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/qwen2.5-coder_0.5b.json +0 -0
  58. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/qwen2.5-coder_3b.json +0 -0
  59. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/qwen2.5-coder_7b.json +0 -0
  60. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/qwen3.5_2b.json +0 -0
  61. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/qwen3_8b.json +0 -0
  62. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/scoring_input.json +0 -0
  63. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_task_results/summary.json +0 -0
  64. {squackit-0.8.0 → squackit-0.8.2}/scripts/hard_tasks.py +0 -0
  65. {squackit-0.8.0 → squackit-0.8.2}/scripts/harvest_grid.py +0 -0
  66. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_eval.py +0 -0
  67. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_results/summary.json +0 -0
  68. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_results/summary_deepseek_plain.json +0 -0
  69. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_results/summary_distilled_xml.json +0 -0
  70. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_results/summary_editor_3b_7b.json +0 -0
  71. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_results/summary_fulldocs.json +0 -0
  72. {squackit-0.8.0 → squackit-0.8.2}/scripts/iterative_results/summary_plaintext_3b7b.json +0 -0
  73. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/codegemma_2b.json +0 -0
  74. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/codegemma_7b.json +0 -0
  75. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/deepseek-coder-v2_16b.json +0 -0
  76. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/deepseek-llm_7b.json +0 -0
  77. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/gemma_latest.json +0 -0
  78. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/glm4_9b.json +0 -0
  79. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/granite-code_3b.json +0 -0
  80. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/granite3.1-dense_2b.json +0 -0
  81. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/granite3.3_2b.json +0 -0
  82. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/llama2-uncensored_7b.json +0 -0
  83. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/llama3.2-vision_11b.json +0 -0
  84. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/llama3.2_1b.json +0 -0
  85. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/llama3.2_latest.json +0 -0
  86. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/phi3_latest.json +0 -0
  87. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/phi4-mini_latest.json +0 -0
  88. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_0.5b.json +0 -0
  89. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_1.5b.json +0 -0
  90. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_3b.json +0 -0
  91. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_7b.json +0 -0
  92. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5_1.5b.json +0 -0
  93. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5_3b.json +0 -0
  94. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2.5_7b.json +0 -0
  95. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen2_latest.json +0 -0
  96. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_0.8b.json +0 -0
  97. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_2b.json +0 -0
  98. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_4b.json +0 -0
  99. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_9b.json +0 -0
  100. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen3_0.6b.json +0 -0
  101. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/qwen3_8b.json +0 -0
  102. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/smollm2_latest.json +0 -0
  103. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_card_results/summary.json +0 -0
  104. {squackit-0.8.0 → squackit-0.8.2}/scripts/model_cards.py +0 -0
  105. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_results/deepseek-coder-v2_16b.json +0 -0
  106. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_results/qwen2.5-coder_3b.json +0 -0
  107. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_results/qwen2.5-coder_7b.json +0 -0
  108. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_results/qwen3.5_2b.json +0 -0
  109. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_results/qwen3_8b.json +0 -0
  110. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_results/summary.json +0 -0
  111. {squackit-0.8.0 → squackit-0.8.2}/scripts/multiline_tasks.py +0 -0
  112. {squackit-0.8.0 → squackit-0.8.2}/scripts/qm_retrieval_eval.py +0 -0
  113. {squackit-0.8.0 → squackit-0.8.2}/scripts/quartermaster_eval.py +0 -0
  114. {squackit-0.8.0 → squackit-0.8.2}/scripts/quartermaster_results/retrieval_summary.json +0 -0
  115. {squackit-0.8.0 → squackit-0.8.2}/scripts/quartermaster_results/summary.json +0 -0
  116. {squackit-0.8.0 → squackit-0.8.2}/scripts/selector_eval.py +0 -0
  117. {squackit-0.8.0 → squackit-0.8.2}/scripts/selector_results/summary.json +0 -0
  118. {squackit-0.8.0 → squackit-0.8.2}/squackit/__init__.py +0 -0
  119. {squackit-0.8.0 → squackit-0.8.2}/squackit/__main__.py +0 -0
  120. {squackit-0.8.0 → squackit-0.8.2}/squackit/cli.py +0 -0
  121. {squackit-0.8.0 → squackit-0.8.2}/squackit/data/examples.json +0 -0
  122. {squackit-0.8.0 → squackit-0.8.2}/squackit/db.py +0 -0
  123. {squackit-0.8.0 → squackit-0.8.2}/squackit/formatting.py +0 -0
  124. {squackit-0.8.0 → squackit-0.8.2}/squackit/lackpy_integration.py +0 -0
  125. {squackit-0.8.0 → squackit-0.8.2}/squackit/prompts.py +0 -0
  126. {squackit-0.8.0 → squackit-0.8.2}/squackit/session.py +0 -0
  127. {squackit-0.8.0 → squackit-0.8.2}/squackit/tool_config.py +0 -0
  128. {squackit-0.8.0 → squackit-0.8.2}/tests/conftest.py +0 -0
  129. {squackit-0.8.0 → squackit-0.8.2}/tests/fixtures/has_call_filter.py +0 -0
  130. {squackit-0.8.0 → squackit-0.8.2}/tests/test_cli_tools.py +0 -0
  131. {squackit-0.8.0 → squackit-0.8.2}/tests/test_config_consumption.py +0 -0
  132. {squackit-0.8.0 → squackit-0.8.2}/tests/test_formatting_json.py +0 -0
  133. {squackit-0.8.0 → squackit-0.8.2}/tests/test_fts_root.py +0 -0
  134. {squackit-0.8.0 → squackit-0.8.2}/tests/test_prompts.py +0 -0
  135. {squackit-0.8.0 → squackit-0.8.2}/tests/test_resources.py +0 -0
  136. {squackit-0.8.0 → squackit-0.8.2}/tests/test_runtime_config.py +0 -0
  137. {squackit-0.8.0 → squackit-0.8.2}/tests/test_session.py +0 -0
  138. {squackit-0.8.0 → squackit-0.8.2}/tests/test_smoke.py +0 -0
  139. {squackit-0.8.0 → squackit-0.8.2}/tests/test_tool_config.py +0 -0
  140. {squackit-0.8.0 → squackit-0.8.2}/tests/test_tools.py +0 -0
@@ -1,5 +1,94 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2
4
+
5
+ ### Fixed — AST tools could not read a source outside the working directory
6
+
7
+ `find` / `view` / `find_names` / `complexity` build their own connection with
8
+ no root, so fledgling's sandbox confined them to the process working
9
+ directory. An absolute source anywhere else — `find(source="/srv/code/x/**/*.py")`
10
+ — raised a permission error. The connection is now rooted at the source's own
11
+ directory, which is the scope the caller named. Relative sources are untouched:
12
+ they already resolve against the working directory, and re-rooting them would
13
+ double-prefix the path.
14
+
15
+ Each rooted plucker gets its own AST cache file, keyed by root. DuckDB's
16
+ filesystem allow-list belongs to the database *instance*, not the connection,
17
+ and every connection to one file shares an instance — so a single shared cache
18
+ meant the first lockdown won for the life of the process and later roots were
19
+ silently ignored. The cache lives in the temp directory rather than inside the
20
+ root, since a served corpus is mounted read-only.
21
+
22
+ ### Fixed — de-vendoring aborted the transaction and blamed the next query
23
+
24
+ When a glob's tree had no `.git`/`.gitmodules` ancestor, the walk up degraded
25
+ to `/` and `_submodule_prefixes('')` read the filesystem root — refused under
26
+ the sandbox. The failure was swallowed by design (de-vendoring is an
27
+ optimization, not a correctness gate), but the aborted DuckDB transaction was
28
+ not rolled back, so the *next* statement failed with "Current transaction is
29
+ aborted" — pointing at the AST cache, which had done nothing wrong. Submodule
30
+ exclusion is now skipped when there is no repository to read, and the fallback
31
+ rolls back so the connection stays usable.
32
+
33
+ ### Fixed — `read_source` output was double-spaced
34
+
35
+ Rows carry each line's own trailing newline, and the text renderer joined them
36
+ with another, so every second line was blank. That also broke head/tail
37
+ truncation, whose omission message is inserted by row index.
38
+
39
+ ## 0.8.1
40
+
41
+ ### Fixed — every code query returned "not found" under fledgling >= 0.13
42
+
43
+ fledgling 0.13 added `sandbox: bool = True` to `connect()`, restricting the
44
+ connection's filesystem allow-list to the project root. squackit's inferred
45
+ default patterns were relative (`**/*.py`), and DuckDB resolves a relative
46
+ glob against the process working directory — not against the root. A server's
47
+ cwd is never the corpus it serves, so the glob landed outside the allow-list
48
+ and the query raised:
49
+
50
+ Permission Error: Cannot access file "**/*.py"
51
+ - file system operations are disabled by configuration
52
+
53
+ `investigate` catches query failures and reports them as a miss, so the
54
+ visible symptom was:
55
+
56
+ No definition found for 'parse_config'. Try a broader pattern or check spelling.
57
+
58
+ for every symbol, in a project where those symbols existed — while the server
59
+ reported all of its tools healthy. A missing-result message is
60
+ indistinguishable from an empty corpus, which is what made this quiet.
61
+
62
+ Patterns are now anchored at the project root wherever they reach DuckDB:
63
+
64
+ - `ProjectDefaults` carries `root` and exposes `code_glob` / `doc_glob`;
65
+ `TOOL_DEFAULTS` names those rather than the raw pattern fields.
66
+ - `scoped_code_pattern` / `scoped_doc_pattern` anchor their results.
67
+ - Caller-supplied *relative* patterns are anchored too, so `docs/**/*.md`
68
+ means "docs in this project" rather than a permission error.
69
+ - Absolute patterns are passed through untouched.
70
+
71
+ The raw `code_pattern` / `doc_pattern` fields stay relative — `prompts.py`
72
+ shows them to users, where an absolute corpus path is noise.
73
+
74
+ ### Changed — unscoped `investigate` falls back to the served root, not cwd
75
+
76
+ `resolve_scope_path` gained a `default` argument, giving the chain
77
+ `explicit path -> runtime.active_root -> served project root -> cwd`.
78
+ Previously an unscoped call scoped to the process working directory, which
79
+ was a workable stand-in only when the tool ran from inside the repo. For a
80
+ served corpus it searched an unrelated directory, and under the sandbox it
81
+ failed outright. Cross-project isolation is unchanged: results still come
82
+ from one project, now named explicitly instead of inferred from cwd.
83
+
84
+ ### Changed — dependency bounds relaxed to `<1.0`
85
+
86
+ The `fledgling-mcp>=0.12,<0.13` bound excluded 0.13.1, which is where
87
+ fledgling relaxed its own exact `duckdb==1.5.2` pin to a range. Against a
88
+ `duckdb==1.5.5` requirement pip did not report a conflict — it backtracked
89
+ to squackit 0.7.0 and installed that, reporting success while shipping code
90
+ two releases old.
91
+
3
92
  ## 0.8.0
4
93
 
5
94
  ### Added — source text from `find`, and a cache instead of re-parsing
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: squackit
3
- Version: 0.8.0
3
+ Version: 0.8.2
4
4
  Summary: Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents.
5
5
  Project-URL: Homepage, https://github.com/teaguesterling/squackit
6
6
  Project-URL: Documentation, https://squackit.readthedocs.io
@@ -16,10 +16,10 @@ Classifier: License :: OSI Approved :: Apache Software License
16
16
  Classifier: Programming Language :: Python :: 3
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Requires-Python: >=3.10
19
- Requires-Dist: ast-pluckit<0.16,>=0.15
19
+ Requires-Dist: ast-pluckit<1.0,>=0.15
20
20
  Requires-Dist: click>=8.0
21
21
  Requires-Dist: fastmcp>=3.0
22
- Requires-Dist: fledgling-mcp<0.13,>=0.12
22
+ Requires-Dist: fledgling-mcp<1.0,>=0.12
23
23
  Provides-Extra: docs
24
24
  Requires-Dist: mkdocs-exclude>=1.0; extra == 'docs'
25
25
  Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "squackit"
3
- version = "0.8.0"
3
+ version = "0.8.2"
4
4
  description = "Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -17,8 +17,14 @@ classifiers = [
17
17
  "License :: OSI Approved :: Apache Software License",
18
18
  ]
19
19
  dependencies = [
20
- "ast-pluckit>=0.15,<0.16",
21
- "fledgling-mcp>=0.12,<0.13",
20
+ # Upper bounds relaxed to <1.0. The tight `<0.13` on fledgling-mcp excluded
21
+ # 0.13.1, which is where fledgling relaxed its own `duckdb==1.5.2` exact pin
22
+ # to a range. Anything needing a specific DuckDB (e.g. matching a bundled
23
+ # extension set) then conflicted, and pip resolved it by SILENTLY
24
+ # BACKTRACKING to squackit 0.7.0 rather than failing — reporting success
25
+ # while installing code two releases old.
26
+ "ast-pluckit>=0.15,<1.0",
27
+ "fledgling-mcp>=0.12,<1.0",
22
28
  "click>=8.0",
23
29
  "fastmcp>=3.0",
24
30
  ]
@@ -10,7 +10,7 @@ from __future__ import annotations
10
10
  import logging
11
11
  import subprocess
12
12
  import tomllib
13
- from dataclasses import dataclass, field
13
+ from dataclasses import dataclass, field, fields
14
14
  from pathlib import Path
15
15
  from typing import TYPE_CHECKING
16
16
 
@@ -22,7 +22,21 @@ if TYPE_CHECKING:
22
22
 
23
23
  @dataclass
24
24
  class ProjectDefaults:
25
- """Inferred at server startup, cached for the session."""
25
+ """Inferred at server startup, cached for the session.
26
+
27
+ Patterns are stored RELATIVE (``**/*.py``) because they are shown to
28
+ users — prompts.py interpolates ``code_pattern`` into tool guidance,
29
+ where an absolute corpus path is noise. Anything handed to DuckDB
30
+ must instead go through the anchored accessors below.
31
+
32
+ WHY ANCHORING IS MANDATORY: a fledgling connection is sandboxed by
33
+ default (fledgling >= 0.13), which limits the filesystem allow-list to
34
+ ``root``. DuckDB resolves a relative glob against the process CWD, not
35
+ against ``root``. A server's CWD is never the corpus it serves, so an
36
+ un-anchored default pattern lands outside the allow-list and the query
37
+ fails with a permission error — which callers upstack turn into "no
38
+ results". See tests/test_sandbox_anchoring.py.
39
+ """
26
40
 
27
41
  code_pattern: str = "**/*"
28
42
  doc_pattern: str = "**/*.md"
@@ -30,11 +44,40 @@ class ProjectDefaults:
30
44
  from_rev: str = "HEAD~1"
31
45
  to_rev: str = "HEAD"
32
46
  languages: list[str] = field(default_factory=list)
47
+ root: str | None = None
48
+
49
+ def anchor(self, pattern: str | Path) -> str:
50
+ """Resolve *pattern* against the project root.
51
+
52
+ An absolute pattern is returned unchanged: a user who configured
53
+ one meant it, and silently re-rooting it would be surprising.
54
+ With no root known, the pattern is returned as-is, preserving the
55
+ pre-sandbox behaviour for callers that construct defaults directly.
56
+ """
57
+ p = Path(pattern)
58
+ if self.root is None or p.is_absolute():
59
+ return str(pattern)
60
+ return str(Path(self.root) / p)
61
+
62
+ @property
63
+ def code_glob(self) -> str:
64
+ """``code_pattern`` anchored at root — use this for queries."""
65
+ return self.anchor(self.code_pattern)
66
+
67
+ @property
68
+ def doc_glob(self) -> str:
69
+ """``doc_pattern`` anchored at root — use this for queries."""
70
+ return self.anchor(self.doc_pattern)
33
71
 
34
72
  def scoped_code_pattern(self, path: str | Path) -> str:
35
- """Scope the code pattern to a subdirectory path."""
73
+ """Scope the code pattern to a subdirectory path, anchored at root."""
36
74
  filename_glob = self.code_pattern.rsplit("/", 1)[-1]
37
- return str(Path(path) / "**" / filename_glob)
75
+ return self.anchor(Path(path) / "**" / filename_glob)
76
+
77
+ def scoped_doc_pattern(self, path: str | Path) -> str:
78
+ """Scope the doc pattern to a subdirectory path, anchored at root."""
79
+ filename_glob = self.doc_pattern.rsplit("/", 1)[-1]
80
+ return self.anchor(Path(path) / "**" / filename_glob)
38
81
 
39
82
 
40
83
  def apply_defaults(
@@ -50,9 +93,18 @@ def apply_defaults(
50
93
  if not mapping:
51
94
  return dict(kwargs)
52
95
  result = dict(kwargs)
53
- for param, field_name in mapping.items():
96
+ for param, attr_name in mapping.items():
54
97
  if result.get(param) is None:
55
- result[param] = getattr(defaults, field_name)
98
+ result[param] = getattr(defaults, attr_name)
99
+ elif attr_name in _PATTERN_ATTRS:
100
+ # An EXPLICIT pattern gets anchored too. A caller passing
101
+ # `docs/**/*.md` means "docs under this project", but DuckDB
102
+ # resolves it against the process cwd — outside the sandbox's
103
+ # allow-list — so it raised a permission error instead of
104
+ # returning rows. Relative means relative-to-root everywhere,
105
+ # for defaults and explicit arguments alike. Absolute patterns
106
+ # are passed through untouched by anchor().
107
+ result[param] = defaults.anchor(result[param])
56
108
  return result
57
109
 
58
110
 
@@ -70,14 +122,23 @@ def load_config(root: str | Path) -> dict[str, str]:
70
122
  return dict(data.get("defaults", {}))
71
123
 
72
124
 
73
- # Tool name → {param_name: defaults_field_name}
125
+ # Attributes in TOOL_DEFAULTS that hold filesystem globs. Values for these
126
+ # params are root-anchored whether they came from the defaults or the caller.
127
+ _PATTERN_ATTRS = {"code_glob", "doc_glob"}
128
+
129
+ # Tool name → {param_name: defaults_attribute_name}
130
+ #
131
+ # These name the ANCHORED accessors (code_glob / doc_glob), not the raw
132
+ # pattern fields. A value substituted here goes straight to DuckDB, so it
133
+ # has to be resolvable from the sandbox's allow-list rather than from the
134
+ # process CWD. The raw code_pattern/doc_pattern fields remain for display.
74
135
  TOOL_DEFAULTS: dict[str, dict[str, str]] = {
75
- "find_definitions": {"file_pattern": "code_pattern"},
76
- "find_in_ast": {"file_pattern": "code_pattern"},
77
- "code_structure": {"file_pattern": "code_pattern"},
78
- "complexity_hotspots": {"file_pattern": "code_pattern"},
79
- "changed_function_summary": {"file_pattern": "code_pattern"},
80
- "doc_outline": {"file_pattern": "doc_pattern"},
136
+ "find_definitions": {"file_pattern": "code_glob"},
137
+ "find_in_ast": {"file_pattern": "code_glob"},
138
+ "code_structure": {"file_pattern": "code_glob"},
139
+ "complexity_hotspots": {"file_pattern": "code_glob"},
140
+ "changed_function_summary": {"file_pattern": "code_glob"},
141
+ "doc_outline": {"file_pattern": "doc_glob"},
81
142
  "file_changes": {"from_rev": "from_rev", "to_rev": "to_rev"},
82
143
  "file_diff": {"from_rev": "from_rev", "to_rev": "to_rev"},
83
144
  "structural_diff": {"from_rev": "from_rev", "to_rev": "to_rev"},
@@ -216,10 +277,17 @@ def infer_defaults(
216
277
  doc_pattern=doc_pattern,
217
278
  main_branch=main_branch,
218
279
  languages=languages,
280
+ root=str(root) if root is not None else None,
219
281
  )
220
282
 
283
+ # Only real dataclass fields are settable. code_glob/doc_glob are
284
+ # read-only properties, so a config file naming one would otherwise
285
+ # raise AttributeError at startup instead of being ignored.
286
+ settable = {f.name for f in fields(defaults)}
221
287
  for key, value in overrides.items():
222
- if hasattr(defaults, key):
288
+ if key in settable:
223
289
  setattr(defaults, key, value)
290
+ else:
291
+ log.warning("ignoring unknown defaults override %r", key)
224
292
 
225
293
  return defaults
@@ -136,19 +136,33 @@ def reset_runtime() -> SquackitRuntimeConfig:
136
136
  return _runtime
137
137
 
138
138
 
139
- def resolve_scope_path(explicit_path: str | None = None) -> str:
139
+ def resolve_scope_path(
140
+ explicit_path: str | None = None,
141
+ default: str | None = None,
142
+ ) -> str:
140
143
  """Resolve a scope path with the runtime config fallback chain.
141
144
 
142
- Precedence: explicit_path -> runtime.active_root -> process cwd.
145
+ Precedence: explicit_path -> runtime.active_root -> *default* -> cwd.
146
+
143
147
  Used by tools that scope queries to a directory (investigate, etc.)
144
148
  so a single `config(set={"active_root": X})` call configures the whole
145
149
  session.
150
+
151
+ *default* is the served project root. It sits ahead of cwd because a
152
+ server's cwd is not the corpus it serves: falling through to cwd scoped
153
+ every unscoped query to an unrelated directory, and — once fledgling
154
+ began sandboxing connections to the project root — put the resulting
155
+ glob outside the filesystem allow-list, so the query failed outright
156
+ rather than merely searching the wrong tree. cwd stays as the last
157
+ resort for callers with no known root.
146
158
  """
147
159
  if explicit_path is not None:
148
160
  return explicit_path
149
161
  runtime_root = _runtime.active_root
150
162
  if runtime_root is not None:
151
163
  return runtime_root
164
+ if default is not None:
165
+ return default
152
166
  return os.getcwd()
153
167
 
154
168
 
@@ -296,7 +296,12 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
296
296
  if is_text:
297
297
  lines = []
298
298
  for row in rows:
299
- parts = [str(v) for v in row if v is not None]
299
+ # Strip each value's own line terminator: this branch joins
300
+ # rows with "\n" itself, so a source line that already ends
301
+ # in one renders double-spaced, every second line blank.
302
+ # Only \r and \n come off — leading indentation is content,
303
+ # and so is any trailing space a line genuinely carries.
304
+ parts = [str(v).rstrip("\r\n") for v in row if v is not None]
300
305
  lines.append(" ".join(parts))
301
306
  if omission:
302
307
  lines.append(omission)
@@ -594,16 +599,29 @@ def _register_tool(
594
599
 
595
600
  # Format output
596
601
  if is_text:
602
+ def _cell(value) -> str:
603
+ """Render one value as a line fragment.
604
+
605
+ Drops the value's own line terminator: this branch joins rows
606
+ with "\\n" itself, and `read_source` returns each line's content
607
+ WITH its trailing newline. Keeping both renders the file
608
+ double-spaced — every second line blank — which also breaks
609
+ head/tail truncation's line accounting, since the omission
610
+ message is inserted by row index. Only \\r and \\n come off;
611
+ leading indentation is content and must survive.
612
+ """
613
+ return str(value).rstrip("\r\n") if value is not None else ""
614
+
597
615
  if len(cols) == 1:
598
- lines = [str(r[0]) for r in rows]
616
+ lines = [_cell(r[0]) for r in rows]
599
617
  elif "line_number" in cols and "content" in cols:
600
618
  ln_idx = cols.index("line_number")
601
619
  ct_idx = cols.index("content")
602
- lines = [f"{r[ln_idx]:4d} {r[ct_idx]}" for r in rows]
620
+ lines = [f"{r[ln_idx]:4d} {_cell(r[ct_idx])}" for r in rows]
603
621
  else:
604
622
  lines = []
605
623
  for row in rows:
606
- parts = [str(v) for v in row if v is not None]
624
+ parts = [_cell(v) for v in row if v is not None]
607
625
  lines.append(" ".join(parts))
608
626
  if omission:
609
627
  lines.insert(_HEAD_TAIL, omission)
@@ -8,7 +8,10 @@ fledgling macro tools. They take priority over fledgling equivalents
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import hashlib
11
12
  import os
13
+ import re
14
+ import tempfile
12
15
 
13
16
  from fledgling.tools import ToolInfo
14
17
  from squackit.tool_config import ToolPresentation
@@ -79,27 +82,41 @@ def _filtered_source(plucker, source: str, peek: str = DEFAULT_PEEK) -> str:
79
82
  # prefix (the path before the first wildcard) and walk up to the nearest
80
83
  # .gitmodules / .git. The denylist is absolute-pattern based, no root needed.
81
84
  prefix = resolved.split("*", 1)[0]
82
- root = prefix if prefix.endswith("/") else os.path.dirname(prefix)
83
- while root and root != "/":
84
- if os.path.exists(os.path.join(root, ".gitmodules")) or os.path.exists(
85
- os.path.join(root, ".git")
85
+ candidate = prefix if prefix.endswith("/") else os.path.dirname(prefix)
86
+ # Walk up to the repository that owns these files. If there ISN'T one,
87
+ # leave root empty and skip submodule exclusion entirely: there are no
88
+ # submodules to exclude, and the walk otherwise degrades to "/", whose
89
+ # rstrip("/") is the empty string. `_submodule_prefixes('')` then reads
90
+ # the filesystem root — which a sandboxed connection refuses, aborting
91
+ # the transaction and taking down every later query on this connection.
92
+ root = ""
93
+ while candidate and candidate != "/":
94
+ if os.path.exists(os.path.join(candidate, ".gitmodules")) or os.path.exists(
95
+ os.path.join(candidate, ".git")
86
96
  ):
97
+ root = candidate
87
98
  break
88
- root = os.path.dirname(root)
99
+ candidate = os.path.dirname(candidate)
89
100
  rq = resolved.replace("'", "''")
90
- rootq = root.rstrip("/").replace("'", "''")
91
101
 
92
102
  total = db.sql(f"SELECT count(*) FROM glob('{rq}')").fetchone()[0]
93
103
  if total <= 1:
94
104
  return source
95
105
 
106
+ submodule_clause = ""
107
+ if root:
108
+ rootq = root.rstrip("/").replace("'", "''")
109
+ submodule_clause = (
110
+ f"AND NOT EXISTS (SELECT 1 FROM _submodule_prefixes('{rootq}') s "
111
+ f"WHERE file LIKE s.prefix || '%') "
112
+ )
113
+
96
114
  kept = [
97
115
  r[0]
98
116
  for r in db.sql(
99
117
  f"SELECT file FROM glob('{rq}') "
100
118
  f"WHERE NOT _is_vendored_path(file) "
101
- f"AND NOT EXISTS (SELECT 1 FROM _submodule_prefixes('{rootq}') s "
102
- f"WHERE file LIKE s.prefix || '%') "
119
+ f"{submodule_clause}"
103
120
  f"ORDER BY file"
104
121
  ).fetchall()
105
122
  ]
@@ -115,9 +132,81 @@ def _filtered_source(plucker, source: str, peek: str = DEFAULT_PEEK) -> str:
115
132
  except Exception:
116
133
  # Ignore-policy macros unavailable / SQL error — fall back to the raw
117
134
  # glob. Unfiltered (flooded) results beat a broken tool.
135
+ #
136
+ # ROLL BACK FIRST. A failed statement leaves the DuckDB transaction in
137
+ # an aborted state, so swallowing the error without clearing it poisons
138
+ # every later query on this connection with "Current transaction is
139
+ # aborted (please ROLLBACK)" — including the AST cache lookup that runs
140
+ # moments later. The caller then sees a transaction error from a
141
+ # statement that never failed, pointing at the wrong subsystem entirely.
142
+ # Degrading gracefully means the connection is still usable afterwards,
143
+ # which is the whole promise of this except clause.
144
+ try:
145
+ db = plucker._ctx.db
146
+ if db is not None:
147
+ db.execute("ROLLBACK")
148
+ except Exception:
149
+ pass # no transaction open, or the connection is beyond saving
118
150
  return source
119
151
 
120
152
 
153
+ def _source_root(source: str) -> str | None:
154
+ """The directory that must be readable for *source* to resolve.
155
+
156
+ A fledgling connection is sandboxed to its root (fledgling >= 0.13), so a
157
+ Plucker built without one is confined to the process working directory and
158
+ cannot read a source anywhere else: ``find(source="/srv/code/x/**/*.py")``
159
+ raised a permission error instead of returning results. These tools take
160
+ their target as an argument, so the target is the scope — rooting the
161
+ connection at the source's own directory keeps the sandbox meaningful while
162
+ letting an explicit path resolve.
163
+
164
+ Returns None when there is nothing to root at (a bare table/view name, or a
165
+ directory that does not exist), leaving pluckit's default behaviour alone.
166
+ """
167
+ if not source:
168
+ return None
169
+ # A bare identifier is a DuckDB table/view name, not a path.
170
+ if not any(sep in source for sep in ("/", os.sep)):
171
+ return None
172
+ # RELATIVE sources are already handled: they resolve against the working
173
+ # directory, which is what an un-rooted Plucker sandboxes to. Re-rooting
174
+ # them would be actively wrong — _filtered_source joins a relative source
175
+ # onto the connection's repo, so pointing that repo at the source's own
176
+ # directory turns `squackit/**/*.py` into `.../squackit/squackit/**/*.py`
177
+ # and the tool finds nothing.
178
+ if not os.path.isabs(source):
179
+ return None
180
+ head = re.split(r"[*?\[]", source, maxsplit=1)[0]
181
+ directory = head if head.endswith(("/", os.sep)) else os.path.dirname(head)
182
+ if not directory:
183
+ return None
184
+ directory = os.path.abspath(directory)
185
+ return directory if os.path.isdir(directory) else None
186
+
187
+
188
+ def _cache_path(root: str | None) -> str:
189
+ """Where the AST cache database lives for a Plucker rooted at *root*.
190
+
191
+ DuckDB's filesystem allow-list is a property of the database INSTANCE, not
192
+ of the connection, and every connection to the same file shares one
193
+ instance. A single shared cache file therefore means the FIRST lockdown
194
+ wins for the life of the process: a plucker rooted at /srv/code/x reuses
195
+ the instance already locked to the working directory and is refused access
196
+ to its own source, with no indication that its root was ignored.
197
+
198
+ So a rooted plucker gets its own cache file, keyed by the root. It goes in
199
+ the temp directory rather than inside the root: a served corpus is mounted
200
+ read-only, and writing a database into a tree we were only asked to read is
201
+ rude even where it succeeds. Keying by root keeps the cache reusable across
202
+ processes, which is the point of persisting it at all.
203
+ """
204
+ if root is None:
205
+ return os.path.join(os.getcwd(), ".pluckit.duckdb")
206
+ digest = hashlib.sha256(os.path.abspath(root).encode()).hexdigest()[:16]
207
+ return os.path.join(tempfile.gettempdir(), f"squackit-ast-{digest}.duckdb")
208
+
209
+
121
210
  def _make_plucker(source: str, peek: str = DEFAULT_PEEK):
122
211
  """Create a Plucker with AstViewer and Search for tool execution.
123
212
 
@@ -135,7 +224,21 @@ def _make_plucker(source: str, peek: str = DEFAULT_PEEK):
135
224
  # parses with ``peek := 'none'`` and can only ever return NULL. Enabling the
136
225
  # cache moves the un-filtered path onto ``ast_select_from`` too, so both
137
226
  # paths return source text. It also stops re-parsing on every call.
138
- p = Plucker(plugins=[AstViewer, Search], cache=True, peek=peek)
227
+ #
228
+ # The cache path is passed EXPLICITLY rather than left to `cache=True`.
229
+ # pluckit derives the default cache file from the repo (`<repo>/.pluckit.duckdb`),
230
+ # so rooting the connection at the source would also move the cache into the
231
+ # tree being analysed — which for a served corpus is a read-only mount, and
232
+ # for anyone else means writing a database into a repo they only asked us to
233
+ # read. Keeping it under the working directory preserves the location this
234
+ # had before the connection gained a root.
235
+ root = _source_root(source)
236
+ p = Plucker(
237
+ plugins=[AstViewer, Search],
238
+ cache=_cache_path(root),
239
+ peek=peek,
240
+ repo=root,
241
+ )
139
242
  p._code_source = _filtered_source(p, source, peek)
140
243
  return p
141
244
 
@@ -95,8 +95,8 @@ def _sorted_table(con, macro_name, kwargs, sort_col, max_rows, descending=True):
95
95
  def explore(con, defaults, path=None):
96
96
  """First-contact codebase briefing."""
97
97
  # Scope patterns to path if provided
98
- code_pattern = defaults.scoped_code_pattern(path) if path else defaults.code_pattern
99
- doc_pattern = str(Path(path) / "**" / "*.md") if path else defaults.doc_pattern
98
+ code_pattern = defaults.scoped_code_pattern(path) if path else defaults.code_glob
99
+ doc_pattern = defaults.scoped_doc_pattern(path) if path else defaults.doc_glob
100
100
 
101
101
  sections = []
102
102
 
@@ -149,7 +149,9 @@ def investigate(con, defaults, name, file_pattern=None, path=None):
149
149
  # Precedence: explicit path -> runtime.active_root -> process cwd.
150
150
  # Runtime fallback lets a session set active_root once via
151
151
  # `config(set={"active_root": X})` instead of passing path= per call.
152
- file_pattern = defaults.scoped_code_pattern(resolve_scope_path(path))
152
+ file_pattern = defaults.scoped_code_pattern(
153
+ resolve_scope_path(path, default=defaults.root)
154
+ )
153
155
 
154
156
  # 1. Find definitions matching the name. Fetch substring candidates, then
155
157
  # prefer EXACT name matches: a deep-dive on `ensure_loaded` must not drag
@@ -269,7 +271,7 @@ def review(con, defaults, from_rev=None, to_rev=None, file_pattern=None):
269
271
  """Code review prep for a revision range."""
270
272
  from_rev = from_rev or defaults.from_rev
271
273
  to_rev = to_rev or defaults.to_rev
272
- file_pattern = file_pattern or defaults.code_pattern
274
+ file_pattern = file_pattern or defaults.code_glob
273
275
 
274
276
  sections = []
275
277
 
@@ -349,7 +351,7 @@ def review(con, defaults, from_rev=None, to_rev=None, file_pattern=None):
349
351
 
350
352
  def search(con, defaults, query, file_pattern=None):
351
353
  """Multi-source search across code, docs, and git."""
352
- file_pattern = file_pattern or defaults.code_pattern
354
+ file_pattern = file_pattern or defaults.code_glob
353
355
 
354
356
  sections = []
355
357
 
@@ -371,7 +373,7 @@ def search(con, defaults, query, file_pattern=None):
371
373
  # 3. Documentation sections matching the query
372
374
  sections.append(_section("Documentation", lambda: _table(
373
375
  con, "doc_outline",
374
- {"file_pattern": defaults.doc_pattern, "search": query},
376
+ {"file_pattern": defaults.doc_glob, "search": query},
375
377
  max_rows=10,
376
378
  )))
377
379
 
@@ -46,11 +46,14 @@ class TestToolDefaults:
46
46
  for tool in code_tools:
47
47
  assert tool in TOOL_DEFAULTS
48
48
  assert "file_pattern" in TOOL_DEFAULTS[tool]
49
- assert TOOL_DEFAULTS[tool]["file_pattern"] == "code_pattern"
49
+ # The ANCHORED accessor, not the raw pattern: a substituted value
50
+ # goes straight to DuckDB, which resolves a relative glob against
51
+ # the process cwd rather than the sandboxed project root.
52
+ assert TOOL_DEFAULTS[tool]["file_pattern"] == "code_glob"
50
53
 
51
54
  def test_doc_tools_mapped(self):
52
55
  assert "doc_outline" in TOOL_DEFAULTS
53
- assert TOOL_DEFAULTS["doc_outline"]["file_pattern"] == "doc_pattern"
56
+ assert TOOL_DEFAULTS["doc_outline"]["file_pattern"] == "doc_glob"
54
57
 
55
58
  def test_git_tools_mapped(self):
56
59
  git_tools = ["file_changes", "file_diff", "structural_diff"]
@@ -60,12 +63,20 @@ class TestToolDefaults:
60
63
  assert TOOL_DEFAULTS[tool]["to_rev"] == "to_rev"
61
64
 
62
65
  def test_all_field_names_are_valid(self):
63
- """Every value in TOOL_DEFAULTS must name a real ProjectDefaults field."""
64
- valid_fields = {f.name for f in fields(ProjectDefaults)}
66
+ """Every value in TOOL_DEFAULTS must name something apply_defaults can
67
+ actually read off ProjectDefaults.
68
+
69
+ Checks attributes rather than dataclass fields: the pattern entries
70
+ name read-only properties (code_glob / doc_glob), which apply_defaults
71
+ reaches via getattr just the same. The point of the test is that a
72
+ typo here fails loudly at import rather than substituting None into a
73
+ query, and that survives the field-vs-property distinction.
74
+ """
75
+ probe = ProjectDefaults()
65
76
  for tool, mapping in TOOL_DEFAULTS.items():
66
- for _param, field_name in mapping.items():
67
- assert field_name in valid_fields, (
68
- f"{tool}: '{field_name}' is not a ProjectDefaults field"
77
+ for _param, attr_name in mapping.items():
78
+ assert hasattr(probe, attr_name), (
79
+ f"{tool}: '{attr_name}' is not a ProjectDefaults attribute"
69
80
  )
70
81
 
71
82
 
@@ -185,8 +196,16 @@ class TestInferDefaults:
185
196
  def con(self):
186
197
  return create_connection(repo=str(PROJECT_ROOT))
187
198
 
199
+ @requires_fledgling_source
188
200
  def test_code_pattern_is_python(self, con):
189
- """This repo is primarily Python, so code_pattern should be **/*.py."""
201
+ """This repo is primarily Python, so code_pattern should be **/*.py.
202
+
203
+ Needs a source checkout, like its doc_pattern sibling below: an
204
+ installed fledgling wheel bundles sql/ as package data, so SQL
205
+ outnumbers Python in that layout and the inferred pattern is
206
+ legitimately **/*.sql. Asserting against the wheel fails on the
207
+ content of the dog-food corpus, not on the inference logic.
208
+ """
190
209
  defaults = infer_defaults(con, root=str(PROJECT_ROOT))
191
210
  assert "py" in defaults.code_pattern
192
211