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.
Files changed (93) hide show
  1. package/.agents/hooks.json +42 -42
  2. package/.agents/hooks.json.example +42 -42
  3. package/.agents/mcp_config.json.example +29 -29
  4. package/.agents/scripts/safety_guard.sh +143 -34
  5. package/.agents/scripts/verify_completion.sh +90 -27
  6. package/.agents/skills/agentic-architect/SKILL.md +125 -125
  7. package/.agents/skills/agentic-architect/references/agents_md_template.md +62 -62
  8. package/.agents/skills/agentic-architect/references/refinement_workflow.md +32 -32
  9. package/.agents/skills/agentic-architect/references/skill_architecture_inquiry.md +63 -63
  10. package/.agents/skills/agentic-architect/references/skill_template.md +56 -56
  11. package/.agents/skills/agentic-architect/scripts/validate_agentic_configs.sh +402 -402
  12. package/.agents/skills/clean-code-refactor/SKILL.md +91 -91
  13. package/.agents/skills/clean-code-refactor/references/clean_code_smells.md +27 -27
  14. package/.agents/skills/clean-code-refactor/references/design_patterns_ts.md +65 -65
  15. package/.agents/skills/compliance-audit/SKILL.md +120 -120
  16. package/.agents/skills/compliance-audit/references/owasp_top10_controls.md +16 -16
  17. package/.agents/skills/compliance-audit/references/soc2_iso_controls.md +28 -28
  18. package/.agents/skills/lets-build/SKILL.md +173 -173
  19. package/.agents/skills/lets-build/references/architecture_interview_matrix.md +115 -115
  20. package/.agents/skills/lets-build/references/hexagonal_bootstrap_scaffolds.md +160 -160
  21. package/.agents/skills/lets-build/references/project_readme_template.md +79 -79
  22. package/.agents/skills/lets-build/scripts/bootstrap_workspace.sh +419 -255
  23. package/.agents/skills/product-analyst/SKILL.md +154 -154
  24. package/.agents/skills/product-analyst/references/backlog_ordering_techniques.md +107 -107
  25. package/.agents/skills/product-analyst/references/gherkin_patterns.md +46 -46
  26. package/.agents/skills/product-analyst/references/invest_checklist.md +38 -38
  27. package/.agents/skills/product-analyst/references/okr_alignment_guide.md +76 -76
  28. package/.agents/skills/product-analyst/references/smart_tasks.md +59 -59
  29. package/.agents/skills/relentless-questioner/SKILL.md +128 -128
  30. package/.agents/skills/relentless-questioner/references/adaptive_question_trees.md +102 -102
  31. package/.editorconfig +19 -19
  32. package/.github/workflows/ci.yml +167 -78
  33. package/.github/workflows/publish.yml +200 -0
  34. package/.gitignore +40 -25
  35. package/AGENTS.md +103 -102
  36. package/LICENSE +21 -21
  37. package/README.md +168 -165
  38. package/bin/azcodr.js +14 -228
  39. package/docs/knowledge/ubiquitous_language.md +31 -18
  40. package/docs/rules/agentic_configuration.md +259 -259
  41. package/docs/rules/api_architecture.md +179 -179
  42. package/docs/rules/authentication.md +76 -76
  43. package/docs/rules/authorization.md +75 -75
  44. package/docs/rules/caching.md +69 -69
  45. package/docs/rules/clean_code.md +62 -62
  46. package/docs/rules/cloud_native.md +41 -41
  47. package/docs/rules/cqrs.md +203 -203
  48. package/docs/rules/database_design.md +125 -125
  49. package/docs/rules/database_operations.md +69 -69
  50. package/docs/rules/design_patterns.md +98 -98
  51. package/docs/rules/devops_ci_cd.md +76 -76
  52. package/docs/rules/domain_driven_design.md +122 -122
  53. package/docs/rules/error_handling.md +54 -52
  54. package/docs/rules/feature_flags.md +59 -59
  55. package/docs/rules/frontend_architecture.md +157 -157
  56. package/docs/rules/multitenancy_architecture.md +98 -98
  57. package/docs/rules/product_ownership.md +127 -127
  58. package/docs/rules/project_management.md +49 -49
  59. package/docs/rules/relentless_questioning.md +52 -52
  60. package/docs/rules/requirements_engineering.md +98 -98
  61. package/docs/rules/security_compliance.md +53 -53
  62. package/docs/rules/server_driven_ui.md +88 -88
  63. package/docs/rules/test_driven_development.md +185 -185
  64. package/docs/rules/transactional_email.md +27 -27
  65. package/docs/rules/type_safety.md +65 -65
  66. package/docs/rules/ui_ux_architecture.md +150 -150
  67. package/docs/rules/workflow_state_machines.md +117 -117
  68. package/lib/cli-parse.js +51 -0
  69. package/lib/cli-target.js +109 -0
  70. package/lib/cli.js +180 -0
  71. package/lib/errors.js +28 -0
  72. package/lib/git.js +29 -0
  73. package/lib/guards.js +96 -0
  74. package/lib/index.d.ts +199 -134
  75. package/lib/index.js +5 -5
  76. package/lib/links.js +123 -0
  77. package/lib/permissions.js +44 -0
  78. package/lib/repo.js +90 -0
  79. package/lib/scaffold.js +238 -448
  80. package/memory.md +119 -36
  81. package/package.json +65 -62
  82. package/scripts/test_coverage.js +66 -38
  83. package/scripts/validate/adr.js +151 -0
  84. package/scripts/validate/io.js +84 -0
  85. package/scripts/validate/links.js +167 -0
  86. package/scripts/validate/parity.js +124 -0
  87. package/scripts/validate/root.js +184 -0
  88. package/scripts/validate/rules.js +44 -0
  89. package/scripts/validate/skills.js +96 -0
  90. package/scripts/validate/text.js +29 -0
  91. package/scripts/validate-cli.js +13 -0
  92. package/scripts/validate.js +140 -258
  93. 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 };