squackit 0.8.1__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.1 → squackit-0.8.2}/CHANGELOG.md +36 -0
  2. {squackit-0.8.1 → squackit-0.8.2}/PKG-INFO +1 -1
  3. {squackit-0.8.1 → squackit-0.8.2}/pyproject.toml +1 -1
  4. {squackit-0.8.1 → squackit-0.8.2}/squackit/server.py +22 -4
  5. {squackit-0.8.1 → squackit-0.8.2}/squackit/tools.py +112 -9
  6. {squackit-0.8.1 → squackit-0.8.2}/tests/test_truncation.py +5 -0
  7. {squackit-0.8.1 → squackit-0.8.2}/tests/test_workflows.py +16 -2
  8. {squackit-0.8.1 → squackit-0.8.2}/.github/workflows/release.yml +0 -0
  9. {squackit-0.8.1 → squackit-0.8.2}/.gitignore +0 -0
  10. {squackit-0.8.1 → squackit-0.8.2}/.mcp.json +0 -0
  11. {squackit-0.8.1 → squackit-0.8.2}/.readthedocs.yaml +0 -0
  12. {squackit-0.8.1 → squackit-0.8.2}/.readthedocs.yml +0 -0
  13. {squackit-0.8.1 → squackit-0.8.2}/CLAUDE.md +0 -0
  14. {squackit-0.8.1 → squackit-0.8.2}/LICENSE +0 -0
  15. {squackit-0.8.1 → squackit-0.8.2}/README.md +0 -0
  16. {squackit-0.8.1 → squackit-0.8.2}/docs/agent-system-prompt.md +0 -0
  17. {squackit-0.8.1 → squackit-0.8.2}/docs/architecture.md +0 -0
  18. {squackit-0.8.1 → squackit-0.8.2}/docs/configuration.md +0 -0
  19. {squackit-0.8.1 → squackit-0.8.2}/docs/index.md +0 -0
  20. {squackit-0.8.1 → squackit-0.8.2}/docs/prompts.md +0 -0
  21. {squackit-0.8.1 → squackit-0.8.2}/docs/proposals/2026-06-04-response-type-improvements.md +0 -0
  22. {squackit-0.8.1 → squackit-0.8.2}/docs/quickstart.md +0 -0
  23. {squackit-0.8.1 → squackit-0.8.2}/docs/resources.md +0 -0
  24. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
  25. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
  26. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
  27. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
  28. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
  29. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
  30. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-16-session-handoff.md +0 -0
  31. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-26-named-fts-collections.md +0 -0
  32. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/plans/2026-04-27-kibitzer-fts-doc-retrieval.md +0 -0
  33. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/skills/exploring-unfamiliar-code/SKILL.md +0 -0
  34. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/skills/exploring-unfamiliar-docs/SKILL.md +0 -0
  35. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/skills/pluck-chains/SKILL.md +0 -0
  36. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/skills/refactoring-with-squackit/SKILL.md +0 -0
  37. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/skills/using-squackit/SKILL.md +0 -0
  38. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
  39. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
  40. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
  41. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/specs/2026-04-16-training-data-pipeline-design.md +0 -0
  42. {squackit-0.8.1 → squackit-0.8.2}/docs/superpowers/specs/2026-04-26-named-fts-collections-design.md +0 -0
  43. {squackit-0.8.1 → squackit-0.8.2}/docs/tools.md +0 -0
  44. {squackit-0.8.1 → squackit-0.8.2}/mkdocs.yml +0 -0
  45. {squackit-0.8.1 → squackit-0.8.2}/scripts/EXPERIMENT_LOG.md +0 -0
  46. {squackit-0.8.1 → squackit-0.8.2}/scripts/benchmark_models.py +0 -0
  47. {squackit-0.8.1 → squackit-0.8.2}/scripts/cascade_prototype.py +0 -0
  48. {squackit-0.8.1 → squackit-0.8.2}/scripts/combo_eval.py +0 -0
  49. {squackit-0.8.1 → squackit-0.8.2}/scripts/generate_examples.py +0 -0
  50. {squackit-0.8.1 → squackit-0.8.2}/scripts/grid_generate_examples.py +0 -0
  51. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/deepseek-coder-v2_16b.json +0 -0
  52. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/qwen2.5-coder_0.5b.json +0 -0
  53. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/qwen2.5-coder_3b.json +0 -0
  54. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/qwen2.5-coder_7b.json +0 -0
  55. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/qwen3.5_2b.json +0 -0
  56. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/qwen3_8b.json +0 -0
  57. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/scoring_input.json +0 -0
  58. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_task_results/summary.json +0 -0
  59. {squackit-0.8.1 → squackit-0.8.2}/scripts/hard_tasks.py +0 -0
  60. {squackit-0.8.1 → squackit-0.8.2}/scripts/harvest_grid.py +0 -0
  61. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_eval.py +0 -0
  62. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_results/summary.json +0 -0
  63. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_results/summary_deepseek_plain.json +0 -0
  64. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_results/summary_distilled_xml.json +0 -0
  65. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_results/summary_editor_3b_7b.json +0 -0
  66. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_results/summary_fulldocs.json +0 -0
  67. {squackit-0.8.1 → squackit-0.8.2}/scripts/iterative_results/summary_plaintext_3b7b.json +0 -0
  68. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/codegemma_2b.json +0 -0
  69. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/codegemma_7b.json +0 -0
  70. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/deepseek-coder-v2_16b.json +0 -0
  71. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/deepseek-llm_7b.json +0 -0
  72. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/gemma_latest.json +0 -0
  73. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/glm4_9b.json +0 -0
  74. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/granite-code_3b.json +0 -0
  75. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/granite3.1-dense_2b.json +0 -0
  76. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/granite3.3_2b.json +0 -0
  77. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/llama2-uncensored_7b.json +0 -0
  78. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/llama3.2-vision_11b.json +0 -0
  79. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/llama3.2_1b.json +0 -0
  80. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/llama3.2_latest.json +0 -0
  81. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/phi3_latest.json +0 -0
  82. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/phi4-mini_latest.json +0 -0
  83. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_0.5b.json +0 -0
  84. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_1.5b.json +0 -0
  85. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_3b.json +0 -0
  86. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5-coder_7b.json +0 -0
  87. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5_1.5b.json +0 -0
  88. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5_3b.json +0 -0
  89. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2.5_7b.json +0 -0
  90. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen2_latest.json +0 -0
  91. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_0.8b.json +0 -0
  92. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_2b.json +0 -0
  93. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_4b.json +0 -0
  94. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen3.5_9b.json +0 -0
  95. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen3_0.6b.json +0 -0
  96. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/qwen3_8b.json +0 -0
  97. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/smollm2_latest.json +0 -0
  98. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_card_results/summary.json +0 -0
  99. {squackit-0.8.1 → squackit-0.8.2}/scripts/model_cards.py +0 -0
  100. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_results/deepseek-coder-v2_16b.json +0 -0
  101. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_results/qwen2.5-coder_3b.json +0 -0
  102. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_results/qwen2.5-coder_7b.json +0 -0
  103. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_results/qwen3.5_2b.json +0 -0
  104. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_results/qwen3_8b.json +0 -0
  105. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_results/summary.json +0 -0
  106. {squackit-0.8.1 → squackit-0.8.2}/scripts/multiline_tasks.py +0 -0
  107. {squackit-0.8.1 → squackit-0.8.2}/scripts/qm_retrieval_eval.py +0 -0
  108. {squackit-0.8.1 → squackit-0.8.2}/scripts/quartermaster_eval.py +0 -0
  109. {squackit-0.8.1 → squackit-0.8.2}/scripts/quartermaster_results/retrieval_summary.json +0 -0
  110. {squackit-0.8.1 → squackit-0.8.2}/scripts/quartermaster_results/summary.json +0 -0
  111. {squackit-0.8.1 → squackit-0.8.2}/scripts/selector_eval.py +0 -0
  112. {squackit-0.8.1 → squackit-0.8.2}/scripts/selector_results/summary.json +0 -0
  113. {squackit-0.8.1 → squackit-0.8.2}/squackit/__init__.py +0 -0
  114. {squackit-0.8.1 → squackit-0.8.2}/squackit/__main__.py +0 -0
  115. {squackit-0.8.1 → squackit-0.8.2}/squackit/cli.py +0 -0
  116. {squackit-0.8.1 → squackit-0.8.2}/squackit/data/examples.json +0 -0
  117. {squackit-0.8.1 → squackit-0.8.2}/squackit/db.py +0 -0
  118. {squackit-0.8.1 → squackit-0.8.2}/squackit/defaults.py +0 -0
  119. {squackit-0.8.1 → squackit-0.8.2}/squackit/formatting.py +0 -0
  120. {squackit-0.8.1 → squackit-0.8.2}/squackit/lackpy_integration.py +0 -0
  121. {squackit-0.8.1 → squackit-0.8.2}/squackit/prompts.py +0 -0
  122. {squackit-0.8.1 → squackit-0.8.2}/squackit/runtime.py +0 -0
  123. {squackit-0.8.1 → squackit-0.8.2}/squackit/session.py +0 -0
  124. {squackit-0.8.1 → squackit-0.8.2}/squackit/tool_config.py +0 -0
  125. {squackit-0.8.1 → squackit-0.8.2}/squackit/workflows.py +0 -0
  126. {squackit-0.8.1 → squackit-0.8.2}/tests/conftest.py +0 -0
  127. {squackit-0.8.1 → squackit-0.8.2}/tests/fixtures/has_call_filter.py +0 -0
  128. {squackit-0.8.1 → squackit-0.8.2}/tests/test_cli_tools.py +0 -0
  129. {squackit-0.8.1 → squackit-0.8.2}/tests/test_config_consumption.py +0 -0
  130. {squackit-0.8.1 → squackit-0.8.2}/tests/test_defaults.py +0 -0
  131. {squackit-0.8.1 → squackit-0.8.2}/tests/test_formatting_json.py +0 -0
  132. {squackit-0.8.1 → squackit-0.8.2}/tests/test_fts_root.py +0 -0
  133. {squackit-0.8.1 → squackit-0.8.2}/tests/test_prompts.py +0 -0
  134. {squackit-0.8.1 → squackit-0.8.2}/tests/test_resources.py +0 -0
  135. {squackit-0.8.1 → squackit-0.8.2}/tests/test_runtime_config.py +0 -0
  136. {squackit-0.8.1 → squackit-0.8.2}/tests/test_sandbox_anchoring.py +0 -0
  137. {squackit-0.8.1 → squackit-0.8.2}/tests/test_session.py +0 -0
  138. {squackit-0.8.1 → squackit-0.8.2}/tests/test_smoke.py +0 -0
  139. {squackit-0.8.1 → squackit-0.8.2}/tests/test_tool_config.py +0 -0
  140. {squackit-0.8.1 → squackit-0.8.2}/tests/test_tools.py +0 -0
