@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.
- package/.tidyfactor +3 -3
- package/CHANGELOG.md +103 -4
- package/README.ar.md +34 -8
- package/README.de.md +1 -1
- package/README.es.md +1 -1
- package/README.fa.md +1 -1
- package/README.fr.md +1 -1
- package/README.md +34 -8
- package/README.pt.md +1 -1
- package/README.zh.md +1 -1
- package/SKILL.md +28 -5
- package/bin/add-skill.js +44 -6
- package/brand.json +1 -1
- package/brand.yaml +10 -0
- package/manifest.json +216 -0
- package/package.json +4 -2
- package/references/commands/adr.md +25 -0
- package/references/commands/audit.md +15 -0
- package/references/commands/brief.md +16 -0
- package/references/commands/generate.md +7 -6
- package/references/commands/site.md +25 -23
- package/references/commands/vitepress.md +20 -0
- package/references/memory/20-brain-baas-integration.md +83 -0
- package/references/memory/adr-template.md +84 -0
- package/references/memory/changelog-rules.md +58 -0
- package/references/memory/collection-sources.md +48 -47
- package/references/memory/decision-points.md +72 -0
- package/references/memory/doc-templates.md +102 -73
- package/references/memory/doc-tree.md +38 -37
- package/references/memory/docsify-config.md +274 -273
- package/references/memory/git-doc-sync-hook.md +54 -0
- package/references/memory/mkdocs-config.md +171 -170
- package/references/memory/naming-conventions.md +40 -0
- package/references/memory/project-mindmap.md +66 -0
- package/references/memory/site-engines.md +32 -34
- package/references/memory/stacks/js-ts.md +47 -45
- package/references/memory/stacks/php.md +35 -33
- package/references/memory/stacks/react-vue-next.md +52 -50
- package/references/memory/tone-of-voice.md +31 -0
- package/references/memory/vitepress-config.md +174 -0
- package/references/workflows/audit.md +42 -0
- package/references/workflows/brief.md +105 -0
- package/references/workflows/collect.md +60 -25
- package/references/workflows/generate-adr.md +41 -0
- package/references/workflows/generate-changelog.md +52 -0
- package/references/workflows/init-docs.md +44 -18
- package/references/workflows/vitepress.md +57 -0
- package/scripts/audit_docs.py +190 -0
- package/scripts/clean_orphaned_assets.py +185 -0
- package/tools/build-skill.js +3 -0
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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: 
|
|
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()
|
package/tools/build-skill.js
CHANGED
package/assets/og-default.png
DELETED
|
Binary file
|