1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add browser asset quality gate

This commit is contained in:
Andraxion 2026-07-24 22:36:44 -04:00
parent 2841042b9c
commit 5ccb336e84
14 changed files with 2898 additions and 16 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/.venv/ /.venv/
/node_modules/
/.ruff_cache/ /.ruff_cache/
/.pytest_cache/ /.pytest_cache/
/dist/ /dist/

6
.htmlvalidate.json Normal file
View file

@ -0,0 +1,6 @@
{
"extends": ["html-validate:recommended"],
"rules": {
"valid-id": "off"
}
}

View file

@ -12,5 +12,5 @@
- Use deterministic ordering, hashes, JSON results, and structured errors. - Use deterministic ordering, hashes, JSON results, and structured errors.
- Fail closed on stale caches, invalid configuration, ambiguous IDs, and unauthorized families. - Fail closed on stale caches, invalid configuration, ambiguous IDs, and unauthorized families.
- Keep dependencies small and pinned by compatible major version. - Keep dependencies small and pinned by compatible major version.
- Run strict `pyright`, formatting, Ruff, compilation, focused tests, and the complete warning-strict - Run strict `pyright`, `npm run lint:web`, formatting, Ruff, compilation, focused tests, and the
test suite before closing a gate. complete warning-strict test suite before closing a gate.

View file

@ -49,9 +49,11 @@ virtual environment and treats a clean strict run as a required development gate
```bash ```bash
uv sync uv sync
npm ci
pyright pyright
uv run ruff check src tests npm run lint:web
uv run ruff format --check src tests uv run ruff check src tests tools
uv run ruff format --check src tests tools
uv run python -m unittest discover -s tests -v uv run python -m unittest discover -s tests -v
uv run docforge --project-root tests/fixtures/alpha validate uv run docforge --project-root tests/fixtures/alpha validate
uv run docforge --project-root tests/fixtures/alpha render-status manual uv run docforge --project-root tests/fixtures/alpha render-status manual
@ -59,6 +61,9 @@ uv run docforge --project-root tests/fixtures/alpha render manual
uv run docforge-mcp --project-root tests/fixtures/alpha --proposal-writer alpha-editor uv run docforge-mcp --project-root tests/fixtures/alpha --proposal-writer alpha-editor
``` ```
The web gate validates the exact graph-browser HTML, CSS, and JavaScript served by DocForge. It also
renders the fixture manual into a temporary project and validates that final HTML document.
The command prints deterministic JSON. Derived indexes live under each project's configured cache The command prints deterministic JSON. Derived indexes live under each project's configured cache
directory and are never canonical input. directory and are never canonical input.

View file

@ -1,5 +1,24 @@
# Completed slices # Completed slices
## DFG-16 browser asset quality gate
### Changed
- Added pinned ESLint, Stylelint, CSS-tree, and HTML Validate development tooling.
- Added one `npm run lint:web` gate that extracts the exact embedded viewer assets without writing
generated repository files.
- Validated a freshly rendered fixture manual in addition to the graph viewer.
- Corrected viewer landmark names, explicit input type, ARIA group semantics, inline legend styles,
and HTML doctype casing.
### Verification
- HTML Validate passes the served graph document and a freshly rendered manual.
- Stylelint and CSS-tree pass the embedded stylesheet with syntax and property-value validation.
- ESLint passes the embedded browser script with recommended browser rules and no inline disables.
- Strict Pyright, Ruff, formatting, compilation, all 52 warning-strict tests, dependency locks, and
diff checks pass.
## DFG-15 strict static typing gate ## DFG-15 strict static typing gate
### Changed ### Changed

23
eslint.config.mjs Normal file
View file

@ -0,0 +1,23 @@
import js from "@eslint/js";
import globals from "globals";
export default [
{
files: ["**/*.js"],
...js.configs.recommended,
languageOptions: {
ecmaVersion: 2024,
sourceType: "script",
globals: globals.browser,
},
linterOptions: {
reportUnusedDisableDirectives: "error",
},
rules: {
eqeqeq: "error",
"no-implicit-coercion": "error",
"no-var": "error",
"prefer-const": "error",
},
},
];

2659
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "docforge-web-quality",
"version": "0.0.0",
"private": true,
"packageManager": "npm@10.9.7",
"scripts": {
"lint:web": "uv run python tools/check_web_assets.py"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"eslint": "10.8.0",
"globals": "17.7.0",
"html-validate": "11.5.6",
"stylelint": "17.14.1",
"stylelint-config-standard": "40.0.0",
"stylelint-csstree-validator": "4.0.0"
}
}

View file

@ -29,6 +29,6 @@ select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.pyright] [tool.pyright]
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "strict" typeCheckingMode = "strict"
include = ["src"] include = ["src", "tools"]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"

View file

@ -858,7 +858,7 @@ def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list
return [{"value": row[0], "count": row[1]} for row in rows] return [{"value": row[0], "count": row[1]} for row in rows]
_GRAPH_BROWSER_HTML = r"""<!doctype html> _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@ -963,6 +963,9 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
} }
.legend i { width: 8px; height: 8px; border-radius: 50%; } .legend i { width: 8px; height: 8px; border-radius: 50%; }
.legend-primary i { background: var(--primary-stroke); }
.legend-child i { background: var(--child-stroke); }
.legend-edge i { background: var(--edge-stroke); }
.result { .result {
width: 100%; text-align: left; border: 1px solid var(--line); border-radius: 9px; width: 100%; text-align: left; border: 1px solid var(--line); border-radius: 9px;
padding: 9px; background: var(--panel-2); color: var(--text); padding: 9px; background: var(--panel-2); color: var(--text);
@ -1097,11 +1100,12 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
<div class="status" id="status">Loading validated index snapshot</div> <div class="status" id="status">Loading validated index snapshot</div>
</header> </header>
<div class="layout"> <div class="layout">
<aside class="left"> <aside class="left" aria-label="Graph navigation">
<form id="search-form"> <form id="search-form">
<label for="search">Find nodes</label> <label for="search">Find nodes</label>
<div class="search-row"> <div class="search-row">
<input id="search" name="q" autocomplete="off" placeholder="title, symbol, path…"> <input id="search" name="q" type="search" autocomplete="off"
placeholder="title, symbol, path…">
<button class="button" type="submit">Find</button> <button class="button" type="submit">Find</button>
</div> </div>
<label for="family">Family</label> <label for="family">Family</label>
@ -1109,10 +1113,10 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
</form> </form>
<div class="results" id="results"></div> <div class="results" id="results"></div>
<div class="neighborhood" id="neighborhood" hidden> <div class="neighborhood" id="neighborhood" hidden>
<div class="legend" aria-label="Node role colors"> <div class="legend" role="group" aria-label="Node role colors">
<span><i style="background: var(--primary-stroke)"></i>Primary</span> <span class="legend-primary"><i></i>Primary</span>
<span><i style="background: var(--child-stroke)"></i>Children</span> <span class="legend-child"><i></i>Children</span>
<span><i style="background: var(--edge-stroke)"></i>Edge</span> <span class="legend-edge"><i></i>Edge</span>
</div> </div>
<div id="neighborhood-sections"></div> <div id="neighborhood-sections"></div>
</div> </div>
@ -1121,7 +1125,7 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
aria-label="Resize navigation panel" aria-orientation="vertical" aria-label="Resize navigation panel" aria-orientation="vertical"
aria-valuemin="220" aria-valuemax="900" aria-valuenow="310"></div> aria-valuemin="220" aria-valuemax="900" aria-valuenow="310"></div>
<main class="canvas"> <main class="canvas">
<div class="viewport-controls" aria-label="Graph viewport controls"> <div class="viewport-controls" role="group" aria-label="Graph viewport controls">
<button class="viewport-control" id="zoom-in" type="button" <button class="viewport-control" id="zoom-in" type="button"
title="Zoom in" aria-label="Zoom in">+</button> title="Zoom in" aria-label="Zoom in">+</button>
<button class="viewport-control" id="zoom-out" type="button" <button class="viewport-control" id="zoom-out" type="button"
@ -1144,7 +1148,7 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0" <div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
aria-label="Resize details panel" aria-orientation="vertical" aria-label="Resize details panel" aria-orientation="vertical"
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div> aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
<aside class="right"> <aside class="right" aria-label="Node details">
<div id="details"> <div id="details">
<p class="summary">Choose a search result to load its neighborhood.</p> <p class="summary">Choose a search result to load its neighborhood.</p>
</div> </div>

16
stylelint.config.mjs Normal file
View file

@ -0,0 +1,16 @@
export default {
extends: ["stylelint-config-standard"],
plugins: ["stylelint-csstree-validator"],
rules: {
"csstree/validator": true,
"alpha-value-notation": null,
"at-rule-empty-line-before": null,
"color-function-alias-notation": null,
"color-function-notation": null,
"custom-property-empty-line-before": null,
"declaration-block-single-line-max-declarations": null,
"declaration-empty-line-before": null,
"media-feature-range-notation": null,
"rule-empty-line-before": null,
},
};

View file

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">

View file

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">

131
tools/check_web_assets.py Normal file
View file

@ -0,0 +1,131 @@
"""Validate the exact embedded graph-browser HTML, CSS, and JavaScript."""
from __future__ import annotations
import ast
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import cast
from docforge.index import ProjectIndex
from docforge.project import Project
from docforge.rendering import RenderService
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "src" / "docforge" / "visualization.py"
def _graph_browser_html() -> str:
tree = ast.parse(SOURCE.read_text(encoding="utf-8"), filename=str(SOURCE))
for statement in tree.body:
if not isinstance(statement, ast.Assign):
continue
if any(
isinstance(target, ast.Name) and target.id == "_GRAPH_BROWSER_HTML"
for target in statement.targets
):
value = ast.literal_eval(statement.value)
if isinstance(value, str):
return value
raise RuntimeError("Could not find the literal graph-browser HTML asset")
def _embedded(html: str, tag: str) -> str:
opening = f"<{tag}>"
closing = f"</{tag}>"
if html.count(opening) != 1 or html.count(closing) != 1:
raise RuntimeError(f"Expected exactly one embedded {tag} asset")
return html.split(opening, 1)[1].split(closing, 1)[0]
def _rendered_manual_html() -> str:
with tempfile.TemporaryDirectory() as directory:
fixture = ROOT / "tests" / "fixtures" / "alpha"
project_root = Path(directory) / "alpha"
shutil.copytree(fixture, project_root)
project = Project.open(project_root)
ProjectIndex(project).build()
result = RenderService(project).render("manual")
output = result.get("output")
if not isinstance(output, dict):
raise RuntimeError("Manual render did not return output identity")
relative_path = cast(dict[str, object], output).get("path")
if not isinstance(relative_path, str):
raise RuntimeError("Manual render did not return an output path")
return (project_root / relative_path).read_text(encoding="utf-8")
def _tool(name: str) -> str:
suffix = ".cmd" if os.name == "nt" else ""
path = ROOT / "node_modules" / ".bin" / f"{name}{suffix}"
if not path.is_file():
raise RuntimeError(f"Missing web validator {name}; run npm ci")
return str(path)
def _run(label: str, command: list[str], content: str) -> bool:
print(f"Checking {label}...")
result = subprocess.run(
command,
cwd=ROOT,
input=content,
text=True,
check=False,
)
return result.returncode == 0
def main() -> int:
html = _graph_browser_html()
checks = (
(
"HTML",
[
_tool("html-validate"),
"--stdin",
"--stdin-filename=graph-browser.html",
"--max-warnings=0",
],
html,
),
(
"rendered manual HTML",
[
_tool("html-validate"),
"--stdin",
"--stdin-filename=rendered-manual.html",
"--max-warnings=0",
],
_rendered_manual_html(),
),
(
"CSS",
[
_tool("stylelint"),
"--stdin",
"--stdin-filename=graph-browser.css",
"--max-warnings=0",
],
_embedded(html, "style"),
),
(
"JavaScript",
[
_tool("eslint"),
"--stdin",
"--stdin-filename=graph-browser.js",
"--max-warnings=0",
],
_embedded(html, "script"),
),
)
results = [_run(label, command, content) for label, command, content in checks]
return 0 if all(results) else 1
if __name__ == "__main__":
sys.exit(main())