@rigour-labs/core 2.19.1 → 2.20.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.
@@ -72,4 +72,158 @@ describe('Context Awareness Engine', () => {
72
72
  const specificFailures = driftFailures.filter(f => f.files?.includes('valid.js'));
73
73
  expect(specificFailures.length).toBe(0);
74
74
  });
75
+ it('should classify arrow function exports as camelCase, not unknown', async () => {
76
+ // Create files with arrow function patterns that previously returned 'unknown'
77
+ await fs.writeFile(path.join(TEST_CWD, 'api.ts'), `
78
+ export const fetchData = async () => { return []; };
79
+ export const getUserProfile = async (id: string) => { return {}; };
80
+ export const use = () => {};
81
+ export const get = async () => {};
82
+ const handleClick = (e: Event) => {};
83
+ let processItem = async (item: any) => {};
84
+ `);
85
+ // Create a second file with consistent arrow function naming
86
+ await fs.writeFile(path.join(TEST_CWD, 'service.ts'), `
87
+ export const createUser = async (data: any) => {};
88
+ export const deleteUser = async (id: string) => {};
89
+ export const updateUser = async (id: string, data: any) => {};
90
+ `);
91
+ const config = {
92
+ version: 1,
93
+ commands: {},
94
+ gates: {
95
+ context: {
96
+ enabled: true,
97
+ sensitivity: 0.8,
98
+ mining_depth: 10,
99
+ ignored_patterns: [],
100
+ cross_file_patterns: true,
101
+ naming_consistency: true,
102
+ import_relationships: true,
103
+ max_cross_file_depth: 50,
104
+ },
105
+ },
106
+ output: { report_path: 'rigour-report.json' }
107
+ };
108
+ const runner = new GateRunner(config);
109
+ const report = await runner.run(TEST_CWD);
110
+ // Should NOT have any "unknown" naming convention failures
111
+ const namingFailures = report.failures.filter(f => f.id === 'context-drift' && f.details?.includes('unknown'));
112
+ expect(namingFailures.length).toBe(0);
113
+ });
114
+ it('should not classify plain variable declarations as function patterns', async () => {
115
+ // Create file with non-function const declarations
116
+ await fs.writeFile(path.join(TEST_CWD, 'constants.ts'), `
117
+ export const API_URL = 'https://api.example.com';
118
+ export const MAX_RETRIES = 3;
119
+ const config = { timeout: 5000 };
120
+ let count = 0;
121
+ `);
122
+ // Create file with actual functions for a dominant pattern
123
+ await fs.writeFile(path.join(TEST_CWD, 'utils.ts'), `
124
+ function getData() { return []; }
125
+ function setData(d: any) { return d; }
126
+ function processRequest(req: any) { return req; }
127
+ `);
128
+ const config = {
129
+ version: 1,
130
+ commands: {},
131
+ gates: {
132
+ context: {
133
+ enabled: true,
134
+ sensitivity: 0.8,
135
+ mining_depth: 10,
136
+ ignored_patterns: [],
137
+ cross_file_patterns: true,
138
+ naming_consistency: true,
139
+ import_relationships: true,
140
+ max_cross_file_depth: 50,
141
+ },
142
+ },
143
+ output: { report_path: 'rigour-report.json' }
144
+ };
145
+ const runner = new GateRunner(config);
146
+ const report = await runner.run(TEST_CWD);
147
+ // SCREAMING_SNAKE constants should NOT create naming drift failures
148
+ // because they should not be in the 'function' pattern bucket at all
149
+ const namingFailures = report.failures.filter(f => f.id === 'context-drift' && f.details?.includes('SCREAMING_SNAKE'));
150
+ expect(namingFailures.length).toBe(0);
151
+ });
152
+ });
153
+ /**
154
+ * Direct unit tests for detectCasing logic
155
+ */
156
+ describe('detectCasing classification', () => {
157
+ // We test the regex rules directly since detectCasing is private
158
+ function detectCasing(name) {
159
+ if (/^[A-Z][a-z]/.test(name) && /[a-z][A-Z]/.test(name))
160
+ return 'PascalCase';
161
+ if (/^[a-z]/.test(name) && /[a-z][A-Z]/.test(name))
162
+ return 'camelCase';
163
+ if (/^[a-z][a-zA-Z0-9]*$/.test(name))
164
+ return 'camelCase'; // single-word lowercase
165
+ if (/^[a-z]+(_[a-z]+)+$/.test(name))
166
+ return 'snake_case';
167
+ if (/^[A-Z]+(_[A-Z]+)*$/.test(name))
168
+ return 'SCREAMING_SNAKE';
169
+ if (/^[A-Z][a-zA-Z]*$/.test(name))
170
+ return 'PascalCase';
171
+ return 'unknown';
172
+ }
173
+ // Multi-word camelCase
174
+ it('classifies multi-word camelCase', () => {
175
+ expect(detectCasing('fetchData')).toBe('camelCase');
176
+ expect(detectCasing('getUserProfile')).toBe('camelCase');
177
+ expect(detectCasing('handleClick')).toBe('camelCase');
178
+ expect(detectCasing('processItem')).toBe('camelCase');
179
+ expect(detectCasing('createNewUser')).toBe('camelCase');
180
+ });
181
+ // Single-word lowercase (the bug fix)
182
+ it('classifies single-word lowercase as camelCase', () => {
183
+ expect(detectCasing('fetch')).toBe('camelCase');
184
+ expect(detectCasing('use')).toBe('camelCase');
185
+ expect(detectCasing('get')).toBe('camelCase');
186
+ expect(detectCasing('set')).toBe('camelCase');
187
+ expect(detectCasing('run')).toBe('camelCase');
188
+ expect(detectCasing('a')).toBe('camelCase');
189
+ expect(detectCasing('x')).toBe('camelCase');
190
+ expect(detectCasing('id')).toBe('camelCase');
191
+ });
192
+ // Single-word lowercase with digits
193
+ it('classifies lowercase with digits as camelCase', () => {
194
+ expect(detectCasing('handler2')).toBe('camelCase');
195
+ expect(detectCasing('config3')).toBe('camelCase');
196
+ expect(detectCasing('v2')).toBe('camelCase');
197
+ });
198
+ // PascalCase
199
+ it('classifies PascalCase', () => {
200
+ expect(detectCasing('MyComponent')).toBe('PascalCase');
201
+ expect(detectCasing('UserService')).toBe('PascalCase');
202
+ expect(detectCasing('App')).toBe('PascalCase');
203
+ expect(detectCasing('A')).toBe('SCREAMING_SNAKE'); // single uppercase letter
204
+ });
205
+ // snake_case
206
+ it('classifies snake_case', () => {
207
+ expect(detectCasing('my_func')).toBe('snake_case');
208
+ expect(detectCasing('get_data')).toBe('snake_case');
209
+ expect(detectCasing('process_all_items')).toBe('snake_case');
210
+ });
211
+ // SCREAMING_SNAKE
212
+ it('classifies SCREAMING_SNAKE_CASE', () => {
213
+ expect(detectCasing('API_URL')).toBe('SCREAMING_SNAKE');
214
+ expect(detectCasing('MAX_RETRIES')).toBe('SCREAMING_SNAKE');
215
+ expect(detectCasing('A')).toBe('SCREAMING_SNAKE');
216
+ expect(detectCasing('DB')).toBe('SCREAMING_SNAKE');
217
+ });
218
+ // Edge cases that should NOT be unknown
219
+ it('does not return unknown for valid identifiers', () => {
220
+ const validIdentifiers = [
221
+ 'fetch', 'getData', 'MyClass', 'my_func', 'API_KEY',
222
+ 'use', 'run', 'a', 'x', 'id', 'App', 'handler2',
223
+ 'processItem', 'UserProfile', 'get_all_data', 'MAX_SIZE',
224
+ ];
225
+ for (const name of validIdentifiers) {
226
+ expect(detectCasing(name)).not.toBe('unknown');
227
+ }
228
+ });
75
229
  });
