@tidyfactor/doc 1.3.0 → 1.9.0

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 (51) hide show
  1. package/.tidyfactor +3 -3
  2. package/CHANGELOG.md +103 -4
  3. package/README.ar.md +34 -8
  4. package/README.de.md +1 -1
  5. package/README.es.md +1 -1
  6. package/README.fa.md +1 -1
  7. package/README.fr.md +1 -1
  8. package/README.md +34 -8
  9. package/README.pt.md +1 -1
  10. package/README.zh.md +1 -1
  11. package/SKILL.md +28 -5
  12. package/bin/add-skill.js +44 -6
  13. package/brand.json +1 -1
  14. package/brand.yaml +10 -0
  15. package/manifest.json +216 -0
  16. package/package.json +4 -2
  17. package/references/commands/adr.md +25 -0
  18. package/references/commands/audit.md +15 -0
  19. package/references/commands/brief.md +16 -0
  20. package/references/commands/generate.md +7 -6
  21. package/references/commands/site.md +25 -23
  22. package/references/commands/vitepress.md +20 -0
  23. package/references/memory/20-brain-baas-integration.md +83 -0
  24. package/references/memory/adr-template.md +84 -0
  25. package/references/memory/changelog-rules.md +58 -0
  26. package/references/memory/collection-sources.md +48 -47
  27. package/references/memory/decision-points.md +72 -0
  28. package/references/memory/doc-templates.md +102 -73
  29. package/references/memory/doc-tree.md +38 -37
  30. package/references/memory/docsify-config.md +274 -273
  31. package/references/memory/git-doc-sync-hook.md +54 -0
  32. package/references/memory/mkdocs-config.md +171 -170
  33. package/references/memory/naming-conventions.md +40 -0
  34. package/references/memory/project-mindmap.md +66 -0
  35. package/references/memory/site-engines.md +32 -34
  36. package/references/memory/stacks/js-ts.md +47 -45
  37. package/references/memory/stacks/php.md +35 -33
  38. package/references/memory/stacks/react-vue-next.md +52 -50
  39. package/references/memory/tone-of-voice.md +31 -0
  40. package/references/memory/vitepress-config.md +174 -0
  41. package/references/workflows/audit.md +42 -0
  42. package/references/workflows/brief.md +105 -0
  43. package/references/workflows/collect.md +60 -25
  44. package/references/workflows/generate-adr.md +41 -0
  45. package/references/workflows/generate-changelog.md +52 -0
  46. package/references/workflows/init-docs.md +44 -18
  47. package/references/workflows/vitepress.md +57 -0
  48. package/scripts/audit_docs.py +190 -0
  49. package/scripts/clean_orphaned_assets.py +185 -0
  50. package/tools/build-skill.js +3 -0
  51. package/assets/og-default.png +0 -0
