mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-29 03:44:18 +08:00
47bab7d15c
* feat(project-context): rewrite as prescriptive AGENTS.md generator Replace the kernel+bundle context system with a single product: a short verified agent guide (AGENTS.md). A field trial of the first version showed repo scanning produces polished-but-useless factoids; the rewrite fills a fixed section plan from ranked evidence channels (executable config and CI, targeted git history, session logs, human interview) and uses the repository only to verify claims, never as the source of knowledge. - Intents: bootstrap, refresh, record (capture an observed agent mistake), audit; query is gone with the bundle - Per-fact entry files, trust frontmatter, index, placement machinery, and the skill's context.py mechanics script are removed; accountability moves to one plain ledger file recording every candidate claim and its disposition - Skill directory only; docs, forwarding husks, and shared scripts untouched * refactor(project-context): per-section admission rules, two-tier guide Revisions from two end-to-end trials plus review: - Replace the global non-derivable test with per-section admission rules: brevity (orientation), authority (policy), universal need verified by execution (commands, verification), wrong-default-assumption (conventions), localization value (pointers), observed failure only (pitfalls) - Two-tier output: AGENTS.md (orientation + policy + pointer) for every session, AGENTS-dev.md for coding sessions; single file when tiny - Pitfalls can never be nominated by scans: sources are recorded lessons, maintainer recall, session evidence, and the writing session's own caught mistakes; retirement only when the guarded thing is gone or the human says so, since a working rule erases its own evidence - Interview ergonomics: recall questions, never review lists; testimony the repo contradicts is surfaced with evidence, never written or dropped - Trial-driven fixes: guide-to-filesystem link check, mutating-command go-ahead as the interview's first question, plain-English rewrite throughout * fix(project-context): bidirectional coverage trace, history-evidenced pitfalls Round-3 trial findings: an unsourced pitfall entered the guide at composition time because coverage only checked ledger-to-guide; and repeat-fix git history, the strongest pitfall evidence observed, was not an explicitly admitted source. * refactor(project-context): move Where-things-are to AGENTS.md, imperative lines Where-things-are pointers serve planning sessions as much as coding ones, so they belong in the always-loaded file. Shape rules now require every line to state an action (bare facts only as justification clauses) and stable contract headings across runs. * docs(project-context): session-kind guides as a third structural axis A maintainer-named frequent session kind (UX, manual testing, data work) may earn its own AGENTS-<kind>.md behind a pointer; module-level differences stay with scoped guides. * refactor(project-context): action-gated dev-guide pointer, two-file example The AGENTS-dev.md hop is the most common progressive-discovery trigger, so it is now gated on the first hands-on action rather than session self-classification, names its payoff, and names the exemption. The contract's worked example shows the two-file form with the pointer in situ. Scoped-guide discovery no longer assumes harness nearest-file loading: the root-guide pointer is the mechanism. * refactor(project-context): adopt shared memlog, drop unearned claims The run record is now a standard memlog kept with the shared memlog.py script — append-only typed entries, latest entry wins — replacing the bespoke ledger format; stale-disposition notes become structurally impossible. Two appeal-to-measurement assertions cut: the operative admission and exclusion rules carry that load. * docs(project-context): guard handwritten guides The skill never commits — its output stays as working-tree changes for the user. Headless runs never rewrite a guide the memlog doesn't record writing; they leave an AGENTS.md.proposed for an interactive merge. * docs(project-context): fold in prior-art research findings Five adoptions from the generator prior-art survey: prohibitions name their permitted alternative; an emphasis-marker budget; a git-log --diff-filter=DR drift check on refresh; TODO placeholders over guessed greenfield commands; commit and branch conventions mined from history. * docs(project-context): route candidates to enforcement before prose Compose now asks, per accepted candidate, whether a hook, lint rule, or CI check enforces it better than a guide line; the line is the fallback and a landed check deletes it. * docs(project-context): narrow refresh interview and contradiction flagging Refresh interviews shrink to one recall question — what changed since the last run. Cross-file contradictions are flagged only when they change behavior; rewording and overlap are not contradictions. * refactor(project-context): conversational skill, no script, AGENTS.md block Refine the skill into an implementation-layer capability: a conversation that produces one small verified block inside the repo's AGENTS.md. The human is in the loop for every write; there is no autonomous mode. - Drop src/scripts/context.py and its tests. Nothing it did is needed once the output is a single spliced block rather than a bundle of files. - Replace guide-contract.md and evidence.md with best-practices.md (admission, exclusion, retirement, retrieval, maintenance) and template.md (section list plus a worked example, no placeholders). - Collapse the two-file AGENTS.md/AGENTS-dev.md split into one block. A pointer the agent must choose to follow gets skipped; anything load-bearing goes in the always-loaded file. - Replace per-section admission rules with one test: anything derivable from source is read live, never stored. Commands stated in package.json, a Makefile, or CI config no longer earn a line; their caveats do. - Ask up front whether a run covers the root only or named sub-projects, gated on observable evidence (a workspace manifest, per-directory build manifests). - Husk bmad-document-project and bmad-generate-project-context onto setup intent, and say plainly that the deeper system-explanation altitude is a separate capability rather than shipping a thin substitute. - Align module-help.csv, bmad-correct-course, the analyst menu, and the docs set with the block as the output. --------- Co-authored-by: Alex Verkhovsky <alexey.verkhovsky@gmail.com>
564 lines
18 KiB
JavaScript
564 lines
18 KiB
JavaScript
/**
|
|
* File Reference Validator
|
|
*
|
|
* Validates cross-file references in BMAD source files (agents, workflows, tasks, steps).
|
|
* Catches broken file paths, missing referenced files, and absolute path leaks.
|
|
*
|
|
* What it checks:
|
|
* - {project-root}/_bmad/ references in YAML and markdown resolve to real src/ files
|
|
* - Relative path references (./file.md, ../data/file.csv) point to existing files
|
|
* - exec="..." and <invoke-task> targets exist
|
|
* - Step metadata (thisStepFile, nextStepFile) references are valid
|
|
* - Load directives (Load: `./file.md`) target existing files
|
|
* - No absolute paths (/Users/, /home/, C:\) leak into source files
|
|
*
|
|
* What it does NOT check (deferred):
|
|
* - {installed_path} variable interpolation (self-referential, low risk)
|
|
* - {{mustache}} template variables (runtime substitution)
|
|
* - {config_source}:key dynamic YAML dereferences
|
|
*
|
|
* Usage:
|
|
* node tools/validate-file-refs.js # Warn on broken references (exit 0)
|
|
* node tools/validate-file-refs.js --strict # Fail on broken references (exit 1)
|
|
* node tools/validate-file-refs.js --verbose # Show all checked references
|
|
*
|
|
* Default mode is warning-only (exit 0) so adoption is non-disruptive.
|
|
* Use --strict when you want CI or pre-commit to enforce valid references.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const yaml = require('yaml');
|
|
const { parse: parseCsv } = require('csv-parse/sync');
|
|
|
|
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
|
|
const VERBOSE = process.argv.includes('--verbose');
|
|
const STRICT = process.argv.includes('--strict');
|
|
|
|
// --- Constants ---
|
|
|
|
// File extensions to scan
|
|
const SCAN_EXTENSIONS = new Set(['.yaml', '.yml', '.md', '.xml', '.csv']);
|
|
|
|
// Skip directories
|
|
const SKIP_DIRS = new Set(['node_modules', '.git']);
|
|
|
|
// Pattern: {project-root}/_bmad/ references
|
|
const PROJECT_ROOT_REF = /\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/g;
|
|
|
|
// Pattern: {_bmad}/ shorthand references
|
|
const BMAD_SHORTHAND_REF = /\{_bmad\}\/([^\s'"<>})\]`]+)/g;
|
|
|
|
// Pattern: exec="..." attributes
|
|
const EXEC_ATTR = /exec="([^"]+)"/g;
|
|
|
|
// Pattern: <invoke-task> content
|
|
const INVOKE_TASK = /<invoke-task>([^<]+)<\/invoke-task>/g;
|
|
|
|
// Pattern: relative paths in quotes
|
|
const RELATIVE_PATH_QUOTED = /['"](\.\.\/?[^'"]+\.(?:md|yaml|yml|xml|json|csv|txt))['"]/g;
|
|
const RELATIVE_PATH_DOT = /['"](\.\/[^'"]+\.(?:md|yaml|yml|xml|json|csv|txt))['"]/g;
|
|
|
|
// Pattern: step metadata
|
|
const STEP_META = /(?:thisStepFile|nextStepFile|continueStepFile|skipToStepFile|altStepFile|workflowFile):\s*['"](\.[^'"]+)['"]/g;
|
|
|
|
// Pattern: Load directives
|
|
const LOAD_DIRECTIVE = /Load[:\s]+`(\.[^`]+)`/g;
|
|
|
|
// Pattern: absolute path leaks
|
|
const ABS_PATH_LEAK = /(?:\/Users\/|\/home\/|[A-Z]:\\\\)/;
|
|
|
|
// --- Output Escaping ---
|
|
|
|
function escapeAnnotation(str) {
|
|
return str.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
|
|
}
|
|
|
|
function escapeTableCell(str) {
|
|
return String(str).replaceAll('|', String.raw`\|`);
|
|
}
|
|
|
|
// Path prefixes/patterns that only exist in installed structure, not in source
|
|
const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/bmad-build/', 'render/bmad-build-auto/'];
|
|
|
|
// Files that are generated at install time and don't exist in the source tree
|
|
const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml'];
|
|
|
|
// Variables that indicate a path is not statically resolvable
|
|
const UNRESOLVABLE_VARS = [
|
|
'{output_folder}',
|
|
'{value}',
|
|
'{timestamp}',
|
|
'{config_source}:',
|
|
'{installed_path}',
|
|
'{shared_path}',
|
|
'{planning_artifacts}',
|
|
'{research_topic}',
|
|
'{user_name}',
|
|
'{communication_language}',
|
|
'{epic_number}',
|
|
'{next_epic_num}',
|
|
'{epic_num}',
|
|
'{part_id}',
|
|
'{count}',
|
|
'{date}',
|
|
'{outputFile}',
|
|
'{nextStepFile}',
|
|
];
|
|
|
|
// --- File Discovery ---
|
|
|
|
function getSourceFiles(dir) {
|
|
const files = [];
|
|
|
|
function walk(currentDir) {
|
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
|
|
const fullPath = path.join(currentDir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
walk(fullPath);
|
|
} else if (entry.isFile() && SCAN_EXTENSIONS.has(path.extname(entry.name))) {
|
|
files.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(dir);
|
|
return files;
|
|
}
|
|
|
|
// --- Code Block Stripping ---
|
|
|
|
function stripCodeBlocks(content) {
|
|
return content.replaceAll(/```[\s\S]*?```/g, (m) => m.replaceAll(/[^\n]/g, ''));
|
|
}
|
|
|
|
function stripJsonExampleBlocks(content) {
|
|
// Strip bare JSON example blocks: { and } each on their own line.
|
|
// These are example/template data (not real file references).
|
|
return content.replaceAll(/^\{\s*\n(?:.*\n)*?^\}\s*$/gm, (m) => m.replaceAll(/[^\n]/g, ''));
|
|
}
|
|
|
|
// --- Path Mapping ---
|
|
|
|
function mapInstalledToSource(refPath) {
|
|
// Strip {project-root}/_bmad/ or {_bmad}/ prefix
|
|
let cleaned = refPath.replace(/^\{project-root\}\/_bmad\//, '').replace(/^\{_bmad\}\//, '');
|
|
|
|
// Also handle bare _bmad/ prefix (seen in some invoke-task)
|
|
cleaned = cleaned.replace(/^_bmad\//, '');
|
|
|
|
// Skip install-only paths (generated at install time, not in source)
|
|
if (isInstallOnly(cleaned)) return null;
|
|
|
|
// Map installed module names to their source directory names
|
|
// _bmad/core/ → src/core-skills/, _bmad/bmm/ → src/bmm-skills/
|
|
if (cleaned.startsWith('core/')) {
|
|
return path.join(SRC_DIR, 'core-skills', cleaned.slice('core/'.length));
|
|
}
|
|
if (cleaned.startsWith('bmm/')) {
|
|
return path.join(SRC_DIR, 'bmm-skills', cleaned.slice('bmm/'.length));
|
|
}
|
|
if (cleaned.startsWith('utility/')) {
|
|
return path.join(SRC_DIR, cleaned);
|
|
}
|
|
|
|
// Fallback: map directly under src/
|
|
return path.join(SRC_DIR, cleaned);
|
|
}
|
|
|
|
// --- Reference Extraction ---
|
|
|
|
function isResolvable(refStr) {
|
|
// Skip refs containing unresolvable runtime variables
|
|
if (refStr.includes('{{')) return false;
|
|
for (const v of UNRESOLVABLE_VARS) {
|
|
if (refStr.includes(v)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function isInstallOnly(cleanedPath) {
|
|
// Skip paths that only exist in the installed _bmad/ structure, not in src/
|
|
for (const prefix of INSTALL_ONLY_PATHS) {
|
|
if (cleanedPath.startsWith(prefix)) return true;
|
|
}
|
|
// Skip files that are generated during installation
|
|
const basename = path.basename(cleanedPath);
|
|
for (const generated of INSTALL_GENERATED_FILES) {
|
|
if (basename === generated) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function extractYamlRefs(filePath, content) {
|
|
const refs = [];
|
|
|
|
let doc;
|
|
try {
|
|
doc = yaml.parseDocument(content);
|
|
} catch {
|
|
return refs; // Skip unparseable YAML (schema validator handles this)
|
|
}
|
|
|
|
function checkValue(value, range, keyPath) {
|
|
if (typeof value !== 'string') return;
|
|
if (!isResolvable(value)) return;
|
|
|
|
const line = range ? offsetToLine(content, range[0]) : undefined;
|
|
|
|
// Check for {project-root}/_bmad/ refs
|
|
const prMatch = value.match(/\{project-root\}\/_bmad\/[^\s'"<>})\]`]+/);
|
|
if (prMatch) {
|
|
refs.push({ file: filePath, raw: prMatch[0], type: 'project-root', line, key: keyPath });
|
|
}
|
|
|
|
// Check for {_bmad}/ refs
|
|
const bmMatch = value.match(/\{_bmad\}\/[^\s'"<>})\]`]+/);
|
|
if (bmMatch) {
|
|
refs.push({ file: filePath, raw: bmMatch[0], type: 'project-root', line, key: keyPath });
|
|
}
|
|
|
|
// Check for relative paths
|
|
const relMatch = value.match(/^\.\.?\/[^\s'"<>})\]`]+\.(?:md|yaml|yml|xml|json|csv|txt)$/);
|
|
if (relMatch) {
|
|
refs.push({ file: filePath, raw: relMatch[0], type: 'relative', line, key: keyPath });
|
|
}
|
|
}
|
|
|
|
function walkNode(node, keyPath) {
|
|
if (!node) return;
|
|
|
|
if (yaml.isMap(node)) {
|
|
for (const item of node.items) {
|
|
const key = item.key && item.key.value !== undefined ? item.key.value : '?';
|
|
const childPath = keyPath ? `${keyPath}.${key}` : String(key);
|
|
walkNode(item.value, childPath);
|
|
}
|
|
} else if (yaml.isSeq(node)) {
|
|
for (const [i, item] of node.items.entries()) {
|
|
walkNode(item, `${keyPath}[${i}]`);
|
|
}
|
|
} else if (yaml.isScalar(node)) {
|
|
checkValue(node.value, node.range, keyPath);
|
|
}
|
|
}
|
|
|
|
walkNode(doc.contents, '');
|
|
return refs;
|
|
}
|
|
|
|
function offsetToLine(content, offset) {
|
|
let line = 1;
|
|
for (let i = 0; i < offset && i < content.length; i++) {
|
|
if (content[i] === '\n') line++;
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function extractMarkdownRefs(filePath, content) {
|
|
const refs = [];
|
|
const stripped = stripJsonExampleBlocks(stripCodeBlocks(content));
|
|
|
|
function runPattern(regex, type) {
|
|
regex.lastIndex = 0;
|
|
let match;
|
|
while ((match = regex.exec(stripped)) !== null) {
|
|
const raw = match[1];
|
|
if (!isResolvable(raw)) continue;
|
|
refs.push({ file: filePath, raw, type, line: offsetToLine(stripped, match.index) });
|
|
}
|
|
}
|
|
|
|
// {project-root}/_bmad/ refs
|
|
runPattern(PROJECT_ROOT_REF, 'project-root');
|
|
|
|
// {_bmad}/ shorthand
|
|
runPattern(BMAD_SHORTHAND_REF, 'project-root');
|
|
|
|
// exec="..." attributes
|
|
runPattern(EXEC_ATTR, 'exec-attr');
|
|
|
|
// <invoke-task> tags
|
|
runPattern(INVOKE_TASK, 'invoke-task');
|
|
|
|
// Step metadata
|
|
runPattern(STEP_META, 'relative');
|
|
|
|
// Load directives
|
|
runPattern(LOAD_DIRECTIVE, 'relative');
|
|
|
|
// Relative paths in quotes
|
|
runPattern(RELATIVE_PATH_QUOTED, 'relative');
|
|
runPattern(RELATIVE_PATH_DOT, 'relative');
|
|
|
|
return refs;
|
|
}
|
|
|
|
function extractCsvRefs(filePath, content) {
|
|
const refs = [];
|
|
|
|
let records;
|
|
try {
|
|
records = parseCsv(content, {
|
|
columns: true,
|
|
skip_empty_lines: true,
|
|
relax_column_count: true,
|
|
});
|
|
} catch (error) {
|
|
// No CSV schema validator exists yet (planned as Layer 2c) — surface parse errors visibly.
|
|
// YAML equivalent (line ~198) defers to validate-agent-schema.js; CSV has no such fallback.
|
|
const rel = path.relative(PROJECT_ROOT, filePath);
|
|
console.error(` [CSV-PARSE-ERROR] ${rel}: ${error.message}`);
|
|
if (process.env.GITHUB_ACTIONS) {
|
|
console.log(`::warning file=${rel},line=1::${escapeAnnotation(`CSV parse error: ${error.message}`)}`);
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
// Only process if workflow-file column exists
|
|
const firstRecord = records[0];
|
|
if (!firstRecord || !('workflow-file' in firstRecord)) {
|
|
return refs;
|
|
}
|
|
|
|
for (const [i, record] of records.entries()) {
|
|
const raw = record['workflow-file'];
|
|
if (!raw || raw.trim() === '') continue;
|
|
if (!isResolvable(raw)) continue;
|
|
// skill: prefixed references are resolved by the IDE/CLI, not as file paths
|
|
if (raw.startsWith('skill:')) continue;
|
|
|
|
// Line = header (1) + data row index (0-based) + 1
|
|
const line = i + 2;
|
|
refs.push({ file: filePath, raw, type: 'project-root', line });
|
|
}
|
|
|
|
return refs;
|
|
}
|
|
|
|
// --- Reference Resolution ---
|
|
|
|
function resolveRef(ref) {
|
|
if (ref.type === 'project-root') {
|
|
return mapInstalledToSource(ref.raw);
|
|
}
|
|
|
|
if (ref.type === 'relative') {
|
|
return path.resolve(path.dirname(ref.file), ref.raw);
|
|
}
|
|
|
|
if (ref.type === 'exec-attr') {
|
|
let execPath = ref.raw;
|
|
if (execPath.includes('{project-root}')) {
|
|
return mapInstalledToSource(execPath);
|
|
}
|
|
if (execPath.includes('{_bmad}')) {
|
|
return mapInstalledToSource(execPath);
|
|
}
|
|
if (execPath.startsWith('_bmad/')) {
|
|
return mapInstalledToSource(execPath);
|
|
}
|
|
// Relative exec path
|
|
return path.resolve(path.dirname(ref.file), execPath);
|
|
}
|
|
|
|
if (ref.type === 'invoke-task') {
|
|
// Extract file path from invoke-task content
|
|
const prMatch = ref.raw.match(/\{project-root\}\/_bmad\/([^\s'"<>})\]`]+)/);
|
|
if (prMatch) return mapInstalledToSource(prMatch[0]);
|
|
|
|
const bmMatch = ref.raw.match(/\{_bmad\}\/([^\s'"<>})\]`]+)/);
|
|
if (bmMatch) return mapInstalledToSource(bmMatch[0]);
|
|
|
|
const bareMatch = ref.raw.match(/_bmad\/([^\s'"<>})\]`]+)/);
|
|
if (bareMatch) return mapInstalledToSource(bareMatch[0]);
|
|
|
|
return null; // Can't resolve — skip
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// --- Absolute Path Leak Detection ---
|
|
|
|
function checkAbsolutePathLeaks(filePath, content) {
|
|
const leaks = [];
|
|
const stripped = stripCodeBlocks(content);
|
|
const lines = stripped.split('\n');
|
|
|
|
for (const [i, line] of lines.entries()) {
|
|
if (ABS_PATH_LEAK.test(line)) {
|
|
leaks.push({ file: filePath, line: i + 1, content: line.trim() });
|
|
}
|
|
}
|
|
|
|
return leaks;
|
|
}
|
|
|
|
// --- Exports (for testing) ---
|
|
module.exports = { extractCsvRefs };
|
|
|
|
// --- Main ---
|
|
|
|
if (require.main === module) {
|
|
console.log(`\nValidating file references in: ${SRC_DIR}`);
|
|
console.log(`Mode: ${STRICT ? 'STRICT (exit 1 on issues)' : 'WARNING (exit 0)'}${VERBOSE ? ' + VERBOSE' : ''}\n`);
|
|
|
|
const files = getSourceFiles(SRC_DIR);
|
|
console.log(`Found ${files.length} source files\n`);
|
|
|
|
let totalRefs = 0;
|
|
let brokenRefs = 0;
|
|
let totalLeaks = 0;
|
|
let filesWithIssues = 0;
|
|
const allIssues = []; // Collect for $GITHUB_STEP_SUMMARY
|
|
|
|
for (const filePath of files) {
|
|
const relativePath = path.relative(PROJECT_ROOT, filePath);
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
const ext = path.extname(filePath);
|
|
|
|
// Extract references
|
|
let refs;
|
|
if (ext === '.yaml' || ext === '.yml') {
|
|
refs = extractYamlRefs(filePath, content);
|
|
} else if (ext === '.csv') {
|
|
refs = extractCsvRefs(filePath, content);
|
|
} else {
|
|
refs = extractMarkdownRefs(filePath, content);
|
|
}
|
|
|
|
// Resolve and classify all refs before printing anything.
|
|
// This avoids the confusing pattern of printing headers at two different
|
|
// times depending on verbosity — collect first, then print once.
|
|
const broken = [];
|
|
const ok = [];
|
|
|
|
for (const ref of refs) {
|
|
totalRefs++;
|
|
const resolved = resolveRef(ref);
|
|
|
|
if (resolved && !fs.existsSync(resolved)) {
|
|
// Extensionless paths may be directory references or partial templates.
|
|
// If the path has no extension, check whether it exists as a directory.
|
|
// Flag it if nothing exists at all — likely a real broken reference.
|
|
const hasExt = path.extname(resolved) !== '';
|
|
if (!hasExt) {
|
|
if (fs.existsSync(resolved)) {
|
|
ok.push({ ref, tag: 'OK-DIR' });
|
|
} else {
|
|
// No extension and nothing exists — not a file, not a directory.
|
|
// Flag as UNRESOLVED (distinct from BROKEN which means "file with extension not found").
|
|
broken.push({ ref, resolved: path.relative(PROJECT_ROOT, resolved), kind: 'unresolved' });
|
|
brokenRefs++;
|
|
}
|
|
continue;
|
|
}
|
|
broken.push({ ref, resolved: path.relative(PROJECT_ROOT, resolved), kind: 'broken' });
|
|
brokenRefs++;
|
|
continue;
|
|
}
|
|
|
|
if (resolved) {
|
|
ok.push({ ref, tag: 'OK' });
|
|
}
|
|
}
|
|
|
|
// Check absolute path leaks
|
|
const leaks = checkAbsolutePathLeaks(filePath, content);
|
|
totalLeaks += leaks.length;
|
|
|
|
// Print results — file header appears once, in one place
|
|
const hasFileIssues = broken.length > 0 || leaks.length > 0;
|
|
|
|
if (hasFileIssues) {
|
|
filesWithIssues++;
|
|
console.log(`\n${relativePath}`);
|
|
|
|
if (VERBOSE) {
|
|
for (const { ref, tag, note } of ok) {
|
|
const suffix = note ? ` (${note})` : '';
|
|
console.log(` [${tag}] ${ref.raw}${suffix}`);
|
|
}
|
|
}
|
|
|
|
for (const { ref, resolved, kind } of broken) {
|
|
const location = ref.line ? `line ${ref.line}` : ref.key ? `key: ${ref.key}` : '';
|
|
const tag = kind === 'unresolved' ? 'UNRESOLVED' : 'BROKEN';
|
|
const detail = kind === 'unresolved' ? 'Not found as file or directory' : 'Target not found';
|
|
const issueType = kind === 'unresolved' ? 'unresolved path' : 'broken ref';
|
|
console.log(` [${tag}] ${ref.raw}${location ? ` (${location})` : ''}`);
|
|
console.log(` ${detail}: ${resolved}`);
|
|
allIssues.push({ file: relativePath, line: ref.line || 1, ref: ref.raw, issue: issueType });
|
|
if (process.env.GITHUB_ACTIONS) {
|
|
const line = ref.line || 1;
|
|
console.log(
|
|
`::warning file=${relativePath},line=${line}::${escapeAnnotation(`${tag === 'UNRESOLVED' ? 'Unresolved path' : 'Broken reference'}: ${ref.raw} → ${resolved}`)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const leak of leaks) {
|
|
console.log(` [ABS-PATH] Line ${leak.line}: ${leak.content}`);
|
|
allIssues.push({ file: relativePath, line: leak.line, ref: leak.content, issue: 'abs-path' });
|
|
if (process.env.GITHUB_ACTIONS) {
|
|
console.log(`::warning file=${relativePath},line=${leak.line}::${escapeAnnotation(`Absolute path leak: ${leak.content}`)}`);
|
|
}
|
|
}
|
|
} else if (VERBOSE && refs.length > 0) {
|
|
console.log(`\n${relativePath}`);
|
|
for (const { ref, tag, note } of ok) {
|
|
const suffix = note ? ` (${note})` : '';
|
|
console.log(` [${tag}] ${ref.raw}${suffix}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
console.log(`\n${'─'.repeat(60)}`);
|
|
console.log(`\nSummary:`);
|
|
console.log(` Files scanned: ${files.length}`);
|
|
console.log(` References checked: ${totalRefs}`);
|
|
console.log(` Broken references: ${brokenRefs}`);
|
|
console.log(` Absolute path leaks: ${totalLeaks}`);
|
|
|
|
const hasIssues = brokenRefs > 0 || totalLeaks > 0;
|
|
|
|
if (hasIssues) {
|
|
console.log(`\n ${filesWithIssues} file(s) with issues`);
|
|
|
|
if (STRICT) {
|
|
console.log(`\n [STRICT MODE] Exiting with failure.`);
|
|
} else {
|
|
console.log(`\n Run with --strict to treat warnings as errors.`);
|
|
}
|
|
} else {
|
|
console.log(`\n All file references valid!`);
|
|
}
|
|
|
|
console.log('');
|
|
|
|
// Write GitHub Actions step summary
|
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
let summary = '## File Reference Validation\n\n';
|
|
if (allIssues.length > 0) {
|
|
summary += '| File | Line | Reference | Issue |\n';
|
|
summary += '|------|------|-----------|-------|\n';
|
|
for (const issue of allIssues) {
|
|
summary += `| ${escapeTableCell(issue.file)} | ${issue.line} | ${escapeTableCell(issue.ref)} | ${issue.issue} |\n`;
|
|
}
|
|
summary += '\n';
|
|
}
|
|
summary += `**${files.length} files scanned, ${totalRefs} references checked, ${brokenRefs + totalLeaks} issues found**\n`;
|
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
|
|
}
|
|
|
|
process.exit(hasIssues && STRICT ? 1 : 0);
|
|
}
|