@@ -57,9 +57,10 @@ export class TypeScriptHandler extends ASTHandler {
57
57
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
58
58
  addFailure({
59
59
  id: 'STALENESS_NO_VAR',
60
- title: `Stale 'var' keyword at line ${line}`,
60
+ title: `Stale 'var' keyword`,
61
61
  details: `Use 'const' or 'let' instead of 'var' in ${relativePath}:${line}`,
62
62
  files: [relativePath],
63
+ line,
63
64
  hint: `Replace 'var' with 'const' (preferred) or 'let' for modern JavaScript.`
64
65
  });
65
66
  }
@@ -70,9 +71,10 @@ export class TypeScriptHandler extends ASTHandler {
70
71
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
71
72
  addFailure({
72
73
  id: 'STALENESS_NO_COMMONJS',
73
- title: `CommonJS require() at line ${line}`,
74
+ title: `CommonJS require()`,
74
75
  details: `Use ES6 'import' instead of 'require()' in ${relativePath}:${line}`,
75
76
  files: [relativePath],
77
+ line,
76
78
  hint: `Replace require('module') with import module from 'module'.`
77
79
  });
78
80
  }
@@ -85,9 +87,10 @@ export class TypeScriptHandler extends ASTHandler {
85
87
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
86
88
  addFailure({
87
89
  id: 'STALENESS_NO_ARGUMENTS',
88
- title: `Legacy 'arguments' object at line ${line}`,
90
+ title: `Legacy 'arguments' object`,
89
91
  details: `Use rest parameters (...args) instead of 'arguments' in ${relativePath}:${line}`,
90
92
  files: [relativePath],
93
+ line,
91
94
  hint: `Replace 'arguments' with rest parameters: function(...args) { }`
92
95
  });
93
96
  }
