251148c3ff
## Summary - add repository-wide quality tooling and verification scaffolding, including CI workflows, pnpm workspace setup, ESLint/Prettier/markdown checks, and generated-output verification helpers - reorganize skill sources and generation flow by introducing canonical `_source` variants, generator/manifests, reusable helper abstractions, and shared web-automation/browser utilities - clean up and expand documentation so the root README flows into docs and skill docs, with clearer development, reviewer, installer, and workflow guidance ## Notable changes - docs flow and consistency cleanup across `README.md`, `docs/README.md`, and related docs - new scripts for `check`, docs verification, generated-file verification, shell portability, and safe directory replacement - refactors in Atlassian and web-automation skill runtimes to reduce duplication and centralize reusable code - changelog, development documentation, and CI surface updates ## Test Plan - [ ] `pnpm run check` - [ ] review generated/manifests and skill sync outputs - [ ] smoke-check docs flow from `README.md` to `docs/README.md` to skill docs ## Notes - this branch currently includes tracked `skills/web-automation/shared/node_modules` content that should be reviewed carefully as potentially noisy/accidental committed artifacts Co-authored-by: Stefano Fiorini <stefano.fiorini@firsthorizon.com> Reviewed-on: #1
319 lines
10 KiB
JavaScript
319 lines
10 KiB
JavaScript
#!/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);
|
|
});
|
|
}
|