feat(M2): Documentation flow, accuracy, consistency cleanup, and cross-platform shell portability
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# portable.sh — POSIX-safe helper functions for BSD/GNU shell portability
|
||||
#
|
||||
# Source this file in scripts that need cross-platform variants of:
|
||||
# - stat(1) — BSD uses -f, GNU uses -c
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/portable.sh"
|
||||
# portable_stat_perms "$file" # -> octal permissions string, e.g. "755"
|
||||
#
|
||||
# Supported platforms:
|
||||
# - macOS (BSD stat)
|
||||
# - Linux/Ubuntu (GNU stat)
|
||||
|
||||
# portable_stat_perms <path>
|
||||
# Outputs the file's permission bits as an octal string (e.g. "755").
|
||||
# Exits non-zero if stat fails.
|
||||
portable_stat_perms() {
|
||||
local path="$1"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
stat -f '%Lp' "$path"
|
||||
;;
|
||||
*)
|
||||
stat -c '%a' "$path"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -132,7 +132,7 @@ console.log(`shellcheck: scanning ${files.length} file(s)…`);
|
||||
|
||||
let failures = 0;
|
||||
for (const file of files.sort()) {
|
||||
const result = spawnSync("shellcheck", [file], {
|
||||
const result = spawnSync("shellcheck", ["-x", "--source-path=SCRIPTDIR", file], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* verify-docs-flow.test.mjs — unit tests for the docs-flow verifier (M2, S-206)
|
||||
*
|
||||
* Tests the exported functions from scripts/verify-docs-flow.mjs.
|
||||
* Each test is structured as a RED → GREEN cycle: we first verify the function
|
||||
* exists and behaves correctly; any structural violation surfaces as a clear
|
||||
* test failure rather than a cryptic runtime error.
|
||||
*/
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Import the verifier lazily so missing-module errors surface as test
|
||||
* failures rather than crashing the whole suite.
|
||||
*/
|
||||
async function loadVerifier() {
|
||||
const verifierPath = path.join(REPO_ROOT, "scripts", "verify-docs-flow.mjs");
|
||||
return import(verifierPath);
|
||||
}
|
||||
|
||||
// ── S-206 acceptance checks ────────────────────────────────────────────────
|
||||
|
||||
describe("verify-docs-flow.mjs", () => {
|
||||
test("module exists and exports required functions", async () => {
|
||||
const mod = await loadVerifier();
|
||||
assert.equal(typeof mod.checkDocsIndexCoverage, "function",
|
||||
"must export checkDocsIndexCoverage");
|
||||
assert.equal(typeof mod.checkReviewerMatrixConsistency, "function",
|
||||
"must export checkReviewerMatrixConsistency");
|
||||
assert.equal(typeof mod.checkTelegramAgentCoverage, "function",
|
||||
"must export checkTelegramAgentCoverage");
|
||||
assert.equal(typeof mod.checkRepoPathsExist, "function",
|
||||
"must export checkRepoPathsExist");
|
||||
});
|
||||
|
||||
test("checkDocsIndexCoverage: every docs/*.md is linked from docs/README.md", async () => {
|
||||
const { checkDocsIndexCoverage } = await loadVerifier();
|
||||
const errors = await checkDocsIndexCoverage(REPO_ROOT);
|
||||
assert.deepEqual(errors, [],
|
||||
`docs/README.md coverage errors:\n${errors.join("\n")}`);
|
||||
});
|
||||
|
||||
test("checkReviewerMatrixConsistency: reviewer tables consistent across canonical sources", async () => {
|
||||
const { checkReviewerMatrixConsistency } = await loadVerifier();
|
||||
const errors = await checkReviewerMatrixConsistency(REPO_ROOT);
|
||||
assert.deepEqual(errors, [],
|
||||
`Reviewer matrix inconsistency errors:\n${errors.join("\n")}`);
|
||||
});
|
||||
|
||||
test("checkTelegramAgentCoverage: Telegram doc lists all agents with Pi helpers", async () => {
|
||||
const { checkTelegramAgentCoverage } = await loadVerifier();
|
||||
const errors = await checkTelegramAgentCoverage(REPO_ROOT);
|
||||
assert.deepEqual(errors, [],
|
||||
`Telegram coverage errors:\n${errors.join("\n")}`);
|
||||
});
|
||||
|
||||
test("checkRepoPathsExist: all repo-relative paths in README.md and docs/ exist", async () => {
|
||||
const { checkRepoPathsExist } = await loadVerifier();
|
||||
const errors = await checkRepoPathsExist(REPO_ROOT);
|
||||
assert.deepEqual(errors, [],
|
||||
`Broken repo-relative path references:\n${errors.join("\n")}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-docs-flow.mjs — documentation reading-flow and consistency verifier (M2, S-206)
|
||||
*
|
||||
* Asserts:
|
||||
* (i) every docs/*.md file is linked from docs/README.md
|
||||
* (ii) the reviewer CLI matrix is consistent across the four canonical sources
|
||||
* (iii) the Telegram agent list matches the set of agents with helpers on disk
|
||||
* (iv) all repository-relative paths referenced in README.md and docs/ exist
|
||||
*
|
||||
* Exits 0 when all checks pass; exits 1 with a report when any check fails.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-docs-flow.mjs
|
||||
* pnpm run verify:docs (wired via package.json)
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const REPO_ROOT = path.resolve(__dirname, "..");
|
||||
|
||||
// ── (i) docs/README.md coverage ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check that every *.md file under docs/ is linked from docs/README.md.
|
||||
* Returns an array of error strings (empty = pass).
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function checkDocsIndexCoverage(repoRoot) {
|
||||
const docsDir = path.join(repoRoot, "docs");
|
||||
const readmePath = path.join(docsDir, "README.md");
|
||||
|
||||
if (!fs.existsSync(readmePath)) {
|
||||
return ["docs/README.md is missing"];
|
||||
}
|
||||
|
||||
const readmeContent = fs.readFileSync(readmePath, "utf8");
|
||||
const errors = [];
|
||||
|
||||
// Collect all *.md files under docs/ except README.md itself
|
||||
const mdFiles = fs
|
||||
.readdirSync(docsDir)
|
||||
.filter((f) => f.endsWith(".md") && f !== "README.md");
|
||||
|
||||
for (const file of mdFiles) {
|
||||
// Accept both ./FILE.md and FILE.md link forms
|
||||
const linked =
|
||||
readmeContent.includes(`](${file})`) ||
|
||||
readmeContent.includes(`](./${file})`);
|
||||
if (!linked) {
|
||||
errors.push(`docs/README.md does not link to docs/${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── (ii) Reviewer matrix consistency ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Canonical reviewer CLI names expected in workflow docs.
|
||||
* Order matters for the consistency check.
|
||||
*/
|
||||
const EXPECTED_REVIEWER_CLIS = ["codex", "claude", "cursor", "opencode", "pi"];
|
||||
|
||||
/**
|
||||
* Check that the reviewer CLI matrix is consistent across the four canonical
|
||||
* workflow docs. Returns an array of error strings (empty = pass).
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function checkReviewerMatrixConsistency(repoRoot) {
|
||||
const canonical = [
|
||||
"docs/CREATE-PLAN.md",
|
||||
"docs/IMPLEMENT-PLAN.md",
|
||||
"docs/DO-TASK.md",
|
||||
"docs/REVIEWERS.md",
|
||||
];
|
||||
|
||||
const errors = [];
|
||||
|
||||
for (const docPath of canonical) {
|
||||
const fullPath = path.join(repoRoot, docPath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
errors.push(`${docPath} is missing`);
|
||||
continue;
|
||||
}
|
||||
const content = fs.readFileSync(fullPath, "utf8");
|
||||
// Each canonical doc must reference all five CLIs
|
||||
for (const cli of EXPECTED_REVIEWER_CLIS) {
|
||||
if (!content.includes(`\`${cli}\``)) {
|
||||
errors.push(`${docPath}: does not mention reviewer CLI \`${cli}\``);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── (iii) Telegram agent coverage ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Agents that must have helpers on disk in skills/reviewer-runtime/.
|
||||
* Non-Pi agents use the top-level directory; Pi uses the pi/ sub-directory.
|
||||
*/
|
||||
const TELEGRAM_AGENTS = [
|
||||
{
|
||||
id: "codex",
|
||||
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||
telegramSectionKeyword: "Codex",
|
||||
},
|
||||
{
|
||||
id: "claude-code",
|
||||
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||
telegramSectionKeyword: "Claude Code",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||
telegramSectionKeyword: "OpenCode",
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||
telegramSectionKeyword: "Cursor",
|
||||
},
|
||||
{
|
||||
id: "pi",
|
||||
helperPath: "skills/reviewer-runtime/pi/notify-telegram.sh",
|
||||
telegramSectionKeyword: "Pi",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Check that docs/TELEGRAM-NOTIFICATIONS.md documents every agent that has a
|
||||
* notify-telegram.sh helper on disk. Returns an array of error strings.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function checkTelegramAgentCoverage(repoRoot) {
|
||||
const telegramDoc = path.join(repoRoot, "docs", "TELEGRAM-NOTIFICATIONS.md");
|
||||
const errors = [];
|
||||
|
||||
if (!fs.existsSync(telegramDoc)) {
|
||||
return ["docs/TELEGRAM-NOTIFICATIONS.md is missing"];
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(telegramDoc, "utf8");
|
||||
|
||||
for (const agent of TELEGRAM_AGENTS) {
|
||||
const helperFullPath = path.join(repoRoot, agent.helperPath);
|
||||
if (!fs.existsSync(helperFullPath)) {
|
||||
errors.push(
|
||||
`Telegram agent ${agent.id}: helper ${agent.helperPath} does not exist on disk`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// The Telegram doc must mention this agent somewhere
|
||||
if (!content.includes(agent.telegramSectionKeyword)) {
|
||||
errors.push(
|
||||
`docs/TELEGRAM-NOTIFICATIONS.md does not document agent: ${agent.telegramSectionKeyword}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pi-specific: must link to PI-COMMON-REVIEWER.md
|
||||
if (!content.includes("PI-COMMON-REVIEWER.md")) {
|
||||
errors.push(
|
||||
"docs/TELEGRAM-NOTIFICATIONS.md must link to docs/PI-COMMON-REVIEWER.md"
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── (iv) Repo-relative path references ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Patterns to skip when checking path references — these are URLs and
|
||||
* non-filesystem references that look like repo paths but aren't.
|
||||
*/
|
||||
const PATH_SKIP_PATTERNS = [
|
||||
/^https?:\/\//, // HTTP URLs
|
||||
/^\/tmp\//, // /tmp paths (runtime artifacts)
|
||||
/^\$\{/, // shell variable expansions
|
||||
/^~\//, // home directory paths
|
||||
/^\.\.?\//, // relative ./ or ../ — checked differently
|
||||
/node_modules/, // node_modules
|
||||
/^[A-Z_]+_[A-Z_]+$/, // environment variable names
|
||||
];
|
||||
|
||||
/**
|
||||
* Extract candidate repository-relative paths from markdown text.
|
||||
* Looks for:
|
||||
* - markdown links: [text](path)
|
||||
* - inline code: `path`
|
||||
* - bare paths starting with known top-level directories
|
||||
*
|
||||
* @param {string} content
|
||||
* @returns {string[]} - candidate relative paths
|
||||
*/
|
||||
function extractRepoPaths(content) {
|
||||
const candidates = new Set();
|
||||
|
||||
// Markdown links: [text](path) where path does not start with http/https
|
||||
const linkRe = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
||||
let m;
|
||||
while ((m = linkRe.exec(content)) !== null) {
|
||||
const href = m[1].split("#")[0].trim(); // strip anchors
|
||||
if (!href) continue;
|
||||
if (PATH_SKIP_PATTERNS.some((p) => p.test(href))) continue;
|
||||
// Normalize ./ prefix
|
||||
const normalized = href.startsWith("./") ? href.slice(2) : href;
|
||||
if (normalized.startsWith("../")) continue; // skip upward traversals
|
||||
candidates.add(normalized);
|
||||
}
|
||||
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that all repository-relative paths referenced in README.md and docs/
|
||||
* actually exist in the repository. Returns an array of error strings.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function checkRepoPathsExist(repoRoot) {
|
||||
const errors = [];
|
||||
const filesToCheck = [
|
||||
path.join(repoRoot, "README.md"),
|
||||
...fs
|
||||
.readdirSync(path.join(repoRoot, "docs"))
|
||||
.filter((f) => f.endsWith(".md"))
|
||||
.map((f) => path.join(repoRoot, "docs", f)),
|
||||
];
|
||||
|
||||
for (const filePath of filesToCheck) {
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const relDoc = path.relative(repoRoot, filePath);
|
||||
const candidates = extractRepoPaths(content);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
// Skip things that obviously aren't repo paths
|
||||
if (!candidate || candidate.length < 3) continue;
|
||||
// Skip paths that look like markdown-only links (heading anchors)
|
||||
if (candidate.startsWith("#")) continue;
|
||||
// For links relative to docs/, resolve relative to docs/
|
||||
let candidatePath;
|
||||
if (relDoc.startsWith("docs/")) {
|
||||
candidatePath = path.join(repoRoot, "docs", candidate);
|
||||
// If not found there, try relative to repo root
|
||||
if (!fs.existsSync(candidatePath)) {
|
||||
candidatePath = path.join(repoRoot, candidate);
|
||||
}
|
||||
} else {
|
||||
candidatePath = path.join(repoRoot, candidate);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(candidatePath)) {
|
||||
errors.push(`${relDoc}: broken repo path reference → ${candidate}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const repoRoot = REPO_ROOT;
|
||||
const allErrors = [];
|
||||
const checks = [
|
||||
["docs/README.md coverage", checkDocsIndexCoverage],
|
||||
["reviewer matrix consistency", checkReviewerMatrixConsistency],
|
||||
["Telegram agent coverage", checkTelegramAgentCoverage],
|
||||
["repo-relative path existence", checkRepoPathsExist],
|
||||
];
|
||||
|
||||
for (const [label, fn] of checks) {
|
||||
const errors = await fn(repoRoot);
|
||||
if (errors.length > 0) {
|
||||
console.error(`\n[FAIL] ${label}:`);
|
||||
for (const e of errors) {
|
||||
console.error(` - ${e}`);
|
||||
}
|
||||
allErrors.push(...errors);
|
||||
} else {
|
||||
console.log(`[pass] ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
console.error(`\ndocs-flow: ${allErrors.length} error(s) found.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("\ndocs-flow: all checks passed.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main only when executed directly (not when imported by tests)
|
||||
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=lib/portable.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib/portable.sh"
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"docs/PI-RESEARCH.md"
|
||||
"docs/PI.md"
|
||||
@@ -84,7 +87,7 @@ for family in atlassian create-plan do-task implement-plan web-automation; do
|
||||
rel_path=${source_path#"$source_dir"/}
|
||||
mirror_path="${mirror_dir}/${rel_path}"
|
||||
test -e "$mirror_path"
|
||||
test "$(stat -f '%Lp' "$source_path")" = "$(stat -f '%Lp' "$mirror_path")"
|
||||
test "$(portable_stat_perms "$source_path")" = "$(portable_stat_perms "$mirror_path")"
|
||||
done < <(find "$source_dir" \
|
||||
\( -name '.DS_Store' -o -name 'node_modules' \) -prune -o \
|
||||
-mindepth 1 -print0)
|
||||
|
||||
Reference in New Issue
Block a user