ally-a11y 1.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/ACCESSIBILITY.md +205 -0
- package/LICENSE +21 -0
- package/README.md +940 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +528 -0
- package/dist/commands/audit-palette.d.ts +18 -0
- package/dist/commands/audit-palette.js +613 -0
- package/dist/commands/auto-pr.d.ts +19 -0
- package/dist/commands/auto-pr.js +434 -0
- package/dist/commands/badge.d.ts +11 -0
- package/dist/commands/badge.js +143 -0
- package/dist/commands/completion.d.ts +4 -0
- package/dist/commands/completion.js +185 -0
- package/dist/commands/crawl.d.ts +12 -0
- package/dist/commands/crawl.js +249 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +233 -0
- package/dist/commands/explain.d.ts +12 -0
- package/dist/commands/explain.js +233 -0
- package/dist/commands/fix.d.ts +13 -0
- package/dist/commands/fix.js +668 -0
- package/dist/commands/health.d.ts +11 -0
- package/dist/commands/health.js +367 -0
- package/dist/commands/history.d.ts +10 -0
- package/dist/commands/history.js +191 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +164 -0
- package/dist/commands/learn.d.ts +8 -0
- package/dist/commands/learn.js +592 -0
- package/dist/commands/pr-check.d.ts +12 -0
- package/dist/commands/pr-check.js +270 -0
- package/dist/commands/report.d.ts +11 -0
- package/dist/commands/report.js +375 -0
- package/dist/commands/scan-storybook.d.ts +18 -0
- package/dist/commands/scan-storybook.js +402 -0
- package/dist/commands/scan.d.ts +25 -0
- package/dist/commands/scan.js +673 -0
- package/dist/commands/stats.d.ts +5 -0
- package/dist/commands/stats.js +137 -0
- package/dist/commands/tree.d.ts +12 -0
- package/dist/commands/tree.js +635 -0
- package/dist/commands/triage.d.ts +13 -0
- package/dist/commands/triage.js +327 -0
- package/dist/commands/watch.d.ts +17 -0
- package/dist/commands/watch.js +302 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/types/index.js +4 -0
- package/dist/utils/baseline.d.ts +62 -0
- package/dist/utils/baseline.js +169 -0
- package/dist/utils/browser.d.ts +78 -0
- package/dist/utils/browser.js +239 -0
- package/dist/utils/cache.d.ts +76 -0
- package/dist/utils/cache.js +178 -0
- package/dist/utils/config.d.ts +102 -0
- package/dist/utils/config.js +237 -0
- package/dist/utils/converters.d.ts +77 -0
- package/dist/utils/converters.js +200 -0
- package/dist/utils/copilot.d.ts +36 -0
- package/dist/utils/copilot.js +139 -0
- package/dist/utils/detect.d.ts +22 -0
- package/dist/utils/detect.js +197 -0
- package/dist/utils/enhanced-errors.d.ts +46 -0
- package/dist/utils/enhanced-errors.js +295 -0
- package/dist/utils/errors.d.ts +31 -0
- package/dist/utils/errors.js +149 -0
- package/dist/utils/fix-patterns.d.ts +56 -0
- package/dist/utils/fix-patterns.js +529 -0
- package/dist/utils/history-tracking.d.ts +94 -0
- package/dist/utils/history-tracking.js +230 -0
- package/dist/utils/history.d.ts +42 -0
- package/dist/utils/history.js +255 -0
- package/dist/utils/impact-scores.d.ts +44 -0
- package/dist/utils/impact-scores.js +257 -0
- package/dist/utils/retry.d.ts +24 -0
- package/dist/utils/retry.js +76 -0
- package/dist/utils/scanner.d.ts +74 -0
- package/dist/utils/scanner.js +606 -0
- package/dist/utils/scanner.test.d.ts +4 -0
- package/dist/utils/scanner.test.js +162 -0
- package/dist/utils/ui.d.ts +44 -0
- package/dist/utils/ui.js +276 -0
- package/mcp-server/dist/index.d.ts +8 -0
- package/mcp-server/dist/index.js +1923 -0
- package/package.json +88 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally completion command - Generate shell completion scripts
|
|
3
|
+
*/
|
|
4
|
+
// All commands and their options for completion
|
|
5
|
+
const COMMANDS = {
|
|
6
|
+
scan: {
|
|
7
|
+
description: 'Scan files for accessibility violations',
|
|
8
|
+
options: ['-o', '--output', '-u', '--url', '-j', '--json', '-f', '--format', '-v', '--verbose', '-t', '--threshold', '--ci', '-F', '--fail-on', '-S', '--simulate', '-s', '--standard', '-T', '--timeout'],
|
|
9
|
+
},
|
|
10
|
+
explain: {
|
|
11
|
+
description: 'Get plain-language explanations of violations',
|
|
12
|
+
options: ['-i', '--input', '-s', '--severity', '-l', '--limit'],
|
|
13
|
+
},
|
|
14
|
+
fix: {
|
|
15
|
+
description: 'Fix accessibility issues using Copilot',
|
|
16
|
+
options: ['-i', '--input', '-s', '--severity', '-a', '--auto', '-d', '--dry-run'],
|
|
17
|
+
},
|
|
18
|
+
report: {
|
|
19
|
+
description: 'Generate accessibility report',
|
|
20
|
+
options: ['-i', '--input', '-o', '--output', '-f', '--format'],
|
|
21
|
+
},
|
|
22
|
+
init: {
|
|
23
|
+
description: 'Initialize ally in your project',
|
|
24
|
+
options: ['-f', '--force', '-H', '--hooks'],
|
|
25
|
+
},
|
|
26
|
+
stats: {
|
|
27
|
+
description: 'Show accessibility progress dashboard',
|
|
28
|
+
options: [],
|
|
29
|
+
},
|
|
30
|
+
badge: {
|
|
31
|
+
description: 'Generate accessibility score badge',
|
|
32
|
+
options: ['-i', '--input', '-f', '--format', '-o', '--output'],
|
|
33
|
+
},
|
|
34
|
+
watch: {
|
|
35
|
+
description: 'Watch for file changes and scan continuously',
|
|
36
|
+
options: ['-d', '--debounce', '--clear'],
|
|
37
|
+
},
|
|
38
|
+
learn: {
|
|
39
|
+
description: 'Learn about WCAG accessibility criteria',
|
|
40
|
+
options: ['-l', '--list'],
|
|
41
|
+
},
|
|
42
|
+
crawl: {
|
|
43
|
+
description: 'Crawl website and scan each page',
|
|
44
|
+
options: ['-d', '--depth', '-l', '--limit', '--same-origin', '--no-same-origin', '-o', '--output'],
|
|
45
|
+
},
|
|
46
|
+
tree: {
|
|
47
|
+
description: 'Display accessibility tree for a URL',
|
|
48
|
+
options: ['-d', '--depth', '-r', '--role', '-j', '--json'],
|
|
49
|
+
},
|
|
50
|
+
triage: {
|
|
51
|
+
description: 'Interactively categorize accessibility issues',
|
|
52
|
+
options: ['-i', '--input'],
|
|
53
|
+
},
|
|
54
|
+
'pr-check': {
|
|
55
|
+
description: 'Post accessibility results to GitHub PR',
|
|
56
|
+
options: ['-i', '--input', '-p', '--pr', '--no-comment', '-F', '--fail-on'],
|
|
57
|
+
},
|
|
58
|
+
completion: {
|
|
59
|
+
description: 'Generate shell completion script',
|
|
60
|
+
options: [],
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const commandNames = Object.keys(COMMANDS);
|
|
64
|
+
function generateBashCompletion() {
|
|
65
|
+
return `# Bash completion for ally
|
|
66
|
+
# Add to ~/.bashrc or ~/.bash_profile:
|
|
67
|
+
# eval "$(ally completion bash)"
|
|
68
|
+
|
|
69
|
+
_ally_completions() {
|
|
70
|
+
local cur prev commands
|
|
71
|
+
COMPREPLY=()
|
|
72
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
73
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
74
|
+
|
|
75
|
+
commands="${commandNames.join(' ')}"
|
|
76
|
+
|
|
77
|
+
# Complete commands
|
|
78
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
79
|
+
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
|
|
80
|
+
return 0
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
# Complete options based on command
|
|
84
|
+
case "\${COMP_WORDS[1]}" in
|
|
85
|
+
${Object.entries(COMMANDS).map(([cmd, info]) => ` ${cmd})
|
|
86
|
+
COMPREPLY=( $(compgen -W "${info.options.join(' ')}" -- "\${cur}") )
|
|
87
|
+
;;`).join('\n')}
|
|
88
|
+
esac
|
|
89
|
+
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
complete -F _ally_completions ally
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
function generateZshCompletion() {
|
|
97
|
+
return `#compdef ally
|
|
98
|
+
# Zsh completion for ally
|
|
99
|
+
# Add to ~/.zshrc:
|
|
100
|
+
# eval "$(ally completion zsh)"
|
|
101
|
+
|
|
102
|
+
_ally() {
|
|
103
|
+
local -a commands
|
|
104
|
+
commands=(
|
|
105
|
+
${Object.entries(COMMANDS).map(([cmd, info]) => ` '${cmd}:${info.description}'`).join('\n')}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_arguments -C \\
|
|
109
|
+
'1:command:->command' \\
|
|
110
|
+
'*::options:->options'
|
|
111
|
+
|
|
112
|
+
case $state in
|
|
113
|
+
command)
|
|
114
|
+
_describe 'command' commands
|
|
115
|
+
;;
|
|
116
|
+
options)
|
|
117
|
+
case $words[1] in
|
|
118
|
+
${Object.entries(COMMANDS).map(([cmd, info]) => ` ${cmd})
|
|
119
|
+
_arguments \\
|
|
120
|
+
${info.options.filter(o => o.startsWith('--')).map(opt => ` '${opt}[${opt.replace('--', '')}]'`).join(' \\\n') || " '*:'"}
|
|
121
|
+
;;`).join('\n')}
|
|
122
|
+
esac
|
|
123
|
+
;;
|
|
124
|
+
esac
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
compdef _ally ally
|
|
128
|
+
`;
|
|
129
|
+
}
|
|
130
|
+
function generateFishCompletion() {
|
|
131
|
+
return `# Fish completion for ally
|
|
132
|
+
# Add to ~/.config/fish/completions/ally.fish:
|
|
133
|
+
# ally completion fish > ~/.config/fish/completions/ally.fish
|
|
134
|
+
|
|
135
|
+
# Disable file completions for ally
|
|
136
|
+
complete -c ally -f
|
|
137
|
+
|
|
138
|
+
# Commands
|
|
139
|
+
${Object.entries(COMMANDS).map(([cmd, info]) => `complete -c ally -n "__fish_use_subcommand" -a "${cmd}" -d "${info.description}"`).join('\n')}
|
|
140
|
+
|
|
141
|
+
# Options for each command
|
|
142
|
+
${Object.entries(COMMANDS).map(([cmd, info]) => info.options.filter(o => o.startsWith('--')).map(opt => `complete -c ally -n "__fish_seen_subcommand_from ${cmd}" -l "${opt.replace('--', '')}" -d "${opt.replace('--', '')}"`).join('\n')).filter(Boolean).join('\n')}
|
|
143
|
+
`;
|
|
144
|
+
}
|
|
145
|
+
export async function completionCommand(shell) {
|
|
146
|
+
const validShells = ['bash', 'zsh', 'fish'];
|
|
147
|
+
if (!shell) {
|
|
148
|
+
console.log(`Usage: ally completion <shell>
|
|
149
|
+
|
|
150
|
+
Generate shell completion scripts.
|
|
151
|
+
|
|
152
|
+
Supported shells:
|
|
153
|
+
bash Bash completion script
|
|
154
|
+
zsh Zsh completion script
|
|
155
|
+
fish Fish completion script
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
# Bash - add to ~/.bashrc
|
|
159
|
+
eval "$(ally completion bash)"
|
|
160
|
+
|
|
161
|
+
# Zsh - add to ~/.zshrc
|
|
162
|
+
eval "$(ally completion zsh)"
|
|
163
|
+
|
|
164
|
+
# Fish - save to completions directory
|
|
165
|
+
ally completion fish > ~/.config/fish/completions/ally.fish
|
|
166
|
+
`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!validShells.includes(shell)) {
|
|
170
|
+
console.error(`Unknown shell: ${shell}`);
|
|
171
|
+
console.error(`Supported shells: ${validShells.join(', ')}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
switch (shell) {
|
|
175
|
+
case 'bash':
|
|
176
|
+
console.log(generateBashCompletion());
|
|
177
|
+
break;
|
|
178
|
+
case 'zsh':
|
|
179
|
+
console.log(generateZshCompletion());
|
|
180
|
+
break;
|
|
181
|
+
case 'fish':
|
|
182
|
+
console.log(generateFishCompletion());
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally crawl command - Crawls entire websites by following links
|
|
3
|
+
*/
|
|
4
|
+
import type { AllyReport } from '../types/index.js';
|
|
5
|
+
interface CrawlCommandOptions {
|
|
6
|
+
depth?: number;
|
|
7
|
+
limit?: number;
|
|
8
|
+
sameOrigin?: boolean;
|
|
9
|
+
output?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function crawlCommand(startUrl: string, options?: CrawlCommandOptions): Promise<AllyReport | null>;
|
|
12
|
+
export default crawlCommand;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally crawl command - Crawls entire websites by following links
|
|
3
|
+
*/
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { AccessibilityScanner, createReport, calculateScore } from '../utils/scanner.js';
|
|
8
|
+
import { printBanner, createSpinner, printSummary, printSuccess, printError, printInfo, printWarning, } from '../utils/ui.js';
|
|
9
|
+
import { withRetry } from '../utils/retry.js';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
/**
|
|
12
|
+
* Normalize URL by removing hash and trailing slash
|
|
13
|
+
*/
|
|
14
|
+
function normalizeUrl(url) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(url);
|
|
17
|
+
// Remove hash
|
|
18
|
+
parsed.hash = '';
|
|
19
|
+
// Remove trailing slash (except for root)
|
|
20
|
+
let normalized = parsed.toString();
|
|
21
|
+
if (normalized.endsWith('/') && parsed.pathname !== '/') {
|
|
22
|
+
normalized = normalized.slice(0, -1);
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Check if a URL is same-origin as the base URL
|
|
32
|
+
*/
|
|
33
|
+
function isSameOrigin(baseUrl, targetUrl) {
|
|
34
|
+
try {
|
|
35
|
+
const base = new URL(baseUrl);
|
|
36
|
+
const target = new URL(targetUrl);
|
|
37
|
+
return base.origin === target.origin;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if a URL should be crawled (is a valid page URL)
|
|
45
|
+
*/
|
|
46
|
+
function isValidPageUrl(url) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = new URL(url);
|
|
49
|
+
// Only HTTP/HTTPS
|
|
50
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
// Block localhost and private IPs for security (except for explicit localhost scans)
|
|
54
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
55
|
+
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
|
|
56
|
+
// Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
|
|
57
|
+
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
58
|
+
if (ipMatch) {
|
|
59
|
+
const [, a, b] = ipMatch.map(Number);
|
|
60
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Skip common non-page extensions
|
|
66
|
+
const skipExtensions = [
|
|
67
|
+
'.pdf', '.zip', '.tar', '.gz', '.rar',
|
|
68
|
+
'.jpg', '.jpeg', '.png', '.gif', '.svg', '.ico', '.webp',
|
|
69
|
+
'.mp3', '.mp4', '.avi', '.mov', '.wmv',
|
|
70
|
+
'.css', '.js', '.json', '.xml',
|
|
71
|
+
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
72
|
+
];
|
|
73
|
+
const pathname = parsed.pathname.toLowerCase();
|
|
74
|
+
for (const ext of skipExtensions) {
|
|
75
|
+
if (pathname.endsWith(ext)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Format a page result line for console output
|
|
87
|
+
*/
|
|
88
|
+
function formatPageResult(progress) {
|
|
89
|
+
const { current, total, url, violations, score } = progress;
|
|
90
|
+
// Truncate URL if too long
|
|
91
|
+
const maxUrlLength = 50;
|
|
92
|
+
let displayUrl = url;
|
|
93
|
+
if (displayUrl.length > maxUrlLength) {
|
|
94
|
+
displayUrl = displayUrl.slice(0, maxUrlLength - 3) + '...';
|
|
95
|
+
}
|
|
96
|
+
// Color based on score
|
|
97
|
+
let scoreColor = chalk.green;
|
|
98
|
+
if (score < 50)
|
|
99
|
+
scoreColor = chalk.red;
|
|
100
|
+
else if (score < 75)
|
|
101
|
+
scoreColor = chalk.yellow;
|
|
102
|
+
const violationText = violations === 0
|
|
103
|
+
? chalk.green('0 violations')
|
|
104
|
+
: violations === 1
|
|
105
|
+
? chalk.yellow('1 violation')
|
|
106
|
+
: chalk.yellow(`${violations} violations`);
|
|
107
|
+
return `[${current}/${total}] ${displayUrl} - ${violationText} (score: ${scoreColor(score.toString())})`;
|
|
108
|
+
}
|
|
109
|
+
export async function crawlCommand(startUrl, options = {}) {
|
|
110
|
+
printBanner();
|
|
111
|
+
const { depth = 2, limit = 10, sameOrigin = true, output = '.ally', } = options;
|
|
112
|
+
// Validate URL
|
|
113
|
+
let normalizedStartUrl;
|
|
114
|
+
try {
|
|
115
|
+
normalizedStartUrl = normalizeUrl(startUrl);
|
|
116
|
+
new URL(normalizedStartUrl);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
printError(`Invalid URL: ${startUrl}`);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
console.log(chalk.cyan(`Crawling ${normalizedStartUrl} (depth: ${depth}, limit: ${limit})`));
|
|
123
|
+
console.log();
|
|
124
|
+
const scanner = new AccessibilityScanner();
|
|
125
|
+
const visited = new Set();
|
|
126
|
+
const toVisit = [{ url: normalizedStartUrl, depth: 0 }];
|
|
127
|
+
const results = [];
|
|
128
|
+
try {
|
|
129
|
+
await scanner.init();
|
|
130
|
+
while (toVisit.length > 0 && results.length < limit) {
|
|
131
|
+
const next = toVisit.shift();
|
|
132
|
+
if (!next)
|
|
133
|
+
break;
|
|
134
|
+
const { url, depth: currentDepth } = next;
|
|
135
|
+
const normalized = normalizeUrl(url);
|
|
136
|
+
// Skip if already visited
|
|
137
|
+
if (visited.has(normalized)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
visited.add(normalized);
|
|
141
|
+
// Skip if not same origin and sameOrigin is enabled
|
|
142
|
+
if (sameOrigin && !isSameOrigin(normalizedStartUrl, normalized)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// Skip if not a valid page URL
|
|
146
|
+
if (!isValidPageUrl(normalized)) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const pageNum = results.length + 1;
|
|
150
|
+
const spinner = createSpinner(`[${pageNum}/${limit}] Scanning ${normalized}...`);
|
|
151
|
+
spinner.start();
|
|
152
|
+
try {
|
|
153
|
+
// Scan the page for accessibility issues with retry for transient errors
|
|
154
|
+
const result = await withRetry(() => scanner.scanUrl(normalized), {
|
|
155
|
+
maxRetries: 3,
|
|
156
|
+
baseDelayMs: 1000,
|
|
157
|
+
onRetry: (attempt, error, delayMs) => {
|
|
158
|
+
spinner.stop();
|
|
159
|
+
printWarning(`Retry ${attempt}/3 for ${normalized} after ${delayMs / 1000}s (${error.message})`);
|
|
160
|
+
spinner.start();
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
results.push(result);
|
|
164
|
+
// Calculate score for this single result
|
|
165
|
+
const pageScore = calculateScore([result]);
|
|
166
|
+
spinner.stop();
|
|
167
|
+
console.log(formatPageResult({
|
|
168
|
+
current: pageNum,
|
|
169
|
+
total: limit,
|
|
170
|
+
url: normalized,
|
|
171
|
+
violations: result.violations.length,
|
|
172
|
+
score: pageScore,
|
|
173
|
+
}));
|
|
174
|
+
// Extract links if we haven't reached max depth
|
|
175
|
+
if (currentDepth < depth) {
|
|
176
|
+
const links = await scanner.extractLinks(normalized);
|
|
177
|
+
// Add new links to the queue
|
|
178
|
+
for (const link of links) {
|
|
179
|
+
const normalizedLink = normalizeUrl(link);
|
|
180
|
+
if (!visited.has(normalizedLink) && isValidPageUrl(normalizedLink)) {
|
|
181
|
+
if (!sameOrigin || isSameOrigin(normalizedStartUrl, normalizedLink)) {
|
|
182
|
+
// Check if already in queue
|
|
183
|
+
const alreadyQueued = toVisit.some(item => normalizeUrl(item.url) === normalizedLink);
|
|
184
|
+
if (!alreadyQueued) {
|
|
185
|
+
toVisit.push({ url: normalizedLink, depth: currentDepth + 1 });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
spinner.fail(`Failed to scan ${normalized}`);
|
|
194
|
+
printError(error instanceof Error ? error.message : String(error));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
console.log();
|
|
198
|
+
if (results.length === 0) {
|
|
199
|
+
printError('No pages were successfully scanned');
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
// Create combined report
|
|
203
|
+
const report = createReport(results);
|
|
204
|
+
// Print crawl summary
|
|
205
|
+
console.log(chalk.bold(`Crawl complete: ${results.length} page${results.length === 1 ? '' : 's'} scanned`));
|
|
206
|
+
// Calculate totals
|
|
207
|
+
const totalViolations = results.reduce((sum, r) => sum + r.violations.length, 0);
|
|
208
|
+
const avgScore = Math.round(results.reduce((sum, r) => sum + calculateScore([r]), 0) / results.length);
|
|
209
|
+
console.log(chalk.dim(`Total violations: ${totalViolations}`));
|
|
210
|
+
console.log(chalk.dim(`Average score: ${avgScore}`));
|
|
211
|
+
console.log();
|
|
212
|
+
// Print full summary
|
|
213
|
+
printSummary(report.summary);
|
|
214
|
+
// Save report
|
|
215
|
+
await saveReport(report, output, normalizedStartUrl);
|
|
216
|
+
return report;
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
printError(`Crawl failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
await scanner.close();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function saveReport(report, outputDir, startUrl) {
|
|
227
|
+
try {
|
|
228
|
+
// Ensure output directory exists
|
|
229
|
+
if (!existsSync(outputDir)) {
|
|
230
|
+
await mkdir(outputDir, { recursive: true });
|
|
231
|
+
}
|
|
232
|
+
// Generate filename from URL
|
|
233
|
+
const urlObj = new URL(startUrl);
|
|
234
|
+
const safeHost = urlObj.hostname.replace(/[^a-z0-9]/gi, '-');
|
|
235
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
236
|
+
// Save crawl report
|
|
237
|
+
const reportPath = resolve(outputDir, `crawl-${safeHost}-${timestamp}.json`);
|
|
238
|
+
await writeFile(reportPath, JSON.stringify(report, null, 2));
|
|
239
|
+
printSuccess(`Crawl report saved to ${reportPath}`);
|
|
240
|
+
// Also save as the default scan.json for compatibility with other commands
|
|
241
|
+
const defaultPath = resolve(outputDir, 'scan.json');
|
|
242
|
+
await writeFile(defaultPath, JSON.stringify(report, null, 2));
|
|
243
|
+
printInfo(`Also saved to ${defaultPath} for use with other ally commands`);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
printError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export default crawlCommand;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ally doctor command - Diagnoses installation and configuration issues
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { resolve, join, dirname } from 'path';
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { createRequire } from 'module';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { printBanner } from '../utils/ui.js';
|
|
11
|
+
// ESM equivalent of __dirname
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
/**
|
|
15
|
+
* Get the installed Node.js version
|
|
16
|
+
*/
|
|
17
|
+
function getNodeVersion() {
|
|
18
|
+
return process.version.replace('v', '');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Parse a semver string and return major version number
|
|
22
|
+
*/
|
|
23
|
+
function parseMajorVersion(version) {
|
|
24
|
+
const match = version.match(/^(\d+)/);
|
|
25
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if a command exists
|
|
29
|
+
*/
|
|
30
|
+
function commandExists(command) {
|
|
31
|
+
try {
|
|
32
|
+
execSync(`which ${command}`, { stdio: 'ignore' });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get ally version from package.json
|
|
41
|
+
*/
|
|
42
|
+
function getAllyVersion() {
|
|
43
|
+
try {
|
|
44
|
+
// Use createRequire to read package.json (same approach as cli.ts)
|
|
45
|
+
const require = createRequire(import.meta.url);
|
|
46
|
+
const { version } = require('../../package.json');
|
|
47
|
+
return version || 'unknown';
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Fallback: try to get from local package.json
|
|
51
|
+
try {
|
|
52
|
+
const localPkgPath = resolve(process.cwd(), 'package.json');
|
|
53
|
+
if (existsSync(localPkgPath)) {
|
|
54
|
+
const pkg = JSON.parse(readFileSync(localPkgPath, 'utf-8'));
|
|
55
|
+
if (pkg.name === 'ally-a11y') {
|
|
56
|
+
return pkg.version || 'unknown';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return 'unknown';
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Check if Puppeteer browser is installed
|
|
68
|
+
*/
|
|
69
|
+
async function checkPuppeteerBrowser() {
|
|
70
|
+
try {
|
|
71
|
+
// Try to import puppeteer and check for browser
|
|
72
|
+
const puppeteer = await import('puppeteer');
|
|
73
|
+
const browser = await puppeteer.default.launch({
|
|
74
|
+
headless: true,
|
|
75
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
76
|
+
});
|
|
77
|
+
await browser.close();
|
|
78
|
+
return { status: 'pass', message: 'Puppeteer browser installed' };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
82
|
+
if (errorMsg.includes('Could not find Chrome') || errorMsg.includes('Could not find Chromium')) {
|
|
83
|
+
return { status: 'fail', message: 'Puppeteer browser not installed. Run: npx puppeteer browsers install chrome' };
|
|
84
|
+
}
|
|
85
|
+
return { status: 'fail', message: `Puppeteer browser check failed: ${errorMsg}` };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Check if MCP server is configured
|
|
90
|
+
*/
|
|
91
|
+
function checkMcpServer() {
|
|
92
|
+
const cwd = process.cwd();
|
|
93
|
+
// Check for mcp-server in node_modules (installed package)
|
|
94
|
+
const nodeModulesPath = join(cwd, 'node_modules', 'ally-a11y', 'mcp-server', 'dist', 'index.js');
|
|
95
|
+
if (existsSync(nodeModulesPath)) {
|
|
96
|
+
return { status: 'pass', message: 'MCP server configured (via node_modules)' };
|
|
97
|
+
}
|
|
98
|
+
// Check for local mcp-server (development)
|
|
99
|
+
const localPath = join(cwd, 'mcp-server', 'dist', 'index.js');
|
|
100
|
+
if (existsSync(localPath)) {
|
|
101
|
+
return { status: 'pass', message: 'MCP server configured (local development)' };
|
|
102
|
+
}
|
|
103
|
+
// Check if .copilot/mcp-config.json exists
|
|
104
|
+
const mcpConfigPath = join(cwd, '.copilot', 'mcp-config.json');
|
|
105
|
+
if (existsSync(mcpConfigPath)) {
|
|
106
|
+
try {
|
|
107
|
+
const config = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
|
|
108
|
+
if (config.mcpServers && config.mcpServers['ally-patterns']) {
|
|
109
|
+
return { status: 'warn', message: 'MCP config exists but server files not found. Run: npm run build:all' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { status: 'warn', message: 'MCP config file invalid' };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { status: 'info', message: 'MCP server not configured. Run: ally init' };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if .allyrc.json exists and is valid
|
|
120
|
+
*/
|
|
121
|
+
function checkAllyConfig() {
|
|
122
|
+
const cwd = process.cwd();
|
|
123
|
+
const configPath = join(cwd, '.allyrc.json');
|
|
124
|
+
if (!existsSync(configPath)) {
|
|
125
|
+
return { status: 'info', message: '.allyrc.json not found (using defaults)' };
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
129
|
+
JSON.parse(content);
|
|
130
|
+
return { status: 'pass', message: '.allyrc.json valid' };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
134
|
+
return { status: 'fail', message: `.allyrc.json invalid JSON: ${errorMsg}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Check if .allyignore exists
|
|
139
|
+
*/
|
|
140
|
+
function checkAllyIgnore() {
|
|
141
|
+
const cwd = process.cwd();
|
|
142
|
+
const ignorePath = join(cwd, '.allyignore');
|
|
143
|
+
if (existsSync(ignorePath)) {
|
|
144
|
+
return { status: 'pass', message: '.allyignore found' };
|
|
145
|
+
}
|
|
146
|
+
return { status: 'info', message: '.allyignore not found (optional)' };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Print a check result with appropriate styling
|
|
150
|
+
*/
|
|
151
|
+
function printCheck(result) {
|
|
152
|
+
const icons = {
|
|
153
|
+
pass: chalk.green(' '),
|
|
154
|
+
warn: chalk.yellow(' '),
|
|
155
|
+
fail: chalk.red(' '),
|
|
156
|
+
info: chalk.blue(' '),
|
|
157
|
+
};
|
|
158
|
+
const colors = {
|
|
159
|
+
pass: chalk.green,
|
|
160
|
+
warn: chalk.yellow,
|
|
161
|
+
fail: chalk.red,
|
|
162
|
+
info: chalk.blue,
|
|
163
|
+
};
|
|
164
|
+
console.log(`${icons[result.status]} ${colors[result.status](result.message)}`);
|
|
165
|
+
}
|
|
166
|
+
export async function doctorCommand() {
|
|
167
|
+
printBanner();
|
|
168
|
+
console.log(chalk.bold.cyan('Checking ally installation...\n'));
|
|
169
|
+
const results = [];
|
|
170
|
+
let hasFailures = false;
|
|
171
|
+
// 1. Check Node.js version
|
|
172
|
+
const nodeVersion = getNodeVersion();
|
|
173
|
+
const nodeMajor = parseMajorVersion(nodeVersion);
|
|
174
|
+
if (nodeMajor >= 18) {
|
|
175
|
+
results.push({ status: 'pass', message: `Node.js ${nodeVersion} (>=18 required)` });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
results.push({ status: 'fail', message: `Node.js ${nodeVersion} (>=18 required)` });
|
|
179
|
+
hasFailures = true;
|
|
180
|
+
}
|
|
181
|
+
// 2. Check ally version
|
|
182
|
+
const allyVersion = getAllyVersion();
|
|
183
|
+
if (allyVersion !== 'unknown') {
|
|
184
|
+
results.push({ status: 'pass', message: `ally v${allyVersion} installed` });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
results.push({ status: 'warn', message: 'ally version unknown' });
|
|
188
|
+
}
|
|
189
|
+
// 3. Check Puppeteer browser
|
|
190
|
+
const puppeteerResult = await checkPuppeteerBrowser();
|
|
191
|
+
results.push(puppeteerResult);
|
|
192
|
+
if (puppeteerResult.status === 'fail') {
|
|
193
|
+
hasFailures = true;
|
|
194
|
+
}
|
|
195
|
+
// 4. Check GitHub Copilot CLI
|
|
196
|
+
if (commandExists('copilot')) {
|
|
197
|
+
results.push({ status: 'pass', message: 'GitHub Copilot CLI available' });
|
|
198
|
+
}
|
|
199
|
+
else if (commandExists('gh') && commandExists('gh-copilot')) {
|
|
200
|
+
results.push({ status: 'pass', message: 'GitHub Copilot CLI available (gh extension)' });
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
results.push({ status: 'warn', message: 'GitHub Copilot CLI not found (optional)' });
|
|
204
|
+
}
|
|
205
|
+
// 5. Check MCP server
|
|
206
|
+
const mcpResult = checkMcpServer();
|
|
207
|
+
results.push(mcpResult);
|
|
208
|
+
if (mcpResult.status === 'fail') {
|
|
209
|
+
hasFailures = true;
|
|
210
|
+
}
|
|
211
|
+
// 6. Check .allyrc.json
|
|
212
|
+
const configResult = checkAllyConfig();
|
|
213
|
+
results.push(configResult);
|
|
214
|
+
if (configResult.status === 'fail') {
|
|
215
|
+
hasFailures = true;
|
|
216
|
+
}
|
|
217
|
+
// 7. Check .allyignore
|
|
218
|
+
const ignoreResult = checkAllyIgnore();
|
|
219
|
+
results.push(ignoreResult);
|
|
220
|
+
// Print all results
|
|
221
|
+
for (const result of results) {
|
|
222
|
+
printCheck(result);
|
|
223
|
+
}
|
|
224
|
+
// Print summary
|
|
225
|
+
console.log();
|
|
226
|
+
if (hasFailures) {
|
|
227
|
+
console.log(chalk.red.bold('Some checks failed. Please fix the issues above.'));
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
console.log(chalk.green.bold('All checks passed! ally is ready to use.'));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export default doctorCommand;
|