diff --git a/.ref-cache.json b/.ref-cache.json index 3cccd324..442d2633 100644 --- a/.ref-cache.json +++ b/.ref-cache.json @@ -1,6 +1,6 @@ { "http://c2.com/doc/oopsla92.html": 200, - "http://callbackhell.com/": "403 then HTTPError", + "http://callbackhell.com/": "URLError", "http://commandcenter.blogspot.com.au/2014/01/self-referential-functions-and-design.html": 200, "http://disi.unitn.it/~montreso/ds/papers/montresor17.pdf": 200, "http://dougseven.com/2014/04/17/knightmare-a-devops-cautionary-tale/": 200, @@ -2127,7 +2127,7 @@ "https://fp-ts.github.io/core/modules/Either.ts.html": 200, "https://framework.zend.com/manual/1.12/en/zend.db.table.html": 200, "https://framework.zend.com/manual/1.12/en/zend.db.table.row.html": 200, - "https://freecontent.manning.com/the-service-locator-anti-pattern/": 202, + "https://freecontent.manning.com/the-service-locator-anti-pattern/": "URLError", "https://freetype.org/freetype2/docs/reference/ft2-cache_subsystem.html": 200, "https://fsharp.github.io/fsharp-core-docs/": 200, "https://fsharp.github.io/fsharp-core-docs/reference/fsharp-core-operators.html": 200, @@ -4619,7 +4619,7 @@ "https://www.intercom.com/help/en/articles/9515824-what-is-fin": 200, "https://www.intercom.com/help/en/articles/9929230-the-fin-ai-engine": 200, "https://www.iso.org/standard/81870.html": 200, - "https://www.iso20022.org/": "TimeoutError", + "https://www.iso20022.org/": "403 then HTTPError", "https://www.jaegertracing.io/": 200, "https://www.jaegertracing.io/docs/1.6/architecture/": 200, "https://www.jaegertracing.io/docs/2.dev/deployment/configuration/": 200, @@ -4849,12 +4849,12 @@ "https://www.reactive-streams.org/": 200, "https://www.research.ed.ac.uk/en/publications/handlers-of-algebraic-effects": 200, "https://www.research.ed.ac.uk/en/publications/monads-for-functional-programming/": 200, - "https://www.researchgate.net/publication/221366961_Relationship-based_access_control_policies_and_their_policy_languages": "429 then HTTPError", - "https://www.researchgate.net/publication/228342050_Software_design_patterns_for_message_driven_service_oriented_integration_of_stovepipe_applications_in_healthcare_enterprise": "429 then HTTPError", - "https://www.researchgate.net/publication/2378571_Ideal_Hash_Trees": "429 then HTTPError", - "https://www.researchgate.net/publication/2949837_Virtual_Time_and_Global_States_of_Distributed_Systems": "429 then HTTPError", - "https://www.researchgate.net/publication/342799123_RefactoringMiner_20": "429 then HTTPError", - "https://www.researchgate.net/publication/43921655_Combinators_for_bidirectional_tree_transformations_A_linguistic_approach_to_the_view-update_problem": "429 then HTTPError", + "https://www.researchgate.net/publication/221366961_Relationship-based_access_control_policies_and_their_policy_languages": "403 then HTTPError", + "https://www.researchgate.net/publication/228342050_Software_design_patterns_for_message_driven_service_oriented_integration_of_stovepipe_applications_in_healthcare_enterprise": "403 then HTTPError", + "https://www.researchgate.net/publication/2378571_Ideal_Hash_Trees": "403 then HTTPError", + "https://www.researchgate.net/publication/2949837_Virtual_Time_and_Global_States_of_Distributed_Systems": "403 then HTTPError", + "https://www.researchgate.net/publication/342799123_RefactoringMiner_20": "403 then HTTPError", + "https://www.researchgate.net/publication/43921655_Combinators_for_bidirectional_tree_transformations_A_linguistic_approach_to_the_view-update_problem": "403 then HTTPError", "https://www.rfc-editor.org/info/rfc5246": 200, "https://www.rfc-editor.org/info/rfc6265/": 200, "https://www.rfc-editor.org/info/rfc6749": 200, diff --git a/Makefile b/Makefile index ec5c49bb..55a4fc28 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -.PHONY: check structure refs prose code catalogue catalogue-check by-problem-by-language by-problem-by-language-check duplicates duplicates-test test all stats +.PHONY: check structure refs prose links code catalogue catalogue-check by-problem-by-language by-problem-by-language-check duplicates duplicates-test test all stats all: check -check: test structure prose code refs catalogue-check by-problem-by-language-check duplicates-test +check: test structure prose links code refs catalogue-check by-problem-by-language-check duplicates-test structure: @python3 tools/check-structure.py @@ -13,6 +13,9 @@ refs: prose: @python3 tools/check-prose.py +links: + @python3 tools/check-links.py --strict + code: @python3 tools/check-code.py --strict @@ -40,6 +43,7 @@ test: @python3 tools/check-code-test.py @python3 tools/check-claims-test.py @python3 tools/next-batch-test.py + @python3 tools/check-links-test.py duplicates: @python3 tools/check-duplicates.py --strict diff --git a/README.md b/README.md index 9677ac5b..cf019f12 100644 --- a/README.md +++ b/README.md @@ -497,7 +497,7 @@ level software pattern catalogue. Follow these steps exactly, in order. 2. Read .github/CONTRIBUTING.md, docs/ENTRY-TEMPLATE.md, and one existing published entry under patterns/ end to end. These are the real, current rules. Do not assume you already know them. -3. Check docs/AUTHORING-PLAN.md for an unclaimed pattern, or pick a pattern +3. Check docs/PROGRESS.md for an unclaimed pattern, or pick a pattern the maintainer has not catalogued yet. 4. Create a branch named entry/, for example entry/circuit-breaker. Never commit to main. @@ -527,7 +527,7 @@ in the repo, the file in the repo wins, not this prompt. ### Authoring plan and progress -See [docs/AUTHORING-PLAN.md](docs/AUTHORING-PLAN.md) for the family by family +See [docs/PROGRESS.md](docs/PROGRESS.md) for the family by family authoring order and the current state of each family. ## Credits diff --git a/patterns/04-principles-and-laws/README.md b/patterns/04-principles-and-laws/README.md index d80de655..294928e3 100644 --- a/patterns/04-principles-and-laws/README.md +++ b/patterns/04-principles-and-laws/README.md @@ -2,7 +2,7 @@ Origin. Martin, Larman, Brewer, Conway -42 entries, 327,232 words. Every entry carries all 18 +42 entries, 327,229 words. Every entry carries all 18 dimensions from [the entry contract](../../docs/ENTRY-TEMPLATE.md). ## Design Principle @@ -13,7 +13,7 @@ dimensions from [the entry contract](../../docs/ENTRY-TEMPLATE.md). | [Composable](composable.md) | canonical | 7,680 | Every system with more than one moving part eventually needs behavior that no single unit provides on its own. | | [Dependency Inversion Principle](dependency-inversion-principle.md) | canonical | 7,097 | A codebase grows outward from a small number of policy decisions, what the system does, in what order, and why. | | [Inversion of Control](inversion-of-control.md) | canonical | 4,789 | In an ordinary, un-inverted call structure, application code owns the entry point. | -| [Predictable](predictable.md) | canonical | 9,475 | A caller who invokes an operation, reads an API's documentation, or pulls a dependency's published version needs to know, before acting, what is going to happen. | +| [Predictable](predictable.md) | canonical | 9,472 | A caller who invokes an operation, reads an API's documentation, or pulls a dependency's published version needs to know, before acting, what is going to happen. | | [Stable Abstractions Principle](stable-abstractions-principle.md) | canonical | 8,095 | A codebase accumulates two kinds of code over its life. | | [Unix Philosophy (CUPID)](unix-philosophy-cupid.md) | established | 5,536 | A function, class, module, or service accumulates responsibility over time because adding one more branch to something that already exists is almost always locally cheaper than ... | diff --git a/patterns/04-principles-and-laws/predictable.md b/patterns/04-principles-and-laws/predictable.md index cf3d26d7..19cd1ad5 100644 --- a/patterns/04-principles-and-laws/predictable.md +++ b/patterns/04-principles-and-laws/predictable.md @@ -6,7 +6,7 @@ category: Design Principle aliases: [Predictability, Behavioral Predictability, Deterministic Behavior] first_described: "Convergent principle with no single coined origin. Earliest formal reification is Bertrand Meyer, Design by Contract, IEEE Computer, October 1992 (contracts as a predictability guarantee); the idempotency reification traces to HTTP method semantics formalized in RFC 2616, 1999, later RFC 7231, 2014" maturity: canonical -related: [principle-of-least-astonishment, fail-fast, single-source-of-truth, liskov-substitution-principle, postel-law, idempotent-consumer, state, command] +related: [principle-of-least-astonishment, fail-fast, single-source-of-truth, liskov-substitution-principle, postel-law] incompatible_with: [] verified: 2026-08-02 --- @@ -771,19 +771,19 @@ converge predictably if the desired state it is converging toward has exactly one authoritative source, and every out-of-band actor that can also write to the same state undermines the guarantee. -[Idempotent Consumer](idempotent-consumer.md) is the messaging-system +[Idempotent Consumer](../10-microservices/idempotent-consumer.md) is the messaging-system counterpart of the idempotency key variant described in dimension 8, applied to the receiving side of an asynchronous message rather than to a synchronous request, and the two are frequently implemented with the same underlying deduplication store. -The [State](state.md) pattern and the closed transition table variant from +The [State](../01-design-patterns-gof/state.md) pattern and the closed transition table variant from dimension 8 share the same intent, encoding every legal transition explicitly so an illegal one is structurally impossible or explicitly rejected rather than silently allowed to happen through an unconstrained mutation of a status field. -The [Command](command.md) pattern's support for reversible operations, undo +The [Command](../01-design-patterns-gof/command.md) pattern's support for reversible operations, undo and redo, depends on predictable, forecastable effects. a command's undo implementation can only reliably reverse an effect it can predict, which means a command whose execute step touches unpinned non-determinism is diff --git a/tools/check-links-test.py b/tools/check-links-test.py new file mode 100644 index 00000000..46b34756 --- /dev/null +++ b/tools/check-links-test.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Unit tests for check-links.py link validator tool.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +# Load check-links.py module dynamically since file contains a hyphen +TOOLS_DIR = Path(__file__).resolve().parent +SPEC = importlib.util.spec_from_file_location( + "check_links", TOOLS_DIR / "check-links.py" +) +check_links_mod = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(check_links_mod) + + +class TestCheckLinks(unittest.TestCase): + def test_extract_links_from_file_prose_and_code_blocks(self): + content = """# Test Document + +Here is a valid link to [README](../README.md). +Here is an external link to [Google](https://google.com). +Here is an anchor link to [#section](#section). + +```python +# In code block +a = [1](2) +x = func(arg1, arg2) +``` + +And another link to [Progress](../../docs/PROGRESS.md#summary). +""" + with tempfile.NamedTemporaryFile( + mode="w+", suffix=".md", delete=False, encoding="utf-8" + ) as tmp: + tmp.write(content) + tmp_path = Path(tmp.name) + + try: + links = check_links_mod.extract_links_from_file(tmp_path) + targets = [link[2] for link in links] + self.assertIn("../README.md", targets) + self.assertIn("../../docs/PROGRESS.md#summary", targets) + self.assertNotIn("https://google.com", targets) + self.assertNotIn("#section", targets) + self.assertNotIn("2", targets) + finally: + tmp_path.unlink() + + def test_check_links_detects_broken(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + valid_file = root / "exists.md" + valid_file.write_text("# Exists", encoding="utf-8") + + source_file = root / "source.md" + source_file.write_text( + "Link 1: [Valid](exists.md)\nLink 2: [Broken](missing.md)\n", + encoding="utf-8", + ) + + broken = check_links_mod.check_links(root) + self.assertEqual(len(broken), 1) + self.assertEqual(broken[0]["target"], "missing.md") + self.assertEqual(broken[0]["source"], "source.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check-links.py b/tools/check-links.py new file mode 100644 index 00000000..40072ccf --- /dev/null +++ b/tools/check-links.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Internal Markdown Relative Link Checker. + +Validates that all relative internal links within Markdown files (.md) +point to existing files or directories within the repository. + +Ignores: +- External URLs (http://, https://, mailto:, etc.) +- In-page anchors (#anchor) +- Code blocks (fenced ``` or indented code fences) +- Formatting artifacts / false positives in code snippets +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +# Regex to match Markdown links: [text](link) +LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)") + + +def is_code_block_delimiter(line: str) -> bool: + s = line.strip() + return s.startswith("```") or s.startswith("~~~") + + +def find_md_files(root_dir: Path) -> list[Path]: + md_files = [] + for dirpath, dirnames, filenames in os.walk(root_dir): + # Skip .git and dist directories + rel_dir = os.path.relpath(dirpath, root_dir) + if rel_dir == ".git" or rel_dir.startswith(".git" + os.sep): + continue + if rel_dir == "dist" or rel_dir.startswith("dist" + os.sep): + continue + for filename in filenames: + if filename.endswith(".md"): + md_files.append(Path(dirpath) / filename) + return sorted(md_files) + + +def extract_links_from_file(filepath: Path) -> list[tuple[int, str, str]]: + """Returns a list of tuples: (line_number, link_text, link_target) from non-code prose.""" + links = [] + try: + content = filepath.read_text(encoding="utf-8", errors="replace") + except Exception: + return links + + in_code_block = False + + for line_idx, line in enumerate(content.splitlines(), start=1): + if is_code_block_delimiter(line): + in_code_block = not in_code_block + continue + + if in_code_block: + continue + + for m in LINK_RE.finditer(line): + text, target = m.group(1), m.group(2).strip() + + # Ignore external links + if ( + target.startswith("http://") + or target.startswith("https://") + or target.startswith("mailto:") + or target.startswith("ftp://") + ): + continue + + # Ignore pure anchors + if target.startswith("#"): + continue + + # Ignore false positives with space, asterisks, or commas (typically code snippet artifacts) + if " " in target or "*" in target or "," in target: + continue + + links.append((line_idx, text, target)) + + return links + + +def check_links(root_dir: Path) -> list[dict]: + broken = [] + md_files = find_md_files(root_dir) + + for filepath in md_files: + links = extract_links_from_file(filepath) + for line_num, text, target in links: + # Strip anchor fragment if present + path_part = target.split("#")[0] + if not path_part: + continue + + # Resolve relative path against file directory + target_path = (filepath.parent / path_part).resolve() + + # Check if target exists on disk + if not target_path.exists(): + broken.append( + { + "source": str(filepath.relative_to(root_dir)), + "line": line_num, + "text": text, + "target": target, + "resolved_path": str(target_path), + } + ) + + return broken + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check relative internal links in Markdown files." + ) + parser.add_argument( + "--strict", + action="store_true", + help="Exit with non-zero code if broken internal links are found.", + ) + args = parser.parse_args() + + broken = check_links(ROOT) + + if broken: + print(f"Found {len(broken)} broken internal relative link(s):") + for b in broken: + print(f" {b['source']}:{b['line']} -> [{b['text']}]({b['target']})") + if args.strict: + return 1 + else: + print("All internal relative Markdown links resolve correctly.") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())