hana-linter 1.0.2 → 1.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/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ---
6
6
 
7
+ ### 1.1.0
8
+
9
+ - Console output is now grouped by file, then by failed rule, reducing noise when multiple violations exist in the same file
10
+ - Added line numbers to content-lint violations (fields, input/output parameters) so users can jump directly to the offending identifier in their editor
11
+ - All four Chevrotain visitors (`hdbtable`, `hdbview`, `hdbprocedure`, `hdbfunction`) now capture `token.startLine` from the parsed token and propagate it through `ExtractedSubject` and `LintIssue`
12
+
13
+ ---
14
+
15
+ ### 1.0.3
16
+
17
+ - Fixed build: `src/assets/.hana-linter.json` is now correctly copied to `dist/assets/` during `pnpm build` via `copyfiles`, ensuring the default config template is bundled in the published package
18
+
19
+ ---
20
+
7
21
  ### 1.0.2
8
22
 
9
23
  - Added Chevrotain-based `.hdbfunction` parser; content-linting of function input parameters is now fully reliable
@@ -0,0 +1,224 @@
1
+ {
2
+ "rootDir": "db",
3
+ "ignoredDirectories": ["node_modules", ".git", "gen"],
4
+ "extensionRuleSets": [
5
+ {
6
+ "extension": "hdb*",
7
+ "groups": {
8
+ "all": [
9
+ {
10
+ "description": "Upper snake case only",
11
+ "pattern": "^[A-Z0-9]+(?:_[A-Z0-9]+)*$"
12
+ },
13
+ {
14
+ "description": "Max length 30",
15
+ "pattern": "^.{1,30}$",
16
+ "flags": "u"
17
+ }
18
+ ]
19
+ }
20
+ },
21
+ {
22
+ "extension": ".hdbtable",
23
+ "folderName": "tables",
24
+ "groups": {
25
+ "any": [
26
+ {
27
+ "description": "Prefix T_",
28
+ "pattern": "^T_.+"
29
+ },
30
+ {
31
+ "description": "Prefix TX_",
32
+ "pattern": "^TX_.+"
33
+ }
34
+ ]
35
+ }
36
+ },
37
+ {
38
+ "extension": ".hdbview",
39
+ "folderName": "sqlviews",
40
+ "groups": {
41
+ "all": [
42
+ {
43
+ "description": "Prefix V_",
44
+ "pattern": "^V_.+"
45
+ }
46
+ ]
47
+ }
48
+ },
49
+ {
50
+ "extension": ".hdbfunction",
51
+ "folderName": "functions",
52
+ "groups": {
53
+ "any": [
54
+ {
55
+ "description": "Prefix TF_",
56
+ "pattern": "^TF_.+"
57
+ },
58
+ {
59
+ "description": "Prefix SF_",
60
+ "pattern": "^SF_.+"
61
+ }
62
+ ]
63
+ }
64
+ },
65
+ {
66
+ "extension": ".hdbprocedure",
67
+ "folderName": "procedures",
68
+ "groups": {
69
+ "all": [
70
+ {
71
+ "description": "Prefix PR_",
72
+ "pattern": "^PR_.+"
73
+ }
74
+ ]
75
+ }
76
+ },
77
+ {
78
+ "extension": ".hdbcalculationview",
79
+ "folderName": "models",
80
+ "groups": {
81
+ "all": [
82
+ {
83
+ "description": "Prefix CV_",
84
+ "pattern": "^CV_.+"
85
+ }
86
+ ]
87
+ }
88
+ },
89
+ {
90
+ "extension": ".hdbanalyticalprivilege",
91
+ "folderName": "authorizations",
92
+ "groups": {
93
+ "all": [
94
+ {
95
+ "description": "Prefix AP_",
96
+ "pattern": "^AP_.+"
97
+ }
98
+ ]
99
+ }
100
+ },
101
+ {
102
+ "extension": ".hdbrole",
103
+ "folderName": "roles",
104
+ "groups": {
105
+ "all": [
106
+ {
107
+ "description": "Prefix R_",
108
+ "pattern": "^R_.+"
109
+ }
110
+ ]
111
+ }
112
+ },
113
+ {
114
+ "extension": ".hdbsequence",
115
+ "folderName": "sequences",
116
+ "groups": {
117
+ "all": [
118
+ {
119
+ "description": "Prefix S_",
120
+ "pattern": "^S_.+"
121
+ }
122
+ ]
123
+ }
124
+ },
125
+ {
126
+ "extension": ".hdbtabletype",
127
+ "folderName": "tabletypes",
128
+ "groups": {
129
+ "all": [
130
+ {
131
+ "description": "Prefix TT_",
132
+ "pattern": "^TT_.+"
133
+ }
134
+ ]
135
+ }
136
+ },
137
+ {
138
+ "extension": ".hdbconstraint",
139
+ "folderName": "constraints",
140
+ "groups": {
141
+ "all": [
142
+ {
143
+ "description": "Prefix C_",
144
+ "pattern": "^C_.+"
145
+ }
146
+ ]
147
+ }
148
+ },
149
+ {
150
+ "extension": ".hdbschedulerjob",
151
+ "folderName": "jobs",
152
+ "groups": {
153
+ "all": [
154
+ {
155
+ "description": "Prefix J_",
156
+ "pattern": "^J_.+"
157
+ }
158
+ ]
159
+ }
160
+ },
161
+ {
162
+ "extension": ".hdbindex",
163
+ "folderName": "indexes",
164
+ "groups": {
165
+ "all": [
166
+ {
167
+ "description": "Prefix IDX_",
168
+ "pattern": "^IDX_.+"
169
+ }
170
+ ]
171
+ }
172
+ },
173
+ {
174
+ "extension": ".hdbtrigger",
175
+ "folderName": "triggers",
176
+ "groups": {
177
+ "all": [
178
+ {
179
+ "description": "Prefix TR_",
180
+ "pattern": "^TR_.+"
181
+ }
182
+ ]
183
+ }
184
+ }
185
+ ],
186
+ "contentRuleSets": [
187
+ {
188
+ "extension": ".hdbtable",
189
+ "target": "field",
190
+ "groups": {
191
+ "all": [
192
+ {
193
+ "description": "Field names in uppercase snake case",
194
+ "pattern": "^[A-Z0-9]+(?:_[A-Z0-9]+)*$"
195
+ }
196
+ ]
197
+ }
198
+ },
199
+ {
200
+ "extension": ".hdbprocedure",
201
+ "target": "inputParameter",
202
+ "groups": {
203
+ "all": [
204
+ {
205
+ "description": "Input parameters prefixed with IP_",
206
+ "pattern": "^IP_[A-Z0-9_]+$"
207
+ }
208
+ ]
209
+ }
210
+ },
211
+ {
212
+ "extension": ".hdbprocedure",
213
+ "target": "outputParameter",
214
+ "groups": {
215
+ "all": [
216
+ {
217
+ "description": "Output parameters prefixed with OP_",
218
+ "pattern": "^OP_[A-Z0-9_]+$"
219
+ }
220
+ ]
221
+ }
222
+ }
223
+ ]
224
+ }
@@ -70,6 +70,7 @@ function evaluateAllRules(filePath, extension, subject, rules) {
70
70
  extension,
71
71
  subjectType: subject.type,
72
72
  subjectName: subject.name,
73
+ lineNumber: subject.lineNumber,
73
74
  failedRuleDescription: rule.description,
74
75
  failedPattern: toRegexLiteral(rule)
75
76
  });