@@ -98,9 +101,10 @@ export class TypeScriptHandler extends ASTHandler {
98
101
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
99
102
  addFailure({
100
103
  id: 'SECURITY_PROTOTYPE_POLLUTION',
101
- title: `Direct __proto__ access at line ${line}`,
104
+ title: `Direct __proto__ access`,
102
105
  details: `Prototype pollution vulnerability in ${relativePath}:${line}`,
103
106
  files: [relativePath],
107
+ line,
104
108
  hint: `Use Object.getPrototypeOf() or Object.setPrototypeOf() instead of __proto__.`
105
109
  });
106
110
  }
@@ -111,9 +115,10 @@ export class TypeScriptHandler extends ASTHandler {
111
115
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
112
116
  addFailure({
113
117
  id: 'SECURITY_PROTOTYPE_POLLUTION',
114
- title: `Unsafe bracket notation access to '${accessKey}' at line ${line}`,
118
+ title: `Unsafe bracket notation access to '${accessKey}'`,
115
119
  details: `Potential prototype pollution via bracket notation in ${relativePath}:${line}`,
116
120
  files: [relativePath],
121
+ line,
117
122
  hint: `Block access to '${accessKey}' property when handling user input. Use allowlist for object keys.`
118
123
  });
119
124
  }
@@ -130,9 +135,10 @@ export class TypeScriptHandler extends ASTHandler {
130
135
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
131
136
  addFailure({
132
137
  id: 'SECURITY_PROTOTYPE_POLLUTION_MERGE',
133
- title: `Object.assign() merge pattern at line ${line}`,
138
+ title: `Object.assign() merge pattern`,
134
139
  details: `Object.assign({}, ...) can propagate prototype pollution in ${relativePath}:${line}`,
135
140
  files: [relativePath],
141
+ line,
136
142
  hint: `Validate and sanitize source objects before merging. Block __proto__ and constructor keys.`
137
143
  });
138
144
  }
@@ -11,5 +11,5 @@ export declare abstract class Gate {
11
11
  readonly title: string;
12
12
  constructor(id: string, title: string);
13
13
  abstract run(context: GateContext): Promise<Failure[]>;
14
- protected createFailure(details: string, files?: string[], hint?: string, title?: string): Failure;
14
+ protected createFailure(details: string, files?: string[], hint?: string, title?: string, line?: number, endLine?: number): Failure;
15
15
  }
@@ -5,12 +5,14 @@ export class Gate {
5
5
  this.id = id;
6
6
  this.title = title;
7
7
  }
8
- createFailure(details, files, hint, title) {
8
+ createFailure(details, files, hint, title, line, endLine) {
9
9
  return {
10
10
  id: this.id,
11
11
  title: title || this.title,
12
12
  details,
13
13
  files,
14
+ line,
15
+ endLine,
14
16
  hint,
15
17
  };
16
18
  }
@@ -20,20 +20,17 @@ export class ContentGate extends Gate {
20
20
  patterns: context.patterns
21
21
  });
22
22
  const contents = await FileScanner.readFiles(context.cwd, files);
23
- const violations = [];
23
+ const failures = [];
24
24
  for (const [file, content] of contents) {
25
- for (const pattern of patterns) {
26
- if (pattern.test(content)) {
27
- violations.push(file);
28
- break;
25
+ const lines = content.split('\n');
26
+ lines.forEach((line, index) => {
27
+ for (const pattern of patterns) {
28
+ if (pattern.test(line)) {
29
+ failures.push(this.createFailure(`Forbidden placeholder '${pattern.source}' found`, [file], 'Remove forbidden comments. address the root cause or create a tracked issue.', undefined, index + 1, index + 1));
30
+ }
29
31
  }
30
- }
32
+ });
31
33
  }
32
- if (violations.length > 0) {
33
- return [
34
- this.createFailure('Forbidden placeholders found in the following files:', violations, 'Remove all TODO and FIXME comments. Use the "Done is Done" mentality—address the root cause or create a tracked issue.'),
35
- ];
36
- }
37
- return [];
34
+ return failures;
38
35
  }
39
36
  }