@@ -1,5 +1,41 @@
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
+
3
39
  ## 0.8.1
4
40
 
5
41
  ### Fixed — every code query returned "not found" under fledgling >= 0.13
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: squackit
3
- Version: 0.8.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "squackit"
3
- version = "0.8.1"
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"
@@ -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
 
@@ -279,7 +279,12 @@ class TestMarkdownTruncation:
279
279
  assert omission_idx == 7 # 0:header, 1:sep, 2-6:head, 7:omission
280
280
 
281
281
  @pytest.mark.anyio
282
+ @requires_fledgling_source
282
283
  async def test_max_results_zero_disables(self, mcp):
284
+ # recent_changes queries git history, so the dog-food corpus has to be
285
+ # a repository. An installed wheel in site-packages has no .git and the
286
+ # macro raises "No git repository found" — a fact about the corpus, not
287
+ # about truncation.
283
288
  result = _text(await mcp.call_tool("recent_changes", {
284
289
  "max_results": 0,
285
290
  }))
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
 
10
10
  import pytest
11
11
 
12
- from conftest import PROJECT_ROOT
12
+ from conftest import PROJECT_ROOT, requires_fledgling_source
13
13
 
14
14
  try:
15
15
  import fastmcp # noqa: F401
@@ -174,7 +174,18 @@ class TestExplore:
174
174
 
