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.
Files changed (88) hide show
  1. {pipulate-2.44/pipulate.egg-info → pipulate-2.45}/PKG-INFO +1 -1
  2. {pipulate-2.44 → pipulate-2.45}/__init__.py +2 -2
  3. {pipulate-2.44 → pipulate-2.45}/cli.py +98 -1
  4. {pipulate-2.44 → pipulate-2.45/pipulate.egg-info}/PKG-INFO +1 -1
  5. {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/SOURCES.txt +1 -0
  6. {pipulate-2.44 → pipulate-2.45}/pyproject.toml +1 -1
  7. pipulate-2.45/tests/test_apply_airlock.py +151 -0
  8. {pipulate-2.44 → pipulate-2.45}/tools/scraper_tools.py +1 -1
  9. {pipulate-2.44 → pipulate-2.45}/LICENSE +0 -0
  10. {pipulate-2.44 → pipulate-2.45}/MANIFEST.in +0 -0
  11. {pipulate-2.44 → pipulate-2.45}/README.md +0 -0
  12. {pipulate-2.44 → pipulate-2.45}/apps/010_introduction.py +0 -0
  13. {pipulate-2.44 → pipulate-2.45}/apps/015_config.py +0 -0
  14. {pipulate-2.44 → pipulate-2.45}/apps/020_profiles.py +0 -0
  15. {pipulate-2.44 → pipulate-2.45}/apps/025_aspect.py +0 -0
  16. {pipulate-2.44 → pipulate-2.45}/apps/030_roles.py +0 -0
  17. {pipulate-2.44 → pipulate-2.45}/apps/040_hello_workflow.py +0 -0
  18. {pipulate-2.44 → pipulate-2.45}/apps/050_documentation.py +0 -0
  19. {pipulate-2.44 → pipulate-2.45}/apps/060_tasks.py +0 -0
  20. {pipulate-2.44 → pipulate-2.45}/apps/070_history.py +0 -0
  21. {pipulate-2.44 → pipulate-2.45}/apps/110_parameter_buster.py +0 -0
  22. {pipulate-2.44 → pipulate-2.45}/apps/120_link_graph.py +0 -0
  23. {pipulate-2.44 → pipulate-2.45}/apps/130_gap_analysis.py +0 -0
  24. {pipulate-2.44 → pipulate-2.45}/apps/200_workflow_genesis.py +0 -0
  25. {pipulate-2.44 → pipulate-2.45}/apps/210_widget_examples.py +0 -0
  26. {pipulate-2.44 → pipulate-2.45}/apps/220_roadmap.py +0 -0
  27. {pipulate-2.44 → pipulate-2.45}/apps/230_dev_assistant.py +0 -0
  28. {pipulate-2.44 → pipulate-2.45}/apps/240_simon_mcp.py +0 -0
  29. {pipulate-2.44 → pipulate-2.45}/apps/300_blank_placeholder.py +0 -0
  30. {pipulate-2.44 → pipulate-2.45}/apps/400_botify_trifecta.py +0 -0
  31. {pipulate-2.44 → pipulate-2.45}/apps/440_browser_automation.py +0 -0
  32. {pipulate-2.44 → pipulate-2.45}/apps/450_stream_simulator.py +0 -0
  33. {pipulate-2.44 → pipulate-2.45}/apps/510_text_field.py +0 -0
  34. {pipulate-2.44 → pipulate-2.45}/apps/520_text_area.py +0 -0
  35. {pipulate-2.44 → pipulate-2.45}/apps/530_dropdown.py +0 -0
  36. {pipulate-2.44 → pipulate-2.45}/apps/540_checkboxes.py +0 -0
  37. {pipulate-2.44 → pipulate-2.45}/apps/550_radios.py +0 -0
  38. {pipulate-2.44 → pipulate-2.45}/apps/560_range.py +0 -0
  39. {pipulate-2.44 → pipulate-2.45}/apps/570_switch.py +0 -0
  40. {pipulate-2.44 → pipulate-2.45}/apps/580_upload.py +0 -0
  41. {pipulate-2.44 → pipulate-2.45}/apps/610_markdown.py +0 -0
  42. {pipulate-2.44 → pipulate-2.45}/apps/620_mermaid.py +0 -0
  43. {pipulate-2.44 → pipulate-2.45}/apps/630_prism.py +0 -0
  44. {pipulate-2.44 → pipulate-2.45}/apps/640_javascript.py +0 -0
  45. {pipulate-2.44 → pipulate-2.45}/apps/710_pandas.py +0 -0
  46. {pipulate-2.44 → pipulate-2.45}/apps/720_rich.py +0 -0
  47. {pipulate-2.44 → pipulate-2.45}/apps/730_matplotlib.py +0 -0
  48. {pipulate-2.44 → pipulate-2.45}/apps/810_webbrowser.py +0 -0
  49. {pipulate-2.44 → pipulate-2.45}/apps/820_selenium.py +0 -0
  50. {pipulate-2.44 → pipulate-2.45}/apps/830_pico_slider.py +0 -0
  51. {pipulate-2.44 → pipulate-2.45}/config.py +0 -0
  52. {pipulate-2.44 → pipulate-2.45}/imports/__init__.py +0 -0
  53. {pipulate-2.44 → pipulate-2.45}/imports/ai_dictdb.py +0 -0
  54. {pipulate-2.44 → pipulate-2.45}/imports/ai_tool_discovery_simple_parser.py +0 -0
  55. {pipulate-2.44 → pipulate-2.45}/imports/append_only_conversation.py +0 -0
  56. {pipulate-2.44 → pipulate-2.45}/imports/ascii_displays.py +0 -0
  57. {pipulate-2.44 → pipulate-2.45}/imports/botify/__init__.py +0 -0
  58. {pipulate-2.44 → pipulate-2.45}/imports/botify/code_generators.py +0 -0
  59. {pipulate-2.44 → pipulate-2.45}/imports/botify/true_schema_discoverer.py +0 -0
  60. {pipulate-2.44 → pipulate-2.45}/imports/botify_code_generation.py +0 -0
  61. {pipulate-2.44 → pipulate-2.45}/imports/crud.py +0 -0
  62. {pipulate-2.44 → pipulate-2.45}/imports/database_safety_wrapper.py +0 -0
  63. {pipulate-2.44 → pipulate-2.45}/imports/dom_processing/__init__.py +0 -0
  64. {pipulate-2.44 → pipulate-2.45}/imports/dom_processing/ai_dom_beautifier.py +0 -0
  65. {pipulate-2.44 → pipulate-2.45}/imports/dom_processing/enhanced_dom_processor.py +0 -0
  66. {pipulate-2.44 → pipulate-2.45}/imports/durable_backup_system.py +0 -0
  67. {pipulate-2.44 → pipulate-2.45}/imports/mcp_orchestrator.py +0 -0
  68. {pipulate-2.44 → pipulate-2.45}/imports/server_logging.py +0 -0
  69. {pipulate-2.44 → pipulate-2.45}/imports/stream_orchestrator.py +0 -0
  70. {pipulate-2.44 → pipulate-2.45}/imports/voice_synthesis.py +0 -0
  71. {pipulate-2.44 → pipulate-2.45}/pipulate/__init__.py +0 -0
  72. {pipulate-2.44 → pipulate-2.45}/pipulate/core.py +0 -0
  73. {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/dependency_links.txt +0 -0
  74. {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/entry_points.txt +0 -0
  75. {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/requires.txt +0 -0
  76. {pipulate-2.44 → pipulate-2.45}/pipulate.egg-info/top_level.txt +0 -0
  77. {pipulate-2.44 → pipulate-2.45}/server.py +0 -0
  78. {pipulate-2.44 → pipulate-2.45}/setup.cfg +0 -0
  79. {pipulate-2.44 → pipulate-2.45}/tests/test_mck_rep2.py +0 -0
  80. {pipulate-2.44 → pipulate-2.45}/tools/__init__.py +0 -0
  81. {pipulate-2.44 → pipulate-2.45}/tools/advanced_automation_tools.py +0 -0
  82. {pipulate-2.44 → pipulate-2.45}/tools/botify_tools.py +0 -0
  83. {pipulate-2.44 → pipulate-2.45}/tools/conversation_tools.py +0 -0
  84. {pipulate-2.44 → pipulate-2.45}/tools/dom_tools.py +0 -0
  85. {pipulate-2.44 → pipulate-2.45}/tools/keychain_tools.py +0 -0
  86. {pipulate-2.44 → pipulate-2.45}/tools/llm_optics.py +0 -0
  87. {pipulate-2.44 → pipulate-2.45}/tools/mcp_tools.py +0 -0
  88. {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.44
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.44"
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__ = "Massaging Install Paths"
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(f"❌ No Pipulate installation found at [cyan]~/{app_name}[/cyan].")
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.44
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
@@ -72,6 +72,7 @@ pipulate.egg-info/dependency_links.txt
72
72
  pipulate.egg-info/entry_points.txt
73
73
  pipulate.egg-info/requires.txt
74
74
  pipulate.egg-info/top_level.txt
75
+ tests/test_apply_airlock.py
75
76
  tests/test_mck_rep2.py
76
77
  tools/__init__.py
77
78
  tools/advanced_automation_tools.py
@@ -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.44"
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 = [l for l in _host.split(".") if l]
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