@@ -89,6 +90,7 @@ function evaluateAnyRules(filePath, extension, subject, rules) {
89
90
  extension,
90
91
  subjectType: subject.type,
91
92
  subjectName: subject.name,
93
+ lineNumber: subject.lineNumber,
92
94
  failedRuleDescription: 'At least one OR-group rule must match: ' + rules.map((rule) => rule.description).join(' | '),
93
95
  failedPattern: rules.map(toRegexLiteral).join(' OR ')
94
96
  }
@@ -22,8 +22,8 @@ function inputs(ddl) {
22
22
  ) RETURNS NVARCHAR(100) AS BEGIN END
23
23
  `;
24
24
  (0, vitest_1.expect)((0, index_1.extractFunctionParameters)(ddl)).toEqual([
25
- { type: 'inputParameter', name: 'IV_CUSTOMER_ID' },
26
- { type: 'inputParameter', name: 'IV_DATE' }
25
+ { type: 'inputParameter', name: 'IV_CUSTOMER_ID', lineNumber: 3 },
26
+ { type: 'inputParameter', name: 'IV_DATE', lineNumber: 4 }
27
27
  ]);
28
28
  });
29
29
  (0, vitest_1.it)('does not produce outputParameter entries for any parameter', () => {
@@ -46,7 +46,7 @@ function inputs(ddl) {
46
46
  IN IV_STATUS NVARCHAR(1)
47
47
  ) RETURNS TABLE (ID INTEGER, NAME NVARCHAR(100)) AS BEGIN END
48
48
  `;
49
- (0, vitest_1.expect)((0, index_1.extractFunctionParameters)(ddl)).toEqual([{ type: 'inputParameter', name: 'IV_STATUS' }]);
49
+ (0, vitest_1.expect)((0, index_1.extractFunctionParameters)(ddl)).toEqual([{ type: 'inputParameter', name: 'IV_STATUS', lineNumber: 3 }]);
50
50
  });
51
51
  (0, vitest_1.it)('does not produce outputParameter entries for a table function', () => {
52
52
  const ddl = `
@@ -113,7 +113,7 @@ function inputs(ddl) {
113
113
  ) RETURNS INTEGER AS BEGIN END
114
114
  `;
115
115
  const result = (0, index_1.extractFunctionParameters)(ddl);
116
- (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT' });
116
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT', lineNumber: 3 });
117
117
  (0, vitest_1.expect)(result.map((s) => s.name)).not.toContain('COL1');
118
118
  (0, vitest_1.expect)(result.map((s) => s.name)).not.toContain('COL2');
119
119
  });
@@ -168,8 +168,8 @@ function inputs(ddl) {
168
168
  `;
169
169
  const result = (0, index_1.extractFunctionParameters)(ddl);
170
170
  (0, vitest_1.expect)(result).toEqual([
171
- { type: 'inputParameter', name: 'IV_STATUS' },
172
- { type: 'inputParameter', name: 'IV_ID' }
171
+ { type: 'inputParameter', name: 'IV_STATUS', lineNumber: 2 },
172
+ { type: 'inputParameter', name: 'IV_ID', lineNumber: 2 }
173
173
  ]);
174
174
  });
175
175
  });
@@ -224,7 +224,8 @@ function inputs(ddl) {
224
224
  const ddl = `FUNCTION F (IN "IV_CUSTOMER_ID" NVARCHAR(10)) RETURNS INTEGER AS BEGIN END`;
225
225
  (0, vitest_1.expect)((0, index_1.extractFunctionParameters)(ddl)).toContainEqual({
226
226
  type: 'inputParameter',
227
- name: 'IV_CUSTOMER_ID'
227
+ name: 'IV_CUSTOMER_ID',
228
+ lineNumber: 1
228
229
  });
229
230
  });
230
231
  (0, vitest_1.it)('handles mixed quoted and unquoted parameters', () => {
@@ -45,10 +45,10 @@ class HdbFunctionParameterVisitor extends BaseCstVisitorWithDefaults {
45
45
  const nameNodes = ctx['parameterName'];
46
46
  if (!nameNodes?.length || !nameNodes[0])
47
47
  return;
48
- const name = this.extractName(nameNodes[0]);
49
- if (!name)
48
+ const extracted = this.extractName(nameNodes[0]);
49
+ if (!extracted)
50
50
  return;
51
- this.parameters.push({ type: 'inputParameter', name });
51
+ this.parameters.push({ type: 'inputParameter', name: extracted.name, lineNumber: extracted.lineNumber });
52
52
  }
53
53
  // -----------------------------------------------------------------------
54
54
  // tableColumnDefinition — no-op.
@@ -99,7 +99,8 @@ class HdbFunctionParameterVisitor extends BaseCstVisitorWithDefaults {
99
99
  if (!token)
100
100
  return undefined;
101
101
  const raw = token.image;
102
- return raw.startsWith('"') ? raw.slice(1, -1) : raw;
102
+ const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
103
+ return { name, lineNumber: token.startLine };
103
104
  }
104
105
  }
105
106
  exports.HdbFunctionParameterVisitor = HdbFunctionParameterVisitor;
@@ -27,8 +27,8 @@ function outputs(ddl) {
27
27
  ) AS BEGIN END
28
28
  `;
29
29
  (0, vitest_1.expect)((0, index_1.extractProcedureParameters)(ddl)).toEqual([
30
- { type: 'inputParameter', name: 'IV_CUSTOMER_ID' },
31
- { type: 'inputParameter', name: 'IV_DATE' }
30
+ { type: 'inputParameter', name: 'IV_CUSTOMER_ID', lineNumber: 3 },
31
+ { type: 'inputParameter', name: 'IV_DATE', lineNumber: 4 }
32
32
  ]);
33
33
  });
