treestamp 0.1.2__tar.gz → 0.2.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.
Files changed (49) hide show
  1. {treestamp-0.1.2 → treestamp-0.2.0}/CHANGELOG.md +14 -0
  2. {treestamp-0.1.2 → treestamp-0.2.0}/PKG-INFO +10 -3
  3. {treestamp-0.1.2 → treestamp-0.2.0}/README.md +9 -2
  4. {treestamp-0.1.2 → treestamp-0.2.0}/pyproject.toml +1 -1
  5. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/codec.py +68 -173
  6. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/document.py +42 -16
  7. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_codec.py +122 -36
  8. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_document.py +87 -4
  9. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_editor.py +128 -16
  10. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_output_bytes.py +1 -1
  11. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_tui.py +9 -4
  12. {treestamp-0.1.2 → treestamp-0.2.0}/.gitignore +0 -0
  13. {treestamp-0.1.2 → treestamp-0.2.0}/.pre-commit-config.yaml +0 -0
  14. {treestamp-0.1.2 → treestamp-0.2.0}/.pre-commit-hooks.yaml +0 -0
  15. {treestamp-0.1.2 → treestamp-0.2.0}/.python-version +0 -0
  16. {treestamp-0.1.2 → treestamp-0.2.0}/LICENSE +0 -0
  17. {treestamp-0.1.2 → treestamp-0.2.0}/Makefile +0 -0
  18. {treestamp-0.1.2 → treestamp-0.2.0}/PRIVACY.md +0 -0
  19. {treestamp-0.1.2 → treestamp-0.2.0}/docs/MAINTENANCE.md +0 -0
  20. {treestamp-0.1.2 → treestamp-0.2.0}/scripts/changed_tests.py +0 -0
  21. {treestamp-0.1.2 → treestamp-0.2.0}/scripts/check_docs.py +0 -0
  22. {treestamp-0.1.2 → treestamp-0.2.0}/scripts/distribution_check.py +0 -0
  23. {treestamp-0.1.2 → treestamp-0.2.0}/scripts/hook_check.py +0 -0
  24. {treestamp-0.1.2 → treestamp-0.2.0}/scripts/installed_editor_check.py +0 -0
  25. {treestamp-0.1.2 → treestamp-0.2.0}/scripts/release_check.py +0 -0
  26. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/__init__.py +0 -0
  27. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/__main__.py +0 -0
  28. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/cli.py +0 -0
  29. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/editor.py +0 -0
  30. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/errors.py +0 -0
  31. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/gitio.py +0 -0
  32. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/maintenance.py +0 -0
  33. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/model.py +0 -0
  34. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/py.typed +0 -0
  35. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/session.py +0 -0
  36. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/sources.py +0 -0
  37. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/tui.py +0 -0
  38. {treestamp-0.1.2 → treestamp-0.2.0}/src/treestamp/tui_dialogs.py +0 -0
  39. {treestamp-0.1.2 → treestamp-0.2.0}/tests/__init__.py +0 -0
  40. {treestamp-0.1.2 → treestamp-0.2.0}/tests/conftest.py +0 -0
  41. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_changed_tests.py +0 -0
  42. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_cli.py +0 -0
  43. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_gitio.py +0 -0
  44. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_maintenance.py +0 -0
  45. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_paths.py +0 -0
  46. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_release_check.py +0 -0
  47. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_session.py +0 -0
  48. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_sources.py +0 -0
  49. {treestamp-0.1.2 → treestamp-0.2.0}/tests/test_tui_dialogs.py +0 -0
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/reponomics/treestamp/compare/v0.1.2...v0.2.0) (2026-10-29)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * Documents with path-only special_paths registrations must be regenerated; documents without special registrations remain supported.
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * Replace special-path identity backtracking with direct row-number-plus-path registrations.
14
+
15
+ * fix potential DoS threat and improve docs ([#15](https://github.com/reponomics/treestamp/issues/15)) ([f3c77eb](https://github.com/reponomics/treestamp/commit/f3c77eb03a45d22fa47c9fcd7a8fe02339587dd6))
16
+
3
17
  ## [0.1.2](https://github.com/reponomics/treestamp/compare/v0.1.1...v0.1.2) (2026-10-28)
4
18
 
5
19
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: treestamp
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Generate and maintain directory trees for project documentation
5
5
  Project-URL: Homepage, https://treestamp.reponomics.org
6
6
  Project-URL: Documentation, https://github.com/reponomics/treestamp#readme
@@ -36,6 +36,13 @@ Description-Content-Type: text/markdown
36
36
  </picture>
37
37
  </div>
38
38
 
39
+ <br>
40
+
41
+ ![OSSF-Scorecard Score](https://img.shields.io/ossf-scorecard/github.com/reponomics/treestamp?style=flat&label=OpenSSF%20Scorecard)
42
+ [![OpenSSF Baseline](https://www.bestpractices.dev/projects/15900/baseline)](https://www.bestpractices.dev/projects/15900)
43
+ [![CodeQL](https://github.com/reponomics/treestamp/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/reponomics/treestamp/actions/workflows/github-code-scanning/codeql)
44
+
45
+
39
46
  # treestamp
40
47
 
41
48
  Turn your repository into a directory tree for your README. Choose what to show, add short notes, and copy the result—from your terminal or browser.
@@ -165,7 +172,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
165
172
  ./
166
173
  ├── .github/ (8 files, 2 folders)
167
174
  ├── assets/ (2 files, 1 folder)
168
- ├── docs/ (4 files)
175
+ ├── docs/ (6 files)
169
176
  ├── scripts/ (6 files) # Installed-package acceptance
170
177
  ├── src/
171
178
  │ └── treestamp/ # Shared model and terminal editor
@@ -198,7 +205,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
198
205
 
199
206
  ## Development and support
200
207
 
201
- [Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
208
+ [Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Verifying releases](https://github.com/reponomics/treestamp/blob/main/docs/verifying-releases.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
202
209
 
203
210
  Report bugs through [GitHub issues](https://github.com/reponomics/treestamp/issues), vulnerabilities through [private security reporting](https://github.com/reponomics/treestamp/security/advisories/new), and conduct concerns to [conduct@reponomics.org](mailto:conduct@reponomics.org).
204
211
 
@@ -4,6 +4,13 @@
4
4
  </picture>
5
5
  </div>
6
6
 
7
+ <br>
8
+
9
+ ![OSSF-Scorecard Score](https://img.shields.io/ossf-scorecard/github.com/reponomics/treestamp?style=flat&label=OpenSSF%20Scorecard)
10
+ [![OpenSSF Baseline](https://www.bestpractices.dev/projects/15900/baseline)](https://www.bestpractices.dev/projects/15900)
11
+ [![CodeQL](https://github.com/reponomics/treestamp/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/reponomics/treestamp/actions/workflows/github-code-scanning/codeql)
12
+
13
+
7
14
  # treestamp
8
15
 
9
16
  Turn your repository into a directory tree for your README. Choose what to show, add short notes, and copy the result—from your terminal or browser.
@@ -133,7 +140,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
133
140
  ./
134
141
  ├── .github/ (8 files, 2 folders)
135
142
  ├── assets/ (2 files, 1 folder)
136
- ├── docs/ (4 files)
143
+ ├── docs/ (6 files)
137
144
  ├── scripts/ (6 files) # Installed-package acceptance
138
145
  ├── src/
139
146
  │ └── treestamp/ # Shared model and terminal editor
@@ -166,7 +173,7 @@ hidden = [".dockerignore", ".gitignore", ".nvmrc", ".pre-commit-config.yaml", ".
166
173
 
167
174
  ## Development and support
168
175
 
169
- [Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
176
+ [Contributing](https://github.com/reponomics/treestamp/blob/main/.github/CONTRIBUTING.md) · [Maintenance](https://github.com/reponomics/treestamp/blob/main/docs/MAINTENANCE.md) · [Governance](https://github.com/reponomics/treestamp/blob/main/docs/GOVERNANCE.md) · [Design](https://github.com/reponomics/treestamp/blob/main/docs/design.md) · [Security assessment](https://github.com/reponomics/treestamp/blob/main/docs/security-assessment.md) · [Verifying releases](https://github.com/reponomics/treestamp/blob/main/docs/verifying-releases.md) · [Changelog](https://github.com/reponomics/treestamp/blob/main/CHANGELOG.md) · [Privacy](https://github.com/reponomics/treestamp/blob/main/PRIVACY.md)
170
177
 
171
178
  Report bugs through [GitHub issues](https://github.com/reponomics/treestamp/issues), vulnerabilities through [private security reporting](https://github.com/reponomics/treestamp/security/advisories/new), and conduct concerns to [conduct@reponomics.org](mailto:conduct@reponomics.org).
172
179
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "treestamp"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Generate and maintain directory trees for project documentation"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -11,7 +11,6 @@ Counts and targets are regenerated facts, not saved curation.
11
11
  from __future__ import annotations
12
12
 
13
13
  import re
14
- from contextlib import suppress
15
14
  from dataclasses import replace
16
15
  from typing import TYPE_CHECKING
17
16
 
@@ -31,11 +30,10 @@ from treestamp.model import (
31
30
  )
32
31
 
33
32
  if TYPE_CHECKING:
34
- from collections.abc import Iterator
33
+ from collections.abc import Mapping
35
34
 
36
35
  SYMLINK_ARROW = "->"
37
36
  GITLINK_TAG = "[gitlink]"
38
- MAX_IDENTITY_BRANCHES = 4096
39
37
 
40
38
  # A row is one line, so control characters print as C escapes, and a byte that
41
39
  # is not UTF-8 (a surrogate, from decoding with "surrogateescape") prints as its
@@ -137,13 +135,13 @@ def _row_head(node: Node, *, markdown: bool = False) -> str:
137
135
  return text
138
136
 
139
137
 
140
- def special_paths(tree: Node, root: str = ".") -> frozenset[str]:
141
- """Full identities for visible entries whose own names reserve the delimiter."""
142
- return frozenset(
143
- repo_path(node.path, root)
144
- for node in tree.walk()
138
+ def special_paths(tree: Node, root: str = ".") -> dict[int, str]:
139
+ """Map printed row indices to exact repository-relative special identities."""
140
+ return {
141
+ row: repo_path(node.path, root)
142
+ for row, node in enumerate(tree.walk(), 1)
145
143
  if ANNOTATION_DELIMITER in node.name
146
- )
144
+ }
147
145
 
148
146
 
149
147
  def _row(node: Node, *, markdown: bool = False) -> str:
@@ -159,12 +157,22 @@ def render_tree(tree: Node, settings: Settings, *, fenced: bool = False) -> str:
159
157
 
160
158
  Bare output is for display. Reading it requires its generated special paths.
161
159
  """
160
+ return render_tree_with_paths(tree, settings, fenced=fenced)[0]
161
+
162
+
163
+ def render_tree_with_paths(
164
+ tree: Node, settings: Settings, *, fenced: bool = False
165
+ ) -> tuple[str, dict[int, str]]:
166
+ """Emit rows and their special-path registrations in the same traversal."""
162
167
  markdown = settings.style == "list"
163
168
  validate_tree(tree)
164
169
  rows = [_row(tree, markdown=markdown)]
170
+ registered: dict[int, str] = {}
165
171
 
166
172
  def visit(node: Node, prefix: str, depth: int) -> None:
167
173
  for index, child in enumerate(node.children):
174
+ if ANNOTATION_DELIMITER in child.name:
175
+ registered[len(rows)] = repo_path(child.path, settings.root)
168
176
  last = index == len(node.children) - 1
169
177
  if settings.style == "list":
170
178
  rows.append(" " * (depth - 1) + "- " + _row(child, markdown=True))
@@ -178,12 +186,12 @@ def render_tree(tree: Node, settings: Settings, *, fenced: bool = False) -> str:
178
186
 
179
187
  visit(tree, "", 1)
180
188
  text = "\n".join(rows) + "\n"
181
- _check_rendered_tree(tree, text, settings)
189
+ _check_rendered_tree(tree, text, settings, registered)
182
190
  if fenced and not markdown:
183
191
  # These styles prefix every physical row with './' or a connector, so
184
192
  # embedded backticks cannot close the fence. New styles own their framing.
185
- return f"```sh\n{text}```\n"
186
- return text
193
+ text = f"```sh\n{text}```\n"
194
+ return text, registered
187
195
 
188
196
 
189
197
  def _facts(node: Node) -> tuple[object, ...]:
@@ -197,13 +205,15 @@ def _facts(node: Node) -> tuple[object, ...]:
197
205
  )
198
206
 
199
207
 
200
- def _check_rendered_tree(tree: Node, text: str, settings: Settings) -> None:
208
+ def _check_rendered_tree(
209
+ tree: Node, text: str, settings: Settings, registered: Mapping[int, str]
210
+ ) -> None:
201
211
  try:
202
212
  recovered = parse_tree(
203
213
  text,
204
214
  settings.style,
205
215
  root=settings.root,
206
- special_paths=special_paths(tree, settings.root),
216
+ special_paths=registered,
207
217
  )
208
218
  except MarkerError as exc:
209
219
  raise MarkerError(
@@ -312,37 +322,42 @@ def detect_style(text: str, *, fenced: bool = False) -> str:
312
322
  return "unicode" if fenced else "list"
313
323
 
314
324
 
315
- def _registered_candidates(
316
- text: str, parent: str, registered: frozenset[str], *, markdown: bool
317
- ) -> list[Node]:
318
- candidates = []
319
- for path in sorted(registered):
320
- name = path.rsplit("/", 1)[-1]
321
- printed = _display(name, markdown)
322
- if not text.startswith(printed):
323
- continue
324
- suffix = text[len(printed) :]
325
- if suffix and not suffix.startswith(
326
- ("/", " [gitlink]", " -> ", ANNOTATION_DELIMITER)
327
- ):
328
- continue
329
- try:
330
- node = _parse_row("entry" + suffix, parent, markdown=markdown)
331
- except MarkerError:
332
- continue
333
- if node.name == "entry":
334
- candidates.append(replace(node, name=name, path=path))
335
- with suppress(MarkerError):
336
- candidates.append(_parse_row(text, parent, markdown=markdown))
337
- return list(dict.fromkeys(candidates))
338
-
339
-
340
- def _registrations(paths: frozenset[str], root: str) -> frozenset[str]:
341
- registered = frozenset(tree_path(path, root) for path in paths)
342
- if any(ANNOTATION_DELIMITER not in path.rsplit("/", 1)[-1] for path in registered):
343
- raise MarkerError(
344
- "special_paths must register a name containing the annotation delimiter"
345
- )
325
+ def _registered_row(text: str, parent: str, path: str, *, markdown: bool) -> Node:
326
+ """Consume one exact registered name; only its suffix remains to parse."""
327
+ directory, _, name = path.rpartition("/")
328
+ if directory != parent:
329
+ raise MarkerError("special-path parent does not match the row's indentation")
330
+ printed = _display(name, markdown)
331
+ if not text.startswith(printed):
332
+ raise MarkerError("special-path name does not match its registered row")
333
+ suffix = text[len(printed) :]
334
+ if suffix and not suffix.startswith(
335
+ ("/", " [gitlink]", " -> ", ANNOTATION_DELIMITER)
336
+ ):
337
+ raise MarkerError("invalid suffix after registered special-path name")
338
+ node = _parse_row("entry" + suffix, parent, markdown=markdown)
339
+ if node.name != "entry":
340
+ raise MarkerError("invalid suffix after registered special-path name")
341
+ return replace(node, name=name, path=path)
342
+
343
+
344
+ def _registrations(
345
+ paths: Mapping[int, str], root: str, row_count: int
346
+ ) -> dict[int, str]:
347
+ registered: dict[int, str] = {}
348
+ seen: set[str] = set()
349
+ for row, path in paths.items():
350
+ if type(row) is not int or not 0 < row < row_count:
351
+ raise MarkerError("special-path row must identify an existing non-root row")
352
+ relative = tree_path(path, root)
353
+ if ANNOTATION_DELIMITER not in relative.rsplit("/", 1)[-1]:
354
+ raise MarkerError(
355
+ "special_paths must register a name containing the annotation delimiter"
356
+ )
357
+ if relative in seen:
358
+ raise MarkerError("special_paths contains duplicate paths")
359
+ seen.add(relative)
360
+ registered[row] = relative
346
361
  return registered
347
362
 
348
363
 
@@ -355,138 +370,16 @@ def _row_parent(stack: list[Node], depth: int) -> Node:
355
370
  return parent
356
371
 
357
372
 
358
- def _indexed_rows(lines: list[str], style: str) -> list[tuple[int, str]]:
359
- """Resolve indentation independently of the names assigned to each row."""
360
- rows = []
361
- stack = [0]
362
- for index, line in enumerate(lines, 1):
363
- depth, text = _depth_row(line, style)
364
- if depth > len(stack):
365
- raise MarkerError("tree indentation skips a parent")
366
- stack = stack[:depth]
367
- rows.append((stack[-1], text))
368
- stack.append(index)
369
- return rows
370
-
371
-
372
- def _assembled_tree(rows: list[tuple[int, str]], chosen: list[Node]) -> Node:
373
- nodes = list(chosen)
374
- children: dict[int, list[int]] = {}
375
- for index, (parent, _) in enumerate(rows, 1):
376
- children.setdefault(parent, []).append(index)
377
- for index in reversed(range(len(nodes))):
378
- nodes[index] = replace(
379
- nodes[index],
380
- children=tuple(nodes[child] for child in children.get(index, [])),
381
- )
382
- validate_tree(nodes[0])
383
- return nodes[0]
384
-
385
-
386
- class _IdentityAssignments:
387
- """Bounded backtracking over complete assignments, without recursive depth.
388
-
389
- A registered prefix may also be an ordinary name plus an annotation. Only
390
- full registration usage and the complete tree can resolve that ambiguity.
391
- Ordinary rows add no branches and require no copies of the accumulated tree.
392
- """
393
-
394
- def __init__(
395
- self,
396
- root: Node,
397
- rows: list[tuple[int, str]],
398
- registered: frozenset[str],
399
- *,
400
- markdown: bool,
401
- ) -> None:
402
- self.rows, self.registered, self.markdown = rows, registered, markdown
403
- self.chosen = [root]
404
- self.paths = {""}
405
- self.branches = 0
406
- self.by_parent: dict[str, set[str]] = {}
407
- for path in registered:
408
- self.by_parent.setdefault(path.rpartition("/")[0], set()).add(path)
409
- self.choices: list[Iterator[Node]] = [self._choices()] if rows else []
410
-
411
- def _choices(self) -> Iterator[Node]:
412
- parent_index, text = self.rows[len(self.chosen) - 1]
413
- parent = self.chosen[parent_index]
414
- if not parent.is_dir:
415
- return iter(())
416
- registered = frozenset(self.by_parent.get(parent.path, ()))
417
- candidates = _registered_candidates(
418
- text, parent.path, registered, markdown=self.markdown
419
- )
420
- remaining = len(self.rows) - len(self.chosen)
421
- candidates = [
422
- node
423
- for node in candidates
424
- if node.path not in self.paths
425
- and len(self.registered - self.paths - {node.path}) <= remaining
426
- ]
427
- if len(candidates) > 1:
428
- self.branches += 1
429
- if self.branches > MAX_IDENTITY_BRANCHES:
430
- raise MarkerError(
431
- "special-path identity resolution exceeds the search limit"
432
- )
433
- return iter(candidates)
434
-
435
- def _pop(self) -> None:
436
- self.paths.remove(self.chosen.pop().path)
437
-
438
- def _advance(self) -> bool:
439
- while self.choices:
440
- node = next(self.choices[-1], None)
441
- if node is not None:
442
- self.chosen.append(node)
443
- self.paths.add(node.path)
444
- return True
445
- self.choices.pop()
446
- if len(self.chosen) > 1:
447
- self._pop()
448
- return False
449
-
450
- def trees(self) -> Iterator[Node]:
451
- while self._advance():
452
- if len(self.chosen) <= len(self.rows):
453
- self.choices.append(self._choices())
454
- continue
455
- try:
456
- tree = _assembled_tree(self.rows, self.chosen)
457
- except MarkerError:
458
- tree = None
459
- if tree is not None:
460
- yield tree
461
- self._pop()
462
-
463
-
464
- def _registered_tree(
465
- root: Node, lines: list[str], style: str, registered: frozenset[str]
466
- ) -> Node:
467
- assignments = _IdentityAssignments(
468
- root, _indexed_rows(lines, style), registered, markdown=style == "list"
469
- ).trees()
470
- tree = next(assignments, None)
471
- if tree is None:
472
- raise MarkerError(
473
- "special_paths contains unused, mismatched, or invalid identities"
474
- )
475
- if next(assignments, None) is not None:
476
- raise MarkerError("ambiguous special-path identity in printed tree")
477
- return tree
478
-
479
-
480
373
  def parse_tree(
481
374
  text: str,
482
375
  style: str | None = None,
483
376
  *,
484
377
  root: str = ".",
485
- special_paths: frozenset[str] = frozenset(),
378
+ special_paths: Mapping[int, str] | None = None,
486
379
  ) -> Node:
487
380
  """Read structured identities and facts, using registrations before rows."""
488
- registered = _registrations(special_paths, root)
489
381
  lines = text.splitlines()
382
+ registered = _registrations(special_paths or {}, root, len(lines))
490
383
  if not lines:
491
384
  raise MarkerError("tree is empty")
492
385
  if style is None:
@@ -495,16 +388,18 @@ def parse_tree(
495
388
  raise MarkerError(f"unknown tree style: {style!r}")
496
389
  markdown = style == "list"
497
390
  root_node = _parse_row(lines[0], "", root=True, markdown=markdown)
498
- if registered:
499
- return _registered_tree(root_node, lines[1:], style, registered)
500
391
  nodes = {"": root_node}
501
392
  children: dict[str, list[str]] = {"": []}
502
393
  stack = [root_node]
503
- for line in lines[1:]:
394
+ for index, line in enumerate(lines[1:], 1):
504
395
  depth, row = _depth_row(line, style)
505
396
  parent = _row_parent(stack, depth)
506
397
  stack = stack[:depth]
507
- node = _parse_row(row, parent.path, markdown=markdown)
398
+ node = (
399
+ _registered_row(row, parent.path, registered[index], markdown=markdown)
400
+ if index in registered
401
+ else _parse_row(row, parent.path, markdown=markdown)
402
+ )
508
403
  if node.path in nodes:
509
404
  raise MarkerError(f"duplicate tree path: {node.path!r}")
510
405
  nodes[node.path] = node
@@ -12,7 +12,7 @@ from dataclasses import dataclass, replace
12
12
  from pathlib import Path
13
13
  from typing import Literal, overload
14
14
 
15
- from treestamp.codec import detect_style, parse_tree, render_tree, special_paths
15
+ from treestamp.codec import detect_style, parse_tree, render_tree_with_paths
16
16
  from treestamp.errors import MarkerError
17
17
  from treestamp.gitio import checked_path, document_path
18
18
  from treestamp.model import Node, Settings, read_path_value, tree_path
@@ -169,7 +169,30 @@ def _path_array(value: object, key: str, root: str) -> frozenset[str]:
169
169
  return frozenset(paths)
170
170
 
171
171
 
172
- def _metadata(text: str) -> tuple[Settings, frozenset[str], frozenset[str]]:
172
+ def _special_path_record(item: object) -> tuple[int, object]:
173
+ if isinstance(item, str) or isinstance(item, dict) and set(item) == {"git_bytes"}:
174
+ raise MarkerError("legacy special_paths format; regenerate this treestamp")
175
+ if not isinstance(item, dict) or set(item) != {"row", "path"}:
176
+ raise MarkerError("each special_paths record must contain row and path")
177
+ row = item["row"]
178
+ if type(row) is not int or row <= 0:
179
+ raise MarkerError("special-path row must be a positive integer")
180
+ return row, item["path"]
181
+
182
+
183
+ def _special_path_array(value: object) -> dict[int, str]:
184
+ if not isinstance(value, list):
185
+ raise MarkerError("special_paths must be an array of row/path records")
186
+ result: dict[int, str] = {}
187
+ for item in value:
188
+ row, path = _special_path_record(item)
189
+ if row in result:
190
+ raise MarkerError("special_paths contains duplicate rows")
191
+ result[row] = read_path_value(path)
192
+ return result
193
+
194
+
195
+ def _metadata(text: str) -> tuple[Settings, frozenset[str], dict[int, str]]:
173
196
  try:
174
197
  values = tomllib.loads(text)
175
198
  except tomllib.TOMLDecodeError as exc:
@@ -179,9 +202,7 @@ def _metadata(text: str) -> tuple[Settings, frozenset[str], frozenset[str]]:
179
202
  raise MarkerError(f"unknown document settings: {', '.join(sorted(unknown))}")
180
203
  settings = Settings(root=read_path_value(values.get("root", ".")))
181
204
  hidden = _path_array(values.get("hidden", []), "hidden", settings.root)
182
- special = _path_array(
183
- values.get("special_paths", []), "special_paths", settings.root
184
- )
205
+ special = _special_path_array(values.get("special_paths", []))
185
206
  return settings, hidden, special
186
207
 
187
208
 
@@ -220,7 +241,7 @@ def _parse_interior(interior: bytes) -> Document:
220
241
  )
221
242
  settings = Settings()
222
243
  hidden: frozenset[str] = frozenset()
223
- special: frozenset[str] = frozenset()
244
+ special: dict[int, str] = {}
224
245
  if text.startswith(CONFIG_START):
225
246
  payload, text = _split_metadata(text)
226
247
  settings, hidden, special = _metadata(payload)
@@ -311,17 +332,20 @@ def normalized_hidden(document: Document) -> frozenset[str]:
311
332
  return frozenset(result)
312
333
 
313
334
 
314
- def _settings_text(document: Document) -> str:
335
+ def _settings_text(document: Document, registered: dict[int, str]) -> str:
315
336
  rows = []
316
337
  if document.settings.root != ".":
317
338
  rows.append(f"root = {_path_literal(document.settings.root)}")
318
- for key, paths in (
319
- ("hidden", normalized_hidden(document)),
320
- ("special_paths", special_paths(document.tree, document.settings.root)),
321
- ):
322
- if paths:
323
- values = ", ".join(_path_literal(path) for path in sorted(paths))
324
- rows.append(f"{key} = [{values}]")
339
+ hidden = normalized_hidden(document)
340
+ if hidden:
341
+ values = ", ".join(_path_literal(path) for path in sorted(hidden))
342
+ rows.append(f"hidden = [{values}]")
343
+ if registered:
344
+ values = ", ".join(
345
+ f"{{row = {row}, path = {_path_literal(path)}}}"
346
+ for row, path in registered.items()
347
+ )
348
+ rows.append(f"special_paths = [{values}]")
325
349
  return "".join(row + "\n" for row in rows)
326
350
 
327
351
 
@@ -329,8 +353,10 @@ def render_interior(document: Document, root: str | None = None) -> str:
329
353
  """Render the interior using repository-relative config identities."""
330
354
  if root is not None:
331
355
  document = replace(document, settings=replace(document.settings, root=root))
332
- payload = _settings_text(document)
333
- body = render_tree(document.tree, document.settings, fenced=True)
356
+ body, registered = render_tree_with_paths(
357
+ document.tree, document.settings, fenced=True
358
+ )
359
+ payload = _settings_text(document, registered)
334
360
  output = f"{CONFIG_START}\n{payload}-->\n{body}" if payload else body
335
361
  # Validate framing and metadata together before any bytes can be published.
336
362
  _parse_interior(output.encode("utf-8", "surrogateescape"))
@@ -426,7 +426,7 @@ def test_ordinary_names_need_no_quotes_or_extra_marker(style: str) -> None:
426
426
  tree = build_tree(Entry(name) for name in names)
427
427
  settings = Settings(style=style)
428
428
  printed = render_tree(tree, settings)
429
- assert special_paths(tree) == frozenset()
429
+ assert special_paths(tree) == {}
430
430
  for name in names:
431
431
  assert name in printed
432
432
  assert parse_tree(printed, style) == tree
@@ -507,16 +507,14 @@ def test_special_directory_establishes_identity_for_ordinary_children(
507
507
  )
508
508
  settings = Settings(root="src", style=style)
509
509
  paths = special_paths(tree, settings.root)
510
- assert paths == frozenset(
511
- {
512
- "src/notes # drafts",
513
- "src/notes # drafts/another # draft.md",
514
- }
515
- )
510
+ assert paths == {
511
+ 1: "src/notes # drafts",
512
+ 4: "src/notes # drafts/another # draft.md",
513
+ }
516
514
  text = render_tree(tree, settings)
517
515
  assert parse_tree(text, style, root="src", special_paths=paths) == tree
518
516
  collapsed = replace(tree, children=(replace(tree.children[0], children=()),))
519
- assert special_paths(collapsed, "src") == frozenset({"src/notes # drafts"})
517
+ assert special_paths(collapsed, "src") == {1: "src/notes # drafts"}
520
518
 
521
519
 
522
520
  @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
@@ -543,14 +541,14 @@ def test_special_name_roundtrip_preserves_bytes_and_controls(
543
541
  @pytest.mark.parametrize(
544
542
  "paths",
545
543
  [
546
- frozenset({"unused # path"}),
547
- frozenset({"ordinary"}),
548
- frozenset({"other/a # b"}),
549
- frozenset({"../escape # path"}),
544
+ {1: "unused # path"},
545
+ {1: "ordinary"},
546
+ {1: "other/a # b"},
547
+ {1: "../escape # path"},
550
548
  ],
551
549
  )
552
550
  def test_special_registrations_must_match_visible_scoped_names(
553
- paths: frozenset[str],
551
+ paths: dict[int, str],
554
552
  ) -> None:
555
553
  with pytest.raises(MarkerError):
556
554
  parse_tree("./\n└── ordinary\n", special_paths=paths)
@@ -558,7 +556,7 @@ def test_special_registrations_must_match_visible_scoped_names(
558
556
 
559
557
  @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
560
558
  @pytest.mark.parametrize("reverse", [False, True])
561
- def test_special_name_prefixes_resolve_using_all_registered_identities(
559
+ def test_special_name_prefixes_resolve_using_row_identities(
562
560
  style: str, reverse: bool
563
561
  ) -> None:
564
562
  tree = build_tree([Entry("x # y"), Entry("x # y # z")])
@@ -569,7 +567,7 @@ def test_special_name_prefixes_resolve_using_all_registered_identities(
569
567
 
570
568
 
571
569
  @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
572
- def test_special_parent_assignment_uses_descendant_registrations(style: str) -> None:
570
+ def test_special_parents_and_children_resolve_by_row(style: str) -> None:
573
571
  tree = build_tree(
574
572
  [Entry("x # y/a # b"), Entry("x # y # z/a # b"), Entry("ordinary")]
575
573
  )
@@ -578,28 +576,22 @@ def test_special_parent_assignment_uses_descendant_registrations(style: str) ->
578
576
  assert parse_tree(printed, style, special_paths=special_paths(tree)) == tree
579
577
 
580
578
 
581
- def test_multiple_complete_special_identity_assignments_are_rejected() -> None:
582
- with pytest.raises(MarkerError, match="ambiguous special-path"):
583
- parse_tree(
584
- "./\n├── x # y\n└── x # y\n",
585
- special_paths=frozenset({"x # y"}),
586
- )
587
-
588
-
589
- def test_special_identity_search_limit_is_an_explicit_error(
590
- monkeypatch: pytest.MonkeyPatch,
579
+ @pytest.mark.parametrize("registered_row", [1, 2])
580
+ def test_identical_printed_rows_have_exact_registered_identities(
581
+ registered_row: int,
591
582
  ) -> None:
592
- monkeypatch.setattr("treestamp.codec.MAX_IDENTITY_BRANCHES", 0)
593
- with pytest.raises(
594
- MarkerError, match="identity resolution exceeds the search limit"
595
- ):
596
- parse_tree(
597
- "./\n├── x # y\n└── ordinary\n",
598
- special_paths=frozenset({"x # y"}),
599
- )
583
+ tree = parse_tree(
584
+ "./\n├── x # y\n└── x # y\n",
585
+ special_paths={registered_row: "x # y"},
586
+ )
587
+ special = tree.children[registered_row - 1]
588
+ ordinary = tree.children[2 - registered_row]
589
+ assert (special.path, special.annotation) == ("x # y", "")
590
+ assert (ordinary.path, ordinary.annotation) == ("x", "y")
600
591
 
601
592
 
602
- def test_filename_and_annotation_overlap_cannot_duplicate_registered_identity() -> None:
593
+ @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
594
+ def test_filename_and_annotation_overlap_keeps_registered_identity(style: str) -> None:
603
595
  tree = Node(
604
596
  ".",
605
597
  "",
@@ -609,8 +601,102 @@ def test_filename_and_annotation_overlap_cannot_duplicate_registered_identity()
609
601
  Node("x # y", "x # y", "file"),
610
602
  ),
611
603
  )
612
- with pytest.raises(MarkerError, match="cannot represent"):
613
- render_tree(tree, Settings())
604
+ printed = render_tree(tree, Settings(style=style))
605
+ assert parse_tree(printed, style, special_paths=special_paths(tree)) == tree
606
+
607
+
608
+ @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
609
+ def test_registered_prefix_file_and_directory_remain_distinct(style: str) -> None:
610
+ tree = Node(
611
+ ".",
612
+ "",
613
+ "dir",
614
+ children=(
615
+ Node("x # y", "x # y", "file", annotation="z"),
616
+ Node(
617
+ "x # y # z",
618
+ "x # y # z",
619
+ "dir",
620
+ children=(Node("ordinary", "x # y # z/ordinary", "file"),),
621
+ ),
622
+ ),
623
+ )
624
+ settings = Settings(root="src", style=style)
625
+ printed = render_tree(tree, settings)
626
+ assert (
627
+ parse_tree(printed, style, root="src", special_paths=special_paths(tree, "src"))
628
+ == tree
629
+ )
630
+
631
+
632
+ @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
633
+ def test_registered_rows_preserve_types_targets_and_counts(style: str) -> None:
634
+ tree = Node(
635
+ ".",
636
+ "",
637
+ "dir",
638
+ children=(
639
+ Node(
640
+ "dir # name", "dir # name", "dir", files=3, dirs=2, annotation="note"
641
+ ),
642
+ Node("link # name", "link # name", "symlink", target="dir/file"),
643
+ Node(
644
+ "module # name",
645
+ "module # name",
646
+ "gitlink",
647
+ annotation="nested # note",
648
+ ),
649
+ ),
650
+ )
651
+ printed = render_tree(tree, Settings(style=style))
652
+ assert parse_tree(printed, style, special_paths=special_paths(tree)) == tree
653
+
654
+
655
+ @pytest.mark.parametrize(
656
+ "paths",
657
+ [
658
+ {0: "x # y"},
659
+ {-1: "x # y"},
660
+ {3: "x # y"},
661
+ {True: "x # y"},
662
+ {1: "x # y", 2: "x # y"},
663
+ {1: "parent/x # y"},
664
+ ],
665
+ )
666
+ def test_invalid_row_registrations_are_rejected(paths: dict[int, str]) -> None:
667
+ with pytest.raises(MarkerError):
668
+ parse_tree("./\n├── x # y\n└── ordinary\n", special_paths=paths)
669
+
670
+
671
+ @pytest.mark.parametrize("suffix", ["extra", " [gitlink]extra", "/junk"])
672
+ def test_registered_name_requires_a_valid_suffix(suffix: str) -> None:
673
+ with pytest.raises(MarkerError):
674
+ parse_tree(f"./\n└── x # y{suffix}\n", special_paths={1: "x # y"})
675
+
676
+
677
+ @pytest.mark.parametrize("ordinary_count", [400, 6400])
678
+ def test_special_rows_do_not_revisit_following_ordinary_rows(
679
+ ordinary_count: int, monkeypatch: pytest.MonkeyPatch
680
+ ) -> None:
681
+ from treestamp import codec
682
+
683
+ names = [f"special{i:02d} # suffix" for i in range(10)]
684
+ rows = [*names, *(f"ordinary{i:04d}" for i in range(ordinary_count))]
685
+ text = "./\n" + "".join(f"├── {name}\n" for name in rows)
686
+ original = codec._parse_row
687
+ calls = 0
688
+
689
+ def counted(
690
+ text: str, parent: str, *, root: bool = False, markdown: bool = False
691
+ ) -> Node:
692
+ nonlocal calls
693
+ calls += 1
694
+ return original(text, parent, root=root, markdown=markdown)
695
+
696
+ monkeypatch.setattr(codec, "_parse_row", counted)
697
+ parsed = parse_tree(text, special_paths=dict(enumerate(names, 1)))
698
+ assert [node.path for node in parsed.children] == rows
699
+ assert calls == len(rows) + 1
614
700
 
615
701
 
616
702
  @pytest.mark.parametrize("target", ["ordinary # target", "null"])
@@ -407,8 +407,8 @@ def test_config_is_only_curation_and_minimal_special_identities(style: str) -> N
407
407
  assert 'root = "src"' in region
408
408
  assert 'hidden = ["src/hidden"]' in region
409
409
  assert (
410
- 'special_paths = ["src/notes # drafts", "src/notes # drafts/a # b"]'
411
- in region
410
+ 'special_paths = [{row = 1, path = "src/notes # drafts"}, '
411
+ '{row = 2, path = "src/notes # drafts/a # b"}]' in region
412
412
  )
413
413
  assert "style =" not in region and "show_counts =" not in region
414
414
  assert parse_document(region.encode()) == source
@@ -424,6 +424,15 @@ def test_root_only_style_and_count_inference(style: str) -> None:
424
424
  assert loaded.settings.show_counts is True
425
425
 
426
426
 
427
+ @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
428
+ def test_more_than_100_special_entries_roundtrip(style: str) -> None:
429
+ names = [f"file{i:03d} # suffix" for i in range(120)]
430
+ document = Document(
431
+ build_tree([Entry(name) for name in names]), Settings(style=style)
432
+ )
433
+ assert parse_document(render_region(document).encode()) == document
434
+
435
+
427
436
  def test_count_visibility_inferred_without_config() -> None:
428
437
  from treestamp.model import Node
429
438
 
@@ -455,6 +464,75 @@ def test_invalid_metadata_never_reconstructs_another_identity(payload: str) -> N
455
464
  parse_document(content.encode())
456
465
 
457
466
 
467
+ @pytest.mark.parametrize(
468
+ "records",
469
+ [
470
+ '[{row = 1, path = "x # y"}, {row = 1, path = "z # q"}]',
471
+ '[{row = 1, path = "x # y"}, {row = 2, path = "x # y"}]',
472
+ '[{row = true, path = "x # y"}]',
473
+ '[{row = 1.0, path = "x # y"}]',
474
+ '[{row = "1", path = "x # y"}]',
475
+ '[{row = 0, path = "x # y"}]',
476
+ '[{row = -1, path = "x # y"}]',
477
+ '[{row = 3, path = "x # y"}]',
478
+ "[{row = 1}]",
479
+ '[{path = "x # y"}]',
480
+ '[{row = 1, path = "x # y", extra = 0}]',
481
+ "[{row = 1, path = 4}]",
482
+ '[{row = 1, path = {git_bytes = "invalid"}}]',
483
+ '[{row = 1, path = "other # name"}]',
484
+ '[{row = 1, path = "parent/x # y"}]',
485
+ '[{row = 1, path = "../x # y"}]',
486
+ '[{row = 1, path = "ordinary"}]',
487
+ "[4]",
488
+ '{row = 1, path = "x # y"}',
489
+ ],
490
+ )
491
+ def test_invalid_special_row_metadata_is_rejected(records: str) -> None:
492
+ content = (
493
+ f"<!-- treestamp:start -->\n<!-- treestamp:config\nspecial_paths = {records}\n"
494
+ "-->\n```\n./\n├── x # y\n└── z # q\n```\n<!-- treestamp:end -->\n"
495
+ ).encode()
496
+ with pytest.raises(MarkerError):
497
+ parse_document(content)
498
+
499
+
500
+ @pytest.mark.parametrize(
501
+ "records",
502
+ ['["x # y"]', '[{git_bytes = "782020232079"}]'],
503
+ )
504
+ def test_legacy_special_metadata_requires_regeneration(records: str) -> None:
505
+ content = (
506
+ f"<!-- treestamp:start -->\n<!-- treestamp:config\nspecial_paths = {records}\n"
507
+ "-->\n```\n./\n└── x # y\n```\n<!-- treestamp:end -->\n"
508
+ ).encode()
509
+ with pytest.raises(MarkerError, match="legacy special_paths format; regenerate"):
510
+ parse_document(content)
511
+
512
+
513
+ @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
514
+ @pytest.mark.parametrize("eol", ["\n", "\r\n"])
515
+ def test_manual_annotations_preserve_registered_row_identities(
516
+ style: str, eol: str
517
+ ) -> None:
518
+ source = Document(
519
+ build_tree([Entry("x"), Entry("x # y"), Entry("x # y # z")]),
520
+ Settings(style=style),
521
+ )
522
+ region = render_region(source).replace("\n", eol)
523
+ # Change only row annotations; leave the generated registrations untouched.
524
+ edited = region.replace("x" + eol, "x # y" + eol).replace(
525
+ "x # y # z" + eol, "x # y # z # another # note" + eol
526
+ )
527
+ content = ("# Outside the region" + eol * 3 + edited + eol + "After.").encode()
528
+ loaded = parse_document(content)
529
+ assert {node.path: node.annotation for node in loaded.tree.walk()} == {
530
+ "x": "y",
531
+ "x # y": "",
532
+ "x # y # z": "another # note",
533
+ }
534
+
535
+
458
536
  def test_hidden_cannot_overlap_printed_rows_or_live_under_a_collapsed_directory() -> (
459
537
  None
460
538
  ):
@@ -474,11 +552,16 @@ def test_region_extraction_preserves_original_framing_and_endings() -> None:
474
552
  extract_region(b"ordinary document")
475
553
 
476
554
 
477
- def test_byte_special_paths_roundtrip_without_redundant_descendants() -> None:
555
+ @pytest.mark.parametrize("style", ["unicode", "ascii", "list"])
556
+ @pytest.mark.parametrize("root", [".", "src # root"])
557
+ def test_byte_special_paths_roundtrip_without_redundant_descendants(
558
+ style: str, root: str
559
+ ) -> None:
478
560
  source = Document(
479
561
  build_tree(
480
562
  [Entry("bad\udcff # dir/plain"), Entry("bad\udcff # dir/child # name")]
481
- )
563
+ ),
564
+ Settings(root=root, style=style),
482
565
  )
483
566
  content = render_region(source).encode()
484
567
  assert b"git_bytes" in content
@@ -1,10 +1,17 @@
1
1
  """Fixed-snapshot editor journeys and transactional document writes."""
2
2
 
3
+ import tomllib
3
4
  from dataclasses import replace
4
5
 
5
6
  import pytest
6
7
 
7
- from treestamp.document import Document, load_document, render_region, write_document
8
+ from treestamp.document import (
9
+ Document,
10
+ load_document,
11
+ parse_document,
12
+ render_region,
13
+ write_document,
14
+ )
8
15
  from treestamp.editor import OPEN_UPDATED_NOTICE, EditorController
9
16
  from treestamp.errors import MarkerError
10
17
  from treestamp.gitio import capture_snapshot
@@ -190,21 +197,113 @@ def test_noncanonical_open_is_dirty_even_when_semantics_unchanged(repo: Repo) ->
190
197
  assert destination.read_bytes() == before
191
198
 
192
199
 
193
- def test_special_paths_derive_from_printed_entries(repo: Repo) -> None:
194
- repo.write("docs # drafts/notes # draft.md")
200
+ def region_metadata(region: str) -> dict[str, object]:
201
+ _, separator, remainder = region.partition("<!-- treestamp:config\n")
202
+ return tomllib.loads(remainder.partition("-->\n")[0]) if separator else {}
203
+
204
+
205
+ def test_special_rows_follow_expansion_hiding_and_selected_root(repo: Repo) -> None:
206
+ for path in (
207
+ "src/docs # drafts/a.txt",
208
+ "src/docs # drafts/notes # draft.md",
209
+ "src/z # last.txt",
210
+ ):
211
+ repo.write(path)
195
212
  repo.add(".")
196
- editor = EditorController(capture_snapshot(repo.path), Settings())
213
+ editor = EditorController(
214
+ capture_snapshot(repo.path / "src", repository=repo.path),
215
+ Settings(root="src"),
216
+ )
197
217
  session = editor.state.session
198
- session.expand("docs # drafts")
199
- session.set_annotation("docs # drafts/notes # draft.md", "Keep # and ##")
218
+ collapsed_rows = [
219
+ {"row": 1, "path": "src/docs # drafts"},
220
+ {"row": 2, "path": "src/z # last.txt"},
221
+ ]
222
+ assert region_metadata(editor.region_text())["special_paths"] == collapsed_rows
223
+ with editor.edit():
224
+ session.expand("docs # drafts")
225
+ session.set_annotation("docs # drafts/notes # draft.md", "Keep # literal")
200
226
  expanded = editor.region_text()
201
- assert '"docs # drafts/notes # draft.md"' in expanded
202
- session.collapse("docs # drafts")
203
- assert '"docs # drafts/notes # draft.md"' not in editor.region_text()
204
- session.expand("docs # drafts")
227
+ assert region_metadata(expanded)["special_paths"] == [
228
+ {"row": 1, "path": "src/docs # drafts"},
229
+ {"row": 3, "path": "src/docs # drafts/notes # draft.md"},
230
+ {"row": 4, "path": "src/z # last.txt"},
231
+ ]
232
+ with editor.edit():
233
+ session.collapse("docs # drafts")
234
+ assert region_metadata(editor.region_text())["special_paths"] == collapsed_rows
235
+ with editor.edit():
236
+ session.expand("docs # drafts")
237
+ assert editor.region_text() == expanded
238
+ with editor.edit():
239
+ session.hide("docs # drafts/notes # draft.md")
240
+ hidden = region_metadata(editor.region_text())
241
+ assert hidden["hidden"] == ["src/docs # drafts/notes # draft.md"]
242
+ assert hidden["special_paths"] == [
243
+ {"row": 1, "path": "src/docs # drafts"},
244
+ {"row": 3, "path": "src/z # last.txt"},
245
+ ]
246
+ session.show_hidden = True
247
+ assert region_metadata(editor.region_text()) == hidden
248
+ with editor.edit():
249
+ session.unhide("docs # drafts/notes # draft.md")
205
250
  assert editor.region_text() == expanded
206
- session.hide("docs # drafts")
207
- assert "special_paths" not in editor.region_text()
251
+ with editor.edit():
252
+ session.hide("docs # drafts")
253
+ hidden_parent = region_metadata(editor.region_text())
254
+ assert hidden_parent["hidden"] == ["src/docs # drafts"]
255
+ assert hidden_parent["special_paths"] == [{"row": 1, "path": "src/z # last.txt"}]
256
+ with editor.edit():
257
+ session.unhide("docs # drafts")
258
+ assert editor.region_text() == expanded
259
+
260
+
261
+ def test_new_editor_preflights_and_expands_more_than_100_special_entries(
262
+ repo: Repo,
263
+ ) -> None:
264
+ names = [f"docs/file{i:03d} # draft" for i in range(101)]
265
+ for name in names:
266
+ repo.write(name)
267
+ repo.add(".")
268
+ editor = EditorController(capture_snapshot(repo.path), Settings())
269
+ assert "special_paths" not in region_metadata(editor.region_text())
270
+ with editor.edit():
271
+ editor.state.session.expand("docs")
272
+ assert region_metadata(editor.region_text())["special_paths"] == [
273
+ {"row": row, "path": path} for row, path in enumerate(names, 2)
274
+ ]
275
+ editor.create("tree.md")
276
+ loaded, _ = load_document(repo.path / "tree.md")
277
+ assert loaded == editor.document()
278
+
279
+
280
+ def test_load_reindexes_special_rows_after_new_source_entries(repo: Repo) -> None:
281
+ repo.write("z # draft.md")
282
+ repo.add(".")
283
+ editor = EditorController(capture_snapshot(repo.path), Settings())
284
+ with editor.edit():
285
+ editor.state.session.set_annotation("z # draft.md", "retain # this")
286
+ editor.create("tree.md")
287
+ original = repo.read("tree.md")
288
+ assert region_metadata(original)["special_paths"] == [
289
+ {"row": 1, "path": "z # draft.md"}
290
+ ]
291
+ repo.write("a # new.md")
292
+ repo.add("a # new.md")
293
+ editor.adopt(editor.prepare_load("tree.md"))
294
+ assert repo.read("tree.md") == original
295
+ assert region_metadata(editor.region_text())["special_paths"] == [
296
+ {"row": 1, "path": "a # new.md"},
297
+ {"row": 2, "path": "z # draft.md"},
298
+ ]
299
+ parsed = parse_document(editor.region_text().encode())
300
+ assert {node.path: node.annotation for node in parsed.tree.walk()} == {
301
+ "a # new.md": "",
302
+ "z # draft.md": "retain # this",
303
+ }
304
+ assert editor.dirty
305
+ editor.write("tree.md")
306
+ assert not editor.prepare_load("tree.md").dirty
208
307
 
209
308
 
210
309
  @pytest.mark.parametrize("changed", ["count", "target"])
@@ -301,16 +400,29 @@ def test_failed_source_preflight_preserves_loaded_draft(repo: Repo) -> None:
301
400
  assert repo.read("tree.md") == before
302
401
 
303
402
 
304
- def test_candidate_annotation_rolls_back_complete_session(repo: Repo) -> None:
403
+ def test_annotation_can_duplicate_a_special_filename_without_changing_identity(
404
+ repo: Repo,
405
+ ) -> None:
305
406
  repo.write("x")
306
407
  repo.write("x # y")
307
408
  repo.add(".")
308
409
  editor = EditorController(capture_snapshot(repo.path), Settings())
309
410
  session = editor.state.session
310
- before = editor.region_text()
311
- with pytest.raises(MarkerError, match="ambiguous special-path"), editor.edit():
411
+ with editor.edit():
312
412
  session.set_annotation("x", "y")
313
- assert editor.region_text() == before and not session.annotations
413
+ parsed = parse_document(editor.region_text().encode())
414
+ assert {node.path: node.annotation for node in parsed.tree.walk()} == {
415
+ "x": "y",
416
+ "x # y": "",
417
+ }
418
+ assert region_metadata(editor.region_text())["special_paths"] == [
419
+ {"row": 2, "path": "x # y"}
420
+ ]
421
+ before = editor.region_text()
422
+ with pytest.raises(MarkerError, match="single line"), editor.edit():
423
+ session.hide("x")
424
+ session.set_annotation("x # y", "invalid\nannotation")
425
+ assert editor.region_text() == before
314
426
 
315
427
 
316
428
  def test_preflight_only_checks_selected_subtree(repo: Repo) -> None:
@@ -110,7 +110,7 @@ def test_headless_hash_collision_update_and_check_preserve_authored_notes(
110
110
  repo.write("notes # draft.md")
111
111
  repo.add("notes # draft.md")
112
112
  output = cli_bytes(repo, "--print")
113
- assert b'special_paths = ["notes # draft.md"]' in output
113
+ assert b'special_paths = [{row = 3, path = "notes # draft.md"}]' in output
114
114
  output = output.replace(
115
115
  b"notes # draft.md\n", b"notes # draft.md # Keep # this note\n"
116
116
  )
@@ -520,17 +520,22 @@ def test_invalid_annotation_keeps_draft_and_prompt_for_correction(repo: Repo) ->
520
520
  async with app.run_test() as pilot:
521
521
  await press(pilot, "down")
522
522
  field = await prompt(pilot, "a")
523
- field.value = "y"
523
+ field.value = "invalid\x00note"
524
524
  await press(pilot, "enter")
525
525
  assert isinstance(app.screen, PromptScreen)
526
- assert app.screen.query_one(Input).value == "y"
526
+ assert app.screen.query_one(Input).value == "invalid\x00note"
527
527
  assert "draft retained" in rendered_text(app.status_widget)
528
528
  assert editor.region_text() == before
529
529
  assert app.cursor.path == "x"
530
- app.screen.query_one(Input).value = "valid note"
530
+ app.screen.query_one(Input).value = "y"
531
531
  await press(pilot, "enter")
532
532
  assert not isinstance(app.screen, PromptScreen)
533
- assert editor.state.session.annotations["x"] == "valid note"
533
+ assert editor.state.session.annotations["x"] == "y"
534
+ loaded = parse_document(editor.region_text().encode())
535
+ assert {node.path: node.annotation for node in loaded.tree.walk()} == {
536
+ "x": "y",
537
+ "x # y": "",
538
+ }
534
539
 
535
540
  asyncio.run(journey())
536
541
 
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