@@ -70,11 +70,22 @@ export class ContextGate extends Gate {
70
70
  * Collect naming patterns (function names, class names, variable names)
71
71
  */
72
72
  collectNamingPatterns(content, file, patterns) {
73
- // Function declarations
74
- const funcMatches = content.matchAll(/(?:function|const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[=(]/g);
75
- for (const match of funcMatches) {
76
- const name = match[1];
77
- const casing = this.detectCasing(name);
73
+ // Named function declarations: function fetchData() { ... }
74
+ const namedFuncMatches = content.matchAll(/function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g);
75
+ for (const match of namedFuncMatches) {
76
+ const casing = this.detectCasing(match[1]);
77
+ this.addPattern(patterns, 'function', { casing, file, count: 1 });
78
+ }
79
+ // Arrow function expressions: (export) const fetchData = (async) (...) => { ... }
80
+ const arrowFuncMatches = content.matchAll(/(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/g);
81
+ for (const match of arrowFuncMatches) {
82
+ const casing = this.detectCasing(match[1]);
83
+ this.addPattern(patterns, 'function', { casing, file, count: 1 });
84
+ }
85
+ // Function expressions: (export) const fetchData = (async) function(...) { ... }
86
+ const funcExprMatches = content.matchAll(/(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?function\s*\(/g);
87
+ for (const match of funcExprMatches) {
88
+ const casing = this.detectCasing(match[1]);
78
89
  this.addPattern(patterns, 'function', { casing, file, count: 1 });
79
90
  }
80
91
  // Class declarations
@@ -175,6 +186,8 @@ export class ContextGate extends Gate {
175
186
  return 'PascalCase';
176
187
  if (/^[a-z]/.test(name) && /[a-z][A-Z]/.test(name))
177
188
  return 'camelCase';
189
+ if (/^[a-z][a-zA-Z0-9]*$/.test(name))
190
+ return 'camelCase'; // single-word lowercase (e.g. fetch, use, get)
178
191
  if (/^[a-z]+(_[a-z]+)+$/.test(name))
179
192
  return 'snake_case';
180
193
  if (/^[A-Z]+(_[A-Z]+)*$/.test(name))
@@ -186,7 +186,7 @@ export class SecurityPatternsGate extends Gate {
186
186
  const blockThreshold = this.severityOrder[this.config.block_on_severity ?? 'high'];
187
187
  for (const vuln of filteredVulns) {
188
188
  if (this.severityOrder[vuln.severity] <= blockThreshold) {
189
- failures.push(this.createFailure(`[${vuln.cwe}] ${vuln.description} at line ${vuln.line}`, [vuln.file], `Found: "${vuln.match.slice(0, 60)}..." - Use parameterized queries/sanitization.`, `Security: ${vuln.type.replace('_', ' ').toUpperCase()}`));
189
+ failures.push(this.createFailure(`[${vuln.cwe}] ${vuln.description}`, [vuln.file], `Found: "${vuln.match.slice(0, 60)}..." - Use parameterized queries/sanitization.`, `Security: ${vuln.type.replace('_', ' ').toUpperCase()}`, vuln.line, vuln.line));
190
190
  }
191
191
  }
192
192
  if (filteredVulns.length > 0 && failures.length === 0) {
@@ -1080,18 +1080,24 @@ export declare const FailureSchema: z.ZodObject<{
1080
1080
  title: z.ZodString;
1081
1081
  details: z.ZodString;
1082
1082
  files: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1083
+ line: z.ZodOptional<z.ZodNumber>;
1084
+ endLine: z.ZodOptional<z.ZodNumber>;
1083
1085
  hint: z.ZodOptional<z.ZodString>;
1084
1086
  }, "strip", z.ZodTypeAny, {
1085
1087
  id: string;
1086
1088
  title: string;
1087
1089
  details: string;
1088
1090
  files?: string[] | undefined;
1091
+ line?: number | undefined;
1092
+ endLine?: number | undefined;
1089
1093
  hint?: string | undefined;
1090
1094
  }, {
1091
1095
  id: string;
1092
1096
  title: string;
1093
1097
  details: string;
1094
1098
  files?: string[] | undefined;
1099
+ line?: number | undefined;
1100
+ endLine?: number | undefined;
1095
1101
  hint?: string | undefined;
1096
1102
  }>;
1097
1103
  export type Failure = z.infer<typeof FailureSchema>;
@@ -1103,18 +1109,24 @@ export declare const ReportSchema: z.ZodObject<{
1103
1109
  title: z.ZodString;
1104
1110
  details: z.ZodString;
1105
1111
  files: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
1112
+ line: z.ZodOptional<z.ZodNumber>;
1113
+ endLine: z.ZodOptional<z.ZodNumber>;
1106
1114
  hint: z.ZodOptional<z.ZodString>;
1107
1115
  }, "strip", z.ZodTypeAny, {
1108
1116
  id: string;
1109
1117
  title: string;
1110
1118
  details: string;
1111
1119
  files?: string[] | undefined;
1120
+ line?: number | undefined;
1121
+ endLine?: number | undefined;
1112
1122
  hint?: string | undefined;
1113
1123
  }, {
1114
1124
  id: string;
1115
1125
  title: string;
1116
1126
  details: string;
1117
1127
  files?: string[] | undefined;
1128
+ line?: number | undefined;
1129
+ endLine?: number | undefined;
1118
1130
  hint?: string | undefined;
1119
1131
  }>, "many">;
1120
1132
  stats: z.ZodObject<{
@@ -1139,6 +1151,8 @@ export declare const ReportSchema: z.ZodObject<{
1139
1151
  title: string;
1140
1152
  details: string;
1141
1153
  files?: string[] | undefined;
1154
+ line?: number | undefined;
1155
+ endLine?: number | undefined;
1142
1156
  hint?: string | undefined;
1143
1157
  }[];
1144
1158
  }, {
@@ -1153,6 +1167,8 @@ export declare const ReportSchema: z.ZodObject<{
1153
1167
  title: string;
1154
1168
  details: string;
1155
1169
  files?: string[] | undefined;
1170
+ line?: number | undefined;
1171
+ endLine?: number | undefined;
1156
1172
  hint?: string | undefined;
1157
1173
  }[];
1158
1174
  }>;
@@ -127,6 +127,8 @@ export const FailureSchema = z.object({
127
127
  title: z.string(),
128
128
  details: z.string(),
129
129
  files: z.array(z.string()).optional(),
130
+ line: z.number().optional(),
131
+ endLine: z.number().optional(),
130
132
  hint: z.string().optional(),
131
133
  });
132
134
  export const ReportSchema = z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "2.19.1",
3
+ "version": "2.20.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -84,4 +84,173 @@ describe('Context Awareness Engine', () => {
84
84
  const specificFailures = driftFailures.filter(f => f.files?.includes('valid.js'));
85
85
  expect(specificFailures.length).toBe(0);
86
86
  });
87
+ it('should classify arrow function exports as camelCase, not unknown', async () => {
88
+ // Create files with arrow function patterns that previously returned 'unknown'
89
+ await fs.writeFile(path.join(TEST_CWD, 'api.ts'), `
90
+ export const fetchData = async () => { return []; };
91
+ export const getUserProfile = async (id: string) => { return {}; };
92
+ export const use = () => {};
93
+ export const get = async () => {};
94
+ const handleClick = (e: Event) => {};
95
+ let processItem = async (item: any) => {};
96
+ `);
97
+
98
+ // Create a second file with consistent arrow function naming
99
+ await fs.writeFile(path.join(TEST_CWD, 'service.ts'), `
100
+ export const createUser = async (data: any) => {};
101
+ export const deleteUser = async (id: string) => {};
102
+ export const updateUser = async (id: string, data: any) => {};
103
+ `);
104
+
105
+ const config = {
106
+ version: 1,
107
+ commands: {},
108
+ gates: {
109
+ context: {
110
+ enabled: true,
111
+ sensitivity: 0.8,
112
+ mining_depth: 10,
113
+ ignored_patterns: [],
114
+ cross_file_patterns: true,
115
+ naming_consistency: true,
116
+ import_relationships: true,
117
+ max_cross_file_depth: 50,
118
+ },
119
+ },
120
+ output: { report_path: 'rigour-report.json' }
121
+ };
122
+
123
+ const runner = new GateRunner(config as any);
124
+ const report = await runner.run(TEST_CWD);
125
+
126
+ // Should NOT have any "unknown" naming convention failures
127
+ const namingFailures = report.failures.filter(f =>
128
+ f.id === 'context-drift' && f.details?.includes('unknown')
129
+ );
130
+ expect(namingFailures.length).toBe(0);
131
+ });
132
+
133
+ it('should not classify plain variable declarations as function patterns', async () => {
134
+ // Create file with non-function const declarations
135
+ await fs.writeFile(path.join(TEST_CWD, 'constants.ts'), `
136
+ export const API_URL = 'https://api.example.com';
137
+ export const MAX_RETRIES = 3;
138
+ const config = { timeout: 5000 };
139
+ let count = 0;
140
+ `);
141
+
142
+ // Create file with actual functions for a dominant pattern
143
+ await fs.writeFile(path.join(TEST_CWD, 'utils.ts'), `
144
+ function getData() { return []; }
145
+ function setData(d: any) { return d; }
146
+ function processRequest(req: any) { return req; }
147
+ `);
148
+
149
+ const config = {
150
+ version: 1,
151
+ commands: {},
152
+ gates: {
153
+ context: {
154
+ enabled: true,
155
+ sensitivity: 0.8,
156
+ mining_depth: 10,
157
+ ignored_patterns: [],
158
+ cross_file_patterns: true,
159
+ naming_consistency: true,
160
+ import_relationships: true,
161
+ max_cross_file_depth: 50,
162
+ },
163
+ },
164
+ output: { report_path: 'rigour-report.json' }
165
+ };
166
+
167
+ const runner = new GateRunner(config as any);
168
+ const report = await runner.run(TEST_CWD);
169
+
170
+ // SCREAMING_SNAKE constants should NOT create naming drift failures
171
+ // because they should not be in the 'function' pattern bucket at all
172
+ const namingFailures = report.failures.filter(f =>
173
+ f.id === 'context-drift' && f.details?.includes('SCREAMING_SNAKE')
174
+ );
175
+ expect(namingFailures.length).toBe(0);
176
+ });
177
+ });
178
+
179
+ /**
180
+ * Direct unit tests for detectCasing logic
181
+ */
182
+ describe('detectCasing classification', () => {
183
+ // We test the regex rules directly since detectCasing is private
184
+ function detectCasing(name: string): string {
185
+ if (/^[A-Z][a-z]/.test(name) && /[a-z][A-Z]/.test(name)) return 'PascalCase';
186
+ if (/^[a-z]/.test(name) && /[a-z][A-Z]/.test(name)) return 'camelCase';
187
+ if (/^[a-z][a-zA-Z0-9]*$/.test(name)) return 'camelCase'; // single-word lowercase
188
+ if (/^[a-z]+(_[a-z]+)+$/.test(name)) return 'snake_case';
189
+ if (/^[A-Z]+(_[A-Z]+)*$/.test(name)) return 'SCREAMING_SNAKE';
190
+ if (/^[A-Z][a-zA-Z]*$/.test(name)) return 'PascalCase';
191
+ return 'unknown';
192
+ }
193
+
194
+ // Multi-word camelCase
195
+ it('classifies multi-word camelCase', () => {
196
+ expect(detectCasing('fetchData')).toBe('camelCase');
197
+ expect(detectCasing('getUserProfile')).toBe('camelCase');
198
+ expect(detectCasing('handleClick')).toBe('camelCase');
199
+ expect(detectCasing('processItem')).toBe('camelCase');
200
+ expect(detectCasing('createNewUser')).toBe('camelCase');
201
+ });
202
+
203
+ // Single-word lowercase (the bug fix)
204
+ it('classifies single-word lowercase as camelCase', () => {
205
+ expect(detectCasing('fetch')).toBe('camelCase');
206
+ expect(detectCasing('use')).toBe('camelCase');
207
+ expect(detectCasing('get')).toBe('camelCase');
208
+ expect(detectCasing('set')).toBe('camelCase');
209
+ expect(detectCasing('run')).toBe('camelCase');
210
+ expect(detectCasing('a')).toBe('camelCase');
211
+ expect(detectCasing('x')).toBe('camelCase');
212
+ expect(detectCasing('id')).toBe('camelCase');
213
+ });
214
+
215
+ // Single-word lowercase with digits
216
+ it('classifies lowercase with digits as camelCase', () => {
217
+ expect(detectCasing('handler2')).toBe('camelCase');
218
+ expect(detectCasing('config3')).toBe('camelCase');
219
+ expect(detectCasing('v2')).toBe('camelCase');
220
+ });
221
+
222
+ // PascalCase
223
+ it('classifies PascalCase', () => {
224
+ expect(detectCasing('MyComponent')).toBe('PascalCase');
225
+ expect(detectCasing('UserService')).toBe('PascalCase');
226
+ expect(detectCasing('App')).toBe('PascalCase');
227
+ expect(detectCasing('A')).toBe('SCREAMING_SNAKE'); // single uppercase letter
228
+ });
229
+
230
+ // snake_case
231
+ it('classifies snake_case', () => {
232
+ expect(detectCasing('my_func')).toBe('snake_case');
233
+ expect(detectCasing('get_data')).toBe('snake_case');
234
+ expect(detectCasing('process_all_items')).toBe('snake_case');
235
+ });
236
+
237
+ // SCREAMING_SNAKE
238
+ it('classifies SCREAMING_SNAKE_CASE', () => {
239
+ expect(detectCasing('API_URL')).toBe('SCREAMING_SNAKE');
240
+ expect(detectCasing('MAX_RETRIES')).toBe('SCREAMING_SNAKE');
241
+ expect(detectCasing('A')).toBe('SCREAMING_SNAKE');
242
+ expect(detectCasing('DB')).toBe('SCREAMING_SNAKE');
243
+ });
244
+
245
+ // Edge cases that should NOT be unknown
246
+ it('does not return unknown for valid identifiers', () => {
247
+ const validIdentifiers = [
248
+ 'fetch', 'getData', 'MyClass', 'my_func', 'API_KEY',
249
+ 'use', 'run', 'a', 'x', 'id', 'App', 'handler2',
250
+ 'processItem', 'UserProfile', 'get_all_data', 'MAX_SIZE',
251
+ ];
252
+ for (const name of validIdentifiers) {
253
+ expect(detectCasing(name)).not.toBe('unknown');
254
+ }
255
+ });
87
256
  });
@@ -65,9 +65,10 @@ export class TypeScriptHandler extends ASTHandler {
65
65
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
66
66
  addFailure({
67
67
  id: 'STALENESS_NO_VAR',
68
- title: `Stale 'var' keyword at line ${line}`,
68
+ title: `Stale 'var' keyword`,
69
69
  details: `Use 'const' or 'let' instead of 'var' in ${relativePath}:${line}`,
70
70
  files: [relativePath],
71
+ line,
71
72
  hint: `Replace 'var' with 'const' (preferred) or 'let' for modern JavaScript.`
72
73
  });
73
74
  }
@@ -79,9 +80,10 @@ export class TypeScriptHandler extends ASTHandler {
79
80
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
80
81
  addFailure({
81
82
  id: 'STALENESS_NO_COMMONJS',
82
- title: `CommonJS require() at line ${line}`,
83
+ title: `CommonJS require()`,
83
84
  details: `Use ES6 'import' instead of 'require()' in ${relativePath}:${line}`,
84
85
  files: [relativePath],
86
+ line,
85
87
  hint: `Replace require('module') with import module from 'module'.`
86
88
  });
87
89
  }
@@ -95,9 +97,10 @@ export class TypeScriptHandler extends ASTHandler {
95
97
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
96
98
  addFailure({
97
99
  id: 'STALENESS_NO_ARGUMENTS',
98
- title: `Legacy 'arguments' object at line ${line}`,
100
+ title: `Legacy 'arguments' object`,
99
101
  details: `Use rest parameters (...args) instead of 'arguments' in ${relativePath}:${line}`,
100
102
  files: [relativePath],
103
+ line,
101
104
  hint: `Replace 'arguments' with rest parameters: function(...args) { }`
102
105
  });
103
106
  }
@@ -110,9 +113,10 @@ export class TypeScriptHandler extends ASTHandler {
110
113
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
111
114
  addFailure({
112
115
  id: 'SECURITY_PROTOTYPE_POLLUTION',
113
- title: `Direct __proto__ access at line ${line}`,
116
+ title: `Direct __proto__ access`,
114
117
  details: `Prototype pollution vulnerability in ${relativePath}:${line}`,
115
118
  files: [relativePath],
119
+ line,
116
120
  hint: `Use Object.getPrototypeOf() or Object.setPrototypeOf() instead of __proto__.`
117
121
  });
118
122
  }
@@ -124,9 +128,10 @@ export class TypeScriptHandler extends ASTHandler {
124
128
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
125
129
  addFailure({
126
130
  id: 'SECURITY_PROTOTYPE_POLLUTION',
127
- title: `Unsafe bracket notation access to '${accessKey}' at line ${line}`,
131
+ title: `Unsafe bracket notation access to '${accessKey}'`,
128
132
  details: `Potential prototype pollution via bracket notation in ${relativePath}:${line}`,
129
133
  files: [relativePath],
134
+ line,
130
135
  hint: `Block access to '${accessKey}' property when handling user input. Use allowlist for object keys.`
131
136
  });
132
137
  }
@@ -144,9 +149,10 @@ export class TypeScriptHandler extends ASTHandler {
144
149
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
145
150
  addFailure({
146
151
  id: 'SECURITY_PROTOTYPE_POLLUTION_MERGE',
147
- title: `Object.assign() merge pattern at line ${line}`,
152
+ title: `Object.assign() merge pattern`,
148
153
  details: `Object.assign({}, ...) can propagate prototype pollution in ${relativePath}:${line}`,
149
154
  files: [relativePath],
155
+ line,
150
156
  hint: `Validate and sanitize source objects before merging. Block __proto__ and constructor keys.`
151
157
  });
152
158
  }
package/src/gates/base.ts CHANGED
@@ -13,12 +13,14 @@ export abstract class Gate {
13
13
 
14
14
  abstract run(context: GateContext): Promise<Failure[]>;
15
15
 
16
- protected createFailure(details: string, files?: string[], hint?: string, title?: string): Failure {
16
+ protected createFailure(details: string, files?: string[], hint?: string, title?: string, line?: number, endLine?: number): Failure {
17
17
  return {
18
18
  id: this.id,
19
19
  title: title || this.title,
20
20
  details,
21
21
  files,
22
+ line,
23
+ endLine,
22
24
  hint,
23
25
  };
24
26
  }
@@ -13,7 +13,7 @@ export class ContentGate extends Gate {
13
13
  }
14
14
 
15
15
  async run(context: GateContext): Promise<Failure[]> {
16
- const patterns = [];
16
+ const patterns: RegExp[] = [];
17
17
  if (this.config.forbidTodos) patterns.push(/TODO/i);
18
18
  if (this.config.forbidFixme) patterns.push(/FIXME/i);
19
19
 
@@ -26,26 +26,25 @@ export class ContentGate extends Gate {
26
26
  });
27
27
  const contents = await FileScanner.readFiles(context.cwd, files);
28
28
 
29
- const violations: string[] = [];
29
+ const failures: Failure[] = [];
30
30
  for (const [file, content] of contents) {
31
- for (const pattern of patterns) {
32
- if (pattern.test(content)) {
33
- violations.push(file);
34
- break;
31
+ const lines = content.split('\n');
32
+ lines.forEach((line, index) => {
33
+ for (const pattern of patterns) {
34
+ if (pattern.test(line)) {
35
+ failures.push(this.createFailure(
36
+ `Forbidden placeholder '${pattern.source}' found`,
37
+ [file],
38
+ 'Remove forbidden comments. address the root cause or create a tracked issue.',
39
+ undefined,
40
+ index + 1,
41
+ index + 1
42
+ ));
43
+ }
35
44
  }
36
- }
45
+ });
37
46
  }
38
47
 
39
- if (violations.length > 0) {
40
- return [
41
- this.createFailure(
42
- 'Forbidden placeholders found in the following files:',
43
- violations,
44
- 'Remove all TODO and FIXME comments. Use the "Done is Done" mentality—address the root cause or create a tracked issue.'
45
- ),
46
- ];
47
- }
48
-
49
- return [];
48
+ return failures;
50
49
  }
51
50
  }
@@ -107,11 +107,24 @@ export class ContextGate extends Gate {
107
107
  file: string,
108
108
  patterns: Map<string, { casing: string; file: string; count: number }[]>
109
109
  ) {
110
- // Function declarations
111
- const funcMatches = content.matchAll(/(?:function|const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*[=(]/g);
112
- for (const match of funcMatches) {
113
- const name = match[1];
114
- const casing = this.detectCasing(name);
110
+ // Named function declarations: function fetchData() { ... }
111
+ const namedFuncMatches = content.matchAll(/function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/g);
112
+ for (const match of namedFuncMatches) {
113
+ const casing = this.detectCasing(match[1]);
114
+ this.addPattern(patterns, 'function', { casing, file, count: 1 });
115
+ }
116
+
117
+ // Arrow function expressions: (export) const fetchData = (async) (...) => { ... }
118
+ const arrowFuncMatches = content.matchAll(/(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*=>/g);
119
+ for (const match of arrowFuncMatches) {
120
+ const casing = this.detectCasing(match[1]);
121
+ this.addPattern(patterns, 'function', { casing, file, count: 1 });
122
+ }
123
+
124
+ // Function expressions: (export) const fetchData = (async) function(...) { ... }
125
+ const funcExprMatches = content.matchAll(/(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?function\s*\(/g);
126
+ for (const match of funcExprMatches) {
127
+ const casing = this.detectCasing(match[1]);
115
128
  this.addPattern(patterns, 'function', { casing, file, count: 1 });
116
129
  }
117
130
 
@@ -234,6 +247,7 @@ export class ContextGate extends Gate {
234
247
  private detectCasing(name: string): string {
235
248
  if (/^[A-Z][a-z]/.test(name) && /[a-z][A-Z]/.test(name)) return 'PascalCase';
236
249
  if (/^[a-z]/.test(name) && /[a-z][A-Z]/.test(name)) return 'camelCase';
250
+ if (/^[a-z][a-zA-Z0-9]*$/.test(name)) return 'camelCase'; // single-word lowercase (e.g. fetch, use, get)
237
251
  if (/^[a-z]+(_[a-z]+)+$/.test(name)) return 'snake_case';
238
252
  if (/^[A-Z]+(_[A-Z]+)*$/.test(name)) return 'SCREAMING_SNAKE';
239
253
  if (/^[A-Z][a-zA-Z]*$/.test(name)) return 'PascalCase';
@@ -231,10 +231,12 @@ export class SecurityPatternsGate extends Gate {
231
231
  for (const vuln of filteredVulns) {
232
232
  if (this.severityOrder[vuln.severity] <= blockThreshold) {
233
233
  failures.push(this.createFailure(
234
- `[${vuln.cwe}] ${vuln.description} at line ${vuln.line}`,
234
+ `[${vuln.cwe}] ${vuln.description}`,
235
235
  [vuln.file],
236
236
  `Found: "${vuln.match.slice(0, 60)}..." - Use parameterized queries/sanitization.`,
237
- `Security: ${vuln.type.replace('_', ' ').toUpperCase()}`
237
+ `Security: ${vuln.type.replace('_', ' ').toUpperCase()}`,
238
+ vuln.line,
239
+ vuln.line
238
240
  ));
239
241
  }
240
242
  }
@@ -141,6 +141,8 @@ export const FailureSchema = z.object({
141
141
  title: z.string(),
142
142
  details: z.string(),
143
143
  files: z.array(z.string()).optional(),
144
+ line: z.number().optional(),
145
+ endLine: z.number().optional(),
144
146
  hint: z.string().optional(),
145
147
  });
146
148
  export type Failure = z.infer<typeof FailureSchema>;