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