code-auditor-mcp 3.0.4 → 3.0.6

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.
@@ -10,7 +10,7 @@
10
10
  "name": "code-auditor",
11
11
  "source": "./plugin",
12
12
  "description": "Diff-scoped code quality auditing on every edit. Indexes your codebase, enforces invariants, and feeds violations back to the agent so fixes happen inline.",
13
- "version": "3.0.4",
13
+ "version": "3.0.6",
14
14
  "author": {
15
15
  "name": "Ben Hammond"
16
16
  },
package/dist/cli.js CHANGED
@@ -12,9 +12,8 @@ import { promises as fs } from 'fs';
12
12
  import { createInterface } from 'readline';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { dirname, isAbsolute, join, relative, resolve } from 'path';
15
- import { ConfigGeneratorFactory } from './generators/ConfigGeneratorFactory.js';
16
- import { InteractivePrompts } from './ui/InteractivePrompts.js';
17
- import { DEFAULT_SERVER_URL, DEFAULT_PORT } from './constants.js';
15
+ import { DEFAULT_PORT } from './constants.js';
16
+ import inquirer from 'inquirer';
18
17
  import { CodeMapGenerator } from './services/CodeMapGenerator.js';
19
18
  import { initParsers } from './languages/index.js';
20
19
  import { queryParser } from './search/QueryParser.js';
@@ -37,11 +36,19 @@ program
37
36
  .option('-c, --config <config>', 'Configuration name')
38
37
  .option('-o, --output <dir>', 'Output directory for reports')
39
38
  .option('-f, --format <format>', 'Report format: html, json, csv, or sarif')