@@ -0,0 +1,41 @@
1
+ # Workflow: generate-adr
2
+
3
+ One outcome: an Architectural Decision Record saved to `docs/adr/NNNN-<slug>.md` and indexed in `docs/adr/README.md`, adhering to the MADR (Markdown Architectural Decision Records) standard.
4
+
5
+ ## Steps
6
+
7
+ 1. **Initialize ADR Directory & Determine Sequence**:
8
+ - Ensure `docs/adr/` exists.
9
+ - Scan existing files matching `docs/adr/NNNN-*.md`.
10
+ - Calculate next number: `0001` if empty, or increment highest existing number (e.g. `0002`, `0003`).
11
+
12
+ 2. **Establish Context & Problem Statement**:
13
+ - State the technical context clearly: What engineering problem or architectural need forced a decision?
14
+ - Identify Decision Drivers: Performance, developer experience, zero-runtime overhead, license compatibility, RTL support, security.
15
+
16
+ 3. **Formulate Considered Options**:
17
+ - List at least 2–3 viable alternatives (e.g. VitePress vs. MkDocs Material vs. Docsify; Medoo vs. Doctrine vs. Eloquent).
18
+ - For every option, provide an objective Pros & Cons analysis based on verifiable engineering facts.
19
+
20
+ 4. **Document Decision Outcome & Rationale**:
21
+ - Selected option: `Chosen option: [Option Name], because [positive arguments]`.
22
+ - Explain why alternatives were rejected without emotional bias.
23
+
24
+ 5. **Map Consequences & System Impact**:
25
+ - **Positive consequences**: Capabilities gained, performance improvements, simplified maintenance.
26
+ - **Negative consequences / Trade-offs**: Learning curve, dependencies introduced, migration effort.
27
+ - **Mitigation plan**: How trade-offs will be handled.
28
+
29
+ 6. **Write ADR & Update Index**:
30
+ - Write file `docs/adr/NNNN-<slug>.md` using `memory/adr-template.md`.
31
+ - Update `docs/adr/README.md` table of contents with status, title, and date.
32
+ - Run `python scripts/audit_docs.py docs/adr/` to verify link and credential hygiene.
33
+
34
+ ## Validation checklist
35
+
36
+ - [ ] ADR file exists at `docs/adr/NNNN-<slug>.md` with 4-digit sequential numbering.
37
+ - [ ] Conforms strictly to MADR structure in `memory/adr-template.md`.
38
+ - [ ] At least two realistic alternatives were evaluated with pros/cons.
39
+ - [ ] Tone conforms to `memory/tone-of-voice.md` (objective, technical, zero marketing fluff).
40
+ - [ ] `docs/adr/README.md` index includes the new record.
41
+ - [ ] Audited with `python scripts/audit_docs.py docs/adr/` and passed 100/100.
@@ -0,0 +1,52 @@
1
+ # Workflow: generate-changelog
2
+
3
+ One outcome: a project-root `CHANGELOG.md` created or updated strictly conforming to **Keep a Changelog v1.1.0** and **SemVer 2.0.0**. Along with `README.md`, this is the authoritative Single Source of Truth (SSOT) for version releases, changes, and migration notes.
4
+
5
+ ## Steps
6
+
7
+ 1. **Locate or initialize `CHANGELOG.md`**:
8
+ - Check if `CHANGELOG.md` exists at the project root.
9
+ - If missing, load the Keep a Changelog template from `../memory/doc-templates.md` and initialize with standard header, description, and link definitions.
10
+
11
+ 2. **Extract change history & commit deltas**:
12
+ - Inspect git commit history:
13
+ ```bash
14
+ git log --oneline --no-merges -n 50
15
+ # Or between tags:
16
+ git log <last-tag>..HEAD --oneline
17
+ ```
18
+ - Parse Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`, `perf:`, `chore:`, `sec:`).
19
+ - If non-git or manual changes are provided, align them with the verified codebase changes.
20
+
21
+ 3. **Categorize entries into Keep a Changelog standard sections**:
22
+ - `### 🚀 Added`: For new features, commands, or capabilities.
23
+ - `### 🔄 Changed`: For changes in existing functionality, refactors, or UX enhancements.
24
+ - `### ⚠️ Deprecated`: For once-stable features slated for removal in upcoming releases.
25
+ - `### 🗑️ Removed`: For features, deprecated APIs, or obsolete assets removed in this release.
26
+ - `### 🐛 Fixed`: For any bug fixes, link repairs, or error resolutions.
27
+ - `### 🔒 Security`: In case of vulnerability patches or credential hardening.
28
+
29
+ 4. **Determine SemVer version bump**:
30
+ - Load `../memory/changelog-rules.md`.
31
+ - `MAJOR` (X.0.0): Any breaking change or incompatible API/workflow modification.
32
+ - `MINOR` (0.X.0): Backward-compatible new features, commands, or workflows.
33
+ - `PATCH` (0.0.X): Backward-compatible bug fixes, link corrections, or documentation polish.
34
+
35
+ 5. **Format the version entry**:
36
+ - Structure: `## [X.Y.Z] - YYYY-MM-DD` (e.g. `## [1.8.0] - 2026-09-09`).
37
+ - If drafting work-in-progress, record changes under `## [Unreleased]`.
38
+ - Maintain strict reverse-chronological order (newest release at the top).
39
+
40
+ 6. **Validate & update metadata**:
41
+ - Run `python scripts/audit_docs.py CHANGELOG.md` to guarantee zero absolute machine URLs or credential leaks.
42
+ - Ensure atomic synchronization with `package.json`, `.tidyfactor`, `brand.yaml`, `brand.json`, and `README.md`.
43
+
44
+ ## Validation checklist
45
+
46
+ - [ ] `CHANGELOG.md` exists at project root and follows Keep a Changelog v1.1.0 format
47
+ - [ ] Version header strictly matches `## [X.Y.Z] - YYYY-MM-DD` or `## [Unreleased]`
48
+ - [ ] All entries classified under valid sections (`Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`)
49
+ - [ ] Entries are organized in reverse chronological order
50
+ - [ ] SemVer bump is fully justified by the logged changes (Breaking → Major, Feat → Minor, Fix → Patch)
51
+ - [ ] Zero sensitive data, API tokens, passwords, or absolute workstation paths (`file:///`) present
52
+ - [ ] Audited with `python scripts/audit_docs.py CHANGELOG.md` and passed cleanly
@@ -1,18 +1,44 @@
1
- # Workflow: init-docs
2
-
3
- One outcome: a working `/docs` scaffold plus a doc manifest, ready for `collect` and `generate` to populate.
4
-
5
- ## Steps
6
-
7
- 1. **Detect the stack(s) present** in the project root — PHP (`composer.json`), JS/TS (`package.json`, `tsconfig.json`), and any of React/Vue/Next (framework deps or config files). Note more than one if the project is mixed (e.g., a PHP API with a React front end).
8
- 2. **Create the folder tree** exactly per `memory/doc-tree.md` — no extra top-level folders, no missing ones. Do not create a folder for a doc type the project doesn't have yet (e.g., skip `docs/api/` for a project with no API surface) — see the "No Empty Structures" note in `doc-tree.md`.
9
- 3. **Write `docs/.doc-manifest.json`** using the schema in `memory/doc-tree.md`, pre-filled with: detected stack(s), project name (from `composer.json`/`package.json`), and empty `collected` / `generated` tracking sections.
10
- 4. **Write a placeholder `docs/README.md` index** (one paragraph: what this `/docs` folder contains, and a note that it's generated/maintained by TidyFactor Doc) — this is the doc-site landing page, distinct from the project-root `README.md`.
11
- 5. **Report** what was created and what stack(s) were detected, and suggest `collect` as the next step.
12
-
13
- ## Validation checklist
14
-
15
- - [ ] `/docs` exists with only the subfolders `doc-tree.md` calls for given the detected stack(s) — nothing extra, nothing missing
16
- - [ ] `docs/.doc-manifest.json` exists, is valid JSON, and matches the schema in `memory/doc-tree.md`
17
- - [ ] `docs/README.md` (doc-site index) exists and is distinct in content from any project-root `README.md`
18
- - [ ] No API/inline/guide content was written — this workflow scaffolds only
1
+ # Workflow: init-docs
2
+
3
+ One outcome: a working `/docs` scaffold plus an aligned doc manifest, ready for `collect` and `generate` to populate.
4
+
5
+ ---
6
+
7
+ ## 📋 Step 0: Context Delta Resolution (CDL v2.0)
8
+
9
+ Before prompting the user for documentation configuration, execute the mechanical resolution formula:
10
+
11
+ $$\text{Unknowns} = \text{Required Decisions} - (\text{Discovered Facts} \cup \text{Brain KIs})$$
12
+
13
+ 1. **Auto-Sensing on Disk**:
14
+ - `doc_engine`: Check for existing `mkdocs.yml` (selects `mkdocs_material`) or `docs/index.html` (selects `docsify_spa`). If neither exists, default to `mkdocs_material`.
15
+ - `target_scope`: Check project structure or `docs/.doc-manifest.json` (defaults to `full_codebase`).
16
+ - `audience_persona`: Read `brand.yaml` (or fallback `brand.json`) for `audience` / persona. If absent, default to `bilingual_developer`.
17
+ - For file sources marked with `track_staleness: true`, compare file hash/mtime against `.tidyfactor/doc-brief.snapshot.yaml`.
18
+ 2. **Fail-Open Brain MCP Acceleration**:
19
+ - Query `search_knowledge_base(query="documentation stack audience", scope="project")`.
20
+ - If Brain MCP is offline or returns empty, fail open silently (0ms latency penalty).
21
+ 3. **Structured Interactive Disclosure (DM-DA Mode A)**:
22
+ - Present any genuine $\text{Unknowns}$ in a crisp, non-robotic prompt with clear A/B/C/D choices and recommended defaults.
23
+ - Zero robotic preambles or lectures.
24
+ 4. **Local Snapshot Persistence (Anti-Dual-Write)**:
25
+ - Persist confirmed parameters to `.tidyfactor/doc-brief.snapshot.yaml`.
26
+
27
+ ---
28
+
29
+ ## 🛠️ Execution Steps
30
+
31
+ 1. **Detect the stack(s) present** in the project root — PHP (`composer.json`), JS/TS (`package.json`, `tsconfig.json`), and any of React/Vue/Next (framework deps or config files). Note more than one if the project is mixed (e.g., a PHP API with a React front end).
32
+ 2. **Create the folder tree** exactly per `memory/doc-tree.md` — no extra top-level folders, no missing ones. Do not create a folder for a doc type the project doesn't have yet (e.g., skip `docs/api/` for a project with no API surface) — see the "No Empty Structures" note in `doc-tree.md`.
33
+ 3. **Write `docs/.doc-manifest.json`** using the schema in `memory/doc-tree.md`, pre-filled with: detected stack(s), project name (from `composer.json`/`package.json`), confirmed engine/audience from Step 0, and empty `collected` / `generated` tracking sections.
34
+ 4. **Write a placeholder `docs/README.md` index** (one paragraph: what this `/docs` folder contains, and a note that it's generated/maintained by TidyFactor Doc) — this is the doc-site landing page, distinct from the project-root `README.md`.
35
+ 5. **Report** what was created and what stack(s) were detected, and suggest `collect` as the next step.
36
+
37
+ ## Validation checklist
38
+
39
+ - [ ] Evaluated Context Delta Resolution formula before prompting user
40
+ - [ ] Local decision snapshot persisted to `.tidyfactor/doc-brief.snapshot.yaml`
41
+ - [ ] `/docs` exists with only the subfolders `doc-tree.md` calls for given the detected stack(s) — nothing extra, nothing missing
42
+ - [ ] `docs/.doc-manifest.json` exists, is valid JSON, and matches the schema in `memory/doc-tree.md`
43
+ - [ ] `docs/README.md` (doc-site index) exists and is distinct in content from any project-root `README.md`
44
+ - [ ] No API/inline/guide content was written — this workflow scaffolds only
@@ -0,0 +1,57 @@
1
+ # Workflow: vitepress
2
+
3
+ One outcome: A fully compiled, statically hosted VitePress documentation portal with custom luxury typography, surgical RTL flex order, and multi-language routing configured per `memory/vitepress-config.md`.
4
+
5
+ ---
6
+
7
+ ## Steps
8
+
9
+ 1. **Verify Environment & Dependencies:**
10
+ - Run `node --version` (require >= 18).
11
+ - Install VitePress: `npm install -D vitepress` (or verify package.json has `"vitepress": "^1.6.0"`).
12
+ - Inject doc scripts into root `package.json`:
13
+ ```json
14
+ "scripts": {
15
+ "docs:dev": "vitepress dev docs",
16
+ "docs:build": "vitepress build docs",
17
+ "docs:preview": "vitepress preview docs"
18
+ }
19
+ ```
20
+
21
+ 2. **Scaffold Directory Tree & Assets:**
22
+ - Ensure documentation markdown lives in `docs/`.
23
+ - Create `docs/.vitepress/config.mjs` using the master schema in `memory/vitepress-config.md`.
24
+ - Create `docs/.vitepress/theme/index.mjs` registering `custom.css`.
25
+ - Copy brand logo to `docs/public/logo.png`.
26
+
27
+ 3. **Configure Multi-Language Subdirectory Routing:**
28
+ - Enforce dedicated subdirectories for every supported locale to eliminate 404s:
29
+ - Arabic (Native Root): `docs/index.md`
30
+ - English: `docs/en/index.md`
31
+ - Additional growth locales: `docs/es/`, `docs/de/`, `docs/fr/`, `docs/pt/`, `docs/zh/`, `docs/fa/`.
32
+
33
+ 4. **Inject Luxury CSS & RTL Precision:**
34
+ - Write `docs/.vitepress/theme/custom.css` embedding Alexandria, Cairo, and JetBrains Mono fonts.
35
+ - Configure surgical RTL navbar flex order:
36
+ - Title/Logo (`order: 1`), Menu (`order: 2` with `margin-inline-end: auto`), Search (`order: 3`), Translations (`order: 4`), Appearance (`order: 5`), Social (`order: 6`).
37
+ - Align Hero reading hierarchy: Text on the right (`order: 1`), Logo on the left (`order: 2`).
38
+ - Protect Hero Action Buttons (CTAs): single-row layout (`flex-wrap: nowrap !important; white-space: nowrap !important`).
39
+ - Fix 3-Column Feature Cards: explicitly size `.VPFeatures .item` (`width: 33.333333% !important`) and apply `direction: rtl !important; unicode-bidi: plaintext !important` to prevent text or parentheses inversion.
40
+
41
+ 5. **Build & Validate:**
42
+ - Run `npm run docs:build` (must complete with **zero errors and zero broken links**).
43
+
44
+ 6. **Report Deployment Instructions:**
45
+ - Local dev server: `npm run docs:dev` (runs at `http://localhost:5173`).
46
+ - Production bundle: deploy the compiled output directory `docs/.vitepress/dist/`.
47
+
48
+ ---
49
+
50
+ ## Validation Checklist
51
+
52
+ - [ ] `docs/.vitepress/config.mjs` exists with valid ESM syntax and full locales configuration
53
+ - [ ] `docs/.vitepress/theme/custom.css` exists with Alexandria/Cairo fonts, single-row Hero CTAs, and 3-column card widths
54
+ - [ ] Hero layout in RTL displays text/CTAs on the right and logo on the left
55
+ - [ ] Every active locale index (`docs/index.md`, `docs/en/index.md`, etc.) exists on disk
56
+ - [ ] `npm run docs:build` compiles cleanly with **0 errors and 0 dead links**
57
+ - [ ] Clean relative links only — no machine workstation paths (`file:///C:/...`) or hardcoded secrets
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TidyFactor Documentation Quality & Hygiene Auditor
4
+ Deterministic AST and pattern scanner for documentation files under /docs and root README.
5
+ Audits for sensitive data leaks, banned absolute URLs, relative link integrity,
6
+ and Docsify / MkDocs structural requirements.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import re
12
+ import json
13
+ import argparse
14
+ from pathlib import Path
15
+
16
+ # Ensure UTF-8 output on Windows console
17
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
18
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
19
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
20
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
21
+
22
+ SENSITIVE_PATTERNS = [
23
+ (r"(?i)(password|passwd|pwd)\s*[:=]\s*['\"][^'\"]+['\"]", "Hardcoded plain password"),
24
+ (r"(?i)(secret_key|secret|api_key|apikey|token)\s*[:=]\s*['\"][a-zA-Z0-9_\-\.]{12,}['\"]", "Hardcoded secret / API token"),
25
+ (r"(?i)cpanel_[a-zA-Z0-9_]+_pass", "cPanel credential pattern"),
26
+ (r"(?i)whm_[a-zA-Z0-9_]+_token", "WHM access token pattern"),
27
+ (r"ghp_[a-zA-Z0-9]{20,}", "GitHub Personal Access Token"),
28
+ (r"xox[baprs]-[0-9a-zA-Z]{10,}", "Slack Token"),
29
+ (r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
30
+ ]
31
+
32
+ BANNED_URL_PATTERNS = [
33
+ (r"file:///[a-zA-Z]:[/\\]", "Banned machine-specific absolute file URL"),
34
+ (r"file://c:/", "Banned Windows drive path URL"),
35
+ (r"[a-zA-Z]:\\wamp64\\www\\", "Banned internal workstation local path"),
36
+ ]
37
+
38
+ # Standard Keep a Changelog v1.1.0 sections
39
+ VALID_CHANGELOG_SECTIONS = {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"}
40
+ CHANGELOG_VERSION_PATTERN = re.compile(r"^##\s+\[(?:\d+\.\d+\.\d+|Unreleased)\](?:\s+-\s+\d{4}-\d{2}-\d{2})?$")
41
+
42
+ def audit_doc_file(file_path: Path, check_links: bool = True) -> list:
43
+ issues = []
44
+ try:
45
+ content = file_path.read_text(encoding="utf-8", errors="replace")
46
+ except Exception as e:
47
+ return [{"file": str(file_path), "severity": "high", "type": "read_error", "message": str(e)}]
48
+
49
+ lines = content.splitlines()
50
+ is_changelog = file_path.name.lower().startswith("changelog")
51
+
52
+ for idx, line in enumerate(lines, start=1):
53
+ # 1. Check for sensitive data leaks
54
+ for pattern, desc in SENSITIVE_PATTERNS:
55
+ if re.search(pattern, line):
56
+ # Ignore placeholders like YOUR_API_KEY, REDACTED, example
57
+ if not re.search(r"(?i)(your_|example|redacted|\.\.\.|placeholder|<.+>|dummy)", line):
58
+ issues.append({
59
+ "file": str(file_path),
60
+ "line": idx,
61
+ "severity": "critical",
62
+ "type": "sensitive_data_leak",
63
+ "message": f"Potential sensitive data leak detected: {desc}."
64
+ })
65
+
66
+ # 2. Check for banned absolute workstation paths & file:/// URLs
67
+ for pattern, desc in BANNED_URL_PATTERNS:
68
+ if re.search(pattern, line):
69
+ # Ignore lines explaining the ban, rule definitions, or dummy placeholders
70
+ if not re.search(r"(?i)(prohibit|banned|prevent|never|don't|no\s+|avoid|clean relative|mandate|your_|example|redacted|\.\.\.|placeholder|<.+>|dummy)", line):
71
+ issues.append({
72
+ "file": str(file_path),
73
+ "line": idx,
74
+ "severity": "high",
75
+ "type": "banned_absolute_path",
76
+ "message": f"Banned workstation-specific path detected: {desc}. Use clean relative links or public URLs."
77
+ })
78
+
79
+ # 3. Check relative link integrity (strip code spans first to avoid flagging examples)
80
+ if check_links:
81
+ # Remove inline code backticks before checking active markdown links
82
+ line_no_code = re.sub(r"`[^`]*`", "", line)
83
+ # Match markdown links: [text](path)
84
+ for match in re.finditer(r"\[(?:[^\]]*)\]\(([^)]+)\)", line_no_code):
85
+ target_url = match.group(1).strip()
86
+ # Skip external, anchor-only, mailto, tel
87
+ if re.match(r"^(?:https?://|mailto:|tel:|#)", target_url, re.IGNORECASE):
88
+ continue
89
+ # Strip anchor or query params
90
+ clean_target = target_url.split("#")[0].split("?")[0].strip()
91
+ if not clean_target:
92
+ continue
93
+ # Resolve relative path
94
+ resolved = (file_path.parent / clean_target).resolve()
95
+ if not resolved.exists():
96
+ issues.append({
97
+ "file": str(file_path),
98
+ "line": idx,
99
+ "severity": "high",
100
+ "type": "broken_relative_link",
101
+ "message": f"Broken relative link '{target_url}' — target '{clean_target}' does not exist on disk."
102
+ })
103
+
104
+ # 4. CHANGELOG.md specific structure validation
105
+ if is_changelog:
106
+ version_headers = [l.strip() for l in lines if l.startswith("## ")]
107
+ if not version_headers:
108
+ issues.append({
109
+ "file": str(file_path),
110
+ "line": 1,
111
+ "severity": "high",
112
+ "type": "invalid_changelog_format",
113
+ "message": "CHANGELOG.md lacks standard release headers (expected format: '## [x.y.z] - YYYY-MM-DD' or '## [Unreleased]')."
114
+ })
115
+ else:
116
+ for vh in version_headers:
117
+ if not CHANGELOG_VERSION_PATTERN.match(vh):
118
+ issues.append({
119
+ "file": str(file_path),
120
+ "line": 1,
121
+ "severity": "low",
122
+ "type": "non_standard_version_header",
123
+ "message": f"Changelog version header '{vh}' does not strictly match Keep a Changelog SemVer pattern '## [X.Y.Z] - YYYY-MM-DD'."
124
+ })
125
+
126
+ return issues
127
+
128
+ def audit_docs_directory(target_dir: Path) -> dict:
129
+ all_issues = []
130
+ files_scanned = 0
131
+
132
+ if target_dir.is_file():
133
+ files_to_check = [target_dir]
134
+ elif target_dir.is_dir():
135
+ files_to_check = list(target_dir.rglob("*.md"))
136
+ else:
137
+ return {"error": f"Target path does not exist: {target_dir}", "passed": False}
138
+
139
+ for f in files_to_check:
140
+ # Skip vendor/node_modules/.git
141
+ if any(part in f.parts for part in [".git", "node_modules", "dist", "vendor"]):
142
+ continue
143
+ files_scanned += 1
144
+ issues = audit_doc_file(f)
145
+ all_issues.extend(issues)
146
+
147
+ critical_count = len([i for i in all_issues if i.get("severity") == "critical"])
148
+ high_count = len([i for i in all_issues if i.get("severity") == "high"])
149
+
150
+ score = max(0, 100 - (critical_count * 30) - (high_count * 15))
151
+ passed = critical_count == 0 and high_count == 0
152
+
153
+ return {
154
+ "target": str(target_dir),
155
+ "files_scanned": files_scanned,
156
+ "score": score,
157
+ "passed": passed,
158
+ "critical_issues": critical_count,
159
+ "high_issues": high_count,
160
+ "total_issues": len(all_issues),
161
+ "issues": all_issues,
162
+ "timestamp": "2026-09-02T06:00:00Z"
163
+ }
164
+
165
+ def main():
166
+ parser = argparse.ArgumentParser(description="TidyFactor Documentation Quality & Hygiene Auditor")
167
+ parser.add_argument("target", nargs="?", default="docs", help="Directory or markdown file to audit (default: docs)")
168
+ parser.add_argument("--json", action="store_true", help="Output pure JSON format")
169
+
170
+ args = parser.parse_args()
171
+ target_path = Path(args.target)
172
+
173
+ result = audit_docs_directory(target_path)
174
+
175
+ if args.json:
176
+ print(json.dumps(result, indent=2, ensure_ascii=False))
177
+ else:
178
+ status_str = "[PASS]" if result.get("passed") else "[FAIL]"
179
+ print(f"\n{status_str} Documentation Quality & Hygiene Audit — Score: {result.get('score', 0)}/100")
180
+ print(f"Target: {result.get('target')} | Files Scanned: {result.get('files_scanned', 0)}")
181
+ print(f"Issues Found: {result.get('total_issues', 0)} (Critical: {result.get('critical_issues', 0)}, High: {result.get('high_issues', 0)})\n")
182
+
183
+ for iss in result.get("issues", []):
184
+ print(f" - [{iss.get('severity', '').upper()}] {iss.get('file')}:{iss.get('line', '?')} — {iss.get('message')}")
185
+ print()
186
+
187
+ sys.exit(0 if result.get("passed") else 1)
188
+
189
+ if __name__ == "__main__":
190
+ main()
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TidyFactor Asset Hygiene & Orphan Cleaner
4
+ Scans documentation markdown files for referenced image assets, detects orphaned files
5
+ in assets/ directory, detects duplicate assets via SHA-256 hashing, and evaluates
6
+ potential WebP compression savings.
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ import re
12
+ import json
13
+ import hashlib
14
+ import argparse
15
+ from pathlib import Path
16
+
17
+ # Ensure UTF-8 output on Windows console
18
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
19
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
20
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
21
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
22
+
23
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"}
24
+
25
+ def get_file_hash(file_path: Path) -> str:
26
+ hasher = hashlib.sha256()
27
+ try:
28
+ with open(file_path, "rb") as f:
29
+ while chunk := f.read(65536):
30
+ hasher.update(chunk)
31
+ return hasher.hexdigest()
32
+ except Exception:
33
+ return ""
34
+
35
+ def scan_markdown_image_references(root_dir: Path) -> set:
36
+ referenced_filenames = set()
37
+ md_files = []
38
+
39
+ for f in root_dir.rglob("*.md"):
40
+ if any(part in f.parts for part in [".git", "node_modules", "vendor", "dist"]):
41
+ continue
42
+ md_files.append(f)
43
+
44
+ for md_file in md_files:
45
+ try:
46
+ content = md_file.read_text(encoding="utf-8", errors="replace")
47
+ except Exception:
48
+ continue
49
+
50
+ # 1. Match standard markdown image syntax: ![alt](path)
51
+ for match in re.finditer(r"!\[(?:[^\]]*)\]\(([^)]+)\)", content):
52
+ raw_url = match.group(1).strip().split("?")[0].split("#")[0]
53
+ if raw_url.startswith(("http://", "https://", "data:")):
54
+ continue
55
+ referenced_filenames.add(Path(raw_url).name.lower())
56
+
57
+ # 2. Match HTML img tags: <img src="path" ... />
58
+ for match in re.finditer(r"""<img[^>]+src=["']([^"']+)["']""", content, re.IGNORECASE):
59
+ raw_url = match.group(1).strip().split("?")[0].split("#")[0]
60
+ if raw_url.startswith(("http://", "https://", "data:")):
61
+ continue
62
+ referenced_filenames.add(Path(raw_url).name.lower())
63
+
64
+ return referenced_filenames
65
+
66
+ def analyze_assets(repo_root: Path, assets_dir: Path, delete_orphans: bool = False) -> dict:
67
+ if not assets_dir.exists() or not assets_dir.is_dir():
68
+ return {"error": f"Assets directory not found: {assets_dir}", "passed": True, "assets": []}
69
+
70
+ referenced_names = scan_markdown_image_references(repo_root)
71
+
72
+ asset_files = []
73
+ for f in assets_dir.iterdir():
74
+ if f.is_file() and f.suffix.lower() in IMAGE_EXTENSIONS:
75
+ asset_files.append(f)
76
+
77
+ # 1. Detect duplicates via SHA-256
78
+ hash_map = {}
79
+ duplicates = []
80
+ orphaned_files = []
81
+ webp_candidates = []
82
+ total_size = 0
83
+
84
+ for f in asset_files:
85
+ size = f.stat().st_size
86
+ total_size += size
87
+ f_hash = get_file_hash(f)
88
+ f_name_lower = f.name.lower()
89
+
90
+ if f_hash in hash_map:
91
+ duplicates.append({
92
+ "file": str(f.relative_to(repo_root)),
93
+ "duplicate_of": str(hash_map[f_hash]["path"].relative_to(repo_root)),
94
+ "size_bytes": size,
95
+ "sha256": f_hash
96
+ })
97
+ else:
98
+ hash_map[f_hash] = {"path": f, "size": size}
99
+
100
+ # Check orphan status
101
+ is_referenced = f_name_lower in referenced_names
102
+ if not is_referenced:
103
+ orphaned_files.append({
104
+ "file": str(f.relative_to(repo_root)),
105
+ "size_bytes": size,
106
+ "deleted": False
107
+ })
108
+
109
+ # Check WebP optimization candidate (PNG/JPG > 50KB)
110
+ if f.suffix.lower() in {".png", ".jpg", ".jpeg"} and size > 51200:
111
+ estimated_webp_size = int(size * 0.25) # ~75% reduction
112
+ webp_candidates.append({
113
+ "file": str(f.relative_to(repo_root)),
114
+ "current_size_bytes": size,
115
+ "estimated_webp_bytes": estimated_webp_size,
116
+ "estimated_savings_bytes": size - estimated_webp_size
117
+ })
118
+
119
+ # Execute deletion if requested
120
+ if delete_orphans and orphaned_files:
121
+ for item in orphaned_files:
122
+ try:
123
+ target_to_del = repo_root / item["file"]
124
+ target_to_del.unlink()
125
+ item["deleted"] = True
126
+ except Exception as e:
127
+ item["delete_error"] = str(e)
128
+
129
+ passed = len(orphaned_files) == 0 and len(duplicates) == 0
130
+
131
+ return {
132
+ "repo_root": str(repo_root),
133
+ "assets_dir": str(assets_dir),
134
+ "total_assets_scanned": len(asset_files),
135
+ "total_size_bytes": total_size,
136
+ "orphaned_count": len(orphaned_files),
137
+ "orphaned_files": orphaned_files,
138
+ "duplicates_count": len(duplicates),
139
+ "duplicates": duplicates,
140
+ "webp_optimization_candidates": webp_candidates,
141
+ "passed": passed
142
+ }
143
+
144
+ def main():
145
+ parser = argparse.ArgumentParser(description="TidyFactor Asset Hygiene & Orphan Cleaner")
146
+ parser.add_argument("repo_root", nargs="?", default=".", help="Root directory of the skill or repo (default: .)")
147
+ parser.add_argument("--assets-dir", default="assets", help="Relative path to assets directory (default: assets)")
148
+ parser.add_argument("--delete-orphans", action="store_true", help="Delete orphaned asset files directly")
149
+ parser.add_argument("--json", action="store_true", help="Output pure JSON format")
150
+
151
+ args = parser.parse_args()
152
+ root_path = Path(args.repo_root).resolve()
153
+ assets_path = (root_path / args.assets_dir).resolve()
154
+
155
+ result = analyze_assets(root_path, assets_path, delete_orphans=args.delete_orphans)
156
+
157
+ if args.json:
158
+ print(json.dumps(result, indent=2, ensure_ascii=False))
159
+ else:
160
+ status_str = "[PASS]" if result.get("passed") else "[WARN]"
161
+ print(f"\n{status_str} Asset Hygiene & Orphan Scanner — Scanned: {result.get('total_assets_scanned', 0)} assets ({result.get('total_size_bytes', 0):,} bytes)")
162
+ print(f"Orphaned Assets: {result.get('orphaned_count', 0)} | Duplicate Assets: {result.get('duplicates_count', 0)}\n")
163
+
164
+ if result.get("duplicates"):
165
+ print(" ⚠️ Duplicate Assets Found:")
166
+ for d in result["duplicates"]:
167
+ print(f" - {d['file']} (Duplicate of {d['duplicate_of']}, size: {d['size_bytes']:,} B)")
168
+
169
+ if result.get("orphaned_files"):
170
+ print(" ⚠️ Orphaned Assets (Not linked in any .md):")
171
+ for o in result["orphaned_files"]:
172
+ del_str = " [DELETED]" if o.get("deleted") else ""
173
+ print(f" - {o['file']} ({o['size_bytes']:,} B){del_str}")
174
+
175
+ if result.get("webp_optimization_candidates"):
176
+ print("\n 💡 WebP Optimization Recommendations (>50KB PNG/JPG):")
177
+ for w in result["webp_optimization_candidates"]:
178
+ print(f" - {w['file']} (Current: {w['current_size_bytes']:,} B -> Est. WebP: {w['estimated_webp_bytes']:,} B, Save: ~{w['estimated_savings_bytes']:,} B)")
179
+
180
+ print()
181
+
182
+ sys.exit(0 if result.get("passed") else 1)
183
+
184
+ if __name__ == "__main__":
185
+ main()
@@ -23,9 +23,12 @@ const OUT_FILE =
23
23
  const ROOT_COPIES = [
24
24
  "SKILL.md",
25
25
  "references",
26
+ "scripts",
27
+ "manifest.json",
26
28
  "tools",
27
29
  "bin",
28
30
  "brand.json",
31
+ "brand.yaml",
29
32
  ".tidyfactor",
30
33
  "package.json",
31
34
  "AGENTS.md",
Binary file