pipulate 2.44__tar.gz → 2.45__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.
- {pipulate-2.44/pipulate.egg-info → pipulate-2.45}/PKG-INFO +1 -1
- {pipulate-2.44 → pipulate-2.45}/__init__.py +2 -2
- {pipulate-2.44 → pipulate-2.45}/cli.py +98 -1
- {pipulate-2.44 → pipulate-2.45/pipulate.egg-info}/PKG-INFO +1 -1
- {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/SOURCES.txt +1 -0
- {pipulate-2.44 → pipulate-2.45}/pyproject.toml +1 -1
- pipulate-2.45/tests/test_apply_airlock.py +151 -0
- {pipulate-2.44 → pipulate-2.45}/tools/scraper_tools.py +1 -1
- {pipulate-2.44 → pipulate-2.45}/LICENSE +0 -0
- {pipulate-2.44 → pipulate-2.45}/MANIFEST.in +0 -0
- {pipulate-2.44 → pipulate-2.45}/README.md +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/010_introduction.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/015_config.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/020_profiles.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/025_aspect.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/030_roles.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/040_hello_workflow.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/050_documentation.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/060_tasks.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/070_history.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/110_parameter_buster.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/120_link_graph.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/130_gap_analysis.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/200_workflow_genesis.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/210_widget_examples.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/220_roadmap.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/230_dev_assistant.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/240_simon_mcp.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/300_blank_placeholder.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/400_botify_trifecta.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/440_browser_automation.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/450_stream_simulator.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/510_text_field.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/520_text_area.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/530_dropdown.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/540_checkboxes.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/550_radios.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/560_range.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/570_switch.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/580_upload.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/610_markdown.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/620_mermaid.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/630_prism.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/640_javascript.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/710_pandas.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/720_rich.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/730_matplotlib.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/810_webbrowser.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/820_selenium.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/apps/830_pico_slider.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/config.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/__init__.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/ai_dictdb.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/ai_tool_discovery_simple_parser.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/append_only_conversation.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/ascii_displays.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/botify/__init__.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/botify/code_generators.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/botify/true_schema_discoverer.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/botify_code_generation.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/crud.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/database_safety_wrapper.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/dom_processing/__init__.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/dom_processing/ai_dom_beautifier.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/dom_processing/enhanced_dom_processor.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/durable_backup_system.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/mcp_orchestrator.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/server_logging.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/stream_orchestrator.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/imports/voice_synthesis.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/pipulate/__init__.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/pipulate/core.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/dependency_links.txt +0 -0
- {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/entry_points.txt +0 -0
- {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/requires.txt +0 -0
- {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/top_level.txt +0 -0
- {pipulate-2.44 → pipulate-2.45}/server.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/setup.cfg +0 -0
- {pipulate-2.44 → pipulate-2.45}/tests/test_mck_rep2.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/__init__.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/advanced_automation_tools.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/botify_tools.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/conversation_tools.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/dom_tools.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/keychain_tools.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/llm_optics.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/mcp_tools.py +0 -0
- {pipulate-2.44 → pipulate-2.45}/tools/system_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipulate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.45
|
|
4
4
|
Summary: AI-readiness for the agentic web — local-first, Nix-reproducible workflows. The successor to AI SEO software.
|
|
5
5
|
Author-email: Mike Levin <pipulate@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -12,7 +12,7 @@ Usage:
|
|
|
12
12
|
pipulate
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
-
__version__ = "2.
|
|
15
|
+
__version__ = "2.45"
|
|
16
16
|
# APOSTROPHES RESTORED (2026-08-04). They were stripped as a workaround for
|
|
17
17
|
# flake.nix's descMatch regex, whose character class excluded ' from the
|
|
18
18
|
# CAPTURE and truncated the banner to "(So)". That regex was fixed in the same
|
|
@@ -23,7 +23,7 @@ __version__ = "2.44"
|
|
|
23
23
|
# is not a property of the system; the regex is. Blast radius is one banner:
|
|
24
24
|
# nothing but flake.nix reads this name -- version_sync.py syncs __version__
|
|
25
25
|
# and __description__, never this. So'wI' chu' -- "engage the cloaking device."
|
|
26
|
-
__version_description__ = "
|
|
26
|
+
__version_description__ = "Shoshinification"
|
|
27
27
|
# SPDX expression, single source of truth, synced into pyproject.toml by
|
|
28
28
|
# scripts/release/version_sync.py. "-or-later" (not bare AGPL-3.0, which is
|
|
29
29
|
# deprecated SPDX) because the header below grants "any later version".
|
|
@@ -316,11 +316,108 @@ def run_install_script(app_name):
|
|
|
316
316
|
console.print(f"✅ Installation complete!")
|
|
317
317
|
return target_dir
|
|
318
318
|
|
|
319
|
+
# --- MARKER DISCOVERY -------------------------------------------------------
|
|
320
|
+
# THE DERIVED-PATH RULE, aimed at DISCOVERY instead of at writing:
|
|
321
|
+
# root = f(marker), never root = f(guessed name). A directory is a workshop
|
|
322
|
+
# because it CARRIES the marker, never because it is SPELLED like one.
|
|
323
|
+
#
|
|
324
|
+
# THE MARKER IS A TRACKED TRIPLE, NOT whitelabel.txt. whitelabel.txt is listed
|
|
325
|
+
# in .gitignore and is written by the flake's runScript on first entry, so a
|
|
326
|
+
# fresh `git clone` has none -- and neither does any workshop only ever entered
|
|
327
|
+
# through `.#quiet`, which skips runScript entirely. A marker that is ABSENT ON
|
|
328
|
+
# A LEGITIMATE WORKSHOP is worse than no marker at all: it turns a false
|
|
329
|
+
# negative into a confident refusal. whitelabel.txt is therefore the
|
|
330
|
+
# DISAMBIGUATOR, never the certificate -- the identical split
|
|
331
|
+
# assets/installer/mck.sh made in v0.2.0, and this is deliberately its parity
|
|
332
|
+
# implementation. Two readers, ONE definition of "workshop"; if these drift,
|
|
333
|
+
# the launcher and the CLI disagree about what is installed, silently.
|
|
334
|
+
WORKSHOP_MARKERS = ("flake.nix", "scripts/mother_cat.py", "assets/trails")
|
|
335
|
+
def is_workshop(path):
|
|
336
|
+
"""True when this directory CARRIES the marker, whatever it is named."""
|
|
337
|
+
try:
|
|
338
|
+
return all((path / marker).exists() for marker in WORKSHOP_MARKERS)
|
|
339
|
+
except OSError:
|
|
340
|
+
return False
|
|
341
|
+
def workshop_label(path):
|
|
342
|
+
"""Read the disambiguator; fall back to the directory name.
|
|
343
|
+
Case-folded because whitelabel.txt has TWO writers with TWO spellings:
|
|
344
|
+
install.sh writes the name it was handed (lowercase by default) and the
|
|
345
|
+
flake writes a capitalized literal. mck.sh records the same conviction.
|
|
346
|
+
"""
|
|
347
|
+
try:
|
|
348
|
+
label_file = path / "whitelabel.txt"
|
|
349
|
+
if label_file.is_file():
|
|
350
|
+
lines = label_file.read_text(encoding="utf-8").splitlines()
|
|
351
|
+
if lines and lines[0].strip():
|
|
352
|
+
return lines[0].strip().lower()
|
|
353
|
+
except (OSError, UnicodeDecodeError):
|
|
354
|
+
pass
|
|
355
|
+
return path.name.lower()
|
|
356
|
+
def find_workshops(app_name):
|
|
357
|
+
"""Ordered candidates; every one must still pass is_workshop().
|
|
358
|
+
Order mirrors mck.sh's find_checkouts: explicit env, then upward from CWD,
|
|
359
|
+
then ONE bounded level under the usual parents. Deliberately no recursive
|
|
360
|
+
sweep -- a CLI must not walk a stranger's home directory to find itself.
|
|
361
|
+
The guessed path rides LAST, demoted from ANSWER to CANDIDATE: the name
|
|
362
|
+
proposes a place to LOOK, the marker alone certifies a place to USE.
|
|
363
|
+
"""
|
|
364
|
+
candidates = []
|
|
365
|
+
env_root = os.environ.get("PIPULATE_ROOT")
|
|
366
|
+
if env_root:
|
|
367
|
+
candidates.append(Path(env_root).expanduser())
|
|
368
|
+
here = Path.cwd().resolve()
|
|
369
|
+
candidates.append(here)
|
|
370
|
+
candidates.extend(here.parents)
|
|
371
|
+
home = Path.home()
|
|
372
|
+
for parent in (home, home / "repos", home / "src", home / "code",
|
|
373
|
+
home / "dev", home / "Projects", home / "projects"):
|
|
374
|
+
try:
|
|
375
|
+
candidates.extend(sorted(p for p in parent.iterdir() if p.is_dir()))
|
|
376
|
+
except OSError:
|
|
377
|
+
continue
|
|
378
|
+
candidates.append(home / app_name)
|
|
379
|
+
found, seen = [], set()
|
|
380
|
+
for candidate in candidates:
|
|
381
|
+
key = str(candidate)
|
|
382
|
+
if key in seen:
|
|
383
|
+
continue
|
|
384
|
+
seen.add(key)
|
|
385
|
+
if is_workshop(candidate):
|
|
386
|
+
found.append(candidate)
|
|
387
|
+
return found
|
|
388
|
+
def resolve_workshop(app_name):
|
|
389
|
+
"""Return the selected workshop, or None. Never invents a path.
|
|
390
|
+
THE FALLBACK IS VISIBLE ON PURPOSE. Selecting by label and falling back to
|
|
391
|
+
first-found used to print the IDENTICAL line, which is how a selector went
|
|
392
|
+
unwitnessed for the entire life of a feature (SINGLE-CANDIDATE BLINDNESS,
|
|
393
|
+
convicted 2026-08-01 against this same head -n 1 shape in mck.sh). When
|
|
394
|
+
more than one workshop exists and none carries the requested label, say so
|
|
395
|
+
and name the losers, so selection and fallback print DIFFERENT things.
|
|
396
|
+
"""
|
|
397
|
+
found = find_workshops(app_name)
|
|
398
|
+
if not found:
|
|
399
|
+
return None
|
|
400
|
+
wanted = (os.environ.get("PIPULATE_WHITELABEL") or app_name).lower()
|
|
401
|
+
for candidate in found:
|
|
402
|
+
if workshop_label(candidate) == wanted:
|
|
403
|
+
return candidate
|
|
404
|
+
if len(found) > 1:
|
|
405
|
+
console.print(f"⚠️ No workshop labeled '{wanted}'; falling back to first found.")
|
|
406
|
+
for candidate in found:
|
|
407
|
+
console.print(f" • {candidate} (label: {workshop_label(candidate)})")
|
|
408
|
+
return found[0]
|
|
319
409
|
def run_pipulate(app_name):
|
|
320
410
|
"""Runs an existing Pipulate installation."""
|
|
321
411
|
target_dir = Path.home() / app_name
|
|
412
|
+
discovered = resolve_workshop(app_name)
|
|
413
|
+
if discovered is not None:
|
|
414
|
+
target_dir = discovered
|
|
322
415
|
if not (target_dir.exists() and (target_dir / "flake.nix").is_file()):
|
|
323
|
-
console.print(
|
|
416
|
+
console.print("❌ No workshop found. A workshop is a directory that CARRIES the")
|
|
417
|
+
console.print(f" marker ({', '.join(WORKSHOP_MARKERS)}), not one merely NAMED it.")
|
|
418
|
+
console.print(f" Searched: $PIPULATE_ROOT, upward from {Path.cwd()}, one level")
|
|
419
|
+
console.print(f" under {Path.home()} and its repos/src/code/dev/Projects/projects,")
|
|
420
|
+
console.print(f" then the name-derived path ~/{app_name} (which must also carry it).")
|
|
324
421
|
console.print(f"To install, run: [bold]pipulate install {app_name}[/bold]")
|
|
325
422
|
sys.exit(1)
|
|
326
423
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pipulate
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.45
|
|
4
4
|
Summary: AI-readiness for the agentic web — local-first, Nix-reproducible workflows. The successor to AI SEO software.
|
|
5
5
|
Author-email: Mike Levin <pipulate@gmail.com>
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pipulate"
|
|
7
7
|
# VERSION NOTE: Synced from pipulate/__init__.py.__version__ via python version_sync.py
|
|
8
|
-
version = "2.
|
|
8
|
+
version = "2.45"
|
|
9
9
|
authors = [
|
|
10
10
|
{ name="Mike Levin", email="pipulate@gmail.com" },
|
|
11
11
|
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Regression tests for apply.py's autolink contamination airlock.
|
|
4
|
+
|
|
5
|
+
Verifies that self-referential markdown autolinks -- the shape a chat renderer
|
|
6
|
+
produces from a bare www-prefixed host between the compiler and the model --
|
|
7
|
+
are refused before disk mutation on non-.md targets, while the deliberate .md
|
|
8
|
+
exemption is preserved.
|
|
9
|
+
|
|
10
|
+
Every forbidden signature is assembled at runtime from fragments so this file
|
|
11
|
+
never contains the literal shape apply.py refuses. Otherwise the patch that
|
|
12
|
+
creates this file would be refused by the very airlock it tests.
|
|
13
|
+
"""
|
|
14
|
+
import io
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unittest
|
|
18
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
22
|
+
sys.path.insert(0, str(REPO_ROOT))
|
|
23
|
+
|
|
24
|
+
import apply
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _make_autolink_www(domain="probe.invalid", scheme="https"):
|
|
28
|
+
prefix = "www."
|
|
29
|
+
host = prefix + domain
|
|
30
|
+
return f"[{host}]({scheme}://{host})"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _make_autolink_scheme(domain="probe.invalid", scheme="https"):
|
|
34
|
+
url = f"{scheme}://{domain}"
|
|
35
|
+
return f"[{url}]({url})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_search_replace_payload(target, search, replace):
|
|
39
|
+
s_tag = "[" * 3 + "SEARCH" + "]" * 3
|
|
40
|
+
d_tag = "[" * 3 + "DIVIDER" + "]" * 3
|
|
41
|
+
r_tag = "[" * 3 + "REPLACE" + "]" * 3
|
|
42
|
+
return f"Target: {target}\n{s_tag}\n{search}\n{d_tag}\n{replace}\n{r_tag}\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_write_file_payload(target, content):
|
|
46
|
+
w_tag = "[" * 3 + "WRITE_FILE" + "]" * 3
|
|
47
|
+
e_tag = "[" * 3 + "END_WRITE_FILE" + "]" * 3
|
|
48
|
+
return f"Target: {target}\n{w_tag}\n{content}\n{e_tag}\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestApplyAutolinkAirlock(unittest.TestCase):
|
|
52
|
+
def _apply(self, payload):
|
|
53
|
+
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
|
|
54
|
+
return apply.apply_search_replace_patch(payload)
|
|
55
|
+
|
|
56
|
+
def test_ordinary_search_replace_succeeds(self):
|
|
57
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
58
|
+
target = Path(temp_dir) / "module.py"
|
|
59
|
+
target.write_text("x = 1\n", encoding="utf-8")
|
|
60
|
+
payload = _build_search_replace_payload(target, "x = 1", "x = 2")
|
|
61
|
+
self.assertTrue(self._apply(payload))
|
|
62
|
+
self.assertEqual(target.read_text(encoding="utf-8"), "x = 2\n")
|
|
63
|
+
|
|
64
|
+
def test_contaminated_search_replace_refuses_and_preserves_bytes(self):
|
|
65
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
66
|
+
target = Path(temp_dir) / "module.py"
|
|
67
|
+
target.write_text("y = 3\n", encoding="utf-8")
|
|
68
|
+
bad_link = _make_autolink_www("airlock-test.invalid")
|
|
69
|
+
payload = _build_search_replace_payload(
|
|
70
|
+
target, "y = 3", f"# {bad_link}\ny = 4"
|
|
71
|
+
)
|
|
72
|
+
self.assertFalse(self._apply(payload))
|
|
73
|
+
self.assertEqual(target.read_text(encoding="utf-8"), "y = 3\n")
|
|
74
|
+
|
|
75
|
+
def test_clean_write_file_creation_succeeds(self):
|
|
76
|
+
# POSITIVE CONTROL for the WRITE_FILE lane. Without it, the two
|
|
77
|
+
# contaminated-WRITE_FILE tests below go green both when the airlock
|
|
78
|
+
# refuses AND when WRITE_FILE parsing silently breaks -- the
|
|
79
|
+
# no-blocks-found path also returns False and writes nothing. This
|
|
80
|
+
# test fails independently if the WRITE_FILE lane stops parsing, which
|
|
81
|
+
# is what lets the refusals below indict the airlock rather than a
|
|
82
|
+
# parse miss. Sibling of the constitution's SUCCESS-ONLY WITNESS: the
|
|
83
|
+
# WRITE_FILE success branch was otherwise unobserved.
|
|
84
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
85
|
+
target = Path(temp_dir) / "fresh_module.py"
|
|
86
|
+
payload = _build_write_file_payload(target, "created = True\n")
|
|
87
|
+
self.assertTrue(self._apply(payload))
|
|
88
|
+
self.assertTrue(target.exists())
|
|
89
|
+
self.assertEqual(target.read_text(encoding="utf-8"), "created = True\n")
|
|
90
|
+
|
|
91
|
+
def test_contaminated_write_file_creation_refuses_and_creates_no_target(self):
|
|
92
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
93
|
+
target = Path(temp_dir) / "new_module.py"
|
|
94
|
+
bad_link = _make_autolink_www("airlock-create.invalid")
|
|
95
|
+
payload = _build_write_file_payload(target, f"# {bad_link}\nz = 10\n")
|
|
96
|
+
self.assertFalse(self._apply(payload))
|
|
97
|
+
self.assertFalse(target.exists())
|
|
98
|
+
|
|
99
|
+
def test_contaminated_write_file_overwrite_refuses_and_preserves_bytes(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
101
|
+
target = Path(temp_dir) / "existing_module.py"
|
|
102
|
+
target.write_text("status = 'unmodified'\n", encoding="utf-8")
|
|
103
|
+
bad_link = _make_autolink_www("airlock-overwrite.invalid")
|
|
104
|
+
payload = _build_write_file_payload(
|
|
105
|
+
target, f"# {bad_link}\nstatus = 'corrupted'\n"
|
|
106
|
+
)
|
|
107
|
+
self.assertFalse(self._apply(payload))
|
|
108
|
+
self.assertEqual(
|
|
109
|
+
target.read_text(encoding="utf-8"), "status = 'unmodified'\n"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def test_both_regex_alternatives_are_refused(self):
|
|
113
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
114
|
+
target_www = Path(temp_dir) / "alt_www.py"
|
|
115
|
+
target_www.write_text("val = 100\n", encoding="utf-8")
|
|
116
|
+
link_www = _make_autolink_www("branch-one.invalid")
|
|
117
|
+
payload_www = _build_search_replace_payload(
|
|
118
|
+
target_www, "val = 100", f"# {link_www}\nval = 101"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
target_scheme = Path(temp_dir) / "alt_scheme.py"
|
|
122
|
+
target_scheme.write_text("val = 200\n", encoding="utf-8")
|
|
123
|
+
link_scheme = _make_autolink_scheme("branch-two.invalid")
|
|
124
|
+
payload_scheme = _build_search_replace_payload(
|
|
125
|
+
target_scheme, "val = 200", f"# {link_scheme}\nval = 201"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self.assertFalse(self._apply(payload_www))
|
|
129
|
+
self.assertFalse(self._apply(payload_scheme))
|
|
130
|
+
self.assertEqual(target_www.read_text(encoding="utf-8"), "val = 100\n")
|
|
131
|
+
self.assertEqual(target_scheme.read_text(encoding="utf-8"), "val = 200\n")
|
|
132
|
+
|
|
133
|
+
def test_markdown_files_are_deliberately_exempt(self):
|
|
134
|
+
# The .md exemption is intentional: self-referential markdown links are
|
|
135
|
+
# legitimate in articles and documentation, so a blanket refusal there
|
|
136
|
+
# would make normal .md files uneditable. A green here requires the
|
|
137
|
+
# airlock to SKIP the check for .md, so the exemption is witnessed, not
|
|
138
|
+
# merely assumed.
|
|
139
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
140
|
+
target = Path(temp_dir) / "document.md"
|
|
141
|
+
target.write_text("# Documentation\n\nOriginal draft.\n", encoding="utf-8")
|
|
142
|
+
link_md = _make_autolink_www("doc-reference.invalid")
|
|
143
|
+
payload = _build_search_replace_payload(
|
|
144
|
+
target, "Original draft.", f"Updated draft with reference: {link_md}"
|
|
145
|
+
)
|
|
146
|
+
self.assertTrue(self._apply(payload))
|
|
147
|
+
self.assertIn(link_md, target.read_text(encoding="utf-8"))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
unittest.main()
|
|
@@ -345,7 +345,7 @@ async def _selenium_capture(params: dict, checkpoint=None) -> dict:
|
|
|
345
345
|
# defaults, 2026-07-29) skipped the bind, and urlparse(final_url)
|
|
346
346
|
# after the CAPTURE checkpoint died with UnboundLocalError.
|
|
347
347
|
_host = urlparse(params.get("url", "")).netloc.split(":")[0]
|
|
348
|
-
_labels = [
|
|
348
|
+
_labels = [label for label in _host.split(".") if label]
|
|
349
349
|
_slug = _labels[-2] if len(_labels) >= 2 else (_labels[0] if _labels else "")
|
|
350
350
|
if _slug and Path(f"data/uc_profiles/{_slug}").exists():
|
|
351
351
|
profile_name = _slug
|
|
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
|
|
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
|
|
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
|