39
+ .option('--fail-on <severity>', 'Exit code 2 when violations at or above this severity exist')
40
40
  .action(async (options) => {
41
41
  console.log(chalk.blue('šŸ” Code Quality Audit Tool'));
42
42
  console.log(chalk.gray('══════════════════════════════════════════════════'));
43
43
  try {
44
44
  await initParsers();
45
+ // Validate --fail-on severity
46
+ const validSeverities = ['critical', 'warning', 'suggestion'];
47
+ const failOnSeverity = options.failOn;
48
+ if (failOnSeverity && !validSeverities.includes(failOnSeverity)) {
49
+ console.error(chalk.red(`Invalid --fail-on severity: "${failOnSeverity}". Must be one of: ${validSeverities.join(', ')}`));
50
+ process.exit(1);
51
+ }
45
52
  const runner = createAuditRunner({
46
53
  projectRoot: options.path,
47
54
  configName: options.config,
@@ -67,6 +74,19 @@ program
67
74
  await fs.writeFile(reportPath, report, 'utf-8');
68
75
  console.log(chalk.green(`\nReport written to ${reportPath}`));
69
76
  }
77
+ // Exit code based on --fail-on
78
+ if (failOnSeverity) {
79
+ const violations = Object.values(result.analyzerResults).flatMap((r) => r.violations || []);
80
+ const severityOrder = ['critical', 'warning', 'suggestion'];
81
+ const failIndex = severityOrder.indexOf(failOnSeverity);
82
+ const hasAtOrAbove = violations.some((v) => {
83
+ const vIndex = severityOrder.indexOf(v.severity);
84
+ return vIndex >= 0 && vIndex <= failIndex;
85
+ });
86
+ if (hasAtOrAbove) {
87
+ process.exit(2);
88
+ }
89
+ }
70
90
  }
71
91
  catch (error) {
72
92
  console.error(chalk.red('Error:'), error);
@@ -204,15 +224,13 @@ program
204
224
  program
205
225
  .command('generate-config')
206
226
  .alias('gen')
207
- .description('Generate configuration for AI coding assistants')
208
- .option('-t, --tool <tool>', 'Specific tool (cursor, continue, copilot, awsq, codeium, claude, all)')
227
+ .description('Generate a .codeauditor.json scaffold with invariant rules')
209
228
  .option('-o, --output <dir>', 'Output directory', '.')
210
- .option('-s, --server-url <url>', 'MCP server URL', DEFAULT_SERVER_URL)
211
- .option('-i, --interactive', 'Interactive mode for tool selection')
212
- .option('-f, --force', 'Force overwrite existing files without confirmation')
213
- .option('-y, --yes', 'Skip all confirmation prompts (same as --force)')
229
+ .option('-i, --interactive', 'Interactive rule builder')
230
+ .option('-f, --force', 'Force overwrite existing file without confirmation')
231
+ .option('-y, --yes', 'Skip confirmation prompts (same as --force)')
214
232
  .action(async (options) => {
215
- console.log(chalk.blue('šŸ› ļø AI Tool Configuration Generator'));
233
+ console.log(chalk.blue('šŸ› ļø Code Auditor Config Generator'));
216
234
  console.log(chalk.gray('════════════════════════════════════════════════════'));
217
235
  try {
218
236
  await generateConfigurations(options);
@@ -776,126 +794,362 @@ program.parse(process.argv);
776
794
  if (!process.argv.slice(2).length) {
777
795
  program.outputHelp();
778
796
  }
779
- /**
780
- * Generate configurations for AI tools
781
- */
797
+ const RULE_KINDS = [
798
+ {
799
+ kind: 'import-ban',
800
+ label: 'Import Ban — forbid importing a specific module',
801
+ description: 'No file may import the banned module (e.g. deprecated packages, legacy libraries).',
802
+ requiredFields: ['module'],
803
+ },
804
+ {
805
+ kind: 'call-constraint',
806
+ label: 'Call Constraint — restrict who can call a function',
807
+ description: 'Only allow or deny specific callers from invoking a function.',
808
+ requiredFields: ['callee'],
809
+ },
810
+ {
811
+ kind: 'module-boundary',
812
+ label: 'Module Boundary — enforce layer isolation',
813
+ description: 'Files matching a "from" glob may not import from files matching a "to" glob.',
814
+ requiredFields: ['from', 'to'],
815
+ },
816
+ {
817
+ kind: 'naming',
818
+ label: 'Naming — enforce export naming conventions',
819
+ description: 'Exported symbols in matching files must match a regex pattern.',
820
+ requiredFields: ['path', 'exports'],
821
+ },
822
+ {
823
+ kind: 'ast-pattern',
824
+ label: 'AST Pattern — ban syntactic patterns',
825
+ description: 'Match AST nodes using ast-grep patterns (e.g. "new Function($$$)").',
826
+ requiredFields: ['pattern'],
827
+ },
828
+ ];
829
+ const SCAFFOLD_CONFIG = {
830
+ $schema: 'https://unpkg.com/code-auditor-mcp/dist/invariant-rules.schema.json',
831
+ rules: [
832
+ {
833
+ id: 'ban-deprecated-lib',
834
+ kind: 'import-ban',
835
+ severity: 'critical',
836
+ message: 'This module is deprecated — prefer the replacement instead.',
837
+ module: 'deprecated-lib',
838
+ },
839
+ {
840
+ id: 'data-layer-no-browser',
841
+ kind: 'module-boundary',
842
+ severity: 'critical',
843
+ message: 'Data access layer must not import from browser-only modules.',
844
+ from: 'src/data/**',
845
+ to: 'src/browser/**',
846
+ },
847
+ {
848
+ id: 'api-routes-naming',
849
+ kind: 'naming',
850
+ severity: 'warning',
851
+ message: 'API route files must export a handler matching the HTTP method.',
852
+ path: 'src/api/**',
853
+ exports: '^(get|post|put|delete|patch)\\b',
854
+ },
855
+ {
856
+ id: 'no-eval',
857
+ kind: 'ast-pattern',
858
+ severity: 'critical',
859
+ pattern: 'eval($$$)',
860
+ message: 'eval() is forbidden in this codebase.',
861
+ },
862
+ ],
863
+ };
782
864
  async function generateConfigurations(options) {
783
- const prompts = new InteractivePrompts();
784
- let tools = [];
785
- let serverUrl = options.serverUrl;
786
- let outputDir = options.output;
787
- // Determine which tools to configure
788
- if (options.interactive && !options.tool) {
789
- // Interactive mode
790
- tools = await prompts.selectTools();
791
- if (!options.force && !options.yes) {
792
- serverUrl = await prompts.confirmServerUrl(serverUrl);
793
- outputDir = await prompts.selectOutputDirectory(outputDir);
794
- }
795
- }
796
- else if (!options.tool) {
797
- // No tool specified and not interactive - show available tools
798
- const factory = new ConfigGeneratorFactory(serverUrl);
799
- const availableTools = factory.getToolInfo();
800
- console.log(chalk.yellow('No tool specified. Available tools:'));
801
- availableTools.forEach(tool => {
802
- console.log(chalk.gray(` • ${tool.name} - ${tool.displayName}`));
865
+ const outputDir = resolve(options.output || '.');
866
+ const outputPath = join(outputDir, '.codeauditor.json');
867
+ let config;
868
+ if (options.interactive) {
869
+ // --- Interactive rule builder ---
870
+ console.log(chalk.cyan('\nBuild your .codeauditor.json interactively.\n'));
871
+ const { addRules } = await inquirer.prompt({
872
+ addRules: {
873
+ type: 'confirm',
874
+ message: 'Would you like to add invariant rules?',
875
+ default: true,
876
+ },
803
877
  });
804
- console.log(chalk.blue('\nUsage examples:'));
805
- console.log(chalk.gray(' code-auditor gen --tool cursor'));
806
- console.log(chalk.gray(' code-auditor gen --tool cursor,claude,continue'));
807
- console.log(chalk.gray(' code-auditor gen --tool all'));
808
- console.log(chalk.gray(' code-auditor gen --interactive'));
809
- return;
810
- }
811
- else {
812
- // Command line mode
813
- if (options.tool === 'all') {
814
- const factory = new ConfigGeneratorFactory(serverUrl);
815
- tools = factory.getAvailableTools();
878
+ if (!addRules) {
879
+ console.log(chalk.yellow('No rules selected. Writing empty config.'));
880
+ config = { $schema: SCAFFOLD_CONFIG.$schema, rules: [] };
816
881
  }
817
882
  else {
818
- tools = options.tool.split(',').map((t) => t.trim());
883
+ config = await buildRulesInteractively();
819
884
  }
820
- }
821
- console.log(chalk.blue(`\nGenerating configurations for: ${tools.join(', ')}`));
822
- console.log(chalk.gray(`Server URL: ${serverUrl}`));
823
- console.log(chalk.gray(`Output directory: ${outputDir}\n`));
824
- // Create factory
825
- const factory = new ConfigGeneratorFactory(serverUrl);
826
- const generatedFiles = [];
827
- const errors = [];
828
- // Check for existing files
829
- const existingFiles = [];
830
- for (const tool of tools) {
831
- const generator = factory.createGenerator(tool);
832
- if (generator) {
833
- const config = generator.generateConfig();
834
- const outputPath = resolve(outputDir, config.filename);
885
+ // Confirm output directory
886
+ if (!options.force && !options.yes) {
887
+ const { dir } = await inquirer.prompt({
888
+ dir: {
889
+ type: 'input',
890
+ message: 'Output directory:',
891
+ default: outputDir,
892
+ },
893
+ });
894
+ // Re-resolve with the user's choice (they might just hit enter)
895
+ const chosenDir = resolve(dir || outputDir);
896
+ const chosenPath = join(chosenDir, '.codeauditor.json');
897
+ // Check for existing file
898
+ let exists = false;
835
899
  try {
836
- await fs.access(outputPath);
837
- existingFiles.push(config.filename);
900
+ await fs.access(chosenPath);
901
+ exists = true;
838
902
  }
839
- catch {
840
- // File doesn't exist, which is fine
903
+ catch { /* ok */ }
904
+ if (exists) {
905
+ const { overwrite } = await inquirer.prompt({
906
+ overwrite: {
907
+ type: 'confirm',
908
+ message: chalk.yellow(`.codeauditor.json already exists at ${chosenPath}. Overwrite?`),
909
+ default: false,
910
+ },
911
+ });
912
+ if (!overwrite) {
913
+ console.log(chalk.yellow('Operation cancelled.'));
914
+ return;
915
+ }
841
916
  }
917
+ await writeConfigFile(chosenPath, config);
918
+ }
919
+ else {
920
+ await writeConfigFile(outputPath, config);
842
921
  }
843
922
  }
844
- // Confirm overwrite if needed
845
- if (existingFiles.length > 0 && !options.force && !options.yes) {
846
- const shouldOverwrite = await prompts.confirmOverwrite(existingFiles);
847
- if (!shouldOverwrite) {
848
- console.log(chalk.yellow('Operation cancelled.'));
923
+ else {
924
+ // --- Non-interactive: scaffold template ---
925
+ let exists = false;
926
+ try {
927
+ await fs.access(outputPath);
928
+ exists = true;
929
+ }
930
+ catch { /* ok */ }
931
+ if (exists && !options.force && !options.yes) {
932
+ console.log(chalk.yellow(`.codeauditor.json already exists at ${outputPath}`));
933
+ console.log(chalk.gray('Use --force or --yes to overwrite, or --interactive to build a custom config.'));
849
934
  return;
850
935
  }
936
+ if (exists && (options.force || options.yes)) {
937
+ console.log(chalk.yellow('Overwriting existing .codeauditor.json...'));
938
+ }
939
+ config = SCAFFOLD_CONFIG;
940
+ await writeConfigFile(outputPath, config);
851
941
  }
852
- else if (existingFiles.length > 0 && (options.force || options.yes)) {
853
- console.log(chalk.yellow(`Overwriting ${existingFiles.length} existing file(s)...`));
854
- }
855
- // Generate configurations
856
- for (const tool of tools) {
857
- try {
858
- const generator = factory.createGenerator(tool);
859
- if (!generator) {
860
- errors.push(`Unknown tool: ${tool}`);
861
- continue;
942
+ console.log(chalk.blue('\nNext steps:'));
943
+ console.log(chalk.gray(' 1. Edit .codeauditor.json to match your codebase conventions'));
944
+ console.log(chalk.gray(' 2. Run ') + chalk.cyan('code-audit') + chalk.gray(' to enforce your rules'));
945
+ console.log(chalk.gray(' 3. Use ') + chalk.cyan('code-audit changed --fail-on critical') + chalk.gray(' in your agent hook'));
946
+ }
947
+ /**
948
+ * Interactive rule builder — walks the user through adding rules one at a time.
949
+ */
950
+ async function buildRulesInteractively() {
951
+ const rules = [];
952
+ let addMore = true;
953
+ while (addMore) {
954
+ console.log(chalk.gray(`\n── Rule ${rules.length + 1} ──`));
955
+ // Select rule kind
956
+ const { kind } = await inquirer.prompt({
957
+ kind: {
958
+ type: 'list',
959
+ message: 'Select rule kind:',
960
+ choices: RULE_KINDS.map((k) => ({
961
+ name: k.label,
962
+ value: k.kind,
963
+ })),
964
+ pageSize: 10,
965
+ },
966
+ });
967
+ const kindInfo = RULE_KINDS.find((k) => k.kind === kind);
968
+ console.log(chalk.dim(kindInfo.description));
969
+ // Common fields
970
+ const common = await inquirer.prompt({
971
+ id: {
972
+ type: 'input',
973
+ message: 'Rule ID (unique kebab-case identifier):',
974
+ validate: (input) => {
975
+ if (!input.trim())
976
+ return 'Rule ID is required';
977
+ if (!/^[a-z][a-z0-9-]*$/.test(input))
978
+ return 'Use kebab-case (lowercase, digits, hyphens)';
979
+ return true;
980
+ },
981
+ },
982
+ severity: {
983
+ type: 'list',
984
+ message: 'Severity:',
985
+ choices: [
986
+ { name: chalk.red('Critical — exit code 2, blocks the agent loop'), value: 'critical' },
987
+ { name: chalk.yellow('Warning — visible, non-blocking'), value: 'warning' },
988
+ { name: chalk.blue('Suggestion — informational'), value: 'suggestion' },
989
+ ],
990
+ default: 'warning',
991
+ },
992
+ message: {
993
+ type: 'input',
994
+ message: 'Violation message (shown when rule is broken):',
995
+ validate: (input) => input.trim() ? true : 'Message is required',
996
+ },
997
+ });
998
+ const rule = {
999
+ id: common.id,
1000
+ kind,
1001
+ severity: common.severity,
1002
+ message: common.message,
1003
+ };
1004
+ // Kind-specific fields
1005
+ switch (kind) {
1006
+ case 'import-ban': {
1007
+ const { module } = await inquirer.prompt({
1008
+ module: {
1009
+ type: 'input',
1010
+ message: 'Banned module specifier (e.g. "lodash" or "@old-lib/*"):',
1011
+ validate: (input) => input.trim() ? true : 'Module specifier is required',
1012
+ },
1013
+ });
1014
+ rule.module = module;
1015
+ const { addExcept } = await inquirer.prompt({
1016
+ addExcept: {
1017
+ type: 'confirm',
1018
+ message: 'Add exception paths (files allowed to import it)?',
1019
+ default: false,
1020
+ },
1021
+ });
1022
+ if (addExcept) {
1023
+ const { except } = await inquirer.prompt({
1024
+ except: {
1025
+ type: 'input',
1026
+ message: 'Exception globs (comma-separated, e.g. "src/migration/**"):',
1027
+ },
1028
+ });
1029
+ const exceptList = except.split(',').map((s) => s.trim()).filter(Boolean);
1030
+ if (exceptList.length > 0)
1031
+ rule.except = exceptList;
1032
+ }
1033
+ break;
862
1034
  }
863
- const config = generator.generateConfig();
864
- const outputPath = resolve(outputDir, config.filename);
865
- // Ensure directory exists
866
- await fs.mkdir(dirname(outputPath), { recursive: true });
867
- // Write main config file
868
- await fs.writeFile(outputPath, config.content);
869
- generatedFiles.push(config.filename);
870
- // Write additional files if any
871
- if (config.additionalFiles) {
872
- for (const additionalFile of config.additionalFiles) {
873
- const additionalPath = resolve(outputDir, additionalFile.filename);
874
- await fs.mkdir(dirname(additionalPath), { recursive: true });
875
- await fs.writeFile(additionalPath, additionalFile.content);
876
- generatedFiles.push(additionalFile.filename);
1035
+ case 'call-constraint': {
1036
+ const { callee } = await inquirer.prompt({
1037
+ callee: {
1038
+ type: 'input',
1039
+ message: 'Callee (function name, optionally path-qualified as "path/glob#name"):',
1040
+ validate: (input) => input.trim() ? true : 'Callee is required',
1041
+ },
1042
+ });
1043
+ rule.callee = callee;
1044
+ const { mode } = await inquirer.prompt({
1045
+ mode: {
1046
+ type: 'list',
1047
+ message: 'Restriction mode:',
1048
+ choices: [
1049
+ { name: 'Allow only specific callers (allowFrom)', value: 'allow' },
1050
+ { name: 'Deny specific callers (denyFrom)', value: 'deny' },
1051
+ ],
1052
+ },
1053
+ });
1054
+ const { paths } = await inquirer.prompt({
1055
+ paths: {
1056
+ type: 'input',
1057
+ message: `Path globs (comma-separated) for ${mode === 'allow' ? 'allowFrom' : 'denyFrom'}:`,
1058
+ validate: (input) => input.trim() ? true : 'At least one path glob is required',
1059
+ },
1060
+ });
1061
+ const pathList = paths.split(',').map((s) => s.trim()).filter(Boolean);
1062
+ if (mode === 'allow') {
1063
+ rule.allowFrom = pathList;
1064
+ }
1065
+ else {
1066
+ rule.denyFrom = pathList;
877
1067
  }
1068
+ break;
1069
+ }
1070
+ case 'module-boundary': {
1071
+ const { from, to } = await inquirer.prompt({
1072
+ from: {
1073
+ type: 'input',
1074
+ message: 'From (path glob for files that must not import):',
1075
+ validate: (input) => input.trim() ? true : '"from" path glob is required',
1076
+ },
1077
+ to: {
1078
+ type: 'input',
1079
+ message: 'To (path glob for files that must not be imported):',
1080
+ validate: (input) => input.trim() ? true : '"to" path glob is required',
1081
+ },
1082
+ });
1083
+ rule.from = from;
1084
+ rule.to = to;
1085
+ break;
1086
+ }
1087
+ case 'naming': {
1088
+ const { path, exports: exportPattern } = await inquirer.prompt({
1089
+ path: {
1090
+ type: 'input',
1091
+ message: 'Path (glob for files this rule applies to):',
1092
+ validate: (input) => input.trim() ? true : 'Path glob is required',
1093
+ },
1094
+ exports: {
1095
+ type: 'input',
1096
+ message: 'Exports regex (exported symbols must match, e.g. "^use[A-Z]"):',
1097
+ validate: (input) => input.trim() ? true : 'Exports regex is required',
1098
+ },
1099
+ });
1100
+ rule.path = path;
1101
+ rule.exports = exportPattern;
1102
+ break;
1103
+ }
1104
+ case 'ast-pattern': {
1105
+ const { pattern } = await inquirer.prompt({
1106
+ pattern: {
1107
+ type: 'input',
1108
+ message: 'AST pattern (ast-grep syntax, e.g. "new Function($$$)"):',
1109
+ validate: (input) => input.trim() ? true : 'Pattern is required',
1110
+ },
1111
+ });
1112
+ rule.pattern = pattern;
1113
+ const { addLanguage } = await inquirer.prompt({
1114
+ addLanguage: {
1115
+ type: 'confirm',
1116
+ message: 'Restrict to a specific language? (default: typescript)',
1117
+ default: false,
1118
+ },
1119
+ });
1120
+ if (addLanguage) {
1121
+ const { language } = await inquirer.prompt({
1122
+ language: {
1123
+ type: 'list',
1124
+ message: 'Language:',
1125
+ choices: ['typescript', 'javascript', 'go'],
1126
+ },
1127
+ });
1128
+ rule.language = language;
1129
+ }
1130
+ break;
878
1131
  }
879
- // Show success and instructions
880
- console.log(chalk.green(`āœ“ Generated ${generator.getToolName()} configuration: ${config.filename}`));
881
- console.log(chalk.dim('Instructions:'));
882
- console.log(chalk.gray(config.instructions.trim()));
883
- console.log('');
884
- }
885
- catch (error) {
886
- errors.push(`Failed to generate config for ${tool}: ${error}`);
887
1132
  }
888
- }
889
- // Show summary
890
- if (generatedFiles.length > 0) {
891
- prompts.displaySuccess(generatedFiles);
892
- }
893
- if (errors.length > 0) {
894
- console.log(chalk.red('\nErrors:'));
895
- errors.forEach(error => {
896
- console.log(chalk.red(` • ${error}`));
1133
+ rules.push(rule);
1134
+ console.log(chalk.green(` āœ“ Added rule "${rule.id}" [${rule.kind}]`));
1135
+ const { cont } = await inquirer.prompt({
1136
+ cont: {
1137
+ type: 'confirm',
1138
+ message: 'Add another rule?',
1139
+ default: true,
1140
+ },
897
1141
  });
1142
+ addMore = cont;
898
1143
  }
1144
+ return {
1145
+ $schema: 'https://unpkg.com/code-auditor-mcp/dist/invariant-rules.schema.json',
1146
+ rules,
1147
+ };
1148
+ }
1149
+ async function writeConfigFile(outputPath, config) {
1150
+ await fs.mkdir(dirname(outputPath), { recursive: true });
1151
+ await fs.writeFile(outputPath, JSON.stringify(config, null, 2) + '\n');
1152
+ console.log(chalk.green(`\nāœ“ Written .codeauditor.json to ${outputPath}`));
899
1153
  }
900
1154
  /**
901
1155
  * Generate code map for a project