assessify 0.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.
- package/README.md +56 -0
- package/bin/cli.js +13 -0
- package/package.json +30 -0
- package/src/cli.ts +186 -0
- package/src/core/config.ts +117 -0
- package/src/core/git.ts +91 -0
- package/src/core/llm.ts +354 -0
- package/src/core/loopPrevention.ts +79 -0
- package/src/core/patcher.ts +83 -0
- package/src/hooks/installer.ts +57 -0
- package/src/index.ts +138 -0
- package/src/utils/checksum.ts +5 -0
- package/src/utils/logger.ts +92 -0
- package/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Assessify
|
|
2
|
+
|
|
3
|
+
This repo contains Assessify, an accessibility fixer for Git hooks and local files.
|
|
4
|
+
|
|
5
|
+
Quick start (using Bun):
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# install dependencies with bun
|
|
9
|
+
bun install
|
|
10
|
+
|
|
11
|
+
# run in dev (TypeScript run via bun)
|
|
12
|
+
bun run src/index.ts
|
|
13
|
+
|
|
14
|
+
# initialize a project
|
|
15
|
+
bun run src/cli.ts init
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Publish To npm
|
|
19
|
+
|
|
20
|
+
To publish Assessify on npm:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm login
|
|
24
|
+
npm run build
|
|
25
|
+
npm version patch
|
|
26
|
+
npm publish
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If you want to test the package first, run `npm pack` to create a local tarball and inspect the published contents before releasing.
|
|
30
|
+
|
|
31
|
+
## Use In A Project
|
|
32
|
+
|
|
33
|
+
A developer can add Assessify to a frontend project with npm:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install --save-dev assessify
|
|
37
|
+
npx assessify init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
During `init`, Assessify asks for the API key and other config details, writes `.assessify.json`, and installs the selected Git hook. For frontend projects the default is `pre-commit`, so the scan runs automatically before every commit.
|
|
41
|
+
|
|
42
|
+
What happens on commit:
|
|
43
|
+
|
|
44
|
+
1. Git calls the installed `pre-commit` hook.
|
|
45
|
+
2. The hook runs `npx assessify fix --stage staged`.
|
|
46
|
+
3. Assessify scans the configured file types, checks accessibility issues, and applies fixes when possible.
|
|
47
|
+
4. If changes are made, the hook stages them so the commit includes the updates.
|
|
48
|
+
|
|
49
|
+
If the developer wants push-time checks too, `assessify init` can install `pre-push` or both hooks instead of only `pre-commit`.
|
|
50
|
+
|
|
51
|
+
Next steps:
|
|
52
|
+
- Run `assessify init` in a frontend project to collect API key and config details, then install the chosen hook
|
|
53
|
+
- Configure your API key with `--api-key`, `A11Y_CLI_API_KEY`, or the key in `.assessify.json`
|
|
54
|
+
- Implement LLM integration in `src/core/llm.ts`
|
|
55
|
+
- Implement patcher in `src/core/patcher.ts`
|
|
56
|
+
- Flesh out CLI in `bin/cli.js` or TypeScript source
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { spawnSync } = require('child_process');
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
|
|
5
|
+
if (args.length === 0) {
|
|
6
|
+
console.log('assessify: available commands: init, install, fix');
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Prefer running via bun if available
|
|
11
|
+
const runner = 'bun';
|
|
12
|
+
const res = spawnSync(runner, ['run', 'src/cli.ts', ...args], { stdio: 'inherit' });
|
|
13
|
+
process.exit(res.status ?? 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "assessify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Git hook based accessibility fixer with LLM integration",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"assessify": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "bun run src/index.ts",
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"test": "echo \"No tests yet\"",
|
|
13
|
+
"install-hooks": "node ./bin/cli.js install"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@anthropic-ai/sdk": "^0.21.0",
|
|
17
|
+
"@types/yargs": "^17.0.35",
|
|
18
|
+
"chalk": "^5.0.0",
|
|
19
|
+
"dotenv": "^16.3.0",
|
|
20
|
+
"simple-git": "^3.20.0",
|
|
21
|
+
"yargs": "^18.0.0"
|
|
22
|
+
},
|
|
23
|
+
"optionalDependencies": {
|
|
24
|
+
"openai": "^4.11.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.3.0",
|
|
28
|
+
"@types/node": "^20.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
import yargs from 'yargs';
|
|
5
|
+
import { hideBin } from 'yargs/helpers';
|
|
6
|
+
import { AccessibilityTool } from './index';
|
|
7
|
+
import { saveConfig, type AppConfig } from './core/config';
|
|
8
|
+
import { installHooks } from './hooks/installer';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_EXTENSIONS = '.html,.htm,.jsx,.tsx';
|
|
11
|
+
const DEFAULT_MODEL = 'asi1-mini';
|
|
12
|
+
const DEFAULT_BASE_URL = 'https://api.asi1.ai/v1';
|
|
13
|
+
|
|
14
|
+
type HookType = AppConfig['hook'];
|
|
15
|
+
|
|
16
|
+
function normalizeHook(value: string, fallback: HookType = 'pre-push'): HookType {
|
|
17
|
+
const normalized = value.trim().toLowerCase();
|
|
18
|
+
if (normalized === 'pre-commit' || normalized === 'pre-push' || normalized === 'both') {
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeLevel(value: string, fallback: AppConfig['logging']['level'] = 'info'): AppConfig['logging']['level'] {
|
|
26
|
+
const normalized = value.trim().toLowerCase();
|
|
27
|
+
if (normalized === 'debug' || normalized === 'info' || normalized === 'warn' || normalized === 'error') {
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeExtensions(value: string): string[] {
|
|
35
|
+
const parsed = value
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((entry) => entry.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((entry) => (entry.startsWith('.') ? entry : `.${entry}`));
|
|
40
|
+
|
|
41
|
+
return parsed.length > 0 ? parsed : DEFAULT_EXTENSIONS.split(',');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function ask(rl: readline.Interface, question: string, fallback: string): Promise<string> {
|
|
45
|
+
const answer = (await rl.question(`${question} [${fallback}]: `)).trim();
|
|
46
|
+
return answer || fallback;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function runInit(argv: any) {
|
|
50
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY;
|
|
51
|
+
const rl = interactive ? readline.createInterface({ input, output }) : null;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const apiKey = typeof argv.apiKey === 'string' && argv.apiKey.length > 0
|
|
55
|
+
? argv.apiKey
|
|
56
|
+
: rl
|
|
57
|
+
? await ask(rl, 'ASI One API key (leave blank to use env var)', '')
|
|
58
|
+
: '';
|
|
59
|
+
|
|
60
|
+
const model = typeof argv.model === 'string' && argv.model.length > 0
|
|
61
|
+
? argv.model
|
|
62
|
+
: rl
|
|
63
|
+
? await ask(rl, 'Model', DEFAULT_MODEL)
|
|
64
|
+
: DEFAULT_MODEL;
|
|
65
|
+
|
|
66
|
+
const baseUrl = typeof argv.baseUrl === 'string' && argv.baseUrl.length > 0
|
|
67
|
+
? argv.baseUrl
|
|
68
|
+
: rl
|
|
69
|
+
? await ask(rl, 'Base URL', DEFAULT_BASE_URL)
|
|
70
|
+
: DEFAULT_BASE_URL;
|
|
71
|
+
|
|
72
|
+
const hook = normalizeHook(
|
|
73
|
+
typeof argv.hook === 'string' && argv.hook.length > 0
|
|
74
|
+
? argv.hook
|
|
75
|
+
: rl
|
|
76
|
+
? await ask(rl, 'Git hook to install (pre-commit, pre-push, both)', 'pre-commit')
|
|
77
|
+
: 'pre-commit',
|
|
78
|
+
'pre-commit'
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const logLevel = normalizeLevel(
|
|
82
|
+
typeof argv.logLevel === 'string' && argv.logLevel.length > 0
|
|
83
|
+
? argv.logLevel
|
|
84
|
+
: rl
|
|
85
|
+
? await ask(rl, 'Log level', process.env.NODE_ENV === 'production' ? 'info' : 'info')
|
|
86
|
+
: 'info',
|
|
87
|
+
'info'
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const extensions = normalizeExtensions(
|
|
91
|
+
typeof argv.extensions === 'string' && argv.extensions.length > 0
|
|
92
|
+
? argv.extensions
|
|
93
|
+
: rl
|
|
94
|
+
? await ask(rl, 'File extensions to scan', DEFAULT_EXTENSIONS)
|
|
95
|
+
: DEFAULT_EXTENSIONS
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const config: AppConfig = {
|
|
99
|
+
enabled: true,
|
|
100
|
+
hook,
|
|
101
|
+
llm: {
|
|
102
|
+
provider: 'asi1',
|
|
103
|
+
model,
|
|
104
|
+
apiKey: apiKey || undefined,
|
|
105
|
+
apiKeyEnv: 'ASI_ONE_API_KEY',
|
|
106
|
+
baseUrl,
|
|
107
|
+
},
|
|
108
|
+
accessibility: {},
|
|
109
|
+
files: { includeExtensions: extensions },
|
|
110
|
+
loopPrevention: { cacheFile: '.assessify-cache/processed-files.json', maxAttempts: 2 },
|
|
111
|
+
logging: { level: logLevel },
|
|
112
|
+
reporting: {},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
await saveConfig(config);
|
|
116
|
+
await installHooks({ hook: config.hook });
|
|
117
|
+
|
|
118
|
+
console.log('Assessify initialized.');
|
|
119
|
+
console.log(`Config written to .assessify.json with ${config.hook} hook and ${config.files.includeExtensions.length} scan extensions.`);
|
|
120
|
+
if (!apiKey) {
|
|
121
|
+
console.log('No API key saved. Set ASI_ONE_API_KEY or rerun init with a key to enable remote analysis.');
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
rl?.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function main() {
|
|
129
|
+
yargs(hideBin(process.argv))
|
|
130
|
+
.command(
|
|
131
|
+
'init',
|
|
132
|
+
'Initialize Assessify for a project',
|
|
133
|
+
(y) => y
|
|
134
|
+
.option('api-key', { type: 'string', describe: 'Store an API key in the generated config' })
|
|
135
|
+
.option('model', { type: 'string', describe: 'Model name to use for analysis' })
|
|
136
|
+
.option('base-url', { type: 'string', describe: 'Base URL for the API provider' })
|
|
137
|
+
.option('hook', { type: 'string', choices: ['pre-push', 'pre-commit', 'both'] as const, describe: 'Which Git hook(s) to install' })
|
|
138
|
+
.option('log-level', { type: 'string', choices: ['debug', 'info', 'warn', 'error'] as const, describe: 'Initial log level' })
|
|
139
|
+
.option('extensions', { type: 'string', describe: 'Comma-separated file extensions to scan' }),
|
|
140
|
+
async (argv) => {
|
|
141
|
+
await runInit(argv);
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
.command(
|
|
145
|
+
'install',
|
|
146
|
+
'Install Git hooks',
|
|
147
|
+
() => {},
|
|
148
|
+
async () => {
|
|
149
|
+
const tool = new AccessibilityTool();
|
|
150
|
+
await tool.install();
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
.command(
|
|
154
|
+
'fix [files..]',
|
|
155
|
+
'Fix staged or provided files',
|
|
156
|
+
(y) => y.positional('files', { type: 'string', array: true }),
|
|
157
|
+
async (argv) => {
|
|
158
|
+
if (typeof argv.apiKey === 'string' && argv.apiKey.length > 0) {
|
|
159
|
+
process.env.A11Y_CLI_API_KEY = argv.apiKey;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof argv.logLevel === 'string' && argv.logLevel.length > 0) {
|
|
163
|
+
process.env.A11Y_CLI_LOG_LEVEL = argv.logLevel;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const tool = new AccessibilityTool();
|
|
167
|
+
const res = await tool.fix({
|
|
168
|
+
stage: 'staged',
|
|
169
|
+
files: argv.files as string[] | undefined,
|
|
170
|
+
});
|
|
171
|
+
console.log(JSON.stringify(res, null, 2));
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
.option('api-key', {
|
|
175
|
+
type: 'string',
|
|
176
|
+
describe: 'Override the API key used by the LLM client',
|
|
177
|
+
})
|
|
178
|
+
.option('log-level', {
|
|
179
|
+
type: 'string',
|
|
180
|
+
choices: ['debug', 'info', 'warn', 'error'] as const,
|
|
181
|
+
describe: 'Override log verbosity',
|
|
182
|
+
})
|
|
183
|
+
.help().argv;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (require.main === module) main();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
export interface LlmConfig {
|
|
5
|
+
provider: 'asi1' | 'openai' | 'anthropic';
|
|
6
|
+
model: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
apiKeyEnv?: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FileConfig {
|
|
13
|
+
includeExtensions: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LoggingConfig {
|
|
17
|
+
level: 'debug' | 'info' | 'warn' | 'error';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LoopPreventionConfig {
|
|
21
|
+
cacheFile: string;
|
|
22
|
+
maxAttempts: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AppConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
hook: 'pre-commit' | 'pre-push' | 'both';
|
|
28
|
+
llm: LlmConfig;
|
|
29
|
+
accessibility: any;
|
|
30
|
+
files: FileConfig;
|
|
31
|
+
loopPrevention: LoopPreventionConfig;
|
|
32
|
+
logging: LoggingConfig;
|
|
33
|
+
reporting: any;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_PATH = '.assessify.json';
|
|
37
|
+
const LEGACY_PATH = '.a11y-cli.json';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CONFIG: AppConfig = {
|
|
40
|
+
enabled: true,
|
|
41
|
+
hook: 'pre-commit',
|
|
42
|
+
llm: { provider: 'asi1', model: 'asi1-mini', apiKeyEnv: 'ASI_ONE_API_KEY', baseUrl: 'https://api.asi1.ai/v1' },
|
|
43
|
+
accessibility: {},
|
|
44
|
+
files: { includeExtensions: ['.html', '.htm', '.jsx', '.tsx'] },
|
|
45
|
+
loopPrevention: { cacheFile: '.assessify-cache/processed-files.json', maxAttempts: 2 },
|
|
46
|
+
logging: { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' },
|
|
47
|
+
reporting: {},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function normalizeFiles(parsedFiles: any): FileConfig {
|
|
51
|
+
const includeExtensions = Array.isArray(parsedFiles?.includeExtensions)
|
|
52
|
+
? parsedFiles.includeExtensions
|
|
53
|
+
: Array.isArray(parsedFiles?.include)
|
|
54
|
+
? ['.html', '.htm', '.jsx', '.tsx']
|
|
55
|
+
: DEFAULT_CONFIG.files.includeExtensions;
|
|
56
|
+
|
|
57
|
+
return { includeExtensions };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeLoopPrevention(parsedLoopPrevention: any): LoopPreventionConfig {
|
|
61
|
+
if (parsedLoopPrevention?.cacheFile) {
|
|
62
|
+
return {
|
|
63
|
+
cacheFile: parsedLoopPrevention.cacheFile,
|
|
64
|
+
maxAttempts: parsedLoopPrevention.maxAttempts ?? DEFAULT_CONFIG.loopPrevention.maxAttempts,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (parsedLoopPrevention?.storageLocation) {
|
|
69
|
+
return {
|
|
70
|
+
cacheFile: `${parsedLoopPrevention.storageLocation.replace(/\/+$/, '')}/processed-files.json`,
|
|
71
|
+
maxAttempts: parsedLoopPrevention.maxAttempts ?? DEFAULT_CONFIG.loopPrevention.maxAttempts,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return DEFAULT_CONFIG.loopPrevention;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeLogging(parsed: any): LoggingConfig {
|
|
79
|
+
const level = (parsed?.level || '').toLowerCase();
|
|
80
|
+
if (level === 'debug' || level === 'info' || level === 'warn' || level === 'error') {
|
|
81
|
+
return { level };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return DEFAULT_CONFIG.logging;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function loadConfig(path = DEFAULT_PATH): Promise<AppConfig> {
|
|
88
|
+
const candidatePaths = [path, LEGACY_PATH].filter((value, index, list) => list.indexOf(value) === index);
|
|
89
|
+
|
|
90
|
+
for (const candidate of candidatePaths) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await readFile(join(process.cwd(), candidate), 'utf-8');
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
return {
|
|
95
|
+
...DEFAULT_CONFIG,
|
|
96
|
+
...parsed,
|
|
97
|
+
llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
|
|
98
|
+
files: normalizeFiles(parsed.files),
|
|
99
|
+
loopPrevention: normalizeLoopPrevention(parsed.loopPrevention),
|
|
100
|
+
logging: normalizeLogging(parsed.logging ?? { level: parsed.reporting?.logLevel }),
|
|
101
|
+
accessibility: { ...DEFAULT_CONFIG.accessibility, ...(parsed.accessibility ?? {}) },
|
|
102
|
+
reporting: { ...DEFAULT_CONFIG.reporting, ...(parsed.reporting ?? {}) },
|
|
103
|
+
} as AppConfig;
|
|
104
|
+
} catch (e) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return DEFAULT_CONFIG;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function saveConfig(config: AppConfig, path = DEFAULT_PATH): Promise<void> {
|
|
113
|
+
const serialized = `${JSON.stringify(config, null, 2)}\n`;
|
|
114
|
+
await writeFile(join(process.cwd(), path), serialized, 'utf-8');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default loadConfig;
|
package/src/core/git.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import simpleGit, { SimpleGit } from 'simple-git';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
export interface FileDiff {
|
|
5
|
+
filePath: string;
|
|
6
|
+
additions: string[];
|
|
7
|
+
deletions: string[];
|
|
8
|
+
fullDiff: string;
|
|
9
|
+
beforeContent: string;
|
|
10
|
+
afterContent: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class GitManager {
|
|
14
|
+
private git: SimpleGit;
|
|
15
|
+
|
|
16
|
+
constructor(cwd?: string) {
|
|
17
|
+
this.git = simpleGit(cwd);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async isInGitRepository(): Promise<boolean> {
|
|
21
|
+
try {
|
|
22
|
+
return await this.git.checkIsRepo();
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getChangedFiles(stage: 'staged' | 'unpushed' | 'recent' = 'staged'): Promise<string[]> {
|
|
29
|
+
if (stage === 'staged') {
|
|
30
|
+
const output = await this.git.diff(['--name-only', '--cached']);
|
|
31
|
+
return output.split('\n').filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (stage === 'recent') {
|
|
35
|
+
const output = await this.git.raw(['diff', '--name-only', 'HEAD~1..HEAD']).catch(() => '');
|
|
36
|
+
return output.split('\n').filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// unpushed: list commits not pushed and get files
|
|
40
|
+
if (stage === 'unpushed') {
|
|
41
|
+
const output = await this.git.raw(['diff', '--name-only', '@{u}..HEAD']).catch(() => '');
|
|
42
|
+
return output.split('\n').filter(Boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getDiffForFile(filePath: string, stage: 'staged' | 'unpushed' | 'recent' = 'staged'): Promise<FileDiff> {
|
|
49
|
+
const args = stage === 'staged'
|
|
50
|
+
? ['--cached', '--', filePath]
|
|
51
|
+
: stage === 'recent'
|
|
52
|
+
? ['HEAD~1..HEAD', '--', filePath]
|
|
53
|
+
: ['--', filePath];
|
|
54
|
+
const fullDiff = await this.git.diff(args).catch(() => '');
|
|
55
|
+
|
|
56
|
+
const beforeContent = stage === 'recent'
|
|
57
|
+
? await this.getFileContent(filePath, 'HEAD~1').catch(() => '')
|
|
58
|
+
: await this.getFileContent(filePath, 'HEAD~1').catch(() => '');
|
|
59
|
+
const afterContent = await this.getFileContent(filePath).catch(() => '');
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
filePath,
|
|
63
|
+
additions: [],
|
|
64
|
+
deletions: [],
|
|
65
|
+
fullDiff,
|
|
66
|
+
beforeContent,
|
|
67
|
+
afterContent
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async getFileContent(filePath: string, ref?: string): Promise<string> {
|
|
72
|
+
if (ref) {
|
|
73
|
+
try {
|
|
74
|
+
return await this.git.show([`${ref}:${filePath}`]);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// read from disk
|
|
81
|
+
try {
|
|
82
|
+
return await readFile(filePath, 'utf-8');
|
|
83
|
+
} catch (e) {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async stageFile(filePath: string): Promise<void> {
|
|
89
|
+
await this.git.add(filePath);
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/core/llm.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import OpenAI from 'openai';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { logger } from '../utils/logger';
|
|
7
|
+
import { loadConfig } from './config';
|
|
8
|
+
|
|
9
|
+
dotenv.config();
|
|
10
|
+
// Auto-sync frontend environment variables if present
|
|
11
|
+
const frontendEnvLocal = join(process.cwd(), 'frontend', '.env.local');
|
|
12
|
+
const frontendEnv = join(process.cwd(), 'frontend', '.env');
|
|
13
|
+
const parentFrontendEnvLocal = join(process.cwd(), '..', 'frontend', '.env.local');
|
|
14
|
+
if (existsSync(frontendEnvLocal)) {
|
|
15
|
+
dotenv.config({ path: frontendEnvLocal });
|
|
16
|
+
} else if (existsSync(parentFrontendEnvLocal)) {
|
|
17
|
+
dotenv.config({ path: parentFrontendEnvLocal });
|
|
18
|
+
} else if (existsSync(frontendEnv)) {
|
|
19
|
+
dotenv.config({ path: frontendEnv });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let cachedConfig: ReturnType<typeof loadConfig> | null = null;
|
|
23
|
+
|
|
24
|
+
export interface AccessibilityIssue {
|
|
25
|
+
line?: number;
|
|
26
|
+
type: string;
|
|
27
|
+
severity: 'error' | 'warning' | 'info';
|
|
28
|
+
description: string;
|
|
29
|
+
wcagCriteria?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AccessibilityFix {
|
|
33
|
+
issue: AccessibilityIssue;
|
|
34
|
+
suggestedFix: string;
|
|
35
|
+
explanation?: string;
|
|
36
|
+
confidence?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class LLMAccessibilityAnalyzer {
|
|
40
|
+
private systemPrompt = `You are an expert web accessibility auditor. When I send code or diffs, return JSON only with two top-level keys: "issues" (array) and "fixes" (array). Each issue should have type, severity, description, and optional line. Each fix should reference an issue and include suggestedFix as either a unified diff hunk or a replacement payload prefixed with REPLACE_FILE_WITH:. Do not return explanatory text or comments.`;
|
|
41
|
+
|
|
42
|
+
private isHttpStatus(error: any, status: number): boolean {
|
|
43
|
+
return error?.status === status || error?.statusCode === status || error?.response?.status === status;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async getConfig() {
|
|
47
|
+
if (!cachedConfig) {
|
|
48
|
+
cachedConfig = loadConfig();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return cachedConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async createClient(): Promise<OpenAI> {
|
|
55
|
+
const config = await this.getConfig();
|
|
56
|
+
const apiKey = await this.resolveApiKey();
|
|
57
|
+
|
|
58
|
+
const provider = config.llm.provider;
|
|
59
|
+
if (provider === 'anthropic') {
|
|
60
|
+
return new OpenAI({ apiKey });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const baseURL = config.llm.baseUrl || process.env.ASI_ONE_BASE_URL || process.env.OPENAI_BASE_URL || 'https://api.asi1.ai/v1';
|
|
64
|
+
|
|
65
|
+
return new OpenAI({ apiKey, baseURL });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async resolveApiKey(): Promise<string | undefined> {
|
|
69
|
+
const config = await this.getConfig();
|
|
70
|
+
const providerEnvKey = config.llm.apiKeyEnv;
|
|
71
|
+
return process.env.GEMINI_API_KEY
|
|
72
|
+
|| process.env.VITE_GEMINI_API_KEY
|
|
73
|
+
|| process.env.A11Y_CLI_API_KEY
|
|
74
|
+
|| config.llm.apiKey
|
|
75
|
+
|| (providerEnvKey ? process.env[providerEnvKey] : undefined)
|
|
76
|
+
|| process.env.ASI_ONE_API_KEY
|
|
77
|
+
|| process.env.OPENAI_API_KEY
|
|
78
|
+
|| process.env.ANTHROPIC_API_KEY;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private async callRemoteModel(userContent: string, maxTokens: number): Promise<string | null> {
|
|
82
|
+
const config = await this.getConfig();
|
|
83
|
+
const provider = config.llm.provider;
|
|
84
|
+
const apiKey = await this.resolveApiKey();
|
|
85
|
+
|
|
86
|
+
if (!apiKey) {
|
|
87
|
+
logger.info('No API key configured; using local accessibility heuristics', {
|
|
88
|
+
provider,
|
|
89
|
+
hint: 'Set A11Y_CLI_API_KEY or the provider-specific env var in .assessify.json.',
|
|
90
|
+
});
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (provider === 'anthropic') {
|
|
95
|
+
const client = new Anthropic({ apiKey });
|
|
96
|
+
try {
|
|
97
|
+
const resp = await client.messages.create({
|
|
98
|
+
model: config.llm.model,
|
|
99
|
+
max_tokens: maxTokens,
|
|
100
|
+
system: this.systemPrompt,
|
|
101
|
+
messages: [{ role: 'user', content: userContent }],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return resp.content
|
|
105
|
+
.map((part) => (part.type === 'text' ? part.text : ''))
|
|
106
|
+
.join('');
|
|
107
|
+
} catch (error: any) {
|
|
108
|
+
logger.warn('Anthropic provider unavailable; using local accessibility heuristics', {
|
|
109
|
+
provider,
|
|
110
|
+
message: error.message,
|
|
111
|
+
});
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const client = await this.createClient();
|
|
117
|
+
try {
|
|
118
|
+
const resp = await client.chat.completions.create({
|
|
119
|
+
model: config.llm.model,
|
|
120
|
+
messages: [
|
|
121
|
+
{ role: 'system', content: this.systemPrompt },
|
|
122
|
+
{ role: 'user', content: userContent },
|
|
123
|
+
],
|
|
124
|
+
temperature: 0.0,
|
|
125
|
+
max_tokens: maxTokens,
|
|
126
|
+
response_format: { type: 'json_object' },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const raw = resp.choices?.[0]?.message?.content ?? resp.choices?.[0]?.message;
|
|
130
|
+
return typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
131
|
+
} catch (error: any) {
|
|
132
|
+
logger.warn('LLM provider unavailable; using local accessibility heuristics', {
|
|
133
|
+
provider,
|
|
134
|
+
message: error.message,
|
|
135
|
+
});
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private parseJsonObject(text: string): any | null {
|
|
141
|
+
const fencedMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
142
|
+
const candidateTexts = [fencedMatch?.[1], text].filter((value): value is string => Boolean(value));
|
|
143
|
+
|
|
144
|
+
for (const candidate of candidateTexts) {
|
|
145
|
+
const jsonStart = candidate.indexOf('{');
|
|
146
|
+
const jsonEnd = candidate.lastIndexOf('}');
|
|
147
|
+
if (jsonStart < 0 || jsonEnd <= jsonStart) continue;
|
|
148
|
+
|
|
149
|
+
const jsonText = candidate.slice(jsonStart, jsonEnd + 1);
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(jsonText);
|
|
152
|
+
} catch {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private inferAltText(sourceTag: string): string {
|
|
161
|
+
const srcMatch = sourceTag.match(/src=["']([^"']+)["']/i);
|
|
162
|
+
const source = srcMatch?.[1] ?? '';
|
|
163
|
+
const filename = source.split('/').pop()?.split('.')[0] ?? '';
|
|
164
|
+
const cleaned = filename
|
|
165
|
+
.replace(/[-_]+/g, ' ')
|
|
166
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
|
167
|
+
.trim();
|
|
168
|
+
|
|
169
|
+
if (!cleaned) return 'Descriptive text for the image';
|
|
170
|
+
if (/logo/i.test(cleaned)) return `${cleaned} logo`;
|
|
171
|
+
if (/banner/i.test(cleaned)) return `${cleaned} banner`;
|
|
172
|
+
return cleaned;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private addAriaLabel(tag: string, label: string): string {
|
|
176
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
177
|
+
return tag;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const normalized = label.trim() || 'Descriptive label';
|
|
181
|
+
return tag.replace(/\s*\/?>$/, ` aria-label="${normalized}"$&`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private synthesizeIssues(content: string): AccessibilityIssue[] {
|
|
185
|
+
const issues: AccessibilityIssue[] = [];
|
|
186
|
+
|
|
187
|
+
if (/<img\b(?![^>]*\balt\s*=)[^>]*>/i.test(content)) {
|
|
188
|
+
issues.push({
|
|
189
|
+
type: 'image-alt-text',
|
|
190
|
+
severity: 'error',
|
|
191
|
+
description: 'Image elements should include alternative text.',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const textInputPattern = /<input\b[^>]*type=["'](?:text|password|email|search|tel|url)["'][^>]*>/gi;
|
|
196
|
+
for (const match of content.matchAll(textInputPattern)) {
|
|
197
|
+
const tag = match[0];
|
|
198
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
issues.push({
|
|
203
|
+
type: 'form-label',
|
|
204
|
+
severity: 'error',
|
|
205
|
+
description: 'Text-like inputs should have an accessible label.',
|
|
206
|
+
});
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const controlPattern = /<input\b[^>]*type=["'](?:checkbox|radio)["'][^>]*>/gi;
|
|
211
|
+
for (const match of content.matchAll(controlPattern)) {
|
|
212
|
+
const tag = match[0];
|
|
213
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
issues.push({
|
|
218
|
+
type: 'form-label',
|
|
219
|
+
severity: 'error',
|
|
220
|
+
description: 'Checkbox and radio inputs should have an accessible label.',
|
|
221
|
+
});
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (/<button\b[^>]*>(\s| )*<\/button>/i.test(content)) {
|
|
226
|
+
issues.push({
|
|
227
|
+
type: 'button-label',
|
|
228
|
+
severity: 'error',
|
|
229
|
+
description: 'Buttons should have visible or programmatic text.',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return issues;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private synthesizeFixes(content: string): AccessibilityFix[] {
|
|
237
|
+
let updated = content;
|
|
238
|
+
|
|
239
|
+
updated = updated.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
240
|
+
if (/\balt\s*=/.test(tag)) return tag;
|
|
241
|
+
const altText = this.inferAltText(tag);
|
|
242
|
+
return tag.replace(/\s*\/?>$/, ` alt="${altText}"$&`);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
updated = updated.replace(/<input\b[^>]*type=["'](?:text|password|email|search|tel|url)["'][^>]*>/gi, (tag) => {
|
|
246
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
247
|
+
return tag;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const placeholderMatch = tag.match(/placeholder=["']([^"']+)["']/i);
|
|
251
|
+
const label = placeholderMatch?.[1] ?? 'Input';
|
|
252
|
+
return this.addAriaLabel(tag, label);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
updated = updated.replace(/<input\b[^>]*type=["'](?:checkbox|radio)["'][^>]*>/gi, (tag) => {
|
|
256
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
257
|
+
return tag;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return this.addAriaLabel(tag, 'Option');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (updated !== content) {
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
issue: {
|
|
267
|
+
type: 'accessibility-fallback',
|
|
268
|
+
severity: 'warning',
|
|
269
|
+
description: 'Applied deterministic accessibility replacements because the model response could not be parsed.',
|
|
270
|
+
},
|
|
271
|
+
suggestedFix: `REPLACE_FILE_WITH:${updated}`,
|
|
272
|
+
explanation: 'Fallback replacement generated locally to keep the fixer functional when the model output is malformed.',
|
|
273
|
+
confidence: 0.5,
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async analyzeFile(filePath: string, content: string, diff?: string): Promise<AccessibilityIssue[]> {
|
|
282
|
+
const userContent = `Analyze the following file for accessibility issues (WCAG 2.1 AA). Return JSON issues array.\n\nFilePath: ${filePath}\n\nContent:\n${content}\n\nDiff:\n${diff ?? ''}`;
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const text = await this.callRemoteModel(userContent, 800);
|
|
286
|
+
if (!text) {
|
|
287
|
+
return this.synthesizeIssues(content);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// try to parse JSON out of the response
|
|
291
|
+
const jsonStart = text.indexOf('{');
|
|
292
|
+
if (jsonStart >= 0) {
|
|
293
|
+
const jsonText = text.slice(jsonStart);
|
|
294
|
+
try {
|
|
295
|
+
const parsed = JSON.parse(jsonText);
|
|
296
|
+
const parsedIssues = parsed.issues ?? [];
|
|
297
|
+
if (parsedIssues.length > 0) {
|
|
298
|
+
return parsedIssues;
|
|
299
|
+
}
|
|
300
|
+
} catch (e: any) {
|
|
301
|
+
logger.warn('Failed to parse JSON from LLM analyze response', { error: e.message });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return this.synthesizeIssues(content);
|
|
306
|
+
} catch (error: any) {
|
|
307
|
+
if (this.isHttpStatus(error, 404)) {
|
|
308
|
+
logger.warn('LLM provider returned 404; using local accessibility heuristics', {
|
|
309
|
+
filePath,
|
|
310
|
+
hint: 'Check llm.provider, llm.baseUrl, and llm.model in .assessify.json or environment variables.',
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
logger.error('LLM analyzeFile error', { message: error.message });
|
|
314
|
+
}
|
|
315
|
+
return this.synthesizeIssues(content);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async generateFixes(issues: AccessibilityIssue[], content: string): Promise<AccessibilityFix[]> {
|
|
320
|
+
const issuesSummary = JSON.stringify(issues.slice(0, 20));
|
|
321
|
+
const userContent = `Given the following issues: ${issuesSummary}\n\nFile content:\n${content}\n\nReturn JSON only with a "fixes" array where each fix references an issue index and provides suggestedFix as either a unified diff hunk or a complete replacement prefixed with REPLACE_FILE_WITH:.`;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const text = await this.callRemoteModel(userContent, 1200);
|
|
325
|
+
if (!text) {
|
|
326
|
+
return this.synthesizeFixes(content);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const parsed = this.parseJsonObject(text);
|
|
330
|
+
if (parsed) {
|
|
331
|
+
const parsedFixes = parsed.fixes ?? [];
|
|
332
|
+
if (parsedFixes.length > 0) {
|
|
333
|
+
return parsedFixes;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
logger.warn('Failed to parse JSON from LLM generateFixes response', {
|
|
338
|
+
error: 'No valid JSON object found in model response',
|
|
339
|
+
});
|
|
340
|
+
return this.synthesizeFixes(content);
|
|
341
|
+
} catch (error: any) {
|
|
342
|
+
if (this.isHttpStatus(error, 404)) {
|
|
343
|
+
logger.warn('LLM provider returned 404 while generating fixes; using local accessibility heuristics', {
|
|
344
|
+
hint: 'Check llm.provider, llm.baseUrl, and llm.model in .assessify.json or environment variables.',
|
|
345
|
+
});
|
|
346
|
+
} else {
|
|
347
|
+
logger.error('LLM generateFixes error', { message: error.message });
|
|
348
|
+
}
|
|
349
|
+
return this.synthesizeFixes(content);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export default new LLMAccessibilityAnalyzer();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'path';
|
|
3
|
+
import { sha256 } from '../utils/checksum';
|
|
4
|
+
import { logger } from '../utils/logger';
|
|
5
|
+
import { loadConfig } from './config';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CACHE_FILE = '.assessify-cache/processed-files.json';
|
|
8
|
+
|
|
9
|
+
interface ProcessedRecord {
|
|
10
|
+
fileChecksum: string;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
attempts: number;
|
|
13
|
+
lastFixes?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class LoopPreventionManager {
|
|
17
|
+
private path: string;
|
|
18
|
+
private cache: Record<string, ProcessedRecord> | null = null;
|
|
19
|
+
private maxAttempts = 2;
|
|
20
|
+
|
|
21
|
+
constructor(baseDir = process.cwd()) {
|
|
22
|
+
this.path = join(baseDir, DEFAULT_CACHE_FILE);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private keyFor(filePath: string): string {
|
|
26
|
+
return resolve(process.cwd(), filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private async loadSettings() {
|
|
30
|
+
const config = await loadConfig();
|
|
31
|
+
this.path = join(process.cwd(), config.loopPrevention.cacheFile || DEFAULT_CACHE_FILE);
|
|
32
|
+
this.maxAttempts = config.loopPrevention.maxAttempts || 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private async ensureCacheLoaded() {
|
|
36
|
+
if (this.cache) return;
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(this.path, 'utf-8');
|
|
39
|
+
this.cache = JSON.parse(raw);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
this.cache = {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async shouldProcessFile(filePath: string, content: string, maxAttempts?: number): Promise<boolean> {
|
|
46
|
+
await this.loadSettings();
|
|
47
|
+
await this.ensureCacheLoaded();
|
|
48
|
+
const checksum = sha256(content);
|
|
49
|
+
const rec = this.cache![this.keyFor(filePath)];
|
|
50
|
+
if (!rec) return true;
|
|
51
|
+
if (rec.fileChecksum !== checksum) return true;
|
|
52
|
+
const attemptLimit = maxAttempts ?? this.maxAttempts;
|
|
53
|
+
if (rec.attempts >= attemptLimit) {
|
|
54
|
+
logger.warn(`Max attempts reached for ${filePath}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// same checksum and below attempts -> allow
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async recordProcessing(filePath: string, content: string, fixes: string[] = []) {
|
|
62
|
+
await this.loadSettings();
|
|
63
|
+
await mkdir(dirname(this.path), { recursive: true }).catch(() => {});
|
|
64
|
+
await this.ensureCacheLoaded();
|
|
65
|
+
const checksum = sha256(content);
|
|
66
|
+
const cacheKey = this.keyFor(filePath);
|
|
67
|
+
const prev = this.cache![cacheKey];
|
|
68
|
+
const attempts = (prev?.attempts ?? 0) + 1;
|
|
69
|
+
this.cache![cacheKey] = {
|
|
70
|
+
fileChecksum: checksum,
|
|
71
|
+
timestamp: Date.now(),
|
|
72
|
+
attempts,
|
|
73
|
+
lastFixes: fixes,
|
|
74
|
+
};
|
|
75
|
+
await writeFile(this.path, JSON.stringify(this.cache, null, 2), 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default new LoopPreventionManager();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFile, writeFile, copyFile } from 'fs/promises';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
|
|
4
|
+
export interface AccessibilityFix {
|
|
5
|
+
issueIndex?: number;
|
|
6
|
+
suggestedFix: string; // full file content replacement or snippet
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class FilePatcher {
|
|
10
|
+
async createBackup(filePath: string): Promise<string> {
|
|
11
|
+
const backupPath = `${filePath}.a11y.bak`;
|
|
12
|
+
try {
|
|
13
|
+
await copyFile(filePath, backupPath);
|
|
14
|
+
} catch (e) {
|
|
15
|
+
// ignore
|
|
16
|
+
}
|
|
17
|
+
return backupPath;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async applyFix(filePath: string, fix: AccessibilityFix): Promise<boolean> {
|
|
21
|
+
try {
|
|
22
|
+
const original = await readFile(filePath, 'utf-8');
|
|
23
|
+
const backup = await this.createBackup(filePath);
|
|
24
|
+
|
|
25
|
+
const suggestedFix = fix.suggestedFix?.trim();
|
|
26
|
+
if (!suggestedFix) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let newContent = original;
|
|
31
|
+
|
|
32
|
+
// If suggestedFix looks like a full-file replacement, apply it directly.
|
|
33
|
+
if (suggestedFix.startsWith('---')) {
|
|
34
|
+
// naive patch: replace full content after marker
|
|
35
|
+
const parts = suggestedFix.split('\n');
|
|
36
|
+
newContent = parts.slice(1).join('\n');
|
|
37
|
+
} else if (suggestedFix.includes('REPLACE_FILE_WITH:')) {
|
|
38
|
+
newContent = suggestedFix.replace('REPLACE_FILE_WITH:', '').trim();
|
|
39
|
+
} else if (filePath.match(/\.html?$/i)) {
|
|
40
|
+
const htmlReplacement = this.applyHtmlSuggestion(original, suggestedFix);
|
|
41
|
+
if (!htmlReplacement) {
|
|
42
|
+
logger.warn('Skipping unrecognized HTML fix format', { filePath });
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
newContent = htmlReplacement;
|
|
46
|
+
} else {
|
|
47
|
+
logger.warn('Skipping unrecognized fix format', { filePath });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await writeFile(filePath, newContent, 'utf-8');
|
|
52
|
+
return true;
|
|
53
|
+
} catch (e: any) {
|
|
54
|
+
logger.error('Failed to apply fix', { filePath, error: e.message });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private applyHtmlSuggestion(original: string, suggestion: string): string | null {
|
|
60
|
+
const titlePattern = /<title\b[\s\S]*?<\/title>/i;
|
|
61
|
+
if (suggestion.includes('<title') && titlePattern.test(original)) {
|
|
62
|
+
return original.replace(titlePattern, suggestion);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (suggestion.includes('<label') && suggestion.includes('<input')) {
|
|
66
|
+
const inputPattern = /<input\b[^>]*type=["']text["'][^>]*\/?>/i;
|
|
67
|
+
if (inputPattern.test(original)) {
|
|
68
|
+
return original.replace(inputPattern, suggestion);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (suggestion.includes('<img')) {
|
|
73
|
+
const imgPattern = /<img\b[^>]*>/i;
|
|
74
|
+
if (imgPattern.test(original)) {
|
|
75
|
+
return original.replace(imgPattern, suggestion);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default new FilePatcher();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFile, writeFile, chmod, mkdir } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
type HookType = 'pre-commit' | 'pre-push' | 'both';
|
|
5
|
+
|
|
6
|
+
export async function installHooks(options: { hook?: HookType } = {}) {
|
|
7
|
+
const hookDir = '.git/hooks';
|
|
8
|
+
await mkdir(hookDir, { recursive: true }).catch(() => {});
|
|
9
|
+
const hookType = options.hook ?? 'pre-commit';
|
|
10
|
+
|
|
11
|
+
const assessifyPreCommitBlock = `\n# --- Accessify A11y Hook Start ---\nnpx assessify fix --stage staged || true\ngit add -A\n# --- Accessify A11y Hook End ---\n`;
|
|
12
|
+
const assessifyPrePushBlock = `\n# --- Accessify A11y Hook Start ---\nnpx assessify fix --stage recent || true\nif git diff --cached --quiet; then\n echo "No changes needed"\nelse\n git add -A\n git commit --amend --no-edit\nfi\n# --- Accessify A11y Hook End ---\n`;
|
|
13
|
+
|
|
14
|
+
if (hookType === 'pre-commit' || hookType === 'both') {
|
|
15
|
+
const preCommitPath = join(hookDir, 'pre-commit');
|
|
16
|
+
let existingContent = '';
|
|
17
|
+
try {
|
|
18
|
+
existingContent = await readFile(preCommitPath, 'utf-8');
|
|
19
|
+
} catch (e) {
|
|
20
|
+
existingContent = '#!/bin/bash\nset -e\n';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!existingContent.includes('Accessify') && !existingContent.includes('assessify')) {
|
|
24
|
+
if (existingContent.trim().length > 0 && !existingContent.includes('#!/bin/bash')) {
|
|
25
|
+
await writeFile(`${preCommitPath}.bak`, existingContent, { encoding: 'utf-8' });
|
|
26
|
+
}
|
|
27
|
+
const updatedContent = existingContent.trimEnd() + '\n' + assessifyPreCommitBlock;
|
|
28
|
+
await writeFile(preCommitPath, updatedContent, { encoding: 'utf-8' });
|
|
29
|
+
await chmod(preCommitPath, 0o755);
|
|
30
|
+
console.log('✓ Accessify pre-commit hook appended successfully.');
|
|
31
|
+
} else {
|
|
32
|
+
console.log('ℹ Accessify pre-commit hook is already installed.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (hookType === 'pre-push' || hookType === 'both') {
|
|
37
|
+
const prePushPath = join(hookDir, 'pre-push');
|
|
38
|
+
let existingContent = '';
|
|
39
|
+
try {
|
|
40
|
+
existingContent = await readFile(prePushPath, 'utf-8');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
existingContent = '#!/bin/bash\nset -e\n';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!existingContent.includes('Accessify') && !existingContent.includes('assessify')) {
|
|
46
|
+
if (existingContent.trim().length > 0 && !existingContent.includes('#!/bin/bash')) {
|
|
47
|
+
await writeFile(`${prePushPath}.bak`, existingContent, { encoding: 'utf-8' });
|
|
48
|
+
}
|
|
49
|
+
const updatedContent = existingContent.trimEnd() + '\n' + assessifyPrePushBlock;
|
|
50
|
+
await writeFile(prePushPath, updatedContent, { encoding: 'utf-8' });
|
|
51
|
+
await chmod(prePushPath, 0o755);
|
|
52
|
+
console.log('✓ Accessify pre-push hook appended successfully.');
|
|
53
|
+
} else {
|
|
54
|
+
console.log('ℹ Accessify pre-push hook is already installed.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { installHooks } from './hooks/installer';
|
|
2
|
+
import { GitManager } from './core/git';
|
|
3
|
+
import { loadConfig } from './core/config';
|
|
4
|
+
import llm from './core/llm';
|
|
5
|
+
import patcher from './core/patcher';
|
|
6
|
+
import loopManager from './core/loopPrevention';
|
|
7
|
+
import { logger } from './utils/logger';
|
|
8
|
+
|
|
9
|
+
function isSupportedFile(filePath: string, includeExtensions: string[]): boolean {
|
|
10
|
+
const lowerPath = filePath.toLowerCase();
|
|
11
|
+
return includeExtensions.some((extension) => lowerPath.endsWith(extension.toLowerCase()));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildLocalReplacement(content: string): string | null {
|
|
15
|
+
let updated = content;
|
|
16
|
+
|
|
17
|
+
updated = updated.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
18
|
+
if (/\balt\s*=/.test(tag)) return tag;
|
|
19
|
+
const srcMatch = tag.match(/src=["']([^"']+)["']/i);
|
|
20
|
+
const source = srcMatch?.[1] ?? '';
|
|
21
|
+
const filename = source.split('/').pop()?.split('.')[0] ?? '';
|
|
22
|
+
const altText = filename
|
|
23
|
+
? filename.replace(/[-_]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
|
|
24
|
+
: 'Descriptive text for the image';
|
|
25
|
+
return tag.replace(/\s*\/?>$/, ` alt="${altText}"$&`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
updated = updated.replace(/<input\b[^>]*type=["'](?:text|password|email|search|tel|url)["'][^>]*>/gi, (tag) => {
|
|
29
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
30
|
+
return tag;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const placeholderMatch = tag.match(/placeholder=["']([^"']+)["']/i);
|
|
34
|
+
const label = placeholderMatch?.[1] ?? 'Input';
|
|
35
|
+
return tag.replace(/\s*\/?>$/, ` aria-label="${label}"$&`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
updated = updated.replace(/<input\b[^>]*type=["'](?:checkbox|radio)["'][^>]*>/gi, (tag) => {
|
|
39
|
+
if (/\baria-label\s*=/.test(tag) || /\baria-labelledby\s*=/.test(tag)) {
|
|
40
|
+
return tag;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return tag.replace(/\s*\/?>$/, ' aria-label="Option"$&');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return updated === content ? null : updated;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class AccessibilityTool {
|
|
50
|
+
async install() {
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
await installHooks({ hook: config.hook });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async fix(options: { stage?: 'staged' | 'unpushed' | 'recent'; force?: boolean; files?: string[] } = {}) {
|
|
56
|
+
const config = await loadConfig();
|
|
57
|
+
const git = new GitManager();
|
|
58
|
+
const requestedFiles = options.files && options.files.length > 0
|
|
59
|
+
? options.files
|
|
60
|
+
: await git.getChangedFiles(options.stage ?? 'staged');
|
|
61
|
+
const files = requestedFiles.filter((file) => isSupportedFile(file, config.files.includeExtensions));
|
|
62
|
+
const results: any[] = [];
|
|
63
|
+
|
|
64
|
+
logger.info('Starting accessibility scan', {
|
|
65
|
+
requested: requestedFiles.length,
|
|
66
|
+
supported: files.length,
|
|
67
|
+
skipped: requestedFiles.length - files.length,
|
|
68
|
+
extensions: config.files.includeExtensions,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
for (const file of requestedFiles) {
|
|
72
|
+
if (!isSupportedFile(file, config.files.includeExtensions)) {
|
|
73
|
+
logger.debug('Skipping unsupported file type', { file });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
try {
|
|
79
|
+
const diff = await git.getDiffForFile(file, options.stage ?? 'staged');
|
|
80
|
+
const should = await loopManager.shouldProcessFile(file, diff.afterContent);
|
|
81
|
+
if (!should) {
|
|
82
|
+
logger.info('Skipping file due to loop prevention', { file });
|
|
83
|
+
results.push({ file, skipped: true });
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const issues = await llm.analyzeFile(file, diff.afterContent, diff.fullDiff);
|
|
88
|
+
if (!issues || issues.length === 0) {
|
|
89
|
+
logger.info('No accessibility issues found', { file });
|
|
90
|
+
results.push({ file, issues: 0 });
|
|
91
|
+
await loopManager.recordProcessing(file, diff.afterContent, []);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fixes = await llm.generateFixes(issues, diff.afterContent);
|
|
96
|
+
logger.debug('Generated fix candidates', { file, issues: issues.length, fixes: fixes.length });
|
|
97
|
+
const applied: string[] = [];
|
|
98
|
+
|
|
99
|
+
if (fixes.length === 0) {
|
|
100
|
+
const localReplacement = buildLocalReplacement(diff.afterContent);
|
|
101
|
+
if (localReplacement) {
|
|
102
|
+
const ok = await patcher.applyFix(file, { suggestedFix: `REPLACE_FILE_WITH:${localReplacement}` });
|
|
103
|
+
if (ok) {
|
|
104
|
+
applied.push('local-fallback');
|
|
105
|
+
await git.stageFile(file);
|
|
106
|
+
logger.info('Applied local fallback fix', { file });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const fix of fixes) {
|
|
112
|
+
const ok = await patcher.applyFix(file, { suggestedFix: fix.suggestedFix ?? fix });
|
|
113
|
+
if (ok) {
|
|
114
|
+
applied.push(typeof fix === 'string' ? fix : fix.suggestedFix ?? JSON.stringify(fix));
|
|
115
|
+
await git.stageFile(file);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await loopManager.recordProcessing(file, diff.afterContent, applied);
|
|
120
|
+
logger.info('Finished processing file', { file, issues: issues.length, fixesApplied: applied.length });
|
|
121
|
+
results.push({ file, issues: issues.length, fixesApplied: applied.length });
|
|
122
|
+
} catch (e: any) {
|
|
123
|
+
logger.error('Error processing file', { file, message: e.message });
|
|
124
|
+
results.push({ file, error: e.message });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { processed: results };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (require.main === module) {
|
|
133
|
+
(async () => {
|
|
134
|
+
const tool = new AccessibilityTool();
|
|
135
|
+
const res = await tool.fix({ stage: 'staged' });
|
|
136
|
+
console.log(JSON.stringify(res, null, 2));
|
|
137
|
+
})();
|
|
138
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
4
|
+
|
|
5
|
+
const levelRank: Record<LogLevel, number> = {
|
|
6
|
+
debug: 10,
|
|
7
|
+
info: 20,
|
|
8
|
+
warn: 30,
|
|
9
|
+
error: 40,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function currentLevel(): LogLevel {
|
|
13
|
+
const explicit = (process.env.A11Y_CLI_LOG_LEVEL || process.env.LOG_LEVEL || '').toLowerCase();
|
|
14
|
+
if (explicit === 'debug' || explicit === 'info' || explicit === 'warn' || explicit === 'error') {
|
|
15
|
+
return explicit;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const configLevel = readConfiguredLevel();
|
|
19
|
+
if (configLevel) {
|
|
20
|
+
return configLevel;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return process.env.NODE_ENV === 'production' ? 'info' : 'debug';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readConfiguredLevel(): LogLevel | null {
|
|
27
|
+
const candidateFiles = ['.assessify.json', '.a11y-cli.json'];
|
|
28
|
+
|
|
29
|
+
for (const candidate of candidateFiles) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = readFileSync(candidate, 'utf-8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
const configured = (parsed.logging?.level || parsed.reporting?.logLevel || '').toLowerCase();
|
|
34
|
+
if (configured === 'debug' || configured === 'info' || configured === 'warn' || configured === 'error') {
|
|
35
|
+
return configured;
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatContext(ctx?: any): string {
|
|
46
|
+
if (ctx === undefined) return '';
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(ctx);
|
|
49
|
+
} catch {
|
|
50
|
+
return String(ctx);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function emit(level: LogLevel, message: string, ctx?: any) {
|
|
55
|
+
if (levelRank[level] < levelRank[currentLevel()]) return;
|
|
56
|
+
|
|
57
|
+
const timestamp = new Date().toISOString();
|
|
58
|
+
const payload = { timestamp, level, message, ...(ctx === undefined ? {} : { context: ctx }) };
|
|
59
|
+
|
|
60
|
+
if (process.env.NODE_ENV === 'production') {
|
|
61
|
+
const line = JSON.stringify(payload);
|
|
62
|
+
if (level === 'error') {
|
|
63
|
+
console.error(line);
|
|
64
|
+
} else if (level === 'warn') {
|
|
65
|
+
console.warn(line);
|
|
66
|
+
} else {
|
|
67
|
+
console.log(line);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const contextText = formatContext(ctx);
|
|
73
|
+
const suffix = contextText ? ` ${contextText}` : '';
|
|
74
|
+
const line = `${timestamp} [${level}] ${message}${suffix}`;
|
|
75
|
+
|
|
76
|
+
if (level === 'error') {
|
|
77
|
+
console.error(line);
|
|
78
|
+
} else if (level === 'warn') {
|
|
79
|
+
console.warn(line);
|
|
80
|
+
} else if (level === 'info') {
|
|
81
|
+
console.info(line);
|
|
82
|
+
} else {
|
|
83
|
+
console.debug(line);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const logger = {
|
|
88
|
+
debug: (msg: string, ctx?: any) => emit('debug', msg, ctx),
|
|
89
|
+
info: (msg: string, ctx?: any) => emit('info', msg, ctx),
|
|
90
|
+
warn: (msg: string, ctx?: any) => emit('warn', msg, ctx),
|
|
91
|
+
error: (msg: string, ctx?: any) => emit('error', msg, ctx),
|
|
92
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"moduleResolution": "Node",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|