175
175
  @requires_fastmcp
176
176
  class TestInvestigate:
177
- """Test the investigate compound tool."""
177
+ """Test the investigate compound tool.
178
+
179
+ The section assertions below need a fledgling SOURCE checkout as the
180
+ dog-food corpus. Against an installed wheel the corpus is site-packages,
181
+ where the bundled sql/ outnumbers the Python, so the inferred code_pattern
182
+ is legitimately `**/*.sql` and investigating a Python symbol finds nothing
183
+ — the briefing then has no Definition/Source/Called-by sections. That is a
184
+ fact about the corpus, not about investigate, and asserting it against a
185
+ wheel sends anyone debugging it into squackit's scoping logic instead. The
186
+ tests that hold either way (registration, unknown-name handling, project
187
+ scoping) stay unmarked.
188
+ """
178
189
 
179
190
  @pytest.fixture(scope="class")
180
191
  def text(self, mcp):
@@ -185,12 +196,15 @@ class TestInvestigate:
185
196
  def test_returns_non_empty(self, text):
186
197
  assert len(text) > 0
187
198
 
199
+ @requires_fledgling_source
188
200
  def test_contains_definition_section(self, text):
189
201
  assert "Definition" in text
190
202
 
203
+ @requires_fledgling_source
191
204
  def test_contains_source_section(self, text):
192
205
  assert "Source" in text
193
206
 
207
+ @requires_fledgling_source
194
208
  def test_contains_called_by_section(self, text):
195
209
  assert "Called by" in text
196
210
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes