Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a7ea060
ci: make test imports deterministic and scope web lint exception
sanskarIN Aug 26, 2026
8db9434
ci: install web dependencies in regression gate
sanskarIN Aug 26, 2026
04ed22b
style: format project synchronization helper
sanskarIN Aug 26, 2026
4b1d89b
style(ui): wrap synchronization accessibility copy
sanskarIN Aug 26, 2026
18c08aa
style(ui): wrap project sync guidance
sanskarIN Aug 26, 2026
691d7d1
style(tests): normalize UNO process-group imports
sanskarIN Aug 26, 2026
3c58edb
style(tests): sort OOXML risk imports
sanskarIN Aug 26, 2026
666c04a
style(tests): satisfy canonical URL assertion lint
sanskarIN Aug 26, 2026
474bbc3
lint: preserve explicit UI checkpoint guard
sanskarIN Aug 26, 2026
5c66288
style(tests): apply Ruff import grouping for UNO test
sanskarIN Aug 26, 2026
9cb9821
style(tests): apply Ruff OOXML import ordering
sanskarIN Aug 26, 2026
5d234f1
ci: align formatting gate with Ruff pre-commit formatter
sanskarIN Aug 26, 2026
a3db25d
build: remove conflicting Black formatter dependency
sanskarIN Aug 26, 2026
a6f4694
ci: pin Ruff to the pre-commit formatter revision
sanskarIN Aug 26, 2026
60c5245
ci: add one-shot release formatting helper
sanskarIN Aug 26, 2026
8cbb509
style: normalize repository with Ruff formatter
github-actions[bot] Aug 26, 2026
f37d0cc
ci: remove one-shot release formatting helper
sanskarIN Aug 26, 2026
81aa1a2
ci: align project sync safety with Ruff formatting
sanskarIN Aug 26, 2026
bf30c82
fix(types): define logging handler once before fallback
sanskarIN Aug 26, 2026
50ab841
test: create real files in scanner pruning fixture
sanskarIN Aug 26, 2026
ed06d33
fix(types): avoid logging handler name reuse
sanskarIN Aug 26, 2026
7ce2359
ci: add one-shot archived link correction
sanskarIN Aug 26, 2026
4692e6b
docs: fix archived history link
github-actions[bot] Aug 26, 2026
a4309ab
ci: document canonical Ruff quality gate
sanskarIN Aug 26, 2026
6c46e74
fix(docx): reject same-path Word roundtrip before overwrite guard
sanskarIN Aug 26, 2026
5cd919b
ci: add one-shot release checklist correction
sanskarIN Aug 26, 2026
af67a4e
ci: force-add tracked release checklist correction
sanskarIN Aug 26, 2026
c8bbd70
docs: align release checklist with Ruff formatter
github-actions[bot] Aug 26, 2026
b897966
ci: finalize Ruff quality-gate documentation
sanskarIN Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/project-sync-safety.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ jobs:

steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
Expand All @@ -54,7 +54,7 @@ jobs:

- name: Check focused formatting
run: >-
python -m black --check --diff
python -m ruff format --check
src/docmergeforge/project/sync.py
src/docmergeforge/project/drift.py
src/docmergeforge/project/discovery.py
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ jobs:
- run: python -m pip install --upgrade pip
- run: pip install -e ".[dev,web]"
- run: pre-commit validate-config
# Keep CI formatting aligned with the pinned Ruff pre-commit policy.
- run: ruff check .
- run: black --check --diff .
- run: ruff format --check .
- run: mypy src/docmergeforge
- run: python scripts/check_docs_links.py
- run: python scripts/check_repository_reference.py
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/regression.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y libegl1
- run: python -m pip install --upgrade pip
- run: pip install -e ".[dev]"
- run: pip install -e ".[dev,web]"
- run: python scripts/generate_120_fixture.py fixtures/generated/sql-120
- run: pytest -m "regression or integration" tests/regression tests/integration
- run: docmergeforge validate --input fixtures/generated/sql-120 --parts 1-120
2 changes: 1 addition & 1 deletion docs/build/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ At the intended release commit:
- [ ] Quality workflow green.
- [ ] `pre-commit validate-config` green.
- [ ] Ruff green.
- [ ] Black green.
- [ ] Ruff formatting green.
- [ ] strict mypy green.
- [ ] repository-local Markdown link integrity green.
- [ ] full pytest green.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# What Changed

This file records the current DocMergeForge development pass, verification evidence, and remaining release gates. Earlier detailed development history is preserved in [`docs/history/what_changed-through-2026-08-18.md`](docs/history/what_changed-through-2026-08-18.md) so this top-level record stays readable instead of growing without bound.
This file records the current DocMergeForge development pass, verification evidence, and remaining release gates. Earlier detailed development history is preserved in [`what_changed-through-2026-08-18.md`](what_changed-through-2026-08-18.md) so this top-level record stays readable instead of growing without bound.

An item is not treated as finished merely because code was pushed. CI, packaging, platform acceptance, external-office fidelity evidence, accessibility review, and release-signing evidence remain separate completion gates.

Expand Down
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ Funding = "https://buymeacoffee.com/sanskarIN"
dev = [
"pytest>=8.3",
"pytest-cov>=6",
"ruff>=0.9",
"black>=25.1",
"ruff==0.16.3",
"mypy>=1.15",
"pre-commit>=4",
"types-setuptools",
Expand All @@ -73,6 +72,7 @@ packages = ["src/docmergeforge"]

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-q --strict-markers"
markers = [
"integration: tests requiring document libraries",
Expand All @@ -86,9 +86,9 @@ target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM", "C4"]

[tool.black]
line-length = 100
target-version = ["py312"]
[tool.ruff.lint.per-file-ignores]
"src/docmergeforge/ui/main.py" = ["SIM103"]
"src/docmergeforge/web/app.py" = ["E501"]

[tool.mypy]
python_version = "3.12"
Expand Down
4 changes: 1 addition & 3 deletions scripts/check_docx_fidelity_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ def build_fixture(path: Path) -> None:
document = Document()
document.core_properties.title = "DocMergeForge Fidelity Acceptance"
document.add_heading("Fidelity Acceptance", level=1)
document.add_paragraph(
"Representative smoke content with bold, italic, and list formatting."
)
document.add_paragraph("Representative smoke content with bold, italic, and list formatting.")
formatted = document.add_paragraph()
formatted.add_run("Bold text").bold = True
formatted.add_run(" and ")
Expand Down
3 changes: 1 addition & 2 deletions scripts/generate_120_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ def main() -> int:
zf.writestr("example.sql", f"-- Part {part}\nSELECT {part};\n")

print(
"Generated 120 PDF, 120 DOCX, and 120 independent companion ZIP fixtures "
f"in {args.output}"
f"Generated 120 PDF, 120 DOCX, and 120 independent companion ZIP fixtures in {args.output}"
)
return 0

Expand Down
4 changes: 1 addition & 3 deletions src/docmergeforge/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,7 @@ def _run_direct_merge(args: argparse.Namespace) -> int:
"ready": False,
"missing": validation_result.missing_parts,
"duplicates": validation_result.duplicate_parts,
"diagnostics": [
item.to_dict() for item in validation_result.diagnostics
],
"diagnostics": [item.to_dict() for item in validation_result.diagnostics],
},
indent=2,
)
Expand Down
13 changes: 6 additions & 7 deletions src/docmergeforge/diagnostics/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
r"(?P=quote)?"
)
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
_AUTH_HEADER_PATTERN = re.compile(
r"(?i)\bAuthorization\s*:\s*(?:Basic|Bearer)\s+[^\s,;]+"
)
_AUTH_HEADER_PATTERN = re.compile(r"(?i)\bAuthorization\s*:\s*(?:Basic|Bearer)\s+[^\s,;]+")
_API_KEY_HEADER_PATTERN = re.compile(r"(?i)\b(?:X-)?Api-Key\s*:\s*[^\s,;]+")


Expand Down Expand Up @@ -57,13 +55,14 @@ def configure_logging(path: Path, level: str = "INFO") -> logging.Logger:
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
logger.propagate = False

for handler in list(logger.handlers):
handler.close()
logger.removeHandler(handler)
for existing_handler in list(logger.handlers):
existing_handler.close()
logger.removeHandler(existing_handler)

handler: logging.Handler
try:
path.parent.mkdir(parents=True, exist_ok=True)
handler: logging.Handler = RotatingFileHandler(
handler = RotatingFileHandler(
path,
maxBytes=5 * 1024 * 1024,
backupCount=3,
Expand Down
4 changes: 1 addition & 3 deletions src/docmergeforge/discovery/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ def _iter_directory(
for directory, directory_names, file_names in os.walk(root, followlinks=False):
directory_path = Path(directory)
directory_names[:] = [
name
for name in directory_names
if not _is_excluded(directory_path / name, excluded)
name for name in directory_names if not _is_excluded(directory_path / name, excluded)
]
for name in file_names:
path = directory_path / name
Expand Down
2 changes: 1 addition & 1 deletion src/docmergeforge/docx/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def merge(
diagnostics = validate_docx_package(temporary)
if any(diag.level.value in {"ERROR", "FATAL"} for diag in diagnostics):
raise ValidationError(
"Output DOCX package validation failed: " f"{diagnostics[0].message}"
f"Output DOCX package validation failed: {diagnostics[0].message}"
)

Document(str(temporary))
Expand Down
12 changes: 3 additions & 9 deletions src/docmergeforge/docx/fidelity_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,8 @@ def snapshot_docx_structure(path: Path) -> DocxStructureSnapshot:
inline_shapes=len(document.inline_shapes),
sections=len(document.sections),
headings=headings,
header_paragraphs=sum(
len(section.header.paragraphs) for section in document.sections
),
footer_paragraphs=sum(
len(section.footer.paragraphs) for section in document.sections
),
header_paragraphs=sum(len(section.header.paragraphs) for section in document.sections),
footer_paragraphs=sum(len(section.footer.paragraphs) for section in document.sections),
header_tables=sum(len(section.header.tables) for section in document.sections),
footer_tables=sum(len(section.footer.tables) for section in document.sections),
)
Expand All @@ -152,9 +148,7 @@ def snapshot_docx_content(path: Path) -> DocxContentSnapshot:
footer_texts.extend(_table_texts(section.footer.tables))

return DocxContentSnapshot(
body_paragraphs_sha256=_digest_texts(
paragraph.text for paragraph in document.paragraphs
),
body_paragraphs_sha256=_digest_texts(paragraph.text for paragraph in document.paragraphs),
tables_sha256=_digest_texts(_table_texts(document.tables)),
headers_sha256=_digest_texts(header_texts),
footers_sha256=_digest_texts(footer_texts),
Expand Down
6 changes: 1 addition & 5 deletions src/docmergeforge/docx/fidelity_corpus.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,7 @@ def stopped_early(self) -> bool:

@property
def accepted(self) -> bool:
return (
self.discovered_count > 0
and not self.stopped_early
and self.failed_count == 0
)
return self.discovered_count > 0 and not self.stopped_early and self.failed_count == 0

def to_dict(self) -> dict[str, Any]:
return {
Expand Down
4 changes: 1 addition & 3 deletions src/docmergeforge/docx/libreoffice_uno_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,7 @@ def run_libreoffice_uno_acceptance(
capability = require_fidelity_automation("libreoffice")
uno_python = find_uno_python()
if uno_python is None:
raise ValidationError(
"LibreOffice UNO acceptance requires a Python UNO bridge."
)
raise ValidationError("LibreOffice UNO acceptance requires a Python UNO bridge.")
libreoffice_uno_merge_documents(
ordered,
output,
Expand Down
11 changes: 4 additions & 7 deletions src/docmergeforge/docx/libreoffice_uno_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from docmergeforge.utilities.hashing import sha256_file

_UNO_WORKER = r'''
_UNO_WORKER = r"""
from __future__ import annotations

import argparse
Expand Down Expand Up @@ -142,7 +142,7 @@ def main() -> int:

if __name__ == "__main__":
raise SystemExit(main())
'''.strip()
""".strip()


@dataclass(slots=True, frozen=True)
Expand Down Expand Up @@ -371,9 +371,7 @@ def libreoffice_uno_merge_documents(
text=True,
)
try:
worker_stdout, worker_stderr = worker_process.communicate(
timeout=timeout_seconds
)
worker_stdout, worker_stderr = worker_process.communicate(timeout=timeout_seconds)
except subprocess.TimeoutExpired as exc:
worker_process.kill()
worker_stdout, worker_stderr = worker_process.communicate()
Expand All @@ -383,8 +381,7 @@ def libreoffice_uno_merge_documents(
if worker_process.returncode != 0:
detail = worker_stderr.strip() or worker_stdout.strip()
raise ValidationError(
"LibreOffice UNO merge worker failed"
+ (f": {detail}" if detail else ".")
"LibreOffice UNO merge worker failed" + (f": {detail}" if detail else ".")
)
finally:
if worker_process is not None and worker_process.poll() is None:
Expand Down
8 changes: 2 additions & 6 deletions src/docmergeforge/docx/section_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ def page_number_section_records(path: Path) -> tuple[PageNumberSectionRecord, ..
try:
document_xml = archive.read(_DOCUMENT_XML)
except KeyError as exc:
raise ValidationError(
f"DOCX package is missing {_DOCUMENT_XML}: {path}"
) from exc
raise ValidationError(f"DOCX package is missing {_DOCUMENT_XML}: {path}") from exc
except zipfile.BadZipFile as exc:
raise ValidationError(f"Invalid DOCX ZIP container: {path}") from exc

Expand Down Expand Up @@ -118,8 +116,6 @@ def page_number_properties_sha256(paths: Sequence[Path]) -> str:
global_section_index = 0
for path in paths:
for record in page_number_section_records(path):
canonical_records.append(
f"section={global_section_index}|{record.canonical()}"
)
canonical_records.append(f"section={global_section_index}|{record.canonical()}")
global_section_index += 1
return _digest_records(canonical_records)
4 changes: 2 additions & 2 deletions src/docmergeforge/docx/word.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ def word_roundtrip_copy(
raise ValidationError("Microsoft Word fidelity round-trip accepts DOCX paths only.")
if not source.exists() or not source.is_file():
raise FileNotFoundError(source)
if destination.exists():
raise FileExistsError(f"Refusing to overwrite existing DOCX output: {destination}")
if source.resolve() == destination.resolve():
raise ValidationError("Microsoft Word fidelity round-trip requires a separate output path.")
if destination.exists():
raise FileExistsError(f"Refusing to overwrite existing DOCX output: {destination}")

host = powershell or find_word_powershell_host()
if host is None:
Expand Down
31 changes: 9 additions & 22 deletions src/docmergeforge/docx/word_merge_acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,15 @@ def _table_texts(tables: Iterable[Any]) -> list[str]:
def _header_texts(document: Any) -> list[str]:
values: list[str] = []
for section in document.sections:
values.extend(
paragraph.text for paragraph in section.header.paragraphs if paragraph.text
)
values.extend(paragraph.text for paragraph in section.header.paragraphs if paragraph.text)
values.extend(_table_texts(section.header.tables))
return values


def _footer_texts(document: Any) -> list[str]:
values: list[str] = []
for section in document.sections:
values.extend(
paragraph.text for paragraph in section.footer.paragraphs if paragraph.text
)
values.extend(paragraph.text for paragraph in section.footer.paragraphs if paragraph.text)
values.extend(_table_texts(section.footer.tables))
return values

Expand Down Expand Up @@ -181,18 +177,13 @@ def _section_record(section: Any) -> str:
f"gutter={_scalar(section.gutter)}",
f"header_distance={_scalar(section.header_distance)}",
f"footer_distance={_scalar(section.footer_distance)}",
"different_first_page="
f"{int(bool(section.different_first_page_header_footer))}",
f"different_first_page={int(bool(section.different_first_page_header_footer))}",
f"header_linked={int(bool(section.header.is_linked_to_previous))}",
"first_header_linked="
f"{int(bool(section.first_page_header.is_linked_to_previous))}",
"even_header_linked="
f"{int(bool(section.even_page_header.is_linked_to_previous))}",
f"first_header_linked={int(bool(section.first_page_header.is_linked_to_previous))}",
f"even_header_linked={int(bool(section.even_page_header.is_linked_to_previous))}",
f"footer_linked={int(bool(section.footer.is_linked_to_previous))}",
"first_footer_linked="
f"{int(bool(section.first_page_footer.is_linked_to_previous))}",
"even_footer_linked="
f"{int(bool(section.even_page_footer.is_linked_to_previous))}",
f"first_footer_linked={int(bool(section.first_page_footer.is_linked_to_previous))}",
f"even_footer_linked={int(bool(section.even_page_footer.is_linked_to_previous))}",
)
)

Expand All @@ -205,9 +196,7 @@ def _section_properties_sha256(paths: Sequence[Path]) -> str:
for path in paths:
document = Document(str(path))
for section in document.sections:
records.append(
f"section={global_section_index}|{_section_record(section)}"
)
records.append(f"section={global_section_index}|{_section_record(section)}")
global_section_index += 1
return _digest_texts(records)

Expand Down Expand Up @@ -315,9 +304,7 @@ def _validate_acceptance_inputs(
return ordered_sources


def _verify_source_hashes(
sources: Sequence[Path], source_hashes: Sequence[str]
) -> None:
def _verify_source_hashes(sources: Sequence[Path], source_hashes: Sequence[str]) -> None:
for source, expected_hash in zip(sources, source_hashes, strict=True):
verify_native_source_unchanged(source, expected_hash)

Expand Down
Loading