azcodr 1.5.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/hooks.json +42 -42
- package/.agents/hooks.json.example +42 -42
- package/.agents/mcp_config.json.example +29 -29
- package/.agents/scripts/safety_guard.sh +143 -34
- package/.agents/scripts/verify_completion.sh +90 -27
- package/.agents/skills/agentic-architect/SKILL.md +125 -125
- package/.agents/skills/agentic-architect/references/agents_md_template.md +62 -62
- package/.agents/skills/agentic-architect/references/refinement_workflow.md +32 -32
- package/.agents/skills/agentic-architect/references/skill_architecture_inquiry.md +63 -63
- package/.agents/skills/agentic-architect/references/skill_template.md +56 -56
- package/.agents/skills/agentic-architect/scripts/validate_agentic_configs.sh +402 -402
- package/.agents/skills/clean-code-refactor/SKILL.md +91 -91
- package/.agents/skills/clean-code-refactor/references/clean_code_smells.md +27 -27
- package/.agents/skills/clean-code-refactor/references/design_patterns_ts.md +65 -65
- package/.agents/skills/compliance-audit/SKILL.md +120 -120
- package/.agents/skills/compliance-audit/references/owasp_top10_controls.md +16 -16
- package/.agents/skills/compliance-audit/references/soc2_iso_controls.md +28 -28
- package/.agents/skills/lets-build/SKILL.md +173 -173
- package/.agents/skills/lets-build/references/architecture_interview_matrix.md +115 -115
- package/.agents/skills/lets-build/references/hexagonal_bootstrap_scaffolds.md +160 -160
- package/.agents/skills/lets-build/references/project_readme_template.md +79 -79
- package/.agents/skills/lets-build/scripts/bootstrap_workspace.sh +419 -255
- package/.agents/skills/product-analyst/SKILL.md +154 -154
- package/.agents/skills/product-analyst/references/backlog_ordering_techniques.md +107 -107
- package/.agents/skills/product-analyst/references/gherkin_patterns.md +46 -46
- package/.agents/skills/product-analyst/references/invest_checklist.md +38 -38
- package/.agents/skills/product-analyst/references/okr_alignment_guide.md +76 -76
- package/.agents/skills/product-analyst/references/smart_tasks.md +59 -59
- package/.agents/skills/relentless-questioner/SKILL.md +128 -128
- package/.agents/skills/relentless-questioner/references/adaptive_question_trees.md +102 -102
- package/.editorconfig +19 -19
- package/.github/workflows/ci.yml +167 -78
- package/.github/workflows/publish.yml +200 -0
- package/.gitignore +40 -25
- package/AGENTS.md +103 -102
- package/LICENSE +21 -21
- package/README.md +168 -165
- package/bin/azcodr.js +14 -228
- package/docs/knowledge/ubiquitous_language.md +31 -18
- package/docs/rules/agentic_configuration.md +259 -259
- package/docs/rules/api_architecture.md +179 -179
- package/docs/rules/authentication.md +76 -76
- package/docs/rules/authorization.md +75 -75
- package/docs/rules/caching.md +69 -69
- package/docs/rules/clean_code.md +62 -62
- package/docs/rules/cloud_native.md +41 -41
- package/docs/rules/cqrs.md +203 -203
- package/docs/rules/database_design.md +125 -125
- package/docs/rules/database_operations.md +69 -69
- package/docs/rules/design_patterns.md +98 -98
- package/docs/rules/devops_ci_cd.md +76 -76
- package/docs/rules/domain_driven_design.md +122 -122
- package/docs/rules/error_handling.md +54 -52
- package/docs/rules/feature_flags.md +59 -59
- package/docs/rules/frontend_architecture.md +157 -157
- package/docs/rules/multitenancy_architecture.md +98 -98
- package/docs/rules/product_ownership.md +127 -127
- package/docs/rules/project_management.md +49 -49
- package/docs/rules/relentless_questioning.md +52 -52
- package/docs/rules/requirements_engineering.md +98 -98
- package/docs/rules/security_compliance.md +53 -53
- package/docs/rules/server_driven_ui.md +88 -88
- package/docs/rules/test_driven_development.md +185 -185
- package/docs/rules/transactional_email.md +27 -27
- package/docs/rules/type_safety.md +65 -65
- package/docs/rules/ui_ux_architecture.md +150 -150
- package/docs/rules/workflow_state_machines.md +117 -117
- package/lib/cli-parse.js +51 -0
- package/lib/cli-target.js +109 -0
- package/lib/cli.js +180 -0
- package/lib/errors.js +28 -0
- package/lib/git.js +29 -0
- package/lib/guards.js +96 -0
- package/lib/index.d.ts +199 -134
- package/lib/index.js +5 -5
- package/lib/links.js +123 -0
- package/lib/permissions.js +44 -0
- package/lib/repo.js +90 -0
- package/lib/scaffold.js +238 -448
- package/memory.md +119 -36
- package/package.json +65 -62
- package/scripts/test_coverage.js +66 -38
- package/scripts/validate/adr.js +151 -0
- package/scripts/validate/io.js +84 -0
- package/scripts/validate/links.js +167 -0
- package/scripts/validate/parity.js +124 -0
- package/scripts/validate/root.js +184 -0
- package/scripts/validate/rules.js +44 -0
- package/scripts/validate/skills.js +96 -0
- package/scripts/validate/text.js +29 -0
- package/scripts/validate-cli.js +13 -0
- package/scripts/validate.js +140 -258
- package/.github/copilot-instructions.md +0 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reporter seam. Tests inject a collector so assertions can read the exact
|
|
8
|
+
* pass/warn/fail outcome instead of scraping stdout.
|
|
9
|
+
*/
|
|
10
|
+
function createReporter(sink = console) {
|
|
11
|
+
return {
|
|
12
|
+
pass: (msg) => sink.log(` ✅ ${msg}`),
|
|
13
|
+
warn: (msg) => sink.log(` ⚠️ ${msg}`),
|
|
14
|
+
fail: (msg) => sink.log(` ❌ ${msg}`),
|
|
15
|
+
log: (msg) => sink.log(msg),
|
|
16
|
+
heading: (msg) => sink.log(msg)
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Reads a UTF-8 text file, reporting rather than throwing on failure.
|
|
22
|
+
*
|
|
23
|
+
* Unguarded readFileSync calls crashed the whole run on a directory named
|
|
24
|
+
* `*.md`, an unreadable file, or a broken symlink, aborting every later phase
|
|
25
|
+
* and emitting a stack trace instead of a verdict.
|
|
26
|
+
*/
|
|
27
|
+
function readTextOrFail(filePath, label, fail) {
|
|
28
|
+
try {
|
|
29
|
+
const st = fs.statSync(filePath);
|
|
30
|
+
if (!st.isFile()) {
|
|
31
|
+
fail(`${label} is not a regular file (${st.isDirectory() ? 'it is a directory' : 'special file'}).`);
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
} catch (err) {
|
|
36
|
+
fail(`Could not read ${label}: ${err.code || err.message}`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Recursively collects markdown files under `root`. */
|
|
42
|
+
function walkMarkdown(root, label, fail) {
|
|
43
|
+
const out = [];
|
|
44
|
+
const stack = [root];
|
|
45
|
+
while (stack.length > 0) {
|
|
46
|
+
const dir = stack.pop();
|
|
47
|
+
let entries = [];
|
|
48
|
+
try {
|
|
49
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
fail(`Could not list ${label} directory ${dir}: ${err.code || err.message}`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
56
|
+
const full = path.join(dir, entry.name);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
stack.push(full);
|
|
59
|
+
} else if (entry.name.toLowerCase().endsWith('.md')) {
|
|
60
|
+
out.push(full);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out.sort();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Lists skill directories, skipping symlinks that cannot be resolved. */
|
|
68
|
+
function readSkillFolders(skillsDir, fail) {
|
|
69
|
+
let entries = [];
|
|
70
|
+
try {
|
|
71
|
+
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
fail(`Could not list skills directory: ${err.code || err.message}`);
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const folders = [];
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (!entry.isDirectory()) continue;
|
|
79
|
+
folders.push(entry.name);
|
|
80
|
+
}
|
|
81
|
+
return folders;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { createReporter, readTextOrFail, walkMarkdown, readSkillFolders };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readTextOrFail } = require('./io.js');
|
|
6
|
+
const { stripFencedCode } = require('./text.js');
|
|
7
|
+
|
|
8
|
+
function normalizeLinkTarget(rawTarget) {
|
|
9
|
+
let target = rawTarget.trim();
|
|
10
|
+
if (!target) return null;
|
|
11
|
+
// Link titles: [x](./a.md "Title") and 'Title' / (Title).
|
|
12
|
+
target = target.replace(/\s+(?:"[^"]*"|'[^']*'|\([^)]*\))$/, '').trim();
|
|
13
|
+
// Angle-bracket destinations: [x](<./a.md>)
|
|
14
|
+
const angled = target.match(/^<([^>]*)>$/);
|
|
15
|
+
if (angled) target = angled[1].trim();
|
|
16
|
+
// Strip a query string; strip a fragment.
|
|
17
|
+
const display = target.split('#')[0].split('?')[0];
|
|
18
|
+
if (!display) return null;
|
|
19
|
+
// Schemes, case-insensitively. Protocol-relative and Windows absolute
|
|
20
|
+
// paths are external too.
|
|
21
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(display)) return null;
|
|
22
|
+
if (display.startsWith('//')) return null;
|
|
23
|
+
if (/^[a-z]:[\\/]/i.test(display)) return null;
|
|
24
|
+
// Percent-decode so ./exists%2emd resolves like a renderer would.
|
|
25
|
+
try {
|
|
26
|
+
return { target: decodeURIComponent(display), display };
|
|
27
|
+
} catch {
|
|
28
|
+
return { target: display, display };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function recordLinkTarget(link, ctx) {
|
|
33
|
+
const normalized = normalizeLinkTarget(link.rawTarget);
|
|
34
|
+
if (normalized === null) return;
|
|
35
|
+
ctx.linkCount += 1;
|
|
36
|
+
const resolved = path.normalize(path.join(link.dir, normalized.target));
|
|
37
|
+
if (!fs.existsSync(resolved)) {
|
|
38
|
+
ctx.broken.push(`${path.relative(ctx.workspaceRoot, link.filePath)} -> ${normalized.display}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collectInlineLinks(job, ctx) {
|
|
43
|
+
// Inline links: [text](target). Tolerates nested brackets in the link text,
|
|
44
|
+
// which previously made `[click [here] now](./x)` unmatchable.
|
|
45
|
+
const inline = /\[((?:[^\][]|\[[^\][]*\])*)\]\(([^)]*)\)/g;
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = inline.exec(job.live)) !== null) {
|
|
48
|
+
const display = m[2].trim() || '(empty target)';
|
|
49
|
+
recordLinkTarget({ rawTarget: m[2], display, dir: job.dir, filePath: job.filePath }, ctx);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function collectReferenceLinks(job, ctx) {
|
|
54
|
+
// Reference-style definitions: [ref]: ./target
|
|
55
|
+
const reference = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/gm;
|
|
56
|
+
let m;
|
|
57
|
+
while ((m = reference.exec(job.live)) !== null) {
|
|
58
|
+
recordLinkTarget({ rawTarget: m[1], display: m[1], dir: job.dir, filePath: job.filePath }, ctx);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectHtmlAnchors(job, ctx) {
|
|
63
|
+
// HTML anchors: <a href="./target">
|
|
64
|
+
const html = /<a\s[^>]*href\s*=\s*["']([^"']+)["']/gi;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = html.exec(job.live)) !== null) {
|
|
67
|
+
recordLinkTarget({ rawTarget: m[1], display: m[1], dir: job.dir, filePath: job.filePath }, ctx);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkFile(filePath, ctx) {
|
|
72
|
+
// An unreadable markdown file must be reported, not allowed to abort the
|
|
73
|
+
// entire validation run: the remaining files still need checking.
|
|
74
|
+
const content = readTextOrFail(filePath, `markdown file ${path.relative(ctx.workspaceRoot, filePath)}`, ctx.fail);
|
|
75
|
+
if (content === null) return;
|
|
76
|
+
const job = {
|
|
77
|
+
live: stripFencedCode(content),
|
|
78
|
+
dir: path.dirname(filePath),
|
|
79
|
+
filePath
|
|
80
|
+
};
|
|
81
|
+
collectInlineLinks(job, ctx);
|
|
82
|
+
collectReferenceLinks(job, ctx);
|
|
83
|
+
collectHtmlAnchors(job, ctx);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function reportUnreadableDir(dir, err, ctx) {
|
|
87
|
+
// A silently-skipped subtree yields "0 broken links" derived from zero
|
|
88
|
+
// coverage, which is the worst outcome a checker can produce: a green
|
|
89
|
+
// result that proves nothing. Report what could not be read instead.
|
|
90
|
+
if (err instanceof RangeError) {
|
|
91
|
+
// Stack exhaustion (needs a tree thousands of levels deep -- not
|
|
92
|
+
// reproducible portably). Abort loudly rather than pretending clean.
|
|
93
|
+
ctx.fail(`Directory traversal exhausted the stack at ${path.relative(ctx.workspaceRoot, dir) || '.'}; link validation is incomplete.`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ctx.warn(`Skipped unreadable directory ${path.relative(ctx.workspaceRoot, dir) || '.'}: ${err.code || err.message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function followSymlinkEntry(full, ctx) {
|
|
100
|
+
let real = null;
|
|
101
|
+
try {
|
|
102
|
+
real = fs.realpathSync(full);
|
|
103
|
+
} catch {
|
|
104
|
+
real = null;
|
|
105
|
+
}
|
|
106
|
+
if (real === null) {
|
|
107
|
+
ctx.warn(`Skipped dangling symlink ${path.relative(ctx.workspaceRoot, full)}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (ctx.visitedRealPaths.has(real)) return;
|
|
111
|
+
ctx.visitedRealPaths.add(real);
|
|
112
|
+
let st = null;
|
|
113
|
+
try {
|
|
114
|
+
st = fs.statSync(full);
|
|
115
|
+
} catch {
|
|
116
|
+
st = null;
|
|
117
|
+
}
|
|
118
|
+
if (st && st.isDirectory()) walk(full, ctx);
|
|
119
|
+
else if (full.endsWith('.md')) checkFile(full, ctx);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function visitEntry(entry, full, ctx) {
|
|
123
|
+
if (entry.isDirectory()) {
|
|
124
|
+
walk(full, ctx);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// Symlink following is unreachable on Windows without Developer Mode, so the
|
|
128
|
+
// isSymbolicLink() arm is covered only on hosts that allow it.
|
|
129
|
+
if (entry.isSymbolicLink()) {
|
|
130
|
+
followSymlinkEntry(full, ctx);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) checkFile(full, ctx);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function walk(dir, ctx) {
|
|
137
|
+
let entries = [];
|
|
138
|
+
try {
|
|
139
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
140
|
+
} catch (err) {
|
|
141
|
+
reportUnreadableDir(dir, err, ctx);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
146
|
+
visitEntry(entry, path.join(dir, entry.name), ctx);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function seedVisitedPaths(ctx) {
|
|
151
|
+
try {
|
|
152
|
+
ctx.visitedRealPaths.add(fs.realpathSync(ctx.workspaceRoot));
|
|
153
|
+
} catch {
|
|
154
|
+
// Unresolvable root: the walk reports each missing file individually.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function phaseLinks(ctx) {
|
|
159
|
+
ctx.log('');
|
|
160
|
+
ctx.heading('4. Checking Markdown Internal Links & Cross-References...');
|
|
161
|
+
seedVisitedPaths(ctx);
|
|
162
|
+
walk(ctx.workspaceRoot, ctx);
|
|
163
|
+
if (ctx.broken.length === 0) ctx.pass(`Validated ${ctx.linkCount} internal links across workspace (0 broken links).`);
|
|
164
|
+
else for (const b of ctx.broken) ctx.fail(`Broken markdown link: ${b}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { phaseLinks, checkFile, normalizeLinkTarget };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Harness-parity verdicts, kept free of filesystem I/O.
|
|
7
|
+
*
|
|
8
|
+
* Callers pass in what lstat/readlink/readFile observed, because symlink
|
|
9
|
+
* creation is unavailable on some hosts -- Windows without Developer Mode
|
|
10
|
+
* returns EPERM -- so the symlink verdicts would otherwise be unreachable in
|
|
11
|
+
* CI on the platform matrix that matters most.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The three target spellings a harness-parity file may legitimately use.
|
|
16
|
+
* Pure so it can be unit-tested without touching the filesystem.
|
|
17
|
+
*/
|
|
18
|
+
function isValidAgentsTarget(target, agentsFile) {
|
|
19
|
+
const normalized = target.replace(/\\/g, '/');
|
|
20
|
+
const rootNormalized = agentsFile.replace(/\\/g, '/');
|
|
21
|
+
return normalized === 'AGENTS.md' || normalized === './AGENTS.md' || normalized === rootNormalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function symlinkVerdict(ctx) {
|
|
25
|
+
const { label, linkTarget, agentsFile } = ctx;
|
|
26
|
+
const normalized = linkTarget.replace(/\\/g, '/');
|
|
27
|
+
const isCopilot = label.endsWith('copilot-instructions.md');
|
|
28
|
+
const ok = isCopilot
|
|
29
|
+
? (normalized === '../AGENTS.md'
|
|
30
|
+
|| normalized === agentsFile.replace(/\\/g, '/')
|
|
31
|
+
|| normalized === 'AGENTS.md')
|
|
32
|
+
: isValidAgentsTarget(linkTarget, agentsFile);
|
|
33
|
+
if (ok) return { kind: 'pass', message: `${label} is a valid symlink to AGENTS.md.` };
|
|
34
|
+
return { kind: 'fail', message: `${label} points to '${linkTarget}' instead of 'AGENTS.md'.` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function textPointerVerdict(ctx) {
|
|
38
|
+
const { label, agentsFile, content } = ctx;
|
|
39
|
+
const trimmed = content.trim();
|
|
40
|
+
if (trimmed === 'AGENTS.md' || trimmed === './AGENTS.md' || trimmed === agentsFile) {
|
|
41
|
+
return { kind: 'pass', message: `${label} is a text pointer to AGENTS.md (symlink fallback).` };
|
|
42
|
+
}
|
|
43
|
+
const isCopilot = label.endsWith('copilot-instructions.md');
|
|
44
|
+
if (isCopilot && content.includes('AGENTS.md')) {
|
|
45
|
+
return { kind: 'pass', message: `${label} references AGENTS.md (symlink fallback).` };
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function copyFallbackVerdict(ctx) {
|
|
51
|
+
const { label, allowCopyFallback, agentsContent, agentsContentMissing, content } = ctx;
|
|
52
|
+
if (allowCopyFallback && !agentsContentMissing && agentsContent && content === agentsContent) {
|
|
53
|
+
return {
|
|
54
|
+
kind: 'warn',
|
|
55
|
+
message: `${label} is a byte-identical copy of AGENTS.md (Windows symlink fallback; drift risk).`
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function caseInsensitiveVerdict(ctx) {
|
|
62
|
+
const { label, entries } = ctx;
|
|
63
|
+
if (label === 'agents.md' && !entries.includes('agents.md') && entries.includes('AGENTS.md')) {
|
|
64
|
+
return {
|
|
65
|
+
kind: 'pass',
|
|
66
|
+
message: 'agents.md is satisfied natively by AGENTS.md (case-insensitive filesystem).'
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fileVerdict(ctx) {
|
|
73
|
+
const pointer = textPointerVerdict(ctx);
|
|
74
|
+
if (pointer !== null) return pointer;
|
|
75
|
+
const copy = copyFallbackVerdict(ctx);
|
|
76
|
+
if (copy !== null) return copy;
|
|
77
|
+
const native = caseInsensitiveVerdict(ctx);
|
|
78
|
+
if (native !== null) return native;
|
|
79
|
+
return { kind: 'fail', message: `${ctx.label} is not a symbolic link.` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decides whether a harness-parity entry is healthy, drifted, or outright wrong.
|
|
84
|
+
*/
|
|
85
|
+
function evaluateParityTarget(ctx) {
|
|
86
|
+
if (ctx.isSymlink) return symlinkVerdict(ctx);
|
|
87
|
+
if (ctx.isFile) return fileVerdict(ctx);
|
|
88
|
+
// Anything lstat saw that is neither a symlink nor a regular file (a
|
|
89
|
+
// directory sitting in a parity slot, a socket, a device node).
|
|
90
|
+
return { kind: 'fail', message: `${ctx.label} is neither a symlink nor a regular file.` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates the lowercase parity link, preferring a real symlink and falling back
|
|
95
|
+
* to a text pointer. Never throws: an unrecoverable workspace is reported so
|
|
96
|
+
* the caller can surface it.
|
|
97
|
+
*
|
|
98
|
+
* @returns {{created: boolean, strategy: 'symlink'|'pointer'|null, reason: string|null}}
|
|
99
|
+
*/
|
|
100
|
+
function createLowercaseParityLink(lowerPath, fsImpl = fs) {
|
|
101
|
+
try {
|
|
102
|
+
fsImpl.symlinkSync('AGENTS.md', lowerPath);
|
|
103
|
+
return { created: true, strategy: 'symlink', reason: null };
|
|
104
|
+
} catch (symlinkErr) {
|
|
105
|
+
// Symlinks unavailable (Windows without Developer Mode): a text pointer
|
|
106
|
+
// preserves the invariant without duplicating content.
|
|
107
|
+
try {
|
|
108
|
+
fsImpl.writeFileSync(lowerPath, 'AGENTS.md\n', 'utf-8');
|
|
109
|
+
return { created: true, strategy: 'pointer', reason: null };
|
|
110
|
+
} catch (writeErr) {
|
|
111
|
+
return {
|
|
112
|
+
created: false,
|
|
113
|
+
strategy: null,
|
|
114
|
+
reason: `symlink failed (${symlinkErr.code || symlinkErr.message}); pointer write failed (${writeErr.code || writeErr.message})`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
isValidAgentsTarget,
|
|
122
|
+
evaluateParityTarget,
|
|
123
|
+
createLowercaseParityLink
|
|
124
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readTextOrFail } = require('./io.js');
|
|
6
|
+
const { evaluateParityTarget, createLowercaseParityLink } = require('./parity.js');
|
|
7
|
+
|
|
8
|
+
function reportAgentsLineBudget(agentsText, ctx) {
|
|
9
|
+
const lines = agentsText.split('\n').length;
|
|
10
|
+
if (lines <= 120) ctx.pass(`AGENTS.md line count is lean: ${lines} lines (<= 120).`);
|
|
11
|
+
else if (lines <= 150) ctx.warn(`AGENTS.md line count is getting large: ${lines} lines (warn > 120).`);
|
|
12
|
+
else ctx.fail(`AGENTS.md exceeds maximum line limit: ${lines} lines (max 150).`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function checkAgentsFile(ctx, agentsFile) {
|
|
16
|
+
if (!fs.existsSync(agentsFile)) {
|
|
17
|
+
ctx.fail(`Missing root AGENTS.md at ${agentsFile}`);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
ctx.pass('AGENTS.md exists.');
|
|
21
|
+
const agentsText = readTextOrFail(agentsFile, 'AGENTS.md', ctx.fail);
|
|
22
|
+
if (agentsText === null) return null;
|
|
23
|
+
// A 0-byte root contract passed the line budget (''.split('\n').length
|
|
24
|
+
// is 1). Progressive disclosure with an empty root file discloses nothing.
|
|
25
|
+
if (agentsText.trim().length === 0) {
|
|
26
|
+
ctx.fail('AGENTS.md is empty; the root agent contract must define its operating rules.');
|
|
27
|
+
return agentsText;
|
|
28
|
+
}
|
|
29
|
+
reportAgentsLineBudget(agentsText, ctx);
|
|
30
|
+
return agentsText;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readParityLink(filePath, stat) {
|
|
34
|
+
if (stat.isSymbolicLink()) {
|
|
35
|
+
try {
|
|
36
|
+
return { isSymlink: true, linkTarget: fs.readlinkSync(filePath) };
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// A symlink that lstat sees but readlink cannot resolve (dangling on
|
|
39
|
+
// Windows, or a race) must be reported, never silently downgraded to a
|
|
40
|
+
// regular file that might then "pass" as a valid pointer.
|
|
41
|
+
return { readlinkError: err.message };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (stat.isFile()) {
|
|
45
|
+
try {
|
|
46
|
+
return { isFile: true, content: fs.readFileSync(filePath, 'utf-8') };
|
|
47
|
+
} catch (err) {
|
|
48
|
+
return { readError: err.message };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { neither: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function observeParityEntry(filePath, workspaceRoot) {
|
|
55
|
+
let stat = null;
|
|
56
|
+
try {
|
|
57
|
+
stat = fs.lstatSync(filePath);
|
|
58
|
+
} catch {
|
|
59
|
+
return { missing: true };
|
|
60
|
+
}
|
|
61
|
+
let entries = [];
|
|
62
|
+
try {
|
|
63
|
+
entries = fs.readdirSync(workspaceRoot);
|
|
64
|
+
} catch {
|
|
65
|
+
// Verdict degrades to a plain fail.
|
|
66
|
+
}
|
|
67
|
+
return { ...readParityLink(filePath, stat), entries };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyParityVerdict(label, verdict, ctx) {
|
|
71
|
+
if (verdict.kind === 'pass') ctx.pass(verdict.message);
|
|
72
|
+
else if (verdict.kind === 'warn') ctx.warn(verdict.message);
|
|
73
|
+
else ctx.fail(verdict.message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkParity(filePath, target, ctx) {
|
|
77
|
+
const observed = observeParityEntry(filePath, ctx.workspaceRoot);
|
|
78
|
+
if (observed.missing) {
|
|
79
|
+
ctx.fail(`${target.label} is missing.`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (observed.readlinkError) {
|
|
83
|
+
ctx.fail(`${target.label} readlink failed: ${observed.readlinkError}`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (observed.readError) {
|
|
87
|
+
ctx.fail(`${target.label} could not be read: ${observed.readError}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const verdict = evaluateParityTarget({
|
|
91
|
+
label: target.label,
|
|
92
|
+
allowCopyFallback: target.allowCopyFallback,
|
|
93
|
+
agentsContent: ctx.agentsContent,
|
|
94
|
+
agentsContentMissing: !ctx.agentsContent,
|
|
95
|
+
agentsFile: ctx.agentsFile,
|
|
96
|
+
entries: observed.entries || [],
|
|
97
|
+
isSymlink: Boolean(observed.isSymlink),
|
|
98
|
+
linkTarget: observed.linkTarget || null,
|
|
99
|
+
isFile: Boolean(observed.isFile),
|
|
100
|
+
content: observed.content || ''
|
|
101
|
+
});
|
|
102
|
+
applyParityVerdict(target.label, verdict, ctx);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parityTarget(label) {
|
|
106
|
+
return { label, allowCopyFallback: true };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function restoreLowercaseParity(lowerPath, ctx) {
|
|
110
|
+
const outcome = createLowercaseParityLink(lowerPath, fs);
|
|
111
|
+
if (outcome.created) {
|
|
112
|
+
ctx.pass('Created agents.md parity link to AGENTS.md (case-sensitive filesystem).');
|
|
113
|
+
} else {
|
|
114
|
+
ctx.warn(`Could not restore agents.md parity: ${outcome.reason}`);
|
|
115
|
+
}
|
|
116
|
+
checkParity(lowerPath, parityTarget('agents.md'), ctx);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function checkLowercaseParity(ctx) {
|
|
120
|
+
const lowerPath = path.join(ctx.workspaceRoot, 'agents.md');
|
|
121
|
+
// An unreadable root degrades to an empty listing, which routes every file
|
|
122
|
+
// through checkParity and reports each one individually.
|
|
123
|
+
let rootEntries = [];
|
|
124
|
+
try {
|
|
125
|
+
rootEntries = fs.readdirSync(ctx.workspaceRoot);
|
|
126
|
+
} catch {
|
|
127
|
+
rootEntries = [];
|
|
128
|
+
}
|
|
129
|
+
if (rootEntries.includes('AGENTS.md') && !rootEntries.includes('agents.md')) {
|
|
130
|
+
// No exact 'agents.md' in the listing. On a case-insensitive filesystem the
|
|
131
|
+
// lower-cased path resolves to AGENTS.md itself, so the invariant already
|
|
132
|
+
// holds and MUST NOT be "restored": writing here would overwrite the very
|
|
133
|
+
// file it is trying to point at. Only repair on a genuinely
|
|
134
|
+
// case-sensitive filesystem, where the two paths are distinct entries.
|
|
135
|
+
if (fs.existsSync(lowerPath)) {
|
|
136
|
+
ctx.pass('agents.md is satisfied natively by AGENTS.md (case-insensitive filesystem).');
|
|
137
|
+
} else {
|
|
138
|
+
restoreLowercaseParity(lowerPath, ctx);
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
checkParity(lowerPath, parityTarget('agents.md'), ctx);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkHarnessParity(ctx) {
|
|
146
|
+
const { workspaceRoot } = ctx;
|
|
147
|
+
checkParity(path.join(workspaceRoot, 'CLAUDE.md'), parityTarget('CLAUDE.md'), ctx);
|
|
148
|
+
checkLowercaseParity(ctx);
|
|
149
|
+
checkParity(path.join(workspaceRoot, 'GEMINI.md'), parityTarget('GEMINI.md'), ctx);
|
|
150
|
+
checkParity(path.join(workspaceRoot, '.cursorrules'), parityTarget('.cursorrules'), ctx);
|
|
151
|
+
checkParity(path.join(workspaceRoot, '.windsurfrules'), parityTarget('.windsurfrules'), ctx);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function checkGithubParity(ctx) {
|
|
155
|
+
const githubDir = path.join(ctx.workspaceRoot, '.github');
|
|
156
|
+
if (!fs.existsSync(githubDir) || !fs.statSync(githubDir).isDirectory()) return;
|
|
157
|
+
const copilot = path.join(githubDir, 'copilot-instructions.md');
|
|
158
|
+
if (fs.existsSync(copilot)) {
|
|
159
|
+
checkParity(copilot, parityTarget('.github/copilot-instructions.md'), ctx);
|
|
160
|
+
} else {
|
|
161
|
+
ctx.warn('.github/copilot-instructions.md is missing (run scaffold to restore harness parity).');
|
|
162
|
+
}
|
|
163
|
+
if (!fs.existsSync(path.join(githubDir, 'workflows'))) {
|
|
164
|
+
ctx.warn('.github/workflows is missing (CI will not run in scaffolded projects).');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function checkGitignore(ctx) {
|
|
169
|
+
if (fs.existsSync(path.join(ctx.workspaceRoot, '.gitignore'))) ctx.pass('.gitignore exists.');
|
|
170
|
+
else ctx.fail('Missing .gitignore');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function phaseRootConfig(ctx) {
|
|
174
|
+
ctx.heading('1. Checking Root Configuration & Symlinks...');
|
|
175
|
+
const agentsFile = path.join(ctx.workspaceRoot, 'AGENTS.md');
|
|
176
|
+
const agentsText = checkAgentsFile(ctx, agentsFile);
|
|
177
|
+
ctx.agentsFile = agentsFile;
|
|
178
|
+
ctx.agentsContent = agentsText === null ? '' : agentsText;
|
|
179
|
+
checkHarnessParity(ctx);
|
|
180
|
+
checkGithubParity(ctx);
|
|
181
|
+
checkGitignore(ctx);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { phaseRootConfig, checkParity, checkAgentsFile };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readTextOrFail, walkMarkdown } = require('./io.js');
|
|
6
|
+
const { stripFencedCode } = require('./text.js');
|
|
7
|
+
|
|
8
|
+
function checkOneRule(fp, name, ctx) {
|
|
9
|
+
const content = readTextOrFail(fp, `Rule ${name}`, ctx.fail);
|
|
10
|
+
if (content === null) return;
|
|
11
|
+
const stat = fs.statSync(fp);
|
|
12
|
+
// Strip fenced code so a documentation example cannot satisfy either
|
|
13
|
+
// structural check.
|
|
14
|
+
const structural = stripFencedCode(content);
|
|
15
|
+
if (!/^# /m.test(structural)) ctx.fail(`Rule ${name} missing H1 header (# Title)`);
|
|
16
|
+
if (!/^> \*\*Core Mandate:\*\*/m.test(structural)) ctx.warn(`Rule ${name} missing standardized '> **Core Mandate:**' summary`);
|
|
17
|
+
if (stat.size > 24000) ctx.warn(`Rule ${name} exceeds 24KB token-economy cap (${stat.size} bytes)`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function phaseRules(ctx) {
|
|
21
|
+
ctx.log('');
|
|
22
|
+
ctx.heading('2. Checking Progressive Disclosure Rules...');
|
|
23
|
+
const rulesDir = path.join(ctx.workspaceRoot, 'docs', 'rules');
|
|
24
|
+
if (!fs.existsSync(rulesDir) || !fs.statSync(rulesDir).isDirectory()) {
|
|
25
|
+
ctx.fail(`Missing docs/rules directory at ${rulesDir}`);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
// Recurse, and match the extension case-insensitively. The previous flat
|
|
29
|
+
// readdir missed docs/rules/sub/*.md and *.MD entirely, so the reported
|
|
30
|
+
// count could be a lie and a 200KB rule could go unvalidated.
|
|
31
|
+
const files = walkMarkdown(rulesDir, 'rule', ctx.fail);
|
|
32
|
+
let count = 0;
|
|
33
|
+
for (const fp of files) {
|
|
34
|
+
count += 1;
|
|
35
|
+
checkOneRule(fp, path.relative(rulesDir, fp), ctx);
|
|
36
|
+
}
|
|
37
|
+
// Progressive disclosure with zero rules defeats the purpose of the
|
|
38
|
+
// directory, so an empty one is a failure, not a pass.
|
|
39
|
+
if (count === 0) ctx.fail('docs/rules contains no .md rule files; progressive disclosure has nothing to disclose.');
|
|
40
|
+
ctx.pass(`Validated ${count} modular rule files in docs/rules/.`);
|
|
41
|
+
return count;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { phaseRules, checkOneRule };
|