squackit 0.3.3__tar.gz → 0.4.0__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.
- {squackit-0.3.3 → squackit-0.4.0}/PKG-INFO +2 -2
- {squackit-0.3.3 → squackit-0.4.0}/pyproject.toml +2 -2
- {squackit-0.3.3 → squackit-0.4.0}/squackit/__init__.py +1 -1
- {squackit-0.3.3 → squackit-0.4.0}/squackit/cli.py +35 -5
- {squackit-0.3.3 → squackit-0.4.0}/squackit/server.py +9 -3
- {squackit-0.3.3 → squackit-0.4.0}/squackit/tools.py +65 -5
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_cli_tools.py +17 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_smoke.py +1 -1
- squackit-0.4.0/tests/test_tools.py +282 -0
- squackit-0.3.3/tests/test_tools.py +0 -142
- {squackit-0.3.3 → squackit-0.4.0}/.github/workflows/release.yml +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/.gitignore +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/.mcp.json +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/.readthedocs.yaml +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/.readthedocs.yml +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/CLAUDE.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/LICENSE +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/README.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/architecture.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/configuration.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/index.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/prompts.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/quickstart.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/resources.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/docs/tools.md +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/mkdocs.yml +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/__main__.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/db.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/defaults.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/formatting.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/prompts.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/session.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/tool_config.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/squackit/workflows.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/conftest.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_defaults.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_formatting_json.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_prompts.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_resources.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_session.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_tool_config.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_truncation.py +0 -0
- {squackit-0.3.3 → squackit-0.4.0}/tests/test_workflows.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: squackit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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,7 +16,7 @@ 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.9
|
|
19
|
-
Requires-Dist: ast-pluckit>=0.
|
|
19
|
+
Requires-Dist: ast-pluckit>=0.9.0
|
|
20
20
|
Requires-Dist: click>=8.0
|
|
21
21
|
Requires-Dist: fastmcp>=3.0
|
|
22
22
|
Provides-Extra: docs
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "squackit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
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,7 +17,7 @@ classifiers = [
|
|
|
17
17
|
"License :: OSI Approved :: Apache Software License",
|
|
18
18
|
]
|
|
19
19
|
dependencies = [
|
|
20
|
-
"ast-pluckit>=0.
|
|
20
|
+
"ast-pluckit>=0.9.0",
|
|
21
21
|
"click>=8.0",
|
|
22
22
|
"fastmcp>=3.0",
|
|
23
23
|
]
|
|
@@ -95,10 +95,12 @@ class ToolGroup(click.Group):
|
|
|
95
95
|
def _get_registry():
|
|
96
96
|
"""Lazily build the tool registry."""
|
|
97
97
|
from pluckit import Plucker
|
|
98
|
-
from
|
|
99
|
-
|
|
98
|
+
from pluckit.pluckins.viewer import AstViewer
|
|
99
|
+
from squackit.tools import PLUCKIT_TOOLS, collect_pluckin_tools
|
|
100
|
+
p = Plucker(plugins=[AstViewer])
|
|
100
101
|
con = p.connection
|
|
101
|
-
|
|
102
|
+
extra = list(PLUCKIT_TOOLS) + collect_pluckin_tools(p)
|
|
103
|
+
return build_tool_registry(con._tools, extra_tools=extra), con
|
|
102
104
|
|
|
103
105
|
|
|
104
106
|
def _format_result(result, presentation, json_output):
|
|
@@ -286,18 +288,29 @@ def pluck(click_ctx, argv):
|
|
|
286
288
|
squackit pluck "src/api.py" find .fn#handler view
|
|
287
289
|
squackit pluck "**/*.py" find .fn names -- find .class names
|
|
288
290
|
|
|
291
|
+
Mutations (rename, replaceWith, wrap, etc.) are blocked by default.
|
|
292
|
+
Pass --write to allow them. Example:
|
|
293
|
+
|
|
294
|
+
squackit pluck --write "src/api.py" find .fn#old rename new
|
|
295
|
+
|
|
289
296
|
See pluckit documentation for full chain grammar.
|
|
290
297
|
"""
|
|
291
298
|
from pluckit import Chain
|
|
292
299
|
from squackit.formatting import _format_markdown_table, format_json
|
|
300
|
+
from squackit.tools import _chain_mutation_ops
|
|
293
301
|
|
|
294
302
|
if not argv:
|
|
295
303
|
click.echo(click_ctx.get_help())
|
|
296
304
|
return
|
|
297
305
|
|
|
306
|
+
# Extract squackit-level --write flag before passing to pluckit
|
|
307
|
+
argv_list = list(argv)
|
|
308
|
+
write_mode = "--write" in argv_list
|
|
309
|
+
if write_mode:
|
|
310
|
+
argv_list = [a for a in argv_list if a != "--write"]
|
|
311
|
+
|
|
298
312
|
try:
|
|
299
|
-
chain = Chain.from_argv(
|
|
300
|
-
result = chain.evaluate()
|
|
313
|
+
chain = Chain.from_argv(argv_list)
|
|
301
314
|
except SystemExit:
|
|
302
315
|
raise
|
|
303
316
|
except Exception as e:
|
|
@@ -305,6 +318,23 @@ def pluck(click_ctx, argv):
|
|
|
305
318
|
click_ctx.exit(1)
|
|
306
319
|
return
|
|
307
320
|
|
|
321
|
+
mutations = _chain_mutation_ops(chain)
|
|
322
|
+
if mutations and not write_mode:
|
|
323
|
+
click.echo(
|
|
324
|
+
f"Error: chain contains mutation operations: {', '.join(mutations)}\n"
|
|
325
|
+
f"Pass --write to allow mutations (they modify source files).",
|
|
326
|
+
err=True,
|
|
327
|
+
)
|
|
328
|
+
click_ctx.exit(1)
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
try:
|
|
332
|
+
result = chain.evaluate()
|
|
333
|
+
except Exception as e:
|
|
334
|
+
click.echo(f"Error: {e}", err=True)
|
|
335
|
+
click_ctx.exit(1)
|
|
336
|
+
return
|
|
337
|
+
|
|
308
338
|
json_output = click_ctx.obj.get("json", False) if click_ctx.obj else False
|
|
309
339
|
data = result.get("data")
|
|
310
340
|
result_type = result.get("type")
|
|
@@ -62,8 +62,13 @@ def create_server(
|
|
|
62
62
|
A FastMCP server instance ready to .run().
|
|
63
63
|
"""
|
|
64
64
|
from fastmcp import FastMCP
|
|
65
|
+
from pluckit.pluckins.viewer import AstViewer
|
|
65
66
|
|
|
66
|
-
|
|
67
|
+
plucker = Plucker(
|
|
68
|
+
repo=root, profile=profile, modules=modules, init=init,
|
|
69
|
+
plugins=[AstViewer],
|
|
70
|
+
)
|
|
71
|
+
con = plucker.connection
|
|
67
72
|
mcp = FastMCP(name)
|
|
68
73
|
|
|
69
74
|
# Infer smart defaults, merge with config file overrides
|
|
@@ -78,8 +83,9 @@ def create_server(
|
|
|
78
83
|
mcp.access_log = access_log
|
|
79
84
|
|
|
80
85
|
# Register each macro as an MCP tool
|
|
81
|
-
from squackit.tools import PLUCKIT_TOOLS
|
|
82
|
-
|
|
86
|
+
from squackit.tools import PLUCKIT_TOOLS, collect_pluckin_tools
|
|
87
|
+
extra = list(PLUCKIT_TOOLS) + collect_pluckin_tools(plucker)
|
|
88
|
+
registry = build_tool_registry(con._tools, extra_tools=extra)
|
|
83
89
|
for presentation in registry.values():
|
|
84
90
|
_register_tool(mcp, con, presentation, defaults, cache, access_log)
|
|
85
91
|
|
|
@@ -15,7 +15,7 @@ from squackit.tool_config import ToolPresentation
|
|
|
15
15
|
def _make_plucker(source: str):
|
|
16
16
|
"""Create a Plucker with AstViewer for tool execution."""
|
|
17
17
|
from pluckit import Plucker
|
|
18
|
-
from pluckit.
|
|
18
|
+
from pluckit.pluckins.viewer import AstViewer
|
|
19
19
|
return Plucker(code=source, plugins=[AstViewer])
|
|
20
20
|
|
|
21
21
|
|
|
@@ -54,18 +54,60 @@ def complexity_executor(*, source: str, selector: str):
|
|
|
54
54
|
return rel
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def
|
|
57
|
+
def _chain_mutation_ops(chain) -> list[str]:
|
|
58
|
+
"""Return the list of mutation op names present in the chain (in order)."""
|
|
59
|
+
from pluckit.chain import Chain as _Chain
|
|
60
|
+
return [step.op for step in chain.steps if step.op in _Chain._MUTATION_OPS]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def collect_pluckin_tools(plucker) -> list:
|
|
64
|
+
"""Collect squackit tools from a Plucker's registered pluckins.
|
|
65
|
+
|
|
66
|
+
Pluckins that want to contribute squackit tools expose a
|
|
67
|
+
``squackit_tools()`` method returning a list of ToolPresentation
|
|
68
|
+
objects. This function walks the Plucker's pluckin registry and
|
|
69
|
+
collects tools from any pluckin that implements the method.
|
|
70
|
+
|
|
71
|
+
Plugin authors can add squackit integration without depending on
|
|
72
|
+
squackit at import time — the import lives inside the method body
|
|
73
|
+
and only fires when squackit actually calls it.
|
|
74
|
+
"""
|
|
75
|
+
tools: list = []
|
|
76
|
+
registry = getattr(plucker, "_registry", None)
|
|
77
|
+
if registry is None:
|
|
78
|
+
return tools
|
|
79
|
+
pluckins = getattr(registry, "pluckins", None)
|
|
80
|
+
if pluckins is None:
|
|
81
|
+
return tools
|
|
82
|
+
for pluckin in pluckins:
|
|
83
|
+
fn = getattr(pluckin, "squackit_tools", None)
|
|
84
|
+
if callable(fn):
|
|
85
|
+
try:
|
|
86
|
+
tools.extend(fn())
|
|
87
|
+
except Exception:
|
|
88
|
+
# A broken pluckin shouldn't break the whole registry.
|
|
89
|
+
# Squackit's server/CLI will surface the error contextually.
|
|
90
|
+
pass
|
|
91
|
+
return tools
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pluck_executor(*, argv: str, allow_mutations: str | bool = False):
|
|
58
95
|
"""Execute a pluckit chain from a whitespace-separated command string.
|
|
59
96
|
|
|
60
97
|
Accepts the same grammar as `squackit pluck` on the CLI:
|
|
61
98
|
"source_pattern [method [arg]]... [terminal]"
|
|
62
99
|
|
|
100
|
+
**Mutation safety:** chains containing mutation operations (rename,
|
|
101
|
+
replaceWith, wrap, remove, etc.) are blocked by default. Pass
|
|
102
|
+
``allow_mutations=true`` to opt in. Agents should only enable this
|
|
103
|
+
when the user has explicitly authorized code changes.
|
|
104
|
+
|
|
63
105
|
Returns a JSON-serialized chain result: {chain, type, data}.
|
|
64
106
|
|
|
65
107
|
Examples:
|
|
66
108
|
pluck(argv="**/*.py find .fn names")
|
|
67
109
|
pluck(argv="--plugin AstViewer src/api.py find .fn#handler view")
|
|
68
|
-
pluck(argv="**/*.py find .fn
|
|
110
|
+
pluck(argv="**/*.py find .fn#old rename new_name", allow_mutations=True)
|
|
69
111
|
"""
|
|
70
112
|
import shlex
|
|
71
113
|
import json
|
|
@@ -76,6 +118,23 @@ def pluck_executor(*, argv: str):
|
|
|
76
118
|
return json.dumps({"error": "Empty argv"}, indent=2)
|
|
77
119
|
|
|
78
120
|
chain = Chain.from_argv(tokens)
|
|
121
|
+
|
|
122
|
+
# Coerce string "true"/"false" from MCP clients to bool
|
|
123
|
+
if isinstance(allow_mutations, str):
|
|
124
|
+
allow = allow_mutations.lower() in ("true", "1", "yes")
|
|
125
|
+
else:
|
|
126
|
+
allow = bool(allow_mutations)
|
|
127
|
+
|
|
128
|
+
mutations = _chain_mutation_ops(chain)
|
|
129
|
+
if mutations and not allow:
|
|
130
|
+
return json.dumps({
|
|
131
|
+
"error": "blocked: chain contains mutation operations",
|
|
132
|
+
"mutations": mutations,
|
|
133
|
+
"hint": "Set allow_mutations=true to enable. Mutations modify "
|
|
134
|
+
"source files — ensure the user has authorized changes.",
|
|
135
|
+
"chain": chain.to_dict(),
|
|
136
|
+
}, indent=2)
|
|
137
|
+
|
|
79
138
|
result = chain.evaluate()
|
|
80
139
|
return json.dumps(result, indent=2, default=str)
|
|
81
140
|
|
|
@@ -128,7 +187,7 @@ COMPLEXITY_TOOL = ToolPresentation(
|
|
|
128
187
|
PLUCK_TOOL = ToolPresentation(
|
|
129
188
|
info=ToolInfo(
|
|
130
189
|
macro_name="pluck",
|
|
131
|
-
params=["argv"],
|
|
190
|
+
params=["argv", "allow_mutations"],
|
|
132
191
|
description=(
|
|
133
192
|
"Execute a pluckit chain query. Pass a whitespace-separated "
|
|
134
193
|
"command: 'source_pattern [method [arg]]... [terminal]'. "
|
|
@@ -136,7 +195,8 @@ PLUCK_TOOL = ToolPresentation(
|
|
|
136
195
|
"Use 'reset' to start a new chain from the source. "
|
|
137
196
|
"Example: '**/*.py find .fn containing cache names'. "
|
|
138
197
|
"Use '--plugin AstViewer' prefix for view terminals. "
|
|
139
|
-
"
|
|
198
|
+
"Mutations (rename, replaceWith, wrap, etc.) are blocked "
|
|
199
|
+
"unless allow_mutations=true. Returns JSON: {chain, type, data}."
|
|
140
200
|
),
|
|
141
201
|
required=["argv"],
|
|
142
202
|
),
|
|
@@ -139,3 +139,20 @@ class TestPluckCommand:
|
|
|
139
139
|
assert result.exit_code == 0, result.output
|
|
140
140
|
# Should show class names (last chain) not fn names
|
|
141
141
|
assert "ToolGroup" in result.output or "LazyToolGroup" in result.output
|
|
142
|
+
|
|
143
|
+
def test_pluck_mutation_blocked_by_default(self):
|
|
144
|
+
result = runner.invoke(cli, ["pluck", "squackit/cli.py",
|
|
145
|
+
"find", ".fn#nonexistent", "rename", "foo"])
|
|
146
|
+
assert result.exit_code != 0
|
|
147
|
+
assert "mutation" in result.output.lower()
|
|
148
|
+
assert "rename" in result.output
|
|
149
|
+
|
|
150
|
+
def test_pluck_mutation_allowed_with_write(self):
|
|
151
|
+
# Target nonexistent function so no actual mutation occurs,
|
|
152
|
+
# but block check should pass
|
|
153
|
+
result = runner.invoke(cli, ["pluck", "--write", "squackit/cli.py",
|
|
154
|
+
"find", ".fn#__definitely_not_a_function__",
|
|
155
|
+
"rename", "bar"])
|
|
156
|
+
# Should not be blocked by mutation check; may succeed or fail for
|
|
157
|
+
# other reasons, but the mutation message should not appear
|
|
158
|
+
assert "chain contains mutation operations" not in result.output
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# tests/test_tools.py
|
|
2
|
+
"""Tests for squackit.tools -- pluckit-backed tool executors."""
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from squackit.tools import (
|
|
6
|
+
PLUCKIT_TOOLS,
|
|
7
|
+
view_executor,
|
|
8
|
+
find_executor,
|
|
9
|
+
find_names_executor,
|
|
10
|
+
complexity_executor,
|
|
11
|
+
pluck_executor,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestViewExecutor:
|
|
16
|
+
|
|
17
|
+
def test_returns_view_object(self):
|
|
18
|
+
from pluckit.pluckins.viewer import View
|
|
19
|
+
result = view_executor(source="squackit/**/*.py", selector=".fn")
|
|
20
|
+
assert isinstance(result, View)
|
|
21
|
+
|
|
22
|
+
def test_view_has_markdown(self):
|
|
23
|
+
result = view_executor(source="squackit/cli.py", selector=".fn#cli")
|
|
24
|
+
assert "def cli" in result.markdown
|
|
25
|
+
|
|
26
|
+
def test_view_has_blocks(self):
|
|
27
|
+
result = view_executor(source="squackit/**/*.py", selector=".fn")
|
|
28
|
+
assert len(result.blocks) > 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestFindExecutor:
|
|
32
|
+
|
|
33
|
+
def test_returns_relation(self):
|
|
34
|
+
result = find_executor(source="squackit/**/*.py", selector=".fn")
|
|
35
|
+
assert hasattr(result, "columns")
|
|
36
|
+
assert hasattr(result, "fetchall")
|
|
37
|
+
|
|
38
|
+
def test_relation_has_expected_columns(self):
|
|
39
|
+
result = find_executor(source="squackit/**/*.py", selector=".fn")
|
|
40
|
+
assert "name" in result.columns
|
|
41
|
+
assert "file_path" in result.columns
|
|
42
|
+
assert "start_line" in result.columns
|
|
43
|
+
|
|
44
|
+
def test_finds_known_function(self):
|
|
45
|
+
result = find_executor(source="squackit/cli.py", selector=".fn#cli")
|
|
46
|
+
rows = result.fetchall()
|
|
47
|
+
assert len(rows) >= 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestFindNamesExecutor:
|
|
51
|
+
|
|
52
|
+
def test_returns_list_of_strings(self):
|
|
53
|
+
result = find_names_executor(source="squackit/**/*.py", selector=".fn")
|
|
54
|
+
assert isinstance(result, list)
|
|
55
|
+
assert all(isinstance(n, str) for n in result)
|
|
56
|
+
|
|
57
|
+
def test_finds_known_function(self):
|
|
58
|
+
result = find_names_executor(source="squackit/cli.py", selector=".fn")
|
|
59
|
+
assert "cli" in result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestComplexityExecutor:
|
|
63
|
+
|
|
64
|
+
def test_returns_relation(self):
|
|
65
|
+
result = complexity_executor(source="squackit/**/*.py", selector=".fn")
|
|
66
|
+
assert hasattr(result, "columns")
|
|
67
|
+
assert "complexity" in result.columns
|
|
68
|
+
|
|
69
|
+
def test_ordered_by_complexity_desc(self):
|
|
70
|
+
result = complexity_executor(source="squackit/**/*.py", selector=".fn")
|
|
71
|
+
rows = result.fetchall()
|
|
72
|
+
cx_idx = result.columns.index("complexity")
|
|
73
|
+
complexities = [r[cx_idx] for r in rows]
|
|
74
|
+
assert complexities == sorted(complexities, reverse=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestPluckitToolsList:
|
|
78
|
+
|
|
79
|
+
def test_five_tools_defined(self):
|
|
80
|
+
assert len(PLUCKIT_TOOLS) == 5
|
|
81
|
+
|
|
82
|
+
def test_all_have_executors(self):
|
|
83
|
+
for tp in PLUCKIT_TOOLS:
|
|
84
|
+
assert tp.executor is not None
|
|
85
|
+
|
|
86
|
+
def test_tool_names(self):
|
|
87
|
+
names = {tp.name for tp in PLUCKIT_TOOLS}
|
|
88
|
+
assert names == {"view", "find", "find_names", "complexity", "pluck"}
|
|
89
|
+
|
|
90
|
+
def test_selector_tools_require_source_and_selector(self):
|
|
91
|
+
for tp in PLUCKIT_TOOLS:
|
|
92
|
+
if tp.name == "pluck":
|
|
93
|
+
continue
|
|
94
|
+
assert "source" in tp.required
|
|
95
|
+
assert "selector" in tp.required
|
|
96
|
+
|
|
97
|
+
def test_pluck_requires_argv(self):
|
|
98
|
+
pluck = next(tp for tp in PLUCKIT_TOOLS if tp.name == "pluck")
|
|
99
|
+
assert "argv" in pluck.required
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestPluckExecutor:
|
|
103
|
+
|
|
104
|
+
def test_returns_json_string(self):
|
|
105
|
+
import json
|
|
106
|
+
result = pluck_executor(argv="squackit/cli.py find .fn names")
|
|
107
|
+
parsed = json.loads(result)
|
|
108
|
+
assert "chain" in parsed
|
|
109
|
+
assert "type" in parsed
|
|
110
|
+
assert "data" in parsed
|
|
111
|
+
|
|
112
|
+
def test_find_names(self):
|
|
113
|
+
import json
|
|
114
|
+
result = pluck_executor(argv="squackit/cli.py find .fn names")
|
|
115
|
+
parsed = json.loads(result)
|
|
116
|
+
assert parsed["type"] == "names"
|
|
117
|
+
assert isinstance(parsed["data"], list)
|
|
118
|
+
assert "cli" in parsed["data"]
|
|
119
|
+
|
|
120
|
+
def test_count(self):
|
|
121
|
+
import json
|
|
122
|
+
result = pluck_executor(argv="squackit/cli.py find .fn count")
|
|
123
|
+
parsed = json.loads(result)
|
|
124
|
+
assert parsed["type"] == "count"
|
|
125
|
+
assert isinstance(parsed["data"], int)
|
|
126
|
+
assert parsed["data"] > 0
|
|
127
|
+
|
|
128
|
+
def test_view_with_plugin(self):
|
|
129
|
+
import json
|
|
130
|
+
result = pluck_executor(
|
|
131
|
+
argv="--plugin AstViewer squackit/cli.py find .fn#cli view"
|
|
132
|
+
)
|
|
133
|
+
parsed = json.loads(result)
|
|
134
|
+
assert parsed["type"] == "view"
|
|
135
|
+
# data is a dict with blocks
|
|
136
|
+
assert "blocks" in parsed["data"]
|
|
137
|
+
|
|
138
|
+
def test_empty_argv_returns_error(self):
|
|
139
|
+
import json
|
|
140
|
+
result = pluck_executor(argv="")
|
|
141
|
+
parsed = json.loads(result)
|
|
142
|
+
assert "error" in parsed
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestPluckMutationSafety:
|
|
146
|
+
|
|
147
|
+
def test_mutation_blocked_by_default(self):
|
|
148
|
+
import json
|
|
149
|
+
# rename is a mutation op
|
|
150
|
+
result = pluck_executor(argv="squackit/cli.py find .fn#nonexistent rename foo")
|
|
151
|
+
parsed = json.loads(result)
|
|
152
|
+
assert "error" in parsed
|
|
153
|
+
assert "blocked" in parsed["error"]
|
|
154
|
+
assert "rename" in parsed["mutations"]
|
|
155
|
+
|
|
156
|
+
def test_mutation_blocked_reports_all_ops(self):
|
|
157
|
+
import json
|
|
158
|
+
result = pluck_executor(
|
|
159
|
+
argv="squackit/cli.py find .fn#x rename new wrap before after"
|
|
160
|
+
)
|
|
161
|
+
parsed = json.loads(result)
|
|
162
|
+
assert "error" in parsed
|
|
163
|
+
assert "rename" in parsed["mutations"]
|
|
164
|
+
assert "wrap" in parsed["mutations"]
|
|
165
|
+
|
|
166
|
+
def test_mutation_allowed_with_flag_string(self):
|
|
167
|
+
import json
|
|
168
|
+
# Target a nonexistent fn so no actual mutation happens, but the
|
|
169
|
+
# block check should pass and evaluation should proceed
|
|
170
|
+
result = pluck_executor(
|
|
171
|
+
argv="squackit/cli.py find .fn#__definitely_not_a_function__ rename bar",
|
|
172
|
+
allow_mutations="true",
|
|
173
|
+
)
|
|
174
|
+
parsed = json.loads(result)
|
|
175
|
+
# Should NOT be blocked — may have a different error or succeed
|
|
176
|
+
assert parsed.get("error", "").startswith("blocked") is False
|
|
177
|
+
|
|
178
|
+
def test_mutation_allowed_with_flag_bool(self):
|
|
179
|
+
import json
|
|
180
|
+
result = pluck_executor(
|
|
181
|
+
argv="squackit/cli.py find .fn#__definitely_not_a_function__ rename bar",
|
|
182
|
+
allow_mutations=True,
|
|
183
|
+
)
|
|
184
|
+
parsed = json.loads(result)
|
|
185
|
+
assert parsed.get("error", "").startswith("blocked") is False
|
|
186
|
+
|
|
187
|
+
def test_non_mutation_chain_runs_normally(self):
|
|
188
|
+
import json
|
|
189
|
+
# No mutation ops — should run regardless of allow_mutations
|
|
190
|
+
result = pluck_executor(argv="squackit/cli.py find .fn names")
|
|
191
|
+
parsed = json.loads(result)
|
|
192
|
+
assert "error" not in parsed
|
|
193
|
+
assert parsed["type"] == "names"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestChainMutationOps:
|
|
197
|
+
|
|
198
|
+
def test_detects_rename(self):
|
|
199
|
+
from pluckit import Chain
|
|
200
|
+
from squackit.tools import _chain_mutation_ops
|
|
201
|
+
chain = Chain.from_argv(["squackit/cli.py", "find", ".fn", "rename", "new"])
|
|
202
|
+
assert _chain_mutation_ops(chain) == ["rename"]
|
|
203
|
+
|
|
204
|
+
def test_detects_multiple(self):
|
|
205
|
+
from pluckit import Chain
|
|
206
|
+
from squackit.tools import _chain_mutation_ops
|
|
207
|
+
chain = Chain.from_argv([
|
|
208
|
+
"squackit/cli.py", "find", ".fn", "rename", "new",
|
|
209
|
+
"wrap", "before", "after",
|
|
210
|
+
])
|
|
211
|
+
assert _chain_mutation_ops(chain) == ["rename", "wrap"]
|
|
212
|
+
|
|
213
|
+
def test_no_mutations_returns_empty(self):
|
|
214
|
+
from pluckit import Chain
|
|
215
|
+
from squackit.tools import _chain_mutation_ops
|
|
216
|
+
chain = Chain.from_argv(["squackit/cli.py", "find", ".fn", "names"])
|
|
217
|
+
assert _chain_mutation_ops(chain) == []
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class TestCollectPluckinTools:
|
|
221
|
+
"""collect_pluckin_tools walks registered pluckins and calls squackit_tools()."""
|
|
222
|
+
|
|
223
|
+
def test_empty_registry_returns_empty(self):
|
|
224
|
+
from pluckit import Plucker
|
|
225
|
+
from squackit.tools import collect_pluckin_tools
|
|
226
|
+
p = Plucker() # no plugins
|
|
227
|
+
assert collect_pluckin_tools(p) == []
|
|
228
|
+
|
|
229
|
+
def test_pluckin_without_squackit_tools_skipped(self):
|
|
230
|
+
from pluckit import Plucker
|
|
231
|
+
from pluckit.pluckins.viewer import AstViewer
|
|
232
|
+
from squackit.tools import collect_pluckin_tools
|
|
233
|
+
# AstViewer doesn't implement squackit_tools — should be skipped silently
|
|
234
|
+
p = Plucker(plugins=[AstViewer])
|
|
235
|
+
assert collect_pluckin_tools(p) == []
|
|
236
|
+
|
|
237
|
+
def test_pluckin_with_squackit_tools_collected(self):
|
|
238
|
+
from pluckit import Plucker
|
|
239
|
+
from pluckit.pluckins.base import Pluckin
|
|
240
|
+
from fledgling.tools import ToolInfo
|
|
241
|
+
from squackit.tool_config import ToolPresentation
|
|
242
|
+
from squackit.tools import collect_pluckin_tools
|
|
243
|
+
|
|
244
|
+
def exec_fn(*, x: str):
|
|
245
|
+
return [x]
|
|
246
|
+
|
|
247
|
+
sentinel = ToolPresentation(
|
|
248
|
+
info=ToolInfo(
|
|
249
|
+
macro_name="_test_sentinel",
|
|
250
|
+
params=["x"],
|
|
251
|
+
description="test tool",
|
|
252
|
+
required=["x"],
|
|
253
|
+
),
|
|
254
|
+
executor=exec_fn,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
class MyPluckin(Pluckin):
|
|
258
|
+
name = "MyPluckin"
|
|
259
|
+
methods = {}
|
|
260
|
+
|
|
261
|
+
def squackit_tools(self):
|
|
262
|
+
return [sentinel]
|
|
263
|
+
|
|
264
|
+
p = Plucker(plugins=[MyPluckin])
|
|
265
|
+
tools = collect_pluckin_tools(p)
|
|
266
|
+
assert sentinel in tools
|
|
267
|
+
|
|
268
|
+
def test_broken_squackit_tools_does_not_crash(self):
|
|
269
|
+
from pluckit import Plucker
|
|
270
|
+
from pluckit.pluckins.base import Pluckin
|
|
271
|
+
from squackit.tools import collect_pluckin_tools
|
|
272
|
+
|
|
273
|
+
class BrokenPluckin(Pluckin):
|
|
274
|
+
name = "BrokenPluckin"
|
|
275
|
+
methods = {}
|
|
276
|
+
|
|
277
|
+
def squackit_tools(self):
|
|
278
|
+
raise RuntimeError("intentional")
|
|
279
|
+
|
|
280
|
+
p = Plucker(plugins=[BrokenPluckin])
|
|
281
|
+
# Should return empty, not crash
|
|
282
|
+
assert collect_pluckin_tools(p) == []
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
# tests/test_tools.py
|
|
2
|
-
"""Tests for squackit.tools -- pluckit-backed tool executors."""
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
from squackit.tools import (
|
|
6
|
-
PLUCKIT_TOOLS,
|
|
7
|
-
view_executor,
|
|
8
|
-
find_executor,
|
|
9
|
-
find_names_executor,
|
|
10
|
-
complexity_executor,
|
|
11
|
-
pluck_executor,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class TestViewExecutor:
|
|
16
|
-
|
|
17
|
-
def test_returns_view_object(self):
|
|
18
|
-
from pluckit.plugins.viewer import View
|
|
19
|
-
result = view_executor(source="squackit/**/*.py", selector=".fn")
|
|
20
|
-
assert isinstance(result, View)
|
|
21
|
-
|
|
22
|
-
def test_view_has_markdown(self):
|
|
23
|
-
result = view_executor(source="squackit/cli.py", selector=".fn#cli")
|
|
24
|
-
assert "def cli" in result.markdown
|
|
25
|
-
|
|
26
|
-
def test_view_has_blocks(self):
|
|
27
|
-
result = view_executor(source="squackit/**/*.py", selector=".fn")
|
|
28
|
-
assert len(result.blocks) > 0
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class TestFindExecutor:
|
|
32
|
-
|
|
33
|
-
def test_returns_relation(self):
|
|
34
|
-
result = find_executor(source="squackit/**/*.py", selector=".fn")
|
|
35
|
-
assert hasattr(result, "columns")
|
|
36
|
-
assert hasattr(result, "fetchall")
|
|
37
|
-
|
|
38
|
-
def test_relation_has_expected_columns(self):
|
|
39
|
-
result = find_executor(source="squackit/**/*.py", selector=".fn")
|
|
40
|
-
assert "name" in result.columns
|
|
41
|
-
assert "file_path" in result.columns
|
|
42
|
-
assert "start_line" in result.columns
|
|
43
|
-
|
|
44
|
-
def test_finds_known_function(self):
|
|
45
|
-
result = find_executor(source="squackit/cli.py", selector=".fn#cli")
|
|
46
|
-
rows = result.fetchall()
|
|
47
|
-
assert len(rows) >= 1
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class TestFindNamesExecutor:
|
|
51
|
-
|
|
52
|
-
def test_returns_list_of_strings(self):
|
|
53
|
-
result = find_names_executor(source="squackit/**/*.py", selector=".fn")
|
|
54
|
-
assert isinstance(result, list)
|
|
55
|
-
assert all(isinstance(n, str) for n in result)
|
|
56
|
-
|
|
57
|
-
def test_finds_known_function(self):
|
|
58
|
-
result = find_names_executor(source="squackit/cli.py", selector=".fn")
|
|
59
|
-
assert "cli" in result
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
class TestComplexityExecutor:
|
|
63
|
-
|
|
64
|
-
def test_returns_relation(self):
|
|
65
|
-
result = complexity_executor(source="squackit/**/*.py", selector=".fn")
|
|
66
|
-
assert hasattr(result, "columns")
|
|
67
|
-
assert "complexity" in result.columns
|
|
68
|
-
|
|
69
|
-
def test_ordered_by_complexity_desc(self):
|
|
70
|
-
result = complexity_executor(source="squackit/**/*.py", selector=".fn")
|
|
71
|
-
rows = result.fetchall()
|
|
72
|
-
cx_idx = result.columns.index("complexity")
|
|
73
|
-
complexities = [r[cx_idx] for r in rows]
|
|
74
|
-
assert complexities == sorted(complexities, reverse=True)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
class TestPluckitToolsList:
|
|
78
|
-
|
|
79
|
-
def test_five_tools_defined(self):
|
|
80
|
-
assert len(PLUCKIT_TOOLS) == 5
|
|
81
|
-
|
|
82
|
-
def test_all_have_executors(self):
|
|
83
|
-
for tp in PLUCKIT_TOOLS:
|
|
84
|
-
assert tp.executor is not None
|
|
85
|
-
|
|
86
|
-
def test_tool_names(self):
|
|
87
|
-
names = {tp.name for tp in PLUCKIT_TOOLS}
|
|
88
|
-
assert names == {"view", "find", "find_names", "complexity", "pluck"}
|
|
89
|
-
|
|
90
|
-
def test_selector_tools_require_source_and_selector(self):
|
|
91
|
-
for tp in PLUCKIT_TOOLS:
|
|
92
|
-
if tp.name == "pluck":
|
|
93
|
-
continue
|
|
94
|
-
assert "source" in tp.required
|
|
95
|
-
assert "selector" in tp.required
|
|
96
|
-
|
|
97
|
-
def test_pluck_requires_argv(self):
|
|
98
|
-
pluck = next(tp for tp in PLUCKIT_TOOLS if tp.name == "pluck")
|
|
99
|
-
assert "argv" in pluck.required
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
class TestPluckExecutor:
|
|
103
|
-
|
|
104
|
-
def test_returns_json_string(self):
|
|
105
|
-
import json
|
|
106
|
-
result = pluck_executor(argv="squackit/cli.py find .fn names")
|
|
107
|
-
parsed = json.loads(result)
|
|
108
|
-
assert "chain" in parsed
|
|
109
|
-
assert "type" in parsed
|
|
110
|
-
assert "data" in parsed
|
|
111
|
-
|
|
112
|
-
def test_find_names(self):
|
|
113
|
-
import json
|
|
114
|
-
result = pluck_executor(argv="squackit/cli.py find .fn names")
|
|
115
|
-
parsed = json.loads(result)
|
|
116
|
-
assert parsed["type"] == "names"
|
|
117
|
-
assert isinstance(parsed["data"], list)
|
|
118
|
-
assert "cli" in parsed["data"]
|
|
119
|
-
|
|
120
|
-
def test_count(self):
|
|
121
|
-
import json
|
|
122
|
-
result = pluck_executor(argv="squackit/cli.py find .fn count")
|
|
123
|
-
parsed = json.loads(result)
|
|
124
|
-
assert parsed["type"] == "count"
|
|
125
|
-
assert isinstance(parsed["data"], int)
|
|
126
|
-
assert parsed["data"] > 0
|
|
127
|
-
|
|
128
|
-
def test_view_with_plugin(self):
|
|
129
|
-
import json
|
|
130
|
-
result = pluck_executor(
|
|
131
|
-
argv="--plugin AstViewer squackit/cli.py find .fn#cli view"
|
|
132
|
-
)
|
|
133
|
-
parsed = json.loads(result)
|
|
134
|
-
assert parsed["type"] == "view"
|
|
135
|
-
# data is a dict with blocks
|
|
136
|
-
assert "blocks" in parsed["data"]
|
|
137
|
-
|
|
138
|
-
def test_empty_argv_returns_error(self):
|
|
139
|
-
import json
|
|
140
|
-
result = pluck_executor(argv="")
|
|
141
|
-
parsed = json.loads(result)
|
|
142
|
-
assert "error" in parsed
|
|
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
|
{squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md
RENAMED
|
File without changes
|
|
File without changes
|
{squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{squackit-0.3.3 → squackit-0.4.0}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md
RENAMED
|
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
|