34
34
  (0, vitest_1.it)('does not produce outputParameter entries for IN parameters', () => {
@@ -52,8 +52,8 @@ function outputs(ddl) {
52
52
  ) AS BEGIN END
53
53
  `;
54
54
  (0, vitest_1.expect)((0, index_1.extractProcedureParameters)(ddl)).toEqual([
55
- { type: 'outputParameter', name: 'EV_COUNT' },
56
- { type: 'outputParameter', name: 'EV_STATUS' }
55
+ { type: 'outputParameter', name: 'EV_COUNT', lineNumber: 3 },
56
+ { type: 'outputParameter', name: 'EV_STATUS', lineNumber: 4 }
57
57
  ]);
58
58
  });
59
59
  (0, vitest_1.it)('does not produce inputParameter entries for OUT parameters', () => {
@@ -68,15 +68,15 @@ function outputs(ddl) {
68
68
  (0, vitest_1.it)('produces both types for an INOUT scalar parameter', () => {
69
69
  const ddl = `PROCEDURE P (INOUT CV_STATUS NVARCHAR(1)) AS BEGIN END`;
70
70
  (0, vitest_1.expect)((0, index_1.extractProcedureParameters)(ddl)).toEqual([
71
- { type: 'inputParameter', name: 'CV_STATUS' },
72
- { type: 'outputParameter', name: 'CV_STATUS' }
71
+ { type: 'inputParameter', name: 'CV_STATUS', lineNumber: 1 },
72
+ { type: 'outputParameter', name: 'CV_STATUS', lineNumber: 1 }
73
73
  ]);
74
74
  });
75
75
  (0, vitest_1.it)('produces both types for an INOUT TABLE parameter', () => {
76
76
  const ddl = `PROCEDURE P (INOUT TV_RESULT TABLE (ID INTEGER)) AS BEGIN END`;
77
77
  const result = (0, index_1.extractProcedureParameters)(ddl);
78
- (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'TV_RESULT' });
79
- (0, vitest_1.expect)(result).toContainEqual({ type: 'outputParameter', name: 'TV_RESULT' });
78
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'TV_RESULT', lineNumber: 1 });
79
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'outputParameter', name: 'TV_RESULT', lineNumber: 1 });
80
80
  });
81
81
  (0, vitest_1.it)('yields exactly two entries for a single INOUT parameter', () => {
82
82
  const ddl = `PROCEDURE P (INOUT CV_X INTEGER) AS BEGIN END`;
@@ -94,7 +94,7 @@ function outputs(ddl) {
94
94
  ) AS BEGIN END
95
95
  `;
96
96
  const result = (0, index_1.extractProcedureParameters)(ddl);
97
- (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT' });
97
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT', lineNumber: 3 });
98
98
  (0, vitest_1.expect)(result.map((s) => s.name)).not.toContain('COL1');
99
99
  (0, vitest_1.expect)(result.map((s) => s.name)).not.toContain('COL2');
100
100
  });
@@ -226,14 +226,16 @@ function outputs(ddl) {
226
226
  const ddl = `PROCEDURE P (IN "IV_CUSTOMER_ID" NVARCHAR(10)) AS BEGIN END`;
227
227
  (0, vitest_1.expect)((0, index_1.extractProcedureParameters)(ddl)).toContainEqual({
228
228
  type: 'inputParameter',
229
- name: 'IV_CUSTOMER_ID'
229
+ name: 'IV_CUSTOMER_ID',
230
+ lineNumber: 1
230
231
  });
231
232
  });
232
233
  (0, vitest_1.it)('strips double-quotes from a quoted OUT parameter name', () => {
233
234
  const ddl = `PROCEDURE P (OUT "EV_RESULT" INTEGER) AS BEGIN END`;
234
235
  (0, vitest_1.expect)((0, index_1.extractProcedureParameters)(ddl)).toContainEqual({
235
236
  type: 'outputParameter',
236
- name: 'EV_RESULT'
237
+ name: 'EV_RESULT',
238
+ lineNumber: 1
237
239
  });
238
240
  });
239
241
  });
@@ -355,10 +357,10 @@ function outputs(ddl) {
355
357
  ) AS BEGIN END
356
358
  `;
357
359
  const result = (0, index_1.extractProcedureParameters)(ddl);
358
- (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'IV_ID' });
359
- (0, vitest_1.expect)(result).toContainEqual({ type: 'outputParameter', name: 'EV_COUNT' });
360
- (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'CV_STATUS' });
361
- (0, vitest_1.expect)(result).toContainEqual({ type: 'outputParameter', name: 'CV_STATUS' });
360
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'IV_ID', lineNumber: 3 });
361
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'outputParameter', name: 'EV_COUNT', lineNumber: 4 });
362
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'inputParameter', name: 'CV_STATUS', lineNumber: 5 });
363
+ (0, vitest_1.expect)(result).toContainEqual({ type: 'outputParameter', name: 'CV_STATUS', lineNumber: 5 });
362
364
  (0, vitest_1.expect)(result).toHaveLength(4);
363
365
  });
364
366
  (0, vitest_1.it)('handles mixed scalar and TABLE parameters', () => {
@@ -53,14 +53,14 @@ class HdbProcedureParameterVisitor extends BaseCstVisitorWithDefaults {
53
53
  const nameNodes = ctx['parameterName'];
54
54
  if (!nameNodes?.length || !nameNodes[0])
55
55
  return;
56
- const name = this.extractName(nameNodes[0]);
57
- if (!name)
56
+ const extracted = this.extractName(nameNodes[0]);
57
+ if (!extracted)
58
58
  return;
59
59
  if (isIn || isInout) {
60
- this.parameters.push({ type: 'inputParameter', name });
60
+ this.parameters.push({ type: 'inputParameter', name: extracted.name, lineNumber: extracted.lineNumber });
61
61
  }
62
62
  if (isOut || isInout) {
63
- this.parameters.push({ type: 'outputParameter', name });
63
+ this.parameters.push({ type: 'outputParameter', name: extracted.name, lineNumber: extracted.lineNumber });
64
64
  }
65
65
  }
66
66
  // -----------------------------------------------------------------------
@@ -101,7 +101,8 @@ class HdbProcedureParameterVisitor extends BaseCstVisitorWithDefaults {
101
101
  if (!token)
102
102
  return undefined;
103
103
  const raw = token.image;
104
- return raw.startsWith('"') ? raw.slice(1, -1) : raw;
104
+ const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
105
+ return { name, lineNumber: token.startLine };
105
106
  }
106
107
  }
107
108
  exports.HdbProcedureParameterVisitor = HdbProcedureParameterVisitor;
@@ -22,9 +22,9 @@ function names(ddl) {
22
22
  )
23
23
  `;
24
24
  (0, vitest_1.expect)((0, index_1.extractTableColumns)(ddl)).toEqual([
25
- { type: 'field', name: 'ID' },
26
- { type: 'field', name: 'NAME' },
27
- { type: 'field', name: 'CREATED_AT' }
25
+ { type: 'field', name: 'ID', lineNumber: 3 },
26
+ { type: 'field', name: 'NAME', lineNumber: 4 },
27
+ { type: 'field', name: 'CREATED_AT', lineNumber: 5 }
28
28
  ]);
29
29
  });
30
30
  (0, vitest_1.it)('does not include the constraint name in the result', () => {
@@ -108,7 +108,7 @@ function names(ddl) {
108
108
  (0, vitest_1.describe)('AC-4: quoted identifier normalisation', () => {
109
109
  (0, vitest_1.it)('strips surrounding double-quotes from a quoted column identifier', () => {
110
110
  const ddl = `COLUMN TABLE "MY_TABLE" ("MY_COLUMN" NVARCHAR(100))`;
111
- (0, vitest_1.expect)((0, index_1.extractTableColumns)(ddl)).toContainEqual({ type: 'field', name: 'MY_COLUMN' });
111
+ (0, vitest_1.expect)((0, index_1.extractTableColumns)(ddl)).toContainEqual({ type: 'field', name: 'MY_COLUMN', lineNumber: 1 });
112
112
  });
113
113
  (0, vitest_1.it)('strips quotes even when the table name is also quoted', () => {
114
114
  const ddl = `COLUMN TABLE "SCHEMA"."T" ("COL" INTEGER)`;
@@ -269,8 +269,8 @@ function names(ddl) {
269
269
  (0, vitest_1.it)('handles a mix in the same table', () => {
270
270
  const ddl = `COLUMN TABLE T ("QUOTED" INTEGER, UNQUOTED NVARCHAR(10))`;
271
271
  (0, vitest_1.expect)((0, index_1.extractTableColumns)(ddl)).toEqual([
272
- { type: 'field', name: 'QUOTED' },
273
- { type: 'field', name: 'UNQUOTED' }
272
+ { type: 'field', name: 'QUOTED', lineNumber: 1 },
273
+ { type: 'field', name: 'UNQUOTED', lineNumber: 1 }
274
274
  ]);
275
275
  });
276
276
  });
@@ -43,10 +43,11 @@ class HdbTableColumnVisitor extends BaseCstVisitorWithDefaults {
43
43
  if (!tokenElement) {
44
44
  return;
45
45
  }
46
- const raw = tokenElement.image;
46
+ const token = tokenElement;
47
+ const raw = token.image;
47
48
  // Strip surrounding double-quotes from quoted identifiers ("MY_COL" → MY_COL).
48
49
  const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
49
- this.columns.push({ type: 'field', name });
50
+ this.columns.push({ type: 'field', name, lineNumber: token.startLine });
50
51
  }
51
52
  }
52
53
  exports.HdbTableColumnVisitor = HdbTableColumnVisitor;
@@ -41,8 +41,8 @@ function names(ddl) {
41
41
  (0, vitest_1.it)('extracts AS aliases from the top-level SELECT clause', () => {
42
42
  const ddl = `VIEW V_BAR AS SELECT T."CUST_ID" AS "ID", T."CUST_NAME" AS "NAME" FROM T`;
43
43
  (0, vitest_1.expect)((0, index_1.extractViewColumns)(ddl)).toEqual([
44
- { type: 'field', name: 'ID' },
45
- { type: 'field', name: 'NAME' }
44
+ { type: 'field', name: 'ID', lineNumber: 1 },
45
+ { type: 'field', name: 'NAME', lineNumber: 1 }
46
46
  ]);
47
47
  });
48
48
  (0, vitest_1.it)('handles unquoted alias names', () => {
@@ -158,11 +158,11 @@ function names(ddl) {
158
158
  (0, vitest_1.describe)('AC-6: quoted identifier normalisation', () => {
159
159
  (0, vitest_1.it)('strips double-quotes from quoted aliases', () => {
160
160
  const ddl = `VIEW V AS SELECT T.X AS "MY_ALIAS" FROM T`;
161
- (0, vitest_1.expect)((0, index_1.extractViewColumns)(ddl)).toContainEqual({ type: 'field', name: 'MY_ALIAS' });
161
+ (0, vitest_1.expect)((0, index_1.extractViewColumns)(ddl)).toContainEqual({ type: 'field', name: 'MY_ALIAS', lineNumber: 1 });
162
162
  });
163
163
  (0, vitest_1.it)('strips double-quotes from quoted names in explicit column list', () => {
164
164
  const ddl = `VIEW V ("MY_COL") AS SELECT T.X FROM T`;
165
- (0, vitest_1.expect)((0, index_1.extractViewColumns)(ddl)).toContainEqual({ type: 'field', name: 'MY_COL' });
165
+ (0, vitest_1.expect)((0, index_1.extractViewColumns)(ddl)).toContainEqual({ type: 'field', name: 'MY_COL', lineNumber: 1 });
166
166
  });
167
167
  });
168
168
  // ---------------------------------------------------------------------------
@@ -113,7 +113,7 @@ class HdbViewColumnVisitor extends BaseCstVisitorWithDefaults {
113
113
  const raw = token.image;
114
114
  // Strip surrounding double-quotes: "MY_COL" → MY_COL.
115
115
  const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
116
- this.columns.push({ type: 'field', name });
116
+ this.columns.push({ type: 'field', name, lineNumber: token.startLine });
117
117
  }
118
118
  }
119
119
  exports.HdbViewColumnVisitor = HdbViewColumnVisitor;
package/dist/report.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.printReport = printReport;
4
4
  /**
5
5
  * Print lint result report to stdout/stderr.
6
+ * Issues are grouped by file, then by failed rule.
6
7
  *
7
8
  * @param issues - Found naming violations.
8
9
  * @param filesToValidate - Number of candidate files processed.
@@ -14,15 +15,45 @@ function printReport(issues, filesToValidate) {
14
15
  }
15
16
  console.error(`❌ HANA naming lint failed. Violations: ${issues.length}`);
16
17
  console.error('');
18
+ // Group issues by file path.
19
+ const byFile = new Map();
17
20
  for (const issue of issues) {
18
- console.error(`- File: ${issue.filePath}`);
19
- console.error(` Artifact: ${issue.artifactName}`);
20
- if (issue.subjectType && issue.subjectName) {
21
- console.error(` Subject: ${issue.subjectType} (${issue.subjectName})`);
21
+ const bucket = byFile.get(issue.filePath);
22
+ if (bucket) {
23
+ bucket.push(issue);
24
+ }
25
+ else {
26
+ byFile.set(issue.filePath, [issue]);
27
+ }
28
+ }
29
+ for (const [filePath, fileIssues] of byFile) {
30
+ const count = fileIssues.length;
31
+ console.error(`File: ${filePath} (${count} violation${count === 1 ? '' : 's'})`);
32
+ // Group file issues by failed rule description.
33
+ const byRule = new Map();
34
+ for (const issue of fileIssues) {
35
+ const key = `${issue.failedRuleDescription}||${issue.failedPattern}`;
36
+ const bucket = byRule.get(key);
37
+ if (bucket) {
38
+ bucket.push(issue);
39
+ }
40
+ else {
41
+ byRule.set(key, [issue]);
42
+ }
43
+ }
44
+ for (const ruleIssues of byRule.values()) {
45
+ const { failedRuleDescription, failedPattern } = ruleIssues[0];
46
+ console.error(` Rule: "${failedRuleDescription}" (pattern: ${failedPattern})`);
47
+ for (const issue of ruleIssues) {
48
+ const location = issue.lineNumber !== undefined ? ` at line ${issue.lineNumber}` : '';
49
+ if (issue.subjectType && issue.subjectName) {
50
+ console.error(` - ${issue.subjectType} "${issue.subjectName}"${location}`);
51
+ }
52
+ else {
53
+ console.error(` - artifact "${issue.artifactName}"${location}`);
54
+ }
55
+ }
22
56
  }
23
- console.error(` Type: ${issue.extension}`);
24
- console.error(` Failed rule: ${issue.failedRuleDescription}`);
25
- console.error(` Expected regex: ${issue.failedPattern}`);
26
57
  console.error('');
27
58
  }
28
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hana-linter",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Linter for SAP HANA artifacts in an SAP CAP project",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "url": "git+https://github.com/qualiture/hana-linter.git"
18
18
  },
19
19
  "scripts": {
20
- "build": "tsc",
20
+ "build": "tsc && copyfiles -u 1 \"src/assets/.hana-linter.json\" dist",
21
21
  "test": "vitest run",
22
22
  "test:coverage": "vitest run --coverage"
23
23
  },
@@ -26,6 +26,7 @@
26
26
  "devDependencies": {
27
27
  "@types/node": "^25.9.3",
28
28
  "@vitest/coverage-v8": "^4.1.9",
29
+ "copyfiles": "^2.4.1",
29
30
  "vitest": "^4.1.9"
30
31
  },
31
32
  "dependencies": {
@@ -83,6 +83,7 @@ function evaluateAllRules(filePath: string, extension: string, subject: Extracte
83
83
  extension,
84
84
  subjectType: subject.type,
85
85
  subjectName: subject.name,
86
+ lineNumber: subject.lineNumber,
86
87
  failedRuleDescription: rule.description,
87
88
  failedPattern: toRegexLiteral(rule)
88
89
  });
@@ -106,6 +107,7 @@ function evaluateAnyRules(filePath: string, extension: string, subject: Extracte
106
107
  extension,
107
108
  subjectType: subject.type,
108
109
  subjectName: subject.name,
110
+ lineNumber: subject.lineNumber,
109
111
  failedRuleDescription: 'At least one OR-group rule must match: ' + rules.map((rule) => rule.description).join(' | '),
110
112
  failedPattern: rules.map(toRegexLiteral).join(' OR ')
111
113
  }
@@ -24,8 +24,8 @@ describe('AC-1: IN parameter extraction (scalar function)', () => {
24
24
  ) RETURNS NVARCHAR(100) AS BEGIN END
25
25
  `;
26
26
  expect(extractFunctionParameters(ddl)).toEqual([
27
- { type: 'inputParameter', name: 'IV_CUSTOMER_ID' },
28
- { type: 'inputParameter', name: 'IV_DATE' }
27
+ { type: 'inputParameter', name: 'IV_CUSTOMER_ID', lineNumber: 3 },
28
+ { type: 'inputParameter', name: 'IV_DATE', lineNumber: 4 }
29
29
  ]);
30
30
  });
31
31
 
@@ -52,7 +52,7 @@ describe('AC-2: IN parameter extraction (table function)', () => {
52
52
  IN IV_STATUS NVARCHAR(1)
53
53
  ) RETURNS TABLE (ID INTEGER, NAME NVARCHAR(100)) AS BEGIN END
54
54
  `;
55
- expect(extractFunctionParameters(ddl)).toEqual([{ type: 'inputParameter', name: 'IV_STATUS' }]);
55
+ expect(extractFunctionParameters(ddl)).toEqual([{ type: 'inputParameter', name: 'IV_STATUS', lineNumber: 3 }]);
56
56
  });
57
57
 
58
58
  it('does not produce outputParameter entries for a table function', () => {
@@ -128,7 +128,7 @@ describe('AC-5: TABLE-type IN parameter columns not extracted', () => {
128
128
  ) RETURNS INTEGER AS BEGIN END
129
129
  `;
130
130
  const result = extractFunctionParameters(ddl);
131
- expect(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT' });
131
+ expect(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT', lineNumber: 3 });
132
132
  expect(result.map((s) => s.name)).not.toContain('COL1');
133
133
  expect(result.map((s) => s.name)).not.toContain('COL2');
134
134
  });
@@ -188,8 +188,8 @@ describe('AC-6: function body SQL does not pollute extraction', () => {
188
188
  `;
189
189
  const result = extractFunctionParameters(ddl);
190
190
  expect(result).toEqual([
191
- { type: 'inputParameter', name: 'IV_STATUS' },
192
- { type: 'inputParameter', name: 'IV_ID' }
191
+ { type: 'inputParameter', name: 'IV_STATUS', lineNumber: 2 },
192
+ { type: 'inputParameter', name: 'IV_ID', lineNumber: 2 }
193
193
  ]);
194
194
  });
195
195
  });
@@ -251,7 +251,8 @@ describe('AC-9: quoted identifier normalisation', () => {
251
251
  const ddl = `FUNCTION F (IN "IV_CUSTOMER_ID" NVARCHAR(10)) RETURNS INTEGER AS BEGIN END`;
252
252
  expect(extractFunctionParameters(ddl)).toContainEqual({
253
253
  type: 'inputParameter',
254
- name: 'IV_CUSTOMER_ID'
254
+ name: 'IV_CUSTOMER_ID',
255
+ lineNumber: 1
255
256
  });
256
257
  });
257
258
 
@@ -49,10 +49,10 @@ export class HdbFunctionParameterVisitor extends BaseCstVisitorWithDefaults {
49
49
  const nameNodes = ctx['parameterName'] as CstNode[] | undefined;
50
50
  if (!nameNodes?.length || !nameNodes[0]) return;
51
51
 
52
- const name = this.extractName(nameNodes[0]);
53
- if (!name) return;
52
+ const extracted = this.extractName(nameNodes[0]);
53
+ if (!extracted) return;
54
54
 
55
- this.parameters.push({ type: 'inputParameter', name });
55
+ this.parameters.push({ type: 'inputParameter', name: extracted.name, lineNumber: extracted.lineNumber });
56
56
  }
57
57
 
58
58
  // -----------------------------------------------------------------------
@@ -97,7 +97,7 @@ export class HdbFunctionParameterVisitor extends BaseCstVisitorWithDefaults {
97
97
  // Strips surrounding double-quotes from quoted identifiers.
98
98
  // -----------------------------------------------------------------------
99
99
 
100
- private extractName(node: CstNode): string | undefined {
100
+ private extractName(node: CstNode): { name: string; lineNumber?: number } | undefined {
101
101
  if (!node.children) return undefined;
102
102
 
103
103
  // parameterName → identifier → Identifier | QuotedIdentifier
@@ -113,6 +113,7 @@ export class HdbFunctionParameterVisitor extends BaseCstVisitorWithDefaults {
113
113
  if (!token) return undefined;
114
114
 
115
115
  const raw = token.image;
116
- return raw.startsWith('"') ? raw.slice(1, -1) : raw;
116
+ const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
117
+ return { name, lineNumber: token.startLine };
117
118
  }
118
119
  }
@@ -30,8 +30,8 @@ describe('AC-1: IN parameter extraction', () => {
30
30
  ) AS BEGIN END
31
31
  `;
32
32
  expect(extractProcedureParameters(ddl)).toEqual([
33
- { type: 'inputParameter', name: 'IV_CUSTOMER_ID' },
34
- { type: 'inputParameter', name: 'IV_DATE' }
33
+ { type: 'inputParameter', name: 'IV_CUSTOMER_ID', lineNumber: 3 },
34
+ { type: 'inputParameter', name: 'IV_DATE', lineNumber: 4 }
35
35
  ]);
36
36
  });
37
37
 
@@ -59,8 +59,8 @@ describe('AC-2: OUT parameter extraction', () => {
59
59
  ) AS BEGIN END
60
60
  `;
61
61
  expect(extractProcedureParameters(ddl)).toEqual([
62
- { type: 'outputParameter', name: 'EV_COUNT' },
63
- { type: 'outputParameter', name: 'EV_STATUS' }
62
+ { type: 'outputParameter', name: 'EV_COUNT', lineNumber: 3 },
63
+ { type: 'outputParameter', name: 'EV_STATUS', lineNumber: 4 }
64
64
  ]);
65
65
  });
66
66
 
@@ -78,16 +78,16 @@ describe('AC-3: INOUT parameter yields inputParameter and outputParameter', () =
78
78
  it('produces both types for an INOUT scalar parameter', () => {
79
79
  const ddl = `PROCEDURE P (INOUT CV_STATUS NVARCHAR(1)) AS BEGIN END`;
80
80
  expect(extractProcedureParameters(ddl)).toEqual([
81
- { type: 'inputParameter', name: 'CV_STATUS' },
82
- { type: 'outputParameter', name: 'CV_STATUS' }
81
+ { type: 'inputParameter', name: 'CV_STATUS', lineNumber: 1 },
82
+ { type: 'outputParameter', name: 'CV_STATUS', lineNumber: 1 }
83
83
  ]);
84
84
  });
85
85
 
86
86
  it('produces both types for an INOUT TABLE parameter', () => {
87
87
  const ddl = `PROCEDURE P (INOUT TV_RESULT TABLE (ID INTEGER)) AS BEGIN END`;
88
88
  const result = extractProcedureParameters(ddl);
89
- expect(result).toContainEqual({ type: 'inputParameter', name: 'TV_RESULT' });
90
- expect(result).toContainEqual({ type: 'outputParameter', name: 'TV_RESULT' });
89
+ expect(result).toContainEqual({ type: 'inputParameter', name: 'TV_RESULT', lineNumber: 1 });
90
+ expect(result).toContainEqual({ type: 'outputParameter', name: 'TV_RESULT', lineNumber: 1 });
91
91
  });
92
92
 
93
93
  it('yields exactly two entries for a single INOUT parameter', () => {
@@ -108,7 +108,7 @@ describe('AC-4: TABLE-type parameter columns not extracted', () => {
108
108
  ) AS BEGIN END
109
109
  `;
110
110
  const result = extractProcedureParameters(ddl);
111
- expect(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT' });
111
+ expect(result).toContainEqual({ type: 'inputParameter', name: 'TV_INPUT', lineNumber: 3 });
112
112
  expect(result.map((s) => s.name)).not.toContain('COL1');
113
113
  expect(result.map((s) => s.name)).not.toContain('COL2');
114
114
  });
@@ -253,7 +253,8 @@ describe('AC-8: quoted identifier normalisation', () => {
253
253
  const ddl = `PROCEDURE P (IN "IV_CUSTOMER_ID" NVARCHAR(10)) AS BEGIN END`;
254
254
  expect(extractProcedureParameters(ddl)).toContainEqual({
255
255
  type: 'inputParameter',
256
- name: 'IV_CUSTOMER_ID'
256
+ name: 'IV_CUSTOMER_ID',
257
+ lineNumber: 1
257
258
  });
258
259
  });
259
260
 
@@ -261,7 +262,8 @@ describe('AC-8: quoted identifier normalisation', () => {
261
262
  const ddl = `PROCEDURE P (OUT "EV_RESULT" INTEGER) AS BEGIN END`;
262
263
  expect(extractProcedureParameters(ddl)).toContainEqual({
263
264
  type: 'outputParameter',
264
- name: 'EV_RESULT'
265
+ name: 'EV_RESULT',
266
+ lineNumber: 1
265
267
  });
266
268
  });
267
269
  });
@@ -401,10 +403,10 @@ describe('mixed parameter modes', () => {
401
403
  ) AS BEGIN END
402
404
  `;
403
405
  const result = extractProcedureParameters(ddl);
404
- expect(result).toContainEqual({ type: 'inputParameter', name: 'IV_ID' });
405
- expect(result).toContainEqual({ type: 'outputParameter', name: 'EV_COUNT' });
406
- expect(result).toContainEqual({ type: 'inputParameter', name: 'CV_STATUS' });
407
- expect(result).toContainEqual({ type: 'outputParameter', name: 'CV_STATUS' });
406
+ expect(result).toContainEqual({ type: 'inputParameter', name: 'IV_ID', lineNumber: 3 });
407
+ expect(result).toContainEqual({ type: 'outputParameter', name: 'EV_COUNT', lineNumber: 4 });
408
+ expect(result).toContainEqual({ type: 'inputParameter', name: 'CV_STATUS', lineNumber: 5 });
409
+ expect(result).toContainEqual({ type: 'outputParameter', name: 'CV_STATUS', lineNumber: 5 });
408
410
  expect(result).toHaveLength(4);
409
411
  });
410
412
 
@@ -58,14 +58,14 @@ export class HdbProcedureParameterVisitor extends BaseCstVisitorWithDefaults {
58
58
  const nameNodes = ctx['parameterName'] as CstNode[] | undefined;
59
59
  if (!nameNodes?.length || !nameNodes[0]) return;
60
60
 
61
- const name = this.extractName(nameNodes[0]);
62
- if (!name) return;
61
+ const extracted = this.extractName(nameNodes[0]);
62
+ if (!extracted) return;
63
63
 
64
64
  if (isIn || isInout) {
65
- this.parameters.push({ type: 'inputParameter', name });
65
+ this.parameters.push({ type: 'inputParameter', name: extracted.name, lineNumber: extracted.lineNumber });
66
66
  }
67
67
  if (isOut || isInout) {
68
- this.parameters.push({ type: 'outputParameter', name });
68
+ this.parameters.push({ type: 'outputParameter', name: extracted.name, lineNumber: extracted.lineNumber });
69
69
  }
70
70
  }
71
71
 
@@ -98,7 +98,7 @@ export class HdbProcedureParameterVisitor extends BaseCstVisitorWithDefaults {
98
98
  // Strips surrounding double-quotes from quoted identifiers.
99
99
  // -----------------------------------------------------------------------
100
100
 
101
- private extractName(node: CstNode): string | undefined {
101
+ private extractName(node: CstNode): { name: string; lineNumber?: number } | undefined {
102
102
  if (!node.children) return undefined;
103
103
 
104
104
  // parameterName → identifier → Identifier | QuotedIdentifier
@@ -114,6 +114,7 @@ export class HdbProcedureParameterVisitor extends BaseCstVisitorWithDefaults {
114
114
  if (!token) return undefined;
115
115
 
116
116
  const raw = token.image;
117
- return raw.startsWith('"') ? raw.slice(1, -1) : raw;
117
+ const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
118
+ return { name, lineNumber: token.startLine };
118
119
  }
119
120
  }
@@ -24,9 +24,9 @@ describe('AC-1: standard column extraction', () => {
24
24
  )
25
25
  `;
26
26
  expect(extractTableColumns(ddl)).toEqual([
27
- { type: 'field', name: 'ID' },
28
- { type: 'field', name: 'NAME' },
29
- { type: 'field', name: 'CREATED_AT' }
27
+ { type: 'field', name: 'ID', lineNumber: 3 },
28
+ { type: 'field', name: 'NAME', lineNumber: 4 },
29
+ { type: 'field', name: 'CREATED_AT', lineNumber: 5 }
30
30
  ]);
31
31
  });
32
32
 
@@ -121,7 +121,7 @@ describe('AC-3: line comment exclusion', () => {
121
121
  describe('AC-4: quoted identifier normalisation', () => {
122
122
  it('strips surrounding double-quotes from a quoted column identifier', () => {
123
123
  const ddl = `COLUMN TABLE "MY_TABLE" ("MY_COLUMN" NVARCHAR(100))`;
124
- expect(extractTableColumns(ddl)).toContainEqual({ type: 'field', name: 'MY_COLUMN' });
124
+ expect(extractTableColumns(ddl)).toContainEqual({ type: 'field', name: 'MY_COLUMN', lineNumber: 1 });
125
125
  });
126
126
 
127
127
  it('strips quotes even when the table name is also quoted', () => {
@@ -309,8 +309,8 @@ describe('mixed quoted and unquoted identifiers', () => {
309
309
  it('handles a mix in the same table', () => {
310
310
  const ddl = `COLUMN TABLE T ("QUOTED" INTEGER, UNQUOTED NVARCHAR(10))`;
311
311
  expect(extractTableColumns(ddl)).toEqual([
312
- { type: 'field', name: 'QUOTED' },
313
- { type: 'field', name: 'UNQUOTED' }
312
+ { type: 'field', name: 'QUOTED', lineNumber: 1 },
313
+ { type: 'field', name: 'UNQUOTED', lineNumber: 1 }
314
314
  ]);
315
315
  });
316
316
  });
@@ -50,10 +50,11 @@ export class HdbTableColumnVisitor extends BaseCstVisitorWithDefaults {
50
50
  return;
51
51
  }
52
52
 
53
- const raw = (tokenElement as IToken).image;
53
+ const token = tokenElement as IToken;
54
+ const raw = token.image;
54
55
  // Strip surrounding double-quotes from quoted identifiers ("MY_COL" → MY_COL).
55
56
  const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
56
57
 
57
- this.columns.push({ type: 'field', name });
58
+ this.columns.push({ type: 'field', name, lineNumber: token.startLine });
58
59
  }
59
60
  }
@@ -49,8 +49,8 @@ describe('AC-2: SELECT alias extraction (no explicit column list)', () => {
49
49
  it('extracts AS aliases from the top-level SELECT clause', () => {
50
50
  const ddl = `VIEW V_BAR AS SELECT T."CUST_ID" AS "ID", T."CUST_NAME" AS "NAME" FROM T`;
51
51
  expect(extractViewColumns(ddl)).toEqual([
52
- { type: 'field', name: 'ID' },
53
- { type: 'field', name: 'NAME' }
52
+ { type: 'field', name: 'ID', lineNumber: 1 },
53
+ { type: 'field', name: 'NAME', lineNumber: 1 }
54
54
  ]);
55
55
  });
56
56
 
@@ -182,12 +182,12 @@ describe('AC-5: line comment exclusion', () => {
182
182
  describe('AC-6: quoted identifier normalisation', () => {
183
183
  it('strips double-quotes from quoted aliases', () => {
184
184
  const ddl = `VIEW V AS SELECT T.X AS "MY_ALIAS" FROM T`;
185
- expect(extractViewColumns(ddl)).toContainEqual({ type: 'field', name: 'MY_ALIAS' });
185
+ expect(extractViewColumns(ddl)).toContainEqual({ type: 'field', name: 'MY_ALIAS', lineNumber: 1 });
186
186
  });
187
187
 
188
188
  it('strips double-quotes from quoted names in explicit column list', () => {
189
189
  const ddl = `VIEW V ("MY_COL") AS SELECT T.X FROM T`;
190
- expect(extractViewColumns(ddl)).toContainEqual({ type: 'field', name: 'MY_COL' });
190
+ expect(extractViewColumns(ddl)).toContainEqual({ type: 'field', name: 'MY_COL', lineNumber: 1 });
191
191
  });
192
192
  });
193
193
 
@@ -125,6 +125,6 @@ export class HdbViewColumnVisitor extends BaseCstVisitorWithDefaults {
125
125
  const raw = token.image;
126
126
  // Strip surrounding double-quotes: "MY_COL" → MY_COL.
127
127
  const name = raw.startsWith('"') ? raw.slice(1, -1) : raw;
128
- this.columns.push({ type: 'field', name });
128
+ this.columns.push({ type: 'field', name, lineNumber: token.startLine });
129
129
  }
130
130
  }
package/src/report.ts CHANGED
@@ -2,6 +2,7 @@ import { LintIssue } from './types/issues';
2
2
 
3
3
  /**
4
4
  * Print lint result report to stdout/stderr.
5
+ * Issues are grouped by file, then by failed rule.
5
6
  *
6
7
  * @param issues - Found naming violations.
7
8
  * @param filesToValidate - Number of candidate files processed.
@@ -15,15 +16,47 @@ export function printReport(issues: readonly LintIssue[], filesToValidate: reado
15
16
  console.error(`❌ HANA naming lint failed. Violations: ${issues.length}`);
16
17
  console.error('');
17
18
 
19
+ // Group issues by file path.
20
+ const byFile = new Map<string, LintIssue[]>();
18
21
  for (const issue of issues) {
19
- console.error(`- File: ${issue.filePath}`);
20
- console.error(` Artifact: ${issue.artifactName}`);
21
- if (issue.subjectType && issue.subjectName) {
22
- console.error(` Subject: ${issue.subjectType} (${issue.subjectName})`);
22
+ const bucket = byFile.get(issue.filePath);
23
+ if (bucket) {
24
+ bucket.push(issue);
25
+ } else {
26
+ byFile.set(issue.filePath, [issue]);
23
27
  }
24
- console.error(` Type: ${issue.extension}`);
25
- console.error(` Failed rule: ${issue.failedRuleDescription}`);
26
- console.error(` Expected regex: ${issue.failedPattern}`);
28
+ }
29
+
30
+ for (const [filePath, fileIssues] of byFile) {
31
+ const count = fileIssues.length;
32
+ console.error(`File: ${filePath} (${count} violation${count === 1 ? '' : 's'})`);
33
+
34
+ // Group file issues by failed rule description.
35
+ const byRule = new Map<string, LintIssue[]>();
36
+ for (const issue of fileIssues) {
37
+ const key = `${issue.failedRuleDescription}||${issue.failedPattern}`;
38
+ const bucket = byRule.get(key);
39
+ if (bucket) {
40
+ bucket.push(issue);
41
+ } else {
42
+ byRule.set(key, [issue]);
43
+ }
44
+ }
45
+
46
+ for (const ruleIssues of byRule.values()) {
47
+ const { failedRuleDescription, failedPattern } = ruleIssues[0]!;
48
+ console.error(` Rule: "${failedRuleDescription}" (pattern: ${failedPattern})`);
49
+
50
+ for (const issue of ruleIssues) {
51
+ const location = issue.lineNumber !== undefined ? ` at line ${issue.lineNumber}` : '';
52
+ if (issue.subjectType && issue.subjectName) {
53
+ console.error(` - ${issue.subjectType} "${issue.subjectName}"${location}`);
54
+ } else {
55
+ console.error(` - artifact "${issue.artifactName}"${location}`);
56
+ }
57
+ }
58
+ }
59
+
27
60
  console.error('');
28
61
  }
29
62
  }
@@ -3,6 +3,7 @@ import type { ContentTarget } from './rules';
3
3
  export type ExtractedSubject = {
4
4
  readonly type: ContentTarget;
5
5
  readonly name: string;
6
+ readonly lineNumber?: number;
6
7
  };
7
8
 
8
9
  export type LintIssue = {
@@ -11,6 +12,7 @@ export type LintIssue = {
11
12
  readonly extension: string;
12
13
  readonly subjectType?: 'artifact' | 'field' | 'inputParameter' | 'outputParameter';
13
14
  readonly subjectName?: string;
15
+ readonly lineNumber?: number;
14
16
  readonly failedRuleDescription: string;
15
17
  readonly failedPattern: string;
16
18
  };