From a307ac3716df2afd311046e6ae3d1aa5e97ad240 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 6 Oct 2026 10:05:44 -0500 Subject: [PATCH 01/11] Lint docs before the PR: Vale, Dale, and an AI-writing check at commit Move doc linting off the PR and into the pre-commit hook. The hook checks new pages whole and existing pages only on the lines the commit changes: Vale, the AI-isms word list, and two model checks (an AI-isms judge and Dale), each voted over 3 runs with verdicts cached by content. Vale must be installed or the commit is blocked with install steps. - scripts/quality: score CLI (staged/branch/file), Vale and Dale runners, judge, claude-CLI fallback when there is no API key, optional fix helper with validated drop/swap/reword edits, optional readability report - Claude Code: Stop hook that checks only the docs the session edited (recorded by a PostToolUse hook), hooks use node instead of jq - tech-writer agent: lints its own drafts, reads a generated lint-rules list, follows the documentation section guide for placement and new directories, takes GitHub issues, Jira tickets, and Azure DevOps items, and gives examples to every procedure (TODOs for concepts) - Vale ignores bold UI labels and double-quoted product messages - Remove vale-autofix.yml; add script-tests.yml for script changes - Update CLAUDE.md, docs/CLAUDE.md, and CONTRIBUTING.md to match Co-Authored-By: Claude Sonnet 5.5 --- .claude/agents/tech-writer.md | 85 ++- .claude/hooks/post-edit-dale.sh | 15 +- .claude/hooks/post-edit-record.sh | 25 + .claude/hooks/post-vale-recheck.sh | 15 +- .claude/hooks/stop-quality-check.sh | 46 ++ .../references/documentation-section-guide.md | 131 +++++ .claude/references/humanizer-rules.md | 86 +++ .claude/references/lint-rules.md | 71 +++ .claude/settings.json | 15 + .claude/skills/doc-help/SKILL.md | 8 +- .github/workflows/script-tests.yml | 41 ++ .github/workflows/vale-autofix.yml | 461 ---------------- .gitignore | 3 + .vale.ini | 3 + CLAUDE.md | 31 +- CONTRIBUTING.md | 23 +- docs/CLAUDE.md | 36 +- docusaurus.config.js | 4 + package-lock.json | 69 +++ package.json | 6 +- scripts/doc-draft/ai-isms.yml | 256 +++++++++ scripts/doc-draft/lib/prose.mjs | 178 ++++++ scripts/doc-draft/lib/report.mjs | 91 +++ scripts/doc-draft/readability.mjs | 245 +++++++++ scripts/doc-draft/slop.mjs | 294 ++++++++++ scripts/doc-draft/vale-gate.mjs | 516 ++++++++++++++++++ scripts/quality/config.json | 47 ++ scripts/quality/fix-loop.mjs | 272 +++++++++ scripts/quality/gen-lint-digest.mjs | 87 +++ scripts/quality/lib/dale.mjs | 84 +++ scripts/quality/lib/edits.mjs | 332 +++++++++++ scripts/quality/lib/gates.mjs | 17 + scripts/quality/lib/judge.mjs | 146 +++++ scripts/quality/lib/llm.mjs | 95 ++++ scripts/quality/lib/pool.mjs | 13 + scripts/quality/lib/preserve.mjs | 77 +++ scripts/quality/lib/scope.mjs | 91 +++ scripts/quality/lib/vale.mjs | 38 ++ scripts/quality/lib/verify.mjs | 57 ++ scripts/quality/readability.mjs | 60 ++ scripts/quality/score.mjs | 123 +++++ scripts/quality/todo-check.mjs | 44 ++ 42 files changed, 3803 insertions(+), 534 deletions(-) create mode 100755 .claude/hooks/post-edit-record.sh create mode 100755 .claude/hooks/stop-quality-check.sh create mode 100644 .claude/references/documentation-section-guide.md create mode 100644 .claude/references/humanizer-rules.md create mode 100644 .claude/references/lint-rules.md create mode 100644 .github/workflows/script-tests.yml delete mode 100644 .github/workflows/vale-autofix.yml create mode 100644 scripts/doc-draft/ai-isms.yml create mode 100644 scripts/doc-draft/lib/prose.mjs create mode 100644 scripts/doc-draft/lib/report.mjs create mode 100644 scripts/doc-draft/readability.mjs create mode 100644 scripts/doc-draft/slop.mjs create mode 100644 scripts/doc-draft/vale-gate.mjs create mode 100644 scripts/quality/config.json create mode 100644 scripts/quality/fix-loop.mjs create mode 100644 scripts/quality/gen-lint-digest.mjs create mode 100644 scripts/quality/lib/dale.mjs create mode 100644 scripts/quality/lib/edits.mjs create mode 100644 scripts/quality/lib/gates.mjs create mode 100644 scripts/quality/lib/judge.mjs create mode 100644 scripts/quality/lib/llm.mjs create mode 100644 scripts/quality/lib/pool.mjs create mode 100644 scripts/quality/lib/preserve.mjs create mode 100644 scripts/quality/lib/scope.mjs create mode 100644 scripts/quality/lib/vale.mjs create mode 100644 scripts/quality/lib/verify.mjs create mode 100644 scripts/quality/readability.mjs create mode 100644 scripts/quality/score.mjs create mode 100644 scripts/quality/todo-check.mjs diff --git a/.claude/agents/tech-writer.md b/.claude/agents/tech-writer.md index 0f5412eebd..1f5c5438e9 100644 --- a/.claude/agents/tech-writer.md +++ b/.claude/agents/tech-writer.md @@ -1,6 +1,6 @@ --- name: tech-writer -description: "Autonomous end-to-end documentation agent: drafting from specs, editing for style and clarity, or incorporating external documents." +description: "Use this agent to write documentation from source material, end to end, without back-and-forth. Launch it whenever the user supplies material to document, even if they never say 'tech writer' or 'draft'. Triggers: (1) a pull request or commit to document ('look at PR 123 in netwrix/repo and document the feature'); (2) specs, requirements, or a design doc to turn into feature documentation; (3) rough notes, a transcript, or a brain dump to turn into a document; (4) a prompt that describes what to write in enough detail to draft from ('write a page on X that covers A, B, C'); (4a) a request to structure or organize a new docs directory for a product or version; (5) a work item to document: a GitHub issue, a Jira ticket, or an Azure DevOps work item, given as a URL, a key or number, or pasted text ('look at issue 88 in netwrix/repo and write the doc', 'document PLAT-1234', 'write a page for ADO item 5521'). For editing existing docs, style or Vale questions, brainstorming, or merging a .docx into a page, use the doc-help skill instead.\nExamples:\n- Example 1: user: \"Look at PR 482 in netwrix/accessanalyzer-app and document the new agents dashboard.\" assistant: \"I'll launch the tech-writer agent to read the PR and draft the documentation.\" A PR to document. The agent reads the diff with gh and drafts the page.\n- Example 2: user: \"Here are the specs for the new export feature. I want to document it in Access Analyzer 26.1.\" assistant: \"I'll have the tech-writer agent draft the feature documentation from these specs.\" Specs supplied to document as a feature.\n- Example 3: user: \"Here are my notes from the SME call. Turn them into a document.\" assistant: \"I'll launch the tech-writer agent to turn the notes into a page.\" Rough notes to turn into a document.\n- Example 4: user: \"Write a how-to on rotating a service account certificate. Cover generating a certificate, uploading your own, and what happens to running scans.\" assistant: \"I'll launch the tech-writer agent to write that page.\" A prompt with enough detail to draft from, with no source files.\n- Example 5: user: \"Look at Jira ticket PLAT-1234 and write the documentation for it.\" assistant: \"I'll launch the tech-writer agent to read the ticket and its linked PRs and draft the page.\" A work item to document. The agent fetches it from the tracker, follows linked PRs, and drafts only what the sources support." model: sonnet color: purple memory: project @@ -10,7 +10,7 @@ You are an expert technical writer for Netwrix, a cybersecurity company that bui Your background: you've written production code at scale, shipped security products to enterprise customers, and owned documentation end-to-end at a fast-moving company. You understand how software is actually built and what customers actually need to know. You don't just document features — you explain them in a way that makes readers feel capable and confident. -You write clearly, conversationally, concisely, and consistently. Every concept you introduce comes with an example. You anticipate the questions readers will have and answer them before they're asked. You provide enough context for newer users to follow along without over-explaining things experienced users already know. +You write clearly, conversationally, concisely, and consistently. Every procedure comes with an example. A concept gets one only when you can write a real one from the source; otherwise you leave a TODO for the writer instead of padding the page. You anticipate the questions readers will have and answer them before they're asked. You provide enough context for newer users to follow along without over-explaining things experienced users already know. **Always read `docs/CLAUDE.md` before starting any task.** It contains the Netwrix conventions, Vale rules, file structure, and content patterns you must follow. @@ -22,47 +22,78 @@ If the task is ambiguous, ask one clarifying question before proceeding. Otherwi Before starting work, create a todo for each step of your task using the TaskCreate tool. Mark each task complete as you finish it. This gives the user visibility into your progress on long-running tasks. -After editing docs files, hooks will suggest running Vale and Dale. Linting is handled separately by hooks and CI. +You lint your own drafts: run the style check in step 9 of "Draft new documentation" before you finish. It is the same check that blocks the commit, and nothing lints on the PR. ## Task Types ### Draft new documentation -1. Read `docs/CLAUDE.md` for conventions +1. Read `docs/CLAUDE.md` for conventions, and `.claude/references/lint-rules.md`, the list of rules the style check enforces, so your first draft avoids them 2. Read the specification or source material provided -3. Read 1–2 similar existing documents in the same product for structural reference +3. Read 1–2 similar existing documents in the same product for structural reference. Decide which zone the page belongs in from `.claude/references/documentation-section-guide.md`, by the reader's question the page answers 4. Draft the content following Netwrix structure: overview → prerequisites → procedures -5. Include examples for every concept introduced +5. Give every procedure an example: sample values, a command with real arguments, or the result the reader should see. Do not add an example to a concept just because it is introduced. If a concept would be clearer with an example and the source does not support a real one, write `{/* TODO: add an example of */}` where it belongs and list it in your report. Never invent an example to fill the space. 6. Anticipate reader questions and answer them inline 7. Reread the drafted file. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. +8. Write plainly the first time, and never trade meaning for shorter sentences: keep every fact, condition, qualifier, and requirement. Do not rewrite a finished draft to hit readability numbers. If the writer asks how readable the page is, run `npm run quality:readability -- ` and report the numbers; it is a report, not a target. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. +9. Lint the page before you finish. Run `npm run quality:score -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. -### Edit for style and clarity +### Draft from a pull request -1. Read `docs/CLAUDE.md` for style rules -2. Read the full document before making any changes -3. Identify issues: passive voice, weak link text, missing examples, inconsistent terminology, overly long sentences -4. Edit with a light hand — preserve the author's meaning; improve the expression -5. Reread the file. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. +When given a PR (a number and repo, or a URL): -### Incorporate external documents +1. **Load product context first.** Find the product from the repo name, then read its brief at `.claude/references/products/.md` and `docs//CLAUDE.md` if they exist. The brief says what the product is, how the code is laid out, and what the code calls things versus what the UI and docs call them. If the code contradicts the brief, the code wins: note the mismatch in your report. If there is no brief, say so in your report and continue. +2. Read the PR with `gh pr view --repo --json title,body,files,commits` and `gh pr diff --repo `. Read linked issues the body references. +3. **Read beyond the diff.** Reading the full code answers what the diff cannot: menu paths, button labels, defaults, permissions, feature flags. Get a local copy of the PR's repo, in this order: + 1. The brief's `local_clone` path, if it exists and its `origin` remote matches the repo. + 2. A search for an existing clone: check `~/src`, `~/code`, `~/repos`, `~/git`, `~/projects`, `~/work`, and the parent of the current directory for a git repo whose `origin` remote matches `/` (`git -C remote get-url origin`). Most users already have one. + 3. No clone found: ask the user once for the path, or whether you may clone it. If they agree, run `gh repo clone / ~/src/` (a full clone, so later runs reuse it) and tell them where it landed. If they decline or there is no answer, read the changed files at the PR's head commit with `gh api` and say in your report that the draft lacks full-code context. -When given an external document (e.g., `.docx`, `.pdf`, or pasted content) to merge into an existing markdown file: + Never check out in the user's working tree. Fetch the PR into a separate worktree in your scratchpad directory (`git -C fetch origin pull//head` then `git -C worktree add /pr- FETCH_HEAD`) and remove the worktree when you finish. In it, read the changed files in full, then their callers, config defaults, and UI strings or resources. +4. **Find what the docs already say.** Search `docs//` (latest version) with Grep for the feature's UI labels, setting names, and CLI switches. Decide: new page, new section in an existing page, or a change to existing text. Say which in your report, with paths. +5. Work out what a user can now see or do: new UI, settings, defaults, permissions, limits, and behavior changes. Document only what the code and description support. Where they are silent, write a `{/* TODO: ... */}` marker and list it in your report. Do not infer behavior. +6. Decide the version and target folder from the PR and `src/config/products.js`. If you can't tell, ask one question. +7. Continue with "Draft new documentation" from step 3. -1. Read `docs/CLAUDE.md` for conventions -2. Read the external document to understand its content and structure -3. Read the target markdown file in full -4. Identify where the new content fits — match the existing document's structure and heading hierarchy -5. Merge the content, adapting it to Netwrix style: active voice, present tense, imperative procedures, examples for every concept -6. Remove any content from the external document that duplicates what's already in the target file -7. Reread the merged file. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. +### Draft from a work item (GitHub issue, Jira ticket, Azure DevOps item) + +When given an issue, ticket, or work item (a URL, a key such as `PLAT-1234`, an issue number with a repo, or an Azure DevOps item id): + +1. **Fetch it from its tracker.** Read the title, description, acceptance criteria, labels, status, fix or target version, comments, and linked items. + - **GitHub issue:** `gh issue view --repo --comments --json title,body,labels,state,comments`. Find linked PRs from the body, the comments, and `gh pr list --repo --state all --search ""`. + - **Jira ticket:** use an Atlassian or Jira tool if one is connected (load it with ToolSearch; names contain "Atlassian" or "Jira"), or a Jira CLI if one is installed. Include the issue's links, development panel, and fix version. + - **Azure DevOps item:** use `az boards work-item show --id --expand relations` if `az` and its `azure-devops` extension are installed, or an Azure DevOps tool if one is connected. Follow the related pull requests and linked items. + - **No access.** If you cannot reach the tracker (no tool, not signed in, or the item is private), say what you tried and ask the user once to paste the title, description, acceptance criteria, and comments. Never reconstruct a ticket from its key or title alone. +2. **Treat the item as a claim, not as proof.** Work items describe intent, and they go stale. Prefer what linked code shows over what the description says, and note any conflict in your report. +3. **Follow the code.** If you find one or more linked or merged PRs, run "Draft from a pull request" for them (product context, reading the code, finding what the docs already say), and use the item's description for the user-facing purpose, audience, and wording. If there is no linked code, treat the item's text as the only source, as in "Draft from specs, notes, or a prompt." +4. **Check whether it shipped.** If the item is open, in progress, or not in a released version, write `{/* TODO: confirm this ships in */}` and say so in your report. Take the version from the item's fix or target version. Do not guess one. +5. **Keep tracker details out of the page.** Never copy ticket keys, customer or tenant names, internal URLs, credentials, or personal names into the docs. If the item or its comments describe an unpatched vulnerability, do not document the vulnerability. Report it to the user and stop short of publishing details. +6. Continue with "Draft new documentation" from step 3. In your report, list the tracker and item id, every source you read (item, comments, linked PRs, code), what you could not read, and each gap you marked with a TODO. + +### Draft from specs, notes, or a prompt + +Treat the supplied text as the only source of fact. Restructure it into the Netwrix page type it fits (concept, procedure, reference). Mark gaps with `{/* TODO: ... */}` instead of filling them from memory, and list every gap in your report. If the prompt names no product or version, ask one question. + +### Structure a new directory from scratch + +When there is no docs directory yet for a product or version, or the user asks you to organize one, follow `.claude/references/documentation-section-guide.md`. Read it first and apply it exactly. + +1. **Pick the zones.** Always include the required zones: Getting Started, Requirements, Install and Update, Configuration, and Administration (the core `admin` category is always present). Add a conditional zone (Release Notes, Migration, User Guide, Integrations & API, Troubleshooting) only when its "when it applies" condition is met and you have real content for it. Never create an empty section. +2. **Keep the order.** Release Notes first, then Getting Started, Requirements, Install and Update, Migration, Configuration, Administration, User Guide, Integrations & API, Troubleshooting. Knowledge Base stays at position 999. +3. **Place each page by the reader's question it answers.** Getting Started has no install steps, requirements, or settings. Requirements and Install hold only the base product; a module's own requirements and setup live with that module. A routine upgrade stays in Install and Update. Migration is for moves between major versions or from a competing product. +4. **Administration can be a band.** Keep `admin` for core operation. Give a substantial, distinct feature area its own sibling category. Don't flatten large features into `admin`, and don't invent categories for content that fits under it. A module with its own install, admin, and end-user surfaces repeats the zone logic one level down. +5. **Release notes page.** One page that links to the Netwrix Community, with the standard header and body text from the guide. +6. **Create the structure.** One folder per zone, each with a `_category_.json` (label, position, collapsed and collapsible, and a link to its overview page, as existing products do) and a landing page. Use positions in zone order with gaps, the Administration core at 400 with siblings at 440, 460, and so on, and the Knowledge Base at 999. If the product already has directories, match its existing numbers and folder-naming convention. +7. **Registration.** If the product or version is not in `src/config/products.js`, do not guess an entry or edit that file. Give the entry it needs in your report. +8. **Report the structure.** List each zone you created and its position, each zone you left out and why, and anything that did not fit a zone. ### Multi-file tasks -When a task covers multiple files (e.g., "fix Vale issues in `docs/accessanalyzer/12.0/`"): +When the source needs several pages (for example a feature with a concept page, a procedure, and a reference table): -1. List all matching files first using Glob -2. Create one todo per file -3. Process files one at a time — complete all steps for each file before moving to the next +1. Plan the page list first: file path, page type, and the facts each page covers +2. Create one todo per page +3. Draft pages one at a time — complete all steps for each file before moving to the next ## Output Style @@ -90,7 +121,7 @@ The difference: ## Style Reference -Vale and Dale run via hooks after you edit files and automatically on PRs. The self-review step in each task type covers the same issues Dale checks (passive voice, wordiness, idioms, hedging, future tense). The rules below cover what linters don't catch. Apply these while writing. +Vale and Dale run when you run the style check (step 9) and again in the pre-commit hook. Nothing lints on the PR. The self-review step in each task type covers the same issues Dale checks (passive voice, wordiness, idioms, hedging, future tense). The rules below cover what linters don't catch. Apply these while writing. ### Grammar @@ -131,7 +162,7 @@ Vale and Dale run via hooks after you edit files and automatically on PRs. The s ### Structure - Concepts before procedures: overview → prerequisites → steps. -- Examples immediately after the concept they illustrate. +- An example goes immediately after the procedure or concept it illustrates. - Common tasks before advanced topics. - Cross-references at the end of sections. - Alt text on every image. diff --git a/.claude/hooks/post-edit-dale.sh b/.claude/hooks/post-edit-dale.sh index 43c46d76be..3de60efc69 100755 --- a/.claude/hooks/post-edit-dale.sh +++ b/.claude/hooks/post-edit-dale.sh @@ -6,8 +6,12 @@ # Output: JSON with context message for Claude (stdout on exit 0) INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name') -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +# Read one field from the hook's JSON input. Uses node (always present in this repo); jq is not installed everywhere. +read_field() { + echo "$INPUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const v=process.argv[1].split(".").reduce((o,k)=>o?.[k],JSON.parse(d));process.stdout.write(v==null?"":String(v))}catch{}})' "$1" +} +TOOL_NAME=$(read_field tool_name) +FILE_PATH=$(read_field tool_input.file_path) # Only act on Edit or Write tools if [ "$TOOL_NAME" != "Edit" ] && [ "$TOOL_NAME" != "Write" ]; then @@ -30,9 +34,4 @@ if [ "$BASENAME" = "CLAUDE.md" ] || [ "$BASENAME" = "SKILL.md" ] || [ "$BASENAME fi # Output a context message that Claude will see -jq -n --arg file "$FILE_PATH" '{ - hookSpecificOutput: { - hookEventName: "PostToolUse", - message: ("You just edited " + $file + ". Run /dale " + $file + " to check for dale linting issues.") - } -}' +node -e 'const f=process.argv[1];console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"PostToolUse",message:"You just edited "+f+". Run /dale "+f+" to check for dale linting issues."}}))' "$FILE_PATH" diff --git a/.claude/hooks/post-edit-record.sh b/.claude/hooks/post-edit-record.sh new file mode 100755 index 0000000000..f6a8ac7b2d --- /dev/null +++ b/.claude/hooks/post-edit-record.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# PostToolUse hook (Edit|Write): remember which docs this session edited, so the Stop hook +# checks only those and never pushes Claude to rewrite someone else's draft on the branch. +# Appends the repo-relative path to "$TMPDIR/nwx-quality-files-". Never blocks. + +INPUT=$(cat) +node -e ' + let d = ""; + process.stdin.on("data", c => (d += c)).on("end", () => { + try { + const j = JSON.parse(d); + const file = j.tool_input && j.tool_input.file_path; + if (!["Edit", "Write"].includes(j.tool_name) || !file) return; + const path = require("path"), fs = require("fs"), os = require("os"); + const root = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const rel = path.relative(root, path.resolve(root, file)).split(path.sep).join("/"); + if (!/^docs\/.*\.mdx?$/.test(rel) || /(^|\/)(CLAUDE|SKILL)\.md$/.test(rel)) return; + const session = String(j.session_id || "default"); + const list = path.join(process.env.TMPDIR || os.tmpdir(), "nwx-quality-files-" + session); + const seen = fs.existsSync(list) ? fs.readFileSync(list, "utf8").split("\n") : []; + if (!seen.includes(rel)) fs.appendFileSync(list, rel + "\n"); + } catch {} + }); +' <<< "$INPUT" 2>/dev/null +exit 0 diff --git a/.claude/hooks/post-vale-recheck.sh b/.claude/hooks/post-vale-recheck.sh index 8beacbebc1..7c2ba14173 100755 --- a/.claude/hooks/post-vale-recheck.sh +++ b/.claude/hooks/post-vale-recheck.sh @@ -6,8 +6,12 @@ # Input: JSON on stdin with tool_name and tool_input fields INPUT=$(cat) -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name') -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') +# Read one field from the hook's JSON input. Uses node (always present in this repo); jq is not installed everywhere. +read_field() { + echo "$INPUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const v=process.argv[1].split(".").reduce((o,k)=>o?.[k],JSON.parse(d));process.stdout.write(v==null?"":String(v))}catch{}})' "$1" +} +TOOL_NAME=$(read_field tool_name) +COMMAND=$(read_field tool_input.command) # Only act on Bash tool if [ "$TOOL_NAME" != "Bash" ]; then @@ -20,9 +24,4 @@ if ! echo "$COMMAND" | grep -q "^vale "; then fi # Remind to check if issues remain -jq -n '{ - hookSpecificOutput: { - hookEventName: "PostToolUse", - message: "Vale run complete. If issues were found, fix them and re-run vale until zero errors remain." - } -}' +node -e 'console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"PostToolUse",message:"Vale run complete. If issues were found, fix them and re-run vale until zero errors remain."}}))' diff --git a/.claude/hooks/stop-quality-check.sh b/.claude/hooks/stop-quality-check.sh new file mode 100755 index 0000000000..4e5f4c7416 --- /dev/null +++ b/.claude/hooks/stop-quality-check.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Stop hook: before Claude finishes, run the pre-PR style check (Vale, Dale, AI-isms) on the +# docs THIS SESSION edited (recorded by post-edit-record.sh): new pages whole, existing pages on +# their changed lines. Other drafts on the branch are not this session's to rewrite, so they are +# left alone. If anything is flagged, exit 2 hands the findings back to Claude to fix. After 3 +# rounds it lets go; the pre-commit hook is the hard gate and checks everything that is staged. + +[ -n "$NWX_QUALITY_CHILD" ] && exit 0 +INPUT=$(cat) +# Parse with node, which the repo already requires; jq is not installed everywhere. +SESSION=$(echo "$INPUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).session_id||""))}catch{}})' 2>/dev/null) +SESSION=${SESSION:-default} +COUNTER="${TMPDIR:-/tmp}/nwx-quality-$SESSION" +ROUNDS=$(cat "$COUNTER" 2>/dev/null || echo 0) + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 +[ -f scripts/quality/score.mjs ] || exit 0 + +# Only the files this session edited. None edited, nothing to check. +LIST="${TMPDIR:-/tmp}/nwx-quality-files-$SESSION" +if [ ! -s "$LIST" ]; then + rm -f "$COUNTER" + exit 0 +fi + +# Unresolved draft markers in those files: tell the user, never block. +node scripts/quality/todo-check.mjs --files $(cat "$LIST") >&2 || true + +if OUTPUT=$(node scripts/quality/score.mjs --base origin/dev --only "$LIST" --vale-optional --table 2>&1); then + rm -f "$COUNTER" + exit 0 +fi + +if [ "$ROUNDS" -ge 3 ]; then + rm -f "$COUNTER" + exit 0 +fi +echo $((ROUNDS + 1)) > "$COUNTER" + +{ + echo "The AI-isms check flagged documentation you wrote or edited (round $((ROUNDS + 1)) of 3):" + echo "$OUTPUT" + echo "" + echo "Rewrite the flagged lines in plain, specific wording without changing what they say. Do not add new filler. Then finish." +} >&2 +exit 2 diff --git a/.claude/references/documentation-section-guide.md b/.claude/references/documentation-section-guide.md new file mode 100644 index 0000000000..66b462d195 --- /dev/null +++ b/.claude/references/documentation-section-guide.md @@ -0,0 +1,131 @@ +# Documentation section guide: what goes where + +Source of truth: the Xchange page "Documentation Section Guide: What Goes Where" +(https://xchange.netwrix.com/doc/documentation-section-guide-what-goes-where-JEaLpMEBNQ), copied here on +2026-10-06 so agents can read it offline. If the Xchange page and this file disagree, the page wins; tell the +user so this file can be updated. + +Every product's docs share the same top-level sidebar sections, in the same order. Each section is a **zone**: +a top-level sidebar grouping such as Getting Started, Requirements, or Install. A zone is usually one folder. +It can also be a **position band**: several sibling categories for distinct feature areas (Administration). + +Use this when you write new content, decide where a page should live, or structure a new directory. + +## Zones, in sidebar order + +| # | Zone | Required? | Reader's question | +|---|---|---|---| +| 1 | Release Notes / What's New | conditional | What changed in this version? | +| 2 | Getting Started | required | What is this product? How is it useful to me? What do I need to do to get started? | +| 3 | Requirements | required | What do I need in place before I can install this? | +| 4 | Install and Update | required | How do I get this installed and running? | +| 5 | Migration | conditional | How do I move to this from an older version or a competing product? | +| 6 | Configuration | required | How do I set up this product to perform task X? | +| 7 | Administration | required | How do I operate and manage this product day to day? | +| 8 | User Guide | conditional | How do I use this as an end user, not an admin? | +| 9 | Integrations & API | conditional | How do I connect this to my other systems, or automate it via the API? | +| 10 | Troubleshooting | conditional | How do I diagnose and fix this problem? | +| 11 | Knowledge Base | moving to Zendesk | (keep at position 999 until it moves) | + +Place a page by the reader's question it answers, not by the feature it mentions. + +## What each zone holds + +**1. Release Notes / What's New (conditional).** One page that links to the Netwrix Community. Apply it when there +are release notes or what's-new information for current users. It comes first, before Getting Started, because +current users want it immediately and new users skip it at no cost. The page says: + +- Header: `What's New` +- Body: `Release notes for are published on the Netwrix Community. See the [ release notes]() for new features, improvements, and fixes in the current release.` + +**2. Getting Started (required).** Describes what the product does, optionally how it works (architecture), and +how someone starts using it, with links to the documents for each next step. The landing page introduces the +reader to the product and is the jumping-off point to install, configure, and use it. It can include a quick-start +guide, the fastest path to value. +- Includes: product overview/introduction, key concepts, architecture and how it fits in the Netwrix ecosystem, quick-start guide (if available), a jumping-off point with links to next steps. +- Excludes: system requirements, deployment steps, ongoing settings, standalone commercial pages (for example a "Free Trial" page). **These documents never give installation steps, requirements, or settings.** + +**3. Requirements (required).** What has to be true before you install the base product: hardware, software, +network, permissions, and licensing prerequisites. +- Includes: system requirements, supported platforms and versions, network and firewall rules, account and permission prerequisites. +- Excludes: requirements for a specific module or feature within a product (those live with that module). + +**4. Install and Update (required).** The steps to get the product running, plus deployment mechanics. +- Includes: installation steps, initial setup wizards, licensing activation, upgrade paths that aren't substantial enough to need their own Migration zone. +- Excludes: setup steps for a specific module or feature within a product. + +**5. Migration (conditional).** Steps to move from an old version or a competing product to the current version. +Apply it when users must follow a migration process, such as moving between major versions with data or +configuration translation. A routine point-release upgrade stays in Install and Update. + +**6. Configuration (required).** One-time or infrequent setup after installation that users need before they can +perform regular tasks. +- Includes: data source and connector setup, initial policy definitions, environment-specific settings. +- Excludes: how to perform regular tasks with the product. + +**7. Administration (required).** Day-to-day operation and management. This is not always a single folder. It can +be a position band holding several sibling categories for distinct feature areas. +- Includes: the core `admin` category (dashboards, alerts, user and role management, reporting) and product-specific feature categories substantial enough to deserve their own top-level spot. +- Rule for siblings: don't flatten large distinct features into one generic bucket, and don't invent extra categories for content that fits naturally under `admin`. +- Minimum: the `admin` category itself is always present. +- Example: Change Tracker's Administration zone has `admin` at position 400 and sibling categories Agents (440), Compliance (460), Baseline (480), and Cloud (500), each a distinct feature area, not buried under one generic `admin/` folder. + +**8. User Guide (conditional).** Content for the actual end user of a self-service surface, not the administrator. +Includes end-user portal walkthroughs, such as a self-service password reset flow or an access request portal. +Apply it when the product has an end-user-facing surface distinct from the admin console. + +**9. Integrations & API (conditional).** How to connect the product to other systems, and the REST/API reference +if one exists. Includes third-party integrations (SIEM, ITSM, ticketing), webhooks, SDKs, API authentication and +reference docs. If the API reference is substantial, make it its own top-level section instead of folding it into +Integrations. A substantial reference is an OpenAPI spec, like Change Tracker's at +https://docs.netwrix.com/docs/changetracker/api/reference/. Apply the zone when the product offers ways to integrate. + +**10. Troubleshooting (conditional).** Helps users diagnose and resolve problems: common error conditions, +diagnostic steps, log locations. Distinct from the Knowledge Base, which is driven by customer support cases. +Apply it only when real troubleshooting content exists. Never create an empty section. + +**11. Knowledge Base.** Moving to Zendesk. Keep it at position 999 until it moves. + +## Rules that apply to every new directory + +- Include every required zone. Include a conditional zone only when its "when it applies" condition is met and you have real content for it. Don't create empty sections. +- Keep zones in the order above. Release Notes goes first. +- Keep a module's own requirements, install, and setup with that module. A licensed add-on with its own install, admin, and end-user surfaces gets its own sibling category, and the same zone logic can repeat one level down inside it (install, admin, user-guide). +- Merge duplicates (for example a top-level `whats-new.md` and an `overview/whatsnew.md`) into the one page the zone calls for. +- Group many parallel connectors under Integrations by kind (for example SIEM, ITSM, cloud, security, storage, other) instead of one flat folder. + +## Worked example: Auditor 10.9 + +Before, the product had a flat mix: `overview/`, `requirements/`, `install/`, `configuration/`, `admin/`, +`accessreviews/`, `accountlockoutexaminer/`, `tools/`, `addon/` (about 20 connector folders, no grouping), and +`api/`. Applying the guide gave: + +```text +docs/auditor/10.9/ +├── release-notes/whats-new.md # Release Notes: first position; merges the two what's-new duplicates +├── getting-started/ # overview, product editions, landing page (orientation only) +├── requirements/ +├── install/ # upgrade.md stays here: a routine point-release upgrade, so no Migration zone +├── configuration/ +├── admin/ # Administration: core category +├── access-reviews/ # Administration: sibling (substantial add-on module) +│ ├── overview.md, install/, admin/ +│ └── user-guide/ # its reviewers are business resource owners, so User Guide recurs one level down +├── account-lockout-examiner/ # Administration: sibling (named, self-contained) +├── tools/ # Administration: sibling (five independent utilities) +└── integrations/ # Integrations & API + ├── overview.md + ├── siem/, itsm/, cloud/, security/, storage/, other/ + └── api/ # nested here because it isn't a substantial reference +``` + +8 of the 11 zones applied (no Migration, no standalone Troubleshooting). The Administration band ended up with +four siblings: `admin`, `access-reviews`, `account-lockout-examiner`, and `tools`. + +## Positions + +The guide fixes only these numbers: Administration `admin` at 400 with sibling categories at 440, 460, 480, and +500 in Change Tracker, and the Knowledge Base at 999. Existing products still use older positions (Change +Tracker's `admin` category is at 50 today), so match a product's existing numbers when you add to it. For a new +directory, give zones ascending positions in the order above, with gaps so later zones can slot in, put the +Administration core at 400 with siblings at 440, 460, and so on, and state the positions you chose. diff --git a/.claude/references/humanizer-rules.md b/.claude/references/humanizer-rules.md new file mode 100644 index 0000000000..ab6902a60c --- /dev/null +++ b/.claude/references/humanizer-rules.md @@ -0,0 +1,86 @@ +# Humanizer rubric — model-judged AI-isms + +Judgment-only companion to `scripts/doc-draft/slop.mjs`. That gate already +catches everything a regex or a page-level statistic can catch: the lexical +catalog (`scripts/doc-draft/ai-isms.yml`) and six structural signals +(sentence-length variance, tricolon rate, repeated paragraph openers, +bullet density, heading-to-prose ratio, em-dash rate). Never re-implement +any of those here — this file exists only for the patterns from +[Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) +that need a reader's judgment, not a counter. + +Sourced from the `humanizer` skill's pattern catalog, trimmed to what +applies to Netwrix technical documentation. Dropped entirely: the skill's +"personality and soul" guidance (first-person voice, opinions, humor) — +that's Wikipedia-article/essay advice, and it conflicts with Netwrix house +style (`docs/CLAUDE.md`), which is impersonal, instructional, second-person +("you"). Also dropped: patterns that only occur in encyclopedia articles +(notability/media-coverage framing, "Challenges and Future Prospects" +sections) — kept below only where a docs-shaped version of the pattern +exists. + +Scoring a page against this rubric means finding zero instances of any +pattern below. Every finding must cite the exact line and quote the +offending text — never a vague "this section feels AI-written." + +## Patterns to find + +1. **Inflated significance.** Framing a routine feature or step as + uniquely important: "plays a crucial role," "serves as a cornerstone," + "is essential to ensuring." State what the feature does; let its + importance speak for itself. +2. **Superficial `-ing` analysis clauses.** A sentence that states a fact, + then tacks on an unearned interpretive clause: "The scan runs nightly, + ensuring your data stays current." Cut the clause or replace it with a + real, specific consequence. +3. **Promotional/advertisement language.** "Powerful," "robust," + "seamless," "cutting-edge," "industry-leading," "game-changing" used as + unsupported praise rather than a specific, checkable claim. +4. **Vague attributions.** "Users report," "many organizations find," + "it's widely recognized" — with no source. Either name the source or + drop the claim. +5. **Copula avoidance.** Circling around a simple "is/are" with "serves + as," "functions as," "acts as," "stands as" when a plain "is" says the + same thing. +6. **Negative parallelisms.** "Not just X, but Y," "isn't merely X — it's + Y." Occasional use is fine; more than one per page is a tell. +7. **Elegant variation / synonym cycling.** Swapping in a synonym for a + term purely to avoid repeating it ("the tool... the utility... the + application...") when the repeated term would be clearer. Netwrix style + prefers the same term every time for the same thing. +8. **False ranges.** "From basic reporting to advanced analytics," + "ranging from simple fixes to complex overhauls" — a rhetorical span + standing in for a real list. Name the actual items instead. +9. **Boldface overuse.** Bolding for emphasis rather than the house-style + uses (UI labels, key terms on first use). More than a couple of + emphasis-only bolded phrases in a paragraph is a tell. +10. **Inline-header vertical lists.** A bullet list where every item opens + with a bolded 2-4 word label immediately followed by a colon, used + for plain prose that doesn't need list formatting at all. +11. **Emojis and curly/smart quotation marks.** Netwrix docs use straight + quotes and no decorative emoji in body prose. +12. **Collaborative-artifact leftovers.** Text that reads like a reply to + a prompt rather than documentation: "Certainly, here's...", "I hope + this helps," "As an AI...", "Let me know if you'd like...", or a + knowledge-cutoff disclaimer ("as of my last update"). +13. **Sycophantic/servile tone.** "Great question," "you're absolutely + right to ask" — has no place in reference documentation aimed at a + reader, not a requester. +14. **Filler phrases and throat-clearing.** "It is important to note + that," "In today's fast-paced environment," "needless to say" — adds + words, not information. +15. **Excessive hedging.** Stacking qualifiers ("may potentially," + "could possibly in some cases") past what the actual certainty + warrants for a documented, tested behavior. +16. **Generic positive conclusions.** A closing paragraph or section that + restates the page's value in vague, feel-good terms instead of ending + on the last concrete instruction or fact. + +## Not in scope here + +Sentence-length uniformity, tricolon overuse, repeated paragraph openers, +bullet density, heading-to-prose ratio, and em-dash rate are scored by +`slop.mjs` as real numbers against calibrated thresholds — don't +re-score them by eye here, and don't flag a single tricolon or em dash on +sight; the gate already knows the difference between house style and +overuse. diff --git a/.claude/references/lint-rules.md b/.claude/references/lint-rules.md new file mode 100644 index 0000000000..97c75ef434 --- /dev/null +++ b/.claude/references/lint-rules.md @@ -0,0 +1,71 @@ +# Lint rules at a glance + +Generated by `scripts/quality/gen-lint-digest.mjs` from the Vale rules, the Dale rules, and the AI-isms word list. Do not edit by hand. +Read this before drafting. Each rule below is checked before a PR: Vale and the word list by code, Dale by a model. + +## Vale (code, every page) + +- **Aforementioned**: Avoid 'aforementioned'. Refer to the subject directly or use 'described earlier'. Terms: aforementioned +- **AllowsYouTo**: Avoid ''. Rewrite with a direct imperative or active construction, e.g. 'Use X to ...' or 'With X, you can ...'. Terms: allows you to, allow you to, allows users to, allow users to, enables you to, enable you to, permits you to, enables users to, enable users to, permits users to +- **CalledUp**: Replace '' with 'open', 'access', or 'view' — 'called up' is not standard English for this meaning. Terms: called\s+up, calls\s+up, calling\s+up, call\s+up +- **CanBeUsedTo**: Rewrite '' in active voice — for example, 'Use X to...' or 'X lets you...'. Terms: can be used to, can be used for +- **CarriedOut**: Replace the wordy phrase 'carried out' with a direct verb like 'performed', 'completed', or 'run' — or rewrite in active voice. Terms: carried out +- **Checkbox**: Use '
' instead of 'check box'. Examples: check box → checkbox +- **ClickOn**: Use '' — omit 'on' after click verbs. Examples: (double-click) on → $1; (right-click) on → $1; (left-click) on → $1; (left click) on → $1; (click) on → $1 +- **Contractions**: Use the contraction '' instead of the formal form. Examples: do not → don't; does not → doesn't; did not → didn't; cannot → can't; can not → can't; would not → wouldn't; should not → shouldn't; could not → couldn't; is not → isn't; are not → aren't; was not → wasn't; were not → weren't +- **DesiredAsAdjective**: Avoid '' — name the specific item or write 'the [noun] you want'. Terms: the desired \w+ +- **Dropdown**: Use 'dropdown' (one word). Avoid 'drop-down' and 'drop down'. Examples: drop down → dropdown; drop-down → dropdown +- **FirstPerson**: Avoid first person singular ''. Rewrite without 'I', 'me', 'my', 'mine', or contractions of 'I'. Terms: I, me, my, I'm, I've, I'll, I'd +- **FirstPersonPlural**: Avoid first person plural ''. Rewrite without 'we', 'our', 'us', or 'ours'. Terms: we, our, ours, us +- **FollowTheStepsTo**: Avoid ''. Use a direct heading and numbered list instead. Terms: follow the steps to, follow these steps to +- **FormalHedging**: Use '' instead of ''. Examples: prior to → before; subsequent to → after; in the event that → if; in the event of → if there is; for the purpose of → to; with the intention of → to; at this point in time → now +- **Idioms**: Avoid the idiom ''. Write what you mean directly and literally. Terms: tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp(s|ed|ing)? up, spin(s|ning)? up, stand(s|ing)? up, tear(s|ing)? down, tore? down, low.hanging fruit, moves? the needle, boil(s|ed|ing)? the ocean, levels? the playing field, raises? the bar, sets? the stage, paves? the way, bridges? the gap, fills? the gap, cut(s|ting)? corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, … (70 in all) +- **ImpersonalFiller**: Remove '' and state the point directly. Terms: it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say +- **InOrderTo**: Use '' instead of 'in order to'. Examples: in order to → to +- **IsAbleTo**: Use '' instead of the 'able to' construction. Examples: is able to → can; are able to → can; was able to → could; were able to → could +- **LatinAbbreviations**: Avoid Latin abbreviation ''. Use the English equivalent instead: 'for example' or 'and so on'. Terms: e.g., etc. +- **LatinIE**: Avoid the Latin abbreviation ''. Rewrite the clause using plain English — for example, 'meaning', 'in other words', or rephrase to integrate the explanation naturally. Terms: i.e. +- **LoginVerb**: Use '' — 'login' as a verb should be two words. Examples: login to → log in to +- **LogonAsVerb**: Use 'log on' (two words) as a verb. 'Logon' is a noun or adjective only. Found: '' Terms: (need|needs|needed|have|has|had|want|wants|wanted|try|tries|tried|attempt|attempts|attempted|able)\s+to\s+logon, (will|can|could|must|should|would|shall|may|might)\s+logon, who\s+logon, (they|you|we|he|she|it)\s+logon, (normally|infrequently|typically|usually|always|never|now|still|also|just)\s+logon, (cannot|can.t)\s+logon, unable\s+to\s+logon, connect/logon, users?\s+logon\s+(with|onto|using) +- **MakeSure**: Use '' instead of 'make sure'. Examples: make sure → ensure +- **NaturallyFiller**: Remove the filler word '' — state the fact directly without implying it should be obvious. Terms: naturally +- **NoteThat**: Avoid ''. Use a proper admonition block (Note, Warning, or Tip) for supplementary information. Terms: note that, please note +- **OnceUsage**: Avoid '' for temporal sequence. Use 'After' (for sequential steps) or 'When' (for conditions or events). Terms: once you, once the, once it, once this +- **OxfordComma**: '' appears to be missing an Oxford comma. Add a comma before 'and' or 'or' in series of three or more items. Terms: \w+,\s+\w+\s+and, \w+,\s+\w+\s+or +- **PlainTextAdmonition**: Use a Docusaurus admonition block (:::note, :::warning, or :::tip) instead of plain text ''. Terms: (?m)^NOTE:, (?m)^\*\*NOTE:\*\*, (?m)^CAUTION:, (?m)^\*\*CAUTION:\*\* +- **Please**: Avoid 'please' in instructions. Be direct. Terms: please(?!\s+note) +- **Plurals**: Avoid parenthetical plurals ''. Use the plural form, or rephrase with 'one or more'. Terms: \w+(s) +- **ProvidesAbilityTo**: Use '' instead of 'provides the ability to'. Examples: provides the ability to → lets you; provide the ability to → let you; provided the ability to → let you +- **QuestionHeadings**: Headings should be declarative statements, not questions. Rewrite '' without a question mark. Terms: \? +- **ReferToTheFollowing**: Avoid ''. For cross-references, use 'See [link]'. For procedure lead-ins, use a direct heading and numbered list instead. Terms: refer to the following +- **Repetition**: Repeated word '' detected. +- **SetupUsage**: Use '' — 'setup' is a noun or adjective. The verb form is 'set up' (two words). Examples: to setup → to set up; will setup → will set up; can setup → can set up; must setup → must set up; should setup → should set up; have setup → have set up; has setup → has set up; had setup → had set up; getting setup → getting set up +- **Spacing**: Use a single space after sentence-ending punctuation, not two. Terms: [.!?]\s{2,}\S +- **TemporalHedges**: Avoid ''. Documentation should describe the current state without temporal qualifiers. Terms: currently, presently, as of this writing +- **TypeVsEnter**: Use '' — 'enter' is hardware-neutral and preferred over 'type' when instructing users to supply text. Examples: type the → Enter the; type a → Enter a; type an → Enter an; type your → Enter your +- **Utilize**: Use '' — avoid 'utilize' and its variants. Examples: utilizes → uses; utilized → used; utilizing → using; utilize → use; utilises → uses; utilised → used; utilising → using; utilise → use +- **WeakLinkText**: Avoid generic link text ''. Write descriptive link text that describes the destination or action. Terms: click here, this link, learn more, see more, read more +- **WhereEver**: Replace '' with 'wherever' (one word). Examples: where ever → wherever +- **WhetherOrNot**: Use '' instead of 'whether or not'. The 'or not' is redundant. Examples: whether or not → whether +- **WishTo**: Use '' instead of 'wish to', or rewrite as a direct imperative. Examples: wish to → want to + +## Dale (model-checked) + +- **exclamatory-sentences**: This rule should trigger when the user or agent writes exclamatory sentences. (Message: Don't use exclamatory sentences.) +- **idioms**: This rule should trigger when the user or agent uses an idiom or other culturally specific expression. (Message: Don't use idioms. Write what you mean more directly and literally.) +- **minimizing-difficulty**: This rule should trigger when the documentation minimizes task difficulty—for example, 'With [product], you can easily [task].' (Message: Do not minimize the difficulty of tasks users are performing.) +- **misplaced-modifiers**: This rule should trigger when the documentation has a misplaced or dangling modifier. This includes participial phrases that attach to the wrong subject (e.g., '[participle phrase], [wrong subject] [verb]') and modifying clauses placed too far from the word they describe (e.g., '[noun A] [preposition] [noun B] [modifier that actually describes noun A]'). (Message: Avoid misplaced modifiers. Move the modifier, or descriptive phrase, so that it accurately describes the right thing.) +- **negative-assumptions**: This rule should trigger when the user or agent writes something that assumes a user cannot do something without the product. (Message: Don't make negative assumptions about what users can or cannot do.) +- **passive-voice**: This rule should trigger when the user or agent writes a sentence or clause in passive voice. (Message: Don't use passive voice. Use active voice instead.) +- **positional-references**: This rule should trigger when the documentation uses spatial direction words like 'below', 'above', 'as shown below', or 'the above section' to reference other content. (Message: Avoid positional references like 'below', 'above', or 'as shown below'. Use 'the following' or a named anchor instead.) +- **undefined-acronyms**: This rule should trigger when a product-specific or non-standard acronym is used without being spelled out on first use in body prose, following the pattern: 'File System Access Audit (FSAA)' on first use, then 'FSAA' thereafter. Do NOT flag: well-known IT industry standards that a sysadmin audience would know (DNS, AD, IP, HTTP, TCP, SID, UID, CPU, RAM, URL, SQL, OS, VM, API, SSH, SSL, TLS, LDAP, SAML, MFA, ACL, DACL, SACL, OU, GPO, NTFS, etc.); code literals in backticks; Netwrix product short names when the full product name has already appeared in the article; acronyms that appear only in headings or titles (define-on-first-use applies to body prose only). Do flag product-specific acronyms like AIC, FSAA, SPAA, CEE, AMQP when used in body text without prior definition, even if the article's title or subject implies the reader knows them. (Message: Acronym used without being defined on first use. Spell it out: 'Full Name (ACRONYM)'.) +- **wordiness**: This rule should trigger when the user or agent writes a wordy sentence when a more concise, direct alternative is possible without altering the meaning. (Message: This sentence is wordy. A more concise, direct alternative is possible.) +- **xy-slop**: This rule should trigger when the user or agent writes something in the negative-postive form, for example 'x is not y, x is z'—or similar. (Message: Do not use the 'x is not y, x is z' format.) + +## AI-isms word list (avoid these words and phrases) + +tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp up, spin up, stand up (idiom), tear down (idiom), tore down, low-hanging fruit, moves the needle, boil the ocean, levels the playing field, raises the bar, sets the stage, paves the way, bridges the gap, fills the gap, cut corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, at first glance, by the same token, on the fly, touch base, circle back, loop in, flesh out, iron out, hammer out, drill down, zero in on, hone in on, a deep dive, deep dive, take a deep dive, from scratch, heavy lifting, pain points, game-changer, silver bullet, magic bullet, one-stop-shop, best of breed, bleeding-edge, cutting-edge, mission-critical, world-class, turnkey, synergy, leverage, it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say, prior to, subsequent to, in the event that, in the event of, for the purpose of, with the intention of, at this point in time, currently, presently, as of this writing, naturally, delve, seamless, robust, comprehensive, landscape, realm, testament to, unlock, empower, elevate, streamline, pivotal, underscores, showcase, plethora, myriad, foster, harness, ever-evolving, in today's, it's worth noting, rest assured, at its core, when it comes to, dive into. + +## AI-writing patterns the judge looks for + +See `.claude/references/humanizer-rules.md`. In short: no inflated significance, no tacked-on "-ing" clauses, no promotional language, no unsourced attributions, no "not just X, but Y", no false ranges, and no synonym cycling. diff --git a/.claude/settings.json b/.claude/settings.json index 244a4cc85d..0d470d6856 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -8,6 +8,10 @@ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-dale.sh", "statusMessage": "Checking if dale linting is needed..." + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-record.sh" } ] }, @@ -21,6 +25,17 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/stop-quality-check.sh", + "statusMessage": "Checking docs for AI-isms..." + } + ] + } ] } } diff --git a/.claude/skills/doc-help/SKILL.md b/.claude/skills/doc-help/SKILL.md index f743afff25..2138c6c6bd 100644 --- a/.claude/skills/doc-help/SKILL.md +++ b/.claude/skills/doc-help/SKILL.md @@ -1,6 +1,6 @@ --- name: doc-help -description: "Interactive writing assistant for Netwrix documentation. Use when a writer wants hands-on, conversational help: brainstorming structure, drafting a section, editing existing content, incorporating external documents (e.g., .docx files) into existing markdown files, or understanding a style or Vale rule. For fully autonomous tasks (write this entire doc, fix all Vale errors end-to-end), use the tech-writer agent instead." +description: "Interactive help with Netwrix documentation already in progress or already written. Use whenever the user wants to review, edit, tighten, restructure, or improve an existing doc, section, or paragraph ('review this page', 'does this read well', 'make this clearer', 'how should I structure this', 'fix this section'); asks why Vale or Dale flagged something or what a style rule means; wants to brainstorm or outline before writing; wants a section drafted with their input; or wants an external document (.docx) merged into an existing markdown file. Applies to any file under docs/ or any pasted doc text. When the user hands over source material to write from (a PR, a GitHub issue, Jira ticket, or Azure DevOps item, specs, notes, a detailed prompt), use tech-writer instead." argument-hint: "[topic, file path, content to edit, or question]" --- @@ -13,7 +13,7 @@ Read `docs/CLAUDE.md` before starting any session. It contains the Netwrix style ## Trigger Conditions - User invokes `/doc-help` with or without arguments -- User asks for help writing, editing, or reviewing Netwrix documentation +- User asks for interactive help writing, editing, or reviewing Netwrix documentation, with no source material to draft from - User asks to incorporate, merge, or integrate content from an external document (e.g., `.docx`) into an existing markdown file - User has a question about a style rule, Vale error, or Netwrix writing convention @@ -81,7 +81,7 @@ For each section, in order: Continue iterating until they are satisfied, then move to the next section. **Always:** -- Provide an example for every concept introduced +- Give every procedure an example. For a concept, add one only if it helps the reader; if one would help and you can't confirm it, ask the writer or leave `{/* TODO: add an example */}` - Anticipate the question the reader is about to ask and answer it inline - Write for the newer user without condescending to the experienced one @@ -109,6 +109,8 @@ Re-read the full document and check for: - Flow and consistency across sections - Redundancy or contradictions - Any sentence that doesn't earn its place +- Style check: before you hand back, run `npm run quality:score -- --table` (Vale, Dale, and the AI-isms check on what changed since `origin/dev`) and fix what it flags without changing any fact. `.claude/references/lint-rules.md` lists the rules. +- Readability: write plainly the first time, and do not shorten sentences at the cost of a fact, condition, or qualifier. `npm run quality:readability -- ` is an optional report; run it only if the writer asks, and report the numbers without revising to meet them. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. Provide a short summary of any final suggestions. Ask if the writer wants to refine anything or if the doc is ready. diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml new file mode 100644 index 0000000000..f88394383d --- /dev/null +++ b/.github/workflows/script-tests.yml @@ -0,0 +1,41 @@ +name: Script Tests + +# Runs the repo's own script tests when a PR changes scripts or package.json. +# Docs are linted before the PR (Vale, Dale, and the AI-isms check run in the +# pre-commit hook); nothing here lints documentation. + +on: + pull_request: + types: [opened, synchronize] + branches: + - dev + paths: + - 'scripts/**' + - 'package.json' + +concurrency: + group: script-tests-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + test-scripts: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + cache: npm + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Run script tests + run: npm run test:scripts diff --git a/.github/workflows/vale-autofix.yml b/.github/workflows/vale-autofix.yml deleted file mode 100644 index 45e24000b3..0000000000 --- a/.github/workflows/vale-autofix.yml +++ /dev/null @@ -1,461 +0,0 @@ -name: Vale Auto-Fix - -on: - pull_request: - types: [opened, synchronize] - branches: - - dev - paths: - - 'docs/**/*.md' - - '!docs/**/CLAUDE.md' - - '!docs/**/SKILL.md' - - '!docs/kb/**' - - 'scripts/**' - - 'package.json' - -concurrency: - group: vale-autofix-${{ github.event.pull_request.number }} - -jobs: - test-scripts: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - - name: Check if scripts changed - id: scripts-changed - run: | - CHANGED=$(git diff --name-only origin/dev...HEAD -- scripts/ package.json) - if [ -z "$CHANGED" ]; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Node.js - if: steps.scripts-changed.outputs.changed == 'true' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '22.x' - cache: npm - - - name: Install dependencies - if: steps.scripts-changed.outputs.changed == 'true' - run: npm ci --ignore-scripts - - - name: Test anchor/slugify scripts - if: steps.scripts-changed.outputs.changed == 'true' - run: npm run test:scripts - - vale-autofix: - needs: test-scripts - if: >- - github.event.pull_request.head.repo.fork == false && - github.actor != 'dependabot[bot]' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Check if triggered by bot commit - id: bot-check - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get the latest commit on the PR head - COMMIT=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }} --jq '{author: .commit.author.name, message: .commit.message}') - AUTHOR=$(echo "$COMMIT" | jq -r '.author') - MESSAGE=$(echo "$COMMIT" | jq -r '.message') - echo "Latest commit author: $AUTHOR" - echo "Latest commit message: $MESSAGE" - - # Skip if this is a commit from the vale-autofix workflow itself - # (prevents re-trigger loops) but allow other bot commits through - # so they still get linted - if echo "$MESSAGE" | grep -qE '^fix\((vale|dale)\):'; then - echo "Skipping: commit is from vale-autofix workflow" - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout PR branch - if: steps.bot-check.outputs.skip != 'true' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.ref }} - token: ${{ secrets.VALE_TOKEN }} - fetch-depth: 0 - - - name: Configure git identity - if: steps.bot-check.outputs.skip != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Get changed markdown files - id: changed-files - if: steps.bot-check.outputs.skip != 'true' - run: | - DIFF_OUTPUT=$(git diff --name-only origin/dev...HEAD -- docs/) - CHANGED_MD_FILES=$(printf '%s\n' "$DIFF_OUTPUT" | grep -E '^docs/.*\.md$' | grep -v '/CLAUDE\.md$' | grep -v '/SKILL\.md$' | grep -v '^docs/kb/' || true) - if [ -z "$CHANGED_MD_FILES" ]; then - echo "No docs markdown files changed" - echo "count=0" >> "$GITHUB_OUTPUT" - else - echo "Changed markdown files:" - echo "$CHANGED_MD_FILES" - echo "count=$(echo "$CHANGED_MD_FILES" | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT" - echo "$CHANGED_MD_FILES" > /tmp/changed-files.txt - fi - - # Same Vale version the local git hooks use (.config/mise.toml). - - name: Install Vale - if: steps.changed-files.outputs.count > 0 - uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 - with: - install_args: vale - - - name: Show Vale version - if: steps.changed-files.outputs.count > 0 - run: vale --version - - - name: Run Vale on changed files - id: vale-initial - if: steps.changed-files.outputs.count > 0 - run: | - jq -n '[]' > /tmp/violations.json - - while IFS= read -r file; do - if [ -f "$file" ]; then - RESULT=$(vale --output JSON "$file" 2>>/tmp/vale-stderr.log || true) - if [ -n "$RESULT" ] && [ "$RESULT" != "{}" ]; then - echo "$RESULT" | jq --arg f "$file" ' - [to_entries[] | .value[] | {path: $f, line: .Line, check: .Check, message: .Message}] - ' > /tmp/vale-file.json - jq -s '.[0] + .[1]' /tmp/violations.json /tmp/vale-file.json > /tmp/vale-tmp.json - mv /tmp/vale-tmp.json /tmp/violations.json - fi - fi - done < /tmp/changed-files.txt - - TOTAL=$(jq 'length' /tmp/violations.json) - echo "total=$TOTAL" >> "$GITHUB_OUTPUT" - echo "Vale found $TOTAL issue(s)" - - - name: Phase 1 — Script fixes - id: phase1 - if: steps.vale-initial.outputs.total > 0 - run: | - chmod +x scripts/vale-autofix.sh - SUMMARY=$(./scripts/vale-autofix.sh /tmp/violations.json) - echo "$SUMMARY" > /tmp/phase1-summary.json - PHASE1_TOTAL=$(echo "$SUMMARY" | jq '.total') - echo "fixed=$PHASE1_TOTAL" >> "$GITHUB_OUTPUT" - echo "Phase 1 fixed $PHASE1_TOTAL issue(s)" - - - name: Re-run Vale for remaining violations - id: vale-remaining - if: steps.vale-initial.outputs.total > 0 - run: | - jq -n '[]' > /tmp/remaining-violations.json - - while IFS= read -r file; do - if [ -f "$file" ]; then - RESULT=$(vale --output JSON "$file" 2>>/tmp/vale-stderr.log || true) - if [ -n "$RESULT" ] && [ "$RESULT" != "{}" ]; then - echo "$RESULT" | jq --arg f "$file" ' - [to_entries[] | .value[] | {path: $f, line: .Line, check: .Check, message: .Message}] - ' > /tmp/vale-file.json - jq -s '.[0] + .[1]' /tmp/remaining-violations.json /tmp/vale-file.json > /tmp/vale-tmp.json - mv /tmp/vale-tmp.json /tmp/remaining-violations.json - fi - fi - done < /tmp/changed-files.txt - - REMAINING=$(jq 'length' /tmp/remaining-violations.json) - echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT" - echo "$REMAINING remaining violation(s) for Phase 2" - - - name: Phase 2 — Claude fixes - id: phase2 - if: steps.vale-remaining.outputs.remaining > 0 - continue-on-error: true - uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - show_full_output: true - prompt: | - You are a documentation fixer. Your job is to fix Vale linting violations in markdown files. - - Read docs/CLAUDE.md for Netwrix writing standards before making any changes. - - Here are the remaining Vale violations after mechanical fixes were applied: - - ```json - $(cat /tmp/remaining-violations.json) - ``` - - For each violation: - 1. Read the file and understand the context around the flagged line - 2. Apply a fix that resolves the Vale rule while preserving the author's meaning - 3. If you are NOT confident in a fix (ambiguous context, multiple valid interpretations, fix would change meaning) SKIP it - - Never edit heading text (a line starting with `#` through `######`, outside any fenced code block). Other files link to headings by anchor, and a heading rename breaks those links. If a violation is on a heading line, SKIP it with reason "heading text — not modified to avoid breaking anchor links". - - If a fix adds or changes a link with a `#anchor`, the anchor must match a real Markdown heading OUTSIDE any fenced code block. Text that merely looks like a heading inside a ``` code block (for example, sample CLI/installer output) is never a valid anchor target, even if it matches the wording you're looking for. Before finishing, run exactly `bash scripts/check-anchors.sh ` (that literal invocation, one file per call, no chaining with other commands) on every file where you added or changed a link anchor. The script also reports broken link targets, not just anchors — only act on a finding for an anchor your own edit introduced: find the correct heading and fix it, or SKIP the violation if no correct target exists. - - After fixing, write a JSON summary to /tmp/phase2-summary.json with this structure: - ```json - { - "fixed": [ - {"path": "file.md", "line": 1, "check": "Netwrix.RuleName", "action": "brief description of fix"} - ], - "skipped": [ - {"path": "file.md", "line": 1, "check": "Netwrix.RuleName", "reason": "why it was skipped"} - ] - } - ``` - - IMPORTANT: Write the summary JSON file, then stop. Do NOT run any git commands — the workflow handles staging, committing, and pushing. Do not post any PR comments. - claude_args: '--allowedTools "Bash(git:*),Bash(bash scripts/check-anchors.sh:*),Bash(./scripts/check-anchors.sh:*),Bash(scripts/check-anchors.sh:*),Read,Write,Edit,Glob,Grep"' - - - name: Phase 3 — Dale fixes - id: phase3 - if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 - continue-on-error: true - uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - github_token: ${{ secrets.GITHUB_TOKEN }} - show_full_output: true - prompt: | - You are Dale, a documentation fixer. Your job is to find and fix documentation style issues using Dale rules. - - Read docs/CLAUDE.md for Netwrix writing standards before making any changes. - - INSTRUCTIONS: - - Step 1: Read every .yml rule file in .claude/skills/dale/rules/ - - Step 2: Read the list of changed files from /tmp/changed-files.txt (one file path per line). - - Step 3: Read each changed file. For each file, check every line against each rule's "reason" field. - - Step 4: Fix each violation in-place, preserving the author's meaning. If you are NOT confident in a fix (ambiguous context, multiple valid interpretations, fix would change meaning), SKIP it. - - Never edit heading text (a line starting with `#` through `######`, outside any fenced code block). Other files link to headings by anchor, and a heading rename breaks those links. If a violation is on a heading line, SKIP it with reason "heading text — not modified to avoid breaking anchor links". - - If a fix adds or changes a link with a `#anchor` (for example, resolving a "positional-references" violation into an explicit link), the anchor must match a real Markdown heading OUTSIDE any fenced code block. Text that merely looks like a heading inside a ``` code block (for example, sample CLI/installer output) is never a valid anchor target. Before finishing, run exactly `bash scripts/check-anchors.sh ` (that literal invocation, one file per call, no chaining with other commands) on every file where you added or changed a link anchor. The script also reports broken link targets, not just anchors — only act on a finding for an anchor your own edit introduced: find the correct heading and fix it, or SKIP the violation if no correct target exists. - - Step 5: Write a JSON summary to /tmp/dale-summary.json with this structure: - ```json - { - "fixed": [ - {"path": "file.md", "line": 1, "rule": "passive-voice", "action": "brief description of fix"} - ], - "skipped": [ - {"path": "file.md", "line": 1, "rule": "idioms", "reason": "why it was skipped"} - ] - } - ``` - If no issues found, write: {"fixed": [], "skipped": []} - - Step 6: Write the summary JSON file (see Step 5 format above), then stop. - - IMPORTANT: Do NOT run any git commands — the workflow handles staging, committing, and pushing. Do not post any PR comments. - claude_args: '--allowedTools "Bash(git:*),Bash(bash scripts/check-anchors.sh:*),Bash(./scripts/check-anchors.sh:*),Bash(scripts/check-anchors.sh:*),Read,Write,Edit,Glob,Grep"' - - - name: Check anchor links - id: anchor-check - if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 - continue-on-error: true - run: | - chmod +x scripts/check-anchors.sh - touch /tmp/anchor-errors.txt - BROKEN=0 - mapfile -t FILES < /tmp/changed-files.txt - ./scripts/check-anchors.sh "${FILES[@]}" > /tmp/anchor-check-output.txt 2>&1 || BROKEN=1 - if [ "$BROKEN" -eq 1 ]; then - grep '^\s' /tmp/anchor-check-output.txt > /tmp/anchor-errors.txt || true - BROKEN=$(grep -oE 'Found [0-9]+' /tmp/anchor-check-output.txt | grep -oE '[0-9]+' | tail -1 || echo "0") - echo "Broken anchors found:" - cat /tmp/anchor-check-output.txt - fi - echo "broken=$BROKEN" >> "$GITHUB_OUTPUT" - - - name: Commit all fixes - if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 - run: | - while IFS= read -r file; do - [ -f "$file" ] && git add -- "$file" - done < /tmp/changed-files.txt - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "fix(vale): auto-fix style issues (Vale + Dale)" - fi - - - name: Push all fixes - if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 - env: - VALE_TOKEN: ${{ secrets.VALE_TOKEN }} - run: | - # Re-configure git credentials with VALE_TOKEN (claude-code-action - # overrides credentials with GITHUB_TOKEN, which doesn't trigger workflows) - git remote set-url origin "https://x-access-token:${VALE_TOKEN}@github.com/${{ github.repository }}.git" - if [ "$(git rev-list @{u}..HEAD --count 2>/dev/null)" -gt 0 ]; then - echo "Pushing $(git rev-list @{u}..HEAD --count) commit(s)..." - git push - else - echo "No new commits to push" - fi - - - name: Build and post summary comment - if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PR_NUMBER=${{ github.event.pull_request.number }} - REPO=${{ github.repository }} - - # Collect Phase 1 stats - PHASE1_TOTAL=0 - PHASE1_BODY="" - if [ -f /tmp/phase1-summary.json ]; then - PHASE1_TOTAL=$(jq '.total' /tmp/phase1-summary.json) - # Build category rows from Phase 1 - PHASE1_BODY=$(jq -r ' - .by_category | to_entries | sort_by(.key) | .[] | - "| \(.key) | \(.value) |" - ' /tmp/phase1-summary.json) - fi - - # Collect Phase 2 stats - PHASE2_FIXED=0 - PHASE2_SKIPPED=0 - PHASE2_BODY="" - SKIPPED_BODY="" - if [ -f /tmp/phase2-summary.json ]; then - PHASE2_FIXED=$(jq '.fixed | length' /tmp/phase2-summary.json) - PHASE2_SKIPPED=$(jq '.skipped | length' /tmp/phase2-summary.json) - - if [ "$PHASE2_FIXED" -gt 0 ]; then - PHASE2_BODY=$(jq -r ' - [.fixed[] | .check] | group_by(.) | .[] | - "| \(.[0] | sub("Netwrix\\."; "")) (rewrite) | \(length) |" - ' /tmp/phase2-summary.json) - fi - - if [ "$PHASE2_SKIPPED" -gt 0 ]; then - SKIPPED_BODY=$(jq -r ' - .skipped[] | - "| `\(.path):\(.line)` — \(.check) | \(.reason) |" - ' /tmp/phase2-summary.json) - fi - fi - - # Collect Phase 3 (Dale) stats - DALE_FIXED=0 - DALE_SKIPPED=0 - DALE_BODY="" - DALE_SKIPPED_BODY="" - if [ -f /tmp/dale-summary.json ]; then - DALE_FIXED=$(jq '.fixed | length' /tmp/dale-summary.json) - DALE_SKIPPED=$(jq '.skipped | length' /tmp/dale-summary.json) - - if [ "$DALE_FIXED" -gt 0 ]; then - DALE_BODY=$(jq -r ' - [.fixed[] | .rule] | group_by(.) | .[] | - "| Dale: \(.[0]) | \(length) |" - ' /tmp/dale-summary.json) - fi - - if [ "$DALE_SKIPPED" -gt 0 ]; then - DALE_SKIPPED_BODY=$(jq -r ' - .skipped[] | - "| `\(.path):\(.line)` — Dale: \(.rule) | \(.reason) |" - ' /tmp/dale-summary.json) - fi - fi - - TOTAL_FIXED=$((PHASE1_TOTAL + PHASE2_FIXED + DALE_FIXED)) - TOTAL_SKIPPED=$((PHASE2_SKIPPED + DALE_SKIPPED)) - - # Count affected files - FILE_COUNT=$(wc -l < /tmp/changed-files.txt | tr -d ' ') - - # Collect anchor check results - BROKEN_ANCHORS=0 - ANCHOR_BODY="" - if [ -f /tmp/anchor-check-output.txt ] && [ -s /tmp/anchor-check-output.txt ]; then - BROKEN_ANCHORS=$(grep -oE 'Found [0-9]+' /tmp/anchor-check-output.txt | grep -oE '[0-9]+' | tail -1 || echo "0") - ANCHOR_BODY=$(cat /tmp/anchor-errors.txt 2>/dev/null || true) - fi - - # Build the summary comment - { - if [ "$BROKEN_ANCHORS" -gt 0 ]; then - echo "## ⚠️ Broken Anchor Links" - echo "" - echo "**${BROKEN_ANCHORS} broken anchor link(s) found — these will cause the build to fail.**" - echo "" - echo "\`\`\`" - echo "$ANCHOR_BODY" - echo "\`\`\`" - echo "" - fi - - echo "## Auto-Fix Summary" - echo "" - echo "**${TOTAL_FIXED} issues fixed, ${TOTAL_SKIPPED} skipped across ${FILE_COUNT} files**" - echo "" - - if [ "$TOTAL_FIXED" -gt 0 ]; then - echo "| Category | Fixes |" - echo "|----------|-------|" - if [ -n "$PHASE1_BODY" ]; then - echo "$PHASE1_BODY" - fi - if [ -n "$PHASE2_BODY" ]; then - echo "$PHASE2_BODY" - fi - if [ -n "$DALE_BODY" ]; then - echo "$DALE_BODY" - fi - echo "" - fi - - if [ "$TOTAL_SKIPPED" -gt 0 ]; then - echo "| Skipped (needs manual review) | Reason |" - echo "|-------------------------------|--------|" - echo "$SKIPPED_BODY" - if [ -n "$DALE_SKIPPED_BODY" ]; then - echo "$DALE_SKIPPED_BODY" - fi - echo "" - fi - - echo 'Ask `@claude` on this PR if you'\''d like an explanation of any fix.' - } > /tmp/vale-summary.md - - # Delete previous Vale Auto-Fix comments - COMMENT_IDS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ - --jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | contains("## Auto-Fix Summary") or .body | contains("## ⚠️ Broken Anchor Links"))) | .id] | .[]' 2>/dev/null || true) - for ID in $COMMENT_IDS; do - gh api "repos/${REPO}/issues/comments/${ID}" -X DELETE 2>/dev/null || true - done - - # Post new summary - gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/vale-summary.md diff --git a/.gitignore b/.gitignore index 38cdf23975..b3c9b917b2 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ touch_files.py # Claude build/preview skill logs .docs-logs/ + +# local caches (quality-check verdicts, pilot runs) +.cache/ diff --git a/.vale.ini b/.vale.ini index 7ba971d191..17baca16bf 100644 --- a/.vale.ini +++ b/.vale.ini @@ -3,6 +3,9 @@ MinAlertLevel = suggestion [*.md] BasedOnStyles = Netwrix +# Text the writer must reproduce exactly is not theirs to reword: bold UI labels (the style guide bolds +# UI elements) and double-quoted product text such as error messages. Rules skip both. +TokenIgnores = (\*\*[^*\n]+\*\*), ("[^"\n]+"), (\u201c[^\u201d\n]+\u201d) [docs/kb/**/*.md] BasedOnStyles = NetwrixKB diff --git a/CLAUDE.md b/CLAUDE.md index eb5b62373e..6cfbd000f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,8 @@ npm run clear # Clear Docusaurus cache (fixes stale build issues) # Linting vale # Run Vale style checker on a markdown file /dale # Run Dale linter (Claude skill) on a markdown file +npm run quality:score # Vale + Dale + AI-isms check on your staged or branch docs (what the pre-commit hook runs) +npm run quality:score -- --file # the same check on one page, whole # Vale and the git hooks (hk) are pinned in .config/mise.toml # macOS: brew install mise Windows: winget install jdx.mise @@ -71,14 +73,18 @@ PRs target `dev`. Never commit directly to `dev` or `main`. The `sync-dev-to-mai | Workflow | Trigger | Purpose | |---|---|---| -| `build-and-deploy.yml` | Push to main/dev, PRs to dev | Build and deploy to Azure | -| `vale-autofix.yml` | PRs with `.md` changes | Auto-fix Vale + Dale issues (script + AI), post summary comment | -| `claude-doc-pr.yml` | PRs to dev with `docs/` changes | Editorial review; `@claude` follow-up | -| `claude-documentation-reviewer.yml` | PRs with `.md` changes | AI review with inline suggestions | -| `claude-documentation-fixer.yml` | `@claude` comment on PR | Apply fixes and push | -| `claude-issue-labeler.yml` | Issues opened/edited | Security screening, CoC check, auto-labeling, content fix automation | -| `sync-dev-to-main.yml` | Daily 8 AM PST | Auto-merge dev to main | -| `reindex-algolia.yml` | After main deploy | Refresh search index | +| `build-and-deploy.yml` | Push to main/dev, PRs to dev, manual | Build the site. Pushes deploy to Azure (`dev` to development, `main` to production); PRs build only | +| `script-tests.yml` | PRs that change `scripts/` or `package.json` | Runs the repo's script tests. Docs are linted before the PR (pre-commit: Vale, Dale, AI-isms); nothing lints on the PR | +| `md-extension-autofix.yml` | PRs to dev | Adds missing `.md` extensions and frontmatter, and commits the fix | +| `claude-code-review.yml` | PRs to dev that change non-doc files | Code review comment | +| `claude-code-followup.yml` | `@claude` comment on a code PR (owners, members, collaborators) | Answers or applies a fix | +| `claude-doc-pr.yml` | PRs to dev with `docs/` changes; `@claude` comments | Editorial review (`doc-review` job) and `@claude` follow-up (`doc-followup` job) | +| `claude-issue-labeler.yml` | Issues opened/edited, comments | Security screening, CoC check, auto-labeling, content fix automation | +| `auto-create-pr-tracking-issues.yml` | Review requested on a PR | Creates the admin or KB tracking issue | +| `slack-notify-pr.yml` | PR opened, review requested, PR comment | Posts to #docs-gh and @-mentions the CODEOWNERS teams for the changed files | +| `slack-notify-issue.yml` | Issue opened or commented | Posts to #docs-gh and @-mentions the relevant CODEOWNERS members | +| `update-project-board-on-pr-merge.yml` | PR closed | Sets the linked tracking issue's status on the project board | +| `sync-dev-to-main.yml` | Daily 8 AM PST (16:00 UTC), manual | Auto-merge dev to main if the build passes | ## Skills and Agents @@ -86,7 +92,7 @@ Skills (`.claude/skills/`) are invoked with `/skill-name`. Agents (`.claude/agen When a user asks for help with documentation, always use the appropriate tool: - **`/doc-help` skill** — Interactive tasks: reviewing content, suggesting improvements, discussing structure or flow, brainstorming, explaining style rules, incorporating external documents (e.g., `.docx` files) into existing markdown files, or any back-and-forth conversation about writing. -- **`tech-writer` agent** — Autonomous end-to-end tasks: drafting new documents, rewriting files, fixing all Vale errors, or editing for style and clarity. +- **`tech-writer` agent** — Drafts documentation from source material (Sonnet by default; pass `model: "opus"` if a PR draft comes back thin): a PR to document ("look at PR X in repo Y"), a GitHub issue, Jira ticket, or Azure DevOps item to document ("look at PLAT-1234"), specs for a feature, notes to turn into a document, or a prompt detailed enough to draft from. Product context for PR drafting lives in `.claude/references/products/.md`; add a brief there for any product you want it to understand. It does not edit existing docs; use `/doc-help`, `/dale`, or Vale for that. | Component | Type | Purpose | |---|---|---| @@ -102,7 +108,7 @@ When a user asks for help with documentation, always use the appropriate tool: | `/kb-pr-open` | Skill | Last-mile KB submission helper for TSEs — lints and opens a PR to `dev` | | `/kb-pr-review` | Skill | Reviews a KB PR (Vale + Dale + Derek) and drafts a review comment | | `/audit-fix` | Skill | Applies a docs-audit correction to a page and its duplicate versions | -| `tech-writer` | Agent | Autonomous end-to-end doc writing/editing | +| `tech-writer` | Agent | Autonomous drafting from a PR, a work item (GitHub issue, Jira ticket, Azure DevOps item), specs, notes, or a detailed prompt (Sonnet) | | `vale-rule-writer` | Agent | Creates new Vale rules | | `vale-auditor` | Agent | Audits Vale rule set for conflicts | | `github-issue-manager` | Agent | Issue intake pipeline orchestrator | @@ -110,8 +116,11 @@ When a user asks for help with documentation, always use the appropriate tool: ## Hooks Project hooks are in `.claude/settings.json`: -- **PostToolUse (Edit|Write)**: After editing a `docs/*.md` file, reminds to run `/dale` +- **PostToolUse (Edit|Write)**: After editing a `docs/*.md` file, reminds to run `/dale`; a second hook records the file so the Stop hook knows which docs this session edited - **PostToolUse (Bash)**: After running `vale`, reminds to fix and re-run until clean +- **Stop**: When Claude finishes a turn, runs the style check (Vale, Dale, AI-isms) on the docs this session edited and hands any findings back to Claude, up to 3 rounds + +Git hook (`.husky/pre-commit`, installed by `npm install`): runs the same style check on staged docs and blocks the commit, plus the anchor-link check and a draft-marker warning. Hook scripts live in `.claude/hooks/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e618da9213..ac3af0f12b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,8 @@ mise install # answer yes when mise asks to trust .config/mise.toml npm run start ``` +`npm install` also installs a git pre-commit hook that lints your docs before each commit, so run it even if you only edit markdown. + The dev server runs on port 4500 with hot reload — changes you make to documentation files appear immediately in the browser. ## Workflow @@ -53,7 +55,7 @@ The dev server runs on port 4500 with hot reload — changes you make to documen 5. Push your branch. The pre-push hook blocks the push while any Vale warning remains on a line you changed. 6. Create a pull request (PR) targeting `dev`. -After you open a PR, Vale and Dale issues are auto-fixed and a summary is posted. An editorial review also runs and posts results as a PR comment. To get help with editorial suggestions, comment `@claude` on the PR followed by your request. +Nothing lints on the PR: that happens at commit. After you open a PR, an editorial review runs and posts results as a PR comment. To get help with editorial suggestions, comment `@claude` on the PR followed by your request. ## Writing standards @@ -151,7 +153,7 @@ If you have [Claude Code](https://claude.ai/code) installed, this repository inc ### Linting with Dale -Dale is an AI linter that catches context-dependent issues that regex-based Vale can't — passive voice, misplaced modifiers, idioms, wordiness, and other patterns. Dale issues are auto-fixed on PRs alongside Vale. +Dale is an AI linter that catches context-dependent issues that regex-based Vale can't — passive voice, misplaced modifiers, idioms, wordiness, and other patterns. The pre-commit hook also runs Dale (a model applies the rules) on new pages and on the lines you changed. Run Dale locally on any markdown file to preview context-dependent issues: @@ -165,24 +167,27 @@ Ask Claude Code for help with your writing — brainstorming document structure, ### Run autonomous documentation tasks -For well-defined tasks where the work is clear and doesn't need back-and-forth, Claude automatically uses the `tech-writer` agent to handle the work end-to-end. +For well-defined drafting tasks that don't need back-and-forth, Claude automatically uses the `tech-writer` agent. It drafts new pages from a pull request, a GitHub issue, a Jira ticket or Azure DevOps item, specs, notes, or a detailed prompt, and it can structure a new docs directory following the section guide (`.claude/references/documentation-section-guide.md`). It doesn't edit existing pages; use `doc-help` for that. Before it finishes, it lints its own draft (Vale, Dale, and the AI-writing check) and fixes what is flagged. Examples: -- "Fix all the Vale errors in `docs/accessanalyzer/12.0/install.md`". -- "Edit this procedure for clarity and Netwrix style". +- "Look at PR 1040 in itdr-pingcastle-core and document it". +- "Look at Jira ticket PLAT-1234 and write the doc". - "Draft the installation steps based on this outline: [outline]" +- "Create the docs directory for this new product from these specs: [specs]" -For tasks that need design decisions first — like writing a new guide from scratch — Claude uses doc-help to brainstorm the structure with you, then hands the drafting off to the tech-writer agent. +For tasks that need design decisions first — like planning a new guide from scratch — Claude uses doc-help to brainstorm the structure with you, then hands the drafting off to the tech-writer agent. ### Quick reference | Task | Tool | |---|---| | Quick lint check on a file | `/dale docs/path/to/file.md` | +| Check your changes before committing | `npm run quality:score` | | Plan structure for a new document | `doc-help` then `tech-writer` agent | +| Draft a new page from a PR, issue, ticket, or specs | `tech-writer` agent | | Review or improve existing content | `doc-help` | -| Fix Vale errors across a file | `tech-writer` agent | -| Edit a file for style and clarity | `tech-writer` agent | +| Fix Vale errors in a file | `doc-help`, or fix by hand and run `npm run quality:score` | +| Edit a file for style and clarity | `doc-help` | | Understand a style rule or convention | `doc-help` | ## Common Mistakes @@ -192,4 +197,4 @@ For tasks that need design decisions first — like writing a new guide from scr - Don't commit directly to `dev` or `main` — create a branch from `dev` first. - Don't target `main` in PRs — always use `dev`. - Don't use first person anywhere in documentation content. -- Don't omit examples — every concept introduced needs one. +- Don't omit the example from a procedure. Don't pad a concept with an example it doesn't need; if one would help and you can't confirm it, leave a `{/* TODO: add an example */}`. diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 73e01c08d2..4709e5c1bd 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -18,6 +18,7 @@ Write for the person who knows their job but may be new to this specific product ## File & Directory Structure - `docs///` — Versioned product documentation (e.g., `docs/accessanalyzer/12.0/`) +- Inside a product, every section follows the zone order and rules in `.claude/references/documentation-section-guide.md` (Getting Started, Requirements, Install and Update, Configuration, Administration, and so on). Read it when you add a page or create a directory. - `docs//` — Single-version (SaaS) products using `version: "current"` - `docs/kb/` — Knowledge base articles (canonical source; never manually copy into versioned folders) - `static/images//` — Images (`.webp` format, organized by version/section, absolute paths: `/images/...`) @@ -37,7 +38,7 @@ The full style guide is in `netwrix_style_guide.md` at the project root. Read it The four core qualities: - **Accuracy** — Verify all technical information. Test procedures. Flag gaps. -- **Clarity** — Simple, direct language. Define every term and acronym on first use. Always provide examples. +- **Clarity** — Simple, direct language. Define every term and acronym on first use. Every procedure has an example. A concept gets one only when it helps; if one would help and the source doesn't support a real one, leave `{/* TODO: add an example */}` for the writer. - **Consistency** — Same terminology, structure, and formatting throughout. - **Professionalism** — Neutral, informative tone. No humor, no marketing language, no first person. @@ -59,14 +60,14 @@ The four core qualities: - Order: overview → prerequisites → procedures - Task headings: imperative verbs — "Configure the monitoring plan" - Concept/overview headings: noun phrase or gerund — "Configuring the monitoring plan" -- Examples immediately follow the concept they illustrate +- An example immediately follows the procedure or concept it illustrates - Never skip heading levels ## Linting ### Vale -Vale enforces 43 Netwrix-specific rules in `.vale/styles/Netwrix/` covering word choice, punctuation, formatting, and common writing issues. Vale issues are auto-fixed on PRs by the `vale-autofix` workflow — you don't need to fix them manually before pushing. You can still run Vale locally to preview issues: +Vale enforces 43 Netwrix-specific rules in `.vale/styles/Netwrix/` covering word choice, punctuation, formatting, and common writing issues. Vale runs before the PR: the pre-commit hook blocks a commit while Vale flags a new page, or the lines you changed on an existing page. Nothing lints on the PR, so fix findings before you push. Vale must be installed. Vale and Dale skip bold text (UI labels) and double-quoted text (verbatim product messages), so write each label exactly as the product shows it, in bold, and never reword either to clear a finding. If a UI label is flagged because it isn't bold, bold it. For exact product text that is neither, wrap the line in `` and `` for that one rule. Preview issues with: ```bash vale @@ -86,10 +87,33 @@ These rules require extra care: ### Dale -Dale is an AI linter that catches issues regex-based Vale rules can't — passive voice, misplaced modifiers, idioms, wordiness, and other context-dependent patterns. Dale rules are in `.claude/skills/dale/rules/`. Dale issues are auto-fixed on PRs alongside Vale by the `vale-autofix` workflow. +Dale is an AI linter that catches issues regex-based Vale rules can't — passive voice, misplaced modifiers, idioms, wordiness, and other context-dependent patterns. Dale rules are in `.claude/skills/dale/rules/`. Dale runs before the PR too: the pre-commit hook has a model apply these rules to new pages and to the lines you changed, and blocks the commit on findings. Run Dale locally with `/dale `. +### Pre-PR style check (local, required) + +Every docs contribution is linted before it reaches a PR: Vale, Dale, and a check for signs of AI writing. There is no CI job for this; it runs on your machine. Run it on a page with `npm run quality:score -- --file `. The hook is installed by `npm install`. Skipping it on purpose (`git commit --no-verify`) is allowed. + +- **Scope:** a new page is checked whole. An existing page is checked only on the lines the change adds or rewrites; text it already had is never flagged. A page git detects as a copy or rename (50% or more similar) counts as existing, so cloning a version directory only checks what differs. `docs/kb/` is out of scope. +- **Vale:** the repo's Vale rules, from the page text (so the staged version is checked). Vale must be installed, or the commit is blocked with install steps. The Stop hook runs without that requirement. +- **Dale:** a model applies the 10 rules in `.claude/skills/dale/rules/` in one call per page. Like the judge, it runs 3 times, keeps findings that 2 runs report at the same spot, and counts only findings whose quote appears verbatim on a changed line. Verdicts are cached. +- **Deterministic AI-isms:** `scripts/doc-draft/slop.mjs` (catalog in `scripts/doc-draft/ai-isms.yml`). Structural signals such as sentence-length variance apply only to new pages. +- **Judged:** a Haiku 4.5 judge applies the 16 patterns in `.claude/references/humanizer-rules.md`. It runs 3 times per page and keeps findings that at least 2 runs report at the same spot. A finding counts only if its quote appears verbatim on a changed line. Verdicts are cached by content hash in `.cache/quality`. +- **Draft markers:** write unresolved gaps as `{/* TODO: ... */}` so they never render. `scripts/quality/todo-check.mjs` warns (never blocks) about leftovers at commit and in the Stop hook. +- **Approved phrases:** `judge.ignoreQuotes`, `judge.ignoreReasons`, `dale.ignoreQuotes`, and `vale.ignore` (a Vale rule name) in `scripts/quality/config.json`. + +Where it runs: + +- **Pre-commit (husky):** `scripts/quality/score.mjs --staged` blocks the commit while anything is flagged. The judge uses `ANTHROPIC_API_KEY` if set, and otherwise your Claude Code login (`claude -p`). With neither, it runs the deterministic checks only and says so. +- **Claude Code Stop hook:** `.claude/hooks/stop-quality-check.sh` runs the same check when Claude finishes, but only on the docs that session edited (`post-edit-record.sh` records each file written with Edit or Write, so a file changed through a shell command is not recorded). It hands the findings back to Claude to rewrite, up to 3 rounds, and never touches other drafts on the branch. Pre-commit is the hard gate and checks everything staged. + +Run it by hand with `npm run quality:score` (`-- --staged` for the index). `npm run quality:fix -- --staged` is an optional helper (same model access as the judge) that drops flagged filler, swaps plain words, and rewords flagged sentences. Each edit is validated, drops and rewords get a meaning check, and a reword may not lose a number, negation, or requirement word. A reword the meaning check doubts is still applied but printed as `review`; read those in `git diff`. A drop the check doubts goes to a patch instead. + +### Readability (optional report) + +Readability is not a gate and not a rewrite target. Write plainly the first time, and never trade a fact, condition, or qualifier for a shorter sentence. `npm run quality:readability -- ` reports Flesch reading ease, mean sentence length, and the share of sentences over 30 words, for a writer who wants to see them (reference points: Flesch 45 or higher, mean sentence 16 words or fewer, 10% or fewer over 30 words). Agents report the numbers if asked and do not revise to meet them. + ## Content Patterns ### Admonitions @@ -126,7 +150,7 @@ Each step is a single action. Lead with the UI element or command: ## CI/CD Context -**Auto-fix (Vale + Dale)** — Runs on PRs to `dev` with docs changes. Automatically fixes Vale issues (script for mechanical rules, Claude for judgment-based rules) and Dale issues (Claude), then posts a summary comment. No inline comments. +**No lint on the PR** — Vale, Dale, and the AI-isms check run before the PR, in the pre-commit hook. The `Script Tests` workflow runs only when a PR changes `scripts/` or `package.json`. **Doc PR review** — Runs on PRs to `dev` with docs changes. Posts an editorial review summary. Does not block merges. @@ -142,4 +166,4 @@ Each step is a single action. Lead with the UI element or command: - Don't commit directly to `dev` or `main` — create a branch from `dev` first - Don't target `main` in PRs — use `dev` - Don't use first person anywhere in documentation content -- Don't omit examples — every concept introduced needs one +- Don't omit the example from a procedure. Don't pad a concept with an example it doesn't need; if one would help and you can't confirm it, leave `{/* TODO: add an example */}` diff --git a/docusaurus.config.js b/docusaurus.config.js index 9833ce2c5e..3af992c713 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -8,6 +8,7 @@ import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { themes as prismThemes } from 'prism-react-renderer'; import { generateDocusaurusPlugins, generateNavbarDropdowns, PRODUCTS, versionToUrl, getDefaultVersion, getLatestVersionUrlMap, getActiveProducts, getActiveVersions, generateRouteBasePath } from './src/config/products.js'; +import llmsTxtPlugin from './src/plugins/llms-txt.js'; import { accessAnalyzer261Redirects } from './src/config/redirects/accessanalyzer-26.1.js'; // Strip TypeScript syntax from a generated sidebar.ts and return its apisidebar array. @@ -177,6 +178,9 @@ const config = { }; }, + // llms.txt index (site root) and per-product-version page listings + llmsTxtPlugin, + // Google Analytics [ '@docusaurus/plugin-google-gtag', diff --git a/package-lock.json b/package-lock.json index e442acf868..ed46d043c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "serve": "^14.2.6" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.129.0", "@docusaurus/module-type-aliases": "^3.10.0", "@docusaurus/types": "^3.10.0", "@mdx-js/mdx": "^3.1.1", @@ -303,6 +304,49 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@anthropic-ai/sdk/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "15.3.5", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-15.3.5.tgz", @@ -6236,6 +6280,13 @@ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -12101,6 +12152,13 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "dev": true, + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -22319,6 +22377,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", diff --git a/package.json b/package.json index 57e5f9ca10..9f9c9c7808 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,10 @@ "kb:dry": "node scripts/copy-kb-to-versions.mjs --dry", "kb:test:aa": "npm run kb:clean && npm run prestart && npm run start", "kb:prodtest:aa": "npm run kb:clean && npm run prebuild && npm run build && npm run serve", - "test:scripts": "bash scripts/test-slugify.sh && bash scripts/test-heading-skip.sh && bash scripts/test-md-extension-autofix.sh && node --test scripts/test-set-blob-metadata.mjs && node scripts/test-set-blob-metadata-integration.mjs && node --test scripts/claude/test-log-summary.mjs && node --test scripts/test-vale-local.mjs", + "quality:score": "node scripts/quality/score.mjs --table", + "quality:readability": "node scripts/quality/readability.mjs", + "quality:fix": "node scripts/quality/fix-loop.mjs", + "test:scripts": "bash scripts/test-slugify.sh && bash scripts/test-heading-skip.sh && bash scripts/test-md-extension-autofix.sh && node --test scripts/test-set-blob-metadata.mjs && node scripts/test-set-blob-metadata-integration.mjs && node --test scripts/claude/test-log-summary.mjs && node --test scripts/test-vale-local.mjs && node scripts/quality/gen-lint-digest.mjs --check", "prepare": "node scripts/hooks/install.mjs" }, "dependencies": { @@ -47,6 +50,7 @@ "serve": "^14.2.6" }, "devDependencies": { + "@anthropic-ai/sdk": "^0.129.0", "@docusaurus/module-type-aliases": "^3.10.0", "@docusaurus/types": "^3.10.0", "@mdx-js/mdx": "^3.1.1", diff --git a/scripts/doc-draft/ai-isms.yml b/scripts/doc-draft/ai-isms.yml new file mode 100644 index 0000000000..2cae32fb07 --- /dev/null +++ b/scripts/doc-draft/ai-isms.yml @@ -0,0 +1,256 @@ +# ai-isms.yml — single hand-maintained source of AI-slop rules for the +# `slop` gate (scripts/doc-draft/slop.mjs). +# +# Two sections: +# lexical — word/phrase catalog. See doc-draft-conventions.md §3.1. +# Consumed by slop.mjs only. It is not generated into a +# Vale style; Vale's own wording rules live separately in +# .vale/styles/Netwrix/ and are edited there. +# structural — numeric floors/ceilings for page-level AI-slop signals +# (sentence-length variance, tricolon rate, repeated +# paragraph openers, bullet density, heading-to-prose +# ratio, em-dash rate). See doc-draft-conventions.md §3.2, +# and §3.3 specifically for em-dash density. +# +# Every structural threshold below is calibrated against the 56 shipped, +# accepted pages at docs/accessanalyzer/26.1/ (excluding docs/kb/): +# git ls-files docs/accessanalyzer/26.1 | grep '\.md$' | grep -v '/kb/' +# All 56 must score zero findings against these thresholds. Each threshold +# comment below cites the actual max/min metric observed in that corpus at +# calibration time. If a threshold ever needs loosening to keep the corpus +# clean, loosen it here (with a fresh citation) — never hand-tune slop.mjs +# to special-case a page. + +lexical: + # --- Seed: .vale/styles/Netwrix/Idioms.yml — full token list (70 + # tokens across 8 groups). Patterns copied verbatim from Idioms.yml so + # the two lists can't drift in spirit even though they're separate + # files serving separate tools (Vale vs. this gate). Corpus check + # (all 56 pages of docs/accessanalyzer/26.1, prose-extracted) found + # zero hits for any of the 58 tokens below that weren't already present + # here, so no exceptions were needed the way 'unlock'/'underscores' + # needed them further down. + + # Overarching / scope idioms + 'tip of the iceberg': 'tip of the iceberg' + 'at the end of the day': 'at the end of the day' + 'out of the box': '\bout of the box\b' + 'out-of-the-box': '\bout-of-the-box\b' + 'in-the-box': '\bin-the-box\b' + 'under the hood': '\bunder the hood\b' + 'in a nutshell': '\bin a nutshell\b' + 'on the same page': '\bon the same page\b' + 'from the ground up': '\bfrom the ground up\b' + 'behind the scenes': '\bbehind the scenes\b' + 'out of the gate': '\bout of the gate\b' + 'off the beaten path': '\boff the beaten path\b' + 'the big picture': '\bthe big picture\b' + 'the bottom line': '\bthe bottom line\b' + + # Status / momentum idioms + 'up and running': '\bup and running\b' + 'hit the ground running': '\bhit the ground running\b' + 'ramp up': '\bramp(?:s|ed|ing)? up\b' + 'spin up': '\bspin(?:s|ning)? up\b' + 'stand up (idiom)': '\bstand(?:s|ing)? up\b' + 'tear down (idiom)': '\btear(?:s|ing)? down\b' + 'tore down': '\btore? down\b' + + # Business/strategy jargon + 'low-hanging fruit': '\blow.hanging fruit\b' + 'moves the needle': '\bmoves? the needle\b' + 'boil the ocean': '\bboil(?:s|ed|ing)? the ocean\b' + 'levels the playing field': '\blevels? the playing field\b' + 'raises the bar': '\braises? the bar\b' + 'sets the stage': '\bsets? the stage\b' + 'paves the way': '\bpaves? the way\b' + 'bridges the gap': '\bbridges? the gap\b' + 'fills the gap': '\bfills? the gap\b' + 'cut corners': '\bcut(?:s|ting)? corners\b' + 'rule of thumb': '\brule of thumb\b' + 'across the board': '\bacross the board\b' + 'in the long run': '\bin the long run\b' + 'in the same boat': '\bin the same boat\b' + + # Attention/awareness idioms + 'keep in mind': '\bkeep in mind\b' + 'bear in mind': '\bbear in mind\b' + 'at your fingertips': '\bat your fingertips\b' + 'on the radar': '\bon the radar\b' + 'in the weeds': '\bin the weeds\b' + 'at first glance': '\bat first glance\b' + 'by the same token': '\bby the same token\b' + 'on the fly': '\bon the fly\b' + + # Collaboration/process jargon + 'touch base': '\btouch(?:es|ing|ed)? base\b' + 'circle back': '\bcircle(?:s|d|ing)? back\b' + 'loop in': '\bloop(?:s|ed|ing)? in\b' + 'flesh out': '\bflesh(?:es|ed|ing)? out\b' + 'iron out': '\biron(?:s|ed|ing)? out\b' + 'hammer out': '\bhammer(?:s|ed|ing)? out\b' + 'drill down': '\bdrill(?:s|ed|ing)? down\b' + 'zero in on': '\bzero(?:s|ed|ing)? in on\b' + 'hone in on': '\bhone(?:s|d|ing)? in on\b' + + # Deep dive variants + 'a deep dive': '\ba deep dive\b' + 'deep dive': '\bdeep dive\b' + 'take a deep dive': '\btake a deep dive\b' + + # Work idioms + 'from scratch': '\bfrom scratch\b' + 'heavy lifting': '\bheavy lifting\b' + 'pain points': '\bpain points?\b' + + # Tech marketing buzzwords + 'game-changer': '\bgame.changer\b' + 'silver bullet': '\bsilver bullet\b' + 'magic bullet': '\bmagic bullet\b' + 'one-stop-shop': '\bone.stop.shop\b' + 'best of breed': '\bbest of breed\b' + 'bleeding-edge': '\bbleeding.?edge\b' + 'cutting-edge': '\bcutting.?edge\b' + 'mission-critical': '\bmission.critical\b' + 'world-class': '\bworld.class\b' + 'turnkey': '\bturnkey\b' + 'synergy': '\bsynergy\b' + 'leverage': '\bleverage(?:s|d|ing)?\b' + + # --- Seed: .vale/styles/Netwrix/ImpersonalFiller.yml (full token list). + 'it should be noted that': '\bit should be noted that\b' + 'it is important to note that': '\bit is important to note that\b' + 'it is important to ensure that': '\bit is important to ensure that\b' + 'it is important to': '\bit is important to\b' + 'it is recommended that': '\bit is recommended that\b' + 'it is necessary to': '\bit is necessary to\b' + 'it is possible to': '\bit is possible to\b' + 'it is worth noting that': '\bit is worth noting that\b' + 'it must be noted that': '\bit must be noted that\b' + 'as mentioned previously': '\bas mentioned previously\b' + 'as previously mentioned': '\bas previously mentioned\b' + 'needless to say': '\bneedless to say\b' + + # --- Seed: .vale/styles/Netwrix/FormalHedging.yml (swap keys — the + # phrases the rule tells writers to replace). + 'prior to': '\bprior to\b' + 'subsequent to': '\bsubsequent to\b' + 'in the event that': '\bin the event that\b' + 'in the event of': '\bin the event of\b' + 'for the purpose of': '\bfor the purpose of\b' + 'with the intention of': '\bwith the intention of\b' + 'at this point in time': '\bat this point in time\b' + + # --- Seed: .vale/styles/Netwrix/TemporalHedges.yml (full token list). + 'currently': '\bcurrently\b' + 'presently': '\bpresently\b' + 'as of this writing': '\bas of this writing\b' + + # --- Seed: .vale/styles/Netwrix/NaturallyFiller.yml (full token list). + 'naturally': '\bnaturally\b' + + # --- .claude/skills/dale/rules/xy-slop.yml and minimizing-difficulty.yml + # are prose-*pattern* descriptions ("x is not y, x is z"; minimizing task + # difficulty), not word lists — per the brief, no literal tokens are + # pulled from them. They inform how a future gate might catch those + # patterns structurally, not the lexical catalog here. + + # --- Explicit AI-isms additions (not covered by the seed corpus above). + 'delve': '\bdelve(?:s|d|ing)?\b' + 'seamless': '\bseamless(?:ly)?\b' + 'robust': '\brobust(?:ly|ness)?\b' + 'comprehensive': '\bcomprehensive(?:ly)?\b' + 'landscape': '\blandscape\b' + 'realm': '\brealm\b' + 'testament to': '\btestament to\b' + # 'unlock' is a real UI/security verb in this domain (unlock a locked + # account, click **Unlock**) — corpus check found it constantly, in + # known-limitations.md, settings/users.md (heading "Unlock a User", "clicks + # **Unlock**", "click **Actions > Unlock**", "Unlocking doesn't change..."), + # and first-sign-in.md ("unlock it"). A negative-lookaround exclusion list + # couldn't keep up with that many legitimate phrasings, so this is a + # positive match instead: only trips when "unlock" is paired with an + # abstract/marketing noun AI-isms actually pair it with (power, potential, + # value, insights, workflow, etc.) — never for the literal + # account/session/UI sense. + 'unlock': '\bunlocks?\s+(?:the\s+|a\s+|an\s+|its\s+|your\s+|their\s+)?(?:[a-z][\w-]*,?\s+){0,3}(?:power|potential|value|insight\w*|possibilit\w*|opportunit\w*|capabilit\w*|secret\w*|productivity|efficienc\w*|growth|success|innovation\w*|workflow\w*|benefit\w*)\b' + 'empower': '\bempower(?:s|ed|ing|ment)?\b' + 'elevate': '\belevate(?:s|d|ing)?\b' + 'streamline': '\bstreamline(?:s|d|ing)?\b' + 'pivotal': '\bpivotal\b' + # 'underscores' is also a punctuation character (hyphens, underscores, + # and dots). Corpus check (scans/scan-types.md) found the literal + # character-set sense — narrowed to the verb-of-emphasis sense + # ("underscores the/its/a ...") that AI-isms actually use. + 'underscores': '\bunderscores?\s+(?:the|its|their|his|her|a|an|how|that|this)\b' + 'showcase': '\bshowcase(?:s|d|ing)?\b' + 'plethora': '\bplethora\b' + 'myriad': '\bmyriad\b' + 'foster': '\bfoster(?:s|ed|ing)?\b' + 'harness': '\bharness(?:es|ed|ing)?\b' + 'ever-evolving': '\bever.evolving\b' + "in today's": "\\bin today's\\b" + "it's worth noting": "\\bit'?s worth noting\\b" + 'rest assured': '\brest assured\b' + 'at its core': '\bat its core\b' + 'when it comes to': '\bwhen it comes to\b' + 'dive into': '\bdive into\b' + +structural: + # Sentence-length variance: uniform rhythm is the strongest AI tell. + # stddev of per-sentence word counts. Flag when stddev falls BELOW the + # floor (too uniform) — never above. Skipped entirely below + # min_sentences, to avoid false positives on short pages/sections. + # Corpus: minimum stddev observed across all 56 pages was 6.51 + # (docs/accessanalyzer/26.1/integrations/index.md). Floor set to 2.5 to + # sit safely below that with margin. + sentence_length_stddev: + floor: 2.5 + min_sentences: 5 + + # Tricolon rate: "X, Y, and Z" clause-level parallel constructions, per + # 100 sentences. Netwrix house style itself models this pattern (see + # netwrix_style_guide.md's own colon example, "authenticate to the + # system, send an initial request, and validate the response") so real + # technical docs use tricolons often — corpus max observed: 33.33 per 100 + # sentences (docs/accessanalyzer/26.1/dashboards-reports/reports/identity.md + # and docs/accessanalyzer/26.1/integrations/index.md, both short pages + # where 1 in 3 sentences is a legitimate enumeration). Ceiling set to 45 + # to clear that with margin; only relentless, near-every-sentence + # tricolon use (well past any real page here) trips it. + tricolon_rate_per_100_sentences: + ceiling: 45 + min_sentences: 5 + + # Repeated paragraph openers: same first word starting >= 2 paragraphs, + # as a share of total paragraphs. Corpus max observed: 0.5 (docs/accessanalyzer/26.1/integrations/index.md). + # Ceiling set to 0.6 with a paragraph-count floor so tiny pages (few + # paragraphs, where any repeat looks huge as a ratio) aren't flagged. + repeated_paragraph_openers_rate: + ceiling: 0.6 + min_paragraphs: 4 + + # Bullet density: bullet/numbered-list lines as a share of non-blank body + # lines. Corpus max observed: 0.67 (docs/accessanalyzer/26.1/known-limitations.md). + # Ceiling set to 0.85 — reference docs are legitimately bullet-heavy; + # this only catches pages that are *almost nothing but* fragmented bullets. + bullet_density: + ceiling: 0.85 + + # Heading-to-prose ratio: heading lines as a share of non-blank body + # lines. Corpus max observed: 0.44 (docs/accessanalyzer/26.1/key-concepts.md, + # a glossary-style page that's mostly short defined-term headings). + # Ceiling set to 0.7. + heading_to_prose_ratio: + ceiling: 0.7 + + # Em-dash rate per 100 words. Netwrix house style *prescribes* em dashes + # for emphasis/interruption (netwrix_style_guide.md line ~409-411) — this + # must never fire on ordinary house-style use, only on abnormally dense, + # mechanically uniform em-dash use. Corpus max observed: 1.34 per 100 + # words (docs/accessanalyzer/26.1/install/automatic-tls-certificates.md). + # Ceiling set to 8.0 — roughly 6x the real max, so genuine house style + # (even a page written with much heavier em-dash use than anything + # shipped today) still passes; only mechanical AI-pattern overuse trips it. + em_dash_rate_per_100_words: + ceiling: 8.0 diff --git a/scripts/doc-draft/lib/prose.mjs b/scripts/doc-draft/lib/prose.mjs new file mode 100644 index 0000000000..9e4f88721d --- /dev/null +++ b/scripts/doc-draft/lib/prose.mjs @@ -0,0 +1,178 @@ +/** + * prose.mjs — strip a markdown page down to body prose. + * + * Used by readability.mjs and slop.mjs so neither scores content that was + * never meant to read as prose: frontmatter, code fences, tables, JSX/import + * lines, image references, and link URLs. See + * .claude/references/doc-draft-conventions.md §7.1. + * + * Contract: extractProse() returns one prose line per surviving source line + * (stripped lines are omitted, not blanked), plus a parallel array mapping + * each returned line back to its 1-based line number in the original file. + * Callers that report a finding against prose text use this map to recover + * the line number a human would look up in the actual page. + */ + +/** @typedef {{ text: string, lineNumbers: number[] }} ProseResult */ + +const FENCE_RE = /^\s*(```|~~~)/; +const ADMONITION_RE = /^\s*:::(note|warning|tip|danger)?\s*$/i; +const TABLE_ROW_RE = /^\s*\|.*\|\s*$/; +const IMPORT_EXPORT_RE = /^\s*(import|export)\s.+$/; +const JSX_LINE_ONLY_RE = /^\s*<\/?[A-Za-z][\w.]*(\s[^<>]*)?\/?>\s*$/; +const HEADING_PREFIX_RE = /^(#{1,6})\s+/; + +/** + * Strip YAML frontmatter (--- ... ---) from the top of a file. + * @param {string[]} lines + * @returns {{ body: string[], bodyStartLine: number }} body lines (1:1 with + * original numbering preserved via offset) and the 1-based line the body + * starts on. + */ +function stripFrontmatter(lines) { + if (lines[0] !== '---') { + return { body: lines, bodyStartLine: 1 }; + } + for (let i = 1; i < lines.length; i++) { + if (lines[i] === '---') { + return { body: lines.slice(i + 1), bodyStartLine: i + 2 }; + } + } + // Unterminated frontmatter fence — treat the whole file as frontmatter. + return { body: [], bodyStartLine: lines.length + 1 }; +} + +/** + * Remove markdown image syntax entirely: ![alt](url "title"). + * Alt text is accessibility metadata, not body prose — unlike link text, + * which stays (see stripLinkUrls). + */ +function stripImages(line) { + return line.replace(/!\[[^\]]*\]\([^)]*\)/g, ''); +} + +/** + * Replace [text](url) with text — the link text is real prose, the URL isn't. + */ +function stripLinkUrls(line) { + return line.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); +} + +/** Remove inline code spans, backticks and all — not prose, and code tokens + * (flag names, paths) would otherwise skew sentence-length/readability + * stats and get treated as words they aren't. */ +function stripInlineCode(line) { + return line.replace(/`[^`]*`/g, ''); +} + +/** Strip a leading admonition marker's own line; the content inside stays. */ +function isAdmonitionMarkerLine(line) { + return ADMONITION_RE.test(line); +} + +/** Strip JSX/HTML tags but keep any inline text they wrap, e.g. + * `` on its own line is dropped entirely + * (it carries no reader-facing prose), but `Some text.` + * keeps "Some text." */ +function stripJsxTags(line) { + return line.replace(/<\/?[A-Za-z][\w.]*(\s[^<>]*)?\/?>/g, ''); +} + +function stripHeadingMarker(line) { + return line.replace(HEADING_PREFIX_RE, ''); +} + +/** + * @param {string} markdown + * @returns {ProseResult} + */ +export function extractProse(markdown) { + const rawLines = markdown.split('\n'); + const { body, bodyStartLine } = stripFrontmatter(rawLines); + + const outLines = []; + const lineNumbers = []; + let inFence = false; + let inTable = false; + + for (let i = 0; i < body.length; i++) { + const originalLineNo = bodyStartLine + i; + const raw = body[i]; + + if (FENCE_RE.test(raw)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + if (IMPORT_EXPORT_RE.test(raw)) continue; + if (JSX_LINE_ONLY_RE.test(raw)) continue; + if (isAdmonitionMarkerLine(raw)) continue; + + if (TABLE_ROW_RE.test(raw)) { + inTable = true; + continue; + } + if (inTable && raw.trim() === '') { + inTable = false; + continue; + } + if (inTable) continue; + + let text = raw; + text = stripImages(text); + text = stripLinkUrls(text); + text = stripInlineCode(text); + text = stripJsxTags(text); + text = stripHeadingMarker(text); + + outLines.push(text); + lineNumbers.push(originalLineNo); + } + + return { text: outLines.join('\n'), lineNumbers }; +} + +/** + * Split prose text into sentences for readability scoring. + * Deliberately simple (period/question/exclamation boundary, guarding + * against common abbreviations) — good enough for scoring body prose that + * has already had code/tables/links stripped by extractProse(). + * @param {string} text + * @returns {string[]} + */ +export function splitSentences(text) { + const cleaned = text + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .join(' '); + if (!cleaned) return []; + + const ABBREV_RE = /\b(e\.g|i\.e|etc|vs|Mr|Mrs|Ms|Dr|Inc|Ltd|Co|Fig|No)\.$/i; + const parts = cleaned.split(/(?<=[.!?])\s+(?=[A-Z0-9"'(‘“])/); + + const sentences = []; + let buffer = ''; + for (const part of parts) { + buffer = buffer ? `${buffer} ${part}` : part; + const trimmed = buffer.trim(); + const lastWord = trimmed.split(/\s+/).pop() || ''; + if (ABBREV_RE.test(lastWord)) continue; + sentences.push(trimmed); + buffer = ''; + } + if (buffer.trim()) sentences.push(buffer.trim()); + return sentences.filter(s => /[A-Za-z0-9]/.test(s)); +} + +/** + * Extract words for readability/lexical scoring: alphanumeric runs + * (contractions kept whole, e.g. "don't"). + * @param {string} text + * @returns {string[]} + */ +export function splitWords(text) { + const matches = text.match(/[A-Za-z0-9]+(?:'[A-Za-z]+)?/g); + return matches || []; +} diff --git a/scripts/doc-draft/lib/report.mjs b/scripts/doc-draft/lib/report.mjs new file mode 100644 index 0000000000..ecc03562d8 --- /dev/null +++ b/scripts/doc-draft/lib/report.mjs @@ -0,0 +1,91 @@ +/** + * report.mjs — shared gate output contract. + * + * Every doc-draft gate (frontmatter.mjs, slop.mjs, readability.mjs, + * leak-check.mjs, vale-gate.mjs) builds its result with buildResult() and + * prints it with one of the print* functions below, so the JSON shape and + * the human-facing table never drift between gates. See + * .claude/references/doc-draft-conventions.md §7.2. + */ + +/** @typedef {{ line: number, rule: string, message: string, text: string }} Finding */ +/** + * @typedef {{ + * page: string, + * gate: string, + * pass: boolean, + * metrics: Record, + * findings: Finding[] + * }} GateResult + */ + +/** + * @param {{ page: string, gate: string, metrics?: Record, findings?: Finding[] }} args + * @returns {GateResult} + */ +export function buildResult({ page, gate, metrics = {}, findings = [] }) { + return { + page, + gate, + pass: findings.length === 0, + metrics, + findings, + }; +} + +/** + * Render findings as the repo's standard table, verbatim format used by + * the `derek` and `dale` skills: `| Line | Rule | Message | Offending Text |`. + * @param {Finding[]} findings + * @returns {string} + */ +export function renderFindingsTable(findings) { + if (findings.length === 0) { + return 'No issues found.'; + } + const rows = findings + .slice() + .sort((a, b) => a.line - b.line) + .map(f => `| ${f.line} | ${escapeCell(f.rule)} | ${escapeCell(f.message)} | ${escapeCell(f.text)} |`); + return ['| Line | Rule | Message | Offending Text |', '|------|------|---------|----------------|', ...rows].join('\n'); +} + +function escapeCell(value) { + return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +/** + * Machine-facing summary in vale-autofix.sh's `{fixed:[…], skipped:[…]}` + * shape (scripts/vale-autofix.sh). Gates never repair content (N5), so + * `fixed` is always empty here — this exists only so downstream tooling + * that already knows autofix's shape can consume gate output the same + * way. `skipped` carries the findings a human or the drafter must still + * act on. + * @param {Finding[]} findings + * @returns {{ fixed: [], skipped: Finding[] }} + */ +export function buildAutofixShapeSummary(findings) { + return { fixed: [], skipped: findings }; +} + +/** + * Print a gate result: JSON contract on stdout by default, or the human + * table when `format` is 'table'. + * @param {GateResult} result + * @param {{ format?: 'json' | 'table' }} [opts] + */ +export function printResult(result, { format = 'json' } = {}) { + if (format === 'table') { + console.log(renderFindingsTable(result.findings)); + return; + } + console.log(JSON.stringify(result, null, 2)); +} + +/** + * Standard CLI exit: gates exit non-zero when they fail, 0 when they pass. + * @param {GateResult} result + */ +export function exitForResult(result) { + process.exit(result.pass ? 0 : 1); +} diff --git a/scripts/doc-draft/readability.mjs b/scripts/doc-draft/readability.mjs new file mode 100644 index 0000000000..62708cf33a --- /dev/null +++ b/scripts/doc-draft/readability.mjs @@ -0,0 +1,245 @@ +#!/usr/bin/env node + +/** + * readability.mjs — deterministic prose readability gate (Phase 1). + * + * Phase 1 of the docdraft quality system: no model calls, no fact pack, no + * extractor — this gate must run standalone against an arbitrary markdown + * file, today. It scores the four metrics in .claude/references/ + * doc-draft-conventions.md §4.1 (Flesch Reading Ease, mean sentence word + * count, share of sentences over 30 words, mean syllables per word) + * against thresholds calibrated from the repo's own shipped writing. + * + * §4.2 note: Phase 1 has no page-type template yet, so every page is + * scored against one shared threshold set below. A template-driven + * per-page-type override (e.g. looser thresholds for reference/API pages, + * tighter for concept pages) is future work, not built here. + * + * Scoring runs on extractProse() output, not the raw file — tables, code + * fences, JSX, frontmatter, and link URLs are not prose and would otherwise + * skew sentence/word statistics (see lib/prose.mjs). + * + * Usage: + * node scripts/doc-draft/readability.mjs [--table] + * + * Default output is the shared JSON gate-result contract (lib/report.mjs). + * --table prints the human-facing findings table instead. + */ + +import fs from 'fs'; +import path from 'path'; +import { extractProse, splitSentences, splitWords } from './lib/prose.mjs'; +import { buildResult, printResult, exitForResult } from './lib/report.mjs'; + +const GATE = 'readability'; +const LONG_SENTENCE_WORDS = 30; + +// --------------------------------------------------------------------------- +// §4.1 thresholds — calibrated against the 56-page docs/accessanalyzer/26.1 +// corpus: +// git -C ls-files docs/accessanalyzer/26.1 | grep '\.md$' | grep -v '/kb/' +// All 56 pages must pass. A gate that rejects the repo's own accepted, +// shipped writing is not shippable, so each floor/ceiling below is set at +// or past the worst value actually observed in that corpus, not a guess. +// +// Worst observed across the 56 pages (prose only, after extractProse()): +// Flesch Reading Ease worst = 43.69 (dashboards-reports/dashboards/active-directory.md) +// -> floor set to 40 +// mean sentence words worst = 26.48 (dashboards-reports/reports/identity.md) +// -> ceiling set to 27 +// long-sentence share worst = 24.00% (install/adcs-tls-certificates.md) +// -> ceiling set to 25% +// mean syllables/word worst = 1.704 (integrations/index.md) +// -> ceiling set to 1.71 +// +// The plan's proposed defaults (Flesch min ~50, mean sentence words max +// ~22, long-sentence share max ~15%) do not survive contact with the real +// corpus — several shipped pages (dashboard/report descriptions and +// integration pages with long, technical, multi-clause sentences) run +// measurably harder than that. Trust the data: thresholds below are set to +// the observed worst case (with a little headroom) so the gate flags +// genuinely unusual pages, not the repo's own normal register. +// --------------------------------------------------------------------------- +const THRESHOLDS = { + fleschMin: 40, + meanSentenceWordsMax: 27, + longSentenceShareMax: 25, + meanSyllablesPerWordMax: 1.71, +}; + +/** + * Count syllables in a single word using the standard vowel-group + * heuristic plus a suffix-adjustment table — the common approach behind + * most Flesch implementations, not a dictionary lookup. + * @param {string} rawWord + * @returns {number} + */ +function countSyllables(rawWord) { + const word = rawWord.toLowerCase().replace(/[^a-z]/g, ''); + if (word.length === 0) return 0; + if (word.length <= 3) return 1; + + let w = word; + // Suffix adjustments: a trailing silent "e" (e.g. "make") doesn't add a + // syllable, but "-le" preceded by a consonant does (e.g. "table", + // "little" — the [^laeiouy] class excludes 'l' itself so "-le" endings + // survive this strip). A trailing "ed" that isn't preceded by "t"/"d" + // (e.g. "walked") is usually silent; "es" behaves the same way. + w = w.replace(/(?:[^laeiouy]es|[^td]ed|[^laeiouy]e)$/, ''); + // A leading "y" acts as a consonant, not a vowel. + w = w.replace(/^y/, ''); + + const vowelGroups = w.match(/[aeiouy]{1,2}/g); + return vowelGroups ? Math.max(vowelGroups.length, 1) : 1; +} + +/** + * Score prose text against the four §4.1 metrics. + * @param {string} text + * @returns {{ + * fleschReadingEase: number, meanSentenceWords: number, + * longSentenceSharePct: number, meanSyllablesPerWord: number, + * sentenceCount: number, wordCount: number + * }} + */ +export function scoreProse(text) { + const sentences = splitSentences(text); + const sentenceCount = sentences.length; + + let wordCount = 0; + let syllableCount = 0; + let longSentences = 0; + + for (const sentence of sentences) { + const words = splitWords(sentence); + wordCount += words.length; + if (words.length > LONG_SENTENCE_WORDS) longSentences++; + for (const word of words) { + syllableCount += countSyllables(word); + } + } + + if (sentenceCount === 0 || wordCount === 0) { + return { + fleschReadingEase: 100, + meanSentenceWords: 0, + longSentenceSharePct: 0, + meanSyllablesPerWord: 0, + sentenceCount, + wordCount, + }; + } + + const meanSentenceWords = wordCount / sentenceCount; + const meanSyllablesPerWord = syllableCount / wordCount; + const fleschReadingEase = + 206.835 - 1.015 * meanSentenceWords - 84.6 * meanSyllablesPerWord; + const longSentenceSharePct = (longSentences / sentenceCount) * 100; + + return { + fleschReadingEase, + meanSentenceWords, + longSentenceSharePct, + meanSyllablesPerWord, + sentenceCount, + wordCount, + }; +} + +/** + * Build findings for any metric that breaches its threshold. Metrics are + * whole-document, not line-specific, so `line: 0` is used (top of the + * document is the reasonable attribution point if one is needed). + * @param {ReturnType} metrics + * @returns {import('./lib/report.mjs').Finding[]} + */ +function buildFindings(metrics) { + const findings = []; + + if (metrics.fleschReadingEase < THRESHOLDS.fleschMin) { + findings.push({ + line: 0, + rule: 'readability:flesch-reading-ease', + message: `Flesch Reading Ease is ${metrics.fleschReadingEase.toFixed(1)}, threshold is ${THRESHOLDS.fleschMin}.`, + text: '', + }); + } + if (metrics.meanSentenceWords > THRESHOLDS.meanSentenceWordsMax) { + findings.push({ + line: 0, + rule: 'readability:mean-sentence-words', + message: `Mean sentence word count is ${metrics.meanSentenceWords.toFixed(1)}, threshold is ${THRESHOLDS.meanSentenceWordsMax}.`, + text: '', + }); + } + if (metrics.longSentenceSharePct > THRESHOLDS.longSentenceShareMax) { + findings.push({ + line: 0, + rule: 'readability:long-sentence-share', + message: `Share of sentences over ${LONG_SENTENCE_WORDS} words is ${metrics.longSentenceSharePct.toFixed(1)}%, threshold is ${THRESHOLDS.longSentenceShareMax}%.`, + text: '', + }); + } + if (metrics.meanSyllablesPerWord > THRESHOLDS.meanSyllablesPerWordMax) { + findings.push({ + line: 0, + rule: 'readability:mean-syllables-per-word', + message: `Mean syllables per word is ${metrics.meanSyllablesPerWord.toFixed(2)}, threshold is ${THRESHOLDS.meanSyllablesPerWordMax}.`, + text: '', + }); + } + + return findings; +} + +/** + * Run the gate against a single markdown file. + * @param {string} filePath + * @returns {import('./lib/report.mjs').GateResult} + */ +export function runGate(filePath) { + const markdown = fs.readFileSync(filePath, 'utf8'); + const { text } = extractProse(markdown); + const metrics = scoreProse(text); + const findings = buildFindings(metrics); + + return buildResult({ + page: filePath, + gate: GATE, + metrics: { + fleschReadingEase: round(metrics.fleschReadingEase, 2), + meanSentenceWords: round(metrics.meanSentenceWords, 2), + longSentenceSharePct: round(metrics.longSentenceSharePct, 2), + meanSyllablesPerWord: round(metrics.meanSyllablesPerWord, 3), + sentenceCount: metrics.sentenceCount, + wordCount: metrics.wordCount, + }, + findings, + }); +} + +function round(value, digits) { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} + +function main() { + const args = process.argv.slice(2); + const useTable = args.includes('--table'); + const filePath = args.find(a => !a.startsWith('--')); + + if (!filePath) { + console.error('Usage: node scripts/doc-draft/readability.mjs [--table]'); + process.exit(2); + } + + const resolved = path.resolve(filePath); + const result = runGate(resolved); + printResult(result, { format: useTable ? 'table' : 'json' }); + exitForResult(result); +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); +if (isMain) { + main(); +} diff --git a/scripts/doc-draft/slop.mjs b/scripts/doc-draft/slop.mjs new file mode 100644 index 0000000000..a08fb8cdae --- /dev/null +++ b/scripts/doc-draft/slop.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +/** + * slop.mjs — deterministic AI-isms / AI-slop gate. + * + * Phase 1 of the docdraft quality system: no model calls, no fact pack, no + * extractor — this gate must run standalone on any markdown file today. + * See .claude/references/doc-draft-conventions.md §3 (gate scope), §3.1 + * (lexical catalog), §3.2 (structural signals), §3.3 (em-dash density). + * + * Scores a page two ways: + * 1. Lexical AI-isms — catalog lives in ai-isms.yml (never hardcoded + * here; ai-isms.yml is the single source of truth for the word list). + * 2. Structural signals — sentence-length variance, tricolon rate, + * repeated paragraph openers, bullet density, heading-to-prose + * ratio, em-dash rate — computed as real numbers and compared + * against the floors/ceilings in ai-isms.yml. + * + * Usage: + * node scripts/doc-draft/slop.mjs # JSON GateResult + * node scripts/doc-draft/slop.mjs --table # findings table + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import yaml from 'js-yaml'; +import { extractProse, splitSentences, splitWords } from './lib/prose.mjs'; +import { buildResult, printResult, exitForResult } from './lib/report.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const CATALOG_PATH = path.join(__dirname, 'ai-isms.yml'); + +/** + * Load and parse ai-isms.yml. Exported so other scripts can reuse the + * exact same loader instead of re-implementing YAML parsing. + * @returns {{ lexical: Record, structural: Record }} + */ +export function loadCatalog() { + const raw = fs.readFileSync(CATALOG_PATH, 'utf8'); + return yaml.load(raw); +} + +function compileLexicalRules(catalog) { + return Object.entries(catalog.lexical).map(([term, pattern]) => ({ + term, + re: new RegExp(pattern, 'gi'), + })); +} + +/** + * Scan prose lines for lexical AI-isms hits, mapped back to original file + * line numbers via prose.lineNumbers. + */ +function findLexicalHits(prose, lexicalRules) { + const findings = []; + const lines = prose.text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) continue; + const lineNo = prose.lineNumbers[i]; + for (const { term, re } of lexicalRules) { + re.lastIndex = 0; + let match; + while ((match = re.exec(line)) !== null) { + findings.push({ + line: lineNo, + rule: `slop-lexical:${term}`, + message: `Avoid AI-ism '${term}'.`, + text: line.trim(), + }); + if (match[0].length === 0) re.lastIndex += 1; + } + } + } + return findings; +} + +function mean(nums) { + return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0; +} + +function stddev(nums) { + if (nums.length === 0) return 0; + const m = mean(nums); + const variance = mean(nums.map(n => (n - m) ** 2)); + return Math.sqrt(variance); +} + +/** Sentence-length variance: word count per sentence, stddev across the page. */ +function computeSentenceLengthStats(proseText) { + const sentences = splitSentences(proseText); + const lengths = sentences.map(s => splitWords(s).length).filter(n => n > 0); + return { sentenceCount: lengths.length, stddev: stddev(lengths) }; +} + +// Three-part parallel construction at the clause level: "X, Y, and Z" (or +// "or"), each part a short run of words — not just any 3-item list. +const TRICOLON_RE = + /\b[A-Za-z0-9][\w'-]*(?:\s+[A-Za-z0-9][\w'-]*){0,4},\s+[A-Za-z0-9][\w'-]*(?:\s+[A-Za-z0-9][\w'-]*){0,4},\s+(?:and|or)\s+[A-Za-z0-9][\w'-]*(?:\s+[A-Za-z0-9][\w'-]*){0,4}/gi; + +/** Tricolon frequency, per 100 sentences. */ +function computeTricolonRate(proseText) { + const sentences = splitSentences(proseText); + let count = 0; + for (const s of sentences) { + const matches = s.match(TRICOLON_RE); + if (matches) count += matches.length; + } + const rate = sentences.length ? (count / sentences.length) * 100 : 0; + return { sentenceCount: sentences.length, count, rate }; +} + +/** Repeated paragraph openers: same first word starting >= 2 paragraphs. */ +function computeRepeatedOpenersRate(proseText) { + const paragraphs = proseText + .split(/\n\s*\n/) + .map(p => p.trim()) + .filter(Boolean); + const counts = {}; + for (const p of paragraphs) { + const firstWord = (splitWords(p.split('\n')[0])[0] || '').toLowerCase(); + if (!firstWord) continue; + counts[firstWord] = (counts[firstWord] || 0) + 1; + } + const maxRepeat = Object.values(counts).reduce((a, b) => Math.max(a, b), 0); + const rate = paragraphs.length ? maxRepeat / paragraphs.length : 0; + return { paragraphCount: paragraphs.length, maxRepeat, rate }; +} + +const HEADING_LINE_RE = /^\s{0,3}#{1,6}\s+/; +const BULLET_LINE_RE = /^\s*(?:[-*+]|\d+[.)])\s+/; + +/** Bullet density and heading-to-prose ratio, both as a share of non-blank body lines. */ +function computeLineShapeRatios(markdown, prose) { + const rawLines = markdown.split('\n'); + const proseLines = prose.text.split('\n'); + let nonBlankCount = 0; + let headingCount = 0; + let bulletCount = 0; + for (let i = 0; i < proseLines.length; i++) { + if (!proseLines[i].trim()) continue; + nonBlankCount++; + const raw = rawLines[prose.lineNumbers[i] - 1] || ''; + if (HEADING_LINE_RE.test(raw)) headingCount++; + else if (BULLET_LINE_RE.test(raw)) bulletCount++; + } + return { + nonBlankCount, + bulletDensity: nonBlankCount ? bulletCount / nonBlankCount : 0, + headingToProseRatio: nonBlankCount ? headingCount / nonBlankCount : 0, + }; +} + +/** Em-dash rate, per 100 words. House style prescribes em dashes — see §3.3 — so this only fires on abnormal density. */ +function computeEmDashRate(proseText) { + const emDashCount = (proseText.match(/—/g) || []).length; + const wordCount = splitWords(proseText).length; + return { emDashCount, wordCount, rate: wordCount ? (emDashCount / wordCount) * 100 : 0 }; +} + +/** + * Compute every structural metric for a page, and evaluate each one + * against ai-isms.yml's structural thresholds. + * @returns {{ metrics: Record, findings: import('./lib/report.mjs').Finding[] }} + */ +export function computeStructuralSignals(markdown, prose, structuralConfig) { + const findings = []; + const metrics = {}; + + const sentenceStats = computeSentenceLengthStats(prose.text); + metrics.sentenceLengthStddev = round(sentenceStats.stddev); + metrics.sentenceCount = sentenceStats.sentenceCount; + const slCfg = structuralConfig.sentence_length_stddev; + if (sentenceStats.sentenceCount >= slCfg.min_sentences && sentenceStats.stddev < slCfg.floor) { + findings.push({ + line: 1, + rule: 'slop-structural:sentence-length-stddev', + message: `Sentence-length stddev is ${round(sentenceStats.stddev)} words (floor ${slCfg.floor}) — uniform sentence rhythm is a strong AI tell.`, + text: `stddev=${round(sentenceStats.stddev)} over ${sentenceStats.sentenceCount} sentences`, + }); + } + + const tricolon = computeTricolonRate(prose.text); + metrics.tricolonRatePer100Sentences = round(tricolon.rate); + const triCfg = structuralConfig.tricolon_rate_per_100_sentences; + if (tricolon.sentenceCount >= triCfg.min_sentences && tricolon.rate > triCfg.ceiling) { + findings.push({ + line: 1, + rule: 'slop-structural:tricolon-rate', + message: `Tricolon rate is ${round(tricolon.rate)} per 100 sentences (ceiling ${triCfg.ceiling}) — overuse of "X, Y, and Z" parallelism is a strong AI tell.`, + text: `${tricolon.count} tricolons over ${tricolon.sentenceCount} sentences`, + }); + } + + const openers = computeRepeatedOpenersRate(prose.text); + metrics.repeatedParagraphOpenersRate = round(openers.rate); + const openCfg = structuralConfig.repeated_paragraph_openers_rate; + if (openers.paragraphCount >= openCfg.min_paragraphs && openers.rate > openCfg.ceiling) { + findings.push({ + line: 1, + rule: 'slop-structural:repeated-paragraph-openers', + message: `Repeated paragraph-opener rate is ${round(openers.rate)} (ceiling ${openCfg.ceiling}) — ${openers.maxRepeat} of ${openers.paragraphCount} paragraphs share the same opening word.`, + text: `maxRepeat=${openers.maxRepeat} paragraphs=${openers.paragraphCount}`, + }); + } + + const shapes = computeLineShapeRatios(markdown, prose); + metrics.bulletDensity = round(shapes.bulletDensity); + metrics.headingToProseRatio = round(shapes.headingToProseRatio); + const bulletCfg = structuralConfig.bullet_density; + if (shapes.bulletDensity > bulletCfg.ceiling) { + findings.push({ + line: 1, + rule: 'slop-structural:bullet-density', + message: `Bullet density is ${round(shapes.bulletDensity)} of body lines (ceiling ${bulletCfg.ceiling}) — the page is almost entirely fragmented bullets.`, + text: `bulletDensity=${round(shapes.bulletDensity)}`, + }); + } + const headingCfg = structuralConfig.heading_to_prose_ratio; + if (shapes.headingToProseRatio > headingCfg.ceiling) { + findings.push({ + line: 1, + rule: 'slop-structural:heading-to-prose-ratio', + message: `Heading-to-prose ratio is ${round(shapes.headingToProseRatio)} (ceiling ${headingCfg.ceiling}) — too many headings relative to body content.`, + text: `headingToProseRatio=${round(shapes.headingToProseRatio)}`, + }); + } + + const emDash = computeEmDashRate(prose.text); + metrics.emDashRatePer100Words = round(emDash.rate); + const emDashCfg = structuralConfig.em_dash_rate_per_100_words; + if (emDash.rate > emDashCfg.ceiling) { + findings.push({ + line: 1, + rule: 'slop-structural:em-dash-rate', + message: `Em-dash rate is ${round(emDash.rate)} per 100 words (ceiling ${emDashCfg.ceiling}) — abnormally dense even accounting for house style's prescribed em-dash use.`, + text: `emDashCount=${emDash.emDashCount} words=${emDash.wordCount}`, + }); + } + + return { metrics, findings }; +} + +function round(n) { + return Math.round(n * 100) / 100; +} + +/** + * Run the full gate against a markdown string. Exported for calibration + * tooling and tests; the CLI below is a thin wrapper around this. + * @param {string} page - file path, used only for the GateResult's `page` field. + * @param {string} markdown - raw file contents. + * @returns {import('./lib/report.mjs').GateResult} + */ +export function runSlopGate(page, markdown) { + const catalog = loadCatalog(); + const prose = extractProse(markdown); + const lexicalRules = compileLexicalRules(catalog); + + const lexicalFindings = findLexicalHits(prose, lexicalRules); + const { metrics, findings: structuralFindings } = computeStructuralSignals( + markdown, + prose, + catalog.structural + ); + + const findings = [...lexicalFindings, ...structuralFindings]; + return buildResult({ page, gate: 'slop', metrics, findings }); +} + +function main() { + const args = process.argv.slice(2); + const tableFlag = args.includes('--table'); + const file = args.find(a => !a.startsWith('--')); + + if (!file) { + console.error('Usage: node scripts/doc-draft/slop.mjs [--table]'); + process.exit(2); + } + + const filePath = path.resolve(file); + const markdown = fs.readFileSync(filePath, 'utf8'); + const result = runSlopGate(file, markdown); + + printResult(result, { format: tableFlag ? 'table' : 'json' }); + exitForResult(result); +} + +const isDirectRun = path.resolve(process.argv[1] || '') === path.resolve(__filename); +if (isDirectRun) { + main(); +} diff --git a/scripts/doc-draft/vale-gate.mjs b/scripts/doc-draft/vale-gate.mjs new file mode 100644 index 0000000000..843e33dd17 --- /dev/null +++ b/scripts/doc-draft/vale-gate.mjs @@ -0,0 +1,516 @@ +#!/usr/bin/env node + +/** + * vale-gate.mjs — deterministic Vale findings gate (Phase 1). + * + * Phase 1 of the docdraft quality system: no model calls, no fact pack, no + * extractor — this gate must run standalone against an arbitrary markdown + * file, today. It reuses the repo's existing Vale install and full Netwrix + * rule pack (.vale/styles/Netwrix/*.yml, 43 rules, all `level: warning`, + * configured via .vale.ini at the repo root) rather than rebuilding any of + * that. Every Netwrix rule is a warning, so Vale itself never "fails" a + * file — this script is what turns "N warnings found" into a pass/fail + * decision, by comparing the post-exclusion finding count against a + * ceiling (.claude/references/doc-draft-conventions.md §6.1; known + * false-positive exclusions are §6.2). + * + * Usage: + * node scripts/doc-draft/vale-gate.mjs [--table] [--ceiling N] [--page-type TYPE] + * + * Default output is the shared JSON gate-result contract (lib/report.mjs). + * --table prints the human-facing findings table instead. The ceiling is + * per-page-type (§6.1) — the type is inferred from the file's path/name + * (inferPageType() below) since Phase 1 has no fact-pack-driven template to + * ask instead (same caveat as readability.mjs §4.2). --page-type lets a + * caller state the type explicitly rather than rely on inference — the hook + * a future template plugs into. --ceiling overrides the resolved ceiling + * outright, for one-off calls. + */ + +import fs from 'fs'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { buildResult, printResult, exitForResult } from './lib/report.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const REPO_ROOT = path.resolve(__dirname, '../..'); + +const GATE = 'vale-gate'; + +// --------------------------------------------------------------------------- +// §6.1 — per-page-type finding ceiling, calibrated against the 56-page +// docs/accessanalyzer/26.1 corpus: +// git -C ls-files docs/accessanalyzer/26.1 | grep '\.md$' | grep -v '/kb/' +// +// Real vale --output=JSON was run against every one of those 56 pages, then +// run through the §6.2 false-positive exclusion logic below, bucketed by +// inferPageType(). Post-exclusion finding counts actually observed, by type: +// landing (1 page): max 0 +// area-index (10 pages): max 0 +// whats-new (1 page): max 0 +// concepts (1 page): max 0 +// requirements (1 page): max 0 +// reference (1 page): max 0 +// procedure (6 pages): max 0 +// feature (35 pages): max 7 (service-accounts/client-id-certificate.md) +// +// That worst page (client-id-certificate.md, type "feature") carries 7 real, +// non-excluded Netwrix.FirstPerson hits on literal UI option labels +// ("Generate for me", "Upload my own") — accepted, shipped text that quotes +// an on-screen control name. Rewriting those to dodge the linter would +// misquote the UI, and "quoted first-person UI label" isn't one of the five +// documented false-positive classes this gate implements (see §6.2), so +// those findings are correctly left in, not excluded. +// +// Every other page type has zero real, non-excluded findings anywhere in +// the corpus — but zero headroom on a type with only 1-10 observed samples +// would make the gate maximally brittle for that type's very next real page +// (one incidental warning-level hit and the type's whole ceiling is blown). +// Phase 1 has no fact-pack-driven template (§4.2's caveat applies here too), +// so these numbers can't yet be split further than "type"; PAGE_TYPE_HEADROOM +// gives the zero-observed types the same small cushion §3.2/§4.2 give their +// thresholds, without matching feature's much higher real-world ceiling +// (driven specifically by literal first-person UI labels, a pattern that +// doesn't recur in the other seven types' shipped pages). Note the plan's C1 +// pass-the-corpus bar formally binds readability.mjs/slop.mjs, not +// vale-gate.mjs — but calibrating against real accepted writing, by type, is +// still the right practice here. +// --------------------------------------------------------------------------- +const PAGE_TYPE_HEADROOM = 2; +const PAGE_TYPE_CEILINGS = { + landing: 0 + PAGE_TYPE_HEADROOM, + 'area-index': 0 + PAGE_TYPE_HEADROOM, + 'whats-new': 0 + PAGE_TYPE_HEADROOM, + concepts: 0 + PAGE_TYPE_HEADROOM, + requirements: 0 + PAGE_TYPE_HEADROOM, + reference: 0 + PAGE_TYPE_HEADROOM, + procedure: 0 + PAGE_TYPE_HEADROOM, + feature: 7, +}; +// Fallback for a path inferPageType() can't place in the table above (an +// arbitrary file outside any recognized docs layout — G0c requires this +// gate to run standalone on any file path, recognized or not). Matches the +// corpus's real worst case across all types, so an unrecognized page isn't +// held to a stricter bar than the type that's actually allowed to be this +// noisy. +const DEFAULT_CEILING = 7; + +/** + * Infer a page's type from its path, the same vocabulary the plan's + * `docset..json` template (§4.2, not built in Phase 1) uses: + * landing, whats-new, concepts, requirements, reference, procedure, + * area-index (an `index.md` that isn't the version root), feature (the + * fallback — most pages in the real corpus are this type). Filename-pattern + * based, not accessanalyzer-specific: `index.md`, `whats-new.md`, and an + * `install/` directory are Docusaurus/house conventions this repo uses + * across products (see docs/CLAUDE.md's versioning section), not literals + * unique to one product. + * @param {string} filePath absolute or repo-relative path + * @returns {string} + */ +export function inferPageType(filePath) { + const rel = path.relative(REPO_ROOT, path.resolve(filePath)).split(path.sep).join('/'); + const parts = rel.split('/').filter(Boolean); + const base = (parts[parts.length - 1] || '').toLowerCase(); + + if (base === 'whats-new.md') return 'whats-new'; + if (base === 'key-concepts.md') return 'concepts'; + if (base === 'requirements.md') return 'requirements'; + if (/reference\.md$/.test(base)) return 'reference'; + if (base === 'index.md') { + // A version root is docs//index.md (single-version/SaaS) or + // docs///index.md (versioned) — 3 or 4 path segments + // starting at "docs". Anything deeper is a section/area index, not the + // product landing page. + const isVersionRoot = parts[0] === 'docs' && (parts.length === 3 || parts.length === 4); + return isVersionRoot ? 'landing' : 'area-index'; + } + if (parts.includes('install')) return 'procedure'; + return 'feature'; +} + +/** + * Resolve the ceiling for a file: explicit --ceiling wins outright; else the + * table above keyed by an explicit --page-type or, failing that, + * inferPageType(); else DEFAULT_CEILING for a type the table doesn't cover. + * @param {string} filePath + * @param {{ ceiling?: number, pageType?: string }} [opts] + * @returns {number} + */ +function resolveCeiling(filePath, { ceiling, pageType } = {}) { + if (ceiling !== undefined) return ceiling; + const type = pageType || inferPageType(filePath); + return PAGE_TYPE_CEILINGS[type] ?? DEFAULT_CEILING; +} + +// --------------------------------------------------------------------------- +// §6.2 — known false-positive classes. +// Source: .claude/agent-memory/tech-writer/vale_false_positives.md. Vale's +// Netwrix rules are regex existence/substitution checks with no grammar or +// context awareness, so a handful of documented patterns fire on text that +// isn't actually a violation. Findings matching one of these classes are +// excluded from the pass/fail count entirely (not just downweighted) and +// reported separately in metrics.excluded_false_positives so the exclusion +// stays visible instead of silently vanishing. +// +// Class 1 — Netwrix.FirstPerson matching the "I" inside "I/O". The token is +// \bI\b; a slash is a word boundary, so "disk I/O performance" trips it even +// though it's a standard technical term, not the pronoun. +// +// Class 2 — Netwrix.FirstPersonPlural matching "us" inside the country code +// "US" (e.g. an example value in a table) or the "en-us" locale segment of a +// URL. The token is \bus\b, ignorecase: true. +// +// Class 3 — Netwrix.OxfordComma firing on literal Windows/NetApp UI strings +// quoted verbatim from the product, most commonly "This folder, subfolders +// and files" (a real Windows Advanced Security Settings dropdown value). +// Adding a comma would misquote what the reader sees on screen. +// +// Classes 4/5 — Netwrix.OxfordComma's regex (`\w+,\s+\w+\s+and/or\b`) can't +// tell a genuine 3+-item series ("FQDN, NETBIOS or IPv4 address" — a real +// violation, still needs the comma) from a 2-item pattern that only looks +// like one: either (a) the comma is a clause boundary and what follows is a +// 2-item verb/participle list ("Prior to configuring your plan, read and +// complete the instructions"), or (b) the 2-item phrase is a comma-bounded +// appositive/parenthetical ("An account's type, local or federated, can't +// change..."), or (c) the leading segment's own comma introduces a "for +// example"/"e.g."/"for instance" parenthetical rather than a first list item +// ("The tool covers frameworks, for example, PCI and NIST 800-53 +// requirements." — two items, PCI and NIST, not three). classifyOxfordComma() +// below distinguishes these from a real series by: an earlier comma already +// inside the leading segment that is NOT a "for example"-style parenthetical +// (already a 3+ series — never excluded, this is the "don't invent an +// alternatives-list exception" warning in the source doc); a clause-like +// leading segment (common instructional verb or a gerund/participle); or the +// 2-item phrase being immediately closed by another comma or ")" (appositive +// bound). +// +// Class 6 (Netwrix.Idioms / any rule) — a rule fires on a substring of a +// literal UI string quoted verbatim from the product (a bolded toggle name +// like "Leverage Integration API", a literal tab name like "APIs my +// organization uses", the Windows dropdown value "This folder, subfolders +// and files", or a real Microsoft domain pattern like +// "{TenantName}-my.sharepoint.com"). The rule that fires varies by string +// (Netwrix.Idioms on "Leverage", Netwrix.FirstPerson on "my", +// Netwrix.OxfordComma on the folder string's comma) but the reason is the +// same in every case: the string is quoted verbatim from the UI and editing +// it — for any rule, not just the one the source doc happened to name — +// would misquote what the customer sees on screen. isLiteralUiStringMatch() +// below is therefore rule-agnostic, checked ahead of every other +// classifier. +// --------------------------------------------------------------------------- + +const KNOWN_LITERAL_UI_STRINGS = [ + 'this folder, subfolders and files', + 'apis my organization uses', + 'leverage integration api', +]; +const KNOWN_LITERAL_UI_DOMAIN_RE = /\{[^}]+\}-my\.sharepoint\.com/i; + +/** + * Class 6: a Vale match falling inside a known literal UI string, regardless + * of which rule fired. Substring-contained, not position-anchored — these + * are short, specific, curated phrases (not general text), so a same-line + * substring check is enough without risking an unrelated match elsewhere on + * the line. + * @param {string} match + * @param {string} lineText + * @returns {boolean} + */ +function isLiteralUiStringMatch(match, lineText) { + const lowerLine = lineText.toLowerCase(); + const lowerMatch = match.toLowerCase(); + for (const literal of KNOWN_LITERAL_UI_STRINGS) { + if (lowerLine.includes(literal) && literal.includes(lowerMatch)) return true; + } + if (KNOWN_LITERAL_UI_DOMAIN_RE.test(lineText) && lowerMatch === 'my') return true; + return false; +} + +/** Class 1: "I" inside "I/O". */ +function isIoFalsePositive(rule, match, lineText, matchIndex) { + if (rule !== 'Netwrix.FirstPerson' || match !== 'I') return false; + const before = lineText[matchIndex - 1]; + const after = lineText[matchIndex + match.length]; + return before === '/' || after === '/'; +} + +/** Class 2: "us" inside "US" (country code) or an "en-us" locale segment. */ +function isUsCodeFalsePositive(rule, match, lineText, matchIndex) { + if (rule !== 'Netwrix.FirstPersonPlural') return false; + if (match.toLowerCase() !== 'us') return false; + if (match === 'US') return true; // all-caps two-letter country code + const before = lineText.slice(Math.max(0, matchIndex - 3), matchIndex); + return /en-$/i.test(before); // ".../en-us/..." locale segment +} + +// Common instructional/imperative verbs and participles that mark a leading +// segment as a clause rather than a bare list item — deliberately a curated +// list (plus a generic gerund/participle suffix check) rather than a full +// POS tagger; this is a heuristic, not a parser (see §6.2 classes 4/5). +const CLAUSE_VERB_SIGNALS = new Set([ + 'go', 'goes', 'went', 'create', 'creates', 'created', 'click', 'clicks', + 'select', 'selects', 'selected', 'choose', 'chooses', 'chose', 'configure', + 'configures', 'configured', 'configuring', 'launch', 'launches', 'launched', + 'read', 'reads', 'reading', 'write', 'writes', 'writing', 'add', 'adds', + 'adding', 'added', 'open', 'opens', 'opened', 'opening', 'navigate', + 'navigates', 'navigated', 'navigating', 'enable', 'enables', 'enabled', + 'enabling', 'disable', 'disables', 'disabled', 'ensure', 'ensures', + 'ensured', 'use', 'uses', 'used', 'using', 'set', 'sets', 'setting', 'run', + 'runs', 'running', 'ran', 'check', 'checks', 'checked', 'checking', + 'verify', 'verifies', 'verified', 'verifying', 'leave', 'leaves', 'leaving', + 'left', 'provide', 'provides', 'provided', 'providing', 'collect', + 'collects', 'collected', 'collecting', 'prefilter', 'prefilters', + 'prefiltered', 'prefiltering', 'register', 'registers', 'registered', + 'registering', 'sign', 'signs', 'signed', 'signing', 'connect', 'connects', + 'connected', 'connecting', 'remove', 'removes', 'removed', 'removing', + 'upload', 'uploads', 'uploaded', 'uploading', +]); + +function leadingSegmentReadsAsClause(text) { + const words = text.trim().split(/\s+/).filter(Boolean); + for (const raw of words) { + const word = raw.toLowerCase().replace(/[^a-z']/g, ''); + if (CLAUSE_VERB_SIGNALS.has(word)) return true; + if (word.length > 4 && /(ing|ed)$/.test(word)) return true; + } + return false; +} + +/** + * Classify a Netwrix.OxfordComma finding as a real violation or one of the + * documented false-positive classes (§6.2, classes 3/4/5). + * @param {string} match + * @param {string} lineText + * @param {number} matchIndex + * @returns {{ excluded: boolean, reason?: string }} + */ +function classifyOxfordComma(match, lineText, matchIndex) { + // Literal-UI-string exclusion (class 6) is checked upstream in + // classifyFinding() for every rule, not just this one — see + // isLiteralUiStringMatch(). + + const commaIdx = match.indexOf(','); + if (commaIdx === -1) return { excluded: false }; + const absoluteCommaIdx = matchIndex + commaIdx; + const before = lineText.slice(0, absoluteCommaIdx); + const lastBoundaryIdx = Math.max( + before.lastIndexOf('.'), before.lastIndexOf('!'), before.lastIndexOf('?'), + before.lastIndexOf(';'), before.lastIndexOf(':'), before.lastIndexOf('|'), + ); + const leadingSegment = before.slice(lastBoundaryIdx + 1); + + // Extend past the matched "and"/"or" to the word right after it, and look + // at what immediately follows that word. + const andOrMatch = match.match(/\b(and|or)\b/i); + let nextChar = ''; + if (andOrMatch) { + const afterMatchStart = matchIndex + match.length; + const rest = lineText.slice(afterMatchStart); + const wordMatch = rest.match(/^\s*([A-Za-z0-9'-]+)/); + if (wordMatch) { + const idxAfterWord = afterMatchStart + wordMatch[0].length; + nextChar = lineText[idxAfterWord] || ''; + } + } + + const appositiveBounded = nextChar === ',' || nextChar === ')'; + const leadingHasOwnComma = leadingSegment.includes(','); + const leadingIsClause = leadingSegmentReadsAsClause(leadingSegment); + + // An earlier comma already inside the leading segment means an earlier + // item already exists in this series — 3+ items, a genuine violation. + // Do NOT exclude it just because it's an alternatives ("or") list; see the + // source doc's explicit warning against inventing that exception. The one + // exception to THAT: the earlier comma introduces a "for example"/"e.g."/ + // "for instance" parenthetical rather than a first list item (class 1) — + // the leading segment ends with the introducer phrase itself, not with a + // list item. + if (leadingHasOwnComma) { + if (/(?:^|[\s,])(?:for example|for instance|e\.g\.|i\.e\.)$/i.test(leadingSegment.trim())) { + return { excluded: true, reason: 'oxfordcomma-for-example-parenthetical' }; + } + return { excluded: false }; + } + + if (appositiveBounded || leadingIsClause) { + return { excluded: true, reason: 'oxfordcomma-2item-clause-boundary' }; + } + + return { excluded: false }; +} + +/** + * Run the real `vale` binary against a file and return its raw findings + * array (Vale's JSON keys results by file path; a single-file invocation + * has exactly one key). Must run with cwd at the repo root so Vale can find + * .vale.ini — Vale has no ancestor-search fallback once StylesPath fails to + * resolve, it just errors (E100 "no config file found"). + * @param {string} filePath + * @returns {Array>} + */ +function runVale(filePath) { + let stdout; + try { + stdout = execFileSync('vale', ['--output=JSON', filePath], { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + } catch (err) { + // Vale's Netwrix rules are all `level: warning`, so a normal run with + // findings still exits 0; a non-zero exit here means a real runtime + // error (bad path, config problem). Some of those still print a JSON + // error payload on stdout, so surface that if present. + if (err.stdout) { + stdout = err.stdout; + } else { + throw new Error(`vale invocation failed: ${err.message}`); + } + } + + const parsed = JSON.parse(stdout || '{}'); + const values = Object.values(parsed); + return values[0] || []; +} + +/** + * Resolve each raw Vale finding's position within its source line, so the + * false-positive classifiers can inspect surrounding text. Findings with + * identical (line, rule, match) tuples (e.g. "US" appearing twice on one + * line) are resolved to successive occurrences via a per-key cursor. + * @param {Array>} rawFindings + * @param {string[]} lines + * @returns {Array<{ raw: Record, lineText: string, matchIndex: number }>} + */ +function resolvePositions(rawFindings, lines) { + const cursors = new Map(); + return rawFindings.map(raw => { + const lineText = lines[raw.Line - 1] || ''; + const key = `${raw.Line}|${raw.Check}|${raw.Match}`; + const fromIndex = cursors.get(key) || 0; + const foundAt = lineText.indexOf(raw.Match, fromIndex); + const matchIndex = foundAt === -1 ? 0 : foundAt; + cursors.set(key, matchIndex + 1); + return { raw, lineText, matchIndex }; + }); +} + +/** + * @param {{ raw: Record, lineText: string, matchIndex: number }} resolved + * @returns {{ excluded: boolean, reason?: string }} + */ +function classifyFinding({ raw, lineText, matchIndex }) { + if (isLiteralUiStringMatch(raw.Match, lineText)) { + return { excluded: true, reason: 'literal-ui-string' }; + } + if (isIoFalsePositive(raw.Check, raw.Match, lineText, matchIndex)) { + return { excluded: true, reason: 'firstperson-io' }; + } + if (isUsCodeFalsePositive(raw.Check, raw.Match, lineText, matchIndex)) { + return { excluded: true, reason: 'firstpersonplural-us-code' }; + } + if (raw.Check === 'Netwrix.OxfordComma') { + return classifyOxfordComma(raw.Match, lineText, matchIndex); + } + return { excluded: false }; +} + +/** + * Run the gate against a single markdown file. + * @param {string} filePath + * @param {{ ceiling?: number, pageType?: string }} [opts] + * @returns {import('./lib/report.mjs').GateResult} + */ +export function runGate(filePath, { ceiling, pageType } = {}) { + const resolvedCeiling = resolveCeiling(filePath, { ceiling, pageType }); + const markdown = fs.readFileSync(filePath, 'utf8'); + const lines = markdown.split('\n'); + const rawFindings = runVale(filePath); + const resolved = resolvePositions(rawFindings, lines); + + const findings = []; + const excludedFalsePositives = []; + + for (const item of resolved) { + const classification = classifyFinding(item); + const finding = { + line: item.raw.Line, + rule: item.raw.Check, + message: item.raw.Message, + text: item.raw.Match, + }; + if (classification.excluded) { + excludedFalsePositives.push({ ...finding, reason: classification.reason }); + } else { + findings.push(finding); + } + } + + const overCeiling = findings.length > resolvedCeiling; + // buildResult() derives pass from findings.length === 0, which doesn't + // match this gate's ceiling-based pass/fail rule, so when the finding + // count is within the ceiling but non-zero, report the same findings for + // visibility but override pass back to true afterward. + const result = buildResult({ + page: filePath, + gate: GATE, + metrics: { + raw_finding_count: rawFindings.length, + excluded_false_positive_count: excludedFalsePositives.length, + remaining_finding_count: findings.length, + page_type: pageType || inferPageType(filePath), + ceiling: resolvedCeiling, + excluded_false_positives: excludedFalsePositives, + }, + findings, + }); + result.pass = !overCeiling; + + return result; +} + +function parseArgs(argv) { + const useTable = argv.includes('--table'); + let ceiling; + const ceilingIdx = argv.indexOf('--ceiling'); + if (ceilingIdx !== -1) { + const value = Number(argv[ceilingIdx + 1]); + if (!Number.isFinite(value) || value < 0) { + console.error(`Invalid --ceiling value: ${argv[ceilingIdx + 1]}`); + process.exit(2); + } + ceiling = value; + } + let pageType; + const pageTypeIdx = argv.indexOf('--page-type'); + if (pageTypeIdx !== -1) { + pageType = argv[pageTypeIdx + 1]; + } + const consumedFlagValues = new Set(); + if (ceilingIdx !== -1) consumedFlagValues.add(ceilingIdx + 1); + if (pageTypeIdx !== -1) consumedFlagValues.add(pageTypeIdx + 1); + const filePath = argv.find((a, i) => !a.startsWith('--') && !consumedFlagValues.has(i)); + return { filePath, useTable, ceiling, pageType }; +} + +function main() { + const { filePath, useTable, ceiling, pageType } = parseArgs(process.argv.slice(2)); + + if (!filePath) { + console.error('Usage: node scripts/doc-draft/vale-gate.mjs [--table] [--ceiling N] [--page-type TYPE]'); + process.exit(2); + } + + const resolved = path.resolve(filePath); + const result = runGate(resolved, { ceiling, pageType }); + printResult(result, { format: useTable ? 'table' : 'json' }); + exitForResult(result); +} + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (isMain) { + main(); +} diff --git a/scripts/quality/config.json b/scripts/quality/config.json new file mode 100644 index 0000000000..5c9f829487 --- /dev/null +++ b/scripts/quality/config.json @@ -0,0 +1,47 @@ +{ + "scope": { + "include": [ + "docs/**/*.md", + "docs/**/*.mdx" + ], + "exclude": [ + "docs/kb/", + "/CLAUDE.md", + "/SKILL.md" + ], + "concurrency": 3 + }, + "readability": { + "fleschMin": 45, + "meanSentenceWordsMax": 16, + "longSentenceSharePctMax": 10 + }, + "models": { + "judge": "claude-haiku-4-5", + "rewrite": "claude-sonnet-5-5", + "verify": "claude-opus-5-5", + "dale": "claude-haiku-4-5" + }, + "loop": { + "maxPasses": 3 + }, + "judge": { + "ignoreQuotes": [ + "generally recommended that", + "\\bkey\\b" + ], + "ignoreReasons": [ + "\\bpassive\\b" + ], + "runs": 3, + "agree": 2 + }, + "dale": { + "runs": 3, + "agree": 2, + "ignoreQuotes": [] + }, + "vale": { + "ignore": [] + } +} diff --git a/scripts/quality/fix-loop.mjs b/scripts/quality/fix-loop.mjs new file mode 100644 index 0000000000..ca317f0c31 --- /dev/null +++ b/scripts/quality/fix-loop.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node +/** + * fix-loop.mjs — optional local helper: clear AI-isms from the lines a change + * adds or edits, without changing what they say. The required gates are the + * pre-commit hook and the Claude Code Stop hook; this only automates the easy fixes. + * + * node scripts/quality/fix-loop.mjs [--staged | --base origin/dev] [--files list.txt] + * [--max-passes 3] [--summary out.json] + * [--suggestions out.patch] [--dry-run] + * + * The model never returns a page. Per pass it proposes small typed edits + * (drop, swap, reword) against masked sentences. Each edit must pass a + * deterministic validator (lib/edits.mjs); drops and rewords also get an + * independent meaning check (lib/verify.mjs). A drop the check doubts is not + * applied: it goes to one patch file. A reword the check doubts is applied and + * printed as "review", because the writer sees the diff before committing. + * + * A page that passes has its judge verdict cached so the hooks accept it + * without a second model call. Never runs git commit or push. + * + * Summary shape (formerly read by the vale-autofix workflow, now removed): + * { results: [{ path, passes, status: pass|unchanged|stalled, findings_before, + * findings_after, applied, rejected, suggestions }] } + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { discoverChanges, rangesFor, inRanges } from './lib/scope.mjs'; +import { scorePage } from './score.mjs'; +import { checkPreserved } from './lib/preserve.mjs'; +import { prepare, promptView, validateEdit, applyEdits, readable, flaggedMatchers, fillerEdits } from './lib/edits.mjs'; +import { mapPool } from './lib/pool.mjs'; +import { verifyMeaning } from './lib/verify.mjs'; +import { getClient, textOf, hasApiKey } from './lib/llm.mjs'; +import { runGate as valeGate } from '../doc-draft/vale-gate.mjs'; +import { renderFindingsTable } from '../doc-draft/lib/report.mjs'; + +const config = JSON.parse(fs.readFileSync(new URL('./config.json', import.meta.url), 'utf8')); + +const SYSTEM = `You edit one Netwrix documentation page so it passes an automated check for signs of AI-generated writing, without changing what it says. + +You see the page's paragraphs and list items, each on one line tagged "L:". Inline code, links, bold text, and images appear as placeholders like ⟦3⟧. Leave placeholders exactly where they are. + +Return a list of small edits. Every edit's "before" must be copied exactly from one line, and must appear only once on the page. Use only these types: + +- drop: delete a run of words (at most 15) that the findings flag as filler or AI phrasing. Give the whole sentence again in "after" without those words. Never delete a number, a negation, a requirement word, a condition (if, when, before, after, until), a name, or a placeholder. +- swap: replace one word or phrase with a plain one. "from" must be one of: utilize, leverage, in order to, prior to, subsequent to, due to the fact that, in the event that, at this point in time, commence, endeavor (and their -s, -ed, -ing forms). Put the original wording in "before", leave "after" empty. +- reword: a free rewrite of one sentence when neither of the above can fix it, for example a sentence that is only hype. Write plain, specific, instructional wording, or state the plain fact the sentence implies. Keep every fact, number, condition, and requirement. The writer reviews the result in the diff. + +Prefer drop and swap. Go through every finding: propose an edit for it if one is safe, otherwise a reword. Do not leave a finding without an edit. House style: impersonal, instructional, second person ("you"). Set unused fields to an empty string.`; + +const EDIT_SCHEMA = { + type: 'object', + properties: { + edits: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['drop', 'swap', 'reword'] }, + before: { type: 'string' }, + after: { type: 'string' }, + from: { type: 'string' }, + }, + required: ['type', 'before', 'after', 'from'], + additionalProperties: false, + }, + }, + }, + required: ['edits'], + additionalProperties: false, +}; + +const problemCount = r => r.deterministic.findings.length + r.judge.findings.length; + +function findingsText(r) { + const parts = []; + if (r.deterministic.findings.length) parts.push(renderFindingsTable(r.deterministic.findings)); + for (const f of r.judge.findings) parts.push(`Line ${f.line}: pattern ${f.pattern} - ${f.reason}\n> ${f.quote}`); + return parts.join('\n\n'); +} + +async function propose(ctx, r, feedback) { + const response = await getClient().messages.create({ + model: config.models.rewrite, + max_tokens: 8000, + system: SYSTEM, + messages: [ + { + role: 'user', + content: `Findings to clear:\n\n${findingsText(r)}${feedback ? `\n\nLast round, these edits were rejected: ${feedback}` : ''}\n\nPage prose:\n\n${promptView(ctx)}`, + }, + ], + output_config: { format: { type: 'json_schema', schema: EDIT_SCHEMA } }, + }); + return JSON.parse(textOf(response)).edits; +} + +const verify = pairs => verifyMeaning(pairs, { model: config.models.verify }); + +/** Unified diff of `content` against the file on disk, applicable with `git apply`. */ +function patchFor(file, content) { + const tmp = path.join(os.tmpdir(), `q-${process.pid}-${path.basename(file)}`); + fs.writeFileSync(tmp, content); + let out = ''; + try { + out = execFileSync('git', ['diff', '--no-index', '--no-color', '--', file, tmp], { encoding: 'utf8' }); + } catch (e) { + out = e.stdout || ''; // exit 1 means "differences found" + } finally { + fs.rmSync(tmp, { force: true }); + } + return out.split(tmp.replace(/^\//, '')).join(file).replace(/^(diff --git a\/.+ b\/).+$/m, `$1${file}`); +} + +/** Keep only the paragraphs and list items that touch a changed line. A null `ranges` (a new page) keeps everything. */ +function restrict(ctx, ranges) { + if (ranges) ctx.editable = ctx.editable.filter(e => e.indices.some(i => inRanges(i + 1, ranges))); + return ctx; +} + +/** + * `where` is { mode, base } for a page that existed before the change: only the + * lines the change adds or rewrites are checked and edited. Omit it for a new page. + */ +export async function fixPage(file, { where = null, maxPasses = config.loop.maxPasses, dryRun = false, proposeFn = propose, verifyFn = verify, judgeFn } = {}) { + const rangesNow = () => (where ? rangesFor(file, where) : null); + const original = fs.readFileSync(file, 'utf8'); + const valeBefore = valeGate(file).findings.length; + const score = () => scorePage(file, { ranges: rangesNow(), judgeFn }); + let current = await score(); + const before = problemCount(current); + if (current.pass) return { path: file, passes: 0, status: 'unchanged', findings_before: before, findings_after: 0, applied: 0, rejected: 0, suggestions: 0, review: [], patch: '' }; + + let passes = 0; + let feedback = ''; + let applied = 0; + let rejected = 0; + const held = []; // drops the meaning check doubted: not applied, offered as a patch + const review = []; // rewords that were applied although the meaning check doubted them + + while (!current.pass && passes < maxPasses) { + passes++; + const markdown = fs.readFileSync(file, 'utf8'); + const ctx = restrict(prepare(markdown), rangesNow()); + const matchers = flaggedMatchers([...current.deterministic.findings, ...current.judge.findings]); + + const proposed = [...fillerEdits(ctx), ...(await proposeFn(ctx, current, feedback))]; + const reasons = []; + const auto = []; + for (const e of proposed) { + const v = validateEdit(e, ctx, matchers); + if (!v.ok) { + rejected++; + reasons.push(`"${(e.before || '').slice(0, 50)}": ${v.reason}`); + if (process.env.QUALITY_VERBOSE) console.error(` rejected [${e.type}] ${v.reason}\n before: ${e.before}\n after: ${e.after}`); + } else auto.push(v); + } + + const toCheck = auto.filter(e => e.type !== 'swap'); + const verdicts = await verifyFn(toCheck.map((e, id) => ({ id, ...readable(ctx, e) }))); + const accepted = []; + auto.forEach(e => { + if (e.type === 'swap') return accepted.push(e); + const v = verdicts.get(toCheck.indexOf(e)); + if (v?.ok) accepted.push(e); + else if (e.type === 'reword') { + // The writer reviews the diff; flag the line so the doubt is not lost. + accepted.push(e); + review.push({ line: e.line, before: e.before, after: e.after, note: v?.difference || 'not certain' }); + if (process.env.QUALITY_VERBOSE) console.error(` reword applied, flagged [${v?.difference}]\n before: ${e.before}\n after: ${e.after}`); + } else { + rejected++; + held.push({ ...e, note: `meaning check: ${v?.difference || 'not certain'}` }); + if (process.env.QUALITY_VERBOSE) console.error(` held [${e.type}] ${v?.difference}\n before: ${e.before}\n after: ${e.after}`); + } + }); + + if (!accepted.length) { + feedback = reasons.slice(0, 6).join('; '); + continue; + } + + const candidate = applyEdits(ctx, accepted); + const violations = checkPreserved(markdown, candidate); + if (violations.length) { + feedback = `the result changed locked content (${violations[0]})`; + continue; + } + fs.writeFileSync(file, candidate); + const next = await score(); + const valeAfter = valeGate(file).findings.length; + if (valeAfter > valeBefore) { + fs.writeFileSync(file, markdown); + feedback = `the edits added ${valeAfter - valeBefore} Vale style finding(s)`; + continue; + } + if (next.deterministic.findings.length > current.deterministic.findings.length) { + fs.writeFileSync(file, markdown); + feedback = 'the edits made the AI-isms findings worse'; + continue; + } + applied += accepted.length; + feedback = reasons.slice(0, 6).join('; '); + current = next; + } + + const after = problemCount(current); + if (!current.pass && after >= before) { + fs.writeFileSync(file, original); + applied = 0; + review.length = 0; + } + + // Held edits become one patch against whatever the file is now. Ones whose text no longer matches are dropped. + let patch = ''; + let suggestions = 0; + if (!current.pass && held.length) { + const finalText = fs.readFileSync(file, 'utf8'); + const ctx = restrict(prepare(finalText), rangesNow()); + const relocated = [...new Map(held.map(h => [h.before, h])).values()] + .map(h => validateEdit({ type: 'reword', before: h.before, after: h.after }, ctx)) + .filter(v => v.ok); + if (relocated.length) { + suggestions = relocated.length; + patch = patchFor(file, applyEdits(ctx, relocated)); + } + } + if (dryRun) fs.writeFileSync(file, original); + return { + path: file, + passes, + status: current.pass ? 'pass' : 'stalled', + findings_before: before, + findings_after: current.pass ? 0 : Math.min(after, before), + applied, + rejected, + suggestions, + review, + patch, + }; +} + +async function main() { + const args = process.argv.slice(2); + const val = n => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined); + if (!hasApiKey()) { + console.error('No ANTHROPIC_API_KEY and no `claude` CLI on PATH; cannot run the rewrite loop.'); + process.exit(2); + } + const staged = args.includes('--staged'); + // Edits land in the working tree, so between passes changed lines are measured against HEAD, not the index. + const where = { mode: 'base', base: staged ? 'HEAD' : val('--base') || 'origin/dev' }; + let files = val('--files') + ? fs.readFileSync(val('--files'), 'utf8').split('\n').map(s => s.trim()).filter(Boolean).map(file => ({ file, ranges: null })) + : discoverChanges({ ...(staged ? { mode: 'staged' } : where), scope: config.scope }); + + const results = await mapPool(files, config.scope.concurrency ?? 3, async ({ file: f, ranges }) => { + const r = await fixPage(f, { where: ranges ? where : null, maxPasses: Number(val('--max-passes')) || config.loop.maxPasses, dryRun: args.includes('--dry-run') }); + console.log(`${r.status.padEnd(9)} ${r.path} passes=${r.passes} findings ${r.findings_before} -> ${r.findings_after} applied=${r.applied} rejected=${r.rejected} suggestions=${r.suggestions}`); + for (const x of r.review) console.log(` review ${r.path}:${x.line}: reworded; meaning check doubts it (${x.note})\n was: ${x.before}\n now: ${x.after}`); + return r; + }); + if (val('--summary')) fs.writeFileSync(val('--summary'), JSON.stringify({ results: results.map(({ patch, ...r }) => r) }, null, 2)); + const patch = results.map(r => r.patch).filter(Boolean).join('\n'); + if (val('--suggestions') && patch) fs.writeFileSync(val('--suggestions'), patch); + process.exit(results.some(r => r.status === 'stalled') ? 3 : 0); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/quality/gen-lint-digest.mjs b/scripts/quality/gen-lint-digest.mjs new file mode 100644 index 0000000000..2ef17d4a7a --- /dev/null +++ b/scripts/quality/gen-lint-digest.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +/** + * gen-lint-digest.mjs - writes .claude/references/lint-rules.md: every rule Vale, Dale, and the + * AI-isms check enforce, in plain language, so a writer (or the tech-writer agent) can draft + * wording that passes the first time. Generated from the rule files, so it cannot drift. + * + * node scripts/quality/gen-lint-digest.mjs # write the file + * node scripts/quality/gen-lint-digest.mjs --check # exit 1 if it is out of date + */ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; + +const OUT = '.claude/references/lint-rules.md'; +const readYml = f => yaml.load(fs.readFileSync(f, 'utf8')); +const plain = s => String(s).replace(/\\b/g, '').replace(/\\([.'()\-])/g, '$1').replace(/\(\?:/g, '(').replace(/\(\?i\)/g, '').trim(); +const msg = (m, term) => String(m || '').replace(/%s/g, term ?? ''); + +function vale() { + const dir = '.vale/styles/Netwrix'; + const lines = []; + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.yml')).sort()) { + const r = readYml(path.join(dir, f)); + const name = path.basename(f, '.yml'); + if (r.extends === 'substitution' && r.swap) { + const pairs = Object.entries(r.swap).slice(0, 12).map(([a, b]) => `${plain(a)} → ${b}`); + lines.push(`- **${name}**: ${msg(r.message, '')} Examples: ${pairs.join('; ')}${Object.keys(r.swap).length > 12 ? '; …' : ''}`); + } else if (r.extends === 'existence' && r.tokens) { + const toks = r.tokens.map(plain).filter(Boolean); + lines.push(`- **${name}**: ${msg(r.message, '')} Terms: ${toks.slice(0, 40).join(', ')}${toks.length > 40 ? `, … (${toks.length} in all)` : ''}`); + } else { + lines.push(`- **${name}**: ${msg(r.message, '')}`); + } + } + return lines; +} + +function dale() { + const dir = '.claude/skills/dale/rules'; + return fs.readdirSync(dir).filter(f => f.endsWith('.yml')).sort().map(f => { + const r = readYml(path.join(dir, f)); + return `- **${path.basename(f, '.yml')}**: ${String(r.reason).replace(/\s+/g, ' ').trim()} (Message: ${r.message})`; + }); +} + +function aiisms() { + const c = readYml('scripts/doc-draft/ai-isms.yml'); + return Object.keys(c.lexical); +} + +export function build() { + return [ + '# Lint rules at a glance', + '', + 'Generated by `scripts/quality/gen-lint-digest.mjs` from the Vale rules, the Dale rules, and the AI-isms word list. Do not edit by hand.', + 'Read this before drafting. Each rule below is checked before a PR: Vale and the word list by code, Dale by a model.', + '', + '## Vale (code, every page)', + '', + ...vale(), + '', + '## Dale (model-checked)', + '', + ...dale(), + '', + '## AI-isms word list (avoid these words and phrases)', + '', + aiisms().join(', ') + '.', + '', + '## AI-writing patterns the judge looks for', + '', + 'See `.claude/references/humanizer-rules.md`. In short: no inflated significance, no tacked-on "-ing" clauses, no promotional language, no unsourced attributions, no "not just X, but Y", no false ranges, and no synonym cycling.', + '', + ].join('\n'); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) { + const next = build(); + if (process.argv.includes('--check')) { + const cur = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; + if (cur !== next) { console.error(`${OUT} is out of date. Run: node scripts/quality/gen-lint-digest.mjs`); process.exit(1); } + console.log('No drift: lint-rules.md matches the rule files.'); + } else { + fs.writeFileSync(OUT, next); + console.log(`wrote ${OUT} (${next.split('\n').length} lines)`); + } +} diff --git a/scripts/quality/lib/dale.mjs b/scripts/quality/lib/dale.mjs new file mode 100644 index 0000000000..f5c529e65c --- /dev/null +++ b/scripts/quality/lib/dale.mjs @@ -0,0 +1,84 @@ +/** + * Dale: the model-checked style rules in .claude/skills/dale/rules/*.yml (passive voice, wordiness, idioms, + * misplaced modifiers, undefined acronyms, ...). A model reads the page against all the rules in one call. + * Like the AI-isms judge, it runs a few times and keeps findings that enough runs agree on; every finding + * must quote the page verbatim and sit on a changed line. Verdicts are cached by page content and rules. + */ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { getClient, textOf } from './llm.mjs'; +import { verdictKey, readCachedVerdict, writeVerdict, verifyFindings, consensus } from './judge.mjs'; +import { inRanges } from './scope.mjs'; + +const RULES_DIR = '.claude/skills/dale/rules'; +const config = JSON.parse(fs.readFileSync(new URL('../config.json', import.meta.url), 'utf8')); +const RUNS = config.dale?.runs ?? 3; +const AGREE = Math.min(config.dale?.agree ?? 2, RUNS); +// Phrases a maintainer has decided are fine for Dale. Applied after the verdict is read, so editing the list needs no new model call. +const IGNORE = (config.dale?.ignoreQuotes ?? []).map(p => new RegExp(p, 'i')); + +const SCHEMA = { + type: 'object', + properties: { + findings: { + type: 'array', + items: { + type: 'object', + properties: { rule: { type: 'string' }, quote: { type: 'string' }, reason: { type: 'string' } }, + required: ['rule', 'quote', 'reason'], + additionalProperties: false, + }, + }, + }, + required: ['findings'], + additionalProperties: false, +}; + +export function loadRules() { + return fs.readdirSync(RULES_DIR) + .filter(f => f.endsWith('.yml')) + .sort() + .map(f => ({ name: path.basename(f, '.yml'), ...yaml.load(fs.readFileSync(path.join(RULES_DIR, f), 'utf8')) })); +} + +const instructions = rules => + `You are Dale, a linter for Netwrix product documentation. Check the page against every rule below.\n` + + `Report a violation only when it clearly breaks a rule's reason and a technical writer would agree it should change; when unsure, do not report it. ` + + `For each violation give the rule name, the exact offending text copied verbatim from the page (one sentence at most), and a one-line reason. ` + + `Ignore frontmatter, code blocks, inline code, link URLs, tables of commands, bold text (UI labels the writer must reproduce exactly), and text in double quotes (verbatim product messages). Do not report the same text twice for one rule.\n\n` + + rules.map(r => `Rule "${r.name}": ${r.reason}\nMessage to the writer: ${r.message}`).join('\n\n'); + +/** One Dale run on a page, findings verified against the text and limited to known rules. No voting, no cache. */ +export async function daleOnce(markdown, { model, client = getClient() }) { + const rules = loadRules(); + const response = await client.messages.create({ + model, + max_tokens: 4000, + cache_control: { type: 'ephemeral' }, + system: instructions(rules), + messages: [{ role: 'user', content: markdown }], + output_config: { format: { type: 'json_schema', schema: SCHEMA } }, + }); + return verifyFindings(markdown, JSON.parse(textOf(response)).findings).filter(f => rules.some(r => r.name === f.rule)); +} + +/** @returns {Promise<{ pass: boolean, findings: object[], cached: boolean }>} */ +export async function daleCheck(markdown, ranges = null, { model, client = getClient(), useCache = true } = {}) { + const rules = loadRules(); + const system = instructions(rules); + const key = verdictKey(markdown, `dale\n${RUNS}/${AGREE}\n${system}`, model); + let verdict = useCache ? readCachedVerdict(key) : null; + const cached = Boolean(verdict); + if (!verdict) { + const runs = await Promise.all(Array.from({ length: RUNS }, () => daleOnce(markdown, { model, client }))); + const findings = consensus(runs, AGREE); + verdict = { pass: findings.length === 0, findings, model }; + if (useCache) writeVerdict(key, verdict); + } + const byRule = Object.fromEntries(rules.map(r => [r.name, r.message])); + const findings = verdict.findings + .filter(f => inRanges(f.line, ranges) && !IGNORE.some(re => re.test(f.quote))) + .map(f => ({ line: f.line, rule: `dale:${f.rule}`, message: byRule[f.rule] || f.reason, text: f.quote })); + return { pass: findings.length === 0, findings, cached }; +} diff --git a/scripts/quality/lib/edits.mjs b/scripts/quality/lib/edits.mjs new file mode 100644 index 0000000000..39bc81ac12 --- /dev/null +++ b/scripts/quality/lib/edits.mjs @@ -0,0 +1,332 @@ +/** + * Constrained edits. The model never returns a page. It returns small typed + * edits against masked sentences, and this module proves each one is safe + * before anything is written: + * + * drop removes one run of words the gates flagged; never a number, + * negation, modal, proper noun, or markup + * swap one word or phrase from a fixed table; the replacement text is + * computed here, not taken from the model + * reword free rewrite; applied after a meaning check, and flagged for review if the check doubts it + * + * Markup (inline code, links, bold, images, HTML) is masked as placeholders + * before the model sees the text, so it cannot be altered. + */ +import { lockedMask } from './preserve.mjs'; + +const PH = /⟦\d+⟧/g; +const TOKEN = /⟦\d+⟧|[\p{L}\p{N}]+(?:['’][\p{L}]+)*/gu; + +// Inline spans replaced with placeholders. Order matters: longest constructs first. +const MASKS = [ + /!\[[^\]]*\]\([^)]*\)/g, + /\[[^\]]*\]\([^)]*\)/g, + /`[^`\n]+`/g, + /\*\*[^*\n]+\*\*/g, + /<[^>\n]+>/g, + /https?:\/\/[^\s)]+/g, + /\{[^}\n]*\}/g, + /(? { + const m = new Map(); + for (const ch of t) if (!BASIC_CHARS.test(ch)) m.set(ch, (m.get(ch) || 0) + 1); + return m; + }; + const b = count(before); + for (const [ch, n] of count(after)) if (n > (b.get(ch) || 0)) return true; + return false; +} + +export const tokens = s => (s.match(TOKEN) || []); +const lower = s => tokens(s).map(t => t.toLowerCase()); +const eq = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); + +function sentencesOf(text) { + return text.split(/(?<=[.!?])\s+(?=[\p{Lu}⟦"“(])/u).filter(s => s.trim()); +} + +function mask(line) { + const spans = []; + let out = line; + for (const re of MASKS) { + out = out.replace(re, m => { + spans.push(m); + return `⟦${spans.length - 1}⟧`; + }); + } + return { masked: out, spans }; +} + +const unmask = (text, spans) => text.replace(PH, m => spans[Number(m.slice(1, -1))] ?? m); + +const PREFIX = /^(?:\s*(?:[-*+]|\d+[.)])\s+|\s*>\s?)*/; + +/** Split a page into editable lines with masked prose. Line numbers are 1-based and match the gates. */ +const ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/; +const BREAK = /(?:\s{2,}|\\)$/; + +/** + * Split a page into editable units. A unit is one paragraph or list item: + * hard-wrapped lines are joined so sentences that cross a line break are whole. + * `line` is the 1-based number of the unit's first line, matching the gates. + */ +export function prepare(markdown) { + const lines = markdown.split('\n'); + const locked = lockedMask(markdown); + const groups = []; + let cur = null; + lines.forEach((line, i) => { + if (locked[i] || !line.trim()) { + cur = null; + return; + } + if (cur && !ITEM.test(line) && !cur.broken) { + cur.indices.push(i); + } else { + cur = { indices: [i], broken: false }; + groups.push(cur); + } + if (BREAK.test(line)) cur.broken = true; + }); + + const editable = []; + for (const g of groups) { + const first = lines[g.indices[0]]; + const prefix = first.match(PREFIX)[0]; + const rest = g.indices.slice(1).map(i => lines[i].replace(/^\s*(?:>\s?)*/, '').trim()); + const flat = [first.slice(prefix.length).trim(), ...rest].join(' '); + const { masked, spans } = mask(flat); + if (!lower(masked).filter(t => !/^⟦/.test(t)).length) continue; + const second = g.indices[1] !== undefined ? lines[g.indices[1]].match(/^\s*(?:>\s?)*/)[0] : null; + const cont = second ?? (prefix.includes('>') ? prefix : ' '.repeat(prefix.length)); + const width = Math.max(100, ...g.indices.map(i => lines[i].length)); + editable.push({ indices: g.indices, index: g.indices[0], line: g.indices[0] + 1, prefix, cont, width, masked, spans }); + } + return { lines, editable }; +} + +/** Re-wrap a unit. Words are split on spaces in the masked text; each placeholder counts at its real length and is never broken. */ +function wrap(target, maskedText) { + const words = maskedText.split(' ').filter(Boolean); + const out = []; + let line = target.prefix; + let empty = true; + for (const w of words) { + const real = unmask(w, target.spans); + if (!empty && line.length + 1 + real.length > target.width) { + out.push(line); + line = target.cont + real; + } else { + line += (empty ? '' : ' ') + real; + } + empty = false; + } + out.push(line); + return out; +} + +/** The text sent to the model: one masked line per row, tagged with its line number. */ +export function promptView(ctx) { + return ctx.editable.map(e => `L${e.line}: ${e.masked}`).join('\n'); +} + +function findUnique(ctx, before) { + const hits = ctx.editable.filter(e => e.masked.includes(before)); + if (hits.length !== 1) return null; + if (hits[0].masked.split(before).length !== 2) return null; + return hits[0]; +} + +const capitalizedMidSentence = (sentence, span) => { + const all = sentence.match(TOKEN) || []; + return span.some(t => { + const idx = all.findIndex((x, i) => x.toLowerCase() === t && i > 0); + return idx > 0 && /^\p{Lu}/u.test(all[idx]); + }); +}; + +// Sentence-opening filler that carries no information. Removing it is safe without a finding. +const FILLER = ['obviously', 'clearly', 'of course', 'needless to say']; + +/** Drop edits for "Obviously, the ..." style openers, computed here so no model call is needed. */ +export function fillerEdits(ctx) { + const re = new RegExp(`(?:^|(?<=[.!?]\\s))(${FILLER.join('|')}),\\s+(\\p{L}\\S*)`, 'giu'); + const out = []; + for (const e of ctx.editable) { + for (const m of e.masked.matchAll(re)) { + const next = m[2]; + out.push({ type: 'drop', before: m[0], after: next[0].toUpperCase() + next.slice(1) }); + } + } + return out; +} + +/** Strings the gates flagged, as tests over a span of text. */ +export function flaggedMatchers(findings) { + const tests = [span => FILLER.includes(span)]; + for (const f of findings) { + if (f.quote) { + const q = lower(f.quote).join(' '); + tests.push(span => q.includes(span)); + } else if (f.rule?.startsWith('slop-lexical:')) { + try { + const re = new RegExp(f.rule.slice('slop-lexical:'.length), 'i'); + tests.push((span, raw) => re.test(raw)); + } catch { /* not a regex; skip */ } + } + } + return tests; +} + +/** Names and acronyms: capitalized words not opening a sentence, and all-caps words anywhere. */ +function properTokens(text) { + const out = []; + for (const sentence of sentencesOf(text)) { + (sentence.match(TOKEN) || []).forEach((t, i) => { + if (t.startsWith('⟦')) return; + if (/^\p{Lu}{2,}$/u.test(t) || (i > 0 && /\p{Lu}/u.test(t))) out.push(t); + }); + } + return out; +} + +function keepsCase(before, after) { + const have = new Map(); + for (const t of tokens(after)) have.set(t, (have.get(t) || 0) + 1); + for (const t of properTokens(before)) { + if (!have.get(t)) return false; + have.set(t, have.get(t) - 1); + } + return true; +} + +function fail(reason) { + return { ok: false, reason }; +} + +function sameRun(before, after) { + // Returns the removed run if `after` is `before` minus one contiguous run. + if (after.length >= before.length) return null; + let start = 0; + while (start < after.length && before[start] === after[start]) start++; + const removed = before.length - after.length; + const tail = before.slice(start + removed); + return eq(tail, after.slice(start)) ? { start, run: before.slice(start, start + removed) } : null; +} + +/** + * Validate one edit against the page. Returns { ok, reason } or, when valid, + * { ok: true, kind, line, before, after } with the final masked `after`. + */ +export function validateEdit(edit, ctx, matchers = []) { + const hit = edit.before ? findUnique(ctx, edit.before) : null; + if (!hit) return fail('"before" is not one exact, unique span of a single prose line'); + + const beforeSentences = sentencesOf(hit.masked); + const container = beforeSentences.find(s => s.includes(edit.before)) ?? edit.before; + const beforeTok = lower(edit.before); + + if (edit.type === 'swap') { + const from = (edit.from || '').toLowerCase().trim(); + const to = SWAPS.get(from); + if (!to) return fail(`"${edit.from}" is not in the approved swap table`); + const re = new RegExp(`\\b${from.replace(/\s+/g, '\\s+')}\\b`, 'i'); + const m = edit.before.match(re); + if (!m) return fail('swap word not in "before"'); + const cased = /^\p{Lu}/u.test(m[0]) ? to[0].toUpperCase() + to.slice(1) : to; + const after = edit.before.replace(re, cased); + return { ok: true, kind: 'auto', type: 'swap', line: hit.line, before: edit.before, after }; + } + + const after = (edit.after ?? '').replace(/\s{2,}/g, ' ').replace(/\s+([.,;:!?])/g, '$1').trim(); + if (edit.type !== 'drop' && !after.trim()) return fail('empty "after"'); + if (after && addsSymbols(edit.before, after)) return fail('"after" contains characters the checker does not allow (markup or symbols)'); + const afterTok = lower(after); + + // Placeholders must survive in the same order in every edit type. + const ph = a => a.filter(t => t.startsWith('⟦')); + if (!eq(ph(beforeTok), ph(afterTok))) return fail('inline code, links, bold text, or images were changed'); + + if (edit.type === 'reword') { + // A free rewrite may not lose a number, a negation, or a requirement or condition word. + const count = (toks, keep) => toks.filter(keep).reduce((m, t) => m.set(t, (m.get(t) || 0) + 1), new Map()); + const guarded = t => /\d/.test(t) || NEGATIONS.has(t) || MODALS.has(t) || /n['’]t$/.test(t); + const need = count(beforeTok, guarded); + const have = count(afterTok, guarded); + for (const [t, n] of need) if ((have.get(t) || 0) < n) return fail(`reword drops "${t}", which can carry meaning`); + return { ok: true, kind: 'auto', type: 'reword', line: hit.line, before: edit.before, after }; + } + + if (edit.type === 'drop') { + const res = sameRun(beforeTok, afterTok); + if (!res) return fail('drop must remove exactly one contiguous run of words'); + const { run } = res; + if (run.length > 15) return fail('drop removes more than 15 words'); + const bad = run.find(t => /\d/.test(t) || t.startsWith('⟦') || NEGATIONS.has(t) || MODALS.has(t) || /n['’]t$/.test(t)); + if (bad) return fail(`drop would remove "${bad}", which can carry meaning`); + if (capitalizedMidSentence(container, run)) return fail('drop would remove a capitalized name'); + if (!keepsCase(container, after ? container.replace(edit.before, after) : container.replace(edit.before, ''))) return fail('drop changed the capitalization of a name or acronym'); + const span = run.join(' '); + const rawSpan = run.join(' '); + if (!matchers.some(t => t(span, rawSpan))) return fail('removed words were not flagged by the gates'); + const remaining = hit.masked.replace(edit.before, after); + if (!lower(remaining).filter(t => !t.startsWith('⟦')).length) return fail('drop would empty the line'); + return { ok: true, kind: 'auto', type: 'drop', line: hit.line, before: edit.before, after }; + } + + return fail(`unknown edit type "${edit.type}"`); +} + +/** Apply validated edits. Text outside the edited spans is never touched. Returns the new page. */ +export function applyEdits(ctx, edits) { + const byLine = new Map(); + for (const e of edits) { + const target = ctx.editable.find(x => x.line === e.line); + const list = byLine.get(target.index) ?? { target, items: [] }; + list.items.push(e); + byLine.set(target.index, list); + } + const replace = new Map(); + const drop = new Set(); + for (const [index, { target, items }] of byLine) { + let text = target.masked; + for (const e of items) { + if (text.split(e.before).length !== 2) continue; // overlapped by an earlier edit in the same unit + text = text.replace(e.before, () => e.after); + } + if (text === target.masked) continue; + replace.set(index, wrap(target, text)); + target.indices.slice(1).forEach(i => drop.add(i)); + } + const out = []; + ctx.lines.forEach((line, i) => { + if (drop.has(i)) return; + if (replace.has(i)) out.push(...replace.get(i)); + else out.push(line); + }); + return out.join('\n'); +} + +/** Unmasked before/after for a verified edit, for the meaning check and for suggestions. */ +export function readable(ctx, e) { + const target = ctx.editable.find(x => x.line === e.line); + return { before: unmask(e.before, target.spans), after: unmask(e.after, target.spans) }; +} diff --git a/scripts/quality/lib/gates.mjs b/scripts/quality/lib/gates.mjs new file mode 100644 index 0000000000..0b71d77656 --- /dev/null +++ b/scripts/quality/lib/gates.mjs @@ -0,0 +1,17 @@ +import fs from 'fs'; +import { runSlopGate } from '../../doc-draft/slop.mjs'; +import { inRanges } from './scope.mjs'; + +/** + * Deterministic AI-isms gate for one page (slop.mjs). `ranges` limits an + * existing page to its changed lines: only lexical hits inside them count, + * and page-wide structural signals are skipped because they describe text + * the author didn't write. A new page (`ranges` null) gets everything. + */ +export function scoreDeterministic(file, _config, markdown = fs.readFileSync(file, 'utf8'), ranges = null) { + const slop = runSlopGate(file, markdown); + const findings = ranges === null + ? slop.findings + : slop.findings.filter(f => f.rule?.startsWith('slop-lexical:') && inRanges(f.line, ranges)); + return { pass: findings.length === 0, metrics: { slop: slop.metrics }, findings }; +} diff --git a/scripts/quality/lib/judge.mjs b/scripts/quality/lib/judge.mjs new file mode 100644 index 0000000000..351dd6fd8d --- /dev/null +++ b/scripts/quality/lib/judge.mjs @@ -0,0 +1,146 @@ +/** + * Model judge for the 16 patterns in .claude/references/humanizer-rules.md. + * + * Verdicts are cached by sha256(page + rubric + model). The rewrite loop + * stores the verdict for the version it ends on, so the PR check reads the + * same answer instead of asking a model again and possibly disagreeing. + * A finding only counts if its quote appears verbatim in the page. + */ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { getClient, textOf } from './llm.mjs'; + +const config = JSON.parse(fs.readFileSync(new URL('../config.json', import.meta.url), 'utf8')); +// Phrases a maintainer has decided are fine. Matched against each finding's quote after the verdict is read, so editing the list needs no new model call. +const IGNORE = (config.judge?.ignoreQuotes ?? []).map(p => new RegExp(p, 'i')); +// Findings whose reason cites something the rubric does not contain (passive voice is Vale's job, not a pattern here). +const IGNORE_REASONS = (config.judge?.ignoreReasons ?? []).map(p => new RegExp(p, 'i')); +const applyIgnore = v => { + const findings = v.findings.filter(f => !IGNORE.some(re => re.test(f.quote)) && !IGNORE_REASONS.some(re => re.test(f.reason))); + return { ...v, findings, pass: findings.length === 0 }; +}; + +const RUNS = config.judge?.runs ?? 1; +const AGREE = Math.min(config.judge?.agree ?? 1, RUNS); + +const RUBRIC_PATH = '.claude/references/humanizer-rules.md'; +const CACHE_DIR = () => process.env.QUALITY_CACHE_DIR || '.cache/quality'; + +const SCHEMA = { + type: 'object', + properties: { + findings: { + type: 'array', + items: { + type: 'object', + properties: { + pattern: { type: 'integer' }, + quote: { type: 'string' }, + reason: { type: 'string' }, + }, + required: ['pattern', 'quote', 'reason'], + additionalProperties: false, + }, + }, + }, + required: ['findings'], + additionalProperties: false, +}; + +const norm = s => s.replace(/\s+/g, ' ').trim(); +const sha = s => crypto.createHash('sha256').update(s).digest('hex'); + +const INSTRUCTIONS = + `You judge Netwrix product documentation for signs of AI-generated writing, using only the rubric below.\n` + + `Report an instance only when it clearly matches a numbered pattern and a technical writer would agree it should change. When unsure, do not report it. ` + + `Ordinary technical usage is not a finding: "key" meaning important in a plain factual statement or a cryptographic, API, or registry key; hedged advice such as "it is generally recommended that" when the page is giving real guidance; and common words used literally. ` + + `Patterns 1 and 14 apply to inflated or empty wording, such as promotional praise or a sentence that adds nothing, not to plain statements of importance or advice. ` + + `For each finding, give the pattern number, the exact offending text copied verbatim from the page (one sentence at most), and a one-line reason. ` + + `Report only what a numbered pattern in the rubric describes; passive voice and other style points are not patterns. Do not report anything the rubric assigns to the statistical gate. If the page has no instances, return an empty findings array.`; + +export function verdictKey(markdown, rubric, model) { + return sha(`${model}\n${RUNS}/${AGREE}\n${sha(INSTRUCTIONS)}\n${sha(rubric)}\n${sha(markdown)}`); +} + +export function readCachedVerdict(key) { + try { + return JSON.parse(fs.readFileSync(path.join(CACHE_DIR(), `${key}.json`), 'utf8')); + } catch { + return null; + } +} + +export function writeVerdict(key, verdict) { + fs.mkdirSync(CACHE_DIR(), { recursive: true }); + fs.writeFileSync(path.join(CACHE_DIR(), `${key}.json`), JSON.stringify(verdict)); +} + +/** Drop findings whose quote is not in the page, and attach a line number. */ +export function verifyFindings(markdown, findings) { + const lines = markdown.split('\n'); + const flat = norm(markdown); + return findings + .filter(f => f.quote && flat.includes(norm(f.quote))) + .map(f => { + const needle = norm(f.quote).slice(0, 40); + const idx = lines.findIndex(l => norm(l).includes(needle)); + return { ...f, line: idx === -1 ? 0 : idx + 1 }; + }); +} + +const words = q => new Set(norm(q).toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').split(' ').filter(Boolean)); + +/** Two quotes point at the same text when one contains most of the other's words, whatever pattern number each run gave it. */ +export function sameSpot(a, b) { + const x = words(a.quote); + const y = words(b.quote); + const shared = [...x].filter(w => y.has(w)).length; + return shared >= 0.6 * Math.min(x.size, y.size); +} + +/** Keep findings that at least `agree` of the runs reported at the same spot. The first run's wording is used. */ +export function consensus(runs, agree) { + if (agree <= 1) return runs.flat().filter((f, i, all) => all.findIndex(g => sameSpot(f, g)) === i); + const kept = []; + for (const f of runs.flat()) { + if (kept.some(k => sameSpot(k, f))) continue; + const votes = runs.filter(r => r.some(g => sameSpot(f, g))).length; + if (votes >= agree) kept.push(f); + } + return kept; +} + +/** One judge run on a page, with its findings verified against the text. No voting, no cache. */ +export async function judgeOnce(markdown, { model, client = getClient() }) { + const rubric = fs.readFileSync(RUBRIC_PATH, 'utf8'); + const response = await client.messages.create({ + model, + max_tokens: 4000, + cache_control: { type: 'ephemeral' }, + system: `${INSTRUCTIONS}\n\n${rubric}`, + messages: [{ role: 'user', content: markdown }], + output_config: { format: { type: 'json_schema', schema: SCHEMA } }, + }); + return verifyFindings(markdown, JSON.parse(textOf(response)).findings); +} + +/** + * @param {string} markdown + * @param {{ model: string, client?: any, useCache?: boolean }} opts + * @returns {Promise<{ pass: boolean, findings: object[], cached: boolean, key: string }>} + */ +export async function judgePage(markdown, { model, client = getClient(), useCache = true }) { + const rubric = fs.readFileSync(RUBRIC_PATH, 'utf8'); + const key = verdictKey(markdown, rubric, model); + if (useCache) { + const hit = readCachedVerdict(key); + if (hit) return { ...applyIgnore(hit), cached: true, key }; + } + + const runs = await Promise.all(Array.from({ length: RUNS }, () => judgeOnce(markdown, { model, client }))); + const findings = consensus(runs, AGREE); + const verdict = { pass: findings.length === 0, findings, model }; + if (useCache) writeVerdict(key, verdict); + return { ...applyIgnore(verdict), cached: false, key }; +} diff --git a/scripts/quality/lib/llm.mjs b/scripts/quality/lib/llm.mjs new file mode 100644 index 0000000000..dd08d20c33 --- /dev/null +++ b/scripts/quality/lib/llm.mjs @@ -0,0 +1,95 @@ +import { spawn, spawnSync } from 'node:child_process'; +import os from 'node:os'; +import Anthropic from '@anthropic-ai/sdk'; + +let client; + +const hasKey = () => Boolean(process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN); + +let cli; +/** The Claude Code CLI on PATH, signed in as the user. Used when there is no API key. */ +export function hasClaudeCli() { + cli ??= spawnSync('claude', ['--version'], { stdio: 'ignore' }).status === 0; + return cli; +} + +/** True when a model can be called: an API key, or the user's Claude Code login. */ +export function hasApiKey() { + return hasKey() || hasClaudeCli(); +} + +/** + * Stand-in for the SDK's messages.create that runs `claude -p`. It covers what the + * quality scripts use: system, one user message, and a JSON schema. It runs from the + * temp dir with no settings, tools, or skills, so no project hooks fire (no recursion) + * and the model sees only the prompt. + */ +// Many pages times several runs each would start dozens of `claude -p` processes at once; cap them. +const MAX_CLI = 6; +let running = 0; +const waiting = []; +const acquire = () => (running < MAX_CLI ? (running++, Promise.resolve()) : new Promise(r => waiting.push(r))); +const release = () => (waiting.length ? waiting.shift()() : running--); + +const cliClient = { + messages: { + async create(params) { + await acquire(); + try { + // A `claude -p` process occasionally returns nothing (timeout, rate limit); one retry clears most of those. + try { + return await runCli(params); + } catch { + return await runCli(params); + } + } finally { + release(); + } + }, + }, +}; + +function runCli({ model, system, messages, output_config }) { + const args = [ + '-p', '--model', model, '--setting-sources', '', '--tools', '', '--disable-slash-commands', + '--no-session-persistence', '--output-format', 'json', '--system-prompt', system, + ]; + const schema = output_config?.format?.schema; + if (schema) args.push('--json-schema', JSON.stringify(schema)); + return new Promise((resolve, reject) => { + const child = spawn('claude', args, { + cwd: os.tmpdir(), + env: { ...process.env, NWX_QUALITY_CHILD: '1' }, + }); + let out = ''; + let err = ''; + const timer = setTimeout(() => child.kill(), 180_000); + child.stdout.on('data', d => (out += d)); + child.stderr.on('data', d => (err += d)); + child.on('error', e => { clearTimeout(timer); reject(e); }); + child.on('close', code => { + clearTimeout(timer); + try { + const j = JSON.parse(out); + if (code !== 0 || j.is_error) throw new Error(j.result || `claude exited ${code}`); + const text = j.structured_output ? JSON.stringify(j.structured_output) : j.result; + resolve({ content: [{ type: 'text', text }] }); + } catch (e) { + reject(new Error(`claude -p failed: ${e.message} ${err.slice(0, 200)}`)); + } + }); + child.stdin.end(messages.map(m => m.content).join('\n\n')); + }); +} + +export function getClient() { + if (hasKey()) return (client ??= new Anthropic()); + return cliClient; +} + +export function textOf(response) { + return response.content + .filter(b => b.type === 'text') + .map(b => b.text) + .join(''); +} diff --git a/scripts/quality/lib/pool.mjs b/scripts/quality/lib/pool.mjs new file mode 100644 index 0000000000..215d5c0794 --- /dev/null +++ b/scripts/quality/lib/pool.mjs @@ -0,0 +1,13 @@ +/** Run `fn` over `items` with at most `limit` in flight. Results keep the input order. */ +export async function mapPool(items, limit, fn) { + const out = new Array(items.length); + let next = 0; + const worker = async () => { + while (next < items.length) { + const i = next++; + out[i] = await fn(items[i], i); + } + }; + await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker)); + return out; +} diff --git a/scripts/quality/lib/preserve.mjs b/scripts/quality/lib/preserve.mjs new file mode 100644 index 0000000000..d8e14806bd --- /dev/null +++ b/scripts/quality/lib/preserve.mjs @@ -0,0 +1,77 @@ +/** + * Preservation check for a rewritten page. The model may reword prose; it may + * not touch anything a reader depends on. Returns a list of violations + * (empty means the rewrite is safe to accept). + */ + +const FENCE = /^\s*(```|~~~)/; + +/** True for each line the model may never touch: frontmatter, fences, headings, tables, :::, imports, JSX/HTML. */ +export function lockedMask(markdown) { + const lines = markdown.split('\n'); + const mask = new Array(lines.length).fill(false); + let i = 0; + if (lines[0]?.trim() === '---') { + let j = 1; + while (j < lines.length && lines[j].trim() !== '---') j++; + for (; i <= j && i < lines.length; i++) mask[i] = true; + } + let inFence = false; + for (; i < lines.length; i++) { + const line = lines[i]; + if (FENCE.test(line)) { + inFence = !inFence; + mask[i] = true; + } else if (inFence || /^#{1,6}\s/.test(line) || /^\s*\|/.test(line) || /^\s*:::/.test(line) || /^\s*(import|export)\s/.test(line) || /^\s* mask[i]), + prose: lines.filter((_, i) => !mask[i]).join('\n'), + }; +} + +const counts = arr => arr.reduce((m, x) => m.set(x, (m.get(x) || 0) + 1), new Map()); + +function missing(before, after, label, out) { + const a = counts(after); + for (const [k, n] of counts(before)) { + if ((a.get(k) || 0) < n) out.push(`${label} removed or changed: ${k}`); + } +} + +function extract(prose) { + const inlineCode = prose.match(/`[^`\n]+`/g) || []; + const linkTargets = [...prose.matchAll(/\]\(([^)\s]+)/g)].map(m => m[1]); + const bold = prose.match(/\*\*[^*\n]+\*\*/g) || []; + const stripped = prose + .replace(/`[^`\n]+`/g, ' ') + .replace(/\]\([^)]*\)/g, ']') + .replace(/^\s*\d+\.\s/gm, ''); + const numbers = stripped.match(/\d+(?:[.,]\d+)*/g) || []; + return { inlineCode, linkTargets, bold, numbers }; +} + +export function checkPreserved(original, rewritten) { + const violations = []; + const a = split(original); + const b = split(rewritten); + if (a.locked.length !== b.locked.length || a.locked.some((l, i) => l !== b.locked[i])) { + violations.push('frontmatter, headings, tables, code blocks, admonition markers, imports, or JSX lines changed'); + } + const x = extract(a.prose); + const y = extract(b.prose); + missing(x.inlineCode, y.inlineCode, 'inline code', violations); + missing(x.linkTargets, y.linkTargets, 'link target', violations); + missing(x.bold, y.bold, 'bold text (UI label)', violations); + missing(x.numbers, y.numbers, 'number', violations); + missing(y.numbers, x.numbers, 'number added', violations); + return violations; +} diff --git a/scripts/quality/lib/scope.mjs b/scripts/quality/lib/scope.mjs new file mode 100644 index 0000000000..ae4d7c8770 --- /dev/null +++ b/scripts/quality/lib/scope.mjs @@ -0,0 +1,91 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; + +const git = (args, opts = {}) => + execFileSync('git', ['-c', 'diff.renameLimit=100000', ...args], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + ...opts, + }); + +export function isInScope(file, scope) { + if (!/\.mdx?$/.test(file) || !file.startsWith('docs/')) return false; + return !scope.exclude.some(ex => (ex.startsWith('/') ? file.endsWith(ex) : file.startsWith(ex))); +} + +const DIFF_ARGS = ['diff', '--no-color', '-M50%', '-C50%', '--find-copies-harder', '-U0']; + +/** + * Parse `git diff -U0` output into { file, ranges }. `ranges` is null for a + * brand-new page (check all of it) and a list of [start, end] added-line + * ranges (1-based, inclusive) for a page that already existed. A page git + * detects as a copy or rename of another page (>= 50% similar) is diffed + * against that source, so a cloned version directory only yields the lines + * that differ from the release it was cloned from. Pure deletions and + * unchanged renames yield no ranges and are dropped. + */ +export function parseDiff(text) { + const out = []; + for (const block of text.split(/^(?=diff --git )/m).filter(Boolean)) { + const file = block.match(/^diff --git a\/.+ b\/(.+)$/m)?.[1]; + if (!file) continue; + if (/^new file mode/m.test(block)) { + out.push({ file, ranges: null }); + continue; + } + const ranges = []; + for (const m of block.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) { + const start = Number(m[1]); + const count = m[2] === undefined ? 1 : Number(m[2]); + if (count > 0) ranges.push([start, start + count - 1]); + } + if (ranges.length) out.push({ file, ranges }); + } + return out; +} + +/** + * Documentation this change touches, with what to check in each page. + * staged: what is staged for the next commit (pre-commit hook) + * base: everything on this branch since it left `base`, including + * uncommitted and untracked work (Claude Code hook) + */ +export function discoverChanges({ mode = 'base', base = 'origin/dev', scope }) { + let diff; + const untracked = []; + if (mode === 'staged') { + diff = git([...DIFF_ARGS, '--cached', '--', 'docs']); + } else { + const mergeBase = mergeBaseOf(base); + diff = git([...DIFF_ARGS, mergeBase, '--', 'docs']); + untracked.push(...git(['ls-files', '--others', '--exclude-standard', '--', 'docs']).split('\n').filter(Boolean)); + } + const byFile = new Map(parseDiff(diff).map(c => [c.file, c])); + for (const f of untracked) byFile.set(f, { file: f, ranges: null }); + return [...byFile.values()] + .filter(c => isInScope(c.file, scope) && fs.existsSync(c.file)) + .sort((a, b) => a.file.localeCompare(b.file)); +} + +/** Current changed-line ranges for one file, or null when the page is new. Used between rewrite passes, since edits shift line numbers. */ +export function rangesFor(file, { mode = 'base', base = 'origin/dev' }) { + const hit = discoverChanges({ mode, base, scope: { exclude: [] } }).find(c => c.file === file); + return hit ? hit.ranges : []; +} + +function mergeBaseOf(base) { + for (const ref of [base, 'dev', 'origin/main']) { + try { + return git(['merge-base', ref, 'HEAD']).trim(); + } catch { /* try the next ref */ } + } + return 'HEAD'; +} + +export const inRanges = (line, ranges) => ranges === null || ranges.some(([a, b]) => line >= a && line <= b); + +/** Keep only the changes whose path is on a list (one repo-relative path per line). */ +export function onlyListed(changes, listText) { + const wanted = new Set(listText.split('\n').map(l => l.trim()).filter(Boolean)); + return changes.filter(c => wanted.has(c.file)); +} diff --git a/scripts/quality/lib/vale.mjs b/scripts/quality/lib/vale.mjs new file mode 100644 index 0000000000..abaca36ef2 --- /dev/null +++ b/scripts/quality/lib/vale.mjs @@ -0,0 +1,38 @@ +/** + * Vale findings for a page, from the text itself (so a staged version can be checked), restricted to + * the changed lines of an existing page. Uses the repo's .vale.ini, so a writer sees the same rules in + * their editor. Never fixes anything. + */ +import { spawnSync } from 'child_process'; +import { inRanges } from './scope.mjs'; + +export const VALE_INSTALL = 'Vale is not installed. Install it (macOS: brew install vale; Linux: sudo snap install vale; Windows: choco install vale; or download from https://github.com/errata-ai/vale/releases), then commit again.'; + +let available; +export function valeAvailable() { + available ??= spawnSync('vale', ['--version'], { stdio: 'ignore' }).status === 0; + return available; +} + +/** + * @param {string} markdown + * @param {[number, number][] | null} ranges null = whole page + * @param {{ ignore?: string[] }} opts Vale check names to skip, e.g. "Netwrix.FirstPerson" + * @returns {{ status: 'ok' | 'missing' | 'error', findings: object[], error?: string }} + */ +export function runVale(markdown, ranges = null, { ignore = [] } = {}) { + if (!valeAvailable()) return { status: 'missing', findings: [] }; + const res = spawnSync('vale', ['--output=JSON', '--ext=.md'], { input: markdown, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + let parsed; + try { + parsed = JSON.parse(res.stdout || '{}'); + } catch { + return { status: 'error', findings: [], error: (res.stderr || res.stdout || 'vale failed').slice(0, 300) }; + } + const findings = Object.values(parsed) + .flat() + .filter(a => a && a.Check && !ignore.includes(a.Check) && inRanges(a.Line, ranges)) + .map(a => ({ line: a.Line, rule: `vale:${a.Check}`, message: a.Message, text: a.Match || '' })) + .sort((a, b) => a.line - b.line); + return { status: 'ok', findings }; +} diff --git a/scripts/quality/lib/verify.mjs b/scripts/quality/lib/verify.mjs new file mode 100644 index 0000000000..e023249dcb --- /dev/null +++ b/scripts/quality/lib/verify.mjs @@ -0,0 +1,57 @@ +/** + * Independent meaning check. A separate model call sees only the original and + * edited sentence, never the rewriter's reasoning, and must say the meaning is + * identical and that it is certain. Anything else rejects the edit. + */ +import { getClient, textOf } from './llm.mjs'; + +const SCHEMA = { + type: 'object', + properties: { + verdicts: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer' }, + same_meaning: { type: 'boolean' }, + certain: { type: 'boolean' }, + difference: { type: 'string' }, + }, + required: ['id', 'same_meaning', 'certain', 'difference'], + additionalProperties: false, + }, + }, + }, + required: ['verdicts'], + additionalProperties: false, +}; + +const SYSTEM = + `You compare pairs of sentences from software documentation. For each pair, decide whether the second sentence says exactly what the first says. ` + + `Same meaning requires all of: the same actions, the same conditions, the same requirements and permissions, the same scope and quantities, and the same referents for every pronoun. ` + + `Set same_meaning to false if anything is added, removed, weakened, strengthened, or made ambiguous. Set certain to false if you have any doubt. ` + + `In "difference", name the change in a few words, or write "none".`; + +/** @param {{id:number,before:string,after:string}[]} pairs @returns {Promise>} */ +export async function verifyMeaning(pairs, { model, client = getClient() }) { + const out = new Map(); + if (!pairs.length) return out; + const response = await client.messages.create({ + model, + max_tokens: 4000, + system: SYSTEM, + messages: [ + { + role: 'user', + content: pairs.map(p => `#${p.id}\nFIRST: ${p.before}\nSECOND: ${p.after}`).join('\n\n'), + }, + ], + output_config: { format: { type: 'json_schema', schema: SCHEMA } }, + }); + const parsed = JSON.parse(textOf(response)); + for (const v of parsed.verdicts) out.set(v.id, { ok: v.same_meaning && v.certain, difference: v.difference }); + // A pair the verifier did not answer is not verified. + for (const p of pairs) if (!out.has(p.id)) out.set(p.id, { ok: false, difference: 'no verdict returned' }); + return out; +} diff --git a/scripts/quality/readability.mjs b/scripts/quality/readability.mjs new file mode 100644 index 0000000000..ca5533bb30 --- /dev/null +++ b/scripts/quality/readability.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * readability.mjs - optional readability report. Not a gate and not a rewrite + * target: nothing in CI or the pre-commit hook runs it, and agents do not revise + * a draft to meet it (that risks changing meaning). A writer who wants the + * numbers runs it; the reference points are in config.json. + * + * node scripts/quality/readability.mjs [...] + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { scoreProse } from '../doc-draft/readability.mjs'; +import { extractProse } from '../doc-draft/lib/prose.mjs'; + +const config = JSON.parse(fs.readFileSync(new URL('./config.json', import.meta.url), 'utf8')); + +const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/; + +/** + * Prose for scoring, with every paragraph, list item, and heading closed by + * terminal punctuation. splitSentences() only breaks at ". Capital", so an + * unpunctuated bullet or heading would otherwise run into the next block and + * count as one very long sentence. + */ +export function sentenceBounded(text) { + const raw = text.split('\n').map(l => l.replace(/\s+$/, '')); + return raw + .map((l, i) => { + if (!l.trim()) return l; + const stripped = l.replace(/^(\s*)(?:(?:[-*+]|\d+[.)])\s+)?(?:\*\*|__|\*|_)?(?=\S)/, '$1'); + const next = raw[i + 1]; + const endsBlock = next === undefined || !next.trim() || LIST_ITEM.test(next) || /^\s*(?:\*\*|__)/.test(next); + if (!endsBlock || /[.!?]["')\]]*$/.test(stripped)) return stripped; + return `${stripped.replace(/[:;,]$/, '')}.`; + }) + .join('\n'); +} + + +export function checkReadability(markdown) { + const t = config.readability; + const m = scoreProse(sentenceBounded(extractProse(markdown).text)); + const problems = []; + if (m.fleschReadingEase < t.fleschMin) problems.push(`Flesch ${m.fleschReadingEase.toFixed(1)} is below ${t.fleschMin}`); + if (m.meanSentenceWords > t.meanSentenceWordsMax) problems.push(`mean sentence ${m.meanSentenceWords.toFixed(1)} words exceeds ${t.meanSentenceWordsMax}`); + if (m.longSentenceSharePct > t.longSentenceSharePctMax) problems.push(`${m.longSentenceSharePct.toFixed(1)}% of sentences run over 30 words; the limit is ${t.longSentenceSharePctMax}%`); + return { metrics: m, problems }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + let bad = 0; + for (const f of process.argv.slice(2)) { + const { metrics: m, problems } = checkReadability(fs.readFileSync(f, 'utf8')); + console.log(`${problems.length ? 'BELOW ' : 'OK '} ${f} Flesch ${m.fleschReadingEase.toFixed(1)}, mean ${m.meanSentenceWords} words, ${m.longSentenceSharePct}% long`); + problems.forEach(p => console.log(` ${p}`)); + bad += problems.length ? 1 : 0; + } + process.exit(0); // a report, not a gate +} diff --git a/scripts/quality/score.mjs b/scripts/quality/score.mjs new file mode 100644 index 0000000000..91a92f844a --- /dev/null +++ b/scripts/quality/score.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * score.mjs - the pre-PR check for documentation a change adds or edits: AI-isms, Vale, and Dale. + * + * node scripts/quality/score.mjs [--staged | --base origin/dev] [--files list.txt] + * [--file ...] [--no-judge | --require-judge] + * [--no-lint] [--vale-optional] [--only list.txt] [--table] + * + * A new page is checked whole. An existing page is checked only on the lines + * the change adds or rewrites; text it already had is left alone. slop.mjs + * runs first; the model judge runs only on a page that passes it, and its + * verdict is cached by page content. Exit 0 = pass, 1 = fail. + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { execFileSync } from 'child_process'; +import { discoverChanges, inRanges, onlyListed } from './lib/scope.mjs'; +import { scoreDeterministic } from './lib/gates.mjs'; +import { judgePage } from './lib/judge.mjs'; +import { daleCheck } from './lib/dale.mjs'; +import { runVale, VALE_INSTALL } from './lib/vale.mjs'; +import { hasApiKey } from './lib/llm.mjs'; +import { mapPool } from './lib/pool.mjs'; + +const config = JSON.parse(fs.readFileSync(new URL('./config.json', import.meta.url), 'utf8')); + +export async function scorePage(file, { ranges = null, judge = true, requireJudge = false, judgeFn, daleFn, lint = false, valeOptional = false, markdown = fs.readFileSync(file, 'utf8') } = {}) { + const det = scoreDeterministic(file, config, markdown, ranges); + const result = { + path: file, + deterministic: det, + vale: { status: 'skipped', findings: [] }, + judge: { status: 'skipped', findings: [] }, + dale: { status: 'skipped', findings: [] }, + }; + let pass = det.pass; + + // Style lint (Vale is instant, so it runs with the other code checks before any model is called). + if (lint) { + const v = runVale(markdown, ranges, { ignore: config.vale?.ignore }); + if (v.status === 'missing' && !valeOptional) { + result.vale = { status: 'missing', findings: [{ line: 0, rule: 'vale:missing', message: VALE_INSTALL }] }; + pass = false; + } else if (v.status === 'missing') { + result.vale = { status: 'skipped: not installed', findings: [] }; + } else if (v.status === 'error') { + result.vale = { status: 'error', findings: [{ line: 0, rule: 'vale:error', message: v.error }] }; + pass = false; + } else { + result.vale = { status: v.findings.length ? 'fail' : 'pass', findings: v.findings }; + if (v.findings.length) pass = false; + } + } + if (!pass) return { ...result, pass: false }; + + if (!judge) return { ...result, pass: true }; + if (!hasApiKey()) { + result.judge.status = requireJudge ? 'unavailable' : 'skipped'; + if (lint) result.dale.status = result.judge.status; + return { ...result, pass: !requireJudge }; + } + // The two model checks are independent, so they run together. + const [v, d] = await Promise.all([ + (judgeFn || judgePage)(markdown, { model: config.models.judge }), + lint ? (daleFn || daleCheck)(markdown, ranges, { model: config.models.dale }) : Promise.resolve(null), + ]); + // The judge reads the whole page for context; only findings on changed lines count. + const findings = v.findings.filter(f => inRanges(f.line, ranges)); + result.judge = { status: findings.length === 0 ? (v.cached ? 'cached-pass' : 'pass') : 'fail', findings }; + if (d) result.dale = { status: d.findings.length === 0 ? (d.cached ? 'cached-pass' : 'pass') : 'fail', findings: d.findings }; + return { ...result, pass: result.judge.findings.length === 0 && result.dale.findings.length === 0 }; +} + +/** The version of a file in the index. */ +function stagedText(file) { + return execFileSync('git', ['show', `:${file}`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} + +async function main() { + const args = process.argv.slice(2); + const flag = n => args.includes(n); + const val = n => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined); + const base = val('--base') || 'origin/dev'; + // --staged checks what the commit will contain, not the working tree, so `git add -p` and later edits cannot slip past it. + const fileArgs = args.flatMap((a, i) => (a === '--file' ? [args[i + 1]] : [])); + const staged = flag('--staged') && !val('--files') && !fileArgs.length; + + let files = fileArgs.length + ? fileArgs.map(file => ({ file, ranges: null })) + : val('--files') + ? fs.readFileSync(val('--files'), 'utf8').split('\n').map(s => s.trim()).filter(Boolean).map(file => ({ file, ranges: null })) + : discoverChanges({ mode: flag('--staged') ? 'staged' : 'base', base, scope: config.scope }); + // --only : keep just the files on the list (the Stop hook passes the files this session edited). + if (val('--only')) { + const list = fs.existsSync(val('--only')) ? fs.readFileSync(val('--only'), 'utf8') : ''; + files = onlyListed(files, list); + } + + // Every file is checked. The word-list check is instant; the judge, which is slow, runs a few pages at a time. + let done = 0; + const results = await mapPool(files, config.scope.concurrency ?? 3, async ({ file: f, ranges }) => { + const r = await scorePage(f, { ranges, judge: !flag('--no-judge'), requireJudge: flag('--require-judge'), lint: !flag('--no-lint'), valeOptional: flag('--vale-optional'), ...(staged ? { markdown: stagedText(f) } : {}) }); + if (files.length > 3 && process.stderr.isTTY) console.error(`quality: checked ${++done}/${files.length} ${f}`); + return r; + }); + const pass = results.every(r => r.pass); + const out = { pass, results }; + + if (flag('--table')) { + for (const r of results) { + console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.path} (vale: ${r.vale.status}, judge: ${r.judge.status}, dale: ${r.dale.status})`); + for (const f of [...r.deterministic.findings, ...r.vale.findings, ...r.judge.findings, ...r.dale.findings].slice(0, 20)) + console.log(` ${f.line || '-'} ${f.rule || `pattern ${f.pattern}`} ${f.message || f.reason}`); + } + if (results.length === 0) console.log('No documentation changes to check.'); + } else { + console.log(JSON.stringify(out, null, 2)); + } + process.exit(pass ? 0 : 1); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/quality/todo-check.mjs b/scripts/quality/todo-check.mjs new file mode 100644 index 0000000000..d8c9d52aab --- /dev/null +++ b/scripts/quality/todo-check.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +// Warn about leftover draft markers in docs: {/* TODO: ... */}, , +// and TODO(docdraft). Never fails; exit code is always 0. +// Usage: todo-check.mjs [--staged | --base ] [--files a.md b.md] +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; + +const MARKER = /\{\/\*\s*TODO\b|` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac3af0f12b..64c6dd75ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,8 @@ description: 'SEO description' --- ``` +The `description` is required on a new page: one sentence, under 160 characters, saying what the reader can do or learn there. The pre-commit check blocks a new page without one. + ## Linting with Vale Vale enforces the Netwrix style rules covering word choice, punctuation, formatting, and common writing issues. Git hooks run it on the `docs/` markdown files you change (KB articles excluded): diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 4709e5c1bd..487b9babe1 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -62,6 +62,7 @@ The four core qualities: - Concept/overview headings: noun phrase or gerund — "Configuring the monitoring plan" - An example immediately follows the procedure or concept it illustrates - Never skip heading levels +- Every new page needs a `description` in its frontmatter: one sentence, under 160 characters, saying what the reader can do or learn on the page. The pre-commit check blocks a new page without one ## Linting @@ -98,6 +99,7 @@ Every docs contribution is linted before it reaches a PR: Vale, Dale, and a chec - **Scope:** a new page is checked whole. An existing page is checked only on the lines the change adds or rewrites; text it already had is never flagged. A page git detects as a copy or rename (50% or more similar) counts as existing, so cloning a version directory only checks what differs. `docs/kb/` is out of scope. - **Vale:** the repo's Vale rules, from the page text (so the staged version is checked). Vale must be installed, or the commit is blocked with install steps. The Stop hook runs without that requirement. - **Dale:** a model applies the 10 rules in `.claude/skills/dale/rules/` in one call per page. Like the judge, it runs 3 times, keeps findings that 2 runs report at the same spot, and counts only findings whose quote appears verbatim on a changed line. Verdicts are cached. +- **Frontmatter:** a new page needs a non-empty `description:` in its frontmatter. Existing pages and partials (a leading underscore) are not checked. - **Deterministic AI-isms:** `scripts/doc-draft/slop.mjs` (catalog in `scripts/doc-draft/ai-isms.yml`). Structural signals such as sentence-length variance apply only to new pages. - **Judged:** a Haiku 4.5 judge applies the 16 patterns in `.claude/references/humanizer-rules.md`. It runs 3 times per page and keeps findings that at least 2 runs report at the same spot. A finding counts only if its quote appears verbatim on a changed line. Verdicts are cached by content hash in `.cache/quality`. - **Draft markers:** write unresolved gaps as `{/* TODO: ... */}` so they never render. `scripts/quality/todo-check.mjs` warns (never blocks) about leftovers at commit and in the Stop hook. diff --git a/scripts/quality/lib/gates.mjs b/scripts/quality/lib/gates.mjs index 0b71d77656..67ae694684 100644 --- a/scripts/quality/lib/gates.mjs +++ b/scripts/quality/lib/gates.mjs @@ -1,7 +1,25 @@ import fs from 'fs'; +import path from 'path'; import { runSlopGate } from '../../doc-draft/slop.mjs'; import { inRanges } from './scope.mjs'; +/** + * A new page needs a `description` in its frontmatter. Existing pages are not + * checked, so a legacy page without one doesn't block an unrelated edit. + * Partials (a leading underscore) are never rendered as pages. + */ +export function missingDescription(file, markdown) { + if (path.basename(file).startsWith('_')) return []; + const fm = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const desc = fm?.[1].match(/^description:[ \t]*(.*)$/m)?.[1].trim().replace(/^(['"])(.*)\1$/, '$2').trim(); + if (desc) return []; + return [{ + line: 1, + rule: 'frontmatter:description', + message: 'Add a `description:` to the frontmatter: one sentence saying what the reader can do or learn on this page.', + }]; +} + /** * Deterministic AI-isms gate for one page (slop.mjs). `ranges` limits an * existing page to its changed lines: only lexical hits inside them count, @@ -11,7 +29,7 @@ import { inRanges } from './scope.mjs'; export function scoreDeterministic(file, _config, markdown = fs.readFileSync(file, 'utf8'), ranges = null) { const slop = runSlopGate(file, markdown); const findings = ranges === null - ? slop.findings + ? [...missingDescription(file, markdown), ...slop.findings] : slop.findings.filter(f => f.rule?.startsWith('slop-lexical:') && inRanges(f.line, ranges)); return { pass: findings.length === 0, metrics: { slop: slop.metrics }, findings }; } From 3d7060c15e8a23cdb0f4eeb4204b846469740cb0 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 6 Oct 2026 12:39:10 -0500 Subject: [PATCH 03/11] Replace the tech-writer agent with the doc-draft skill The drafting workflow runs in the session (or a subagent) instead of a dedicated agent: PR, work item, specs and notes, and new-directory structure, with the lint step and the description rule. The style reference moves to a file beside the skill. CLAUDE.md and CONTRIBUTING.md point to the skill. Co-Authored-By: Claude Sonnet 5.5 --- .../doc-draft/SKILL.md} | 96 ++----------------- .claude/skills/doc-draft/style-reference.md | 75 +++++++++++++++ CLAUDE.md | 4 +- CONTRIBUTING.md | 10 +- scripts/quality/gen-lint-digest.mjs | 2 +- 5 files changed, 90 insertions(+), 97 deletions(-) rename .claude/{agents/tech-writer.md => skills/doc-draft/SKILL.md} (60%) create mode 100644 .claude/skills/doc-draft/style-reference.md diff --git a/.claude/agents/tech-writer.md b/.claude/skills/doc-draft/SKILL.md similarity index 60% rename from .claude/agents/tech-writer.md rename to .claude/skills/doc-draft/SKILL.md index ad59ada1a9..15bd29ba78 100644 --- a/.claude/agents/tech-writer.md +++ b/.claude/skills/doc-draft/SKILL.md @@ -1,24 +1,16 @@ --- -name: tech-writer -description: "Use this agent to write documentation from source material, end to end, without back-and-forth. Launch it whenever the user supplies material to document, even if they never say 'tech writer' or 'draft'. Triggers: (1) a pull request or commit to document ('look at PR 123 in netwrix/repo and document the feature'); (2) specs, requirements, or a design doc to turn into feature documentation; (3) rough notes, a transcript, or a brain dump to turn into a document; (4) a prompt that describes what to write in enough detail to draft from ('write a page on X that covers A, B, C'); (4a) a request to structure or organize a new docs directory for a product or version; (5) a work item to document: a GitHub issue, a Jira ticket, or an Azure DevOps work item, given as a URL, a key or number, or pasted text ('look at issue 88 in netwrix/repo and write the doc', 'document PLAT-1234', 'write a page for ADO item 5521'). For editing existing docs, style or Vale questions, brainstorming, or merging a .docx into a page, use the doc-help skill instead.\nExamples:\n- Example 1: user: \"Look at PR 482 in netwrix/accessanalyzer-app and document the new agents dashboard.\" assistant: \"I'll launch the tech-writer agent to read the PR and draft the documentation.\" A PR to document. The agent reads the diff with gh and drafts the page.\n- Example 2: user: \"Here are the specs for the new export feature. I want to document it in Access Analyzer 26.1.\" assistant: \"I'll have the tech-writer agent draft the feature documentation from these specs.\" Specs supplied to document as a feature.\n- Example 3: user: \"Here are my notes from the SME call. Turn them into a document.\" assistant: \"I'll launch the tech-writer agent to turn the notes into a page.\" Rough notes to turn into a document.\n- Example 4: user: \"Write a how-to on rotating a service account certificate. Cover generating a certificate, uploading your own, and what happens to running scans.\" assistant: \"I'll launch the tech-writer agent to write that page.\" A prompt with enough detail to draft from, with no source files.\n- Example 5: user: \"Look at Jira ticket PLAT-1234 and write the documentation for it.\" assistant: \"I'll launch the tech-writer agent to read the ticket and its linked PRs and draft the page.\" A work item to document. The agent fetches it from the tracker, follows linked PRs, and drafts only what the sources support." -model: sonnet -color: purple -memory: project +name: doc-draft +description: "Draft new Netwrix documentation from source material and write it into the docs tree. Use whenever the user supplies material to document, even if they never say 'draft': (1) a pull request or commit ('look at PR 123 in netwrix/repo and document the feature'); (2) specs, requirements, or a design doc; (3) rough notes, a transcript, or a brain dump; (4) a prompt detailed enough to draft from ('write a page on X covering A, B, C'); (4a) a request to structure or organize a new docs directory for a product or version; (5) a work item to document: a GitHub issue, a Jira ticket, or an Azure DevOps item, as a URL, a key or number, or pasted text. For editing or reviewing existing docs, style or Vale questions, brainstorming, or merging a .docx into a page, use doc-help instead." +argument-hint: "[PR number and repo, work item key or URL, spec or notes, or what to document]" --- -You write documentation for Netwrix, a cybersecurity company whose products serve IT professionals and security teams. +# Draft new documentation -Write clearly, concisely, and consistently. Every procedure comes with an example. A concept gets one only when you can write a real one from the source; otherwise you leave a TODO for the writer instead of padding the page. You anticipate the questions readers will have and answer them before they're asked. You provide enough context for newer users to follow along without over-explaining things experienced users already know. +You write documentation for Netwrix, a cybersecurity company whose products serve IT professionals and security teams. Work end to end in this session: read the source, draft the pages, lint them, and report what you did. -**Always read `docs/CLAUDE.md` before starting any task.** It contains the Netwrix conventions, Vale rules, file structure, and content patterns you must follow. +Read `docs/CLAUDE.md` first. It holds the Netwrix conventions, Vale rules, file structure, and content patterns. Read `style-reference.md` in this skill's folder for the grammar, formatting, and terminology rules that linters don't catch. -## How You Work - -You are an autonomous agent. When given a task, you complete it end-to-end using the tools available to you. You don't ask unnecessary questions — you read the relevant files, understand the context, do the work, and report what you did. - -If the task is ambiguous, ask one clarifying question before proceeding. Otherwise, make a reasonable judgment and proceed. - -Before starting work, create a todo for each step of your task using the TaskCreate tool. Mark each task complete as you finish it. This gives the user visibility into your progress on long-running tasks. +If the task is ambiguous, ask one clarifying question before you start. Otherwise make a reasonable choice, say what you chose in your report, and go on. If you can't ask (you are running in a subagent or headless), don't stop: make the choice, and say what you chose in your report. You lint your own drafts: run the style check in step 9 of "Draft new documentation" before you finish. It is the same check that blocks the commit, and nothing lints on the PR. @@ -93,77 +85,3 @@ When the source needs several pages (for example a feature with a concept page, 1. Plan the page list first: file path, page type, and the facts each page covers 2. Create one todo per page 3. Draft pages one at a time — complete all steps for each file before moving to the next - -## Output Style - -Netwrix documentation sounds like a knowledgeable colleague walking you through something — direct, clear, and respectful of your time. It never sounds like a manual written by committee. - -**Write like this:** - -> The monitoring plan collects audit data from Active Directory and stores it in the Netwrix database. By default, it runs every 24 hours. -> -> To change the collection interval: -> -> 1. Go to **Settings** > **Monitoring Plans**. -> 2. Select the monitoring plan you want to update. -> 3. Update the **Collection interval** field and click **Save**. - -**Not like this:** - -> It should be noted that the monitoring plan is utilized for the purpose of collecting data from Active Directory, which will subsequently be transmitted to the Netwrix database. Users may wish to configure the collection interval as needed by navigating to the appropriate settings. - -The difference: -- **Direct, not padded.** "Collects and stores" vs. "is utilized for the purpose of collecting." -- **Active, not passive.** "The monitoring plan collects" vs. "data will be transmitted." -- **Procedural steps are instructions, not descriptions.** "Go to Settings" vs. "navigating to the appropriate settings." -- **No throat-clearing.** Never start with "It should be noted that" or "Please be aware that." - -## Style Reference - -Vale and Dale run when you run the style check (step 9) and again in the pre-commit hook. Nothing lints on the PR. The self-review step in each task type covers the same issues Dale checks (passive voice, wordiness, idioms, hedging, future tense). The rules below cover what linters don't catch. Apply these while writing. - -### Grammar - -- **Contractions**: Use common contractions (don't, can't, you'll). Avoid unusual ones (should've, could've). -- **Anthropomorphism**: Don't attribute human traits to software. "The system displays" not "the system sees." -- **Parallel structure**: Items in a list or series use the same grammatical form. -- **Nominalizations**: Use verbs, not nouns derived from verbs. "Configure" not "perform the configuration of." -- **One idea per sentence**: Break compound sentences that cover multiple concepts. -- **Articles**: Don't omit articles (a, an, the) for brevity. -- **That/which**: "That" for restrictive clauses (no comma). "Which" for nonrestrictive (with comma). -- **Who/whom**: "Who" for subjects, "whom" for objects. -- **Since/because**: "Since" for time, "because" for causation. -- **While/although**: "While" for time, "although" for contrast. -- **Whether/if**: "Whether" for alternatives, "if" for conditions. -- **Fewer/less**: "Fewer" for countable, "less" for uncountable. -- **Collective nouns**: Singular in American English. "The team configures" not "the team configure." -- **Gendered pronouns**: Avoid. Repeat the noun instead of using he/she or singular they. - -### Formatting - -- **Headings**: Sentence case. Infinitive for tasks ("Install the agent"), gerund for concepts ("Reviewing audit logs"). -- **Bold**: UI elements, buttons, menu items. -- **Code formatting**: Commands, file paths, technical values. -- **No italics**. -- **Oxford comma**: Required. -- **Em dashes**: No spaces (word—word). -- **Hyphens**: Compound modifiers before nouns ("real-time monitoring" but "runs in real time"). -- **Numbers**: Spell out 0–9, numerals for 10+. Numerals with units (5 GB). Commas in thousands (1,500). -- **Dates**: Month Day, Year (January 15, 2025). -- **Time**: 12-hour clock with AM/PM. - -### Terminology - -- **Inclusive terms**: allowlist/denylist, primary/replica — not whitelist/blacklist, master/slave. -- **Version comparisons**: "or later" / "or earlier" — not "or higher" / "or newer." -- **No time-relative qualifiers**: No "currently", "as of this writing", or pre-announcing future features. - -### Structure - -- Concepts before procedures: overview → prerequisites → steps. -- An example goes immediately after the procedure or concept it illustrates. -- Common tasks before advanced topics. -- Cross-references at the end of sections. -- Alt text on every image. - -For the full style guide with detailed examples, see `netwrix_style_guide.md` in the project root. diff --git a/.claude/skills/doc-draft/style-reference.md b/.claude/skills/doc-draft/style-reference.md new file mode 100644 index 0000000000..36a1b92335 --- /dev/null +++ b/.claude/skills/doc-draft/style-reference.md @@ -0,0 +1,75 @@ +# Style reference for drafting + +## Output style + +Netwrix documentation sounds like a knowledgeable colleague walking you through something — direct, clear, and respectful of your time. It never sounds like a manual written by committee. + +**Write like this:** + +> The monitoring plan collects audit data from Active Directory and stores it in the Netwrix database. By default, it runs every 24 hours. +> +> To change the collection interval: +> +> 1. Go to **Settings** > **Monitoring Plans**. +> 2. Select the monitoring plan you want to update. +> 3. Update the **Collection interval** field and click **Save**. + +**Not like this:** + +> It should be noted that the monitoring plan is utilized for the purpose of collecting data from Active Directory, which will subsequently be transmitted to the Netwrix database. Users may wish to configure the collection interval as needed by navigating to the appropriate settings. + +The difference: +- **Direct, not padded.** "Collects and stores" vs. "is utilized for the purpose of collecting." +- **Active, not passive.** "The monitoring plan collects" vs. "data will be transmitted." +- **Procedural steps are instructions, not descriptions.** "Go to Settings" vs. "navigating to the appropriate settings." +- **No throat-clearing.** Never start with "It should be noted that" or "Please be aware that." + +## Style reference + +Vale and Dale run when you run the style check (step 9 of the skill) and again in the pre-commit hook. Nothing lints on the PR. The self-review step in each task type covers the same issues Dale checks (passive voice, wordiness, idioms, hedging, future tense). The rules below cover what linters don't catch. Apply these while writing. + +### Grammar + +- **Contractions**: Use common contractions (don't, can't, you'll). Avoid unusual ones (should've, could've). +- **Anthropomorphism**: Don't attribute human traits to software. "The system displays" not "the system sees." +- **Parallel structure**: Items in a list or series use the same grammatical form. +- **Nominalizations**: Use verbs, not nouns derived from verbs. "Configure" not "perform the configuration of." +- **One idea per sentence**: Break compound sentences that cover multiple concepts. +- **Articles**: Don't omit articles (a, an, the) for brevity. +- **That/which**: "That" for restrictive clauses (no comma). "Which" for nonrestrictive (with comma). +- **Who/whom**: "Who" for subjects, "whom" for objects. +- **Since/because**: "Since" for time, "because" for causation. +- **While/although**: "While" for time, "although" for contrast. +- **Whether/if**: "Whether" for alternatives, "if" for conditions. +- **Fewer/less**: "Fewer" for countable, "less" for uncountable. +- **Collective nouns**: Singular in American English. "The team configures" not "the team configure." +- **Gendered pronouns**: Avoid. Repeat the noun instead of using he/she or singular they. + +### Formatting + +- **Headings**: Sentence case. Infinitive for tasks ("Install the agent"), gerund for concepts ("Reviewing audit logs"). +- **Bold**: UI elements, buttons, menu items. +- **Code formatting**: Commands, file paths, technical values. +- **No italics**. +- **Oxford comma**: Required. +- **Em dashes**: No spaces (word—word). +- **Hyphens**: Compound modifiers before nouns ("real-time monitoring" but "runs in real time"). +- **Numbers**: Spell out 0–9, numerals for 10+. Numerals with units (5 GB). Commas in thousands (1,500). +- **Dates**: Month Day, Year (January 15, 2025). +- **Time**: 12-hour clock with AM/PM. + +### Terminology + +- **Inclusive terms**: allowlist/denylist, primary/replica — not whitelist/blacklist, master/slave. +- **Version comparisons**: "or later" / "or earlier" — not "or higher" / "or newer." +- **No time-relative qualifiers**: No "currently", "as of this writing", or pre-announcing future features. + +### Structure + +- Concepts before procedures: overview → prerequisites → steps. +- An example goes immediately after the procedure or concept it illustrates. +- Common tasks before advanced topics. +- Cross-references at the end of sections. +- Alt text on every image. + +For the full style guide with detailed examples, see `netwrix_style_guide.md` in the project root. diff --git a/CLAUDE.md b/CLAUDE.md index 6cfbd000f0..d3338368ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,7 +92,7 @@ Skills (`.claude/skills/`) are invoked with `/skill-name`. Agents (`.claude/agen When a user asks for help with documentation, always use the appropriate tool: - **`/doc-help` skill** — Interactive tasks: reviewing content, suggesting improvements, discussing structure or flow, brainstorming, explaining style rules, incorporating external documents (e.g., `.docx` files) into existing markdown files, or any back-and-forth conversation about writing. -- **`tech-writer` agent** — Drafts documentation from source material (Sonnet by default; pass `model: "opus"` if a PR draft comes back thin): a PR to document ("look at PR X in repo Y"), a GitHub issue, Jira ticket, or Azure DevOps item to document ("look at PLAT-1234"), specs for a feature, notes to turn into a document, or a prompt detailed enough to draft from. Product context for PR drafting lives in `.claude/references/products/.md`; add a brief there for any product you want it to understand. It does not edit existing docs; use `/doc-help`, `/dale`, or Vale for that. +- **`/doc-draft` skill** — Drafts documentation from source material, in your session: a PR to document ("look at PR X in repo Y"), a GitHub issue, Jira ticket, or Azure DevOps item to document ("look at PLAT-1234"), specs for a feature, notes to turn into a document, or a prompt detailed enough to draft from. Product context for PR drafting lives in `.claude/references/products/.md`; add a brief there for any product you want it to understand. It does not edit existing docs; use `/doc-help`, `/dale`, or Vale for that. | Component | Type | Purpose | |---|---|---| @@ -100,6 +100,7 @@ When a user asks for help with documentation, always use the appropriate tool: | `/docs-preview` | Skill | Start, stop, or check a local dev server or production preview | | `/dale` | Skill | Custom linter for Netwrix-specific writing patterns | | `/doc-help` | Skill | Interactive writing assistant (terminal sessions) | +| `/doc-draft` | Skill | Drafts new pages from a PR, a work item (GitHub issue, Jira ticket, Azure DevOps item), specs, notes, or a detailed prompt, and structures new docs directories | | `/doc-pr` | Skill | Automated PR editorial review | | `/content-fix` | Skill | Autonomous issue-to-PR fixer for content_fix issues | | `/doc-pr-fix` | Skill | Autonomous PR fixer triggered by `@claude` | @@ -108,7 +109,6 @@ When a user asks for help with documentation, always use the appropriate tool: | `/kb-pr-open` | Skill | Last-mile KB submission helper for TSEs — lints and opens a PR to `dev` | | `/kb-pr-review` | Skill | Reviews a KB PR (Vale + Dale + Derek) and drafts a review comment | | `/audit-fix` | Skill | Applies a docs-audit correction to a page and its duplicate versions | -| `tech-writer` | Agent | Autonomous drafting from a PR, a work item (GitHub issue, Jira ticket, Azure DevOps item), specs, notes, or a detailed prompt (Sonnet) | | `vale-rule-writer` | Agent | Creates new Vale rules | | `vale-auditor` | Agent | Audits Vale rule set for conflicts | | `github-issue-manager` | Agent | Issue intake pipeline orchestrator | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64c6dd75ae..1850c0a508 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,9 +167,9 @@ Run Dale locally on any markdown file to preview context-dependent issues: Ask Claude Code for help with your writing — brainstorming document structure, drafting a section, editing existing content, or understanding a style rule. Claude automatically uses the `doc-help` skill when you ask for writing assistance. You can also invoke it directly with `/doc-help`, followed by your request. -### Run autonomous documentation tasks +### Draft new pages -For well-defined drafting tasks that don't need back-and-forth, Claude automatically uses the `tech-writer` agent. It drafts new pages from a pull request, a GitHub issue, a Jira ticket or Azure DevOps item, specs, notes, or a detailed prompt, and it can structure a new docs directory following the section guide (`.claude/references/documentation-section-guide.md`). It doesn't edit existing pages; use `doc-help` for that. Before it finishes, it lints its own draft (Vale, Dale, and the AI-writing check) and fixes what is flagged. +For well-defined drafting tasks, Claude uses the `doc-draft` skill in your session, or you can run `/doc-draft`. It drafts new pages from a pull request, a GitHub issue, a Jira ticket or Azure DevOps item, specs, notes, or a detailed prompt, and it can structure a new docs directory following the section guide (`.claude/references/documentation-section-guide.md`). It doesn't edit existing pages; use `doc-help` for that. Before it finishes, it lints its draft (Vale, Dale, and the AI-writing check) and fixes what is flagged. Examples: - "Look at PR 1040 in itdr-pingcastle-core and document it". @@ -177,7 +177,7 @@ Examples: - "Draft the installation steps based on this outline: [outline]" - "Create the docs directory for this new product from these specs: [specs]" -For tasks that need design decisions first — like planning a new guide from scratch — Claude uses doc-help to brainstorm the structure with you, then hands the drafting off to the tech-writer agent. +For tasks that need design decisions first — like planning a new guide from scratch — Claude uses doc-help to brainstorm the structure with you, then moves to `doc-draft` for the drafting. ### Quick reference @@ -185,8 +185,8 @@ For tasks that need design decisions first — like planning a new guide from sc |---|---| | Quick lint check on a file | `/dale docs/path/to/file.md` | | Check your changes before committing | `npm run quality:score` | -| Plan structure for a new document | `doc-help` then `tech-writer` agent | -| Draft a new page from a PR, issue, ticket, or specs | `tech-writer` agent | +| Plan structure for a new document | `doc-help` then `doc-draft` | +| Draft a new page from a PR, issue, ticket, or specs | `doc-draft` | | Review or improve existing content | `doc-help` | | Fix Vale errors in a file | `doc-help`, or fix by hand and run `npm run quality:score` | | Edit a file for style and clarity | `doc-help` | diff --git a/scripts/quality/gen-lint-digest.mjs b/scripts/quality/gen-lint-digest.mjs index 2ef17d4a7a..894631bb34 100644 --- a/scripts/quality/gen-lint-digest.mjs +++ b/scripts/quality/gen-lint-digest.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * gen-lint-digest.mjs - writes .claude/references/lint-rules.md: every rule Vale, Dale, and the - * AI-isms check enforce, in plain language, so a writer (or the tech-writer agent) can draft + * AI-isms check enforce, in plain language, so a writer (or the doc-draft skill) can draft * wording that passes the first time. Generated from the rule files, so it cannot drift. * * node scripts/quality/gen-lint-digest.mjs # write the file From 0efe4f307253a9647948cb65ae26ee93483bb2a5 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Mon, 12 Oct 2026 16:29:43 -0500 Subject: [PATCH 04/11] Replace the model judge with consensus checks and add AI-writing lint rules Swap lib/judge.mjs and score.mjs for lib/consensus.mjs and check.mjs (npm run quality:check; flags --no-model and --require-model). Add 12 Dale rules and 7 Vale rules for AI-writing patterns, wire a quality step into the pre-commit hook, restore vale-autofix.yml as a CI backstop, and drop phrases from ai-isms.yml that Vale now covers. Co-Authored-By: Claude Sonnet 5.5 --- .claude/hooks/stop-quality-check.sh | 4 +- .claude/references/humanizer-rules.md | 86 ---- .claude/references/lint-rules.md | 23 +- .../dale/rules/collaborative-leftovers.yml | 3 + .../skills/dale/rules/copula-avoidance.yml | 3 + .../skills/dale/rules/excessive-hedging.yml | 3 + .claude/skills/dale/rules/false-range.yml | 3 + .claude/skills/dale/rules/filler-phrases.yml | 3 + .../skills/dale/rules/generic-conclusion.yml | 3 + .../dale/rules/inflated-significance.yml | 3 + .claude/skills/dale/rules/ing-analysis.yml | 3 + .../dale/rules/negative-parallelism.yml | 3 + .../dale/rules/promotional-language.yml | 3 + .../skills/dale/rules/sycophantic-tone.yml | 3 + .../skills/dale/rules/vague-attribution.yml | 3 + .claude/skills/doc-draft/SKILL.md | 2 +- .claude/skills/doc-help/SKILL.md | 2 +- .config/hk.pkl | 16 +- .github/workflows/vale-autofix.yml | 461 ++++++++++++++++++ .vale/styles/Netwrix/AssistantLeftovers.yml | 13 + .vale/styles/Netwrix/CopulaAvoidance.yml | 8 + .vale/styles/Netwrix/FillerPhrases.yml | 15 + .vale/styles/Netwrix/Idioms.yml | 1 - .vale/styles/Netwrix/InflatedSignificance.yml | 13 + .vale/styles/Netwrix/NegativeParallelism.yml | 8 + .vale/styles/Netwrix/PromotionalLanguage.yml | 16 + .vale/styles/Netwrix/TackedOnParticiple.yml | 8 + CLAUDE.md | 10 +- CONTRIBUTING.md | 10 +- docs/CLAUDE.md | 14 +- package.json | 2 +- scripts/doc-draft/ai-isms.yml | 6 - scripts/quality/{score.mjs => check.mjs} | 48 +- scripts/quality/config.json | 12 - scripts/quality/fix-loop.mjs | 20 +- scripts/quality/gen-lint-digest.mjs | 4 - scripts/quality/lib/consensus.mjs | 65 +++ scripts/quality/lib/dale.mjs | 4 +- scripts/quality/lib/gates.mjs | 2 +- scripts/quality/lib/judge.mjs | 146 ------ 40 files changed, 731 insertions(+), 324 deletions(-) delete mode 100644 .claude/references/humanizer-rules.md create mode 100644 .claude/skills/dale/rules/collaborative-leftovers.yml create mode 100644 .claude/skills/dale/rules/copula-avoidance.yml create mode 100644 .claude/skills/dale/rules/excessive-hedging.yml create mode 100644 .claude/skills/dale/rules/false-range.yml create mode 100644 .claude/skills/dale/rules/filler-phrases.yml create mode 100644 .claude/skills/dale/rules/generic-conclusion.yml create mode 100644 .claude/skills/dale/rules/inflated-significance.yml create mode 100644 .claude/skills/dale/rules/ing-analysis.yml create mode 100644 .claude/skills/dale/rules/negative-parallelism.yml create mode 100644 .claude/skills/dale/rules/promotional-language.yml create mode 100644 .claude/skills/dale/rules/sycophantic-tone.yml create mode 100644 .claude/skills/dale/rules/vague-attribution.yml create mode 100644 .github/workflows/vale-autofix.yml create mode 100644 .vale/styles/Netwrix/AssistantLeftovers.yml create mode 100644 .vale/styles/Netwrix/CopulaAvoidance.yml create mode 100644 .vale/styles/Netwrix/FillerPhrases.yml create mode 100644 .vale/styles/Netwrix/InflatedSignificance.yml create mode 100644 .vale/styles/Netwrix/NegativeParallelism.yml create mode 100644 .vale/styles/Netwrix/PromotionalLanguage.yml create mode 100644 .vale/styles/Netwrix/TackedOnParticiple.yml rename scripts/quality/{score.mjs => check.mjs} (64%) create mode 100644 scripts/quality/lib/consensus.mjs delete mode 100644 scripts/quality/lib/judge.mjs diff --git a/.claude/hooks/stop-quality-check.sh b/.claude/hooks/stop-quality-check.sh index 4e5f4c7416..45c229d6bb 100755 --- a/.claude/hooks/stop-quality-check.sh +++ b/.claude/hooks/stop-quality-check.sh @@ -14,7 +14,7 @@ COUNTER="${TMPDIR:-/tmp}/nwx-quality-$SESSION" ROUNDS=$(cat "$COUNTER" 2>/dev/null || echo 0) cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 -[ -f scripts/quality/score.mjs ] || exit 0 +[ -f scripts/quality/check.mjs ] || exit 0 # Only the files this session edited. None edited, nothing to check. LIST="${TMPDIR:-/tmp}/nwx-quality-files-$SESSION" @@ -26,7 +26,7 @@ fi # Unresolved draft markers in those files: tell the user, never block. node scripts/quality/todo-check.mjs --files $(cat "$LIST") >&2 || true -if OUTPUT=$(node scripts/quality/score.mjs --base origin/dev --only "$LIST" --vale-optional --table 2>&1); then +if OUTPUT=$(node scripts/quality/check.mjs --base origin/dev --only "$LIST" --vale-optional --table 2>&1); then rm -f "$COUNTER" exit 0 fi diff --git a/.claude/references/humanizer-rules.md b/.claude/references/humanizer-rules.md deleted file mode 100644 index ab6902a60c..0000000000 --- a/.claude/references/humanizer-rules.md +++ /dev/null @@ -1,86 +0,0 @@ -# Humanizer rubric — model-judged AI-isms - -Judgment-only companion to `scripts/doc-draft/slop.mjs`. That gate already -catches everything a regex or a page-level statistic can catch: the lexical -catalog (`scripts/doc-draft/ai-isms.yml`) and six structural signals -(sentence-length variance, tricolon rate, repeated paragraph openers, -bullet density, heading-to-prose ratio, em-dash rate). Never re-implement -any of those here — this file exists only for the patterns from -[Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing) -that need a reader's judgment, not a counter. - -Sourced from the `humanizer` skill's pattern catalog, trimmed to what -applies to Netwrix technical documentation. Dropped entirely: the skill's -"personality and soul" guidance (first-person voice, opinions, humor) — -that's Wikipedia-article/essay advice, and it conflicts with Netwrix house -style (`docs/CLAUDE.md`), which is impersonal, instructional, second-person -("you"). Also dropped: patterns that only occur in encyclopedia articles -(notability/media-coverage framing, "Challenges and Future Prospects" -sections) — kept below only where a docs-shaped version of the pattern -exists. - -Scoring a page against this rubric means finding zero instances of any -pattern below. Every finding must cite the exact line and quote the -offending text — never a vague "this section feels AI-written." - -## Patterns to find - -1. **Inflated significance.** Framing a routine feature or step as - uniquely important: "plays a crucial role," "serves as a cornerstone," - "is essential to ensuring." State what the feature does; let its - importance speak for itself. -2. **Superficial `-ing` analysis clauses.** A sentence that states a fact, - then tacks on an unearned interpretive clause: "The scan runs nightly, - ensuring your data stays current." Cut the clause or replace it with a - real, specific consequence. -3. **Promotional/advertisement language.** "Powerful," "robust," - "seamless," "cutting-edge," "industry-leading," "game-changing" used as - unsupported praise rather than a specific, checkable claim. -4. **Vague attributions.** "Users report," "many organizations find," - "it's widely recognized" — with no source. Either name the source or - drop the claim. -5. **Copula avoidance.** Circling around a simple "is/are" with "serves - as," "functions as," "acts as," "stands as" when a plain "is" says the - same thing. -6. **Negative parallelisms.** "Not just X, but Y," "isn't merely X — it's - Y." Occasional use is fine; more than one per page is a tell. -7. **Elegant variation / synonym cycling.** Swapping in a synonym for a - term purely to avoid repeating it ("the tool... the utility... the - application...") when the repeated term would be clearer. Netwrix style - prefers the same term every time for the same thing. -8. **False ranges.** "From basic reporting to advanced analytics," - "ranging from simple fixes to complex overhauls" — a rhetorical span - standing in for a real list. Name the actual items instead. -9. **Boldface overuse.** Bolding for emphasis rather than the house-style - uses (UI labels, key terms on first use). More than a couple of - emphasis-only bolded phrases in a paragraph is a tell. -10. **Inline-header vertical lists.** A bullet list where every item opens - with a bolded 2-4 word label immediately followed by a colon, used - for plain prose that doesn't need list formatting at all. -11. **Emojis and curly/smart quotation marks.** Netwrix docs use straight - quotes and no decorative emoji in body prose. -12. **Collaborative-artifact leftovers.** Text that reads like a reply to - a prompt rather than documentation: "Certainly, here's...", "I hope - this helps," "As an AI...", "Let me know if you'd like...", or a - knowledge-cutoff disclaimer ("as of my last update"). -13. **Sycophantic/servile tone.** "Great question," "you're absolutely - right to ask" — has no place in reference documentation aimed at a - reader, not a requester. -14. **Filler phrases and throat-clearing.** "It is important to note - that," "In today's fast-paced environment," "needless to say" — adds - words, not information. -15. **Excessive hedging.** Stacking qualifiers ("may potentially," - "could possibly in some cases") past what the actual certainty - warrants for a documented, tested behavior. -16. **Generic positive conclusions.** A closing paragraph or section that - restates the page's value in vague, feel-good terms instead of ending - on the last concrete instruction or fact. - -## Not in scope here - -Sentence-length uniformity, tricolon overuse, repeated paragraph openers, -bullet density, heading-to-prose ratio, and em-dash rate are scored by -`slop.mjs` as real numbers against calibrated thresholds — don't -re-score them by eye here, and don't flag a single tricolon or em dash on -sight; the gate already knows the difference between house style and -overuse. diff --git a/.claude/references/lint-rules.md b/.claude/references/lint-rules.md index 97c75ef434..457ecc67eb 100644 --- a/.claude/references/lint-rules.md +++ b/.claude/references/lint-rules.md @@ -7,14 +7,17 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the - **Aforementioned**: Avoid 'aforementioned'. Refer to the subject directly or use 'described earlier'. Terms: aforementioned - **AllowsYouTo**: Avoid ''. Rewrite with a direct imperative or active construction, e.g. 'Use X to ...' or 'With X, you can ...'. Terms: allows you to, allow you to, allows users to, allow users to, enables you to, enable you to, permits you to, enables users to, enable users to, permits users to +- **AssistantLeftovers**: Remove ''. It reads like a chat reply, not documentation. Terms: I hope this helps, let me know if, as an AI(?! Information Protection), as of my (last|latest) (update|training), certainly[,!]\s+here, here's (a|an|the) (breakdown|overview|summary|rundown), great question, you're absolutely right - **CalledUp**: Replace '' with 'open', 'access', or 'view' — 'called up' is not standard English for this meaning. Terms: called\s+up, calls\s+up, calling\s+up, call\s+up - **CanBeUsedTo**: Rewrite '' in active voice — for example, 'Use X to...' or 'X lets you...'. Terms: can be used to, can be used for - **CarriedOut**: Replace the wordy phrase 'carried out' with a direct verb like 'performed', 'completed', or 'run' — or rewrite in active voice. Terms: carried out - **Checkbox**: Use '' instead of 'check box'. Examples: check box → checkbox - **ClickOn**: Use '' — omit 'on' after click verbs. Examples: (double-click) on → $1; (right-click) on → $1; (left-click) on → $1; (left click) on → $1; (click) on → $1 - **Contractions**: Use the contraction '' instead of the formal form. Examples: do not → don't; does not → doesn't; did not → didn't; cannot → can't; can not → can't; would not → wouldn't; should not → shouldn't; could not → couldn't; is not → isn't; are not → aren't; was not → wasn't; were not → weren't +- **CopulaAvoidance**: Use 'is' or 'are' instead of ''. Terms: serv(es|ed|ing) as (a|an|the), stand(s|ing) as (a|an|the), boasts? - **DesiredAsAdjective**: Avoid '' — name the specific item or write 'the [noun] you want'. Terms: the desired \w+ - **Dropdown**: Use 'dropdown' (one word). Avoid 'drop-down' and 'drop down'. Examples: drop down → dropdown; drop-down → dropdown +- **FillerPhrases**: Remove the filler phrase ''. Terms: in today's (fast.paced|digital|modern|ever.changing|rapidly evolving), in the (modern|digital) (age|era|world), it goes without saying, when it comes to, at its core, at the heart of, let's dive, dive (in|into), in the (ever.evolving|ever.changing) \w+, ever.(evolving|changing) (landscape|world|threat) - **FirstPerson**: Avoid first person singular ''. Rewrite without 'I', 'me', 'my', 'mine', or contractions of 'I'. Terms: I, me, my, I'm, I've, I'll, I'd - **FirstPersonPlural**: Avoid first person plural ''. Rewrite without 'we', 'our', 'us', or 'ours'. Terms: we, our, ours, us - **FollowTheStepsTo**: Avoid ''. Use a direct heading and numbered list instead. Terms: follow the steps to, follow these steps to @@ -22,6 +25,7 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the - **Idioms**: Avoid the idiom ''. Write what you mean directly and literally. Terms: tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp(s|ed|ing)? up, spin(s|ning)? up, stand(s|ing)? up, tear(s|ing)? down, tore? down, low.hanging fruit, moves? the needle, boil(s|ed|ing)? the ocean, levels? the playing field, raises? the bar, sets? the stage, paves? the way, bridges? the gap, fills? the gap, cut(s|ting)? corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, … (70 in all) - **ImpersonalFiller**: Remove '' and state the point directly. Terms: it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say - **InOrderTo**: Use '' instead of 'in order to'. Examples: in order to → to +- **InflatedSignificance**: Remove the inflated-significance phrase ''. State what the feature does. Terms: (plays?|played|playing) an? (crucial|vital|key|critical|pivotal|significant|essential|important) role, serves? as an? (cornerstone|foundation|testament|backbone|linchpin), (cornerstone|linchpin) of, a testament to, (is|are) (essential|crucial|vital|critical) (to|for) ensuring, (is|are) (essential|crucial|vital) in ensuring, in ensuring that, plays? a (pivotal|crucial|vital) part - **IsAbleTo**: Use '' instead of the 'able to' construction. Examples: is able to → can; are able to → can; was able to → could; were able to → could - **LatinAbbreviations**: Avoid Latin abbreviation ''. Use the English equivalent instead: 'for example' or 'and so on'. Terms: e.g., etc. - **LatinIE**: Avoid the Latin abbreviation ''. Rewrite the clause using plain English — for example, 'meaning', 'in other words', or rephrase to integrate the explanation naturally. Terms: i.e. @@ -29,18 +33,21 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the - **LogonAsVerb**: Use 'log on' (two words) as a verb. 'Logon' is a noun or adjective only. Found: '' Terms: (need|needs|needed|have|has|had|want|wants|wanted|try|tries|tried|attempt|attempts|attempted|able)\s+to\s+logon, (will|can|could|must|should|would|shall|may|might)\s+logon, who\s+logon, (they|you|we|he|she|it)\s+logon, (normally|infrequently|typically|usually|always|never|now|still|also|just)\s+logon, (cannot|can.t)\s+logon, unable\s+to\s+logon, connect/logon, users?\s+logon\s+(with|onto|using) - **MakeSure**: Use '' instead of 'make sure'. Examples: make sure → ensure - **NaturallyFiller**: Remove the filler word '' — state the fact directly without implying it should be obvious. Terms: naturally +- **NegativeParallelism**: Avoid the 'not just X, but Y' construction (''). State the point directly. Terms: not (just|only|merely|simply)[^.!?\n]{0,80}but (also )?, (isn't|is not|aren't|are not|doesn't|does not) (just|merely|simply) [^.!?\n]{0,60}(—|--|;|,) (it|they|it's|it is|they're) - **NoteThat**: Avoid ''. Use a proper admonition block (Note, Warning, or Tip) for supplementary information. Terms: note that, please note - **OnceUsage**: Avoid '' for temporal sequence. Use 'After' (for sequential steps) or 'When' (for conditions or events). Terms: once you, once the, once it, once this - **OxfordComma**: '' appears to be missing an Oxford comma. Add a comma before 'and' or 'or' in series of three or more items. Terms: \w+,\s+\w+\s+and, \w+,\s+\w+\s+or - **PlainTextAdmonition**: Use a Docusaurus admonition block (:::note, :::warning, or :::tip) instead of plain text ''. Terms: (?m)^NOTE:, (?m)^\*\*NOTE:\*\*, (?m)^CAUTION:, (?m)^\*\*CAUTION:\*\* - **Please**: Avoid 'please' in instructions. Be direct. Terms: please(?!\s+note) - **Plurals**: Avoid parenthetical plurals ''. Use the plural form, or rephrase with 'one or more'. Terms: \w+(s) +- **PromotionalLanguage**: Avoid promotional wording (''). Describe the specific capability instead. Terms: powerful, industry.leading, best.in.class, state.of.the.art, revolutionary, groundbreaking, next.generation, unparalleled, unmatched, effortless(ly)?, game.chang(ing|er) - **ProvidesAbilityTo**: Use '' instead of 'provides the ability to'. Examples: provides the ability to → lets you; provide the ability to → let you; provided the ability to → let you - **QuestionHeadings**: Headings should be declarative statements, not questions. Rewrite '' without a question mark. Terms: \? - **ReferToTheFollowing**: Avoid ''. For cross-references, use 'See [link]'. For procedure lead-ins, use a direct heading and numbered list instead. Terms: refer to the following - **Repetition**: Repeated word '' detected. - **SetupUsage**: Use '' — 'setup' is a noun or adjective. The verb form is 'set up' (two words). Examples: to setup → to set up; will setup → will set up; can setup → can set up; must setup → must set up; should setup → should set up; have setup → have set up; has setup → has set up; had setup → had set up; getting setup → getting set up - **Spacing**: Use a single space after sentence-ending punctuation, not two. Terms: [.!?]\s{2,}\S +- **TackedOnParticiple**: Cut the tacked-on clause '' or replace it with a specific consequence. Terms: ,\s+(thereby |thus )?(ensuring|underscoring|reflecting|showcasing|fostering|emphasizing|cementing|solidifying|reinforcing|demonstrating) - **TemporalHedges**: Avoid ''. Documentation should describe the current state without temporal qualifiers. Terms: currently, presently, as of this writing - **TypeVsEnter**: Use '' — 'enter' is hardware-neutral and preferred over 'type' when instructing users to supply text. Examples: type the → Enter the; type a → Enter a; type an → Enter an; type your → Enter your - **Utilize**: Use '' — avoid 'utilize' and its variants. Examples: utilizes → uses; utilized → used; utilizing → using; utilize → use; utilises → uses; utilised → used; utilising → using; utilise → use @@ -51,21 +58,29 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the ## Dale (model-checked) +- **collaborative-leftovers**: Fires on chatbot leftovers: 'Certainly, here is...', 'I hope this helps', 'As an AI', 'Let me know if you would like...', 'Feel free to ask', or a knowledge-cutoff disclaimer such as 'as of my last update'. (Message: Remove text that reads like a reply to a prompt.) +- **copula-avoidance**: Fires when 'serves as', 'functions as', 'acts as', 'stands as', 'represents', or 'boasts' is used where a plain 'is/are/has' would say the same thing. Does not fire when the verb describes a real functional role, such as 'acts as a proxy' or 'serves as the gateway between two networks'. (Message: Use 'is' or 'are' instead of 'serves as', 'functions as', 'acts as', or 'stands as'.) +- **excessive-hedging**: Fires when qualifiers are stacked past what a documented, tested behavior warrants, such as 'may potentially', 'could possibly in some cases', 'might in certain circumstances be able to'. (Message: Remove stacked qualifiers on documented behavior.) - **exclamatory-sentences**: This rule should trigger when the user or agent writes exclamatory sentences. (Message: Don't use exclamatory sentences.) +- **false-range**: Fires when a rhetorical 'from X to Y' or 'ranging from X to Y' span stands in for a real list, such as 'from basic reporting to advanced analytics' or 'ranging from simple fixes to complex overhauls'. Does not fire on real numeric, date, or version ranges. (Message: Name the actual items instead of a rhetorical span.) +- **filler-phrases**: Fires on throat-clearing and filler such as 'It is important to note that', 'It is worth mentioning', 'In today's fast-paced environment', 'needless to say', 'at the end of the day', 'it should be noted that', when the sentence works without it. Does not fire on 'in order to' (a separate wordiness rule covers it). (Message: Cut the throat-clearing; it adds words, not information.) +- **generic-conclusion**: Fires when a closing paragraph or section restates the page's value in vague, feel-good terms (for example 'By following these steps, you can ensure a smooth and efficient experience') instead of ending on the last concrete instruction or fact. Does not fire on a sentence that states the concrete expected result of a step, such as 'The service should now start.' (Message: End on the last concrete instruction or fact.) - **idioms**: This rule should trigger when the user or agent uses an idiom or other culturally specific expression. (Message: Don't use idioms. Write what you mean more directly and literally.) +- **inflated-significance**: Fires when a sentence inflates the significance of a routine feature or step with phrases like 'plays a crucial/vital/key role', 'serves as a cornerstone', 'is essential to ensuring', 'a testament to'. Does not fire on plain factual statements that something is required (for example 'You must have Domain Admin rights'). (Message: Do not frame a routine feature or step as uniquely important ('plays a crucial role', 'serves as a cornerstone', 'is essential to ensuring'). State what it does.) +- **ing-analysis**: Fires when a sentence states a fact and then tacks on an unearned interpretive '-ing' clause, such as 'The scan runs nightly, ensuring your data stays current' or 'The policy applies to all users, highlighting its importance'. Does not fire on '-ing' clauses that describe a concrete action or a verifiable effect. (Message: Cut the trailing '-ing' clause or replace it with a specific consequence.) - **minimizing-difficulty**: This rule should trigger when the documentation minimizes task difficulty—for example, 'With [product], you can easily [task].' (Message: Do not minimize the difficulty of tasks users are performing.) - **misplaced-modifiers**: This rule should trigger when the documentation has a misplaced or dangling modifier. This includes participial phrases that attach to the wrong subject (e.g., '[participle phrase], [wrong subject] [verb]') and modifying clauses placed too far from the word they describe (e.g., '[noun A] [preposition] [noun B] [modifier that actually describes noun A]'). (Message: Avoid misplaced modifiers. Move the modifier, or descriptive phrase, so that it accurately describes the right thing.) - **negative-assumptions**: This rule should trigger when the user or agent writes something that assumes a user cannot do something without the product. (Message: Don't make negative assumptions about what users can or cannot do.) +- **negative-parallelism**: Fires on negative parallelism: 'not just X but Y', 'not only X but also Y', 'isn't merely X, it's Y', 'this isn't about X, it's about Y'. Does not fire on a plain contrast that conveys real information, such as 'Use the service account, not your personal account'. (Message: Do not use 'not just X, but Y' or 'isn't merely X, it's Y'.) - **passive-voice**: This rule should trigger when the user or agent writes a sentence or clause in passive voice. (Message: Don't use passive voice. Use active voice instead.) - **positional-references**: This rule should trigger when the documentation uses spatial direction words like 'below', 'above', 'as shown below', or 'the above section' to reference other content. (Message: Avoid positional references like 'below', 'above', or 'as shown below'. Use 'the following' or a named anchor instead.) +- **promotional-language**: Fires when marketing praise such as powerful, robust, seamless, cutting-edge, industry-leading, game-changing, best-in-class, comprehensive solution is used as unsupported praise rather than a specific, checkable claim. (Message: Replace unsupported praise with a specific, checkable claim.) +- **sycophantic-tone**: Fires on sycophantic phrasing aimed at a requester, such as 'Great question', 'You are absolutely right to ask', 'Excellent choice', 'Good thinking'. (Message: Remove servile or flattering phrasing.) - **undefined-acronyms**: This rule should trigger when a product-specific or non-standard acronym is used without being spelled out on first use in body prose, following the pattern: 'File System Access Audit (FSAA)' on first use, then 'FSAA' thereafter. Do NOT flag: well-known IT industry standards that a sysadmin audience would know (DNS, AD, IP, HTTP, TCP, SID, UID, CPU, RAM, URL, SQL, OS, VM, API, SSH, SSL, TLS, LDAP, SAML, MFA, ACL, DACL, SACL, OU, GPO, NTFS, etc.); code literals in backticks; Netwrix product short names when the full product name has already appeared in the article; acronyms that appear only in headings or titles (define-on-first-use applies to body prose only). Do flag product-specific acronyms like AIC, FSAA, SPAA, CEE, AMQP when used in body text without prior definition, even if the article's title or subject implies the reader knows them. (Message: Acronym used without being defined on first use. Spell it out: 'Full Name (ACRONYM)'.) +- **vague-attribution**: Fires when a claim is attributed to unnamed people or sources with no citation, such as 'users report', 'many organizations find', 'it is widely recognized', 'experts agree', 'studies show'. (Message: Name the source or drop the claim.) - **wordiness**: This rule should trigger when the user or agent writes a wordy sentence when a more concise, direct alternative is possible without altering the meaning. (Message: This sentence is wordy. A more concise, direct alternative is possible.) - **xy-slop**: This rule should trigger when the user or agent writes something in the negative-postive form, for example 'x is not y, x is z'—or similar. (Message: Do not use the 'x is not y, x is z' format.) ## AI-isms word list (avoid these words and phrases) tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp up, spin up, stand up (idiom), tear down (idiom), tore down, low-hanging fruit, moves the needle, boil the ocean, levels the playing field, raises the bar, sets the stage, paves the way, bridges the gap, fills the gap, cut corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, at first glance, by the same token, on the fly, touch base, circle back, loop in, flesh out, iron out, hammer out, drill down, zero in on, hone in on, a deep dive, deep dive, take a deep dive, from scratch, heavy lifting, pain points, game-changer, silver bullet, magic bullet, one-stop-shop, best of breed, bleeding-edge, cutting-edge, mission-critical, world-class, turnkey, synergy, leverage, it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say, prior to, subsequent to, in the event that, in the event of, for the purpose of, with the intention of, at this point in time, currently, presently, as of this writing, naturally, delve, seamless, robust, comprehensive, landscape, realm, testament to, unlock, empower, elevate, streamline, pivotal, underscores, showcase, plethora, myriad, foster, harness, ever-evolving, in today's, it's worth noting, rest assured, at its core, when it comes to, dive into. - -## AI-writing patterns the judge looks for - -See `.claude/references/humanizer-rules.md`. In short: no inflated significance, no tacked-on "-ing" clauses, no promotional language, no unsourced attributions, no "not just X, but Y", no false ranges, and no synonym cycling. diff --git a/.claude/skills/dale/rules/collaborative-leftovers.yml b/.claude/skills/dale/rules/collaborative-leftovers.yml new file mode 100644 index 0000000000..c345754f64 --- /dev/null +++ b/.claude/skills/dale/rules/collaborative-leftovers.yml @@ -0,0 +1,3 @@ +message: Remove text that reads like a reply to a prompt. +level: warning +reason: 'Fires on chatbot leftovers: ''Certainly, here is...'', ''I hope this helps'', ''As an AI'', ''Let me know if you would like...'', ''Feel free to ask'', or a knowledge-cutoff disclaimer such as ''as of my last update''.' diff --git a/.claude/skills/dale/rules/copula-avoidance.yml b/.claude/skills/dale/rules/copula-avoidance.yml new file mode 100644 index 0000000000..37f9710324 --- /dev/null +++ b/.claude/skills/dale/rules/copula-avoidance.yml @@ -0,0 +1,3 @@ +message: Use 'is' or 'are' instead of 'serves as', 'functions as', 'acts as', or 'stands as'. +level: warning +reason: Fires when 'serves as', 'functions as', 'acts as', 'stands as', 'represents', or 'boasts' is used where a plain 'is/are/has' would say the same thing. Does not fire when the verb describes a real functional role, such as 'acts as a proxy' or 'serves as the gateway between two networks'. diff --git a/.claude/skills/dale/rules/excessive-hedging.yml b/.claude/skills/dale/rules/excessive-hedging.yml new file mode 100644 index 0000000000..82187c5073 --- /dev/null +++ b/.claude/skills/dale/rules/excessive-hedging.yml @@ -0,0 +1,3 @@ +message: Remove stacked qualifiers on documented behavior. +level: warning +reason: Fires when qualifiers are stacked past what a documented, tested behavior warrants, such as 'may potentially', 'could possibly in some cases', 'might in certain circumstances be able to'. diff --git a/.claude/skills/dale/rules/false-range.yml b/.claude/skills/dale/rules/false-range.yml new file mode 100644 index 0000000000..ef153d722c --- /dev/null +++ b/.claude/skills/dale/rules/false-range.yml @@ -0,0 +1,3 @@ +message: Name the actual items instead of a rhetorical span. +level: warning +reason: Fires when a rhetorical 'from X to Y' or 'ranging from X to Y' span stands in for a real list, such as 'from basic reporting to advanced analytics' or 'ranging from simple fixes to complex overhauls'. Does not fire on real numeric, date, or version ranges. diff --git a/.claude/skills/dale/rules/filler-phrases.yml b/.claude/skills/dale/rules/filler-phrases.yml new file mode 100644 index 0000000000..f2564918a6 --- /dev/null +++ b/.claude/skills/dale/rules/filler-phrases.yml @@ -0,0 +1,3 @@ +message: Cut the throat-clearing; it adds words, not information. +level: warning +reason: Fires on throat-clearing and filler such as 'It is important to note that', 'It is worth mentioning', 'In today's fast-paced environment', 'needless to say', 'at the end of the day', 'it should be noted that', when the sentence works without it. Does not fire on 'in order to' (a separate wordiness rule covers it). diff --git a/.claude/skills/dale/rules/generic-conclusion.yml b/.claude/skills/dale/rules/generic-conclusion.yml new file mode 100644 index 0000000000..54f216f1d7 --- /dev/null +++ b/.claude/skills/dale/rules/generic-conclusion.yml @@ -0,0 +1,3 @@ +message: End on the last concrete instruction or fact. +level: warning +reason: Fires when a closing paragraph or section restates the page's value in vague, feel-good terms (for example 'By following these steps, you can ensure a smooth and efficient experience') instead of ending on the last concrete instruction or fact. Does not fire on a sentence that states the concrete expected result of a step, such as 'The service should now start.' diff --git a/.claude/skills/dale/rules/inflated-significance.yml b/.claude/skills/dale/rules/inflated-significance.yml new file mode 100644 index 0000000000..d036574072 --- /dev/null +++ b/.claude/skills/dale/rules/inflated-significance.yml @@ -0,0 +1,3 @@ +message: Do not frame a routine feature or step as uniquely important ('plays a crucial role', 'serves as a cornerstone', 'is essential to ensuring'). State what it does. +level: warning +reason: Fires when a sentence inflates the significance of a routine feature or step with phrases like 'plays a crucial/vital/key role', 'serves as a cornerstone', 'is essential to ensuring', 'a testament to'. Does not fire on plain factual statements that something is required (for example 'You must have Domain Admin rights'). diff --git a/.claude/skills/dale/rules/ing-analysis.yml b/.claude/skills/dale/rules/ing-analysis.yml new file mode 100644 index 0000000000..aad74c430a --- /dev/null +++ b/.claude/skills/dale/rules/ing-analysis.yml @@ -0,0 +1,3 @@ +message: Cut the trailing '-ing' clause or replace it with a specific consequence. +level: warning +reason: "Fires when a sentence states a fact and then tacks on a trailing participle clause or ', which makes/gives/ensures ...' clause that interprets or praises instead of stating a concrete result. Typical openers: ensuring, reflecting, highlighting, underscoring, emphasizing, demonstrating, showcasing, strengthening, making this/it ..., giving you ..., helping you ..., which makes/means .... Examples: 'The scan runs nightly, ensuring your data stays current'; 'The policy applies to all users, highlighting its importance'; 'The engine evaluates each rule, highlighting gaps and reflecting the state of your environment'. If a clause names a specific, checkable effect ('the job writes a log, which you can review under Reports') it is not a violation. Does not fire on '-ing' clauses that describe a concrete action." diff --git a/.claude/skills/dale/rules/negative-parallelism.yml b/.claude/skills/dale/rules/negative-parallelism.yml new file mode 100644 index 0000000000..5a22a5dd1a --- /dev/null +++ b/.claude/skills/dale/rules/negative-parallelism.yml @@ -0,0 +1,3 @@ +message: Do not use 'not just X, but Y' or 'isn't merely X, it's Y'. +level: warning +reason: 'Fires on negative parallelism: ''not just X but Y'', ''not only X but also Y'', ''isn''t merely X, it''s Y'', ''this isn''t about X, it''s about Y''. Does not fire on a plain contrast that conveys real information, such as ''Use the service account, not your personal account''.' diff --git a/.claude/skills/dale/rules/promotional-language.yml b/.claude/skills/dale/rules/promotional-language.yml new file mode 100644 index 0000000000..6c74b2fb40 --- /dev/null +++ b/.claude/skills/dale/rules/promotional-language.yml @@ -0,0 +1,3 @@ +message: Replace unsupported praise with a specific, checkable claim. +level: warning +reason: Fires when marketing praise such as powerful, robust, seamless, cutting-edge, industry-leading, game-changing, best-in-class, comprehensive solution is used as unsupported praise rather than a specific, checkable claim. diff --git a/.claude/skills/dale/rules/sycophantic-tone.yml b/.claude/skills/dale/rules/sycophantic-tone.yml new file mode 100644 index 0000000000..b73248e0fc --- /dev/null +++ b/.claude/skills/dale/rules/sycophantic-tone.yml @@ -0,0 +1,3 @@ +message: Remove servile or flattering phrasing. +level: warning +reason: Fires on sycophantic phrasing aimed at a requester, such as 'Great question', 'You are absolutely right to ask', 'Excellent choice', 'Good thinking'. diff --git a/.claude/skills/dale/rules/vague-attribution.yml b/.claude/skills/dale/rules/vague-attribution.yml new file mode 100644 index 0000000000..64cb297846 --- /dev/null +++ b/.claude/skills/dale/rules/vague-attribution.yml @@ -0,0 +1,3 @@ +message: Name the source or drop the claim. +level: warning +reason: Fires when a claim is attributed to unnamed people or sources with no citation, such as 'users report', 'many organizations find', 'it is widely recognized', 'experts agree', 'studies show'. diff --git a/.claude/skills/doc-draft/SKILL.md b/.claude/skills/doc-draft/SKILL.md index 15bd29ba78..3957bec706 100644 --- a/.claude/skills/doc-draft/SKILL.md +++ b/.claude/skills/doc-draft/SKILL.md @@ -27,7 +27,7 @@ You lint your own drafts: run the style check in step 9 of "Draft new documentat 6. Anticipate reader questions and answer them inline 7. Reread the drafted file. Confirm the frontmatter has a `description` on every page you wrote. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. 8. Write plainly the first time, and never trade meaning for shorter sentences: keep every fact, condition, qualifier, and requirement. Do not rewrite a finished draft to hit readability numbers. If the writer asks how readable the page is, run `npm run quality:readability -- ` and report the numbers; it is a report, not a target. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. -9. Lint the page before you finish. Run `npm run quality:score -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. +9. Lint the page before you finish. Run `npm run quality:check -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. ### Draft from a pull request diff --git a/.claude/skills/doc-help/SKILL.md b/.claude/skills/doc-help/SKILL.md index 2138c6c6bd..1cc4099953 100644 --- a/.claude/skills/doc-help/SKILL.md +++ b/.claude/skills/doc-help/SKILL.md @@ -109,7 +109,7 @@ Re-read the full document and check for: - Flow and consistency across sections - Redundancy or contradictions - Any sentence that doesn't earn its place -- Style check: before you hand back, run `npm run quality:score -- --table` (Vale, Dale, and the AI-isms check on what changed since `origin/dev`) and fix what it flags without changing any fact. `.claude/references/lint-rules.md` lists the rules. +- Style check: before you hand back, run `npm run quality:check -- --table` (Vale, Dale, and the AI-isms check on what changed since `origin/dev`) and fix what it flags without changing any fact. `.claude/references/lint-rules.md` lists the rules. - Readability: write plainly the first time, and do not shorten sentences at the cost of a fact, condition, or qualifier. `npm run quality:readability -- ` is an optional report; run it only if the writer asks, and report the numbers without revising to meet them. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. Provide a short summary of any final suggestions. Ask if the writer wants to refine anything or if the doc is ready. diff --git a/.config/hk.pkl b/.config/hk.pkl index 315246dfe8..2752cc369d 100644 --- a/.config/hk.pkl +++ b/.config/hk.pkl @@ -25,7 +25,21 @@ hooks { ["pre-commit"] { fix = true stash = "git" - steps = docsSteps + steps = (docsSteps) { + // Blocks the commit on Vale, Dale, and AI-isms findings: new pages whole, existing + // pages on the lines this commit changes. Reads the staged text, so no {{files}}. + // Dale uses ANTHROPIC_API_KEY or the claude CLI; without either it skips Dale and says so. + ["quality"] { + glob = List("docs/**/*.md", "docs/**/*.mdx") + exclude = notLinted + check = "node scripts/quality/check.mjs --staged --table" + } + // Warns, never blocks, about unresolved {/* TODO: ... */} draft markers. + ["todo"] { + glob = List("docs/**/*.md", "docs/**/*.mdx") + check = "node scripts/quality/todo-check.mjs --staged || true" + } + } } // Blocks the push while a Vale warning remains on a line the branch changed. // Check-only, so hk doesn't suggest a --fix that can't clear what's left. diff --git a/.github/workflows/vale-autofix.yml b/.github/workflows/vale-autofix.yml new file mode 100644 index 0000000000..45e24000b3 --- /dev/null +++ b/.github/workflows/vale-autofix.yml @@ -0,0 +1,461 @@ +name: Vale Auto-Fix + +on: + pull_request: + types: [opened, synchronize] + branches: + - dev + paths: + - 'docs/**/*.md' + - '!docs/**/CLAUDE.md' + - '!docs/**/SKILL.md' + - '!docs/kb/**' + - 'scripts/**' + - 'package.json' + +concurrency: + group: vale-autofix-${{ github.event.pull_request.number }} + +jobs: + test-scripts: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Check if scripts changed + id: scripts-changed + run: | + CHANGED=$(git diff --name-only origin/dev...HEAD -- scripts/ package.json) + if [ -z "$CHANGED" ]; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Node.js + if: steps.scripts-changed.outputs.changed == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.x' + cache: npm + + - name: Install dependencies + if: steps.scripts-changed.outputs.changed == 'true' + run: npm ci --ignore-scripts + + - name: Test anchor/slugify scripts + if: steps.scripts-changed.outputs.changed == 'true' + run: npm run test:scripts + + vale-autofix: + needs: test-scripts + if: >- + github.event.pull_request.head.repo.fork == false && + github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check if triggered by bot commit + id: bot-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Get the latest commit on the PR head + COMMIT=$(gh api repos/${{ github.repository }}/commits/${{ github.event.pull_request.head.sha }} --jq '{author: .commit.author.name, message: .commit.message}') + AUTHOR=$(echo "$COMMIT" | jq -r '.author') + MESSAGE=$(echo "$COMMIT" | jq -r '.message') + echo "Latest commit author: $AUTHOR" + echo "Latest commit message: $MESSAGE" + + # Skip if this is a commit from the vale-autofix workflow itself + # (prevents re-trigger loops) but allow other bot commits through + # so they still get linted + if echo "$MESSAGE" | grep -qE '^fix\((vale|dale)\):'; then + echo "Skipping: commit is from vale-autofix workflow" + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout PR branch + if: steps.bot-check.outputs.skip != 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.VALE_TOKEN }} + fetch-depth: 0 + + - name: Configure git identity + if: steps.bot-check.outputs.skip != 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Get changed markdown files + id: changed-files + if: steps.bot-check.outputs.skip != 'true' + run: | + DIFF_OUTPUT=$(git diff --name-only origin/dev...HEAD -- docs/) + CHANGED_MD_FILES=$(printf '%s\n' "$DIFF_OUTPUT" | grep -E '^docs/.*\.md$' | grep -v '/CLAUDE\.md$' | grep -v '/SKILL\.md$' | grep -v '^docs/kb/' || true) + if [ -z "$CHANGED_MD_FILES" ]; then + echo "No docs markdown files changed" + echo "count=0" >> "$GITHUB_OUTPUT" + else + echo "Changed markdown files:" + echo "$CHANGED_MD_FILES" + echo "count=$(echo "$CHANGED_MD_FILES" | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT" + echo "$CHANGED_MD_FILES" > /tmp/changed-files.txt + fi + + # Same Vale version the local git hooks use (.config/mise.toml). + - name: Install Vale + if: steps.changed-files.outputs.count > 0 + uses: jdx/mise-action@2d8d4cafcbd33be2ea37d2b6f5ad595363d1f1ca # v5.1.1 + with: + install_args: vale + + - name: Show Vale version + if: steps.changed-files.outputs.count > 0 + run: vale --version + + - name: Run Vale on changed files + id: vale-initial + if: steps.changed-files.outputs.count > 0 + run: | + jq -n '[]' > /tmp/violations.json + + while IFS= read -r file; do + if [ -f "$file" ]; then + RESULT=$(vale --output JSON "$file" 2>>/tmp/vale-stderr.log || true) + if [ -n "$RESULT" ] && [ "$RESULT" != "{}" ]; then + echo "$RESULT" | jq --arg f "$file" ' + [to_entries[] | .value[] | {path: $f, line: .Line, check: .Check, message: .Message}] + ' > /tmp/vale-file.json + jq -s '.[0] + .[1]' /tmp/violations.json /tmp/vale-file.json > /tmp/vale-tmp.json + mv /tmp/vale-tmp.json /tmp/violations.json + fi + fi + done < /tmp/changed-files.txt + + TOTAL=$(jq 'length' /tmp/violations.json) + echo "total=$TOTAL" >> "$GITHUB_OUTPUT" + echo "Vale found $TOTAL issue(s)" + + - name: Phase 1 — Script fixes + id: phase1 + if: steps.vale-initial.outputs.total > 0 + run: | + chmod +x scripts/vale-autofix.sh + SUMMARY=$(./scripts/vale-autofix.sh /tmp/violations.json) + echo "$SUMMARY" > /tmp/phase1-summary.json + PHASE1_TOTAL=$(echo "$SUMMARY" | jq '.total') + echo "fixed=$PHASE1_TOTAL" >> "$GITHUB_OUTPUT" + echo "Phase 1 fixed $PHASE1_TOTAL issue(s)" + + - name: Re-run Vale for remaining violations + id: vale-remaining + if: steps.vale-initial.outputs.total > 0 + run: | + jq -n '[]' > /tmp/remaining-violations.json + + while IFS= read -r file; do + if [ -f "$file" ]; then + RESULT=$(vale --output JSON "$file" 2>>/tmp/vale-stderr.log || true) + if [ -n "$RESULT" ] && [ "$RESULT" != "{}" ]; then + echo "$RESULT" | jq --arg f "$file" ' + [to_entries[] | .value[] | {path: $f, line: .Line, check: .Check, message: .Message}] + ' > /tmp/vale-file.json + jq -s '.[0] + .[1]' /tmp/remaining-violations.json /tmp/vale-file.json > /tmp/vale-tmp.json + mv /tmp/vale-tmp.json /tmp/remaining-violations.json + fi + fi + done < /tmp/changed-files.txt + + REMAINING=$(jq 'length' /tmp/remaining-violations.json) + echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT" + echo "$REMAINING remaining violation(s) for Phase 2" + + - name: Phase 2 — Claude fixes + id: phase2 + if: steps.vale-remaining.outputs.remaining > 0 + continue-on-error: true + uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + show_full_output: true + prompt: | + You are a documentation fixer. Your job is to fix Vale linting violations in markdown files. + + Read docs/CLAUDE.md for Netwrix writing standards before making any changes. + + Here are the remaining Vale violations after mechanical fixes were applied: + + ```json + $(cat /tmp/remaining-violations.json) + ``` + + For each violation: + 1. Read the file and understand the context around the flagged line + 2. Apply a fix that resolves the Vale rule while preserving the author's meaning + 3. If you are NOT confident in a fix (ambiguous context, multiple valid interpretations, fix would change meaning) SKIP it + + Never edit heading text (a line starting with `#` through `######`, outside any fenced code block). Other files link to headings by anchor, and a heading rename breaks those links. If a violation is on a heading line, SKIP it with reason "heading text — not modified to avoid breaking anchor links". + + If a fix adds or changes a link with a `#anchor`, the anchor must match a real Markdown heading OUTSIDE any fenced code block. Text that merely looks like a heading inside a ``` code block (for example, sample CLI/installer output) is never a valid anchor target, even if it matches the wording you're looking for. Before finishing, run exactly `bash scripts/check-anchors.sh ` (that literal invocation, one file per call, no chaining with other commands) on every file where you added or changed a link anchor. The script also reports broken link targets, not just anchors — only act on a finding for an anchor your own edit introduced: find the correct heading and fix it, or SKIP the violation if no correct target exists. + + After fixing, write a JSON summary to /tmp/phase2-summary.json with this structure: + ```json + { + "fixed": [ + {"path": "file.md", "line": 1, "check": "Netwrix.RuleName", "action": "brief description of fix"} + ], + "skipped": [ + {"path": "file.md", "line": 1, "check": "Netwrix.RuleName", "reason": "why it was skipped"} + ] + } + ``` + + IMPORTANT: Write the summary JSON file, then stop. Do NOT run any git commands — the workflow handles staging, committing, and pushing. Do not post any PR comments. + claude_args: '--allowedTools "Bash(git:*),Bash(bash scripts/check-anchors.sh:*),Bash(./scripts/check-anchors.sh:*),Bash(scripts/check-anchors.sh:*),Read,Write,Edit,Glob,Grep"' + + - name: Phase 3 — Dale fixes + id: phase3 + if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 + continue-on-error: true + uses: anthropics/claude-code-action@756cc22e19660d20e8cc9496b4f242475a7f7790 # v1 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + show_full_output: true + prompt: | + You are Dale, a documentation fixer. Your job is to find and fix documentation style issues using Dale rules. + + Read docs/CLAUDE.md for Netwrix writing standards before making any changes. + + INSTRUCTIONS: + + Step 1: Read every .yml rule file in .claude/skills/dale/rules/ + + Step 2: Read the list of changed files from /tmp/changed-files.txt (one file path per line). + + Step 3: Read each changed file. For each file, check every line against each rule's "reason" field. + + Step 4: Fix each violation in-place, preserving the author's meaning. If you are NOT confident in a fix (ambiguous context, multiple valid interpretations, fix would change meaning), SKIP it. + + Never edit heading text (a line starting with `#` through `######`, outside any fenced code block). Other files link to headings by anchor, and a heading rename breaks those links. If a violation is on a heading line, SKIP it with reason "heading text — not modified to avoid breaking anchor links". + + If a fix adds or changes a link with a `#anchor` (for example, resolving a "positional-references" violation into an explicit link), the anchor must match a real Markdown heading OUTSIDE any fenced code block. Text that merely looks like a heading inside a ``` code block (for example, sample CLI/installer output) is never a valid anchor target. Before finishing, run exactly `bash scripts/check-anchors.sh ` (that literal invocation, one file per call, no chaining with other commands) on every file where you added or changed a link anchor. The script also reports broken link targets, not just anchors — only act on a finding for an anchor your own edit introduced: find the correct heading and fix it, or SKIP the violation if no correct target exists. + + Step 5: Write a JSON summary to /tmp/dale-summary.json with this structure: + ```json + { + "fixed": [ + {"path": "file.md", "line": 1, "rule": "passive-voice", "action": "brief description of fix"} + ], + "skipped": [ + {"path": "file.md", "line": 1, "rule": "idioms", "reason": "why it was skipped"} + ] + } + ``` + If no issues found, write: {"fixed": [], "skipped": []} + + Step 6: Write the summary JSON file (see Step 5 format above), then stop. + + IMPORTANT: Do NOT run any git commands — the workflow handles staging, committing, and pushing. Do not post any PR comments. + claude_args: '--allowedTools "Bash(git:*),Bash(bash scripts/check-anchors.sh:*),Bash(./scripts/check-anchors.sh:*),Bash(scripts/check-anchors.sh:*),Read,Write,Edit,Glob,Grep"' + + - name: Check anchor links + id: anchor-check + if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 + continue-on-error: true + run: | + chmod +x scripts/check-anchors.sh + touch /tmp/anchor-errors.txt + BROKEN=0 + mapfile -t FILES < /tmp/changed-files.txt + ./scripts/check-anchors.sh "${FILES[@]}" > /tmp/anchor-check-output.txt 2>&1 || BROKEN=1 + if [ "$BROKEN" -eq 1 ]; then + grep '^\s' /tmp/anchor-check-output.txt > /tmp/anchor-errors.txt || true + BROKEN=$(grep -oE 'Found [0-9]+' /tmp/anchor-check-output.txt | grep -oE '[0-9]+' | tail -1 || echo "0") + echo "Broken anchors found:" + cat /tmp/anchor-check-output.txt + fi + echo "broken=$BROKEN" >> "$GITHUB_OUTPUT" + + - name: Commit all fixes + if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 + run: | + while IFS= read -r file; do + [ -f "$file" ] && git add -- "$file" + done < /tmp/changed-files.txt + if git diff --cached --quiet; then + echo "No changes to commit" + else + git commit -m "fix(vale): auto-fix style issues (Vale + Dale)" + fi + + - name: Push all fixes + if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 + env: + VALE_TOKEN: ${{ secrets.VALE_TOKEN }} + run: | + # Re-configure git credentials with VALE_TOKEN (claude-code-action + # overrides credentials with GITHUB_TOKEN, which doesn't trigger workflows) + git remote set-url origin "https://x-access-token:${VALE_TOKEN}@github.com/${{ github.repository }}.git" + if [ "$(git rev-list @{u}..HEAD --count 2>/dev/null)" -gt 0 ]; then + echo "Pushing $(git rev-list @{u}..HEAD --count) commit(s)..." + git push + else + echo "No new commits to push" + fi + + - name: Build and post summary comment + if: steps.bot-check.outputs.skip != 'true' && steps.changed-files.outputs.count > 0 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=${{ github.event.pull_request.number }} + REPO=${{ github.repository }} + + # Collect Phase 1 stats + PHASE1_TOTAL=0 + PHASE1_BODY="" + if [ -f /tmp/phase1-summary.json ]; then + PHASE1_TOTAL=$(jq '.total' /tmp/phase1-summary.json) + # Build category rows from Phase 1 + PHASE1_BODY=$(jq -r ' + .by_category | to_entries | sort_by(.key) | .[] | + "| \(.key) | \(.value) |" + ' /tmp/phase1-summary.json) + fi + + # Collect Phase 2 stats + PHASE2_FIXED=0 + PHASE2_SKIPPED=0 + PHASE2_BODY="" + SKIPPED_BODY="" + if [ -f /tmp/phase2-summary.json ]; then + PHASE2_FIXED=$(jq '.fixed | length' /tmp/phase2-summary.json) + PHASE2_SKIPPED=$(jq '.skipped | length' /tmp/phase2-summary.json) + + if [ "$PHASE2_FIXED" -gt 0 ]; then + PHASE2_BODY=$(jq -r ' + [.fixed[] | .check] | group_by(.) | .[] | + "| \(.[0] | sub("Netwrix\\."; "")) (rewrite) | \(length) |" + ' /tmp/phase2-summary.json) + fi + + if [ "$PHASE2_SKIPPED" -gt 0 ]; then + SKIPPED_BODY=$(jq -r ' + .skipped[] | + "| `\(.path):\(.line)` — \(.check) | \(.reason) |" + ' /tmp/phase2-summary.json) + fi + fi + + # Collect Phase 3 (Dale) stats + DALE_FIXED=0 + DALE_SKIPPED=0 + DALE_BODY="" + DALE_SKIPPED_BODY="" + if [ -f /tmp/dale-summary.json ]; then + DALE_FIXED=$(jq '.fixed | length' /tmp/dale-summary.json) + DALE_SKIPPED=$(jq '.skipped | length' /tmp/dale-summary.json) + + if [ "$DALE_FIXED" -gt 0 ]; then + DALE_BODY=$(jq -r ' + [.fixed[] | .rule] | group_by(.) | .[] | + "| Dale: \(.[0]) | \(length) |" + ' /tmp/dale-summary.json) + fi + + if [ "$DALE_SKIPPED" -gt 0 ]; then + DALE_SKIPPED_BODY=$(jq -r ' + .skipped[] | + "| `\(.path):\(.line)` — Dale: \(.rule) | \(.reason) |" + ' /tmp/dale-summary.json) + fi + fi + + TOTAL_FIXED=$((PHASE1_TOTAL + PHASE2_FIXED + DALE_FIXED)) + TOTAL_SKIPPED=$((PHASE2_SKIPPED + DALE_SKIPPED)) + + # Count affected files + FILE_COUNT=$(wc -l < /tmp/changed-files.txt | tr -d ' ') + + # Collect anchor check results + BROKEN_ANCHORS=0 + ANCHOR_BODY="" + if [ -f /tmp/anchor-check-output.txt ] && [ -s /tmp/anchor-check-output.txt ]; then + BROKEN_ANCHORS=$(grep -oE 'Found [0-9]+' /tmp/anchor-check-output.txt | grep -oE '[0-9]+' | tail -1 || echo "0") + ANCHOR_BODY=$(cat /tmp/anchor-errors.txt 2>/dev/null || true) + fi + + # Build the summary comment + { + if [ "$BROKEN_ANCHORS" -gt 0 ]; then + echo "## ⚠️ Broken Anchor Links" + echo "" + echo "**${BROKEN_ANCHORS} broken anchor link(s) found — these will cause the build to fail.**" + echo "" + echo "\`\`\`" + echo "$ANCHOR_BODY" + echo "\`\`\`" + echo "" + fi + + echo "## Auto-Fix Summary" + echo "" + echo "**${TOTAL_FIXED} issues fixed, ${TOTAL_SKIPPED} skipped across ${FILE_COUNT} files**" + echo "" + + if [ "$TOTAL_FIXED" -gt 0 ]; then + echo "| Category | Fixes |" + echo "|----------|-------|" + if [ -n "$PHASE1_BODY" ]; then + echo "$PHASE1_BODY" + fi + if [ -n "$PHASE2_BODY" ]; then + echo "$PHASE2_BODY" + fi + if [ -n "$DALE_BODY" ]; then + echo "$DALE_BODY" + fi + echo "" + fi + + if [ "$TOTAL_SKIPPED" -gt 0 ]; then + echo "| Skipped (needs manual review) | Reason |" + echo "|-------------------------------|--------|" + echo "$SKIPPED_BODY" + if [ -n "$DALE_SKIPPED_BODY" ]; then + echo "$DALE_SKIPPED_BODY" + fi + echo "" + fi + + echo 'Ask `@claude` on this PR if you'\''d like an explanation of any fix.' + } > /tmp/vale-summary.md + + # Delete previous Vale Auto-Fix comments + COMMENT_IDS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | contains("## Auto-Fix Summary") or .body | contains("## ⚠️ Broken Anchor Links"))) | .id] | .[]' 2>/dev/null || true) + for ID in $COMMENT_IDS; do + gh api "repos/${REPO}/issues/comments/${ID}" -X DELETE 2>/dev/null || true + done + + # Post new summary + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file /tmp/vale-summary.md diff --git a/.vale/styles/Netwrix/AssistantLeftovers.yml b/.vale/styles/Netwrix/AssistantLeftovers.yml new file mode 100644 index 0000000000..243f242eff --- /dev/null +++ b/.vale/styles/Netwrix/AssistantLeftovers.yml @@ -0,0 +1,13 @@ +extends: existence +message: "Remove '%s'. It reads like a chat reply, not documentation." +level: error +ignorecase: true +tokens: + - '\bI hope this helps\b' + - '\blet me know if\b' + - '\bas an AI\b(?! Information Protection)' + - '\bas of my (?:last|latest) (?:update|training)\b' + - '\bcertainly[,!]\s+here' + - '\bhere''s (?:a|an|the) (?:breakdown|overview|summary|rundown)\b' + - '\bgreat question\b' + - "\\byou're absolutely right\\b" diff --git a/.vale/styles/Netwrix/CopulaAvoidance.yml b/.vale/styles/Netwrix/CopulaAvoidance.yml new file mode 100644 index 0000000000..c3b94950fd --- /dev/null +++ b/.vale/styles/Netwrix/CopulaAvoidance.yml @@ -0,0 +1,8 @@ +extends: existence +message: "Use 'is' or 'are' instead of '%s'." +level: warning +ignorecase: true +tokens: + - '\bserv(?:es|ed|ing) as (?:a|an|the)\b' + - '\bstand(?:s|ing) as (?:a|an|the)\b' + - '\bboasts?\b' diff --git a/.vale/styles/Netwrix/FillerPhrases.yml b/.vale/styles/Netwrix/FillerPhrases.yml new file mode 100644 index 0000000000..31adf7f203 --- /dev/null +++ b/.vale/styles/Netwrix/FillerPhrases.yml @@ -0,0 +1,15 @@ +extends: existence +message: "Remove the filler phrase '%s'." +level: warning +ignorecase: true +tokens: + - "\\bin today's (?:fast.paced|digital|modern|ever.changing|rapidly evolving)\\b" + - '\bin the (?:modern|digital) (?:age|era|world)\b' + - '\bit goes without saying\b' + - '\bwhen it comes to\b' + - '\bat its core\b' + - '\bat the heart of\b' + - '\blet''s dive\b' + - '\bdive (?:in|into)\b' + - '\bin the (?:ever.evolving|ever.changing) \w+' + - '\bever.(?:evolving|changing) (?:landscape|world|threat)\b' diff --git a/.vale/styles/Netwrix/Idioms.yml b/.vale/styles/Netwrix/Idioms.yml index 9b39436deb..e873e95bab 100644 --- a/.vale/styles/Netwrix/Idioms.yml +++ b/.vale/styles/Netwrix/Idioms.yml @@ -76,7 +76,6 @@ tokens: - '\bpain points?\b' # Tech marketing buzzwords - - '\bgame.changer\b' - '\bsilver bullet\b' - '\bmagic bullet\b' - '\bone.stop.shop\b' diff --git a/.vale/styles/Netwrix/InflatedSignificance.yml b/.vale/styles/Netwrix/InflatedSignificance.yml new file mode 100644 index 0000000000..bc75838e09 --- /dev/null +++ b/.vale/styles/Netwrix/InflatedSignificance.yml @@ -0,0 +1,13 @@ +extends: existence +message: "Remove the inflated-significance phrase '%s'. State what the feature does." +level: warning +ignorecase: true +tokens: + - '\b(?:plays?|played|playing) an? (?:crucial|vital|key|critical|pivotal|significant|essential|important) role\b' + - '\bserves? as an? (?:cornerstone|foundation|testament|backbone|linchpin)\b' + - '\b(?:cornerstone|linchpin) of\b' + - '\ba testament to\b' + - '\b(?:is|are) (?:essential|crucial|vital|critical) (?:to|for) ensuring\b' + - '\b(?:is|are) (?:essential|crucial|vital) in ensuring\b' + - '\bin ensuring that\b' + - '\bplays? a (?:pivotal|crucial|vital) part\b' diff --git a/.vale/styles/Netwrix/NegativeParallelism.yml b/.vale/styles/Netwrix/NegativeParallelism.yml new file mode 100644 index 0000000000..380d743d46 --- /dev/null +++ b/.vale/styles/Netwrix/NegativeParallelism.yml @@ -0,0 +1,8 @@ +extends: existence +message: "Avoid the 'not just X, but Y' construction ('%s'). State the point directly." +level: warning +ignorecase: true +scope: sentence +tokens: + - '\bnot (?:just|only|merely|simply)\b[^.!?\n]{0,80}\bbut (?:also )?\b' + - '\b(?:isn''t|is not|aren''t|are not|doesn''t|does not) (?:just|merely|simply) [^.!?\n]{0,60}(?:—|--|;|,) (?:it|they|it''s|it is|they''re)\b' diff --git a/.vale/styles/Netwrix/PromotionalLanguage.yml b/.vale/styles/Netwrix/PromotionalLanguage.yml new file mode 100644 index 0000000000..119e9a53b3 --- /dev/null +++ b/.vale/styles/Netwrix/PromotionalLanguage.yml @@ -0,0 +1,16 @@ +extends: existence +message: "Avoid promotional wording ('%s'). Describe the specific capability instead." +level: warning +ignorecase: true +tokens: + - '\bpowerful\b' + - '\bindustry.leading\b' + - '\bbest.in.class\b' + - '\bstate.of.the.art\b' + - '\brevolutionary\b' + - '\bgroundbreaking\b' + - '\bnext.generation\b' + - '\bunparalleled\b' + - '\bunmatched\b' + - '\beffortless(?:ly)?\b' + - '\bgame.chang(?:ing|er)\b' diff --git a/.vale/styles/Netwrix/TackedOnParticiple.yml b/.vale/styles/Netwrix/TackedOnParticiple.yml new file mode 100644 index 0000000000..2749b46451 --- /dev/null +++ b/.vale/styles/Netwrix/TackedOnParticiple.yml @@ -0,0 +1,8 @@ +extends: existence +message: "Cut the tacked-on clause '%s' or replace it with a specific consequence." +level: warning +ignorecase: true +nonword: true +scope: sentence +tokens: + - ',\s+(?:thereby |thus )?(?:ensuring|underscoring|reflecting|showcasing|fostering|emphasizing|cementing|solidifying|reinforcing|demonstrating)\b' diff --git a/CLAUDE.md b/CLAUDE.md index d3338368ec..02985ab431 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,8 +20,8 @@ npm run clear # Clear Docusaurus cache (fixes stale build issues) # Linting vale # Run Vale style checker on a markdown file /dale # Run Dale linter (Claude skill) on a markdown file -npm run quality:score # Vale + Dale + AI-isms check on your staged or branch docs (what the pre-commit hook runs) -npm run quality:score -- --file # the same check on one page, whole +npm run quality:check # Vale + Dale + AI-isms check on your staged or branch docs (what the pre-commit hook runs) +npm run quality:check -- --file # the same check on one page, whole # Vale and the git hooks (hk) are pinned in .config/mise.toml # macOS: brew install mise Windows: winget install jdx.mise @@ -74,7 +74,7 @@ PRs target `dev`. Never commit directly to `dev` or `main`. The `sync-dev-to-mai | Workflow | Trigger | Purpose | |---|---|---| | `build-and-deploy.yml` | Push to main/dev, PRs to dev, manual | Build the site. Pushes deploy to Azure (`dev` to development, `main` to production); PRs build only | -| `script-tests.yml` | PRs that change `scripts/` or `package.json` | Runs the repo's script tests. Docs are linted before the PR (pre-commit: Vale, Dale, AI-isms); nothing lints on the PR | +| `script-tests.yml` | PRs that change `scripts/` or `package.json` | Runs the repo's script tests. Docs are linted before the PR (pre-commit: Vale, Dale, AI-isms); `vale-autofix.yml` still lints on the PR as a backstop | | `md-extension-autofix.yml` | PRs to dev | Adds missing `.md` extensions and frontmatter, and commits the fix | | `claude-code-review.yml` | PRs to dev that change non-doc files | Code review comment | | `claude-code-followup.yml` | `@claude` comment on a code PR (owners, members, collaborators) | Answers or applies a fix | @@ -120,8 +120,6 @@ Project hooks are in `.claude/settings.json`: - **PostToolUse (Bash)**: After running `vale`, reminds to fix and re-run until clean - **Stop**: When Claude finishes a turn, runs the style check (Vale, Dale, AI-isms) on the docs this session edited and hands any findings back to Claude, up to 3 rounds -Git hook (`.husky/pre-commit`, installed by `npm install`): runs the same style check on staged docs and blocks the commit, plus the anchor-link check and a draft-marker warning. - Hook scripts live in `.claude/hooks/`. -Git hooks are defined in `.config/hk.pkl` and installed by `mise install` once the user accepts mise's trust prompt for `.config/mise.toml` (`npm install` reinstalls them after that; it never trusts the config itself). Pre-commit applies the mechanical Vale fixes (`scripts/vale-local.mjs`) and the anchor check. Pre-push blocks on Vale warnings on lines the branch changed since it left `dev`. The hooks use `.vale-local.ini`, which adds the local-only `DaleLocal` style; CI keeps `.vale.ini`. +Git hooks are defined in `.config/hk.pkl` and installed by `mise install` once the user accepts mise's trust prompt for `.config/mise.toml` (`npm install` reinstalls them after that; it never trusts the config itself). Pre-commit applies the mechanical Vale fixes (`scripts/vale-local.mjs`), runs the anchor check, blocks on the style check (Vale, Dale, AI-isms: `scripts/quality/check.mjs --staged`), and warns about draft markers. Pre-push blocks on Vale warnings on lines the branch changed since it left `dev`. The hooks use `.vale-local.ini`, which adds the local-only `DaleLocal` style; CI keeps `.vale.ini`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1850c0a508..fc126de2ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ mise install # answer yes when mise asks to trust .config/mise.toml npm run start ``` -`npm install` also installs a git pre-commit hook that lints your docs before each commit, so run it even if you only edit markdown. +The git hooks lint your docs before each commit and push, so install them even if you only edit markdown (`mise install`, then `npm install`). The dev server runs on port 4500 with hot reload — changes you make to documentation files appear immediately in the browser. @@ -50,12 +50,12 @@ The dev server runs on port 4500 with hot reload — changes you make to documen 1. Create a branch from `dev` (never commit directly to `dev` or `main`). 2. Make your changes to documentation files in `docs/`. -3. Commit your changes. The pre-commit hook fixes mechanical Vale issues in the files you commit and stages the fixes. +3. Commit your changes. The pre-commit hook fixes mechanical Vale issues in the files you commit and stages the fixes. It then blocks the commit if Vale, Dale, or the AI-writing check flags a new or changed line. Fix what it flags, `git add`, and commit again. Preview it any time with `npm run quality:check`. 4. Test the build with `npm run build`. 5. Push your branch. The pre-push hook blocks the push while any Vale warning remains on a line you changed. 6. Create a pull request (PR) targeting `dev`. -Nothing lints on the PR: that happens at commit. After you open a PR, an editorial review runs and posts results as a PR comment. To get help with editorial suggestions, comment `@claude` on the PR followed by your request. +Linting happens at commit through the hooks (or through the `doc-draft` skill). If changes skip that, CI still runs Vale and Dale on the PR and auto-fixes what it can. An editorial review also runs and posts results as a PR comment. To get help with editorial suggestions, comment `@claude` on the PR followed by your request. ## Writing standards @@ -184,11 +184,11 @@ For tasks that need design decisions first — like planning a new guide from sc | Task | Tool | |---|---| | Quick lint check on a file | `/dale docs/path/to/file.md` | -| Check your changes before committing | `npm run quality:score` | +| Check your changes before committing | `npm run quality:check` | | Plan structure for a new document | `doc-help` then `doc-draft` | | Draft a new page from a PR, issue, ticket, or specs | `doc-draft` | | Review or improve existing content | `doc-help` | -| Fix Vale errors in a file | `doc-help`, or fix by hand and run `npm run quality:score` | +| Fix Vale errors in a file | `doc-help`, or fix by hand and run `npm run quality:check` | | Edit a file for style and clarity | `doc-help` | | Understand a style rule or convention | `doc-help` | diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 487b9babe1..87b5c05cf8 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -68,7 +68,7 @@ The four core qualities: ### Vale -Vale enforces 43 Netwrix-specific rules in `.vale/styles/Netwrix/` covering word choice, punctuation, formatting, and common writing issues. Vale runs before the PR: the pre-commit hook blocks a commit while Vale flags a new page, or the lines you changed on an existing page. Nothing lints on the PR, so fix findings before you push. Vale must be installed. Vale and Dale skip bold text (UI labels) and double-quoted text (verbatim product messages), so write each label exactly as the product shows it, in bold, and never reword either to clear a finding. If a UI label is flagged because it isn't bold, bold it. For exact product text that is neither, wrap the line in `` and `` for that one rule. Preview issues with: +Vale enforces 43 Netwrix-specific rules in `.vale/styles/Netwrix/` covering word choice, punctuation, formatting, and common writing issues. Vale runs before the PR: the pre-commit hook blocks a commit while Vale flags a new page, or the lines you changed on an existing page. CI (`vale-autofix.yml`) lints again on the PR as a backstop, but fix findings before you push. Vale must be installed. Vale and Dale skip bold text (UI labels) and double-quoted text (verbatim product messages), so write each label exactly as the product shows it, in bold, and never reword either to clear a finding. If a UI label is flagged because it isn't bold, bold it. For exact product text that is neither, wrap the line in `` and `` for that one rule. Preview issues with: ```bash vale @@ -94,23 +94,23 @@ Run Dale locally with `/dale `. ### Pre-PR style check (local, required) -Every docs contribution is linted before it reaches a PR: Vale, Dale, and a check for signs of AI writing. There is no CI job for this; it runs on your machine. Run it on a page with `npm run quality:score -- --file `. The hook is installed by `npm install`. Skipping it on purpose (`git commit --no-verify`) is allowed. +Every docs contribution is linted before it reaches a PR: Vale, Dale, and a check for signs of AI writing. It runs on your machine; if a change skips it, `vale-autofix.yml` still runs Vale and Dale on the PR. Run it on a page with `npm run quality:check -- --file `. The hook is installed by `npm install`. Skipping it on purpose (`git commit --no-verify`) is allowed. - **Scope:** a new page is checked whole. An existing page is checked only on the lines the change adds or rewrites; text it already had is never flagged. A page git detects as a copy or rename (50% or more similar) counts as existing, so cloning a version directory only checks what differs. `docs/kb/` is out of scope. - **Vale:** the repo's Vale rules, from the page text (so the staged version is checked). Vale must be installed, or the commit is blocked with install steps. The Stop hook runs without that requirement. -- **Dale:** a model applies the 10 rules in `.claude/skills/dale/rules/` in one call per page. Like the judge, it runs 3 times, keeps findings that 2 runs report at the same spot, and counts only findings whose quote appears verbatim on a changed line. Verdicts are cached. +- **Dale:** a model applies the rules in `.claude/skills/dale/rules/` in one call per page. They cover style (passive voice, wordiness) and AI-writing patterns that need judgment (inflated significance, tacked-on "-ing" clauses, vague attribution, negative parallelism). It runs 3 times, keeps findings that 2 runs report at the same spot, and counts only findings whose quote appears verbatim on a changed line. Verdicts are cached. - **Frontmatter:** a new page needs a non-empty `description:` in its frontmatter. Existing pages and partials (a leading underscore) are not checked. - **Deterministic AI-isms:** `scripts/doc-draft/slop.mjs` (catalog in `scripts/doc-draft/ai-isms.yml`). Structural signals such as sentence-length variance apply only to new pages. -- **Judged:** a Haiku 4.5 judge applies the 16 patterns in `.claude/references/humanizer-rules.md`. It runs 3 times per page and keeps findings that at least 2 runs report at the same spot. A finding counts only if its quote appears verbatim on a changed line. Verdicts are cached by content hash in `.cache/quality`. +- **Fixed AI phrases:** Vale rules catch the AI-writing patterns that are fixed phrases (promotional adjectives, "serves as", and similar). Dale catches the paraphrased versions. - **Draft markers:** write unresolved gaps as `{/* TODO: ... */}` so they never render. `scripts/quality/todo-check.mjs` warns (never blocks) about leftovers at commit and in the Stop hook. -- **Approved phrases:** `judge.ignoreQuotes`, `judge.ignoreReasons`, `dale.ignoreQuotes`, and `vale.ignore` (a Vale rule name) in `scripts/quality/config.json`. +- **Approved phrases:** `dale.ignoreQuotes`, and `vale.ignore` (a Vale rule name) in `scripts/quality/config.json`. Where it runs: -- **Pre-commit (husky):** `scripts/quality/score.mjs --staged` blocks the commit while anything is flagged. The judge uses `ANTHROPIC_API_KEY` if set, and otherwise your Claude Code login (`claude -p`). With neither, it runs the deterministic checks only and says so. +- **Pre-commit (hk, `.config/hk.pkl`):** `scripts/quality/check.mjs --staged` blocks the commit while anything is flagged. Dale uses `ANTHROPIC_API_KEY` if set, and otherwise your Claude Code login (`claude -p`). With neither, it runs the deterministic and Vale checks only and says so. - **Claude Code Stop hook:** `.claude/hooks/stop-quality-check.sh` runs the same check when Claude finishes, but only on the docs that session edited (`post-edit-record.sh` records each file written with Edit or Write, so a file changed through a shell command is not recorded). It hands the findings back to Claude to rewrite, up to 3 rounds, and never touches other drafts on the branch. Pre-commit is the hard gate and checks everything staged. -Run it by hand with `npm run quality:score` (`-- --staged` for the index). `npm run quality:fix -- --staged` is an optional helper (same model access as the judge) that drops flagged filler, swaps plain words, and rewords flagged sentences. Each edit is validated, drops and rewords get a meaning check, and a reword may not lose a number, negation, or requirement word. A reword the meaning check doubts is still applied but printed as `review`; read those in `git diff`. A drop the check doubts goes to a patch instead. +Run it by hand with `npm run quality:check` (`-- --staged` for the index). `npm run quality:fix -- --staged` is an optional helper (same model access as Dale) that drops flagged filler, swaps plain words, and rewords flagged sentences. Each edit is validated, drops and rewords get a meaning check, and a reword may not lose a number, negation, or requirement word. A reword the meaning check doubts is still applied but printed as `review`; read those in `git diff`. A drop the check doubts goes to a patch instead. ### Readability (optional report) diff --git a/package.json b/package.json index 9f9c9c7808..6b74e900db 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "kb:dry": "node scripts/copy-kb-to-versions.mjs --dry", "kb:test:aa": "npm run kb:clean && npm run prestart && npm run start", "kb:prodtest:aa": "npm run kb:clean && npm run prebuild && npm run build && npm run serve", - "quality:score": "node scripts/quality/score.mjs --table", + "quality:check": "node scripts/quality/check.mjs --table", "quality:readability": "node scripts/quality/readability.mjs", "quality:fix": "node scripts/quality/fix-loop.mjs", "test:scripts": "bash scripts/test-slugify.sh && bash scripts/test-heading-skip.sh && bash scripts/test-md-extension-autofix.sh && node --test scripts/test-set-blob-metadata.mjs && node scripts/test-set-blob-metadata-integration.mjs && node --test scripts/claude/test-log-summary.mjs && node --test scripts/test-vale-local.mjs && node scripts/quality/gen-lint-digest.mjs --check", diff --git a/scripts/doc-draft/ai-isms.yml b/scripts/doc-draft/ai-isms.yml index 2cae32fb07..69b17a747d 100644 --- a/scripts/doc-draft/ai-isms.yml +++ b/scripts/doc-draft/ai-isms.yml @@ -162,7 +162,6 @@ lexical: 'comprehensive': '\bcomprehensive(?:ly)?\b' 'landscape': '\blandscape\b' 'realm': '\brealm\b' - 'testament to': '\btestament to\b' # 'unlock' is a real UI/security verb in this domain (unlock a locked # account, click **Unlock**) — corpus check found it constantly, in # known-limitations.md, settings/users.md (heading "Unlock a User", "clicks @@ -188,13 +187,8 @@ lexical: 'myriad': '\bmyriad\b' 'foster': '\bfoster(?:s|ed|ing)?\b' 'harness': '\bharness(?:es|ed|ing)?\b' - 'ever-evolving': '\bever.evolving\b' - "in today's": "\\bin today's\\b" "it's worth noting": "\\bit'?s worth noting\\b" 'rest assured': '\brest assured\b' - 'at its core': '\bat its core\b' - 'when it comes to': '\bwhen it comes to\b' - 'dive into': '\bdive into\b' structural: # Sentence-length variance: uniform rhythm is the strongest AI tell. diff --git a/scripts/quality/score.mjs b/scripts/quality/check.mjs similarity index 64% rename from scripts/quality/score.mjs rename to scripts/quality/check.mjs index 91a92f844a..2e93eeeb93 100644 --- a/scripts/quality/score.mjs +++ b/scripts/quality/check.mjs @@ -1,23 +1,22 @@ #!/usr/bin/env node /** - * score.mjs - the pre-PR check for documentation a change adds or edits: AI-isms, Vale, and Dale. + * check.mjs - the pre-PR check for documentation a change adds or edits: AI-isms, Vale, and Dale. * - * node scripts/quality/score.mjs [--staged | --base origin/dev] [--files list.txt] - * [--file ...] [--no-judge | --require-judge] + * node scripts/quality/check.mjs [--staged | --base origin/dev] [--files list.txt] + * [--file ...] [--no-model | --require-model] * [--no-lint] [--vale-optional] [--only list.txt] [--table] * * A new page is checked whole. An existing page is checked only on the lines * the change adds or rewrites; text it already had is left alone. slop.mjs - * runs first; the model judge runs only on a page that passes it, and its + * and Vale run first; Dale (a model) runs only on a page that passes them, and its * verdict is cached by page content. Exit 0 = pass, 1 = fail. */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { execFileSync } from 'child_process'; -import { discoverChanges, inRanges, onlyListed } from './lib/scope.mjs'; -import { scoreDeterministic } from './lib/gates.mjs'; -import { judgePage } from './lib/judge.mjs'; +import { discoverChanges, onlyListed } from './lib/scope.mjs'; +import { checkDeterministic } from './lib/gates.mjs'; import { daleCheck } from './lib/dale.mjs'; import { runVale, VALE_INSTALL } from './lib/vale.mjs'; import { hasApiKey } from './lib/llm.mjs'; @@ -25,13 +24,12 @@ import { mapPool } from './lib/pool.mjs'; const config = JSON.parse(fs.readFileSync(new URL('./config.json', import.meta.url), 'utf8')); -export async function scorePage(file, { ranges = null, judge = true, requireJudge = false, judgeFn, daleFn, lint = false, valeOptional = false, markdown = fs.readFileSync(file, 'utf8') } = {}) { - const det = scoreDeterministic(file, config, markdown, ranges); +export async function checkPage(file, { ranges = null, model = true, requireModel = false, daleFn, lint = false, valeOptional = false, markdown = fs.readFileSync(file, 'utf8') } = {}) { + const det = checkDeterministic(file, config, markdown, ranges); const result = { path: file, deterministic: det, vale: { status: 'skipped', findings: [] }, - judge: { status: 'skipped', findings: [] }, dale: { status: 'skipped', findings: [] }, }; let pass = det.pass; @@ -54,22 +52,14 @@ export async function scorePage(file, { ranges = null, judge = true, requireJudg } if (!pass) return { ...result, pass: false }; - if (!judge) return { ...result, pass: true }; + if (!model || !lint) return { ...result, pass: true }; if (!hasApiKey()) { - result.judge.status = requireJudge ? 'unavailable' : 'skipped'; - if (lint) result.dale.status = result.judge.status; - return { ...result, pass: !requireJudge }; + result.dale.status = requireModel ? 'unavailable' : 'skipped'; + return { ...result, pass: !requireModel }; } - // The two model checks are independent, so they run together. - const [v, d] = await Promise.all([ - (judgeFn || judgePage)(markdown, { model: config.models.judge }), - lint ? (daleFn || daleCheck)(markdown, ranges, { model: config.models.dale }) : Promise.resolve(null), - ]); - // The judge reads the whole page for context; only findings on changed lines count. - const findings = v.findings.filter(f => inRanges(f.line, ranges)); - result.judge = { status: findings.length === 0 ? (v.cached ? 'cached-pass' : 'pass') : 'fail', findings }; - if (d) result.dale = { status: d.findings.length === 0 ? (d.cached ? 'cached-pass' : 'pass') : 'fail', findings: d.findings }; - return { ...result, pass: result.judge.findings.length === 0 && result.dale.findings.length === 0 }; + const d = await (daleFn || daleCheck)(markdown, ranges, { model: config.models.dale }); + result.dale = { status: d.findings.length === 0 ? (d.cached ? 'cached-pass' : 'pass') : 'fail', findings: d.findings }; + return { ...result, pass: d.findings.length === 0 }; } /** The version of a file in the index. */ @@ -97,10 +87,10 @@ async function main() { files = onlyListed(files, list); } - // Every file is checked. The word-list check is instant; the judge, which is slow, runs a few pages at a time. + // Every file is checked. The word-list check is instant; Dale, which is slow, runs a few pages at a time. let done = 0; const results = await mapPool(files, config.scope.concurrency ?? 3, async ({ file: f, ranges }) => { - const r = await scorePage(f, { ranges, judge: !flag('--no-judge'), requireJudge: flag('--require-judge'), lint: !flag('--no-lint'), valeOptional: flag('--vale-optional'), ...(staged ? { markdown: stagedText(f) } : {}) }); + const r = await checkPage(f, { ranges, model: !flag('--no-model'), requireModel: flag('--require-model'), lint: !flag('--no-lint'), valeOptional: flag('--vale-optional'), ...(staged ? { markdown: stagedText(f) } : {}) }); if (files.length > 3 && process.stderr.isTTY) console.error(`quality: checked ${++done}/${files.length} ${f}`); return r; }); @@ -109,9 +99,9 @@ async function main() { if (flag('--table')) { for (const r of results) { - console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.path} (vale: ${r.vale.status}, judge: ${r.judge.status}, dale: ${r.dale.status})`); - for (const f of [...r.deterministic.findings, ...r.vale.findings, ...r.judge.findings, ...r.dale.findings].slice(0, 20)) - console.log(` ${f.line || '-'} ${f.rule || `pattern ${f.pattern}`} ${f.message || f.reason}`); + console.log(`${r.pass ? 'PASS' : 'FAIL'} ${r.path} (vale: ${r.vale.status}, dale: ${r.dale.status})`); + for (const f of [...r.deterministic.findings, ...r.vale.findings, ...r.dale.findings].slice(0, 20)) + console.log(` ${f.line || '-'} ${f.rule} ${f.message}`); } if (results.length === 0) console.log('No documentation changes to check.'); } else { diff --git a/scripts/quality/config.json b/scripts/quality/config.json index 5c9f829487..8eca03537a 100644 --- a/scripts/quality/config.json +++ b/scripts/quality/config.json @@ -17,7 +17,6 @@ "longSentenceSharePctMax": 10 }, "models": { - "judge": "claude-haiku-4-5", "rewrite": "claude-sonnet-5-5", "verify": "claude-opus-5-5", "dale": "claude-haiku-4-5" @@ -25,17 +24,6 @@ "loop": { "maxPasses": 3 }, - "judge": { - "ignoreQuotes": [ - "generally recommended that", - "\\bkey\\b" - ], - "ignoreReasons": [ - "\\bpassive\\b" - ], - "runs": 3, - "agree": 2 - }, "dale": { "runs": 3, "agree": 2, diff --git a/scripts/quality/fix-loop.mjs b/scripts/quality/fix-loop.mjs index ca317f0c31..a3e70718b7 100644 --- a/scripts/quality/fix-loop.mjs +++ b/scripts/quality/fix-loop.mjs @@ -15,7 +15,7 @@ * applied: it goes to one patch file. A reword the check doubts is applied and * printed as "review", because the writer sees the diff before committing. * - * A page that passes has its judge verdict cached so the hooks accept it + * A page that passes has its Dale verdict cached so the hooks accept it * without a second model call. Never runs git commit or push. * * Summary shape (formerly read by the vale-autofix workflow, now removed): @@ -28,7 +28,7 @@ import path from 'path'; import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; import { discoverChanges, rangesFor, inRanges } from './lib/scope.mjs'; -import { scorePage } from './score.mjs'; +import { checkPage } from './check.mjs'; import { checkPreserved } from './lib/preserve.mjs'; import { prepare, promptView, validateEdit, applyEdits, readable, flaggedMatchers, fillerEdits } from './lib/edits.mjs'; import { mapPool } from './lib/pool.mjs'; @@ -73,12 +73,14 @@ const EDIT_SCHEMA = { additionalProperties: false, }; -const problemCount = r => r.deterministic.findings.length + r.judge.findings.length; +// Dale findings carry the offending text as `text`; the edit validators read it as `quote`. +const daleFindings = r => r.dale.findings.map(f => ({ ...f, quote: f.text })); +const problemCount = r => r.deterministic.findings.length + r.dale.findings.length; function findingsText(r) { const parts = []; if (r.deterministic.findings.length) parts.push(renderFindingsTable(r.deterministic.findings)); - for (const f of r.judge.findings) parts.push(`Line ${f.line}: pattern ${f.pattern} - ${f.reason}\n> ${f.quote}`); + for (const f of r.dale.findings) parts.push(`Line ${f.line}: ${f.rule} - ${f.message}\n> ${f.text}`); return parts.join('\n\n'); } @@ -125,12 +127,12 @@ function restrict(ctx, ranges) { * `where` is { mode, base } for a page that existed before the change: only the * lines the change adds or rewrites are checked and edited. Omit it for a new page. */ -export async function fixPage(file, { where = null, maxPasses = config.loop.maxPasses, dryRun = false, proposeFn = propose, verifyFn = verify, judgeFn } = {}) { +export async function fixPage(file, { where = null, maxPasses = config.loop.maxPasses, dryRun = false, proposeFn = propose, verifyFn = verify, daleFn } = {}) { const rangesNow = () => (where ? rangesFor(file, where) : null); const original = fs.readFileSync(file, 'utf8'); const valeBefore = valeGate(file).findings.length; - const score = () => scorePage(file, { ranges: rangesNow(), judgeFn }); - let current = await score(); + const check = () => checkPage(file, { ranges: rangesNow(), lint: true, valeOptional: true, daleFn }); + let current = await check(); const before = problemCount(current); if (current.pass) return { path: file, passes: 0, status: 'unchanged', findings_before: before, findings_after: 0, applied: 0, rejected: 0, suggestions: 0, review: [], patch: '' }; @@ -145,7 +147,7 @@ export async function fixPage(file, { where = null, maxPasses = config.loop.maxP passes++; const markdown = fs.readFileSync(file, 'utf8'); const ctx = restrict(prepare(markdown), rangesNow()); - const matchers = flaggedMatchers([...current.deterministic.findings, ...current.judge.findings]); + const matchers = flaggedMatchers([...current.deterministic.findings, ...daleFindings(current)]); const proposed = [...fillerEdits(ctx), ...(await proposeFn(ctx, current, feedback))]; const reasons = []; @@ -190,7 +192,7 @@ export async function fixPage(file, { where = null, maxPasses = config.loop.maxP continue; } fs.writeFileSync(file, candidate); - const next = await score(); + const next = await check(); const valeAfter = valeGate(file).findings.length; if (valeAfter > valeBefore) { fs.writeFileSync(file, markdown); diff --git a/scripts/quality/gen-lint-digest.mjs b/scripts/quality/gen-lint-digest.mjs index 894631bb34..9c21ab952d 100644 --- a/scripts/quality/gen-lint-digest.mjs +++ b/scripts/quality/gen-lint-digest.mjs @@ -67,10 +67,6 @@ export function build() { '', aiisms().join(', ') + '.', '', - '## AI-writing patterns the judge looks for', - '', - 'See `.claude/references/humanizer-rules.md`. In short: no inflated significance, no tacked-on "-ing" clauses, no promotional language, no unsourced attributions, no "not just X, but Y", no false ranges, and no synonym cycling.', - '', ].join('\n'); } diff --git a/scripts/quality/lib/consensus.mjs b/scripts/quality/lib/consensus.mjs new file mode 100644 index 0000000000..52a3cd05aa --- /dev/null +++ b/scripts/quality/lib/consensus.mjs @@ -0,0 +1,65 @@ +/** + * Helpers for the model checks (Dale): a cache keyed by page + rules + model, + * verbatim-quote verification, and voting across runs. + * A finding only counts if its quote appears verbatim in the page. + */ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; + +const CACHE_DIR = () => process.env.QUALITY_CACHE_DIR || '.cache/quality'; + +const norm = s => s.replace(/\s+/g, ' ').trim(); +const sha = s => crypto.createHash('sha256').update(s).digest('hex'); + +export function verdictKey(markdown, rubric, model) { + return sha(`${model}\n${sha(rubric)}\n${sha(markdown)}`); +} + +export function readCachedVerdict(key) { + try { + return JSON.parse(fs.readFileSync(path.join(CACHE_DIR(), `${key}.json`), 'utf8')); + } catch { + return null; + } +} + +export function writeVerdict(key, verdict) { + fs.mkdirSync(CACHE_DIR(), { recursive: true }); + fs.writeFileSync(path.join(CACHE_DIR(), `${key}.json`), JSON.stringify(verdict)); +} + +/** Drop findings whose quote is not in the page, and attach a line number. */ +export function verifyFindings(markdown, findings) { + const lines = markdown.split('\n'); + const flat = norm(markdown); + return findings + .filter(f => f.quote && flat.includes(norm(f.quote))) + .map(f => { + const needle = norm(f.quote).slice(0, 40); + const idx = lines.findIndex(l => norm(l).includes(needle)); + return { ...f, line: idx === -1 ? 0 : idx + 1 }; + }); +} + +const words = q => new Set(norm(q).toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').split(' ').filter(Boolean)); + +/** Two quotes point at the same text when one contains most of the other's words, whatever pattern number each run gave it. */ +export function sameSpot(a, b) { + const x = words(a.quote); + const y = words(b.quote); + const shared = [...x].filter(w => y.has(w)).length; + return shared >= 0.6 * Math.min(x.size, y.size); +} + +/** Keep findings that at least `agree` of the runs reported at the same spot. The first run's wording is used. */ +export function consensus(runs, agree) { + if (agree <= 1) return runs.flat().filter((f, i, all) => all.findIndex(g => sameSpot(f, g)) === i); + const kept = []; + for (const f of runs.flat()) { + if (kept.some(k => sameSpot(k, f))) continue; + const votes = runs.filter(r => r.some(g => sameSpot(f, g))).length; + if (votes >= agree) kept.push(f); + } + return kept; +} diff --git a/scripts/quality/lib/dale.mjs b/scripts/quality/lib/dale.mjs index f5c529e65c..c66f530dce 100644 --- a/scripts/quality/lib/dale.mjs +++ b/scripts/quality/lib/dale.mjs @@ -1,14 +1,14 @@ /** * Dale: the model-checked style rules in .claude/skills/dale/rules/*.yml (passive voice, wordiness, idioms, * misplaced modifiers, undefined acronyms, ...). A model reads the page against all the rules in one call. - * Like the AI-isms judge, it runs a few times and keeps findings that enough runs agree on; every finding + * It runs a few times and keeps findings that enough runs agree on; every finding * must quote the page verbatim and sit on a changed line. Verdicts are cached by page content and rules. */ import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; import { getClient, textOf } from './llm.mjs'; -import { verdictKey, readCachedVerdict, writeVerdict, verifyFindings, consensus } from './judge.mjs'; +import { verdictKey, readCachedVerdict, writeVerdict, verifyFindings, consensus } from './consensus.mjs'; import { inRanges } from './scope.mjs'; const RULES_DIR = '.claude/skills/dale/rules'; diff --git a/scripts/quality/lib/gates.mjs b/scripts/quality/lib/gates.mjs index 67ae694684..0941e3fb49 100644 --- a/scripts/quality/lib/gates.mjs +++ b/scripts/quality/lib/gates.mjs @@ -26,7 +26,7 @@ export function missingDescription(file, markdown) { * and page-wide structural signals are skipped because they describe text * the author didn't write. A new page (`ranges` null) gets everything. */ -export function scoreDeterministic(file, _config, markdown = fs.readFileSync(file, 'utf8'), ranges = null) { +export function checkDeterministic(file, _config, markdown = fs.readFileSync(file, 'utf8'), ranges = null) { const slop = runSlopGate(file, markdown); const findings = ranges === null ? [...missingDescription(file, markdown), ...slop.findings] diff --git a/scripts/quality/lib/judge.mjs b/scripts/quality/lib/judge.mjs deleted file mode 100644 index 351dd6fd8d..0000000000 --- a/scripts/quality/lib/judge.mjs +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Model judge for the 16 patterns in .claude/references/humanizer-rules.md. - * - * Verdicts are cached by sha256(page + rubric + model). The rewrite loop - * stores the verdict for the version it ends on, so the PR check reads the - * same answer instead of asking a model again and possibly disagreeing. - * A finding only counts if its quote appears verbatim in the page. - */ -import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; -import { getClient, textOf } from './llm.mjs'; - -const config = JSON.parse(fs.readFileSync(new URL('../config.json', import.meta.url), 'utf8')); -// Phrases a maintainer has decided are fine. Matched against each finding's quote after the verdict is read, so editing the list needs no new model call. -const IGNORE = (config.judge?.ignoreQuotes ?? []).map(p => new RegExp(p, 'i')); -// Findings whose reason cites something the rubric does not contain (passive voice is Vale's job, not a pattern here). -const IGNORE_REASONS = (config.judge?.ignoreReasons ?? []).map(p => new RegExp(p, 'i')); -const applyIgnore = v => { - const findings = v.findings.filter(f => !IGNORE.some(re => re.test(f.quote)) && !IGNORE_REASONS.some(re => re.test(f.reason))); - return { ...v, findings, pass: findings.length === 0 }; -}; - -const RUNS = config.judge?.runs ?? 1; -const AGREE = Math.min(config.judge?.agree ?? 1, RUNS); - -const RUBRIC_PATH = '.claude/references/humanizer-rules.md'; -const CACHE_DIR = () => process.env.QUALITY_CACHE_DIR || '.cache/quality'; - -const SCHEMA = { - type: 'object', - properties: { - findings: { - type: 'array', - items: { - type: 'object', - properties: { - pattern: { type: 'integer' }, - quote: { type: 'string' }, - reason: { type: 'string' }, - }, - required: ['pattern', 'quote', 'reason'], - additionalProperties: false, - }, - }, - }, - required: ['findings'], - additionalProperties: false, -}; - -const norm = s => s.replace(/\s+/g, ' ').trim(); -const sha = s => crypto.createHash('sha256').update(s).digest('hex'); - -const INSTRUCTIONS = - `You judge Netwrix product documentation for signs of AI-generated writing, using only the rubric below.\n` + - `Report an instance only when it clearly matches a numbered pattern and a technical writer would agree it should change. When unsure, do not report it. ` + - `Ordinary technical usage is not a finding: "key" meaning important in a plain factual statement or a cryptographic, API, or registry key; hedged advice such as "it is generally recommended that" when the page is giving real guidance; and common words used literally. ` + - `Patterns 1 and 14 apply to inflated or empty wording, such as promotional praise or a sentence that adds nothing, not to plain statements of importance or advice. ` + - `For each finding, give the pattern number, the exact offending text copied verbatim from the page (one sentence at most), and a one-line reason. ` + - `Report only what a numbered pattern in the rubric describes; passive voice and other style points are not patterns. Do not report anything the rubric assigns to the statistical gate. If the page has no instances, return an empty findings array.`; - -export function verdictKey(markdown, rubric, model) { - return sha(`${model}\n${RUNS}/${AGREE}\n${sha(INSTRUCTIONS)}\n${sha(rubric)}\n${sha(markdown)}`); -} - -export function readCachedVerdict(key) { - try { - return JSON.parse(fs.readFileSync(path.join(CACHE_DIR(), `${key}.json`), 'utf8')); - } catch { - return null; - } -} - -export function writeVerdict(key, verdict) { - fs.mkdirSync(CACHE_DIR(), { recursive: true }); - fs.writeFileSync(path.join(CACHE_DIR(), `${key}.json`), JSON.stringify(verdict)); -} - -/** Drop findings whose quote is not in the page, and attach a line number. */ -export function verifyFindings(markdown, findings) { - const lines = markdown.split('\n'); - const flat = norm(markdown); - return findings - .filter(f => f.quote && flat.includes(norm(f.quote))) - .map(f => { - const needle = norm(f.quote).slice(0, 40); - const idx = lines.findIndex(l => norm(l).includes(needle)); - return { ...f, line: idx === -1 ? 0 : idx + 1 }; - }); -} - -const words = q => new Set(norm(q).toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').split(' ').filter(Boolean)); - -/** Two quotes point at the same text when one contains most of the other's words, whatever pattern number each run gave it. */ -export function sameSpot(a, b) { - const x = words(a.quote); - const y = words(b.quote); - const shared = [...x].filter(w => y.has(w)).length; - return shared >= 0.6 * Math.min(x.size, y.size); -} - -/** Keep findings that at least `agree` of the runs reported at the same spot. The first run's wording is used. */ -export function consensus(runs, agree) { - if (agree <= 1) return runs.flat().filter((f, i, all) => all.findIndex(g => sameSpot(f, g)) === i); - const kept = []; - for (const f of runs.flat()) { - if (kept.some(k => sameSpot(k, f))) continue; - const votes = runs.filter(r => r.some(g => sameSpot(f, g))).length; - if (votes >= agree) kept.push(f); - } - return kept; -} - -/** One judge run on a page, with its findings verified against the text. No voting, no cache. */ -export async function judgeOnce(markdown, { model, client = getClient() }) { - const rubric = fs.readFileSync(RUBRIC_PATH, 'utf8'); - const response = await client.messages.create({ - model, - max_tokens: 4000, - cache_control: { type: 'ephemeral' }, - system: `${INSTRUCTIONS}\n\n${rubric}`, - messages: [{ role: 'user', content: markdown }], - output_config: { format: { type: 'json_schema', schema: SCHEMA } }, - }); - return verifyFindings(markdown, JSON.parse(textOf(response)).findings); -} - -/** - * @param {string} markdown - * @param {{ model: string, client?: any, useCache?: boolean }} opts - * @returns {Promise<{ pass: boolean, findings: object[], cached: boolean, key: string }>} - */ -export async function judgePage(markdown, { model, client = getClient(), useCache = true }) { - const rubric = fs.readFileSync(RUBRIC_PATH, 'utf8'); - const key = verdictKey(markdown, rubric, model); - if (useCache) { - const hit = readCachedVerdict(key); - if (hit) return { ...applyIgnore(hit), cached: true, key }; - } - - const runs = await Promise.all(Array.from({ length: RUNS }, () => judgeOnce(markdown, { model, client }))); - const findings = consensus(runs, AGREE); - const verdict = { pass: findings.length === 0, findings, model }; - if (useCache) writeVerdict(key, verdict); - return { ...applyIgnore(verdict), cached: false, key }; -} From 609db8c8ebebe8c52b81347dccc524e5aaa1c420 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 11:55:37 -0500 Subject: [PATCH 05/11] Add Xchange and Jira sources, a page-structure guide, and product briefs to doc-draft - Read tests with the code, fetch Xchange links from work items, and name the Jira tools with a sign-in hint - Put code-established caveats on the page, check internal links, and point step 3 at the new page-structure guide - Add product briefs for auditor, dataclassification, endpointprotector, threatmanager, threatprevention, and pingcastle - Add docs/CLAUDE.md files for four products and update threatmanager's Co-Authored-By: Claude Sonnet 5.5 --- .claude/references/page-structure-guide.md | 95 ++++++++++++++++ .claude/references/products/README.md | 36 ++++++ .claude/references/products/auditor.md | 105 ++++++++++++++++++ .../references/products/dataclassification.md | 100 +++++++++++++++++ .../references/products/endpointprotector.md | 98 ++++++++++++++++ .claude/references/products/pingcastle.md | 87 +++++++++++++++ .claude/references/products/threatmanager.md | 95 ++++++++++++++++ .../references/products/threatprevention.md | 103 +++++++++++++++++ .claude/skills/doc-draft/SKILL.md | 19 ++-- .claude/skills/doc-draft/style-reference.md | 2 +- docs/auditor/CLAUDE.md | 57 ++++++++++ docs/dataclassification/CLAUDE.md | 55 +++++++++ docs/endpointprotector/CLAUDE.md | 49 ++++++++ docs/threatmanager/CLAUDE.md | 10 +- docs/threatprevention/CLAUDE.md | 54 +++++++++ 15 files changed, 952 insertions(+), 13 deletions(-) create mode 100644 .claude/references/page-structure-guide.md create mode 100644 .claude/references/products/README.md create mode 100644 .claude/references/products/auditor.md create mode 100644 .claude/references/products/dataclassification.md create mode 100644 .claude/references/products/endpointprotector.md create mode 100644 .claude/references/products/pingcastle.md create mode 100644 .claude/references/products/threatmanager.md create mode 100644 .claude/references/products/threatprevention.md create mode 100644 docs/auditor/CLAUDE.md create mode 100644 docs/dataclassification/CLAUDE.md create mode 100644 docs/endpointprotector/CLAUDE.md create mode 100644 docs/threatprevention/CLAUDE.md diff --git a/.claude/references/page-structure-guide.md b/.claude/references/page-structure-guide.md new file mode 100644 index 0000000000..7b1c610a28 --- /dev/null +++ b/.claude/references/page-structure-guide.md @@ -0,0 +1,95 @@ +# Page structure guide: what a page is and how to scope and name it + +`documentation-section-guide.md` says where a page goes (the zone). This guide says what the page is: its job, its title, and whether it is a new page, a new section, or an edit. Read both before you decide where new content goes. + +## 1. Decide the page's job + +Before you write, state the reader's goal in one sentence for yourself: what they want to do, understand, or look up. Use it to pick the title, the type, and what to leave out. Give the page one primary type: + +| Type | The reader wants to | Headings | +|---|---|---| +| Task | Do something | Imperative verbs | +| Concept | Understand something | Noun phrases or gerunds | +| Reference | Look something up (flags, settings, fields, codes) | Noun phrases | +| Troubleshooting | Fix a problem | The symptom or the error | + +Secondary content from another type is fine if it supports the job. A task page may carry a short section on limits and a troubleshooting section. If a second job needs its own prerequisites and steps, it is a second page. + +Every page opens with a short overview of one to three sentences: what the feature, surface, or term is, and what it is useful for. Take the value from the source material, and don't invent a benefit it doesn't support. Skip marketing language. A glossary entry is the exception, because its definition does that job. + +## 2. New page, new section, or edit + +Work through these in order and stop at the first that fits. + +1. **The behavior is already documented and the code changed it.** Edit that page. Search the docs for statements the change makes false (limits, "you can't" claims, old labels, old defaults) and fix each one. Don't add a second page that contradicts the first. +2. **An existing page already answers the same reader question.** Add a section to it. +3. **The content is a distinct task with its own prerequisites, procedure, and failure modes.** Write a new page. +4. **The content is a few paragraphs that only make sense inside a parent task.** Add a section to the parent page. + +When you add a page, add a one-line link from the page where a reader would look first (the parent overview or the neighboring task page). Link to existing procedures instead of copying them. + +## 3. Name the page for the reader's goal + +- **Default: name it for the task the reader performs.** A title that states the task tells a reader who needs it that this is their page. +- **Name it for the UI surface (a card, page, or dialog) when that is what the reader looks for.** That means the surface covers several tasks, or the surface exists for one purpose and nothing else belongs on it. Sections under it hold the tasks. +- **Name it for the thing being specified when the page is a reference or a prerequisite**. +- Match the neighboring page titles in form: gerund, noun phrase, or imperative. Follow `docs/CLAUDE.md` for headings. +- Use UI labels exactly as the product shows them, in bold when they appear in text. +- Don't name a page for a PR, a ticket, a service, a code package, or an internal name the reader never sees. + +## 4. Get to the task fast + +This section applies to task pages. For a reference page, see section 5. + +A reader who needs to act should reach the steps quickly. Keep the overview from section 1 to its one to three sentences. + +- After the overview, lead with what the reader came to do. Add only the orientation they need before acting. +- When a surface carries several tasks, give each task its own imperative heading and order them by how often readers need them. +- When there is more than one way to do the task, add a section near the top that says which method fits which situation, then give each method its own section. +- Put conditions that decide whether the task is possible ahead of the steps. A section on prerequisites or conditions comes before the procedure. + +## 5. Reference pages: a UI surface or a glossary + +The reader wants to look something up, not follow steps. They scan, and they may arrive from search in the middle of the page. Organize for lookup. + +- **Open with the overview from section 1**: at least one sentence on what the surface or list covers, and at least one on why it is useful or what people use it for. No walkthrough. +- **Cover everything the surface offers.** For a UI surface, give each element (tab, column, filter, button, field, status) once, in the order the screen shows it. Don't skip elements because they seem obvious. Find the full list in the code or UI strings, not from memory. +- **One heading per element or group**, named with the exact UI label. Use a table when every item has the same attributes (name, what it does, allowed values, default). Use headings with short paragraphs when items need more than a sentence. +- **Describe what each element does or means, not how to use it.** When an element starts a longer procedure, give one sentence and link to the task page. Don't write the procedure here. +- **Define a term where the reader looks it up.** In a glossary, order entries alphabetically, start each with the term in bold and a one-sentence definition, and link to the page that explains it. Don't repeat the full explanation. +- **Keep the same shape for every item.** Readers compare entries, so use the same attributes in the same order. +- **Put conditions and exceptions on the item they affect**, not in a separate section at the end. +- **Don't add Troubleshooting or What it doesn't do sections** unless the surface has failure modes of its own. Link to the task or troubleshooting page instead. +- **Link in both directions.** Task pages link to the reference for details. The reference links back to the tasks it supports. + +If the surface also carries tasks the reader performs, keep the reference page for lookup and give each task its own page or its own section after the reference content. + +## 6. API documentation + +Follow the Change Tracker model (`docs/changetracker/api/`). + +- **Substantial API with an OpenAPI spec:** don't hand-write endpoint pages. The reference is generated from the spec, so the fix for a wrong endpoint description is in the spec. Hand-write only the pages around it: + - `overview`: what the API is for, the base URL, and how to get access. + - `authentication`: how to get and use a token, with one working example. Place it before any page that calls an endpoint. + - `reference/index`: what the reference covers, how it is organized, how to use the interactive explorer, and a link to download the spec. + - Task guides: one page per workflow that spans endpoints or needs context the spec can't give (a gotcha, a required call order, a client library limit). These are task pages, so they follow sections 1 to 4. +- **Small API with no spec:** write one page per resource group, in the endpoint-first form of the existing hand-written API pages. For each endpoint, give the method and path, a request example, the parameters (name, what it does, type, required or optional), the response and its status codes, and an error example if the failure isn't obvious. +- **Keep the same shape for every endpoint**, as in section 5. +- **Wrap URL placeholders such as `` in backticks** so MDX doesn't parse them as tags, and give every code fence a language. +- **Check the product's `api/CLAUDE.md`** for how its reference is generated before you edit anything there. Generated `.api.mdx` files are gitignored, so don't edit them. + +## 7. Order inside a task page + +Overview, then prerequisites, then procedures, as `docs/CLAUDE.md` requires. After the procedures: + +- **Limits:** what the task doesn't do, irreversible effects, and things the reader might assume. +- **Troubleshooting:** the failures a reader can hit, each with its message and the fix. +- Put an example right after the procedure or concept it illustrates. + +## 8. Placement + +Place the page by the reader's question, per `documentation-section-guide.md`. Set `sidebar_position` beside the pages a reader would use before and after it. A prerequisite page sits before the procedure that needs it. + +## 9. Report your structure choice + +In your report, say in one or two lines whether you wrote a new page, a section, or an edit, why, and why you chose that title. List each existing page you edited because the change made a statement false. diff --git a/.claude/references/products/README.md b/.claude/references/products/README.md new file mode 100644 index 0000000000..5b8cc07336 --- /dev/null +++ b/.claude/references/products/README.md @@ -0,0 +1,36 @@ +# Product briefs + +One short file per product, named `.md`. The `doc-draft` skill reads the brief before it reads a PR, so it starts out knowing what the product is and where things live in the code. + +Keep each brief to about two pages. Describe what stays true across releases: don't list features or version-specific behavior. The code wins over the brief; when they disagree, fix the brief. + +Start from this shape: + +```markdown +--- +product: +repo: +local_clone: +last_reviewed: YYYY-MM-DD +--- + +## What it is +Who uses it and what it does, in a few sentences. Editions or tiers, if any. + +## Code layout +| Path | Role | User-visible behavior | +|---|---|---| + +## Vocabulary +| Code says | UI and docs say | +|---|---| + +## Where a change usually lands +The files a typical PR touches for: a new setting, a new option or switch, a new report field, a new check or rule. + +## Docs map +Where each area is documented under `docs//`, and which page types exist. + +## Watch out for +Things that trip up a reader of the code: dead code, flags that look user-facing but aren't, naming that differs from the docs. +``` diff --git a/.claude/references/products/auditor.md b/.claude/references/products/auditor.md new file mode 100644 index 0000000000..4a1ac71d5c --- /dev/null +++ b/.claude/references/products/auditor.md @@ -0,0 +1,105 @@ +--- +product: auditor +repo: netwrix-corp/dspm-auditor-netwrixauditor +local_clone: ~/dspm-auditor-netwrixauditor +last_reviewed: 2026-10-13 +status: drafted from a code survey; not yet reviewed by someone who owns the product +--- + +## What it is + +Netwrix Auditor audits changes and activity across an IT environment: Active Directory, Group Policy, Exchange, file servers, SharePoint, SQL Server, Oracle, Windows Server, VMware, network devices, Microsoft Entra ID, Exchange Online, SharePoint Online, Microsoft Teams, and more. Administrators set up monitoring plans for data sources, and the product collects who did what, when, and where into an audit archive and an SQL audit database. They then use search, SQL Server Reporting Services (SSRS) reports, alerts, dashboards, and a REST API. It runs on Windows with a Windows console. Editions are Enterprise Advanced and a limited Free Community Edition, plus a trial; the docs cover them in `overview/producteditions.md`. + +Docs use the name "Netwrix Auditor", and report packs are named "Netwrix Auditor for " (for example, "Netwrix Auditor for Entra ID"). There is no `CLAUDE.md` under `docs/auditor/`, so this brief and `docs/CLAUDE.md` are the only naming guidance. + +## Code layout + +One repo holds the whole product (C++ and C# on .NET Framework 4.8, built with MSBuild and WiX). Three tiers: collector modules depend on AuditCore and DataCollectionCore, and all of them depend on `Libraries/`. Each top-level folder has its own `README.md`, `ARCHITECTURE.md`, `GLOSSARY.md`, and often `CLAUDE.md`; read the module's own files first. + +| Path | Role | User-visible behavior | +|---|---|---| +| `AuditCore/` | Six Windows services (config server, archive, management, WebAPI, watchdog, routing hub), plus the native console UI | Monitoring plan storage, audit archive, alerts, risk, the REST API on port 9699, the console | +| `AuditCore/Sources/UI/` | Console pages (Dashboard, Settings, Alerts, Containers, and others) and layout XML in `Xml/` | Console screens and dialogs | +| `AuditCore/Sources/Configuration/` | `.acconf` and `.acinc` XML configuration, `EditionNames.acinc`, `GlobalSettings.ini` | Service wiring, edition names, logging | +| `DataCollectionCore/` | Agent management, scheduling, event collection, health, syslog, email sending | Collection schedules, emailed alerts and reports, health status | +| `ADA/` | Active Directory, Group Policy (GPCR), and Exchange (ECR) | Active Directory, Group Policy, and Exchange data sources | +| `FSA/`, `FileStorageAuditor/` | Windows file servers; extended storage (NetApp, Nutanix, Dell, DFS, clusters) | File servers data source | +| `SPA/` | SharePoint on-premises | SharePoint data source | +| `SQLA/`, `OracleAuditor/` | SQL Server and Oracle Database | SQL Server and Oracle data sources | +| `WSA/`, `ELM/`, `UAVR/` | Windows Server; Event Log Manager; user activity and video | Windows Server, Event Log, User Activity data sources | +| `Office365Auditing/`, `MSTeamsAuditor/`, `AzureEventsService/`, `AzureFilesAuditor/` | Cloud collectors (Exchange Online, SharePoint Online, OneDrive, Entra ID, Teams, Azure Files) | Microsoft 365, Entra ID, Teams, Azure Files data sources | +| `VMA/`, `NetworkDeviceAuditor/`, `NetworkLogonAuditor/`, `AdfsAuditor/` | VMware; network devices; logons; AD FS | Matching data sources | +| `NOMBA/` | On-premises Exchange mailboxes | Non-owner mailbox access auditing | +| `IUT/`, `PEN/` | Inactive Users Tracking; Password Expiration Notifier | The tools covered under `tools/` in the docs | +| `DDC/` | Data Discovery and Classification | Sensitive data discovery and its reports | +| `NAC/` | Report subscription delivery and cross-database reporting | Subscriptions, enterprise-wide reports | +| `Reporting/` | SSRS report definitions (`.rdl`) and SQL database scripts | Report packs, report names, report fields | +| `Setup/` | WiX installer (MSI) | Installation, upgrade, uninstall | +| `Libraries/`, `TPLibraries/`, `CommonFiles/`, `Build/` | Shared libraries, third-party submodule, build tooling and CI pipelines | None directly | + +Where text and settings live: +- **Console strings:** C++ resource files (`.rc`) and layout XML under `AuditCore/Sources/UI/` (`Xml/`, one folder per page). Each page also has its own `*Page/` folder. +- **Collector and wizard strings:** `.resx` files inside each module (about 300 in the repo; ADA wizards, ELM, PEN, SQLA, FSA). Some collectors use `.rc` message tables instead (AD FS, VMware, ADA). +- **Settings:** stored in the Configuration Server as XML node trees, not in files. The console settings pages are `AuditCore/Sources/UI/SettingsPage/`. Service-level defaults are `.acconf` and `.acinc` files. Logging and data paths use per-component `GlobalSettings.ini` (see the repo `README.md`). +- **Edition names:** `AuditCore/Sources/Configuration/EditionNames.acinc`. +- **Report names and fields:** `.rdl` files under `Reporting/Source/Reports//`, plus module-specific report projects such as `ADA/Source/ReportBuilder/`. + +## Vocabulary + +| Code says | UI and docs say | +|---|---| +| Managed Object (MO), `ManagedObjectsHolder` | Monitoring plan (and its items); the console pages are `ContainersPage` and `ContainerEditPage` | +| Activity Record (AR), "Change" | Activity record | +| State-in-Time (SIT) | State-in-Time reports and snapshots | +| Audit Archive, Long-Term Archive (LTA), Short-Term Archive (STA) | Long-term archive; working folder holds the short-term data | +| Collector module; Auditor in a folder name (`SPA`, `SQLA`) | Data source, for example SharePoint, SQL Server | +| ADCR, GPCR, ECR, FSCR | Active Directory, Group Policy, Exchange, and file server change reporting (old "Change Reporter" names) | +| Freeware, SMB, Standard, Advanced, Trial (internal edition names) | Free Community, Business Essentials, Enterprise Essentials, Enterprise Advanced, Free Trial | +| Azure AD (some code and folder names) | Microsoft Entra ID | +| `adcr_db`, and per-collector databases | Audit database (the name is historical, not AD-specific) | +| NwCoreSvc, NwCfgServerSvc, NwArchiveSvc, NwManagementSvc, NwWebAPISvc, NwWatchdogSvc | Netwrix Auditor services (named in troubleshooting and KB pages) | +| `IACNode`, `.acconf`, `.acinc` | Internal; not user-facing | +| NLA | Network Logon Auditor; docs call it logon activity | +| DDC | Data Discovery and Classification | +| IUT, PEN | Inactive User Tracker, Password Expiration Notifier | + +The repo's `GLOSSARY.md` (and each module's own) is the authoritative map. + +## Where a change usually lands + +- **New setting:** the console page under `AuditCore/Sources/UI//` with its `Xml/` layout, the Configuration Server schema and `.acconf`/`.acinc` defaults under `AuditCore/Sources/Configuration/`, and the consuming collector or service. Monitoring plan and data source options sit in the owning module's configuration code and its wizard pages (for example `ADA/Source/code/ConfigureAuditWizard/Pages`). Check `admin/settings/` and `admin/monitoringplans/` in the docs. +- **New option or switch:** the same places, and the commit usually names the module. Feature work uses Jira keys (`AUD-nnnn`) in commit messages and in code comments. +- **New report field:** the collector that produces the activity record (`Where`, `What`, `Before`, `After`, `Details`), the module's database schema scripts, and the `.rdl` under `Reporting/Source/Reports/` that shows it. The `Reporting/` module owns the schema version scripts. +- **New check or monitored event:** inside the module for that data source. There is no central rule registry as in some other products. +- **New data source or collector:** a new top-level folder, with its `Setup/`, its `Build/*_BuildInfo.csv` entry, `Build/Auditor_BuildInfo.csv`, a report pack in `Reporting/`, and docs under `configuration/`, `admin/monitoringplans/`, and `requirements/supporteddatasources/`. + +## Docs map + +Under `docs/auditor//`, latest is 10.9 (10.8 and 10.7 also exist). The sidebar is autogenerated from the folders (`sidebars/auditor/10.9.js`), so folder order and `_category_.json` control navigation. + +| Area | Pages | +|---|---| +| Overview | `overview/` (getting started, product editions, what's new), `whats-new.md`, `index.md` | +| Requirements | `requirements/` (software, SQL Server, SSRS, ports, service account, gMSA, working folder, long-term archive, `supporteddatasources/`) | +| Install and update | `install/` (install, upgrade, silent mode, uninstall, virtual appliance, first launch, core services) | +| Configure data sources | `configuration//`: prepares the monitored system (audit policies, permissions) for each source. About 150 pages, one folder per source (`activedirectory`, `fileservers`, `sqlserver`, `microsoft365`, and others) | +| Administration | `admin/`: `monitoringplans//` (the plan wizard, per source), `settings/`, `reports/`, `search/`, `alertsettings/`, `subscriptions/`, `riskassessment/`, `behavioranomalies/`, `healthstatus/`, `navigation/`, `compliancemappings.md` | +| API | `api/` (overview, endpoints, ports, security, activity record reference, filter reference, post data) | +| Tools | `tools/` (Event Log Manager, Inactive User Tracker, Password Expiration Notifier, Audit Configuration Assistant, object restore) | +| Add-ons and integrations | `addon/` (SIEM and vendor integrations; about 135 pages) | +| Other products in the folder | `accessreviews/`, `accountlockoutexaminer/` | +| Knowledge Base | `kb/` with topic folders (configuration, monitoring plans, reports and alerts, troubleshooting, event ID reference) | + +Page types: overview pages (`overview.md` per folder), requirements, task procedures, per-data-source pairs (a `configuration/` page to prepare the system and an `admin/monitoringplans/` page to create the plan), reference pages (activity record fields, filters), integration pages, and KB articles. The versioned `kb/` is copied from `docs/kb/` at build time. + +## Watch out for + +- The repo is large and mixed: many modules have their own `CLAUDE.md`, `GLOSSARY.md`, and `ARCHITECTURE.md`. Read the module's files before reading code. `CONTRIBUTING.md` requires comments on every code change and `AUD-nnnn` keys in bug fixes, so `git log` and code comments are good sources for what a PR changed. +- Branches are `story/`, `fix/[develop|10.9/]`, or `epic/*`, cut from `develop` or a `release/*` branch. Release branches map to docs versions, so a fix ported to a release branch (a `fix/10.9/...` branch) usually means each affected docs version needs the same edit. +- Naming drift: code and folder names use old names (ADCR, FSCR, `adcr_db`, Azure AD, `Auditor` suffixes) that differ from the docs. The edition names in `EditionNames.acinc` are the display names, and the internal names (Freeware, SMB, Standard, Advanced) are not. +- `README.md` says 20 collector modules and `CLAUDE.md` says about 25; the folder count differs from both. Treat the counts as unreliable. `ARCHITECTURE.md` lists the 20 collectors. +- Two folders cover file storage (`FSA/` and `FileStorageAuditor/`); a file-server PR can land in either, and the vendor-specific work (NetApp, Nutanix, Dell, Isilon, Qumulo, Synology) is in `FileStorageAuditor/`. Check which one a PR touched before choosing the docs page. +- Docs `accessreviews/` and `accountlockoutexaminer/` live in the Auditor folder but are not in the code repo layout above; confirm the owning repo before documenting them from this one. +- `GlobalSettings.ini` files in the repo are in development state (verbose logging); the shipped values differ. Don't document the in-repo defaults. +- Settings live in the Configuration Server as node paths, so a string search for a setting name in the console code may find nothing. Search the `.acconf`/`.acinc` files and the module's configuration code. +- Not verified in the survey: the exact UI label for each setting (the console XML and `.rc` files were located but not matched string by string to docs pages); which module owns Exchange Online versus Microsoft Entra ID collection in `Office365Auditing/`; the full product edition and license matrix; how `Libraries/` and `Setup/` map to docs pages; whether `addon/` integrations are built in this repo or maintained elsewhere; what `accessreviews/` and `accountlockoutexaminer/` map to in code; the CLI or API changes that appear in `whats-new.md` before they ship. diff --git a/.claude/references/products/dataclassification.md b/.claude/references/products/dataclassification.md new file mode 100644 index 0000000000..d904b622b5 --- /dev/null +++ b/.claude/references/products/dataclassification.md @@ -0,0 +1,100 @@ +--- +product: dataclassification +repo: netwrix-corp/dspm-ndc-dataclassification +local_clone: ~/dspm-ndc-dataclassification +last_reviewed: 2026-10-13 +status: drafted from a code survey; not yet reviewed by someone who owns the product +--- + +## What it is + +Netwrix Data Classification (NDC) is an on-premises Windows product for security and compliance teams. It crawls data sources (file shares, SharePoint, Exchange, Box, Dropbox, Google Drive, SQL databases, and others), extracts and indexes document text, and classifies documents against built-in and user-defined taxonomies. Optional workflows act on classified documents, and classifications can be written back into Office and PDF files. A web app (QueryServer) provides administration, search, reports, and a REST API. The repo targets .NET Framework 4.7.2 and runs on IIS, three Windows services, and SQL Server. The repo also carries a licensing portal, an SDK, and a SharePoint provider-hosted app. Editions and tiers: not stated in the repo. + +Docs use the name "Netwrix Data Classification" (about 200 times in 5.8, against about 185 for "NDC"). There is no `docs/dataclassification/CLAUDE.md`, so no product-specific naming rules exist beyond `docs/CLAUDE.md`. + +## Code layout + +The 90-odd top-level folders are flat and use the legacy `concept*` prefix. The names below are the ones that matter for docs. + +| Path | Role | User-visible behavior | +|---|---|---| +| `conceptQS/` | QueryServer, ASP.NET MVC under IIS, organized into `Areas/` | Every admin screen: Sources, Taxonomies, Workflows, Reports, Configuration, DSAR, Stats, Search, Security | +| `conceptQS/Areas/_api/` | REST API controllers | The REST API | +| `conceptCollectorService/`, `conceptCollector/` | Collector Windows service and core | Crawls sources, extracts text | +| `conceptIndexerService/`, `conceptIndexer/`, `conceptEngine2/` | Indexer service, and the custom `.cse` index | Search index; see `INDEX.md` in the repo | +| `conceptClassifierService/`, `conceptClassifierEngine/` | Classifier service and rules engine | Classification, scoring, classification writing | +| `conceptClassifierEngine/Rules/`, `Assets/BuiltInTaxonomies.xml` | Clue (rule) types and the shipped taxonomies | Clue types in the taxonomy editor; built-in taxonomies | +| `conceptWorkflow/Actions/Providers/` | Workflow action providers (classification, email, file system, SharePoint, Exchange, SQL, MIP, migration) | Workflow actions | +| `conceptFileSystem`, `conceptSharePoint`, `conceptExchange`, `conceptBox`, `conceptDropbox`, `conceptGoogleDrive`, `conceptSQL`, `conceptSQLServer`, `conceptCmis`, `conceptContentServer`, `conceptOutlookArchive`, `conceptHttp`, `conceptCustomSource*` | Source connectors | Source types in Add Source | +| `conceptReports/`, `conceptReportObjects/` | Report definitions and report data models | Built-in reports and queued reports | +| `conceptCore/`, `conceptData/`, `conceptData.Database/` | Shared enums and config, data access, schema and migrations | Settings storage | +| `conceptEngine.Admin/` | Admin context and the `RKey` enum of UI string keys | Admin screen text | +| `conceptConfig/`, `conceptInstaller/`, `conceptSearch.Presentation.Installer/` | Config utility, installer | Install and post-install setup | +| `Netwrix.UK.*` | Classification REST API service and SDKs | Separate API and SDK surface | +| `conceptUnitTests/` | The only maintained test project | None | + +Where text and settings live: +- **UI strings:** `conceptQS/Language-Resources/Resource.resx` (and `Resource.fr-FR.resx`), keyed by name such as `QS_Config_Settings_Collector_UserAgent_Info`. Each key is also an entry in the `RKey` enum in `conceptEngine.Admin/Enums/RKey.cs`. Many field labels are literal text in the `.cshtml` views and not in resx, as in `Areas/Configuration/Views/Core/_Collector.cshtml`. +- **System settings:** columns on the single-row `Config` table (entity `ConfigValues`), edited under `Areas/Configuration/` (`Core` has Collector, Indexer, Classifier, QueryServer, Logging, Audit Log, and Details tabs). Report settings and per-user settings live in the same table or the `Users` table. +- **Per-page navigation:** `PageKey`, `SectionKey`, and `AreaKey` in `conceptCore/Enums/Qs/`. +- **Connection settings:** Windows registry (`Netwrix > Data Classification > DatabaseConnection`), per `ARCHITECTURE.md`. + +## Vocabulary + +| Code says | UI and docs say | +|---|---| +| `concept*`, "Concept" | Legacy product name; never use in docs | +| QS, QueryServer, `conceptQS` | The NDC web console (the repo calls it the administration interface) | +| Class, Term (`TermSettingsController`) | A class in a taxonomy; the glossary says the two are the same thing | +| Clue (`ClassClue`, `Rules/*Rule.cs`) | A match condition on a class; types include regex, phrase, metadata, date range, and term boost | +| Page | A processed item at a source; a Document is a Page that is a file | +| Source, Source Group, Dynamic Source | Same. A Dynamic Source discovers sub-sources, such as a mailbox per Exchange user | +| Classification writing, `ClassificationWriteConfig` | Writing classifications into file metadata (Office and PDF) | +| Expunge | Remove all records for a deleted document, from the Stats screen | +| DQS | Distributed Query Server: several NDC servers sharing one database | +| Re-index period | How often a source is reprocessed; default 7 days | +| MIP | Microsoft Information Protection sensitivity labels | + +The repo's `GLOSSARY.md` is the authoritative map. + +## Where a change usually lands + +- **New system setting:** a column on `Config` through `conceptDataGenerator/EntityDefinitions/IConfigValuesDefinition.cs` plus a regenerated entity (`conceptData/_Generated/`, do not hand-edit), a migration under `conceptData.Database/Schema/Migrations//` (named `M`, which seeds the default; example: `M543ConfigReportQueueThreshold`), then the controller, model, and view under `conceptQS/Areas/Configuration/`, and a string in `Resource.resx` plus `RKey.cs` if it has help text. The migration folder is the quickest way to find which release added a setting. +- **New source option:** `conceptQS/Areas/Sources/` (models, controllers, views) and the connector project's config class (for example `conceptFileSystem/FileServerConfig.cs`). +- **New report or report field:** a class in `conceptReports/Reports/`, columns in `conceptReportObjects/Columns/`, and the Reports area views. +- **New clue type or taxonomy behavior:** `conceptClassifierEngine/Rules/`, plus the Taxonomies area. Shipped taxonomies are in `Assets/BuiltInTaxonomies.xml`. +- **New workflow action:** a provider in `conceptWorkflow/Actions/Providers/`, plus the Workflows area. +- **New API endpoint:** `conceptQS/Areas/_api/Controllers/`, with tests in `conceptUnitTests`. + +## Docs map + +Under `docs/dataclassification//`; the latest is 5.8, with 5.7 and 5.6.2 alongside. Sidebars are auto-generated from the folder structure (`sidebars/dataclassification/*.js`), so a new page appears without a sidebar edit. + +| Area | Pages | +|---|---| +| Overview | `index.md` (features and benefits), `dashboards.md` | +| Install and deploy | `introduction/requirements/`, `install/`, `deployment/`, `virtualappliance/`, `upgrade.md`, `supportedsources.md` | +| Source setup (prerequisites per source) | `introduction/introduction/` (Box, Dropbox, Google Drive, Exchange, NFS, SharePoint modern auth) | +| First-run setup | `introduction/initialconfiguration/` | +| Sources | `contentconfigurationoverview/introduction/` (`addsource/`, `manage/`, `sourcegroups/`) | +| Taxonomies and clues | `contentconfigurationoverview/taxonomies/` (`settings/`, `languages/`, import, built-in, write classifications) | +| Workflows | `contentconfigurationoverview/workflows/` (overview, run log; actions as a subfolder) | +| System settings | `systemconfigurationoverview/configuration/` (`coreconfiguration/` has collector, indexer, system, administration, details; also metadata, text handling, redaction, communication) | +| Users and security | `systemconfigurationoverview/users/`, `licensing.md` | +| Administration | `systemconfigurationoverview/administration/` (backup, index maintenance steps) | +| Reports and DSAR | `dataanalysisoverview/reportingintroduction/`, `dataanalysisoverview/dsar/` | +| Knowledge Base | `docs/kb/dataclassification/` | + +Page types are mostly concept and reference pages with a few procedures. There are no per-setting or per-report pages: the Core tabs and each report are sections inside the pages above. + +## Watch out for + +- The repo's `CLAUDE.md` says to start every GitHub comment with a "Comment authored by Claude Code" header. That applies to comments in the product repo, not to docs PRs. +- PowerShell scripts in the repo must be ASCII-only. +- The `concept*` names are legacy and many are not used by customers. The `concept*Form` projects are debug harnesses, and `conceptUITests`, `conceptQS.UXTests`, `conceptIntegrationTests`, and `NDC_AT` are unused or not on `master` per `TESTING.md`. `conceptExchange.Legacy`, `conceptSearch.Presentation.Win`, `conceptTermStoreManager`, and `Documentum` interop are old components. +- `Oracle` and `Postgres` exist in the `DatabaseType` enum and schema installers, and `docs/postgres-*.md` describe a Postgres migration. `CLAUDE.md` says the installer allows only SQL Server. Docs 5.8 has a `postgresqlserver.md` source page for scanning a PostgreSQL source, which is a different thing. Do not document Postgres as a supported NDC database without confirming. +- Versions come from release branches (`release/5.7.10`) and from the numbered migration folders. Customer docs for a release live in a `release` branch named `NDC/` created from `dev` in the docs repo (per `CONTRIBUTING.md`). +- Bugs live in Azure DevOps (`Data Classification\Backlog`) and PR titles carry a Jira key such as `NDC-76:`. Enhancement requests go to the NDC Ideas page on the Netwrix Community. +- Folder names under 5.8 do not match the sidebar or UI labels (`contentconfigurationoverview`, `dataanalysisoverview`), so search by topic and not by path. +- `ARCHITECTURE.md` lists `Plugins/`, `Netwrix.UK.MailSender/`, and `NDC_AT/`, which are not in the checked-out tree. +- Not verified in the survey: editions and licensing tiers; which workflow actions and connectors are shipped to customers (the code has more than the docs describe); how the REST API maps to documented endpoints (no API pages found under 5.8); the customer story for `Netwrix.UK.Classification.Api` and the SDKs; whether the `ClassifierApp` area and SharePoint app are still supported; which UI labels live in views versus `Resource.resx`; and whether the 5.8 `overview/` folder holds only a category file. diff --git a/.claude/references/products/endpointprotector.md b/.claude/references/products/endpointprotector.md new file mode 100644 index 0000000000..d07e08ff72 --- /dev/null +++ b/.claude/references/products/endpointprotector.md @@ -0,0 +1,98 @@ +--- +product: endpointprotector +repo: netwrix-corp/endpoint-epp-client +local_clone: ~/endpoint-epp-client +last_reviewed: 2026-10-13 +status: drafted from a code survey; not yet reviewed by someone who owns the product +--- + +## What it is + +Endpoint Protector is a data loss prevention (DLP) and device control product. Administrators define policies in a central web console; software on each Windows, macOS, and Linux endpoint enforces them. Its modules are Device Control, Content Aware Protection (including network inspection, "DPI"), eDiscovery, and Enforced Encryption (EasyLock). + +**This repo is the client only.** It holds the endpoint agent for Windows, macOS, and Linux, the tray notifier, the browser extension, and the Outlook add-ins. The server (the web console, REST API, policy editing, virtual appliance, SCIM and directory sync, licensing, reports, alerts) is a separate codebase that is not here. The client has no standalone mode: it fetches policy from the server and holds only a local cache. + +Docs use "Netwrix Endpoint Protector" for the product. The docs mix "Endpoint Protector Client" and "EPP Client" (and likewise for Server), so match the page you are editing. There is no `docs/endpointprotector/CLAUDE.md`, so no product-specific naming rules exist beyond the repo style guide. + +## Code layout + +Paths are from the repo root. Windows and shared code is under `src/`; `mac/`, `linux/`, and `notifier/` are platform-specific. Each platform has its own build system. + +| Path | Role | User-visible behavior | +|---|---|---| +| `src/SiEServiceDLL/`, `src/SiEService/` | Windows service (policy enforcement, server check-in) | Windows client behavior | +| `mac/epp/`, `mac/epp/app/` | macOS daemon, IOKit and Bluetooth device monitoring | macOS client behavior, device control on Mac | +| `linux/epp-client/src/` | Linux systemd service, UDev device detection | Linux client behavior | +| `src/base/` | Shared logic: server communication, device rights, file shadowing, offline password, EasyLock | Most cross-platform settings and rights | +| `src/soap_client/` | SOAP client for the server protocol | Not user-visible | +| `src/dlp/cap/` | Content Aware Protection scanner: patterns, OCR, dictionaries, PII detectors | What counts as sensitive content | +| `src/netdlp/` | Network DLP: `sslsplit` transparent proxy, Windows redirect drivers | HTTPS inspection ("DPI") | +| `mac/netext/` | macOS network extension for DPI | DPI on macOS | +| `src/CssDcFlt/`, `src/dlp/driver/`, `src/SiEServiceDLL/SieFilter/` | Windows kernel drivers | Device and file enforcement on Windows | +| `src/crypt/` | Encryption and certificates | Client certificate, EasyLock | +| `notifier/` | Qt tray app, with translations in `notifier/resources/translations/` | Pop-ups, remediation dialogs, offline password entry | +| `src/BrowserBroker/`, `src/BrowserConnector/` | Browser extension (named "Netwrix EPP Browser Connector") and its native host | Browser upload inspection | +| `src/OutlookExtension/`, `src/OutlookAddinExtension/` | Outlook desktop (COM) add-in; Outlook web and new Outlook add-in | Email inspection | +| `src/Setup/`, `linux/epp-client-generate-builds/`, `mac/installer/` | MSI, DEB and RPM, and PKG packaging | Install and deployment | +| `src/UnitTest_Main/` | Google Test unit tests, shared by all platforms | Not user-visible | + +Policy and settings arrive from the server in the check-in ("ping") replies and are parsed in `src/base/` and `src/SiEServiceDLL/SiESoapClient.cpp`. The action codes are in `src/base/epp_soap_protocol.h`. Client-to-server traffic is TLS 1.2 or later with a per-client certificate (mTLS) on port 443 by default; the client always initiates. See `docs/misc/epp_client_server_communication_overview.md` in the repo. + +## Vocabulary + +| Code says | UI and docs say | +|---|---| +| EPP | Endpoint Protector (Platform) | +| CAP | Content Aware Protection | +| DC | Device Control | +| DPI, netdlp, sslsplit | Deep Packet Inspection, network content inspection | +| EE | Enforced Encryption, with EasyLock as the companion app | +| SDR, "data at rest" actions | eDiscovery scanning | +| OTP, `OfflineTempPassword` | Offline Temporary Password | +| TD level | Trusted Device level (1 to 4) | +| Allowlist (legacy: whitelist, `WLIST`) | Allowlist | +| Denylist (legacy: blacklist, `BlacklistedAppList`) | Denylist | +| Shadowing, `FileShadower` | File shadowing, file tracing | +| Notifier | Client notifications and remediation dialogs | +| `cososys` in paths and IDs | Former vendor name; not shown to users | + +The repo's `GLOSSARY.md` is the authoritative map for client terms. + +## Where a change usually lands + +- **New or changed policy option or switch:** the server sends it, so the client change is in the parsing and enforcement code in `src/base/` (or `src/SiEServiceDLL/` on Windows), then each platform's service if behavior differs. The matching docs change is on a server console page, which this repo cannot confirm. +- **New content detector (for example a PII format):** a new `*Detect.cpp` under `src/dlp/cap/`, tests in `src/UnitTest_Main/`. Docs: `admin/cap_module/contentdetection.md`. +- **New device type or device control rule:** `src/base/` and the per-platform device monitoring (`mac/epp/ioreg/`, UDev in `linux/`, `src/SiEServiceDLL/` on Windows). Docs: `admin/dc_module/`. +- **New user-facing message or dialog:** `notifier/`, plus translation files. +- **Network inspection change:** `src/netdlp/`, `mac/netext/`, `linux/epp-client/src/netdlp_setup/`. Docs: `admin/cap_module/deeppacket.md`. +- **Anything platform-specific:** check all three platforms; the same change usually appears in `src/`, `mac/`, and `linux/`. + +## Docs map + +Under `docs/endpointprotector/`. The product has one version, `current` (no version folder), and the sidebar is autogenerated from the folder tree (`sidebars/endpointprotector/epp.js`), so new pages appear without editing it. + +| Area | Where | +|---|---| +| Overview, getting started | `index.md`, `gettingstarted.md` | +| Requirements (server, client, components, network architecture) | `requirements/` (`client.md` covers the client) | +| Install and deployment (virtual appliance, cloud images, Intune, Jamf, Active Directory GPO, migration and upgrade) | `install/` | +| Administration (login, dashboard, alerts, reports, directory services, SCIM, backup, licensing, SSO, mail, REST API) | `admin/`, `admin/systemconfiguration/`, `admin/systemmaintenance/`, `admin/epp_rest_api/` | +| Device Control | `admin/dc_module/` | +| Content Aware Protection, DPI, Outlook add-ins | `admin/cap_module/`, `admin/denylistsallowlists/` | +| eDiscovery | `admin/ed_module/` | +| Enforced Encryption | `admin/ee_module/` | +| Client support policy | `supportability/client-supportability.md` (and `server-supportability.md`) | +| Knowledge Base | `kb/`, in seven topic folders (administration, CAP and DPI, deployment, device control, enforced encryption, features, troubleshooting) | + +Page types: overview and module pages (concept plus configuration), step-by-step admin and deployment guides with many screenshots, requirements and supportability pages, an API reference, and KB articles. Most of the docs describe the server console. + +## Watch out for + +- **Client-only repo.** Settings, policy fields, console screens, alerts, reports, licensing, the REST API, the virtual appliance, and server upgrades are not in this repo. A client PR can say a policy "supports" something, but the console page that exposes it must be checked against the server or its owners. +- **Kernel drivers conflict with the docs.** `requirements/client.md` says the client does not inject kernel-level drivers or extensions on any OS. This repo builds Windows kernel drivers (`CssDcFlt`, `dlp/driver`, the WFP drivers, `sieflt`), the installer project references them, and macOS uses a system extension. Do not repeat or "fix" either side without asking the product owner. +- **Version constants disagree.** The Windows, macOS, and Linux client versions live in three different files (`SiEDefines.h`, `mac/epp/app/Constant.h`, `linux/epp-client/src/util/Constant.h`) and the values on `main` are not in step. The scheme is `YYMM...` where the second field is 0 for server, 1 Windows, 2 Mac, 3 Linux, 4 Win-Mac EE. Use release branch names (such as `maintenance/patch5950`) and the work item to find which release a change shipped in. +- **Branches and tracking.** Branch from `main` as `feature/EPP-`; commits end with the Jira key `EPP-`. `CONTRIBUTING.md` points to Jira, while `AGENTS.md` and `.mcp.json` mention Azure DevOps (project `CoSoSys-EPP`); the survey could not tell which is current. +- **Not every pipeline runs on PRs.** All Azure Pipelines are set to `trigger: none`, so a merged client change does not prove a build. +- **Browser extension scope.** The architecture doc names Chrome, Edge, and Firefox in its diagram but describes the extension as Chrome and Edge. The manifest is a Chrome-style manifest v3. Browser support has to come from the server docs or the product team. +- **Docs and repo naming.** Repo docs say "EPP Client"; the product docs mix that with "Endpoint Protector Client". Product docs have the "Netwrix" prefix on first mention. +- **Not verified in the survey:** how the server stores and delivers each policy field; which kernel drivers actually ship in the current installers; supported OS versions and architectures (see `supportability/client-supportability.md`); whether macOS DPI uses a network extension only or also Packet Filter (the glossary says PF "may" be used); the Outlook web add-in's deployment path; whether `kb/` articles are also copied elsewhere by `scripts/copy-kb-to-versions.mjs`; and every docs page that describes server-only behavior. diff --git a/.claude/references/products/pingcastle.md b/.claude/references/products/pingcastle.md new file mode 100644 index 0000000000..5e94f69342 --- /dev/null +++ b/.claude/references/products/pingcastle.md @@ -0,0 +1,87 @@ +--- +product: pingcastle +repo: netwrix-corp/itdr-pingcastle-core +local_clone: ~/src/itdr-pingcastle-core +last_reviewed: 2026-09-30 +status: drafted from a code survey; not yet reviewed by someone who owns the product +--- + +## What it is + +PingCastle assesses the security of Active Directory and Entra ID. It scores the risk of a domain and produces self-contained HTML and XML reports. It ships as tiers: Standard and Basic (a command-line tool), Pro, and Enterprise (a web app with a database, sign-in options, a REST API, scheduling, and agents). 1Secure is a separate SaaS integration built on the same code. The repo targets .NET 10. + +Docs use these exact names: PingCastle Enterprise, Pro, Standard, Basic, and PingCastle for Service Providers. See `docs/pingcastle/CLAUDE.md`. + +## Code layout + +Paths are under `Source/` in the repo. + +| Path | Role | User-visible behavior | +|---|---|---| +| `PingCastle/` | Console app | CLI switches (`Program.cs`, a large `switch` on `--healthcheck`, `--scanner`, `--server`, `--license`, `--out`), help text, scanners, exports | +| `PingCastle/Scanners/` | 16 scanners (ZeroLogon, Spooler, NullSession, LAPSBitLocker, localAdmins, others) | `--scanner ` | +| `PingCastle/Healthcheck/HealthcheckAnalyzer.cs` | Collects Active Directory data | What the assessment can see | +| `PingCastleCommon/Healthcheck/Rules/` | About 190 domain rule classes, named `Heatlcheck*` (the typo is real) | Each rule is one finding in the report | +| `PingCastleCommon/Cloud/Rules/` | Entra ID rules | Findings in the Entra ID report | +| `PingCastleCommon/Rules/` | Rule infrastructure and attributes | Scoring, maturity level, MITRE ATT&CK and ANSSI mappings | +| `PingCastleCommon/Report/`, `Template/` | Report classes and HTML/CSS/JS templates | Report layout and content | +| `PingCastleCommon/PingCastleLicense/` | Licensing | Edition checks, license validation | +| `PingCastleEnterprise/` | ASP.NET Core MVC app | Web UI (Razor views), settings, action plans, scan profiles, scheduling, API | +| `PingCastlePro/` | Web app, same shape as Enterprise | Purpose not stated in the repo docs; check with the team | +| `PingCastleSchedulerService/` | Windows service | Scheduled scans | +| `PingCastleEnterprise.1Secure.Coordinator*` | Azure Functions | 1Secure integration (`1SecureMode` setting) | +| `PingCastle.Cloud.*` | Separate API (Clean Architecture) | Docs in the repo under `Docs/PingCastle.Cloud/` | + +Where text and settings live: +- **Rule text** (title, description, solution, rationale, technical explanation): `RuleDescription.resx`, one under `Healthcheck/Rules/` and one under `Cloud/Rules/`. Keys look like `A_AuditDC_Title`. +- **Other UI strings:** `PingCastleCommon/Properties/Resources.resx`; Enterprise Razor views under `PingCastleEnterprise/Views/`. +- **Enterprise settings:** `appsettings.json` and `Options/` classes; the settings UI is `SettingsController` with `Views/Settings`. Environment variables are listed in the repo `README.md`. +- **Console settings:** `PingCastle/appsettings.console.json`. + +## Vocabulary + +| Code says | UI and docs say | +|---|---| +| Healthcheck / `HealthcheckData` | The main Active Directory risk assessment and its report | +| Rule, `[RuleModel("A-AuditDC", ...)]` | A finding; the ID is its rule ID | +| AAD, Azure AD, `--entraid` | Entra ID | +| Consolidation, `--hc-conso` | Combining several domains into one report | +| Cartography | Trust and network map | +| Action Plan | Remediation list (Critical, High, Medium, Low) | +| Agent, `AgentAuthorisation` | Scanning agent that syncs to Enterprise | +| Coordinator | 1Secure integration layer | +| `AnomalyScore`, `PrivilegedGroupScore`, `StaleObjectsScore`, `TrustScore`, `GlobalScore` | Category scores and the overall score | + +The repo's `GLOSSARY.md` is the authoritative map. + +## Where a change usually lands + +- **New rule:** the rule class under `Healthcheck/Rules/`, found by reflection so there is no registry; its entries in `RuleDescription.resx`; `HealthcheckData.cs` and `HealthcheckAnalyzer.cs` only if it needs new data; tests in `PingCastleTests/` and `PingCastleEnterpriseTests/HealthCheckRuleTests.cs`. The repo skill `.claude/skills/add-pingcastle-risk/SKILL.md` walks through it. `[RuleIntroducedIn(major, minor)]` on the class gives the version that added the rule, which is the fastest way to find the docs version. +- **New CLI option:** a `case "--x":` in `PingCastle/Program.cs`, plus the help text there. New scanners go in `PingCastle/Scanners/`. +- **New Enterprise setting:** an options class under `PingCastleEnterprise/Options/`, `appsettings.json`, and the settings view. Check the docs `enterprisesettings-*.md` pages. + +## Docs map + +Under `docs/pingcastle//`, latest is 4.0. + +| Area | Pages | +|---|---| +| Install, upgrade, requirements | `enterpriseinstall.md`, `enterpriseupgrade.md`, `enterpriserequirements.md`, `enterprisepostinstall.md`, `enterprisehttpssetup.md`, `enterpriselicense.md` | +| Settings | `enterprisesettings.md` and `enterprisesettings-*.md` (application, security, logging, data retention, scanning and scheduling, Entra scanner, SCIM, action plans) | +| Scanning | `enterprisescheduling.md`, `enterpriseagentdeployment.md`, `enterpriseentrascan.md` | +| User manual | `enterpriseuser-*.md` (dashboard, entities, account, import) | +| Other Enterprise | `enterprisearchitecture.md`, `enterpriseauditlog.md`, `enterpriseauthsetup.md`, `enterpriseemailsetup.md`, `enterprisetroubleshooting.md` | +| Standard and Basic | `index.md` | +| Pro | `pro*.md` | +| Knowledge Base | `kb/` | + +## Watch out for + +- The repo's own style is British English with no dashes; the docs follow the Netwrix style guide instead. +- `RELEASE_NOTES.md` in the repo is not a full history. Version numbers come from CI at build time (`Directory.Build.props` holds a `0.0.0.0` placeholder), so read `[RuleIntroducedIn]` and the release branch names (`release/*`) for versions. +- Branches are named `//-`, and PRs go to `master`. Commits reference Azure DevOps items as `AB#` or `PIC-nnn`. +- Worth reading in the repo: `README.md`, `ARCHITECTURE.md`, `GLOSSARY.md`, and `Docs/PingCastle/`. +- Not verified in the survey: the full list of license editions, the purpose of Pro, how scanner discovery works (`PingCastleFactory`), and other connectors such as SIEM or ticketing. +- Docs have no per-rule pages. Rules are covered in the healthcheck and user guide pages, so a PR that adds rules usually needs a new page or a section in an existing one, not an edit to a rule page. +- `sidebars/pingcastle/4.0.js` (and `3.5.js`) is hand-curated. A new page does not appear in the sidebar until someone adds its ID there. +- `docs/pingcastle/CLAUDE.md` describes a `3.5/` file layout that no longer matches `4.0/`. Check the actual folder. diff --git a/.claude/references/products/threatmanager.md b/.claude/references/products/threatmanager.md new file mode 100644 index 0000000000..968d86f410 --- /dev/null +++ b/.claude/references/products/threatmanager.md @@ -0,0 +1,95 @@ +--- +product: threatmanager +repo: netwrix-corp/itdr-threatmanager +local_clone: ~/itdr-threatmanager +last_reviewed: 2026-10-13 +status: drafted from a code survey; not yet reviewed by someone who owns the product +--- + +## What it is + +Threat Manager is an identity threat detection and response (ITDR) product. It watches Active Directory, Microsoft Entra ID, and file systems for abnormal behavior and attacks, shows detections as threats with evidence, and responds with playbooks (PowerShell-based actions). It takes event data from Netwrix Activity Monitor and Netwrix Threat Prevention, and sensitive-data tags from Netwrix Access Analyzer. It can also forward threats to a SIEM and send email alerts. It is a Windows installer product backed by PostgreSQL, not SaaS. A Report Only license mode limits the UI to investigations, object pages, service accounts, and a few settings. + +Docs use "Netwrix Threat Manager" on first reference and "Threat Manager" after that. Never write StealthDEFEND. See `docs/threatmanager/CLAUDE.md`. + +## Code layout + +| Path | Role | User-visible behavior | +|---|---|---| +| `StealthDefendCore/` | .NET backend, 150+ projects, mostly Akka.NET actors | All server behavior | +| `StealthDefendCore/JobManager/AD/ThreatActorsV2/` | One class per threat detector (Azure ones in `Azure/`) | Each detector is a threat type in the UI and the docs threat catalog | +| `StealthDefendCore/Common/ThreatPropertyModel/` | Per-threat data model | The fields shown as evidence on the threat page | +| `StealthDefendCore/PostgresEFCore.Models/Enums/Constants.cs` | `ThreatIds` constants | The numeric ID that ties code, database row, and UI together | +| `StealthDefendCore/WebApi/`, `RestServer/` | REST API; `RestServer` hosts the web service | Everything the UI calls | +| `StealthDefendCore/EventMessageService/` | Event Service | Event intake from Activity Monitor and Threat Prevention | +| `StealthDefendCore/JobService/` | Job Service | Runs detection | +| `StealthDefendCore/ActiveDirectoryService/`, `AzureService/` | Directory sync | Active Directory and Entra ID sync settings | +| `StealthDefendCore/ActionService/`, `ActionManager/` | Playbook execution | Playbooks and response actions | +| `StealthDefendCore/SiemService/`, `EmailService/`, `IntegrationService/` | Outputs and integrations | SIEM, email, and third-party settings | +| `StealthDefendCore/LicenseService/` | Licensing | Licensing page, Report Only mode | +| `web/src/app/` | React and TypeScript UI (`pages/`, `components/defend/`, `services/`, `store/`) | Every screen and its text | +| `installerFiles/sql13/` | SQL patch files `vXX.XX.XX.sql` | Seed data for threats and settings; database upgrades | +| `STEALTHbits.Installer/` | WiX installer, prerequisite check | Install and upgrade, install paths | +| `tools/` | Test tools (attack simulator, test message sender) | None | +| `docs/` | Developer docs (`Guides/`, `Threat Manager Xchange/`) | None. Not customer docs | + +Where text and settings live: +- **UI strings** are hardcoded in `web/src/app/` JSX and TSX. There is no resource file or translation layer. +- **Threat text** (display name, short title, description, color, icon, default settings, attack path) lives in the `job` table row, inserted by a SQL patch in `installerFiles/sql13/`. Evidence rows come from the `job_evidence` table. Both use Liquid templates. +- **Threat settings** are JSON in the `job.settings` column. Each has a type that picks the UI widget, plus an explanation string. +- **Service configuration** is `appsettings.json` per service and a shared `appsettings.common.json` under the data directory. Most customer-facing settings are set in the UI and stored in the database instead. + +## Vocabulary + +| Code says | UI and docs say | +|---|---| +| StealthDEFEND, SD, STEALTHbits | Netwrix Threat Manager (NTM in internal text only) | +| Job, `job` table, `ThreatIds` | A threat (a detector). Some jobs are system jobs, not threats | +| Threat processor, threat actor | The detector behind a threat | +| Action Group | Playbook | +| Honey token, policy | Honeytoken, under Policies | +| UBA / UEBA | User behavior analytics; abnormal behavior threats | +| NTP, NAM, NAA | Netwrix Threat Prevention, Activity Monitor, Access Analyzer | +| SbPAM | Netwrix Privilege Secure | +| RO | Report Only license mode | +| `SBTSDWEB`, `SBTSDEVENT`, `SBTSDJOB`, `SBTSDAction`, `SBTSDAD`, `SBTSDAZ`, `SBTSDSIEM`, `SBTSDINT`, `SBTSDEMAIL`, `SBTSDLICENSE` | The Netwrix Threat Manager services: Web, Event, Job, Action, Active Directory, Azure, SIEM, Integration, Email, License | + +The repo's `GLOSSARY.md` is the fuller map. + +## Where a change usually lands + +- **New threat (detector):** the actor class under `ThreatActorsV2/`, a `ThreatIds` constant, a property model, an `INSERT INTO job` (and `job_evidence`) in a new SQL patch, DI registration, registration in `ThreatSubsystem`, and the ID in a list in `web/src/app/components/defend/configuration/JobsList.jsx` (`adList`, `azureList`, `fileList`, `generalList`, `hideList`). A job ID in none of those lists still runs but does not show in the configuration tree. The repo guide is `docs/Guides/creating-a-threat-processor.md`, and `.claude/skills/new-threat` scaffolds it. +- **New threat setting:** an entry in the `settings` JSON of the job row (SQL patch), read in the actor's `RefreshSettings`. The UI renders it from the setting type, so there is usually no front-end change. +- **New option or switch elsewhere:** a page under `web/src/app/pages/` or `components/defend/configuration/`, a `services/` call, and a controller under `WebApi/`. Check the configuration pages in the docs. +- **New report or investigation field:** event and entity models in the backend, plus the investigation and query-builder pages in `web/`. Export formats are CSV, JSON, PDF, and HTML. +- **Database changes:** the `StealthDefendContext` schema uses manual patches in `installerFiles/sql13/`, and the other EF contexts use migrations. Patch names are coordinated by hand, so a new `vX.Y.Z.sql` name is a good signal of the release. + +## Docs map + +Under `docs/threatmanager//`, versions 3.0 to 3.3, latest is 3.3. Edits to one version do not carry to the others. + +| Area | Pages | +|---|---| +| Overview and first steps | `index.md`, `gettingstarted.md` | +| Install and upgrade | `install/` (application, database, action service, proxy, secure, appsettings, first launch, integration, `upgrade/`) | +| Requirements | `requirements/` (server, database, client, action service, ports, permissions) | +| Threats dashboard, service accounts | `administration/threats/` (Active Directory objects, Entra ID objects, threat details), `administration/serviceaccounts.md`, `administration/home.md` | +| Investigations | `administration/investigations/` (new, predefined, favorites, reports, subscriptions and exports, options) | +| Playbooks | `administration/playbooks/` (trigger, steps, import, export, `action/` by target type) | +| Configuration | `administration/configuration/` (`integrations/`, `policies/`, `systemhealth/`, `systemsettings/`, `threatdetection/`, `threatresponse.md`) | +| Troubleshooting | `administration/troubleshooting/` | +| Threat catalog | `threats/` (`activedirectory.md`, `entraid.md`, `filesystem.md`, `certificateservices.md`, `general.md`, `custom.md`). One H2 per threat | +| Knowledge Base | `kb/` in six category folders | + +Page types: overview pages per section, task and reference pages (one per UI screen), a catalog of threats, KB articles. The sidebar `sidebars/threatmanager/3.3.js` is autogenerated from the folders, so a new page needs no sidebar edit. Images are in `static/images/threatmanager//`. + +## Watch out for + +- The repo, folders, installer, services, database name, and registry keys still say StealthDEFEND and STEALTHbits. Real install paths such as `C:\Program Files\STEALTHbits\StealthDEFEND\` and the default database name `stealthdefend` stay in install docs as literal values. Do not use the old name as a product name. +- `docs/threatmanager/CLAUDE.md` says only 3.0 and 3.1 exist. The folder and `src/config/products.js` have 3.0 through 3.3. +- The `docs/` folder in the code repo is developer documentation (`Threat Manager Xchange`), not the customer docs. Customer docs live here. +- Threat names in the UI come from `job.job_type` in the database, not from code. The actor class name often differs from the name in the docs catalog. Match by threat ID and SQL patch. +- `SingleEventThreatActor` is deprecated. New detectors use `BulkThreatActorV2`, so older detectors can look different. +- Threat IDs are coordinated by hand across the team, and the code guide says a duplicate breaks routing. +- Branches use a Jira key (for example `TMR-1234`), and PRs go to `master`. `CONTRIBUTING.md` links to a GitHub org (`netwrix-eng`) that differs from the repo name above. +- Not verified in the survey: the exact count of detectors (the repo says "30+", and `ThreatActorsV2/` has 38 entries including folders and helpers); license tiers beyond Report Only; whether every UI label matches the docs; how release versions map to SQL patch names or branches; the mapping from each threat ID to the docs catalog entry; and whether `local_clone` and the GitHub org name are right for other machines. diff --git a/.claude/references/products/threatprevention.md b/.claude/references/products/threatprevention.md new file mode 100644 index 0000000000..f431689c8c --- /dev/null +++ b/.claude/references/products/threatprevention.md @@ -0,0 +1,103 @@ +--- +product: threatprevention +repo: netwrix-corp/itdr-threatprevention +local_clone: ~/itdr-threatprevention +last_reviewed: 2026-10-13 +status: drafted from a code survey; not yet reviewed by someone who owns the product +--- + +## What it is + +Netwrix Threat Prevention monitors, alerts on, and can block activity in Active Directory, Exchange, Group Policy, file systems, and credential operations, in real time on domain controllers and Exchange servers. It hooks security-critical functions inside LSASS and Exchange processes instead of reading Windows event logs. Administrators work in a WinForms console or PowerShell cmdlets. The repo targets .NET Framework 4.7.2 for managed code and C++23 (MSVC v143) for the native hooks. It is on-premises software, with no SaaS tier in the repo. The repo's `README.md` doesn't list editions; the license manager (`SIMonitor.Licensing/`) is the place to look. + +The repo and its code call the product StealthINTERCEPT ("SI"). Docs use "Netwrix Threat Prevention" or "Threat Prevention". The docs have no product-level `CLAUDE.md`, so there is no written naming rule. Follow the 8.1 pages, which use "Enterprise Manager" and "Administration Console". Netwrix Threat Manager is a separate product that Threat Prevention integrates with. + +## Code layout + +Paths are under `Src/` in the repo. Each subsystem has its own `CLAUDE.md`. + +| Path | Role | User-visible behavior | +|---|---|---| +| `NetVision/SIMonitor.Service.Host/`, `SIMonitor.Core/` | Enterprise Manager (EM, `SIEnterpriseManager.exe`) | Policies, event consumers, analytics, SQL storage, gRPC server, `filters.json` | +| `NetVision/SIMonitor.Ui.WinConsole/` | WinForms console (`SIWinConsole.exe`) | Every screen, dialog, and wizard an administrator sees | +| `NetVision/SIMonitor.PowerShell/` | PowerShell module | Cmdlets such as `Connect-SIEnterpriseManager` and `Get-SIPolicy` | +| `NetVision/SIMonitorAd.Service.Host/`, `SIMonitorAd.Core/` | Windows Agent (`SIWindowsAgent.exe`), one per domain controller | Event filtering, policy sync, shared-memory settings in `SIWindowsAgent.exe.config` | +| `ActiveDirectoryMonitor/PSA/` | Native hook DLL injected into LSASS | What can be captured or blocked in AD, Kerberos, NTLM, LDAP | +| `NetVision/ExchangeMonitor/` | Native hooks for Exchange | Exchange monitoring and lockdown | +| `GPO Change Detection/` | Windows service | GPO setting change and lockdown events | +| `CredentialProvider/` | Windows logon credential provider | Password policy enforcement at workstation logon | +| `EPE_REST/` | ASP.NET Web API | Enterprise Password Evaluation REST site | +| `NetVision/SIMonitor.Core/Analytics/` | Threat analytics recognizers | Built-in analytics policies (brute force, Golden Ticket, others) | +| `NetVision/SIMonitor.Pwned/` | Breached-password check | Breached Password analytic and EPE | +| `NetVision/SIConfigurator/`, `SICertMgr/` | Setup and certificate tools | Certificate management wizard, configuration tool | +| `../Install/` | WiX installers | Server MSI, Agent EXE, Credential Provider and EPE REST MSIs | + +Where text and settings live: +- **Event type names and descriptions:** `Common.Resources/Source/SharedStrings.cs` (keys like `StrADReplicationLockdown`), read through `ResXManager`. `Common.NNR/SIMonitor.Messages.Source/Collections/EventDefinitionCollection.cs` ties each event type to its name, description, `erTypeId` code, and available filters. +- **Other console strings:** mostly hardcoded in the WinConsole `Forms/` and `Controls*/` code, plus a few `.resx` files (`MainForm.resx`, `Policy.resx`, `SIMonitor.ConsoleMessages/Resource.resx`). Check both. +- **Component settings:** `appconfig.xml` and `appconfig.release.xml` in each host project become `.exe.config` at build. They are read once at start, so a change needs a restart. +- **System-wide noise filters:** `SIMonitor.Service.Host/filters.json`. +- **Cmdlet names:** `SIMonitor.PowerShell/PSHelpers.cs` (`PSDefs`). + +## Vocabulary + +| Code says | UI and docs say | +|---|---| +| StealthINTERCEPT, SI, `SIMonitor.*` | Netwrix Threat Prevention | +| EM, `SIEnterpriseManager` | Enterprise Manager | +| WC, WinConsole, `SIWinConsole` | Administration Console (the docs also use "Windows Console" once) | +| WA, `SIWindowsAgent` | Agent (Windows agent) | +| AD Monitor, `SI.ActiveDirectoryMonitor.dll`, `PSA/` | The LSASS hook; `PSA` is a historical name | +| `EventRegistration`, `erTypeId` | Event type within a policy (for example Active Directory Changes) | +| `EventConsumer` | Action (email, file, PowerShell, .NET script, syslog) | +| Collection | Collection (Collection Manager) | +| Lockdown | Blocking, as in "Active Directory Lockdown" | +| Recognizer, `AbstractScenarioRecognizer` | Analytics policy | +| EPE | Enterprise Password Evaluation | +| `PB_Filters`, `filters.json` | System-wide event filtering | + +The repo's `GLOSSARY.md` covers the acronyms. It maps SI to NTP, the marketing name. + +## Where a change usually lands + +- **New event type or event class:** an entry in `EventDefinitionCollection.cs` and an `ErTypeIdTags.cs` constant; names and descriptions in `SharedStrings.cs`; agent handling in `SIMonitorAd.Core/ActiveDirectory/AdMonitorModule.cs`; a native hook in `ActiveDirectoryMonitor/PSA/` or `ExchangeMonitor/` if new data is needed; console UI under `SIMonitor.Ui.WinConsole/`. Check `Forms/FormEventSelection.Designer.cs`. +- **New filter:** a filter config class in `Common.NNR/SIMonitor.Messages.Source/Source/`. `ARCHITECTURE.md` explains the three filter mechanisms. Only filters that implement `ILockdownAspectGenerator` are enforced at capture time and show in Rules Preview; others are agent-only. A filter control goes in `SIMonitor.Ui.WinConsole/Controls-Filters-*`. +- **New setting:** the `appconfig.xml` and `appconfig.release.xml` of the host project, read in code through `ConfigurationManager.AppSettings`; the console surface goes in `SIMonitor.Ui.WinConsole`. Global options use classes in `SIMonitor.ConsoleMessages/` (such as `GlobalFiltersSettings.cs` and `PwnedConfig.cs`). +- **New analytic:** a `*Recognizer.cs` plus storage classes in `SIMonitor.Core/Analytics/`, settings in `SIMonitor.ConsoleMessages/AnalyticsSettings.cs`, and unit tests in `SIMonitor.Core.UnitTest/`. +- **New cmdlet:** a class in `SIMonitor.PowerShell/` with `[Cmdlet(...)]`, its name in `PSDefs`, and help under `Help/`. +- **Report field or event column:** the event schema and SQL tables (`NvEvent`) in `SIMonitor.Data/` and `SIMonitor.Core/`, the gRPC contracts in `Grpc.Messages.*`, and console grid and filter code. + +## Docs map + +Under `docs/threatprevention//`; versions are 7.4, 7.5, 8.0, and 8.1 (latest). `sidebars/threatprevention/*.js` is autogenerated from the folders, so new pages appear by placement and `sidebar_position`. + +| Area | Where | +|---|---| +| Overview, first steps | `index.md`, `gettingstarted.md` | +| Requirements | `requirements/` (application, Administration Console, agent, SQL Server, EPE REST site, reporting server, `ports.md`) | +| Install, upgrade | `install/` (application, agent, first launch, certificate wizard, DB connection manager, upgrade, migrate EM server) | +| Administration Console | `admin/`: navigation, policies, templates, agents, alerts, analytics, configuration, investigate, tools | +| Event types | `admin/policies/configuration/eventtype/`: one page per event type (monitoring and lockdown) | +| Actions (consumers) | `admin/policies/configuration/actions/` | +| Analytics | `admin/analytics/`: one page per recognizer | +| Collections, global settings | `admin/configuration/` (`collectionmanager/`, `eventfilteringconfiguration.md`, `epesettings.md`, `threatmanagerconfiguration.md`, `filemonitorsettings.md`) | +| PowerShell and EPE API | `api/` (policy, collections, EPE, Threat Manager, load module) | +| Solutions | `solutions/` (Active Directory, Exchange, file system, LDAP, EPE) | +| EPE REST site | `eperestsite/` | +| SIEM and reporting | `siemdashboard/`, `reportingmodule/` | +| Troubleshooting | `troubleshooting/` | +| Knowledge Base | `kb/` (licensing, agent install, database, troubleshooting) | + +Page types: overview pages (`overview.md`) per section, task and reference pages for each screen or option, requirements and install guides, per-event-type and per-analytic reference pages, API reference, and KB articles. + +## Watch out for + +- Several PRs per feature split across the hook (C++), the agent, EM, and the console. A repo PR title may name only one layer. +- The native hook version must be at least the agent version, so changes that look backward-incompatible in IPC messages are intentional. +- Docs cover versions 7.4 through 8.1 as separate trees; an edit to one version does not carry to the others. The repo is at 8.1 (version stamped as `8.1.0.`); `release/` branches hold maintenance lines. +- Paths marked protected in the repo `CLAUDE.md` (`PSA/Signatures/`, `ExchangeMonitor.Common/Hook/`) are not documentation sources. +- The repo's README says 25 cmdlets, while a count of `[Cmdlet(` attributes in `SIMonitor.PowerShell/` found 24 files. The docs' `api/` pages are the user-facing list. +- Docs name some analytics differently from the code: the code has a File System Attacks recognizer (`FilesPerUserRecognizer`) and `DomainLocalLoginsRecognizer`; the docs have `filesystemattacksuser.md` and no Domain Local Logins page. Check before documenting an analytic. +- PRs go to `main`, with titles like `Feature:`, `Bug:`, or `Fix:`. Work items appear as `AB#`. +- Worth reading in the repo: `ARCHITECTURE.md` (policy, event registration, and collection concepts), `GLOSSARY.md`, and the per-subsystem `CLAUDE.md` files. +- Not verified in the survey: license editions and what they unlock; whether the WinConsole screen names always match the doc page titles; where the Threat Manager integration code lives; how the SIEM, reporting module, and Access Analyzer integrations are built (`SISDIntegration/` is the likely home); Exchange monitor and credential provider docs coverage; and the full event-type list against the 8.1 `eventtype/` pages. diff --git a/.claude/skills/doc-draft/SKILL.md b/.claude/skills/doc-draft/SKILL.md index 3957bec706..1738124dd8 100644 --- a/.claude/skills/doc-draft/SKILL.md +++ b/.claude/skills/doc-draft/SKILL.md @@ -12,7 +12,7 @@ Read `docs/CLAUDE.md` first. It holds the Netwrix conventions, Vale rules, file If the task is ambiguous, ask one clarifying question before you start. Otherwise make a reasonable choice, say what you chose in your report, and go on. If you can't ask (you are running in a subagent or headless), don't stop: make the choice, and say what you chose in your report. -You lint your own drafts: run the style check in step 9 of "Draft new documentation" before you finish. It is the same check that blocks the commit, and nothing lints on the PR. +You lint your own drafts: run the style check in step 10 of "Draft new documentation" before you finish. It is the same check that blocks the commit, and nothing lints on the PR. ## Task Types @@ -20,14 +20,15 @@ You lint your own drafts: run the style check in step 9 of "Draft new documentat 1. Read `docs/CLAUDE.md` for conventions, and `.claude/references/lint-rules.md`, the list of rules the style check enforces, so your first draft avoids them 2. Read the specification or source material provided -3. Read 1–2 similar existing documents in the same product for structural reference. Decide which zone the page belongs in from `.claude/references/documentation-section-guide.md`, by the reader's question the page answers +3. Read 1–2 similar existing documents in the same product for structural reference. Decide which zone the page belongs in from `.claude/references/documentation-section-guide.md`, by the reader's question the page answers. Then read `.claude/references/page-structure-guide.md` to decide the page's type (task, concept, reference, or troubleshooting), whether it is a new page, a section, or an edit, and how to title and order it 4. Draft the content following Netwrix structure: overview → prerequisites → procedures Every page you write starts with frontmatter that includes a `description`. The description is one sentence, under 160 characters, that says what the reader can do or learn on the page; write it from what the page contains, not from the title. Match the neighboring pages for the other keys (`title`, `sidebar_position`, `sidebar_label`). 5. Give every procedure an example: sample values, a command with real arguments, or the result the reader should see. Do not add an example to a concept just because it is introduced. If a concept would be clearer with an example and the source does not support a real one, write `{/* TODO: add an example of */}` where it belongs and list it in your report. Never invent an example to fill the space. 6. Anticipate reader questions and answer them inline -7. Reread the drafted file. Confirm the frontmatter has a `description` on every page you wrote. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. -8. Write plainly the first time, and never trade meaning for shorter sentences: keep every fact, condition, qualifier, and requirement. Do not rewrite a finished draft to hit readability numbers. If the writer asks how readable the page is, run `npm run quality:readability -- ` and report the numbers; it is a report, not a target. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. -9. Lint the page before you finish. Run `npm run quality:check -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. +7. Reread the drafted file. Confirm the frontmatter has a `description` on every page you wrote, and that every internal link points to a file that exists. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. +8. **Write plainly and write short sentences, but never trade meaning for length.** Keep every fact, condition, qualifier, and requirement. When a long sentence carries several facts, split it into short sentences instead of cutting one. Aim for one idea per sentence, procedure steps of 20 words or fewer that each start with an imperative verb, descriptive sentences of 25 words or fewer, and one term for each concept (do not alternate "remove", "delete", and "clear" for the same action). Avoid phrasal verbs ("start", not "kick off") and nominalizations ("configure the setting", not "perform the configuration of the setting"). These are drafting targets, not a lint gate. Bold UI labels, quoted product messages, commands, and code keep their exact wording. Do not rewrite a finished draft to hit readability numbers. If the writer asks how readable the page is, run `npm run quality:readability -- ` and report the numbers; it is a report, not a target. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. +9. Check coverage against the source before you lint. List the facts a reader needs in order to act that the code or source establishes: commands and flags, parameter aliases, defaults, limits and allowed ranges, exit codes, HTTP status codes, error messages and what causes each, parameters the code ignores, where data is stored and who can read it, prerequisites, side effects, what cannot be undone, and who can do it. Confirm the page states each one, or say in your report why you left it out. Prefer including a fact over leaving it out when a reader could act on it, check it, or be misled by a swagger file or PR that says otherwise. Leave out only internals no reader can act on, such as locks, timeouts, queue names, and how the code is organized, unless the reader needs them to avoid a mistake or to recover from a failure. Do not add a section only to hold a fact. Note each place the code disagrees with the PR description, a swagger file, or an existing page, state on the page what the code does, and say so when the code ignores a parameter the swagger lists. If a fact matters and the source does not settle it, add a `{/* TODO: ... */}` marker instead of guessing. A limit, trap, or silent failure that the code shows goes on the page as a note or in the step it affects, even if you also add a TODO asking the product team to confirm it. A TODO comment is invisible to the reader, so it never replaces a caveat you know about. +10. Lint the page before you finish. Run `npm run quality:check -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. ### Draft from a pull request @@ -40,7 +41,7 @@ When given a PR (a number and repo, or a URL): 2. A search for an existing clone: check `~/src`, `~/code`, `~/repos`, `~/git`, `~/projects`, `~/work`, and the parent of the current directory for a git repo whose `origin` remote matches `/` (`git -C remote get-url origin`). Most users already have one. 3. No clone found: ask the user once for the path, or whether you may clone it. If they agree, run `gh repo clone / ~/src/` (a full clone, so later runs reuse it) and tell them where it landed. If they decline or there is no answer, read the changed files at the PR's head commit with `gh api` and say in your report that the draft lacks full-code context. - Never check out in the user's working tree. Fetch the PR into a separate worktree in your scratchpad directory (`git -C fetch origin pull//head` then `git -C worktree add /pr- FETCH_HEAD`) and remove the worktree when you finish. In it, read the changed files in full, then their callers, config defaults, and UI strings or resources. + Never check out in the user's working tree. Fetch the PR into a separate worktree in your scratchpad directory (`git -C fetch origin pull//head` then `git -C worktree add /pr- FETCH_HEAD`) and remove the worktree when you finish. In it, read the changed files in full, then their callers, config defaults, UI strings or resources, and their tests, which show exact behavior and edge cases. 4. **Find what the docs already say.** Search `docs//` (latest version) with Grep for the feature's UI labels, setting names, and CLI switches. Decide: new page, new section in an existing page, or a change to existing text. Say which in your report, with paths. 5. Work out what a user can now see or do: new UI, settings, defaults, permissions, limits, and behavior changes. Document only what the code and description support. Where they are silent, write a `{/* TODO: ... */}` marker and list it in your report. Do not infer behavior. 6. Decide the version and target folder from the PR and `src/config/products.js`. If you can't tell, ask one question. @@ -52,10 +53,12 @@ When given an issue, ticket, or work item (a URL, a key such as `PLAT-1234`, an 1. **Fetch it from its tracker.** Read the title, description, acceptance criteria, labels, status, fix or target version, comments, and linked items. - **GitHub issue:** `gh issue view --repo --comments --json title,body,labels,state,comments`. Find linked PRs from the body, the comments, and `gh pr list --repo --state all --search ""`. - - **Jira ticket:** use an Atlassian or Jira tool if one is connected (load it with ToolSearch; names contain "Atlassian" or "Jira"), or a Jira CLI if one is installed. Include the issue's links, development panel, and fix version. + - **Jira ticket:** use the Atlassian Rovo connector. Load `mcp__claude_ai_Atlassian_Rovo__getJiraIssue` with ToolSearch (if that name is missing, search for "jira issue"). It needs the site's `cloudId`; take it from the connector's accessible-resources call if it has one. Request `summary`, `description`, `status`, `issuetype`, `labels`, `components`, `fixVersions`, `comment`, `subtasks`, and `parent`, with `responseContentFormat: "markdown"`. For an epic or a ticket with subtasks, also load `mcp__claude_ai_Atlassian_Rovo__searchJiraIssuesUsingJql` and fetch the children (`parent = `), since their descriptions show the full scope. Read the issue's links and development panel too. The fix version is the docs version to target. + - **Jira not signed in.** If the Atlassian tools appear only as `authenticate` or `complete_authentication`, the connector is not signed in. Tell the user once: run `/mcp`, choose the Atlassian Rovo connector, sign in, and ask again. In a subagent or headless run, don't wait: use the Jira CLI if one is installed (`acli jira workitem view --json`; `acli jira auth login` signs it in), or go to "No access." - **Azure DevOps item:** use `az boards work-item show --id --expand relations` if `az` and its `azure-devops` extension are installed, or an Azure DevOps tool if one is connected. Follow the related pull requests and linked items. + - **Xchange links.** Scan the item and its comments for URLs that start with `https://xchange.netwrix.com/`. Fetch each with the Xchange connector (load `mcp__claude_ai_Xchange__fetch` with ToolSearch; call it with `resource: "document"` and the URL as `id`). Where you can delegate, have a subagent fetch them and return a short summary per article (title, design decisions, known limits, and user-facing behavior that bears on the page) so the full articles stay out of your context. If a fetch fails or the connector is not signed in, say so in your report and go on without it. - **No access.** If you cannot reach the tracker (no tool, not signed in, or the item is private), say what you tried and ask the user once to paste the title, description, acceptance criteria, and comments. Never reconstruct a ticket from its key or title alone. -2. **Treat the item as a claim, not as proof.** Work items describe intent, and they go stale. Prefer what linked code shows over what the description says, and note any conflict in your report. +2. **Treat the item as a claim, not as proof.** Work items describe intent, and they go stale. Prefer what linked code shows over what the description says, and note any conflict in your report. Use the item and its Xchange articles for facts the code cannot show, such as permissions granted in an external portal, third-party setup steps, and supported environments. Mark each such fact `{/* TODO: confirm ... */}` if only the item states it and you cannot corroborate it. 3. **Follow the code.** If you find one or more linked or merged PRs, run "Draft from a pull request" for them (product context, reading the code, finding what the docs already say), and use the item's description for the user-facing purpose, audience, and wording. If there is no linked code, treat the item's text as the only source, as in "Draft from specs, notes, or a prompt." 4. **Check whether it shipped.** If the item is open, in progress, or not in a released version, write `{/* TODO: confirm this ships in */}` and say so in your report. Take the version from the item's fix or target version. Do not guess one. 5. **Keep tracker details out of the page.** Never copy ticket keys, customer or tenant names, internal URLs, credentials, or personal names into the docs. If the item or its comments describe an unpatched vulnerability, do not document the vulnerability. Report it to the user and stop short of publishing details. diff --git a/.claude/skills/doc-draft/style-reference.md b/.claude/skills/doc-draft/style-reference.md index 36a1b92335..e6ce2b9b80 100644 --- a/.claude/skills/doc-draft/style-reference.md +++ b/.claude/skills/doc-draft/style-reference.md @@ -26,7 +26,7 @@ The difference: ## Style reference -Vale and Dale run when you run the style check (step 9 of the skill) and again in the pre-commit hook. Nothing lints on the PR. The self-review step in each task type covers the same issues Dale checks (passive voice, wordiness, idioms, hedging, future tense). The rules below cover what linters don't catch. Apply these while writing. +Vale and Dale run when you run the style check (step 10 of the skill) and again in the pre-commit hook. Nothing lints on the PR. The self-review step in each task type covers the same issues Dale checks (passive voice, wordiness, idioms, hedging, future tense). The rules below cover what linters don't catch. Apply these while writing. ### Grammar diff --git a/docs/auditor/CLAUDE.md b/docs/auditor/CLAUDE.md new file mode 100644 index 0000000000..306b3467d9 --- /dev/null +++ b/docs/auditor/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product + +**Netwrix Auditor** audits changes and activity across an IT environment: Active Directory, Group Policy, Exchange, file servers, SharePoint, SQL Server, Oracle, Windows Server, VMware, network devices, Microsoft Entra ID, Exchange Online, SharePoint Online, Microsoft Teams, and more. Administrators set up monitoring plans for data sources. The product collects who did what, when, and where into an audit archive and an SQL audit database, then offers search, reports, alerts, and a REST API. + +Use "Netwrix Auditor" on first reference and "Auditor" after that. Name report packs "Netwrix Auditor for ", for example "Netwrix Auditor for Entra ID". + +## Versions + +Documented versions: **10.6**, **10.7**, **10.8**, and **10.9** (latest). Edits to one version do **not** propagate to the others. Update each version the change applies to. + +The sidebar is autogenerated from the folders (`sidebars/auditor/.js`), so folder order and `_category_.json` control navigation. A new page needs no sidebar edit. + +## Content Structure + +Each version follows this layout: + +| Folder | Purpose | +|---|---| +| `overview/` | Getting started, product editions, what's new | +| `requirements/` | Software, SQL Server, SSRS, ports, service account, working folder, long-term archive, supported data sources | +| `install/` | Install, upgrade, silent mode, virtual appliance, first launch, core services | +| `configuration//` | Prepare the monitored system (audit policies, permissions), one folder per data source | +| `admin/` | Monitoring plans (`monitoringplans//`), settings, reports, search, alerts, subscriptions, risk assessment, behavior anomalies, health status | +| `api/` | REST API overview, endpoints, ports, security, activity record and filter reference | +| `tools/` | Event Log Manager, Inactive User Tracker, Password Expiration Notifier, Audit Configuration Assistant | +| `addon/` | SIEM and vendor integrations | +| `kb/` | Knowledge base articles | + +`index.md` and `whats-new.md` sit at the version root. `accessreviews/` and `accountlockoutexaminer/` also live in the Auditor folder. + +### Data source pairs + +Most data sources have two pages: one under `configuration/` that prepares the monitored system, and one under `admin/monitoringplans/` that creates the plan. When you change one, check whether the other needs the same change. + +## Terminology + +The product and its folders keep old names. Use the current name in documentation: + +| Old or internal name | Use | +|---|---| +| Azure AD | Microsoft Entra ID | +| Audit Archive, Long-Term Archive, Short-Term Archive | Long-term archive; the working folder holds short-term data | +| Managed object | Monitoring plan (and its items) | +| Freeware, SMB, Standard, Advanced (internal edition names) | Free Community, Business Essentials, Enterprise Essentials, Enterprise Advanced | +| `adcr_db` | Audit database (the name is historical, not specific to Active Directory) | + +## KB Articles + +KB articles come from `docs/kb/auditor/` and are copied into each version's `kb/` folder at build time. Don't edit the copies. See `docs/kb/CLAUDE.md`. + +## Images + +Product images are in `static/images/auditor//` as `.webp` files. Reference them with absolute paths. diff --git a/docs/dataclassification/CLAUDE.md b/docs/dataclassification/CLAUDE.md new file mode 100644 index 0000000000..2be84f5c5b --- /dev/null +++ b/docs/dataclassification/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product + +**Netwrix Data Classification** crawls data sources (file shares, SharePoint, Exchange, Box, Dropbox, Google Drive, SQL databases, and others), extracts and indexes document text, and classifies documents against built-in and user-defined taxonomies. Optional workflows act on classified documents, and classifications can be written back into Office and PDF files. A web console provides administration, search, and reports. + +Use "Netwrix Data Classification" on first reference. "NDC" is common in the existing pages; match the page you are editing, and don't introduce "NDC" into a page that doesn't use it. Never use "Concept", the legacy product name. + +## Versions + +Documented versions: **5.6.2**, **5.7**, and **5.8** (latest). Edits to one version do **not** propagate to the others. Update each version the change applies to. + +The sidebar is autogenerated from the folders (`sidebars/dataclassification/*.js`), so a new page needs no sidebar edit. + +## Content Structure + +Folder names under each version don't match the UI labels. Search by topic, not by path. + +| Area | Folder | +|---|---| +| Overview | `index.md`, `dashboards.md` | +| Requirements, install, deployment, upgrade | `introduction/requirements/`, `install/`, `deployment/`, `virtualappliance/`, `upgrade.md`, `supportedsources.md` | +| Source prerequisites (Box, Dropbox, Google Drive, Exchange, NFS, SharePoint) | `introduction/introduction/` | +| First-run setup | `introduction/initialconfiguration/` | +| Sources and source groups | `contentconfigurationoverview/introduction/` | +| Taxonomies and clues | `contentconfigurationoverview/taxonomies/` | +| Workflows | `contentconfigurationoverview/workflows/` | +| System settings | `systemconfigurationoverview/configuration/` | +| Users, licensing | `systemconfigurationoverview/users/`, `licensing.md` | +| Administration (backup, index maintenance) | `systemconfigurationoverview/administration/` | +| Reports and DSAR | `dataanalysisoverview/` | + +Pages are mostly concept and reference pages with a few procedures. There are no per-setting or per-report pages: each Core configuration tab and each report is a section inside the pages above. + +## Terminology + +| Product or code term | Use in docs | +|---|---| +| QueryServer, QS | The Netwrix Data Classification web console | +| Class, Term | Class (the two are the same thing) | +| Clue | Clue (a match condition on a class: regex, phrase, metadata, date range, term boost) | +| Dynamic Source | A source that discovers sub-sources, such as a mailbox per Exchange user | +| MIP | Microsoft Information Protection sensitivity labels | + +Don't document PostgreSQL or Oracle as supported Data Classification databases without confirming with the product team. The `postgresqlserver.md` page covers a PostgreSQL data source to scan, which is a different thing. + +## KB Articles + +KB articles come from `docs/kb/dataclassification/` and are copied into each version's `kb/` folder at build time. Don't edit the copies. See `docs/kb/CLAUDE.md`. + +## Images + +Product images are in `static/images/dataclassification//` as `.webp` files. Reference them with absolute paths. diff --git a/docs/endpointprotector/CLAUDE.md b/docs/endpointprotector/CLAUDE.md new file mode 100644 index 0000000000..79c14e3f52 --- /dev/null +++ b/docs/endpointprotector/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product + +**Netwrix Endpoint Protector** is a data loss prevention (DLP) and device control product. Administrators define policies in a central web console, and client software on each Windows, macOS, and Linux endpoint enforces them. Its modules are Device Control, Content Aware Protection (including network content inspection), eDiscovery, and Enforced Encryption (EasyLock). + +Use "Netwrix Endpoint Protector" on first reference. The existing pages mix "Endpoint Protector Client" and "EPP Client" (and the same for Server). Match the page you are editing. + +## Versions + +This product has **one version** and no version folder. Pages live directly under `docs/endpointprotector/`. The sidebar is autogenerated from the folder tree (`sidebars/endpointprotector/epp.js`), so a new page needs no sidebar edit. + +## Content Structure + +| Folder | Purpose | +|---|---| +| `requirements/` | Server, client, components, network architecture | +| `install/` | Virtual appliance, cloud images, Intune, Jamf, Active Directory GPO, migration and upgrade | +| `admin/` | Console login, dashboard, alerts, reports, directory services, SCIM, backup, licensing, SSO, mail | +| `admin/dc_module/` | Device Control | +| `admin/cap_module/`, `admin/denylistsallowlists/` | Content Aware Protection, denylists and allowlists | +| `admin/ed_module/` | eDiscovery | +| `admin/ee_module/` | Enforced Encryption | +| `admin/epp_rest_api/` | REST API | +| `supportability/` | Client and server support policy | +| `kb/` | Knowledge base articles in seven topic folders | + +`index.md` and `gettingstarted.md` sit at the root. Most pages describe the server console and use many screenshots. + +## Terminology + +Use "allowlist" and "denylist", not "whitelist" and "blacklist". Don't use "cososys", the former vendor name, in documentation. + +## Open conflicts + +Don't resolve these without the product owner: + +- `requirements/client.md` says the client uses no kernel drivers or extensions. The client code builds Windows kernel drivers, and macOS uses a system extension. +- The Windows, macOS, and Linux client versions are tracked separately and don't always match. + +## KB Articles + +KB articles come from `docs/kb/endpointprotector/`. See `docs/kb/CLAUDE.md`. + +## Images + +Product images are in `static/images/endpointprotector/`. Check the existing version folders there before adding images, and reference them with absolute paths as `.webp` files. diff --git a/docs/threatmanager/CLAUDE.md b/docs/threatmanager/CLAUDE.md index 6efd6266e6..fa5d63f652 100644 --- a/docs/threatmanager/CLAUDE.md +++ b/docs/threatmanager/CLAUDE.md @@ -10,12 +10,14 @@ The product was formerly named **StealthDEFEND** (the legacy install path is `C: ## Versions -Two versions are documented here: **3.0** and **3.1**. The structures are nearly identical; 3.1 adds: +Documented versions: **3.0**, **3.1**, **3.2**, and **3.3** (latest). The structures are nearly identical. Edits to one version do **not** propagate to the others. Update each version the change applies to. -- `administration/serviceaccounts.md` — new Service Accounts dashboard (machine-learning-based service account discovery) +3.1 added: + +- `administration/serviceaccounts.md` — Service Accounts dashboard (machine-learning-based service account discovery) - `install/upgrade/upgrade3.1.md` — upgrade guide from 3.0 to 3.1 (PostgreSQL 14.13 → 14.20, then Threat Manager installer) -Edits to one version do **not** propagate to the other. Update each version that needs the change. +The sidebar is autogenerated from the folders (`sidebars/threatmanager/.js`), so a new page needs no sidebar edit. ## Content Structure @@ -77,7 +79,7 @@ KB images live in `kb/0-images/` and are referenced with a relative path. Do not Product images are in `static/images/threatmanager//` as `.webp` files. Reference them with absolute paths, e.g.: ``` -/images/threatmanager/3.1/administration/serviceaccounts/dashboard.webp +/images/threatmanager/3.3/administration/serviceaccounts/dashboard.webp ``` ## Threat Catalog Style diff --git a/docs/threatprevention/CLAUDE.md b/docs/threatprevention/CLAUDE.md new file mode 100644 index 0000000000..5a70e3c2c1 --- /dev/null +++ b/docs/threatprevention/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product + +**Netwrix Threat Prevention** monitors, alerts on, and can block activity in Active Directory, Exchange, Group Policy, file systems, and credential operations, in real time on domain controllers and Exchange servers. Administrators work in the Administration Console or with PowerShell cmdlets. It integrates with Netwrix Threat Manager, which is a separate product. + +The product was formerly named **StealthINTERCEPT**. Don't introduce this name into documentation. Use "Netwrix Threat Prevention" on first reference and "Threat Prevention" after that. The 8.1 pages use "Enterprise Manager" and "Administration Console". + +## Versions + +Documented versions: **7.4**, **7.5**, **8.0**, and **8.1** (latest). Edits to one version do **not** propagate to the others. Update each version the change applies to. + +The sidebar is autogenerated from the folders (`sidebars/threatprevention/*.js`), so place new pages by folder and `sidebar_position`. + +## Content Structure + +| Folder | Purpose | +|---|---| +| `requirements/` | Application, Administration Console, agent, SQL Server, EPE REST site, reporting server, ports | +| `install/` | Application, agent, first launch, certificate wizard, upgrade, migrating the Enterprise Manager server | +| `admin/` | Administration Console: navigation, policies, templates, agents, alerts, analytics, configuration, investigate, tools | +| `admin/policies/configuration/eventtype/` | One page per event type | +| `admin/policies/configuration/actions/` | Actions | +| `admin/analytics/` | One page per analytic | +| `admin/configuration/` | Collection manager, event filtering, EPE settings, Threat Manager configuration | +| `api/` | PowerShell cmdlets and the EPE API | +| `solutions/` | Active Directory, Exchange, file system, LDAP, EPE | +| `siemdashboard/`, `reportingmodule/`, `eperestsite/` | SIEM dashboard, reporting, EPE REST site | +| `troubleshooting/` | Troubleshooting | +| `kb/` | Knowledge base articles | + +Each section has an `overview.md`. Pages are task and reference pages for each screen or option. + +## Terminology + +| Old or internal name | Use | +|---|---| +| StealthINTERCEPT, SI | Netwrix Threat Prevention | +| WinConsole | Administration Console | +| Lockdown | Blocking (as in "Active Directory Lockdown") | +| Recognizer | Analytics policy | +| EPE | Enterprise Password Evaluation (spell out on first reference) | + +Check that an analytic exists in the docs before you document it from the code. Some analytics have no docs page, and some are named differently. + +## KB Articles + +KB articles come from `docs/kb/threatprevention/` and are copied into each version's `kb/` folder at build time. Don't edit the copies. See `docs/kb/CLAUDE.md`. + +## Images + +Product images are in `static/images/threatprevention//` as `.webp` files. Reference them with absolute paths. From 0fdbf81fbd27986bf9c7955ba6273570e66fb695 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 12:02:53 -0500 Subject: [PATCH 06/11] Offer a product brief and update product context in doc-draft - Offer once, after the report, to write a brief when a product has none - Add step 10: fix a product CLAUDE.md or brief only where the draft made a stated fact false, and report those edits apart from the docs pages - Lint is now step 11 Co-Authored-By: Claude Sonnet 5.5 --- .claude/skills/doc-draft/SKILL.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.claude/skills/doc-draft/SKILL.md b/.claude/skills/doc-draft/SKILL.md index 1738124dd8..bd3ca2240a 100644 --- a/.claude/skills/doc-draft/SKILL.md +++ b/.claude/skills/doc-draft/SKILL.md @@ -12,7 +12,7 @@ Read `docs/CLAUDE.md` first. It holds the Netwrix conventions, Vale rules, file If the task is ambiguous, ask one clarifying question before you start. Otherwise make a reasonable choice, say what you chose in your report, and go on. If you can't ask (you are running in a subagent or headless), don't stop: make the choice, and say what you chose in your report. -You lint your own drafts: run the style check in step 10 of "Draft new documentation" before you finish. It is the same check that blocks the commit, and nothing lints on the PR. +You lint your own drafts: run the style check in step 11 of "Draft new documentation" before you finish. It is the same check that blocks the commit, and nothing lints on the PR. ## Task Types @@ -28,13 +28,14 @@ You lint your own drafts: run the style check in step 10 of "Draft new documenta 7. Reread the drafted file. Confirm the frontmatter has a `description` on every page you wrote, and that every internal link points to a file that exists. Fix any passive voice, hedging, future tense describing software behavior, wordiness, or idioms. 8. **Write plainly and write short sentences, but never trade meaning for length.** Keep every fact, condition, qualifier, and requirement. When a long sentence carries several facts, split it into short sentences instead of cutting one. Aim for one idea per sentence, procedure steps of 20 words or fewer that each start with an imperative verb, descriptive sentences of 25 words or fewer, and one term for each concept (do not alternate "remove", "delete", and "clear" for the same action). Avoid phrasal verbs ("start", not "kick off") and nominalizations ("configure the setting", not "perform the configuration of the setting"). These are drafting targets, not a lint gate. Bold UI labels, quoted product messages, commands, and code keep their exact wording. Do not rewrite a finished draft to hit readability numbers. If the writer asks how readable the page is, run `npm run quality:readability -- ` and report the numbers; it is a report, not a target. The AI-isms check runs later at commit, so avoid filler, promotional wording, and stock AI phrasing as you write. 9. Check coverage against the source before you lint. List the facts a reader needs in order to act that the code or source establishes: commands and flags, parameter aliases, defaults, limits and allowed ranges, exit codes, HTTP status codes, error messages and what causes each, parameters the code ignores, where data is stored and who can read it, prerequisites, side effects, what cannot be undone, and who can do it. Confirm the page states each one, or say in your report why you left it out. Prefer including a fact over leaving it out when a reader could act on it, check it, or be misled by a swagger file or PR that says otherwise. Leave out only internals no reader can act on, such as locks, timeouts, queue names, and how the code is organized, unless the reader needs them to avoid a mistake or to recover from a failure. Do not add a section only to hold a fact. Note each place the code disagrees with the PR description, a swagger file, or an existing page, state on the page what the code does, and say so when the code ignores a parameter the swagger lists. If a fact matters and the source does not settle it, add a `{/* TODO: ... */}` marker instead of guessing. A limit, trap, or silent failure that the code shows goes on the page as a note or in the step it affects, even if you also add a TODO asking the product team to confirm it. A TODO comment is invisible to the reader, so it never replaces a caveat you know about. -10. Lint the page before you finish. Run `npm run quality:check -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. +10. Update the product context only where your changes made it false. Check `docs//CLAUDE.md` and `.claude/references/products/.md` for a stated fact your work changed: a folder or version you added, an area you renamed, a naming rule or code-to-UI term you found, or a "Not verified" item you verified from the code. Fix that line and nothing else. Do not add feature descriptions or version-specific behavior, and leave `last_reviewed` alone, because it means a person reviewed the brief. Briefs live in `.claude/`, which git ignores, so say in your report that they need `git add -f`. In a subagent or headless run, don't edit these files: name each proposed change in your report. List the edits apart from your docs pages. +11. Lint the page before you finish. Run `npm run quality:check -- --file ` for each page you wrote. It runs Vale, Dale, and the AI-isms check, the same checks that block the commit. Fix every finding in your own words without changing any fact, condition, qualifier, or requirement, then run it again. Write each UI label exactly as the product shows it, in bold, and quote product messages verbatim in double quotes. Vale and Dale ignore both, so never reword a bold label or a quoted message to clear a finding. If a finding lands on a UI label that is not bold, bold it; do not reword it. If it lands on other text that must stay exactly as the product shows it (a literal value or a product name, say), wrap only that line in a Vale suppression for that one rule, the way the docs already do, `` before it and `` after it, and list each suppression in your report. Never suppress a rule on your own prose, and a Dale finding on exact product text is cleared by bolding or quoting it, not by suppressing it. Stop after 3 rounds and list anything still flagged in your report. If it says Vale is not installed, tell the user and continue; do not skip the other checks. ### Draft from a pull request When given a PR (a number and repo, or a URL): -1. **Load product context first.** Find the product from the repo name, then read its brief at `.claude/references/products/.md` and `docs//CLAUDE.md` if they exist. The brief says what the product is, how the code is laid out, and what the code calls things versus what the UI and docs call them. If the code contradicts the brief, the code wins: note the mismatch in your report. If there is no brief, say so in your report and continue. +1. **Load product context first.** Find the product from the repo name, then read its brief at `.claude/references/products/.md` and `docs//CLAUDE.md` if they exist. The brief says what the product is, how the code is laid out, and what the code calls things versus what the UI and docs call them. If the code contradicts the brief, the code wins: note the mismatch in your report. If there is no brief, say so in your report and continue. After your report, offer once to write one: "This product has no brief. Want me to survey the repo and write one so later drafts start with context?" Don't ask mid-draft, and in a subagent or headless run don't ask: state that no brief exists. If the user agrees, follow `.claude/references/products/README.md`. Read the repo's README, ARCHITECTURE, GLOSSARY, and CLAUDE.md files, spot-check the code, and map the docs folder (`docs//`). Set `status: drafted from a code survey; not yet reviewed by someone who owns the product`, list what you could not verify under "Watch out for", and don't commit it. 2. Read the PR with `gh pr view --repo --json title,body,files,commits` and `gh pr diff --repo `. Read linked issues the body references. 3. **Read beyond the diff.** Reading the full code answers what the diff cannot: menu paths, button labels, defaults, permissions, feature flags. Get a local copy of the PR's repo, in this order: 1. The brief's `local_clone` path, if it exists and its `origin` remote matches the repo. From b44ac20f5b68049a579c162809121513fcf255ca Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 12:05:46 -0500 Subject: [PATCH 07/11] Fix two CodeQL alerts in the quality scripts - Escape backslashes before pipes in findings table cells - Create the fix-loop patch temp file in a private mkdtemp directory Co-Authored-By: Claude Sonnet 5.5 --- scripts/doc-draft/lib/report.mjs | 2 +- scripts/quality/fix-loop.mjs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/doc-draft/lib/report.mjs b/scripts/doc-draft/lib/report.mjs index ecc03562d8..dda168f209 100644 --- a/scripts/doc-draft/lib/report.mjs +++ b/scripts/doc-draft/lib/report.mjs @@ -51,7 +51,7 @@ export function renderFindingsTable(findings) { } function escapeCell(value) { - return String(value).replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); + return String(value).replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); } /** diff --git a/scripts/quality/fix-loop.mjs b/scripts/quality/fix-loop.mjs index a3e70718b7..6c4d99dc33 100644 --- a/scripts/quality/fix-loop.mjs +++ b/scripts/quality/fix-loop.mjs @@ -104,7 +104,8 @@ const verify = pairs => verifyMeaning(pairs, { model: config.models.verify }); /** Unified diff of `content` against the file on disk, applicable with `git apply`. */ function patchFor(file, content) { - const tmp = path.join(os.tmpdir(), `q-${process.pid}-${path.basename(file)}`); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-')); + const tmp = path.join(dir, path.basename(file)); fs.writeFileSync(tmp, content); let out = ''; try { @@ -112,7 +113,7 @@ function patchFor(file, content) { } catch (e) { out = e.stdout || ''; // exit 1 means "differences found" } finally { - fs.rmSync(tmp, { force: true }); + fs.rmSync(dir, { recursive: true, force: true }); } return out.split(tmp.replace(/^\//, '')).join(file).replace(/^(diff --git a\/.+ b\/).+$/m, `$1${file}`); } From 5d5da9ed148928d305768826c6769e379941e7c9 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 12:09:13 -0500 Subject: [PATCH 08/11] Remove the llms.txt plugin import until its file is committed docusaurus.config.js imported src/plugins/llms-txt.js, which is not in the repository, so the CI build failed with MODULE_NOT_FOUND. Co-Authored-By: Claude Sonnet 5.5 --- docusaurus.config.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docusaurus.config.js b/docusaurus.config.js index 3af992c713..9833ce2c5e 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -8,7 +8,6 @@ import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { themes as prismThemes } from 'prism-react-renderer'; import { generateDocusaurusPlugins, generateNavbarDropdowns, PRODUCTS, versionToUrl, getDefaultVersion, getLatestVersionUrlMap, getActiveProducts, getActiveVersions, generateRouteBasePath } from './src/config/products.js'; -import llmsTxtPlugin from './src/plugins/llms-txt.js'; import { accessAnalyzer261Redirects } from './src/config/redirects/accessanalyzer-26.1.js'; // Strip TypeScript syntax from a generated sidebar.ts and return its apisidebar array. @@ -178,9 +177,6 @@ const config = { }; }, - // llms.txt index (site root) and per-product-version page listings - llmsTxtPlugin, - // Google Analytics [ '@docusaurus/plugin-google-gtag', From 41617e0de04a5fc9c281b25ab8e1fedfeda8701e Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 12:16:59 -0500 Subject: [PATCH 09/11] Address review findings on the style-check scripts - Regenerate lint-rules.md so the drift check passes - Point doc-help at doc-draft instead of the removed tech-writer agent - Treat a failed Dale model call as skipped, and exit 3 when the check itself crashes; the Stop hook no longer sends a crash to Claude as findings - Strip nested JSX tags until stable in the prose extractor Co-Authored-By: Claude Sonnet 5.5 --- .claude/hooks/stop-quality-check.sh | 10 +++++++++- .claude/references/lint-rules.md | 6 +++--- .claude/skills/doc-help/SKILL.md | 6 +++--- scripts/doc-draft/lib/prose.mjs | 8 +++++++- scripts/quality/check.mjs | 17 +++++++++++++++-- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.claude/hooks/stop-quality-check.sh b/.claude/hooks/stop-quality-check.sh index 45c229d6bb..c4e3f9ac56 100755 --- a/.claude/hooks/stop-quality-check.sh +++ b/.claude/hooks/stop-quality-check.sh @@ -26,10 +26,18 @@ fi # Unresolved draft markers in those files: tell the user, never block. node scripts/quality/todo-check.mjs --files $(cat "$LIST") >&2 || true -if OUTPUT=$(node scripts/quality/check.mjs --base origin/dev --only "$LIST" --vale-optional --table 2>&1); then +OUTPUT=$(node scripts/quality/check.mjs --base origin/dev --only "$LIST" --vale-optional --table 2>&1) +CODE=$? +if [ "$CODE" -eq 0 ]; then rm -f "$COUNTER" exit 0 fi +# Exit 1 means style findings. Any other code is a crash of the check itself, which is not Claude's to fix. +if [ "$CODE" -ne 1 ]; then + echo "The style check could not run (exit $CODE); skipping it this turn." >&2 + echo "$OUTPUT" | tail -5 >&2 + exit 0 +fi if [ "$ROUNDS" -ge 3 ]; then rm -f "$COUNTER" diff --git a/.claude/references/lint-rules.md b/.claude/references/lint-rules.md index 457ecc67eb..c6db33a797 100644 --- a/.claude/references/lint-rules.md +++ b/.claude/references/lint-rules.md @@ -22,7 +22,7 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the - **FirstPersonPlural**: Avoid first person plural ''. Rewrite without 'we', 'our', 'us', or 'ours'. Terms: we, our, ours, us - **FollowTheStepsTo**: Avoid ''. Use a direct heading and numbered list instead. Terms: follow the steps to, follow these steps to - **FormalHedging**: Use '' instead of ''. Examples: prior to → before; subsequent to → after; in the event that → if; in the event of → if there is; for the purpose of → to; with the intention of → to; at this point in time → now -- **Idioms**: Avoid the idiom ''. Write what you mean directly and literally. Terms: tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp(s|ed|ing)? up, spin(s|ning)? up, stand(s|ing)? up, tear(s|ing)? down, tore? down, low.hanging fruit, moves? the needle, boil(s|ed|ing)? the ocean, levels? the playing field, raises? the bar, sets? the stage, paves? the way, bridges? the gap, fills? the gap, cut(s|ting)? corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, … (70 in all) +- **Idioms**: Avoid the idiom ''. Write what you mean directly and literally. Terms: tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp(s|ed|ing)? up, spin(s|ning)? up, stand(s|ing)? up, tear(s|ing)? down, tore? down, low.hanging fruit, moves? the needle, boil(s|ed|ing)? the ocean, levels? the playing field, raises? the bar, sets? the stage, paves? the way, bridges? the gap, fills? the gap, cut(s|ting)? corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, … (69 in all) - **ImpersonalFiller**: Remove '' and state the point directly. Terms: it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say - **InOrderTo**: Use '' instead of 'in order to'. Examples: in order to → to - **InflatedSignificance**: Remove the inflated-significance phrase ''. State what the feature does. Terms: (plays?|played|playing) an? (crucial|vital|key|critical|pivotal|significant|essential|important) role, serves? as an? (cornerstone|foundation|testament|backbone|linchpin), (cornerstone|linchpin) of, a testament to, (is|are) (essential|crucial|vital|critical) (to|for) ensuring, (is|are) (essential|crucial|vital) in ensuring, in ensuring that, plays? a (pivotal|crucial|vital) part @@ -67,7 +67,7 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the - **generic-conclusion**: Fires when a closing paragraph or section restates the page's value in vague, feel-good terms (for example 'By following these steps, you can ensure a smooth and efficient experience') instead of ending on the last concrete instruction or fact. Does not fire on a sentence that states the concrete expected result of a step, such as 'The service should now start.' (Message: End on the last concrete instruction or fact.) - **idioms**: This rule should trigger when the user or agent uses an idiom or other culturally specific expression. (Message: Don't use idioms. Write what you mean more directly and literally.) - **inflated-significance**: Fires when a sentence inflates the significance of a routine feature or step with phrases like 'plays a crucial/vital/key role', 'serves as a cornerstone', 'is essential to ensuring', 'a testament to'. Does not fire on plain factual statements that something is required (for example 'You must have Domain Admin rights'). (Message: Do not frame a routine feature or step as uniquely important ('plays a crucial role', 'serves as a cornerstone', 'is essential to ensuring'). State what it does.) -- **ing-analysis**: Fires when a sentence states a fact and then tacks on an unearned interpretive '-ing' clause, such as 'The scan runs nightly, ensuring your data stays current' or 'The policy applies to all users, highlighting its importance'. Does not fire on '-ing' clauses that describe a concrete action or a verifiable effect. (Message: Cut the trailing '-ing' clause or replace it with a specific consequence.) +- **ing-analysis**: Fires when a sentence states a fact and then tacks on a trailing participle clause or ', which makes/gives/ensures ...' clause that interprets or praises instead of stating a concrete result. Typical openers: ensuring, reflecting, highlighting, underscoring, emphasizing, demonstrating, showcasing, strengthening, making this/it ..., giving you ..., helping you ..., which makes/means .... Examples: 'The scan runs nightly, ensuring your data stays current'; 'The policy applies to all users, highlighting its importance'; 'The engine evaluates each rule, highlighting gaps and reflecting the state of your environment'. If a clause names a specific, checkable effect ('the job writes a log, which you can review under Reports') it is not a violation. Does not fire on '-ing' clauses that describe a concrete action. (Message: Cut the trailing '-ing' clause or replace it with a specific consequence.) - **minimizing-difficulty**: This rule should trigger when the documentation minimizes task difficulty—for example, 'With [product], you can easily [task].' (Message: Do not minimize the difficulty of tasks users are performing.) - **misplaced-modifiers**: This rule should trigger when the documentation has a misplaced or dangling modifier. This includes participial phrases that attach to the wrong subject (e.g., '[participle phrase], [wrong subject] [verb]') and modifying clauses placed too far from the word they describe (e.g., '[noun A] [preposition] [noun B] [modifier that actually describes noun A]'). (Message: Avoid misplaced modifiers. Move the modifier, or descriptive phrase, so that it accurately describes the right thing.) - **negative-assumptions**: This rule should trigger when the user or agent writes something that assumes a user cannot do something without the product. (Message: Don't make negative assumptions about what users can or cannot do.) @@ -83,4 +83,4 @@ Read this before drafting. Each rule below is checked before a PR: Vale and the ## AI-isms word list (avoid these words and phrases) -tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp up, spin up, stand up (idiom), tear down (idiom), tore down, low-hanging fruit, moves the needle, boil the ocean, levels the playing field, raises the bar, sets the stage, paves the way, bridges the gap, fills the gap, cut corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, at first glance, by the same token, on the fly, touch base, circle back, loop in, flesh out, iron out, hammer out, drill down, zero in on, hone in on, a deep dive, deep dive, take a deep dive, from scratch, heavy lifting, pain points, game-changer, silver bullet, magic bullet, one-stop-shop, best of breed, bleeding-edge, cutting-edge, mission-critical, world-class, turnkey, synergy, leverage, it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say, prior to, subsequent to, in the event that, in the event of, for the purpose of, with the intention of, at this point in time, currently, presently, as of this writing, naturally, delve, seamless, robust, comprehensive, landscape, realm, testament to, unlock, empower, elevate, streamline, pivotal, underscores, showcase, plethora, myriad, foster, harness, ever-evolving, in today's, it's worth noting, rest assured, at its core, when it comes to, dive into. +tip of the iceberg, at the end of the day, out of the box, out-of-the-box, in-the-box, under the hood, in a nutshell, on the same page, from the ground up, behind the scenes, out of the gate, off the beaten path, the big picture, the bottom line, up and running, hit the ground running, ramp up, spin up, stand up (idiom), tear down (idiom), tore down, low-hanging fruit, moves the needle, boil the ocean, levels the playing field, raises the bar, sets the stage, paves the way, bridges the gap, fills the gap, cut corners, rule of thumb, across the board, in the long run, in the same boat, keep in mind, bear in mind, at your fingertips, on the radar, in the weeds, at first glance, by the same token, on the fly, touch base, circle back, loop in, flesh out, iron out, hammer out, drill down, zero in on, hone in on, a deep dive, deep dive, take a deep dive, from scratch, heavy lifting, pain points, game-changer, silver bullet, magic bullet, one-stop-shop, best of breed, bleeding-edge, cutting-edge, mission-critical, world-class, turnkey, synergy, leverage, it should be noted that, it is important to note that, it is important to ensure that, it is important to, it is recommended that, it is necessary to, it is possible to, it is worth noting that, it must be noted that, as mentioned previously, as previously mentioned, needless to say, prior to, subsequent to, in the event that, in the event of, for the purpose of, with the intention of, at this point in time, currently, presently, as of this writing, naturally, delve, seamless, robust, comprehensive, landscape, realm, unlock, empower, elevate, streamline, pivotal, underscores, showcase, plethora, myriad, foster, harness, it's worth noting, rest assured. diff --git a/.claude/skills/doc-help/SKILL.md b/.claude/skills/doc-help/SKILL.md index 1cc4099953..6ee53a69aa 100644 --- a/.claude/skills/doc-help/SKILL.md +++ b/.claude/skills/doc-help/SKILL.md @@ -1,6 +1,6 @@ --- name: doc-help -description: "Interactive help with Netwrix documentation already in progress or already written. Use whenever the user wants to review, edit, tighten, restructure, or improve an existing doc, section, or paragraph ('review this page', 'does this read well', 'make this clearer', 'how should I structure this', 'fix this section'); asks why Vale or Dale flagged something or what a style rule means; wants to brainstorm or outline before writing; wants a section drafted with their input; or wants an external document (.docx) merged into an existing markdown file. Applies to any file under docs/ or any pasted doc text. When the user hands over source material to write from (a PR, a GitHub issue, Jira ticket, or Azure DevOps item, specs, notes, a detailed prompt), use tech-writer instead." +description: "Interactive help with Netwrix documentation already in progress or already written. Use whenever the user wants to review, edit, tighten, restructure, or improve an existing doc, section, or paragraph ('review this page', 'does this read well', 'make this clearer', 'how should I structure this', 'fix this section'); asks why Vale or Dale flagged something or what a style rule means; wants to brainstorm or outline before writing; wants a section drafted with their input; or wants an external document (.docx) merged into an existing markdown file. Applies to any file under docs/ or any pasted doc text. When the user hands over source material to write from (a PR, a GitHub issue, Jira ticket, or Azure DevOps item, specs, notes, a detailed prompt), use doc-draft instead." argument-hint: "[topic, file path, content to edit, or question]" --- @@ -114,7 +114,7 @@ Re-read the full document and check for: Provide a short summary of any final suggestions. Ask if the writer wants to refine anything or if the doc is ready. -**If the task has grown large enough to hand off entirely**, suggest: "This is substantial enough to give to the tech-writer agent — it can draft the remaining sections and run Vale end-to-end without needing your input at each step." +**If the task has grown large enough to hand off entirely**, suggest: "This is substantial enough to give to the doc-draft skill — it can draft the remaining sections and lint them end-to-end without needing your input at each step." --- @@ -208,6 +208,6 @@ If they say yes, transition to the Editing workflow starting at Step 2. **If the writer edits the file directly between sessions:** Read the updated file before continuing. Note what changed and incorporate their preferences going forward. -**If a task grows large:** Suggest the tech-writer agent rather than attempting to do everything in a single interactive session. +**If a task grows large:** Suggest the doc-draft skill rather than attempting to do everything in a single interactive session. **If a Vale rule is unclear or seems wrong:** Say so. Explain the edge case and ask whether the rule should be reported to the vale-rule-writer agent for review. diff --git a/scripts/doc-draft/lib/prose.mjs b/scripts/doc-draft/lib/prose.mjs index 9e4f88721d..1868228509 100644 --- a/scripts/doc-draft/lib/prose.mjs +++ b/scripts/doc-draft/lib/prose.mjs @@ -75,7 +75,13 @@ function isAdmonitionMarkerLine(line) { * (it carries no reader-facing prose), but `Some text.` * keeps "Some text." */ function stripJsxTags(line) { - return line.replace(/<\/?[A-Za-z][\w.]*(\s[^<>]*)?\/?>/g, ''); + // Repeat until nothing changes, so a tag rebuilt by removing a nested one is removed too. + let prev; + do { + prev = line; + line = line.replace(/<\/?[A-Za-z][\w.]*(\s[^<>]*)?\/?>/g, ''); + } while (line !== prev); + return line; } function stripHeadingMarker(line) { diff --git a/scripts/quality/check.mjs b/scripts/quality/check.mjs index 2e93eeeb93..862a007936 100644 --- a/scripts/quality/check.mjs +++ b/scripts/quality/check.mjs @@ -57,7 +57,14 @@ export async function checkPage(file, { ranges = null, model = true, requireMode result.dale.status = requireModel ? 'unavailable' : 'skipped'; return { ...result, pass: !requireModel }; } - const d = await (daleFn || daleCheck)(markdown, ranges, { model: config.models.dale }); + let d; + try { + d = await (daleFn || daleCheck)(markdown, ranges, { model: config.models.dale }); + } catch (e) { + // A failed model call (rate limit, timeout, a signed-out CLI) is not a style finding. It skips Dale like a missing key does. + result.dale.status = requireModel ? 'unavailable' : `skipped: model call failed (${String(e.message).split('\n')[0].slice(0, 120)})`; + return { ...result, pass: !requireModel }; + } result.dale = { status: d.findings.length === 0 ? (d.cached ? 'cached-pass' : 'pass') : 'fail', findings: d.findings }; return { ...result, pass: d.findings.length === 0 }; } @@ -110,4 +117,10 @@ async function main() { process.exit(pass ? 0 : 1); } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch(e => { + // Exit 3 marks a crash of the check itself; exit 1 is reserved for style findings. + console.error(`quality check failed to run: ${e.stack || e.message}`); + process.exit(3); + }); +} From ccefacd3706c7e3bfa8885302cfcacc69e387a29 Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 12:27:44 -0500 Subject: [PATCH 10/11] Address the second round of review findings - Skip the Stop hook in CI and when dependencies are not installed - Give .vale-local.ini the same TokenIgnores as .vale.ini - Run Script Tests when the Vale or Dale rule files change, and on reopen - Start gen-lint-digest.mjs reliably on any path with fileURLToPath - Declare js-yaml in devDependencies Co-Authored-By: Claude Sonnet 5.5 --- .claude/hooks/stop-quality-check.sh | 4 ++++ .github/workflows/script-tests.yml | 8 ++++++-- .vale-local.ini | 2 ++ package-lock.json | 1 + package.json | 1 + scripts/quality/gen-lint-digest.mjs | 3 ++- 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.claude/hooks/stop-quality-check.sh b/.claude/hooks/stop-quality-check.sh index c4e3f9ac56..6090b1bdd1 100755 --- a/.claude/hooks/stop-quality-check.sh +++ b/.claude/hooks/stop-quality-check.sh @@ -6,6 +6,8 @@ # rounds it lets go; the pre-commit hook is the hard gate and checks everything that is staged. [ -n "$NWX_QUALITY_CHILD" ] && exit 0 +# CI has its own backstop (Vale Auto-Fix), and its jobs would run model calls beyond their scope. +[ -n "$GITHUB_ACTIONS" ] && exit 0 INPUT=$(cat) # Parse with node, which the repo already requires; jq is not installed everywhere. SESSION=$(echo "$INPUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{process.stdout.write(String(JSON.parse(d).session_id||""))}catch{}})' 2>/dev/null) @@ -15,6 +17,8 @@ ROUNDS=$(cat "$COUNTER" 2>/dev/null || echo 0) cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 [ -f scripts/quality/check.mjs ] || exit 0 +# Without installed dependencies the check cannot load, so there is nothing to run. +[ -d node_modules/js-yaml ] || exit 0 # Only the files this session edited. None edited, nothing to check. LIST="${TMPDIR:-/tmp}/nwx-quality-files-$SESSION" diff --git a/.github/workflows/script-tests.yml b/.github/workflows/script-tests.yml index f88394383d..ff163b705f 100644 --- a/.github/workflows/script-tests.yml +++ b/.github/workflows/script-tests.yml @@ -1,17 +1,21 @@ name: Script Tests -# Runs the repo's own script tests when a PR changes scripts or package.json. +# Runs the repo's own script tests when a PR changes scripts, package.json, or the +# Vale and Dale rule files that lint-rules.md is generated from. # Docs are linted before the PR (Vale, Dale, and the AI-isms check run in the # pre-commit hook); nothing here lints documentation. on: pull_request: - types: [opened, synchronize] + types: [opened, synchronize, reopened] branches: - dev paths: - 'scripts/**' - 'package.json' + - '.vale/styles/**' + - '.claude/skills/dale/rules/**' + - '.claude/references/lint-rules.md' concurrency: group: script-tests-${{ github.event.pull_request.number }} diff --git a/.vale-local.ini b/.vale-local.ini index 3d32ae8ba0..fc03b6e36c 100644 --- a/.vale-local.ini +++ b/.vale-local.ini @@ -6,6 +6,8 @@ MinAlertLevel = suggestion [*.md] BasedOnStyles = Netwrix, DaleLocal +# Same as .vale.ini: bold UI labels and double-quoted product text are not the writer's to reword. +TokenIgnores = (\*\*[^*\n]+\*\*), ("[^"\n]+"), (\u201c[^\u201d\n]+\u201d) [docs/kb/**/*.md] BasedOnStyles = NetwrixKB diff --git a/package-lock.json b/package-lock.json index ed46d043c2..a842ea5d05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@docusaurus/module-type-aliases": "^3.10.0", "@docusaurus/types": "^3.10.0", "@mdx-js/mdx": "^3.1.1", + "js-yaml": "^4.3.0", "ora": "^9.4.1", "table": "^6.9.0", "to-vfile": "^8.0.0", diff --git a/package.json b/package.json index 6b74e900db..5c04194668 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@docusaurus/module-type-aliases": "^3.10.0", "@docusaurus/types": "^3.10.0", "@mdx-js/mdx": "^3.1.1", + "js-yaml": "^4.3.0", "ora": "^9.4.1", "table": "^6.9.0", "to-vfile": "^8.0.0", diff --git a/scripts/quality/gen-lint-digest.mjs b/scripts/quality/gen-lint-digest.mjs index 9c21ab952d..d3704a8799 100644 --- a/scripts/quality/gen-lint-digest.mjs +++ b/scripts/quality/gen-lint-digest.mjs @@ -9,6 +9,7 @@ */ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import yaml from 'js-yaml'; const OUT = '.claude/references/lint-rules.md'; @@ -70,7 +71,7 @@ export function build() { ].join('\n'); } -if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) { +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const next = build(); if (process.argv.includes('--check')) { const cur = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''; From 39e1d37db5b0eccd8cf81141ace90fa8c520539a Mon Sep 17 00:00:00 2001 From: "james.haytko" Date: Tue, 13 Oct 2026 12:46:32 -0500 Subject: [PATCH 11/11] Skip the Stop hook unless both quality-check dependencies are installed Co-Authored-By: Claude Sonnet 5.5 --- .claude/hooks/stop-quality-check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/hooks/stop-quality-check.sh b/.claude/hooks/stop-quality-check.sh index 6090b1bdd1..3de6b36456 100755 --- a/.claude/hooks/stop-quality-check.sh +++ b/.claude/hooks/stop-quality-check.sh @@ -18,7 +18,7 @@ ROUNDS=$(cat "$COUNTER" 2>/dev/null || echo 0) cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 [ -f scripts/quality/check.mjs ] || exit 0 # Without installed dependencies the check cannot load, so there is nothing to run. -[ -d node_modules/js-yaml ] || exit 0 +[ -d node_modules/js-yaml ] && [ -d node_modules/@anthropic-ai/sdk ] || exit 0 # Only the files this session edited. None edited, nothing to check. LIST="${TMPDIR:-/tmp}/nwx-quality-files-$SESSION"