From d750a1426cc0debf6c2793a33b6fdd92f121e62b Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Tue, 28 Jul 2026 20:02:29 -0700 Subject: [PATCH 01/11] tools(docs-audit): verify docs against the code they describe Adds a bidirectional documentation audit so doc cleanup is a review task rather than an archaeology task after several rounds of renaming. doc -> code: every path a doc cites is classified with the evidence behind the verdict, not checked as a boolean. A naive "is this path on disk" test calls 68% of this repo's citations dead; almost all of that is false, and acting on it deletes correct docs. Files the reader is told to create, names the code builds at runtime, changelog entries about deleted files, and counter-examples ("NOT: `.pipeline.json`") all look identical to rot under an exists-check. Only ORPHANED -- no path, no basename, no source literal -- is a deletion candidate, and it is reported, never auto-applied. code -> doc: nodes shipping Python with no README, READMEs with no generated params block, and generated blocks that disagree with services*.json. Profile groupings in `fields` (object/properties rather than type) are excluded, since nodes:docs-generate omits them and counting them reports phantom drift on every profile-based node. Current state of this tree: 381 citations -> 380 accounted for, 1 genuine dead reference; 14 code->doc gaps. Every test named test_placeholder_*, test_counter_example_*, or test_profile_groups_* pins a false positive an earlier revision produced. Verified: pytest 16 passed; ruff check + format clean. --- tools/docs_audit/README.md | 69 +++++++ tools/docs_audit/cli.py | 22 +++ tools/docs_audit/src/docs_audit/__init__.py | 1 + tools/docs_audit/src/docs_audit/citations.py | 178 +++++++++++++++++++ tools/docs_audit/src/docs_audit/cli.py | 98 ++++++++++ tools/docs_audit/src/docs_audit/coverage.py | 132 ++++++++++++++ tools/docs_audit/src/docs_audit/index.py | 118 ++++++++++++ tools/docs_audit/test/__init__.py | 0 tools/docs_audit/test/test_docs_audit.py | 174 ++++++++++++++++++ 9 files changed, 792 insertions(+) create mode 100644 tools/docs_audit/README.md create mode 100644 tools/docs_audit/cli.py create mode 100644 tools/docs_audit/src/docs_audit/__init__.py create mode 100644 tools/docs_audit/src/docs_audit/citations.py create mode 100644 tools/docs_audit/src/docs_audit/cli.py create mode 100644 tools/docs_audit/src/docs_audit/coverage.py create mode 100644 tools/docs_audit/src/docs_audit/index.py create mode 100644 tools/docs_audit/test/__init__.py create mode 100644 tools/docs_audit/test/test_docs_audit.py diff --git a/tools/docs_audit/README.md b/tools/docs_audit/README.md new file mode 100644 index 000000000..1f37f59b1 --- /dev/null +++ b/tools/docs_audit/README.md @@ -0,0 +1,69 @@ +# docs-audit: verify documentation against the code it describes + +Answers two questions mechanically, so doc cleanup is a review task instead of +an archaeology task: + +1. **Does every path a doc cites actually exist?** (delete/fix candidates) +2. **Does every node that ships code actually have docs?** (write candidates) + +## Why it is not a `grep -c` + +The obvious version of this tool — "flag every cited path that isn't on disk" — +reports **68% of this repo's doc citations as dead**. Nearly all of that is +wrong, and acting on it deletes correct documentation. Three ways a citation +looks dead while being right: + +| Doc says | On disk | Actually | +| --- | --- | --- | +| ``Save this as `extract.pipe`:`` | absent | a file the **reader** creates | +| ``Writes `version.docker.json``` | absent | built at runtime by `apps/vscode/src/engine/docker/engine-docker.ts` | +| ``**NOT:** `.pipeline.json``` | absent | a **counter-example** — deleting it reintroduces the mistake the doc prevents | + +So every citation gets a **class plus the evidence behind it**, and only one +class is ever a deletion candidate: + +- `VERIFIED` — resolves to a real path, or some file in the tree has that basename +- `PLACEHOLDER` — create-verb prose, a scaffolding tree, or an illustrative example +- `HISTORICAL` — a changelog naming a deleted file is correct by definition +- `RUNTIME` — no file at rest, but source code constructs the name +- `ORPHANED` — no path, no basename, no source literal → **review it** + +`ORPHANED` is never auto-deleted. The tool reports; a human decides. + +## Code → doc + +Ordered by how loudly the gap misleads a reader: + +- `STALE_PARAMS` — the generated schema table disagrees with `services*.json`. + Confidently wrong, which is worse than absent. Fix by re-running + `nodes:docs-generate` — never by hand-editing the generated block. +- `MISSING_PARAMS` — a node README with no generated block at all. +- `MISSING_DOC` — a node ships Python and has no README. + +Profile groupings in `fields` (entries carrying `object`/`properties` rather +than `type`) are **not** parameters; `nodes:docs-generate` omits them from the +table, so counting them reports phantom drift on every profile-based node. + +## Run it + +```sh +python3 tools/docs_audit/cli.py --root . +python3 tools/docs_audit/cli.py --root . --json # machine-readable +python3 tools/docs_audit/cli.py --root . --fail-on-orphaned # CI gate +``` + +Tests: + +```sh +python3 -m pytest tools/docs_audit/test/ -q +``` + +Every test named `test_placeholder_*`, `test_counter_example_*`, or +`test_profile_groups_*` pins a false positive an earlier version of this tool +actually produced. Keep them. + +## Scope + +Path-level citations only. Symbol-level checking (does this doc's +`session.display.render()` still match the signature?) is a natural extension +and is not implemented. diff --git a/tools/docs_audit/cli.py b/tools/docs_audit/cli.py new file mode 100644 index 000000000..4ac8f7df3 --- /dev/null +++ b/tools/docs_audit/cli.py @@ -0,0 +1,22 @@ +""" +Thin CLI entry point for local debugging. + +Invoke as: python tools/docs_audit/cli.py [--root=...] [--json] + +Adjusts sys.path so the ``docs_audit`` package is importable, then delegates +to :mod:`docs_audit.cli`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SRC = Path(__file__).resolve().parent / 'src' +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from docs_audit.cli import main # noqa: E402 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/docs_audit/src/docs_audit/__init__.py b/tools/docs_audit/src/docs_audit/__init__.py new file mode 100644 index 000000000..5119fad24 --- /dev/null +++ b/tools/docs_audit/src/docs_audit/__init__.py @@ -0,0 +1 @@ +"""Documentation audit: verify docs against the code they claim to describe.""" diff --git a/tools/docs_audit/src/docs_audit/citations.py b/tools/docs_audit/src/docs_audit/citations.py new file mode 100644 index 000000000..56dbfd4b6 --- /dev/null +++ b/tools/docs_audit/src/docs_audit/citations.py @@ -0,0 +1,178 @@ +"""Doc -> code direction: every path a doc cites, classified with evidence. + +A naive "does this path exist?" check reports ~68% of this repo's doc citations +as dead. Nearly all of that is false: docs legitimately name files the *reader* +creates, files that only ever exist at runtime, and files that were deleted on +purpose (changelogs). Deleting on that signal destroys correct documentation. + +So a citation is not boolean, it is classified: + +``VERIFIED`` resolves to a real path in the tree +``PLACEHOLDER`` prose tells the reader to create it -- protected +``HISTORICAL`` changelog/release note describing the past -- protected +``RUNTIME`` no file at rest, but source code builds the name -- protected +``ORPHANED`` no referent found anywhere -- the only deletion candidate + +Every verdict carries evidence so a human can check the tool's work. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from .index import CodeIndex + +VERIFIED = 'VERIFIED' +PLACEHOLDER = 'PLACEHOLDER' +HISTORICAL = 'HISTORICAL' +RUNTIME = 'RUNTIME' +ORPHANED = 'ORPHANED' + +PROTECTED = frozenset({VERIFIED, PLACEHOLDER, HISTORICAL, RUNTIME}) + +_EXT = r'\.(?:py|ts|tsx|js|mjs|cjs|json|cpp|cc|h|hpp|cmake|toml|yaml|yml|sh|cmd|pipe|mdx?|env|tsv|csv)' +# An inline-code span that looks like a file or directory reference. +_PATH_SPAN = re.compile(r'`([A-Za-z0-9_.][A-Za-z0-9_.\-/]*' + _EXT + r')`') +_DIR_SPAN = re.compile(r'`((?:nodes|packages|apps|tools|docs|scripts|examples|deploy|docker)/[A-Za-z0-9_.\-/]+)`') + +# Prose that introduces a file the reader is about to make. +_CREATE_VERB = re.compile( + r'\b(?:create|creating|add|adding|new|name\s+it|call\s+it|save\s+(?:this|it|that)?\s*as|scaffold|' + r'generate|write|make|touch|rename\s+to|copy\s+to|place\s+in|put\s+in)\b', + re.IGNORECASE, +) +# Prose citing a name to ILLUSTRATE a convention rather than to point at a file. +# Includes counter-examples ("NOT: `.pipeline.json`"), which are the most +# dangerous thing a cleanup pass can delete: removing them reintroduces exactly +# the mistake the doc exists to prevent. +_ILLUSTRATION = re.compile( + r'(?:\bexamples?\s*:|\be\.g\.|\bfor\s+example\b|\bsuch\s+as\b|\blike\b\s*:|' + r'\bNOT\s*:|\bnot\b\s*:|\bavoid\b|\binstead\s+of\b|\buse\s+descriptive\s+names?\b|' + r'\binclude\s+purpose\b|\bnaming\b)', + re.IGNORECASE, +) +# ASCII tree drawings in scaffolding docs. +_TREE_GLYPH = re.compile(r'[├└│]|^\s*[-*]?\s*\|--') +# Template-ish stems that are obviously stand-ins, not real repo files. +_TEMPLATE_STEM = re.compile(r'^(?:my|your|example|sample|foo|bar|placeholder|some|test)[-_A-Z]', re.IGNORECASE) + +# Docs whose entire job is to describe the past. +_HISTORICAL_DOCS = re.compile( + r'(?:^|/)(?:CHANGELOG|RELEASE|RELEASES|HISTORY|MIGRATION|UPGRADING)[^/]*\.mdx?$', re.IGNORECASE +) + + +@dataclass(frozen=True) +class Citation: + """One path-like token cited by one doc at one line.""" + + token: str + doc: str + line: int + + +@dataclass(frozen=True) +class Verdict: + citation: Citation + verdict: str + evidence: str + + @property + def is_protected(self) -> bool: + return self.verdict in PROTECTED + + +def _strip_fenced_blocks(text: str) -> list[str]: + """Return lines with fenced code-block bodies blanked out. + + Citations inside a fence are usually sample output or config the reader + pastes, not claims about this repo's layout. We keep the line count stable + so reported line numbers still point at the real file. + """ + lines = text.splitlines() + out: list[str] = [] + in_fence = False + for line in lines: + if re.match(r'^\s*(?:```|~~~)', line): + in_fence = not in_fence + out.append('') + continue + out.append('' if in_fence else line) + return out + + +def extract(text: str, doc: str) -> list[Citation]: + """Every distinct path-like citation in ``text``, with line numbers.""" + seen: set[tuple[str, int]] = set() + found: list[Citation] = [] + for number, line in enumerate(_strip_fenced_blocks(text), start=1): + for pattern in (_PATH_SPAN, _DIR_SPAN): + for token in pattern.findall(line): + token = token.rstrip('/') + key = (token, number) + if key in seen: + continue + seen.add(key) + found.append(Citation(token=token, doc=doc, line=number)) + return found + + +def _context(lines: list[str], line: int, before: int = 2) -> str: + """The cited line plus a little preceding prose, for intent detection.""" + start = max(0, line - 1 - before) + return '\n'.join(lines[start:line]) + + +def classify(citation: Citation, index: CodeIndex, doc_lines: list[str]) -> Verdict: + """Classify one citation, attaching the evidence behind the verdict.""" + token = citation.token + doc_dir = Path(citation.doc).parent + + # 1. Resolves relative to the citing doc, or to the repo root. + sibling = (doc_dir / token).as_posix().lstrip('./') + if index.has_path(sibling): + return Verdict(citation, VERIFIED, f'path exists: {sibling}') + if index.has_path(token): + return Verdict(citation, VERIFIED, f'path exists: {token}') + + # 2. Some file in the tree has this basename -- loosely worded, not wrong. + basename = Path(token).name + matches = index.find_basename(basename) + if matches: + return Verdict(citation, VERIFIED, f'basename matches {len(matches)} path(s), e.g. {matches[0]}') + + # 3. A changelog naming a deleted file is correct by definition. + if _HISTORICAL_DOCS.search(citation.doc): + return Verdict(citation, HISTORICAL, f'{citation.doc} documents past state') + + # 4. Prose tells the reader to create it, or it is a template stand-in. + context = _context(doc_lines, citation.line) + if _CREATE_VERB.search(context): + return Verdict(citation, PLACEHOLDER, f'create-verb in context at line {citation.line}') + if _ILLUSTRATION.search(doc_lines[citation.line - 1] if citation.line <= len(doc_lines) else ''): + return Verdict(citation, PLACEHOLDER, f'illustrative naming example at line {citation.line}') + if _TREE_GLYPH.search(doc_lines[citation.line - 1] if citation.line <= len(doc_lines) else ''): + return Verdict(citation, PLACEHOLDER, f'inside a directory-tree diagram at line {citation.line}') + if _TEMPLATE_STEM.search(basename): + return Verdict(citation, PLACEHOLDER, f'template stem: {basename}') + + # 5. No file at rest, but the code constructs the name at runtime. + literal = index.find_literal(basename) + if literal is not None: + where, where_line = literal + return Verdict(citation, RUNTIME, f'source builds this name: {where}:{where_line}') + + return Verdict(citation, ORPHANED, 'no path, basename, or source literal found') + + +def audit_doc(path: Path, root: Path, index: CodeIndex) -> list[Verdict]: + """Classify every citation in a single doc.""" + try: + text = path.read_text(encoding='utf-8', errors='replace') + except OSError: + return [] + doc = path.relative_to(root).as_posix() + lines = text.splitlines() + return [classify(citation, index, lines) for citation in extract(text, doc)] diff --git a/tools/docs_audit/src/docs_audit/cli.py b/tools/docs_audit/src/docs_audit/cli.py new file mode 100644 index 000000000..300c7691d --- /dev/null +++ b/tools/docs_audit/src/docs_audit/cli.py @@ -0,0 +1,98 @@ +"""Command-line entry point for the documentation audit.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from .citations import ORPHANED, audit_doc +from .coverage import MISSING_DOC, MISSING_PARAMS, STALE_PARAMS, audit_nodes +from .index import CodeIndex, is_excluded + +DOC_SUFFIXES = ('.md', '.mdx') + + +def _docs(root: Path): + for suffix in DOC_SUFFIXES: + for path in root.rglob(f'*{suffix}'): + if not is_excluded(path.relative_to(root)): + yield path + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog='docs-audit', + description='Audit documentation against the code it claims to describe.', + ) + parser.add_argument('--root', default='.', help='Repository root (default: cwd)') + parser.add_argument('--json', action='store_true', help='Emit machine-readable JSON') + parser.add_argument( + '--fail-on-orphaned', + action='store_true', + help='Exit non-zero if any ORPHANED citation is found (for CI)', + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + root = Path(args.root).resolve() + + index = CodeIndex.build(root) + verdicts = [verdict for path in _docs(root) for verdict in audit_doc(path, root, index)] + gaps = audit_nodes(root) + + orphaned = [v for v in verdicts if v.verdict == ORPHANED] + + if args.json: + payload = { + 'citations': { + 'total': len(verdicts), + 'by_verdict': { + verdict: sum(1 for v in verdicts if v.verdict == verdict) + for verdict in sorted({v.verdict for v in verdicts}) + }, + 'orphaned': [ + {'doc': v.citation.doc, 'line': v.citation.line, 'token': v.citation.token, 'evidence': v.evidence} + for v in orphaned + ], + }, + 'coverage': [{'kind': g.kind, 'node': g.node, 'detail': g.detail} for g in gaps], + } + json.dump(payload, sys.stdout, indent=2) + sys.stdout.write('\n') + return 1 if (args.fail_on_orphaned and orphaned) else 0 + + print(f'Scanned {len(verdicts)} doc->code citations across the tree.\n') + print(' verdict count meaning') + print(' --------------- ----- -------') + labels = { + 'VERIFIED': 'resolves to real code', + 'PLACEHOLDER': 'reader creates it (protected)', + 'HISTORICAL': 'describes the past (protected)', + 'RUNTIME': 'built at runtime (protected)', + 'ORPHANED': 'no referent -> review for deletion', + } + for verdict in ('VERIFIED', 'PLACEHOLDER', 'HISTORICAL', 'RUNTIME', 'ORPHANED'): + count = sum(1 for v in verdicts if v.verdict == verdict) + print(f' {verdict:<15} {count:>5} {labels[verdict]}') + + if orphaned: + print(f'\nORPHANED citations ({len(orphaned)}) -- each needs a human decision:\n') + for verdict in sorted(orphaned, key=lambda v: (v.citation.doc, v.citation.line)): + citation = verdict.citation + print(f' {citation.doc}:{citation.line} `{citation.token}`') + + if gaps: + print(f'\nUndocumented / drifted code ({len(gaps)}):\n') + for kind in (STALE_PARAMS, MISSING_PARAMS, MISSING_DOC): + matching = [g for g in gaps if g.kind == kind] + if not matching: + continue + print(f' [{kind}] {len(matching)}') + for gap in matching: + print(f' {gap.node}: {gap.detail}') + + return 1 if (args.fail_on_orphaned and orphaned) else 0 diff --git a/tools/docs_audit/src/docs_audit/coverage.py b/tools/docs_audit/src/docs_audit/coverage.py new file mode 100644 index 000000000..b756a344d --- /dev/null +++ b/tools/docs_audit/src/docs_audit/coverage.py @@ -0,0 +1,132 @@ +"""Code -> doc direction: public surface that documentation fails to cover. + +Three findings, ordered by how loudly they mislead a reader: + +``STALE_PARAMS`` the generated schema table disagrees with ``services*.json``. + Worst kind: confidently wrong. Happens when someone edits a + node's schema and never re-runs ``nodes:docs-generate``. +``MISSING_PARAMS`` a node README with no generated block at all, contrary to + the co-located documentation rule in AGENTS.md. +``MISSING_DOC`` a node that ships Python but no README whatsoever. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +STALE_PARAMS = 'STALE_PARAMS' +MISSING_PARAMS = 'MISSING_PARAMS' +MISSING_DOC = 'MISSING_DOC' + +_GENERATED_BLOCK = re.compile( + r'(.*?)', + re.DOTALL, +) +# A row of the generated schema table: | `key` | `type` | ... | +_PARAM_ROW = re.compile(r'^\|\s*`([^`]+)`\s*\|', re.MULTILINE) + + +@dataclass(frozen=True) +class Gap: + """One documentation gap, with the evidence that proves it.""" + + kind: str + node: str + detail: str + + +def documented_params(readme_text: str) -> set[str] | None: + """Param keys listed in the README's generated block, or None if absent.""" + match = _GENERATED_BLOCK.search(readme_text) + if match is None: + return None + return set(_PARAM_ROW.findall(match.group(1))) + + +def _is_user_facing_param(value: object) -> bool: + """True for a real settable parameter, false for a profile grouping. + + ``fields`` holds two different kinds of entry. A parameter carries a + ``type`` (``{"type": "string", "title": ...}``). A profile group carries + ``object``/``properties`` and merely bundles other keys under a preset -- + ``nodes:docs-generate`` does not put those in the schema table, so neither + do we, or every profile-based node reports phantom drift. + """ + return isinstance(value, dict) and 'type' in value and 'object' not in value + + +def schema_params(node_dir: Path) -> set[str]: + """User-facing param keys declared across every ``services*.json``.""" + keys: set[str] = set() + for services in sorted(node_dir.glob('services*.json')): + try: + data = json.loads(services.read_text(encoding='utf-8', errors='replace')) + except (OSError, json.JSONDecodeError): + continue + fields = data.get('fields') + if isinstance(fields, dict): + keys.update(key for key, value in fields.items() if _is_user_facing_param(value)) + return keys + + +def audit_node(node_dir: Path, root: Path) -> list[Gap]: + """Every documentation gap for a single node directory.""" + name = node_dir.name + has_python = any(node_dir.glob('*.py')) + if not has_python: + return [] + + readme = node_dir / 'README.md' + if not readme.exists(): + count = len(list(node_dir.glob('*.py'))) + return [Gap(MISSING_DOC, name, f'{count} Python file(s), no README.md')] + + try: + text = readme.read_text(encoding='utf-8', errors='replace') + except OSError: + return [] + + declared = schema_params(node_dir) + documented = documented_params(text) + + if documented is None: + if declared: + return [ + Gap( + MISSING_PARAMS, + name, + f'{len(declared)} param(s) in services*.json, no ROCKETRIDE:GENERATED:PARAMS block', + ) + ] + return [] + + # Only meaningful when the node actually declares a schema; a node with no + # fields legitimately generates an empty table. + if not declared: + return [] + + undocumented = declared - documented + phantom = documented - declared + if not undocumented and not phantom: + return [] + + parts = [] + if undocumented: + parts.append(f'in schema but not in docs: {", ".join(sorted(undocumented)[:6])}') + if phantom: + parts.append(f'in docs but not in schema: {", ".join(sorted(phantom)[:6])}') + return [Gap(STALE_PARAMS, name, '; '.join(parts) + ' (re-run nodes:docs-generate)')] + + +def audit_nodes(root: Path) -> list[Gap]: + """Every documentation gap across every node.""" + nodes_root = root / 'nodes' / 'src' / 'nodes' + if not nodes_root.is_dir(): + return [] + gaps: list[Gap] = [] + for node_dir in sorted(p for p in nodes_root.iterdir() if p.is_dir()): + gaps.extend(audit_node(node_dir, root)) + return gaps diff --git a/tools/docs_audit/src/docs_audit/index.py b/tools/docs_audit/src/docs_audit/index.py new file mode 100644 index 000000000..e81e9aa4a --- /dev/null +++ b/tools/docs_audit/src/docs_audit/index.py @@ -0,0 +1,118 @@ +"""Read-only index of the repository's source tree. + +Built once per run and shared by every check. Everything here is a lookup the +classifier needs in order to attach *evidence* to a verdict: not just "this +citation is dead" but "no file, basename, or source literal named X exists". +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +# Trees that are vendored, generated, or otherwise not ours to audit. +EXCLUDED_PARTS = frozenset( + { + '.git', + 'node_modules', + 'site-packages', + 'engine-lib', + 'dist', + 'build', + '__pycache__', + '.venv', + 'venv', + '.eggs', + } +) + +# Source extensions we search for citation referents. +SOURCE_SUFFIXES = frozenset( + { + '.py', + '.ts', + '.tsx', + '.js', + '.mjs', + '.cjs', + '.cpp', + '.cc', + '.h', + '.hpp', + '.cmake', + '.json', + '.toml', + '.yaml', + '.yml', + '.sh', + '.cmd', + } +) + +# Skip files larger than this when scanning for string literals. Lockfiles and +# generated blobs blow up the scan and never contain meaningful referents. +MAX_SCAN_BYTES = 512 * 1024 + + +def is_excluded(relpath: Path) -> bool: + """True if any path segment is in an excluded tree.""" + return any(part in EXCLUDED_PARTS for part in relpath.parts) + + +@dataclass +class CodeIndex: + """Paths and source text of the auditable tree.""" + + root: Path + paths: set[str] = field(default_factory=set) + basenames: dict[str, list[str]] = field(default_factory=dict) + _sources: dict[str, str] = field(default_factory=dict) + + @classmethod + def build(cls, root: Path) -> CodeIndex: + root = root.resolve() + index = cls(root=root) + for dirpath, dirnames, filenames in os.walk(root): + rel_dir = Path(dirpath).relative_to(root) + # Prune excluded directories in place so os.walk never descends them. + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_PARTS] + if is_excluded(rel_dir): + continue + for name in list(dirnames) + filenames: + rel = (rel_dir / name).as_posix().lstrip('./') + index.paths.add(rel) + index.basenames.setdefault(name, []).append(rel) + for name in filenames: + path = Path(dirpath) / name + if path.suffix.lower() not in SOURCE_SUFFIXES: + continue + try: + if path.stat().st_size > MAX_SCAN_BYTES: + continue + text = path.read_text(encoding='utf-8', errors='replace') + except OSError: + continue + index._sources[(rel_dir / name).as_posix().lstrip('./')] = text + return index + + def has_path(self, relpath: str) -> bool: + return relpath.strip('/') in self.paths + + def find_basename(self, basename: str) -> list[str]: + """Every indexed path whose final segment is ``basename``.""" + return self.basenames.get(basename, []) + + def find_literal(self, token: str) -> tuple[str, int] | None: + """First ``(relpath, line_number)`` where ``token`` appears in source text. + + This is what separates a genuinely dead reference from one naming a + path the code builds at runtime (a doc citing ``version.docker.json`` + is correct even though no such file exists at rest, because + ``engine-docker.ts`` constructs it). + """ + for relpath, text in self._sources.items(): + position = text.find(token) + if position != -1: + return relpath, text.count('\n', 0, position) + 1 + return None diff --git a/tools/docs_audit/test/__init__.py b/tools/docs_audit/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py new file mode 100644 index 000000000..ae145881a --- /dev/null +++ b/tools/docs_audit/test/test_docs_audit.py @@ -0,0 +1,174 @@ +"""Tests for the documentation audit. + +The two regression tests that matter most are ``test_placeholder_*`` and +``test_profile_groups_*``: each pins a false positive that an earlier version +of this tool produced, and each would have caused correct documentation to be +deleted or a clean node to be reported as drifted. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +_SRC = Path(__file__).resolve().parents[1] / 'src' +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +from docs_audit.citations import ( # noqa: E402 + HISTORICAL, + ORPHANED, + PLACEHOLDER, + RUNTIME, + VERIFIED, + classify, + extract, +) +from docs_audit.coverage import MISSING_DOC, STALE_PARAMS, audit_node, schema_params # noqa: E402 +from docs_audit.index import CodeIndex # noqa: E402 + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + (tmp_path / 'pkg').mkdir() + (tmp_path / 'pkg' / 'real.py').write_text("PATH = 'built_at_runtime.json'\n", encoding='utf-8') + return tmp_path + + +def _classify(text: str, repo: Path, doc: str = 'docs/guide.md') -> list: + index = CodeIndex.build(repo) + lines = text.splitlines() + return [classify(c, index, lines) for c in extract(text, doc)] + + +def test_extract_finds_path_citations() -> None: + found = extract('See `pkg/real.py` for details.', 'docs/guide.md') + assert [c.token for c in found] == ['pkg/real.py'] + + +def test_extract_ignores_fenced_blocks() -> None: + text = '```\n`pkg/inside_fence.py`\n```\n`pkg/outside.py`\n' + assert [c.token for c in extract(text, 'd.md')] == ['pkg/outside.py'] + + +def test_verified_when_path_exists(repo: Path) -> None: + (verdict,) = _classify('See `pkg/real.py`.', repo) + assert verdict.verdict == VERIFIED + + +def test_verified_by_basename_when_path_is_loose(repo: Path) -> None: + """`real.py` alone is loosely worded, not wrong -- it must not be deleted.""" + (verdict,) = _classify('See `real.py`.', repo) + assert verdict.verdict == VERIFIED + + +def test_orphaned_when_nothing_matches(repo: Path) -> None: + (verdict,) = _classify('See `pkg/ghost_module.py`.', repo) + assert verdict.verdict == ORPHANED + + +def test_placeholder_when_prose_says_create(repo: Path) -> None: + """Regression: docs naming a file the READER creates are not stale.""" + (verdict,) = _classify('Create the entry point (`chat.pipe`).', repo) + assert verdict.verdict == PLACEHOLDER + + +def test_placeholder_inside_tree_diagram(repo: Path) -> None: + """Regression: scaffolding trees name template files, not repo files.""" + text = 'Layout:\n └── `src/MyApp.tsx` # client area\n' + verdicts = [v for v in _classify(text, repo) if v.citation.token == 'src/MyApp.tsx'] + assert verdicts and verdicts[0].verdict == PLACEHOLDER + + +def test_placeholder_save_this_as(repo: Path) -> None: + """Regression: 'Save this as X' is a create instruction, not a claim.""" + (verdict,) = _classify('Save this as `extract.pipe`:', repo) + assert verdict.verdict == PLACEHOLDER + + +def test_placeholder_naming_illustration(repo: Path) -> None: + """Regression: 'Examples: `a.pipe`' illustrates a convention.""" + (verdict,) = _classify('**Examples:** `document_processor.pipe`', repo) + assert verdict.verdict == PLACEHOLDER + + +def test_counter_example_is_never_orphaned(repo: Path) -> None: + """Regression: deleting a 'NOT: `x`' line reintroduces the very mistake + the doc exists to prevent. This is the highest-cost false positive. + """ + (verdict,) = _classify('- **NOT:** `.json` or `.pipeline.json`', repo) + assert verdict.verdict == PLACEHOLDER + + +def test_historical_doc_is_protected(repo: Path) -> None: + """A changelog naming a deleted file is correct by definition.""" + (verdict,) = _classify('Removed `pkg/deleted_thing.py`.', repo, doc='CHANGELOG.md') + assert verdict.verdict == HISTORICAL + + +def test_runtime_path_built_by_code_is_protected(repo: Path) -> None: + """Regression: a file that only exists at runtime is still documented correctly.""" + (verdict,) = _classify('Writes `built_at_runtime.json`.', repo) + assert verdict.verdict == RUNTIME + assert 'pkg/real.py' in verdict.evidence + + +def _node(root: Path, name: str, fields: dict, readme: str | None) -> Path: + node = root / 'nodes' / 'src' / 'nodes' / name + node.mkdir(parents=True) + (node / 'impl.py').write_text('x = 1\n', encoding='utf-8') + (node / 'services.json').write_text(json.dumps({'fields': fields}), encoding='utf-8') + if readme is not None: + (node / 'README.md').write_text(readme, encoding='utf-8') + return node + + +def _block(*keys: str) -> str: + rows = '\n'.join(f'| `{k}` | `string` | desc | |' for k in keys) + return f'\n{rows}\n\n' + + +def test_profile_groups_are_not_params(tmp_path: Path) -> None: + """Regression: `object`/`properties` entries are groupings, not settable params. + + Counting them made 8 clean nodes report phantom drift. + """ + node = _node( + tmp_path, + 'grouped', + { + 'model': {'type': 'string', 'title': 'Model'}, + 'grouped.fast': {'object': 'fast', 'properties': ['model']}, + }, + _block('model'), + ) + assert schema_params(node) == {'model'} + assert audit_node(node, tmp_path) == [] + + +def test_stale_params_detected_when_block_misses_a_real_param(tmp_path: Path) -> None: + node = _node( + tmp_path, + 'drifted', + {'a': {'type': 'string'}, 'b': {'type': 'boolean'}}, + _block('a'), + ) + (gap,) = audit_node(node, tmp_path) + assert gap.kind == STALE_PARAMS + assert 'b' in gap.detail + + +def test_missing_doc_for_node_with_code_and_no_readme(tmp_path: Path) -> None: + node = _node(tmp_path, 'undocumented', {'a': {'type': 'string'}}, readme=None) + (gap,) = audit_node(node, tmp_path) + assert gap.kind == MISSING_DOC + + +def test_node_without_python_is_not_a_gap(tmp_path: Path) -> None: + node = tmp_path / 'nodes' / 'src' / 'nodes' / 'assets_only' + node.mkdir(parents=True) + (node / 'icon.svg').write_text('', encoding='utf-8') + assert audit_node(node, tmp_path) == [] From 8f9de9c143cfc6da675d9c8011d0676c74e7c3f9 Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Tue, 28 Jul 2026 20:05:00 -0700 Subject: [PATCH 02/11] tools(docs-audit): parse JSONC services.json instead of failing silently detect_segment/services.json and video_composer/services.json ship `//` comments. json.loads raises on those, and the except branch treated the node as declaring zero parameters -- so drift in any commented schema could never surface. Strips `//` line comments with quote-state tracking so a `https://` inside a string value survives. Unmasks 14 further drifted nodes (STALE_PARAMS 4 -> 18), including graph_falkordb, whose README still documents the pre-rename `tool_falkordb.*` keys. Verified: pytest 18 passed; ruff check + format clean. --- tools/docs_audit/src/docs_audit/coverage.py | 40 ++++++++++++++++++++- tools/docs_audit/test/test_docs_audit.py | 30 +++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/tools/docs_audit/src/docs_audit/coverage.py b/tools/docs_audit/src/docs_audit/coverage.py index b756a344d..3d942fece 100644 --- a/tools/docs_audit/src/docs_audit/coverage.py +++ b/tools/docs_audit/src/docs_audit/coverage.py @@ -46,6 +46,44 @@ def documented_params(readme_text: str) -> set[str] | None: return set(_PARAM_ROW.findall(match.group(1))) +def strip_jsonc(text: str) -> str: + """Drop ``//`` line comments so JSONC ``services.json`` files parse. + + Several nodes ship commented schemas. A plain ``json.loads`` raises on + those, and swallowing the error silently reports the node as having no + parameters at all -- so real drift would never surface. Quote state is + tracked so ``https://`` inside a string value survives. + """ + out: list[str] = [] + in_string = False + escaped = False + index = 0 + while index < len(text): + char = text[index] + if in_string: + out.append(char) + if escaped: + escaped = False + elif char == '\\': + escaped = True + elif char == '"': + in_string = False + index += 1 + continue + if char == '"': + in_string = True + out.append(char) + index += 1 + continue + if char == '/' and index + 1 < len(text) and text[index + 1] == '/': + while index < len(text) and text[index] != '\n': + index += 1 + continue + out.append(char) + index += 1 + return ''.join(out) + + def _is_user_facing_param(value: object) -> bool: """True for a real settable parameter, false for a profile grouping. @@ -63,7 +101,7 @@ def schema_params(node_dir: Path) -> set[str]: keys: set[str] = set() for services in sorted(node_dir.glob('services*.json')): try: - data = json.loads(services.read_text(encoding='utf-8', errors='replace')) + data = json.loads(strip_jsonc(services.read_text(encoding='utf-8', errors='replace'))) except (OSError, json.JSONDecodeError): continue fields = data.get('fields') diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py index ae145881a..2bbab664b 100644 --- a/tools/docs_audit/test/test_docs_audit.py +++ b/tools/docs_audit/test/test_docs_audit.py @@ -27,7 +27,13 @@ classify, extract, ) -from docs_audit.coverage import MISSING_DOC, STALE_PARAMS, audit_node, schema_params # noqa: E402 +from docs_audit.coverage import ( # noqa: E402 + MISSING_DOC, + STALE_PARAMS, + audit_node, + schema_params, + strip_jsonc, +) from docs_audit.index import CodeIndex # noqa: E402 @@ -172,3 +178,25 @@ def test_node_without_python_is_not_a_gap(tmp_path: Path) -> None: node.mkdir(parents=True) (node / 'icon.svg').write_text('', encoding='utf-8') assert audit_node(node, tmp_path) == [] + + +def test_jsonc_services_file_is_parsed(tmp_path: Path) -> None: + """Regression: several nodes ship `//`-commented schemas. Failing to parse + them silently reported the node as having zero params, so real drift could + never surface. + """ + node = tmp_path / 'nodes' / 'src' / 'nodes' / 'commented' + node.mkdir(parents=True) + (node / 'impl.py').write_text('x = 1\n', encoding='utf-8') + (node / 'services.json').write_text( + '{\n\t//\n\t// Required:\n\t//\n\t"fields": {"a": {"type": "string"}}\n}\n', + encoding='utf-8', + ) + assert schema_params(node) == {'a'} + + +def test_strip_jsonc_keeps_urls_inside_strings() -> None: + """A `//` inside a quoted value is data, not a comment.""" + kept = strip_jsonc('{"url": "https://example.com/x"} // trailing') + assert 'https://example.com/x' in kept + assert 'trailing' not in kept From b292c6319c39da4999ba3badff4497fc623a4743 Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Tue, 28 Jul 2026 20:07:05 -0700 Subject: [PATCH 03/11] docs(n8n): drop reference to the uncommitted local test harness README-n8n.md pointed readers at `.context/n8n-test/` for `run.sh` and a WALKTHROUGH.md. None of that exists in the tracked tree and `.context/` is not gitignored, so no contributor can follow it. Points at the real `examples/n8n/` pipes and the importable dispatch workflow instead. Found by tools/docs_audit -- the only ORPHANED citation of 385. --- docs/README-n8n.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/README-n8n.md b/docs/README-n8n.md index b73246585..735dd5639 100644 --- a/docs/README-n8n.md +++ b/docs/README-n8n.md @@ -14,9 +14,9 @@ Combining the two gives **round-trips**: RocketRide → n8n → RocketRide. > (n8n side, importable). > > **Runnable test pipes** that exercise every mode (sync / async / sequential / agent / round-trip) -> live in [`examples/n8n/`](../examples/n8n/) — open them in the IDE. They pair with the local -> test harness in `.context/n8n-test/` (`run.sh --keep` seeds the `rr-echo` / `rr-slow` / `rr-upper` -> / `rr-callback` workflows); see that folder's `WALKTHROUGH.md` for the step-by-step. +> live in [`examples/n8n/`](../examples/n8n/) — open them in the IDE. Import +> [`n8n-dispatch.workflow.json`](../examples/n8n/n8n-dispatch.workflow.json) on the n8n side and +> point each pipe's webhook URL at your own instance. --- From 146bbe50763ef982540d8db465954148d7944e28 Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Tue, 28 Jul 2026 20:07:05 -0700 Subject: [PATCH 04/11] docs(nodes): add READMEs for the nine undocumented nodes background_removal, caption, depth_estimate, detect, detect_segment, face_detection, pose_estimation, tool_google_workspace, and video_composer each ship Python but had no README, contrary to the co-located documentation rule in AGENTS.md. Prose is grounded in each node's own services*.json description, lanes, and field set -- not invented. tool_google_workspace additionally spells out that every destructive capability (hard delete, public sharing) is a separate opt-in flag defaulting to false. The ROCKETRIDE:GENERATED:PARAMS block is deliberately NOT hand-written: it is produced by nodes:docs-generate, which needs the engine build. These nine therefore move from MISSING_DOC to MISSING_PARAMS until a maintainer regenerates. --- nodes/src/nodes/background_removal/README.md | 44 +++++++++++++++++ nodes/src/nodes/caption/README.md | 34 +++++++++++++ nodes/src/nodes/depth_estimate/README.md | 35 +++++++++++++ nodes/src/nodes/detect/README.md | 42 ++++++++++++++++ nodes/src/nodes/detect_segment/README.md | 42 ++++++++++++++++ nodes/src/nodes/face_detection/README.md | 36 ++++++++++++++ nodes/src/nodes/pose_estimation/README.md | 34 +++++++++++++ .../src/nodes/tool_google_workspace/README.md | 49 +++++++++++++++++++ nodes/src/nodes/video_composer/README.md | 34 +++++++++++++ 9 files changed, 350 insertions(+) create mode 100644 nodes/src/nodes/background_removal/README.md create mode 100644 nodes/src/nodes/caption/README.md create mode 100644 nodes/src/nodes/depth_estimate/README.md create mode 100644 nodes/src/nodes/detect/README.md create mode 100644 nodes/src/nodes/detect_segment/README.md create mode 100644 nodes/src/nodes/face_detection/README.md create mode 100644 nodes/src/nodes/pose_estimation/README.md create mode 100644 nodes/src/nodes/tool_google_workspace/README.md create mode 100644 nodes/src/nodes/video_composer/README.md diff --git a/nodes/src/nodes/background_removal/README.md b/nodes/src/nodes/background_removal/README.md new file mode 100644 index 000000000..61c824145 --- /dev/null +++ b/nodes/src/nodes/background_removal/README.md @@ -0,0 +1,44 @@ +# background_removal + +A RocketRide image-filter node that separates foreground from background and emits an RGBA cutout. + +## What it does + +Receives an image stream and runs **BiRefNet** (MIT) to produce an alpha matte, then +composites an RGBA cutout with a **straight (non-premultiplied) alpha** channel, so +downstream nodes can re-composite over any background without dark fringes. + +Per frame the node emits on two lanes: + +- `image` — the RGBA cutout as PNG +- `text` — JSON alpha statistics (`mean_alpha`, `alpha_coverage_pct`) + +Before inference the source is downscaled so its long edge is at most `maxEdge`, which +bounds memory use; the alpha matte is then restored to the original resolution for +compositing. `maxEdge` is clamped to 256–4096 (default 1024) regardless of what is +configured. + +Two profiles ship: the default 1K BiRefNet, and a 2K high-resolution variant for fine +hair and detailed edges. The model runs on CPU, Apple Silicon (MPS), or CUDA. Local +inference serializes GPU access behind a shared device lock; when the engine is started +with `--modelserver`, inference is dispatched to the model server instead. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source image (streamed) | +| `image` | output | RGBA cutout PNG, straight alpha | +| `text` | output | JSON alpha stats: `mean_alpha`, `alpha_coverage_pct` | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `model` | string | HuggingFace model identifier for background removal (overrides the profile) | +| `maxEdge` | number | Default 1024, clamped to 256–4096. Downscale source so the long edge is at most this value before inference | +| `profile` | string | Default `"birefnet-default"`. BiRefNet variant — default is 1K, HR is 2K for finer edges | diff --git a/nodes/src/nodes/caption/README.md b/nodes/src/nodes/caption/README.md new file mode 100644 index 000000000..40902b411 --- /dev/null +++ b/nodes/src/nodes/caption/README.md @@ -0,0 +1,34 @@ +# caption + +A RocketRide image-filter node that generates a natural-language caption for an image. + +## What it does + +Receives an image and runs **Florence-2 Base** (MIT) locally to produce a descriptive +caption on the text lane. Three granularities are exposed via `task`: short, detailed, +and more detailed. + +Runs on CPU, Apple Silicon (MPS), and CUDA with **no API key required** — inference is +local, so images never leave the host. + +For object detection use the **Object Detection** (`detect`) node; for reading text in +an image use the **OCR** node. This node describes a scene, it does not localize or +transcribe. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source image (streamed) | +| `text` | output | The generated caption | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `task` | string | Default `"caption"`. How detailed the caption should be (short / detailed / more detailed) | +| `profile` | string | Default `"florence-base"`. Model variant to load | diff --git a/nodes/src/nodes/depth_estimate/README.md b/nodes/src/nodes/depth_estimate/README.md new file mode 100644 index 000000000..064972bad --- /dev/null +++ b/nodes/src/nodes/depth_estimate/README.md @@ -0,0 +1,35 @@ +# depth_estimate + +A RocketRide image-filter node that estimates per-pixel depth from a single image. + +## What it does + +Runs **Depth Anything V2 Small** (Apache-2.0) for monocular depth estimation and emits +a colorized depth map on the image lane, where **red is near and blue is far**. Depth +statistics (min, max, mean) are emitted as JSON on the text lane. + +Pair this with the **Object Detection** (`detect`) node to get a rough distance to each +detected object. + +Before inference the input is downscaled so its long edge is at most `maxEdge`, which +bounds memory use; the dense output is restored to the original resolution afterwards. +Runs on CPU, Apple Silicon (MPS), and CUDA. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source image (streamed) | +| `image` | output | Colorized depth map (red = near, blue = far) | +| `text` | output | JSON depth statistics: min, max, mean | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `maxEdge` | number | Default 1024. Downscale input so the long edge is at most this value before inference | +| `profile` | string | Default `"v2-small"`. Depth Anything V2 variant to load | diff --git a/nodes/src/nodes/detect/README.md b/nodes/src/nodes/detect/README.md new file mode 100644 index 000000000..c70d1f524 --- /dev/null +++ b/nodes/src/nodes/detect/README.md @@ -0,0 +1,42 @@ +# detect + +A RocketRide image-filter node that finds objects in a frame and emits bounding boxes. + +## What it does + +Runs per-frame object detection and emits bounding boxes, labels, and centroids on the +text lane alongside an annotated frame on the image lane. + +Two engines are available via `profile`: + +- **RF-DETR** (Apache-2.0, default) — a fast **closed-set** detector over the 80 COCO + classes (person, car, dog, and so on). +- **MM-Grounding-DINO** (Apache-2.0 / BSD-3) — the **open-vocabulary** option. Set + `prompt` to detect anything you can name. + +`prompt` accepts either a period- or comma-separated class list (`person . car . dog`) +or a described object (`red car`, `person in a hat`), and returns every matching region. +It matches objects and attributes, not spatial relationships. + +Useful as a cheap per-frame gate in front of heavier models. For pixel-level masks use +the **Segmentation** (`detect_segment`) node instead. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source frame (streamed) | +| `image` | output | Annotated frame with boxes drawn | +| `text` | output | JSON detections: bounding boxes, labels, centroids | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `threshold` | number | Default 0.3. Minimum confidence score (0.0–1.0) required to include a detection | +| `prompt` | string | Open-vocabulary prompt. Only used by the MM-Grounding-DINO profile | +| `profile` | string | Default `"rfdetr"`. Detector engine to load | diff --git a/nodes/src/nodes/detect_segment/README.md b/nodes/src/nodes/detect_segment/README.md new file mode 100644 index 000000000..09464f147 --- /dev/null +++ b/nodes/src/nodes/detect_segment/README.md @@ -0,0 +1,42 @@ +# detect_segment + +A RocketRide image-filter node that produces pixel-level segmentation masks. + +## What it does + +Runs pixel-level segmentation with HuggingFace-native engines and emits an annotated +overlay on the image lane plus a Masks JSON payload on the text lane. + +Two modes are available: + +- **Mask2Former-instance** (MIT, default) — closed-set **instance** masks, one mask per + detected object. +- **Mask2Former-semantic** (MIT) — a per-pixel **class map** over the whole frame. + +Accepts a single frame or multiple frames (via `frame_grabber` documents). Input is +downscaled so its long edge is at most `maxEdge` before inference. + +For bounding boxes only — which is considerably cheaper — use the **Object Detection** +(`detect`) node. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source frame, or multi-frame documents | +| `image` | output | Annotated overlay | +| `text` | output | Masks JSON | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `mode` | string | Default `"instance"`. Instance masks or a semantic per-pixel class map | +| `engine` | string | Default `"mask2former-instance"`. Segmentation engine to load | +| `threshold` | number | Default 0.3. Minimum confidence score to include a mask | +| `maxEdge` | number | Default 1024. Downscale input so the long edge is at most this value before inference | +| `profile` | string | Default `"mask2former-instance"`. Model profile | diff --git a/nodes/src/nodes/face_detection/README.md b/nodes/src/nodes/face_detection/README.md new file mode 100644 index 000000000..f232a4752 --- /dev/null +++ b/nodes/src/nodes/face_detection/README.md @@ -0,0 +1,36 @@ +# face_detection + +A RocketRide image-filter node that detects faces and optional alignment keypoints. + +## What it does + +Runs per-frame face detection using **MediaPipe BlazeFace** (Apache-2.0) and emits +axis-aligned bounding boxes for every detected face. + +When `emit_landmarks` is on (the default) each face also carries 6 coarse, +alignment-grade keypoints: `right_eye`, `left_eye`, `nose_tip`, `mouth_center`, +`right_ear_tragion`, `left_ear_tragion`. + +Fast enough to use as a face-presence gate ahead of heavier models, or to drive +face-aware framing and cropping. These are coarse alignment keypoints — this is not a +dense facial-landmark or face-recognition node. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source frame (streamed) | +| `image` | output | Annotated frame | +| `text` | output | JSON faces: bounding boxes and, optionally, 6 keypoints each | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `profile` | string | Default `"short"`. BlazeFace model variant | +| `threshold` | number | Default 0.5. Minimum confidence score to include a face | +| `emit_landmarks` | boolean | Default true. Emit the 6 alignment keypoints per face | diff --git a/nodes/src/nodes/pose_estimation/README.md b/nodes/src/nodes/pose_estimation/README.md new file mode 100644 index 000000000..ecc5dffb5 --- /dev/null +++ b/nodes/src/nodes/pose_estimation/README.md @@ -0,0 +1,34 @@ +# pose_estimation + +A RocketRide image-filter node that estimates human body pose per frame. + +## What it does + +Runs top-down human pose estimation using **RTMPose** (Apache-2.0) through the `rtmlib` +ONNX wrapper. **RTMDet-nano** performs person detection first, then RTMPose predicts +**17 COCO keypoints** for each person crop. + +Accepts an image or a document and emits an annotated frame, with the per-person +keypoint array attached to the document's metadata. + +Top-down means cost scales with the number of people in frame; `max_persons` bounds +that work. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Source frame or document | +| `image` | output | Annotated frame; keypoint array attached to document metadata | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `profile` | string | Default `"rtmpose-medium"`. RTMPose model variant | +| `threshold` | number | Default 0.3. Minimum keypoint score to keep a joint | +| `max_persons` | number | Default 20. Maximum persons processed per frame | diff --git a/nodes/src/nodes/tool_google_workspace/README.md b/nodes/src/nodes/tool_google_workspace/README.md new file mode 100644 index 000000000..92dfe5571 --- /dev/null +++ b/nodes/src/nodes/tool_google_workspace/README.md @@ -0,0 +1,49 @@ +# tool_google_workspace + +A RocketRide tool node that exposes Google Workspace operations to an AI agent. + +## What it does + +Registers five separate tool surfaces an agent can call. Each ships its own service +definition and its own access controls, so a pipeline can enable only what it needs: + +| Service | File | What the agent can do | +|---|---|---| +| **Gmail** | `services.gmail.json` | Read, search, label, draft, send, and organize mail | +| **Drive** | `services.drive.json` | List and search files, read metadata, download binaries, export native Docs/Sheets/Slides, create/update/copy/move files and folders, manage sharing, trash and untrash, track changes. Supports My Drive and shared drives | +| **Calendar** | `services.calendar.json` | List, get, create, update, move, and delete events (including recurring-series instances and natural-language quick-add); query free/busy; manage calendars and ACL rules. Supports incremental sync via `syncToken` | +| **Docs** | `services.docs.json` | Read document text; create documents; append and replace text; insert images and tables; run arbitrary `batchUpdate` requests | +| **Sheets** | `services.sheets.json` | Read, write, append, and clear cell values; create spreadsheets; add, delete, duplicate, and copy sheets; run arbitrary `batchUpdate` requests | + +Authenticates via a **Google service account** or **user OAuth**. + +This is a tool node, not a filter: it has no image or text lanes. It is invoked by an +agent rather than placed in a streaming path. + +### Destructive operations are opt-in + +Every irreversible or externally-visible capability is gated behind its own flag and is +**off by default**. Permanent deletion (`allowHardDelete` on Gmail and Drive, +`allowDelete` on Calendar) and public or domain-wide sharing (`allowPublicSharing` on +Drive and Calendar) must each be turned on deliberately. The per-service `access` field +bounds what the agent can reach at all — narrow it to read-only when the pipeline only +needs to read. + +--- + +## Configuration + +### Fields + +| Field | Type | Description | +|---|---|---| +| `gmail.access` | string | Default `"modify"`. Gmail access level | +| `gmail.allowHardDelete` | boolean | Default false. Allow permanent deletion of mail | +| `drive.access` | string | Default `"write"`. Drive access level | +| `drive.allowPublicSharing` | boolean | Default false. Allow public / external sharing | +| `drive.allowHardDelete` | boolean | Default false. Allow permanent delete | +| `calendar.access` | string | Default `"write"`. Calendar access level | +| `calendar.allowDelete` | boolean | Default false. Allow event / calendar deletion | +| `calendar.allowPublicSharing` | boolean | Default false. Allow public / domain-wide calendar sharing | +| `docs.access` | string | Default `"write"`. Docs access level | +| `sheets.access` | string | Default `"write"`. Sheets access level | diff --git a/nodes/src/nodes/video_composer/README.md b/nodes/src/nodes/video_composer/README.md new file mode 100644 index 000000000..bc52e4bfd --- /dev/null +++ b/nodes/src/nodes/video_composer/README.md @@ -0,0 +1,34 @@ +# video_composer + +A RocketRide node that stitches a sequence of image frames into an MP4. + +## What it does + +Collects the image frames flowing through it and re-encodes them into a playable MP4 +clip using **FFmpeg**. + +Place it after any image-producing filter — for example `detect`, `pose_estimation`, or +`background_removal` — to turn that filter's annotated frames back into a video. + +Output frame rate is set by `fps`, and quality by `crf` (lower is higher quality and a +larger file; 23 is FFmpeg's default). + +Requires an FFmpeg binary available to the engine. + +--- + +## Configuration + +### Lanes + +| Lane | Direction | Description | +|------|-----------|-------------| +| `image` | input | Frames to stitch, in arrival order | + +### Fields + +| Field | Type | Description | +|---|---|---| +| `fps` | number | Default 1.0. Output frame rate | +| `crf` | number | Default 23. FFmpeg quality (CRF); lower is higher quality | +| `profile` | string | Default `"standard"`. Output quality preset | From b98c1eb77b76010977b845f89a73875d3b38afa3 Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Fri, 31 Jul 2026 13:39:05 -0700 Subject: [PATCH 05/11] docs(nodes): correct the Workspace access claim and two node README nits (#1718 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool_google_workspace README claimed "every irreversible or externally-visible capability is gated behind its own flag and is off by default." That is wrong for the second half. allowHardDelete / allowDelete / allowPublicSharing are indeed opt-in booleans defaulting to false, but the per-service `access` field defaults to `write` on Drive, Calendar, Docs and Sheets (and `modify` on Gmail) — so creating files and calendar events is on by default, and calendar writes send attendee invitations. Reworked the section to separate the two mechanisms and say which one actually bounds the agent day to day. Gmail's `send` really is above the default, and is now called out as the exception rather than the rule. Also from review: - caption: `task` prose said "short / detailed / more detailed" while the default is `"caption"`, leaving no way to map the words to the enum. Now names the actual values: caption, detailed_caption, more_detailed_caption. - depth_estimate: "afterwards" -> "afterward" for American English. Verified: pytest 18 passed; ruff clean. --- nodes/src/nodes/caption/README.md | 6 ++--- nodes/src/nodes/depth_estimate/README.md | 2 +- .../src/nodes/tool_google_workspace/README.md | 26 +++++++++++++------ 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/nodes/src/nodes/caption/README.md b/nodes/src/nodes/caption/README.md index 40902b411..d4ad63146 100644 --- a/nodes/src/nodes/caption/README.md +++ b/nodes/src/nodes/caption/README.md @@ -5,8 +5,8 @@ A RocketRide image-filter node that generates a natural-language caption for an ## What it does Receives an image and runs **Florence-2 Base** (MIT) locally to produce a descriptive -caption on the text lane. Three granularities are exposed via `task`: short, detailed, -and more detailed. +caption on the text lane. Three granularities are exposed via `task`, from shortest to +longest: `caption` (the default), `detailed_caption`, and `more_detailed_caption`. Runs on CPU, Apple Silicon (MPS), and CUDA with **no API key required** — inference is local, so images never leave the host. @@ -30,5 +30,5 @@ transcribe. | Field | Type | Description | |---|---|---| -| `task` | string | Default `"caption"`. How detailed the caption should be (short / detailed / more detailed) | +| `task` | string | Default `"caption"`. How detailed the caption should be — one of `caption`, `detailed_caption`, `more_detailed_caption` | | `profile` | string | Default `"florence-base"`. Model variant to load | diff --git a/nodes/src/nodes/depth_estimate/README.md b/nodes/src/nodes/depth_estimate/README.md index 064972bad..08cc7b97f 100644 --- a/nodes/src/nodes/depth_estimate/README.md +++ b/nodes/src/nodes/depth_estimate/README.md @@ -12,7 +12,7 @@ Pair this with the **Object Detection** (`detect`) node to get a rough distance detected object. Before inference the input is downscaled so its long edge is at most `maxEdge`, which -bounds memory use; the dense output is restored to the original resolution afterwards. +bounds memory use; the dense output is restored to the original resolution afterward. Runs on CPU, Apple Silicon (MPS), and CUDA. --- diff --git a/nodes/src/nodes/tool_google_workspace/README.md b/nodes/src/nodes/tool_google_workspace/README.md index 92dfe5571..0b85500fd 100644 --- a/nodes/src/nodes/tool_google_workspace/README.md +++ b/nodes/src/nodes/tool_google_workspace/README.md @@ -20,14 +20,24 @@ Authenticates via a **Google service account** or **user OAuth**. This is a tool node, not a filter: it has no image or text lanes. It is invoked by an agent rather than placed in a streaming path. -### Destructive operations are opt-in - -Every irreversible or externally-visible capability is gated behind its own flag and is -**off by default**. Permanent deletion (`allowHardDelete` on Gmail and Drive, -`allowDelete` on Calendar) and public or domain-wide sharing (`allowPublicSharing` on -Drive and Calendar) must each be turned on deliberately. The per-service `access` field -bounds what the agent can reach at all — narrow it to read-only when the pipeline only -needs to read. +### What is gated, and what is not + +Two different mechanisms, and only one of them defaults to safe. + +**Irreversible and public-facing operations are opt-in.** Permanent deletion +(`allowHardDelete` on Gmail and Drive, `allowDelete` on Calendar) and public or +domain-wide sharing (`allowPublicSharing` on Drive and Calendar) are separate booleans, +each defaulting to `false`. They must be turned on deliberately. + +**Ordinary writes are not.** The per-service `access` field defaults to `write` on +Drive, Calendar, Docs, and Sheets, and to `modify` (read + organize) on Gmail. So an +agent can create, update, and move files, edit documents, and create calendar events +without any flag being enabled — and calendar writes send invitations to attendees, +which is externally visible. Gmail is the exception in one direction: `send` is a +higher level than the `modify` default, so sending mail does require raising `access`. + +Set `access` to `readonly` for any service the pipeline only needs to read from. That +field, not the boolean flags, is what bounds the agent's day-to-day reach. --- From 0f7f527ac2934484320b6eee4b3ec73556afe19c Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Fri, 31 Jul 2026 14:53:11 -0700 Subject: [PATCH 06/11] fix(docs-audit): stop the audit reporting clean when it audited nothing (#1718 review) Four Major findings from review, three of which share one failure mode: the tool returned success while silently doing no work. - cli: a nonexistent --root produced no docs, no nodes, and exit 0 even under --fail-on-orphaned. A typo in a CI invocation was therefore a green build that never audited anything. Now exits 2 with a message. - coverage: an unparseable services*.json was skipped, leaving zero declared params -- indistinguishable from a node with nothing to document, so real schema drift stayed hidden behind a clean report. Added an UNREADABLE gap kind, reported instead of swallowed; same for an unreadable README. - index: `.lstrip('./')` strips a character SET, not a prefix, so `.env` was indexed as `env` and `.github/workflows/ci.yml` lost its leading dot. Citations to hidden files could be reported orphaned or matched only by basename. `Path('.') / name` is already clean, so the lstrip was both wrong and unnecessary. - cli: _docs used rglob, descending node_modules and vendored trees in full before discarding what they yielded. Now os.walk with in-place pruning, matching CodeIndex.build. Also added the __main__ guard: `python -m docs_audit.cli` previously printed nothing and exited 0, the same silent-success trap as the --root bug. Verified: 21 passed (3 new regression tests), ruff clean, format clean. Full run on this repo: 390 citations, 0 orphaned, 31 coverage gaps, unchanged from before the fix. --- tools/docs_audit/src/docs_audit/cli.py | 31 +++++++++++++---- tools/docs_audit/src/docs_audit/coverage.py | 31 +++++++++++++++-- tools/docs_audit/src/docs_audit/index.py | 7 ++-- tools/docs_audit/test/test_docs_audit.py | 37 +++++++++++++++++++++ 4 files changed, 95 insertions(+), 11 deletions(-) diff --git a/tools/docs_audit/src/docs_audit/cli.py b/tools/docs_audit/src/docs_audit/cli.py index 300c7691d..42200e8da 100644 --- a/tools/docs_audit/src/docs_audit/cli.py +++ b/tools/docs_audit/src/docs_audit/cli.py @@ -4,21 +4,28 @@ import argparse import json +import os import sys from pathlib import Path from .citations import ORPHANED, audit_doc -from .coverage import MISSING_DOC, MISSING_PARAMS, STALE_PARAMS, audit_nodes -from .index import CodeIndex, is_excluded +from .coverage import MISSING_DOC, MISSING_PARAMS, STALE_PARAMS, UNREADABLE, audit_nodes +from .index import EXCLUDED_PARTS, CodeIndex, is_excluded DOC_SUFFIXES = ('.md', '.mdx') def _docs(root: Path): - for suffix in DOC_SUFFIXES: - for path in root.rglob(f'*{suffix}'): - if not is_excluded(path.relative_to(root)): - yield path + # os.walk with in-place pruning, not rglob: rglob descends node_modules and + # every vendored tree in full before the filter discards what it yielded. + for dirpath, dirnames, filenames in os.walk(root): + rel_dir = Path(dirpath).relative_to(root) + dirnames[:] = [d for d in dirnames if d not in EXCLUDED_PARTS] + if is_excluded(rel_dir): + continue + for name in filenames: + if name.endswith(DOC_SUFFIXES): + yield Path(dirpath) / name def build_parser() -> argparse.ArgumentParser: @@ -40,6 +47,12 @@ def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) root = Path(args.root).resolve() + # A typo in --root would otherwise audit nothing, find nothing, and exit 0 -- + # green CI that never ran. Fail loudly instead. + if not root.is_dir(): + print(f'docs-audit: --root is not a directory: {root}', file=sys.stderr) + return 2 + index = CodeIndex.build(root) verdicts = [verdict for path in _docs(root) for verdict in audit_doc(path, root, index)] gaps = audit_nodes(root) @@ -87,7 +100,7 @@ def main(argv: list[str] | None = None) -> int: if gaps: print(f'\nUndocumented / drifted code ({len(gaps)}):\n') - for kind in (STALE_PARAMS, MISSING_PARAMS, MISSING_DOC): + for kind in (UNREADABLE, STALE_PARAMS, MISSING_PARAMS, MISSING_DOC): matching = [g for g in gaps if g.kind == kind] if not matching: continue @@ -96,3 +109,7 @@ def main(argv: list[str] | None = None) -> int: print(f' {gap.node}: {gap.detail}') return 1 if (args.fail_on_orphaned and orphaned) else 0 + + +if __name__ == '__main__': # `python -m docs_audit.cli` printed nothing without this + raise SystemExit(main()) diff --git a/tools/docs_audit/src/docs_audit/coverage.py b/tools/docs_audit/src/docs_audit/coverage.py index 3d942fece..12bc0784f 100644 --- a/tools/docs_audit/src/docs_audit/coverage.py +++ b/tools/docs_audit/src/docs_audit/coverage.py @@ -8,6 +8,10 @@ ``MISSING_PARAMS`` a node README with no generated block at all, contrary to the co-located documentation rule in AGENTS.md. ``MISSING_DOC`` a node that ships Python but no README whatsoever. +``UNREADABLE`` a schema or README that could not be read or parsed. Reported + rather than skipped: a malformed ``services*.json`` yields no + declared fields, which would otherwise look identical to a + node with nothing to document and hide real drift. """ from __future__ import annotations @@ -20,6 +24,7 @@ STALE_PARAMS = 'STALE_PARAMS' MISSING_PARAMS = 'MISSING_PARAMS' MISSING_DOC = 'MISSING_DOC' +UNREADABLE = 'UNREADABLE' _GENERATED_BLOCK = re.compile( r'(.*?)', @@ -110,6 +115,22 @@ def schema_params(node_dir: Path) -> set[str]: return keys +def unreadable_schemas(node_dir: Path) -> list[str]: + """Names of ``services*.json`` files that could not be read or parsed. + + Separate from :func:`schema_params` so that a broken schema is reported as + a finding instead of quietly contributing zero declared params -- which + reads exactly like a node that has nothing to document. + """ + broken = [] + for services in sorted(node_dir.glob('services*.json')): + try: + json.loads(strip_jsonc(services.read_text(encoding='utf-8', errors='replace'))) + except (OSError, json.JSONDecodeError) as exc: + broken.append(f'{services.name} ({type(exc).__name__})') + return broken + + def audit_node(node_dir: Path, root: Path) -> list[Gap]: """Every documentation gap for a single node directory.""" name = node_dir.name @@ -117,6 +138,12 @@ def audit_node(node_dir: Path, root: Path) -> list[Gap]: if not has_python: return [] + broken = unreadable_schemas(node_dir) + if broken: + # Stop here: declared params are unknowable, so any STALE/MISSING + # verdict computed from them would be noise on top of a real problem. + return [Gap(UNREADABLE, name, 'unparseable schema: ' + '; '.join(broken))] + readme = node_dir / 'README.md' if not readme.exists(): count = len(list(node_dir.glob('*.py'))) @@ -124,8 +151,8 @@ def audit_node(node_dir: Path, root: Path) -> list[Gap]: try: text = readme.read_text(encoding='utf-8', errors='replace') - except OSError: - return [] + except OSError as exc: + return [Gap(UNREADABLE, name, f'README.md could not be read ({type(exc).__name__})')] declared = schema_params(node_dir) documented = documented_params(text) diff --git a/tools/docs_audit/src/docs_audit/index.py b/tools/docs_audit/src/docs_audit/index.py index e81e9aa4a..1c65a9d10 100644 --- a/tools/docs_audit/src/docs_audit/index.py +++ b/tools/docs_audit/src/docs_audit/index.py @@ -80,7 +80,10 @@ def build(cls, root: Path) -> CodeIndex: if is_excluded(rel_dir): continue for name in list(dirnames) + filenames: - rel = (rel_dir / name).as_posix().lstrip('./') + # No lstrip('./') here: it strips a character SET, not a prefix, + # so `.env` becomes `env` and `.github/...` loses its dot. + # `Path('.') / name` already yields a clean relative path. + rel = (rel_dir / name).as_posix() index.paths.add(rel) index.basenames.setdefault(name, []).append(rel) for name in filenames: @@ -93,7 +96,7 @@ def build(cls, root: Path) -> CodeIndex: text = path.read_text(encoding='utf-8', errors='replace') except OSError: continue - index._sources[(rel_dir / name).as_posix().lstrip('./')] = text + index._sources[(rel_dir / name).as_posix()] = text return index def has_path(self, relpath: str) -> bool: diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py index 2bbab664b..5f44676f4 100644 --- a/tools/docs_audit/test/test_docs_audit.py +++ b/tools/docs_audit/test/test_docs_audit.py @@ -27,9 +27,11 @@ classify, extract, ) +from docs_audit.cli import main # noqa: E402 from docs_audit.coverage import ( # noqa: E402 MISSING_DOC, STALE_PARAMS, + UNREADABLE, audit_node, schema_params, strip_jsonc, @@ -195,6 +197,41 @@ def test_jsonc_services_file_is_parsed(tmp_path: Path) -> None: assert schema_params(node) == {'a'} +def test_malformed_schema_is_reported_not_skipped(tmp_path: Path) -> None: + """Regression: a broken services.json yielded zero declared params, which is + indistinguishable from a node with nothing to document -- so the audit + reported the node as clean while hiding whatever the schema really said. + """ + node = tmp_path / 'nodes' / 'src' / 'nodes' / 'broken' + node.mkdir(parents=True) + (node / 'impl.py').write_text('x = 1\n', encoding='utf-8') + (node / 'services.json').write_text('{"fields": {', encoding='utf-8') + (node / 'README.md').write_text(_block('a'), encoding='utf-8') + + (gap,) = audit_node(node, tmp_path) + assert gap.kind == UNREADABLE + assert 'services.json' in gap.detail + + +def test_dotfile_paths_keep_their_leading_dot(tmp_path: Path) -> None: + """Regression: `.lstrip('./')` strips a character SET, not a prefix, so + `.env` was indexed as `env` and citations to hidden files looked orphaned. + """ + (tmp_path / '.env').write_text('K=v\n', encoding='utf-8') + (tmp_path / '.github' / 'workflows').mkdir(parents=True) + (tmp_path / '.github' / 'workflows' / 'ci.yml').write_text('on: push\n', encoding='utf-8') + + index = CodeIndex.build(tmp_path) + assert index.has_path('.env') + assert index.has_path('.github/workflows/ci.yml') + assert not index.has_path('env') + + +def test_nonexistent_root_fails_instead_of_passing_green(tmp_path: Path) -> None: + """A typo in --root must not look like a clean audit.""" + assert main(['--root', str(tmp_path / 'nope'), '--fail-on-orphaned']) == 2 + + def test_strip_jsonc_keeps_urls_inside_strings() -> None: """A `//` inside a quoted value is data, not a comment.""" kept = strip_jsonc('{"url": "https://example.com/x"} // trailing') From b57869f47783f0874e93b73d56fd59ff8b124cfe Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Sat, 1 Aug 2026 13:30:30 -0700 Subject: [PATCH 07/11] fix(docs-audit): mirror the generator's field rule, killing a phantom-drift class _is_user_facing_param required a "type" key in addition to excluding "object" entries. nodes:docs-generate only does the latter: if (field && field.object !== undefined) continue; // Skip profile definitions So a field with no "type" -- which the generator emits with an empty Type cell -- was rendered into the table but refused by the audit as declared, and surfaced as "in docs but not in schema". The generator decides what the table contains, so it is the only correct oracle for this comparison; any stricter rule invents drift. Measured on develop @ b8068d7 after regenerating the tables: 10 of the remaining STALE_PARAMS findings were this false positive. With the rule aligned, STALE_PARAMS goes 10 -> 0 and the surviving 13 findings are all genuine (9 MISSING_DOC, 4 MISSING_PARAMS). Verified: 22 passed (1 new regression test), ruff clean, format clean. --- tools/docs_audit/src/docs_audit/coverage.py | 22 +++++++++++++++------ tools/docs_audit/test/test_docs_audit.py | 20 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tools/docs_audit/src/docs_audit/coverage.py b/tools/docs_audit/src/docs_audit/coverage.py index 12bc0784f..e106599bf 100644 --- a/tools/docs_audit/src/docs_audit/coverage.py +++ b/tools/docs_audit/src/docs_audit/coverage.py @@ -92,13 +92,23 @@ def strip_jsonc(text: str) -> str: def _is_user_facing_param(value: object) -> bool: """True for a real settable parameter, false for a profile grouping. - ``fields`` holds two different kinds of entry. A parameter carries a - ``type`` (``{"type": "string", "title": ...}``). A profile group carries - ``object``/``properties`` and merely bundles other keys under a preset -- - ``nodes:docs-generate`` does not put those in the schema table, so neither - do we, or every profile-based node reports phantom drift. + This mirrors ``nodes:docs-generate`` exactly, because that generator decides + what the table contains and is therefore the only correct oracle:: + + if (field && field.object !== undefined) continue; // Skip profile definitions + + (``nodes/scripts/gen-node-tables.mjs``). A profile group carries ``object``/ + ``properties`` and merely bundles other keys under a preset, so it is + excluded from the table and from this comparison. + + Requiring a ``type`` key here as well -- which this did originally -- is + stricter than the generator, and produced phantom STALE_PARAMS on every + node with a typeless field: the generator emits such a field with an empty + Type cell, the audit refused to count it as declared, and the diff surfaced + as "in docs but not in schema". Ten of the repository's findings were this + false positive rather than real drift. """ - return isinstance(value, dict) and 'type' in value and 'object' not in value + return isinstance(value, dict) and 'object' not in value def schema_params(node_dir: Path) -> set[str]: diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py index 5f44676f4..bfa093e6a 100644 --- a/tools/docs_audit/test/test_docs_audit.py +++ b/tools/docs_audit/test/test_docs_audit.py @@ -197,6 +197,26 @@ def test_jsonc_services_file_is_parsed(tmp_path: Path) -> None: assert schema_params(node) == {'a'} +def test_typeless_field_counts_as_declared(tmp_path: Path) -> None: + """Regression: the audit required a ``type`` key, but nodes:docs-generate + only skips entries carrying ``object``. A typeless field (rendered by the + generator with an empty Type cell) was therefore counted as documented but + not declared, reporting phantom drift on ten real nodes. + """ + node = _node( + tmp_path, + 'typeless', + { + 'vector.local.host': {'default': 'localhost'}, # no "type" key + 'vector.profile': {'type': 'string'}, + 'vector.group': {'object': 'grp', 'properties': ['vector.profile']}, + }, + _block('vector.local.host', 'vector.profile'), + ) + assert schema_params(node) == {'vector.local.host', 'vector.profile'} + assert audit_node(node, tmp_path) == [] + + def test_malformed_schema_is_reported_not_skipped(tmp_path: Path) -> None: """Regression: a broken services.json yielded zero declared params, which is indistinguishable from a node with nothing to document -- so the audit From 852cc0a7515c9b1c54e56554c0642702539853cc Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Sat, 1 Aug 2026 13:39:37 -0700 Subject: [PATCH 08/11] fix(docs-audit): three false-positive classes found by running the tool on sibling repos Ran the audit against rocketride-workshops and rocketride-benchmark. The benchmark repo came back clean (114 citations, 0 orphaned). Workshops reported 8 orphaned citations -- and all 8 were the tool's fault, in two distinct ways: - `.rocketride/docs/*` (4). The VS Code extension installs that directory into a *user's* workspace (apps/vscode/src/agents/agent-manager.ts). A doc telling a reader to open a file under it is correct precisely because the repo does not contain it. Now classified RUNTIME. - `ARCHITECTURE.md` / `OWNERSHIP.md` (4). The citing line reads "Emits `ARCHITECTURE.md` and `OWNERSHIP.md` as inline content blocks" -- artifacts the workshop's agent produces at run time. "emit" was missing from the create-verb set, along with produce/output and the -s/-ed forms. Third, found while fixing the above: classify() built its sibling path with `.lstrip('./')`, the same character-set-vs-prefix bug already fixed in index.py, so a citation to a dotfile was looked up with its leading dot stripped. Also reordered classify(): the runtime-literal lookup now runs before the prose heuristics. Finding the name as a literal in source is hard evidence; a create-verb nearby is a guess about intent. Both verdicts are protected so nothing changes about what survives a cleanup, but the evidence a human reads is strictly better. This is why "Writes `built_at_runtime.json`" is RUNTIME again rather than PLACEHOLDER -- the existing test caught the regression when the broader verb list shadowed it. Verified: 24 passed (2 new regression tests), ruff clean, format clean. rocketride-workshops orphans 8 -> 0; rocketride-server unchanged at 0. --- tools/docs_audit/src/docs_audit/citations.py | 42 +++++++++++++++----- tools/docs_audit/test/test_docs_audit.py | 20 ++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/tools/docs_audit/src/docs_audit/citations.py b/tools/docs_audit/src/docs_audit/citations.py index 56dbfd4b6..7b9739a75 100644 --- a/tools/docs_audit/src/docs_audit/citations.py +++ b/tools/docs_audit/src/docs_audit/citations.py @@ -37,12 +37,21 @@ _PATH_SPAN = re.compile(r'`([A-Za-z0-9_.][A-Za-z0-9_.\-/]*' + _EXT + r')`') _DIR_SPAN = re.compile(r'`((?:nodes|packages|apps|tools|docs|scripts|examples|deploy|docker)/[A-Za-z0-9_.\-/]+)`') -# Prose that introduces a file the reader is about to make. +# Prose that introduces a file the reader -- or the code being described -- is +# about to make. ``emit``/``produce``/``output`` cover docs that describe what a +# pipeline or agent writes at run time, which is absent at rest by definition. _CREATE_VERB = re.compile( r'\b(?:create|creating|add|adding|new|name\s+it|call\s+it|save\s+(?:this|it|that)?\s*as|scaffold|' - r'generate|write|make|touch|rename\s+to|copy\s+to|place\s+in|put\s+in)\b', + r'generate|generates|generated|emit|emits|emitted|produce|produces|output|outputs|' + r'write|writes|make|touch|rename\s+to|copy\s+to|place\s+in|put\s+in)\b', re.IGNORECASE, ) + +# Directories that tooling installs into a *user's* workspace, never checked in. +# `.rocketride/` is written by the VS Code extension's installer +# (apps/vscode/src/agents/agent-manager.ts), so docs telling a reader to open a +# file under it are correct precisely because the repo does not contain it. +_INSTALLED_DIR = re.compile(r'(?:^|/)\.rocketride/') # Prose citing a name to ILLUSTRATE a convention rather than to point at a file. # Includes counter-examples ("NOT: `.pipeline.json`"), which are the most # dangerous thing a cleanup pass can delete: removing them reintroduces exactly @@ -131,7 +140,9 @@ def classify(citation: Citation, index: CodeIndex, doc_lines: list[str]) -> Verd doc_dir = Path(citation.doc).parent # 1. Resolves relative to the citing doc, or to the repo root. - sibling = (doc_dir / token).as_posix().lstrip('./') + # No lstrip('./') -- it strips a character SET, not a prefix, so a citation + # to `.env` would be looked up as `env`. Same bug as index.py had. + sibling = (doc_dir / token).as_posix() if index.has_path(sibling): return Verdict(citation, VERIFIED, f'path exists: {sibling}') if index.has_path(token): @@ -147,7 +158,24 @@ def classify(citation: Citation, index: CodeIndex, doc_lines: list[str]) -> Verd if _HISTORICAL_DOCS.search(citation.doc): return Verdict(citation, HISTORICAL, f'{citation.doc} documents past state') - # 4. Prose tells the reader to create it, or it is a template stand-in. + # 4. Installed into the reader's workspace by tooling, not stored here. + if _INSTALLED_DIR.search(token): + return Verdict(citation, RUNTIME, 'installed into the workspace by tooling, not checked in') + + # 5. No file at rest, but the code constructs the name at runtime. + # + # This runs before the prose heuristics below deliberately: finding the name + # as a literal in source is hard evidence, while a create-verb nearby is a + # guess about intent. Both verdicts are protected, so the ordering does not + # change what survives a cleanup -- it changes the evidence a human reads, + # and "source builds this name at pkg/real.py:3" is worth more than + # "create-verb in context". (Prose like "Writes `x.json`" matches both.) + literal = index.find_literal(basename) + if literal is not None: + where, where_line = literal + return Verdict(citation, RUNTIME, f'source builds this name: {where}:{where_line}') + + # 6. Prose tells the reader to create it, or it is a template stand-in. context = _context(doc_lines, citation.line) if _CREATE_VERB.search(context): return Verdict(citation, PLACEHOLDER, f'create-verb in context at line {citation.line}') @@ -158,12 +186,6 @@ def classify(citation: Citation, index: CodeIndex, doc_lines: list[str]) -> Verd if _TEMPLATE_STEM.search(basename): return Verdict(citation, PLACEHOLDER, f'template stem: {basename}') - # 5. No file at rest, but the code constructs the name at runtime. - literal = index.find_literal(basename) - if literal is not None: - where, where_line = literal - return Verdict(citation, RUNTIME, f'source builds this name: {where}:{where_line}') - return Verdict(citation, ORPHANED, 'no path, basename, or source literal found') diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py index bfa093e6a..90f1c73f9 100644 --- a/tools/docs_audit/test/test_docs_audit.py +++ b/tools/docs_audit/test/test_docs_audit.py @@ -124,6 +124,26 @@ def test_runtime_path_built_by_code_is_protected(repo: Path) -> None: assert 'pkg/real.py' in verdict.evidence +def test_tool_installed_workspace_path_is_protected(repo: Path) -> None: + """Regression: `.rocketride/` is written into a *user's* workspace by the + VS Code installer, so a doc telling a reader to open a file under it is + correct precisely because this repo does not contain it. Treating those as + orphaned produced 8 false positives on rocketride-workshops alone. + """ + (verdict,) = _classify('Read `.rocketride/docs/ROCKETRIDE_README.md` first.', repo) + assert verdict.verdict == RUNTIME + assert 'installed into the workspace' in verdict.evidence + + +def test_emitted_artifact_is_not_orphaned(repo: Path) -> None: + """Regression: prose describing what a pipeline emits at run time names a + file that is absent at rest by definition. "emits" was missing from the + create-verb set, so those citations were reported for deletion. + """ + (verdict,) = _classify('Emits `ARCHITECTURE.md` as an inline content block.', repo) + assert verdict.is_protected + + def _node(root: Path, name: str, fields: dict, readme: str | None) -> Path: node = root / 'nodes' / 'src' / 'nodes' / name node.mkdir(parents=True) From 9586008d569bf81f98df93748d6f0376ac9f3b8d Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Sat, 1 Aug 2026 13:53:06 -0700 Subject: [PATCH 09/11] =?UTF-8?q?docs(nodes):=20replace=20hand-written=20f?= =?UTF-8?q?ield=20tables=20with=20generated=20blocks=20=E2=80=94=20the=20k?= =?UTF-8?q?eys=20were=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 9 node READMEs added in this PR documented their config with hand-maintained tables. Every field name in them was missing its node prefix, so they named configuration that does not exist: documented actual maxEdge background_removal.maxEdge task caption.task threshold detect.threshold crf / fps composer.crf / composer.fps video_composer is the worst case: its schema prefix is `composer.`, not the node's directory name, so a reader could not have guessed it. Anyone copying these into a pipe config would get a silently broken node. AGENTS.md already says schema tables are generated and must not be hand-edited; these were the only node docs not following that, which is also why a hand-written table could drift this far in the first place. Replaced with ROCKETRIDE:GENERATED:PARAMS blocks from nodes:docs-generate, and prefixed the same keys where they appear in surrounding prose. Enum *values* (`caption`, `detailed_caption`, `more_detailed_caption`) are left bare -- they are values, not keys. The generated descriptions are also better than what they replace: e.g. depth_estimate.maxEdge now documents the upsample-back behavior and the VRAM/sharpness tradeoff, which the hand-written row omitted. MISSING_PARAMS on this branch: 13 -> 4. The remaining 4 (cloud_tts, landing_ai, tool_mem0, tool_n8n) are fixed in #1773. Verified: 24 passed, ruff clean. Generator itself untouched on this branch — the --force flag used to run it here lives in #1773. --- nodes/src/nodes/background_removal/README.md | 24 ++++--- nodes/src/nodes/caption/README.md | 20 ++++-- nodes/src/nodes/depth_estimate/README.md | 20 ++++-- nodes/src/nodes/detect/README.md | 26 +++++--- nodes/src/nodes/detect_segment/README.md | 30 ++++++--- nodes/src/nodes/face_detection/README.md | 28 ++++++-- nodes/src/nodes/pose_estimation/README.md | 22 +++++-- .../src/nodes/tool_google_workspace/README.md | 65 +++++++++++++++---- nodes/src/nodes/video_composer/README.md | 22 +++++-- 9 files changed, 185 insertions(+), 72 deletions(-) diff --git a/nodes/src/nodes/background_removal/README.md b/nodes/src/nodes/background_removal/README.md index 61c824145..382b1caa9 100644 --- a/nodes/src/nodes/background_removal/README.md +++ b/nodes/src/nodes/background_removal/README.md @@ -13,9 +13,9 @@ Per frame the node emits on two lanes: - `image` — the RGBA cutout as PNG - `text` — JSON alpha statistics (`mean_alpha`, `alpha_coverage_pct`) -Before inference the source is downscaled so its long edge is at most `maxEdge`, which +Before inference the source is downscaled so its long edge is at most `background_removal.maxEdge`, which bounds memory use; the alpha matte is then restored to the original resolution for -compositing. `maxEdge` is clamped to 256–4096 (default 1024) regardless of what is +compositing. `background_removal.maxEdge` is clamped to 256–4096 (default 1024) regardless of what is configured. Two profiles ship: the default 1K BiRefNet, and a 2K high-resolution variant for fine @@ -35,10 +35,18 @@ with `--modelserver`, inference is dispatched to the model server instead. | `image` | output | RGBA cutout PNG, straight alpha | | `text` | output | JSON alpha stats: `mean_alpha`, `alpha_coverage_pct` | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `model` | string | HuggingFace model identifier for background removal (overrides the profile) | -| `maxEdge` | number | Default 1024, clamped to 256–4096. Downscale source so the long edge is at most this value before inference | -| `profile` | string | Default `"birefnet-default"`. BiRefNet variant — default is 1K, HR is 2K for finer edges | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `background_removal.maxEdge` | `number` | **Max input edge (px)**
Downscale source so long edge <= this value before inference; alpha is upsampled back to the (capped) source size for compositing. Lower = faster + less VRAM; higher = sharper edges. | `1024` | +| `background_removal.model` | `string` | **Model**
HuggingFace model identifier for background removal (overrides the profile default) | | +| `background_removal.profile` | `string` | **Model**
BiRefNet variant — default is 1K, HR is 2K for finer edges. | `"birefnet-default"` | + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/background_removal) + diff --git a/nodes/src/nodes/caption/README.md b/nodes/src/nodes/caption/README.md index d4ad63146..67642d819 100644 --- a/nodes/src/nodes/caption/README.md +++ b/nodes/src/nodes/caption/README.md @@ -5,7 +5,7 @@ A RocketRide image-filter node that generates a natural-language caption for an ## What it does Receives an image and runs **Florence-2 Base** (MIT) locally to produce a descriptive -caption on the text lane. Three granularities are exposed via `task`, from shortest to +caption on the text lane. Three granularities are exposed via `caption.task`, from shortest to longest: `caption` (the default), `detailed_caption`, and `more_detailed_caption`. Runs on CPU, Apple Silicon (MPS), and CUDA with **no API key required** — inference is @@ -26,9 +26,17 @@ transcribe. | `image` | input | Source image (streamed) | | `text` | output | The generated caption | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `task` | string | Default `"caption"`. How detailed the caption should be — one of `caption`, `detailed_caption`, `more_detailed_caption` | -| `profile` | string | Default `"florence-base"`. Model variant to load | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `caption.profile` | `string` | **Model** | `"florence-base"` | +| `caption.task` | `string` | **Granularity**
How detailed the caption should be. | `"caption"` | + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/caption) + diff --git a/nodes/src/nodes/depth_estimate/README.md b/nodes/src/nodes/depth_estimate/README.md index 08cc7b97f..785bd5a6b 100644 --- a/nodes/src/nodes/depth_estimate/README.md +++ b/nodes/src/nodes/depth_estimate/README.md @@ -11,7 +11,7 @@ statistics (min, max, mean) are emitted as JSON on the text lane. Pair this with the **Object Detection** (`detect`) node to get a rough distance to each detected object. -Before inference the input is downscaled so its long edge is at most `maxEdge`, which +Before inference the input is downscaled so its long edge is at most `depth_estimate.maxEdge`, which bounds memory use; the dense output is restored to the original resolution afterward. Runs on CPU, Apple Silicon (MPS), and CUDA. @@ -27,9 +27,17 @@ Runs on CPU, Apple Silicon (MPS), and CUDA. | `image` | output | Colorized depth map (red = near, blue = far) | | `text` | output | JSON depth statistics: min, max, mean | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `maxEdge` | number | Default 1024. Downscale input so the long edge is at most this value before inference | -| `profile` | string | Default `"v2-small"`. Depth Anything V2 variant to load | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `depth_estimate.maxEdge` | `number` | **Max input edge (px)**
Downscale input so the long edge <= this value before inference; dense output is upsampled back to original. Lower = faster + less VRAM, higher = sharper depth. | `1024` | +| `depth_estimate.profile` | `string` | **Model** | `"v2-small"` | + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/depth_estimate) + diff --git a/nodes/src/nodes/detect/README.md b/nodes/src/nodes/detect/README.md index c70d1f524..d980ce99c 100644 --- a/nodes/src/nodes/detect/README.md +++ b/nodes/src/nodes/detect/README.md @@ -7,14 +7,14 @@ A RocketRide image-filter node that finds objects in a frame and emits bounding Runs per-frame object detection and emits bounding boxes, labels, and centroids on the text lane alongside an annotated frame on the image lane. -Two engines are available via `profile`: +Two engines are available via `detect.profile`: - **RF-DETR** (Apache-2.0, default) — a fast **closed-set** detector over the 80 COCO classes (person, car, dog, and so on). - **MM-Grounding-DINO** (Apache-2.0 / BSD-3) — the **open-vocabulary** option. Set - `prompt` to detect anything you can name. + `detect.prompt` to detect anything you can name. -`prompt` accepts either a period- or comma-separated class list (`person . car . dog`) +`detect.prompt` accepts either a period- or comma-separated class list (`person . car . dog`) or a described object (`red car`, `person in a hat`), and returns every matching region. It matches objects and attributes, not spatial relationships. @@ -33,10 +33,18 @@ the **Segmentation** (`detect_segment`) node instead. | `image` | output | Annotated frame with boxes drawn | | `text` | output | JSON detections: bounding boxes, labels, centroids | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `threshold` | number | Default 0.3. Minimum confidence score (0.0–1.0) required to include a detection | -| `prompt` | string | Open-vocabulary prompt. Only used by the MM-Grounding-DINO profile | -| `profile` | string | Default `"rfdetr"`. Detector engine to load | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `detect.profile` | `string` | **Model** | `"rfdetr"` | +| `detect.prompt` | `string` | **Detection prompt**
Example: "person . car . dog" (period or comma-separated list) or "red car" / "person in a hat" (described object). Returns all matching regions. Matches objects and attributes — not spatial relationships, so "the car on the left" returns all cars, not just the left one. | | +| `detect.threshold` | `number` | **Confidence threshold**
Minimum confidence score (0.0–1.0) to include a detection | `0.3` | + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/detect) + diff --git a/nodes/src/nodes/detect_segment/README.md b/nodes/src/nodes/detect_segment/README.md index 09464f147..fa73ac9e2 100644 --- a/nodes/src/nodes/detect_segment/README.md +++ b/nodes/src/nodes/detect_segment/README.md @@ -14,7 +14,7 @@ Two modes are available: - **Mask2Former-semantic** (MIT) — a per-pixel **class map** over the whole frame. Accepts a single frame or multiple frames (via `frame_grabber` documents). Input is -downscaled so its long edge is at most `maxEdge` before inference. +downscaled so its long edge is at most `detect_segment.maxEdge` before inference. For bounding boxes only — which is considerably cheaper — use the **Object Detection** (`detect`) node. @@ -31,12 +31,24 @@ For bounding boxes only — which is considerably cheaper — use the **Object D | `image` | output | Annotated overlay | | `text` | output | Masks JSON | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `mode` | string | Default `"instance"`. Instance masks or a semantic per-pixel class map | -| `engine` | string | Default `"mask2former-instance"`. Segmentation engine to load | -| `threshold` | number | Default 0.3. Minimum confidence score to include a mask | -| `maxEdge` | number | Default 1024. Downscale input so the long edge is at most this value before inference | -| `profile` | string | Default `"mask2former-instance"`. Model profile | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `detect_segment.engine` | `string` | **Engine**
Backing engine. Gated by mode: instance -> mask2former-instance; semantic -> mask2former-semantic. | `"mask2former-instance"` | +| `detect_segment.maxEdge` | `number` | **Max input edge (px)**
Downscale source so long edge <= this value before inference; masks are upsampled back to the source size. Lower = faster + less VRAM. | `1024` | +| `detect_segment.mode` | `string` | **Mode**
Segmentation mode. instance: per-instance masks (default). semantic: per-pixel class map. Both use Mask2Former under the hood. | `"instance"` | +| `detect_segment.profile` | `string` | **Profile**
Segmentation preset. Runs on CPU/MPS/CUDA via transformers. | `"mask2former-instance"` | +| `detect_segment.threshold` | `number` | **Confidence threshold**
Minimum score (0.0-1.0) to include a mask | `0.3` | + +## Dependencies + +- `pycocotools` + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/detect_segment) + diff --git a/nodes/src/nodes/face_detection/README.md b/nodes/src/nodes/face_detection/README.md index f232a4752..1cea2d759 100644 --- a/nodes/src/nodes/face_detection/README.md +++ b/nodes/src/nodes/face_detection/README.md @@ -7,7 +7,7 @@ A RocketRide image-filter node that detects faces and optional alignment keypoin Runs per-frame face detection using **MediaPipe BlazeFace** (Apache-2.0) and emits axis-aligned bounding boxes for every detected face. -When `emit_landmarks` is on (the default) each face also carries 6 coarse, +When `face_detection.emit_landmarks` is on (the default) each face also carries 6 coarse, alignment-grade keypoints: `right_eye`, `left_eye`, `nose_tip`, `mouth_center`, `right_ear_tragion`, `left_ear_tragion`. @@ -27,10 +27,24 @@ dense facial-landmark or face-recognition node. | `image` | output | Annotated frame | | `text` | output | JSON faces: bounding boxes and, optionally, 6 keypoints each | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `profile` | string | Default `"short"`. BlazeFace model variant | -| `threshold` | number | Default 0.5. Minimum confidence score to include a face | -| `emit_landmarks` | boolean | Default true. Emit the 6 alignment keypoints per face | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `face_detection.emit_landmarks` | `boolean` | **Emit 6 alignment keypoints**
Include coarse 6-point keypoints per face (eyes, nose, mouth, ear tragions) for face-aware framing and alignment. | `true` | +| `face_detection.profile` | `string` | **Model** | `"short"` | +| `face_detection.threshold` | `number` | **Confidence threshold**
Minimum detection confidence (0.0-1.0). Default 0.5 - higher than object detect to suppress false faces on textured backgrounds. | `0.5` | + +## Dependencies + +- `mediapipe` `>=0.10.35` +- `Pillow` +- `numpy` + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/face_detection) + diff --git a/nodes/src/nodes/pose_estimation/README.md b/nodes/src/nodes/pose_estimation/README.md index ecc5dffb5..839c9d1e1 100644 --- a/nodes/src/nodes/pose_estimation/README.md +++ b/nodes/src/nodes/pose_estimation/README.md @@ -11,7 +11,7 @@ ONNX wrapper. **RTMDet-nano** performs person detection first, then RTMPose pred Accepts an image or a document and emits an annotated frame, with the per-person keypoint array attached to the document's metadata. -Top-down means cost scales with the number of people in frame; `max_persons` bounds +Top-down means cost scales with the number of people in frame; `pose_estimation.max_persons` bounds that work. --- @@ -25,10 +25,18 @@ that work. | `image` | input | Source frame or document | | `image` | output | Annotated frame; keypoint array attached to document metadata | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `profile` | string | Default `"rtmpose-medium"`. RTMPose model variant | -| `threshold` | number | Default 0.3. Minimum keypoint score to keep a joint | -| `max_persons` | number | Default 20. Maximum persons processed per frame | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `pose_estimation.max_persons` | `number` | **Max persons per frame**
Cap on persons retained per frame (sorted by detection score). Limits memory + compute on crowd scenes. | `20` | +| `pose_estimation.profile` | `string` | **Model** | `"rtmpose-medium"` | +| `pose_estimation.threshold` | `number` | **Keypoint score threshold**
Minimum per-keypoint confidence (0.0–1.0). Keypoints below this score are skipped when drawing the skeleton. | `0.3` | + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/pose_estimation) + diff --git a/nodes/src/nodes/tool_google_workspace/README.md b/nodes/src/nodes/tool_google_workspace/README.md index 0b85500fd..b1d55c299 100644 --- a/nodes/src/nodes/tool_google_workspace/README.md +++ b/nodes/src/nodes/tool_google_workspace/README.md @@ -43,17 +43,56 @@ field, not the boolean flags, is what bounds the agent's day-to-day reach. ## Configuration -### Fields + + -| Field | Type | Description | -|---|---|---| -| `gmail.access` | string | Default `"modify"`. Gmail access level | -| `gmail.allowHardDelete` | boolean | Default false. Allow permanent deletion of mail | -| `drive.access` | string | Default `"write"`. Drive access level | -| `drive.allowPublicSharing` | boolean | Default false. Allow public / external sharing | -| `drive.allowHardDelete` | boolean | Default false. Allow permanent delete | -| `calendar.access` | string | Default `"write"`. Calendar access level | -| `calendar.allowDelete` | boolean | Default false. Allow event / calendar deletion | -| `calendar.allowPublicSharing` | boolean | Default false. Allow public / domain-wide calendar sharing | -| `docs.access` | string | Default `"write"`. Docs access level | -| `sheets.access` | string | Default `"write"`. Sheets access level | +## Schema + +### Google Calendar (`services.calendar.json`) + +| Field | Type | Description | Default | +|---|---|---|---| +| `calendar.access` | `string` | **Access level**
Calendar scopes to request. readonly: read events, calendars, ACLs, and free/busy only. write: full read/write (create, update, move, quick-add events; manage calendars and ACLs). Deletion additionally requires the allowDelete flag; public/domain-wide ACL sharing requires allowPublicSharing. | `"write"` | +| `calendar.allowDelete` | `boolean` | **Allow event / calendar deletion**
When off (the default), event_delete and calendar_delete are refused even at the write tier. Enable only if the agent should be able to permanently delete events and calendars — this is irreversible. | `false` | +| `calendar.allowPublicSharing` | `boolean` | **Allow public / domain-wide calendar sharing**
Off by default. When off, acl_insert refuses rules that expose the calendar beyond individual grantees (scopeType 'default' = anyone on the internet, and 'domain' = everyone in a domain). Turn on to allow public or domain-wide sharing. Grants to individual users/groups are not gated. | `false` | + +### Google Docs (`services.docs.json`) + +| Field | Type | Description | Default | +|---|---|---|---| +| `docs.access` | `string` | **Access level**
Docs scopes to request. readonly: read document text and metadata only. write: full read/write (create documents, append and replace text, insert images and tables, and run arbitrary batchUpdate requests). | `"write"` | + +### Google Drive (`services.drive.json`) + +| Field | Type | Description | Default | +|---|---|---|---| +| `drive.access` | `string` | **Access level**
Drive scopes to request. readonly: list, read metadata, download, and export only. write: full read/write (create, update, copy, move, trash, folders, and sharing). | `"write"` | +| `drive.allowHardDelete` | `boolean` | **Allow permanent delete**
Off by default. When off, file_delete (which permanently deletes a file, bypassing Trash and irreversibly) is refused. Turn on to allow permanent deletion; file_trash is the recoverable alternative. | `false` | +| `drive.allowPublicSharing` | `boolean` | **Allow public / external sharing**
Off by default. When off, permission_create refuses anyone-with-link grants and grants to a domain or user outside the account's own domain. Turn on to allow sharing files publicly or with external parties. | `false` | + +### Gmail (`services.gmail.json`) + +| Field | Type | Description | Default | +|---|---|---|---| +| `gmail.access` | `string` | **Access level**
Gmail scopes to request. readonly: read only. modify: read + label/organize. send: modify + send mail. settings: modify + filters/IMAP/POP/vacation/forwarding. settings_sharing: settings + sendAs/delegation/S⁠MIME. full: complete mailbox access, required for permanent delete. | `"modify"` | +| `gmail.allowHardDelete` | `boolean` | **Allow permanent delete**
Enable permanent message/thread deletion (requires full access tier). Disabled by default to protect against accidental data loss. | `false` | + +### Google Sheets (`services.sheets.json`) + +| Field | Type | Description | Default | +|---|---|---|---| +| `sheets.access` | `string` | **Access level**
Sheets scopes to request. readonly: read values and metadata only. write: full read/write (create, update, append, clear, and structure changes such as add/delete/duplicate sheet). | `"write"` | + +## Dependencies + +- `google-api-python-client` +- `google-auth` +- `google-auth-oauthlib` +- `google-auth-httplib2` +- `idna` `>=3.15` +- `protobuf` `>=5.29.6` + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/tool_google_workspace) + diff --git a/nodes/src/nodes/video_composer/README.md b/nodes/src/nodes/video_composer/README.md index bc52e4bfd..bf4bce3c2 100644 --- a/nodes/src/nodes/video_composer/README.md +++ b/nodes/src/nodes/video_composer/README.md @@ -10,7 +10,7 @@ clip using **FFmpeg**. Place it after any image-producing filter — for example `detect`, `pose_estimation`, or `background_removal` — to turn that filter's annotated frames back into a video. -Output frame rate is set by `fps`, and quality by `crf` (lower is higher quality and a +Output frame rate is set by `composer.fps`, and quality by `composer.crf` (lower is higher quality and a larger file; 23 is FFmpeg's default). Requires an FFmpeg binary available to the engine. @@ -25,10 +25,18 @@ Requires an FFmpeg binary available to the engine. |------|-----------|-------------| | `image` | input | Frames to stitch, in arrival order | -### Fields + + -| Field | Type | Description | -|---|---|---| -| `fps` | number | Default 1.0. Output frame rate | -| `crf` | number | Default 23. FFmpeg quality (CRF); lower is higher quality | -| `profile` | string | Default `"standard"`. Output quality preset | +## Schema + +| Field | Type | Description | Default | +|---|---|---|---| +| `composer.crf` | `number` | **Quality (CRF)**
Constant Rate Factor for H.264. Lower = better quality, larger file. Range 0-51. | `23` | +| `composer.fps` | `number` | **Output frame rate (fps)**
Playback speed of the output video. Should match the upstream frame rate. | `1` | +| `composer.profile` | `string` | **Output quality**
Video encoding quality preset | `"standard"` | + +## Source + +[ View source](https://github.com/rocketride-org/rocketride-server/tree/develop/nodes/src/nodes/video_composer) + From 2fe9621dfc33b253b33e57799ed98b44844372d9 Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Sat, 1 Aug 2026 14:24:40 -0700 Subject: [PATCH 10/11] fix(docs-audit): require the full citation, or an extension, before RUNTIME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review on #1718: matching find_literal() on the basename alone let a source occurrence of a common final segment protect an unrelated path. The reviewer's suggested fix — pass the complete token — over-corrects. Measured against develop, full-token-only turns 5 correct citations into ORPHANED: .workspace/global.json built by packages/shell-api/versions/v0.d.ts:3455 build/state.json built by scripts/lib/download.js:42 (x4 citations) Both are real runtime artifacts that code refers to by filename while the doc supplies the directory. So: try the whole token first, and fall back to the basename only when it carries a file extension. That drops the meaningless protections (a citation ending in a bare English word) and keeps the real ones. Repo verdicts are unchanged at 32 RUNTIME / 0 ORPHANED, but the *evidence* is now sound. Before, `tools/list` was protected because the word "list" appears somewhere in source; it now matches the literal "tools/list" in nodes/test/tool_mcp_client/stub_mcp_server.py. Two regression tests added, per the review: one that an extensionless segment is not evidence, one that the extension-bearing fallback still protects. Known limit, now documented in the code: find_literal scans raw source text, so a path merely mentioned in a comment counts as evidence. I hit this while writing the fix — naming real paths in my own explanatory comment made the tool cite that comment as proof. The comment no longer names any real path. Properly narrowing this needs per-language comment stripping, which is a bigger change than this fix. Verified: 26 passed, ruff clean, format clean. --- tools/docs_audit/src/docs_audit/citations.py | 23 +++++++++++++++++++- tools/docs_audit/test/test_docs_audit.py | 19 ++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/tools/docs_audit/src/docs_audit/citations.py b/tools/docs_audit/src/docs_audit/citations.py index 7b9739a75..6369887a4 100644 --- a/tools/docs_audit/src/docs_audit/citations.py +++ b/tools/docs_audit/src/docs_audit/citations.py @@ -170,7 +170,28 @@ def classify(citation: Citation, index: CodeIndex, doc_lines: list[str]) -> Verd # change what survives a cleanup -- it changes the evidence a human reads, # and "source builds this name at pkg/real.py:3" is worth more than # "create-verb in context". (Prose like "Writes `x.json`" matches both.) - literal = index.find_literal(basename) + # Prefer the whole citation. Falling straight back to the basename let any + # source occurrence of a common final segment protect an unrelated path: a + # citation ending in a bare English word was held RUNTIME because that word + # appears somewhere in source, which is not evidence of anything. + # + # The fallback survives only for a basename carrying a file extension, which + # is the real case: a build artifact referred to by filename in code while + # the doc supplies its directory. Requiring the full token reports those as + # dead. + # + # NB: no real repository path is named in this comment on purpose -- + # find_literal scans raw source text, so a path written here would index as + # its own evidence. That cuts both ways and is a known limit: a path merely + # mentioned in a comment anywhere in the tree counts as "source builds this + # name". Narrowing that needs comment-stripping per language, which is a + # bigger change than this fix. + literal = index.find_literal(token) + if literal is None and '.' in basename and basename != token: + found = index.find_literal(basename) + if found is not None: + where, where_line = found + return Verdict(citation, RUNTIME, f'source builds this filename: {where}:{where_line}') if literal is not None: where, where_line = literal return Verdict(citation, RUNTIME, f'source builds this name: {where}:{where_line}') diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py index 90f1c73f9..e96bc3c18 100644 --- a/tools/docs_audit/test/test_docs_audit.py +++ b/tools/docs_audit/test/test_docs_audit.py @@ -124,6 +124,25 @@ def test_runtime_path_built_by_code_is_protected(repo: Path) -> None: assert 'pkg/real.py' in verdict.evidence +def test_extensionless_segment_is_not_runtime_evidence(repo: Path) -> None: + """Regression: matching only the basename let a citation ending in a common + word be protected by that word appearing anywhere in source. `pkg/real.py` + contains "json", which must not make `tools/json` look like a built path. + (`tools/` prefix so the extractor picks the token up as a directory span.) + """ + (verdict,) = _classify('Call `tools/json` to fetch it.', repo) + assert verdict.verdict == ORPHANED + + +def test_basename_fallback_needs_an_extension(repo: Path) -> None: + """The complement: a doc supplying the directory for a file the code refers + to by name is still protected, because the basename looks like a filename. + """ + (verdict,) = _classify('State lives in `build/built_at_runtime.json`.', repo) + assert verdict.verdict == RUNTIME + assert 'pkg/real.py' in verdict.evidence + + def test_tool_installed_workspace_path_is_protected(repo: Path) -> None: """Regression: `.rocketride/` is written into a *user's* workspace by the VS Code installer, so a doc telling a reader to open a file under it is From 1ddeda95518b3b280a90478edcc792408964ef6f Mon Sep 17 00:00:00 2001 From: aayu22809 Date: Sat, 1 Aug 2026 14:46:53 -0700 Subject: [PATCH 11/11] fix(docs-audit): report an unreadable doc instead of auditing zero citations from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review on #1718. audit_doc() caught OSError and returned [], so a doc that could not be read dropped out of the run silently: no verdict, no gap, no error, exit 0. A permission or I/O failure was indistinguishable from a file containing no citations, and the audit reported success while having skipped it. This is the third instance of one shape in this tool, and I had already fixed the other two without noticing the pattern: a mistyped --root produced an empty audit that passed, and an unparseable services*.json produced zero declared params that looked clean. Same failure mode each time — the tool reporting success for work it did not do. Now yields a single UNREADABLE_DOC verdict carrying the file and the errno type, printed above the verdict table and failing the run unconditionally — not only under --fail-on-orphaned, since being unable to read a doc is a failure to audit rather than a finding about citations. Named UNREADABLE_DOC rather than UNREADABLE because coverage.py already exports UNREADABLE for a node's schema/README; importing both into cli.py collided (ruff F811). Distinct concepts, distinct names. Verified end to end: tree with an unreadable doc -> exit 1, file listed same tree readable -> exit 0 this repo -> exit 0, unchanged 27 passed (1 new regression test, skipped when running as root since mode bits do not apply), ruff clean, format clean. --- tools/docs_audit/src/docs_audit/citations.py | 19 +++++++++++++---- tools/docs_audit/src/docs_audit/cli.py | 15 ++++++++++--- tools/docs_audit/test/test_docs_audit.py | 22 ++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/tools/docs_audit/src/docs_audit/citations.py b/tools/docs_audit/src/docs_audit/citations.py index 6369887a4..300e91a4b 100644 --- a/tools/docs_audit/src/docs_audit/citations.py +++ b/tools/docs_audit/src/docs_audit/citations.py @@ -29,6 +29,9 @@ HISTORICAL = 'HISTORICAL' RUNTIME = 'RUNTIME' ORPHANED = 'ORPHANED' +#: The doc itself could not be read. Not a citation verdict -- a failure to +#: audit. Named apart from coverage.UNREADABLE, which is about a node's schema. +UNREADABLE_DOC = 'UNREADABLE_DOC' PROTECTED = frozenset({VERIFIED, PLACEHOLDER, HISTORICAL, RUNTIME}) @@ -211,11 +214,19 @@ def classify(citation: Citation, index: CodeIndex, doc_lines: list[str]) -> Verd def audit_doc(path: Path, root: Path, index: CodeIndex) -> list[Verdict]: - """Classify every citation in a single doc.""" + """Classify every citation in a single doc. + + A doc that cannot be read yields a single ``UNREADABLE`` verdict rather than + an empty list. Returning nothing made an I/O or permission failure + indistinguishable from a doc containing no citations, so the file dropped + out of the audit silently and the run still reported success -- the same + failure shape as a mistyped ``--root``. + """ + doc = path.relative_to(root).as_posix() try: text = path.read_text(encoding='utf-8', errors='replace') - except OSError: - return [] - doc = path.relative_to(root).as_posix() + except OSError as exc: + citation = Citation(token=doc, doc=doc, line=0) + return [Verdict(citation, UNREADABLE_DOC, f'could not be read ({type(exc).__name__})')] lines = text.splitlines() return [classify(citation, index, lines) for citation in extract(text, doc)] diff --git a/tools/docs_audit/src/docs_audit/cli.py b/tools/docs_audit/src/docs_audit/cli.py index 42200e8da..d0a676683 100644 --- a/tools/docs_audit/src/docs_audit/cli.py +++ b/tools/docs_audit/src/docs_audit/cli.py @@ -8,7 +8,7 @@ import sys from pathlib import Path -from .citations import ORPHANED, audit_doc +from .citations import ORPHANED, UNREADABLE_DOC, audit_doc from .coverage import MISSING_DOC, MISSING_PARAMS, STALE_PARAMS, UNREADABLE, audit_nodes from .index import EXCLUDED_PARTS, CodeIndex, is_excluded @@ -58,6 +58,9 @@ def main(argv: list[str] | None = None) -> int: gaps = audit_nodes(root) orphaned = [v for v in verdicts if v.verdict == ORPHANED] + # A doc that could not be read is a failure to audit, not a clean audit, + # so it fails the run unconditionally rather than only under --fail-on-orphaned. + unreadable = [v for v in verdicts if v.verdict == UNREADABLE_DOC] if args.json: payload = { @@ -76,7 +79,13 @@ def main(argv: list[str] | None = None) -> int: } json.dump(payload, sys.stdout, indent=2) sys.stdout.write('\n') - return 1 if (args.fail_on_orphaned and orphaned) else 0 + return 1 if (unreadable or (args.fail_on_orphaned and orphaned)) else 0 + + if unreadable: + print(f'UNREADABLE docs ({len(unreadable)}) -- these were NOT audited:\n') + for v in unreadable: + print(f' {v.citation.doc}: {v.evidence}') + print() print(f'Scanned {len(verdicts)} doc->code citations across the tree.\n') print(' verdict count meaning') @@ -108,7 +117,7 @@ def main(argv: list[str] | None = None) -> int: for gap in matching: print(f' {gap.node}: {gap.detail}') - return 1 if (args.fail_on_orphaned and orphaned) else 0 + return 1 if (unreadable or (args.fail_on_orphaned and orphaned)) else 0 if __name__ == '__main__': # `python -m docs_audit.cli` printed nothing without this diff --git a/tools/docs_audit/test/test_docs_audit.py b/tools/docs_audit/test/test_docs_audit.py index e96bc3c18..37e63a71c 100644 --- a/tools/docs_audit/test/test_docs_audit.py +++ b/tools/docs_audit/test/test_docs_audit.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import os import sys from pathlib import Path @@ -27,6 +28,7 @@ classify, extract, ) +from docs_audit.citations import UNREADABLE_DOC, audit_doc # noqa: E402 from docs_audit.cli import main # noqa: E402 from docs_audit.coverage import ( # noqa: E402 MISSING_DOC, @@ -286,6 +288,26 @@ def test_dotfile_paths_keep_their_leading_dot(tmp_path: Path) -> None: assert not index.has_path('env') +def test_unreadable_doc_is_reported_and_fails(tmp_path: Path) -> None: + """Regression: audit_doc returned [] on OSError, so a doc that could not be + read vanished from the audit and the run still reported success -- the same + silent-green shape as a mistyped --root. + """ + (tmp_path / 'docs').mkdir() + doc = tmp_path / 'docs' / 'unreadable.md' + doc.write_text('See `pkg/real.py`.\n', encoding='utf-8') + doc.chmod(0o000) + try: + if os.access(doc, os.R_OK): # running as root ignores the mode bits + pytest.skip('cannot make a file unreadable as this user') + index = CodeIndex.build(tmp_path) + (verdict,) = audit_doc(doc, tmp_path, index) + assert verdict.verdict == UNREADABLE_DOC + assert main(['--root', str(tmp_path)]) == 1 + finally: + doc.chmod(0o644) + + def test_nonexistent_root_fails_instead_of_passing_green(tmp_path: Path) -> None: """A typo in --root must not look like a clean audit.""" assert main(['--root', str(tmp_path / 'nope'), '--fail-on-orphaned']) == 2