localization-drift 0.1.1 → 0.2.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 ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to
7
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
+
9
+ ## [0.2.0] - 2026-10-13
10
+
11
+ ### Added
12
+
13
+ - `--fix` annotates every reported finding with the exception marker and a
14
+ `TODO: localize` reason. All edits are computed before the first write, line
15
+ endings and indentation are preserved, a second run changes nothing, and the
16
+ edited files are summarized on stderr. Inside JSX children the marker is
17
+ written as a JSX comment.
18
+ - Configuration field `allowlist` suppresses findings whose message text
19
+ matches a `pattern` entry that carries a non-empty `reason`. An invalid
20
+ regular expression or a missing reason is a configuration error and exits
21
+ with status 2.
22
+ - `--sarif` prints one SARIF 2.1.0 document instead of text lines.
23
+ - The sibling exception marker
24
+ `// localization-audit: allow-next-line -- <reason>` is accepted in addition
25
+ to the configured `localization-drift` marker.
26
+ - The JSX comment marker form (`{/* localization-drift: allow-next-line -- <reason> */}`)
27
+ is accepted and used by `--fix` inside JSX children.
package/README.md CHANGED
@@ -7,6 +7,10 @@ Fail a build when customer-visible text bypasses translation keys.
7
7
  - Flags hardcoded JSX text and localized attributes in `.tsx` files.
8
8
  - Flags configured forbidden patterns in the scanned source files.
9
9
  - Honors a reviewed exception comment on the line before a finding.
10
+ - Annotates findings with `--fix` so an adopted codebase can go green and
11
+ triage the annotated debt.
12
+ - Suppresses reviewed message text through an allowlist.
13
+ - Prints text lines, JSON, or one SARIF 2.1.0 document.
10
14
  - Runs as a CLI, an npm package, or a GitHub Action.
11
15
  - Pairs with [localize](https://github.com/m1chlcz/localize) for the workbook and ARB side.
12
16
 
@@ -23,10 +27,42 @@ Node.js 22 or later is required.
23
27
  ```
24
28
  npx localization-drift
25
29
  npx localization-drift --root app --root components
30
+ npx localization-drift --fix
26
31
  npx localization-drift --json
32
+ npx localization-drift --sarif > localization-drift.sarif
27
33
  ```
28
34
 
29
- The output is one line per finding: `path:line: kind: message`. The exit code is 1 for findings and 2 for a usage or configuration error.
35
+ The output is one line per finding: `path:line: kind: message`. The exit code
36
+ is 1 for findings and 2 for a usage or configuration error. `--json` and
37
+ `--sarif` are mutually exclusive. Combined with `--fix`, they print the
38
+ findings that remain after the fix.
39
+
40
+ ## Fix
41
+
42
+ `--fix` adds the exception marker with a `TODO: localize` reason on the line
43
+ before every reported finding. The audit passes at once and the annotated debt
44
+ stays visible for triage.
45
+
46
+ - Every edit is computed before the first write. A failure leaves the tree
47
+ untouched.
48
+ - The file line endings and the indentation of the annotated line are
49
+ preserved.
50
+ - A line that already carries a marker is never touched.
51
+ - A second run changes nothing.
52
+ - Inside JSX children the marker is a JSX comment (`{/* ... */}`) so the file
53
+ stays valid.
54
+ - The summary goes to stderr, one line per edited file plus a total.
55
+ `Localization audit passed.` goes to stdout when no finding remains.
56
+ - The exit code is 0 when no finding remains and 1 when a finding cannot be
57
+ annotated.
58
+
59
+ ## SARIF
60
+
61
+ `--sarif` prints one SARIF 2.1.0 document to stdout instead of text lines. The
62
+ document has one run, the driver name is `localization-drift`, the version
63
+ comes from `package.json`, and the information URI comes from the `repository`
64
+ field. Every finding becomes one `error` result with a rule id, the message
65
+ text, and the file and line. The exit codes do not change.
30
66
 
31
67
  ## Configuration
32
68
 
@@ -43,6 +79,12 @@ Create `localization-drift.json` in the repository root:
43
79
  "pattern": "widget_type\\s*===\\s*[\"'][^\"']+[\"']",
44
80
  "message": "use stored capabilities instead of a widget value"
45
81
  }
82
+ ],
83
+ "allowlist": [
84
+ {
85
+ "pattern": "customer-visible title must come from a translation key",
86
+ "reason": "page titles are rendered from stored content"
87
+ }
46
88
  ]
47
89
  }
48
90
  ```
@@ -54,7 +96,8 @@ Create `localization-drift.json` in the repository root:
54
96
  | `attributes` | `["alt", "aria-label", "placeholder", "title"]` | The JSX attributes that must use a key. |
55
97
  | `exclude` | tests, `/generated/`, `node_modules` | Regular expressions for paths to skip. |
56
98
  | `forbidden` | `[]` | Regular expression rules with a message. |
57
- | `allowComment` | the marker below | The exception marker prefix. |
99
+ | `allowlist` | `[]` | Suppresses findings whose message matches `pattern`. Every entry needs a non-empty `reason`. An invalid pattern is a configuration error. |
100
+ | `allowComment` | the marker below | The exception marker prefix. The sibling `localization-audit` marker is always accepted on top. |
58
101
 
59
102
  ## Exception
60
103
 
@@ -65,6 +108,22 @@ Put the marker and a reason on the line before the finding:
65
108
  <strong>WhatsApp</strong>
66
109
  ```
67
110
 
111
+ Inside JSX children use a JSX comment:
112
+
113
+ ```tsx
114
+ {/* localization-drift: allow-next-line -- registered brand name */}
115
+ <strong>WhatsApp</strong>
116
+ ```
117
+
118
+ The audit also accepts the sibling marker
119
+ `// localization-audit: allow-next-line -- <reason>` in both forms, so a shop
120
+ that already carries those comments adopts this tool with a configuration-only
121
+ change.
122
+
123
+ The inline exception comment wins first. The allowlist suppresses the findings
124
+ that remain: an allowlisted finding is not reported and `--fix` does not
125
+ annotate it.
126
+
68
127
  ## GitHub Action
69
128
 
70
129
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localization-drift",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Fail a build when customer-visible text bypasses translation keys.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,6 +31,7 @@
31
31
  "src",
32
32
  "action.yml",
33
33
  "README.md",
34
+ "CHANGELOG.md",
34
35
  "LICENSE"
35
36
  ],
36
37
  "engines": {
package/src/cli.mjs CHANGED
@@ -4,16 +4,20 @@ import { readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
6
6
 
7
- import { scanRepository } from "./index.mjs";
7
+ import { fixRepository, scanRepository } from "./index.mjs";
8
+ import { buildSarif } from "./sarif.mjs";
8
9
 
9
- const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
11
+ const version = manifest.version;
10
12
 
11
13
  const usage = `Usage: localization-drift [options]
12
14
 
13
15
  Options:
14
16
  --config FILE read a JSON configuration file
15
17
  --root DIR scan DIR (repeatable; overrides the configured roots)
18
+ --fix annotate findings with a TODO: localize marker
16
19
  --json print the findings as JSON
20
+ --sarif print the findings as one SARIF 2.1.0 document
17
21
  -h, --help show this help
18
22
  -v, --version show the version`;
19
23
 
@@ -28,13 +32,31 @@ function readConfig(path, required) {
28
32
  }
29
33
  }
30
34
 
35
+ function printFindings(findings) {
36
+ for (const item of findings) {
37
+ process.stderr.write(`${item.path}:${item.line}: ${item.kind}: ${item.message}\n`);
38
+ }
39
+ process.stderr.write(`${findings.length} localization finding(s).\n`);
40
+ }
41
+
42
+ function printFixSummary(files) {
43
+ let markers = 0;
44
+ for (const file of files) {
45
+ markers += file.annotated;
46
+ process.stderr.write(`annotated ${file.path}: ${file.annotated} marker(s)\n`);
47
+ }
48
+ process.stderr.write(`${files.length} file(s) annotated, ${markers} marker(s) with TODO: localize.\n`);
49
+ }
50
+
31
51
  async function main() {
32
52
  const { values } = parseArgs({
33
53
  args: process.argv.slice(2),
34
54
  options: {
35
55
  config: { type: "string" },
36
56
  root: { type: "string", multiple: true },
57
+ fix: { type: "boolean" },
37
58
  json: { type: "boolean" },
59
+ sarif: { type: "boolean" },
38
60
  help: { type: "boolean", short: "h" },
39
61
  version: { type: "boolean", short: "v" },
40
62
  },
@@ -48,6 +70,9 @@ async function main() {
48
70
  process.stdout.write(`${usage}\n`);
49
71
  return;
50
72
  }
73
+ if (values.json && values.sarif) {
74
+ throw new Error("--json and --sarif are mutually exclusive");
75
+ }
51
76
 
52
77
  const root = process.cwd();
53
78
  const config = values.config
@@ -55,16 +80,21 @@ async function main() {
55
80
  : readConfig(resolve(root, "localization-drift.json"), false);
56
81
  if (values.root?.length) config.roots = values.root;
57
82
 
58
- const findings = await scanRepository(root, config);
59
- if (values.json) {
83
+ const result = values.fix
84
+ ? await fixRepository(root, config)
85
+ : { files: [], findings: await scanRepository(root, config) };
86
+ if (result.files.length > 0) printFixSummary(result.files);
87
+
88
+ const { findings } = result;
89
+ if (values.sarif) {
90
+ const document = buildSarif(findings, { version, repository: manifest.repository });
91
+ process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
92
+ } else if (values.json) {
60
93
  process.stdout.write(`${JSON.stringify(findings, null, 2)}\n`);
61
94
  } else if (findings.length === 0) {
62
95
  process.stdout.write("Localization audit passed.\n");
63
96
  } else {
64
- for (const item of findings) {
65
- process.stderr.write(`${item.path}:${item.line}: ${item.kind}: ${item.message}\n`);
66
- }
67
- process.stderr.write(`${findings.length} localization finding(s).\n`);
97
+ printFindings(findings);
68
98
  }
69
99
  if (findings.length > 0) process.exitCode = 1;
70
100
  }
package/src/index.mjs CHANGED
@@ -1,8 +1,10 @@
1
- import { readFile, readdir } from "node:fs/promises";
1
+ import { readFile, readdir, writeFile } from "node:fs/promises";
2
2
  import { extname, join, relative, sep } from "node:path";
3
3
  import ts from "typescript";
4
4
 
5
5
  export const ALLOW_COMMENT = "// localization-drift: allow-next-line -- ";
6
+ export const SIBLING_ALLOW_COMMENT = "// localization-audit: allow-next-line -- ";
7
+ export const FIX_REASON = "TODO: localize";
6
8
 
7
9
  export const DEFAULT_CONFIG = Object.freeze({
8
10
  roots: ["src"],
@@ -10,6 +12,7 @@ export const DEFAULT_CONFIG = Object.freeze({
10
12
  attributes: ["alt", "aria-label", "placeholder", "title"],
11
13
  exclude: ["\\.test\\.", "/test/", "/generated/", "node_modules", "\\.d\\.ts$"],
12
14
  forbidden: [],
15
+ allowlist: [],
13
16
  });
14
17
 
15
18
  function lineDetails(source, index) {
@@ -23,8 +26,23 @@ function lineDetails(source, index) {
23
26
  };
24
27
  }
25
28
 
26
- function reviewedException(source, index, allowComment) {
27
- return lineDetails(source, index).previous.startsWith(allowComment);
29
+ function commentBody(line) {
30
+ if (line.startsWith("//")) return line.slice(2).trimStart();
31
+ if (line.startsWith("{/*") && line.endsWith("*/}")) return line.slice(3, -3).trim();
32
+ if (line.startsWith("/*") && line.endsWith("*/")) return line.slice(2, -2).trim();
33
+ return null;
34
+ }
35
+
36
+ function markerPrefixMatches(line, prefix) {
37
+ if (line.startsWith(prefix)) return true;
38
+ if (!prefix.startsWith("// ")) return false;
39
+ const body = commentBody(line);
40
+ return body !== null && body.startsWith(prefix.slice(3));
41
+ }
42
+
43
+ function reviewedException(source, index, allowComments) {
44
+ const previous = lineDetails(source, index).previous;
45
+ return allowComments.some((prefix) => markerPrefixMatches(previous, prefix));
28
46
  }
29
47
 
30
48
  function finding(path, source, index, kind, message) {
@@ -43,6 +61,7 @@ export function analyzeSource(path, source, options = {}) {
43
61
  forbidden = [],
44
62
  allowComment = ALLOW_COMMENT,
45
63
  } = options;
64
+ const allowComments = [allowComment, SIBLING_ALLOW_COMMENT];
46
65
  const localizedAttributes = new Set(attributes);
47
66
  const indexed = [];
48
67
 
@@ -54,7 +73,7 @@ export function analyzeSource(path, source, options = {}) {
54
73
  node.initializer && ts.isStringLiteral(node.initializer) &&
55
74
  /\p{L}/u.test(node.initializer.text)) {
56
75
  const index = node.getStart(sourceFile);
57
- if (!reviewedException(source, index, allowComment)) {
76
+ if (!reviewedException(source, index, allowComments)) {
58
77
  indexed.push({
59
78
  index,
60
79
  finding: finding(
@@ -70,7 +89,7 @@ export function analyzeSource(path, source, options = {}) {
70
89
  if (ts.isJsxText(node)) {
71
90
  const value = node.getText(sourceFile).replace(/\s+/g, " ").trim();
72
91
  const index = node.getStart(sourceFile);
73
- if (value && /\p{L}/u.test(value) && !reviewedException(source, index, allowComment)) {
92
+ if (value && /\p{L}/u.test(value) && !reviewedException(source, index, allowComments)) {
74
93
  indexed.push({
75
94
  index,
76
95
  finding: finding(
@@ -92,7 +111,7 @@ export function analyzeSource(path, source, options = {}) {
92
111
  if (!rule || typeof rule.pattern !== "string" || typeof rule.message !== "string") continue;
93
112
  const pattern = new RegExp(rule.pattern, "g");
94
113
  for (const match of source.matchAll(pattern)) {
95
- if (reviewedException(source, match.index, allowComment)) continue;
114
+ if (reviewedException(source, match.index, allowComments)) continue;
96
115
  indexed.push({
97
116
  index: match.index,
98
117
  finding: finding(path, source, match.index, "forbidden-pattern", rule.message),
@@ -105,6 +124,29 @@ export function analyzeSource(path, source, options = {}) {
105
124
  .map(({ finding: result }) => result);
106
125
  }
107
126
 
127
+ function compileAllowlist(allowlist) {
128
+ if (!Array.isArray(allowlist)) {
129
+ throw new Error("allowlist must be an array of { pattern, reason } entries");
130
+ }
131
+ return allowlist.map((entry, index) => {
132
+ if (!entry || typeof entry.pattern !== "string") {
133
+ throw new Error(`allowlist[${index}].pattern must be a string`);
134
+ }
135
+ if (typeof entry.reason !== "string" || entry.reason.trim() === "") {
136
+ throw new Error(`allowlist[${index}].reason must be a non-empty string`);
137
+ }
138
+ try {
139
+ return { pattern: new RegExp(entry.pattern), reason: entry.reason };
140
+ } catch {
141
+ throw new Error(`allowlist[${index}].pattern is not a valid regular expression: ${entry.pattern}`);
142
+ }
143
+ });
144
+ }
145
+
146
+ function allowlisted(item, allowlist) {
147
+ return allowlist.some((rule) => rule.pattern.test(item.message));
148
+ }
149
+
108
150
  function excludedPath(path, exclude) {
109
151
  return exclude.some((pattern) => new RegExp(pattern).test(path));
110
152
  }
@@ -128,33 +170,155 @@ async function* walk(directory, config) {
128
170
  }
129
171
  }
130
172
 
131
- /**
132
- * scanRepository walks the configured roots and returns every finding, sorted
133
- * by path and line.
134
- */
135
- export async function scanRepository(root, config = {}) {
136
- const settings = {
173
+ function compileSettings(config = {}) {
174
+ return {
137
175
  roots: config.roots ?? DEFAULT_CONFIG.roots,
138
176
  extensions: config.extensions ?? DEFAULT_CONFIG.extensions,
139
177
  attributes: config.attributes ?? DEFAULT_CONFIG.attributes,
140
178
  exclude: config.exclude ?? DEFAULT_CONFIG.exclude,
141
179
  forbidden: config.forbidden ?? DEFAULT_CONFIG.forbidden,
142
180
  allowComment: config.allowComment ?? ALLOW_COMMENT,
181
+ allowlist: compileAllowlist(config.allowlist ?? DEFAULT_CONFIG.allowlist),
143
182
  };
144
- const findings = [];
183
+ }
184
+
185
+ function analysisOptions(settings) {
186
+ return {
187
+ attributes: settings.attributes,
188
+ forbidden: settings.forbidden,
189
+ allowComment: settings.allowComment,
190
+ };
191
+ }
192
+
193
+ async function* repositorySources(root, settings) {
145
194
  for (const directory of settings.roots) {
146
195
  for await (const absolute of walk(join(root, directory), settings)) {
147
196
  const path = relative(root, absolute).split(sep).join("/");
148
197
  if (excludedPath(path, settings.exclude)) continue;
149
- const source = await readFile(absolute, "utf8");
150
- findings.push(...analyzeSource(path, source, {
151
- attributes: settings.attributes,
152
- forbidden: settings.forbidden,
153
- allowComment: settings.allowComment,
154
- }));
198
+ yield { path, absolute, source: await readFile(absolute, "utf8") };
155
199
  }
156
200
  }
201
+ }
202
+
203
+ function sortFindings(findings) {
157
204
  return findings.sort((left, right) =>
158
205
  left.path.localeCompare(right.path) || left.line - right.line
159
206
  );
160
207
  }
208
+
209
+ /**
210
+ * scanRepository walks the configured roots and returns every finding, sorted
211
+ * by path and line. Allowlisted messages are removed.
212
+ */
213
+ export async function scanRepository(root, config = {}) {
214
+ const settings = compileSettings(config);
215
+ const options = analysisOptions(settings);
216
+ const findings = [];
217
+ for await (const file of repositorySources(root, settings)) {
218
+ findings.push(...analyzeSource(file.path, file.source, options)
219
+ .filter((item) => !allowlisted(item, settings.allowlist)));
220
+ }
221
+ return sortFindings(findings);
222
+ }
223
+
224
+ function splitLines(source) {
225
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
226
+ const endsWithEol = source.endsWith("\n");
227
+ const lines = source.split(/\r?\n/);
228
+ if (endsWithEol) lines.pop();
229
+ return { eol, endsWithEol, lines };
230
+ }
231
+
232
+ function isMarkerLine(line, allowComments) {
233
+ const trimmed = line.trim();
234
+ return allowComments.some((prefix) => markerPrefixMatches(trimmed, prefix));
235
+ }
236
+
237
+ function applyMarkers(source, markers) {
238
+ const { eol, endsWithEol, lines } = splitLines(source);
239
+ const result = [];
240
+ for (let index = 0; index < lines.length; index += 1) {
241
+ const marker = markers.get(index + 1);
242
+ if (marker !== undefined) result.push(marker);
243
+ result.push(lines[index]);
244
+ }
245
+ return result.join(eol) + (endsWithEol ? eol : "");
246
+ }
247
+
248
+ function markerForms(allowComment) {
249
+ const forms = [`${allowComment}${FIX_REASON}`];
250
+ if (allowComment.startsWith("// ")) {
251
+ forms.push(`{/* ${allowComment.slice(3)}${FIX_REASON} */}`);
252
+ }
253
+ return forms;
254
+ }
255
+
256
+ function chooseMarker(path, source, options, allowComments, item, total) {
257
+ const { lines } = splitLines(source);
258
+ const target = lines[item.line - 1] ?? "";
259
+ if (isMarkerLine(target, allowComments)) return null;
260
+ if (isMarkerLine(lines[item.line - 2] ?? "", allowComments)) return null;
261
+ const indentation = /^[ \t]*/.exec(target)[0];
262
+ for (const form of markerForms(options.allowComment)) {
263
+ const marker = `${indentation}${form}`;
264
+ const candidate = applyMarkers(source, new Map([[item.line, marker]]));
265
+ const introduced = analyzeSource(path, candidate, options);
266
+ const markerIsFinding = introduced.some((candidateFinding) => candidateFinding.text === marker.trim());
267
+ if (!markerIsFinding && introduced.length < total) return marker;
268
+ }
269
+ return null;
270
+ }
271
+
272
+ function fixSource(path, source, options, allowlist) {
273
+ const allowComments = [options.allowComment, SIBLING_ALLOW_COMMENT];
274
+ let current = source;
275
+ let annotated = 0;
276
+ while (true) {
277
+ const findings = analyzeSource(path, current, options)
278
+ .filter((item) => !allowlisted(item, allowlist));
279
+ if (findings.length === 0) break;
280
+ const markers = new Map();
281
+ for (const item of findings) {
282
+ if (markers.has(item.line)) continue;
283
+ const marker = chooseMarker(path, current, options, allowComments, item, findings.length);
284
+ if (marker !== null) markers.set(item.line, marker);
285
+ }
286
+ if (markers.size === 0) break;
287
+ current = applyMarkers(current, markers);
288
+ annotated += markers.size;
289
+ }
290
+ return { source: current, annotated };
291
+ }
292
+
293
+ /**
294
+ * fixRepository annotates every reported finding with an exception marker and
295
+ * writes the edited files. Every edit is computed before the first write.
296
+ * Returns the edited files and the findings that remain.
297
+ */
298
+ export async function fixRepository(root, config = {}) {
299
+ const settings = compileSettings(config);
300
+ const options = analysisOptions(settings);
301
+ const edits = [];
302
+ const remaining = [];
303
+ for await (const file of repositorySources(root, settings)) {
304
+ const fixed = fixSource(file.path, file.source, options, settings.allowlist);
305
+ if (fixed.annotated > 0) {
306
+ edits.push({
307
+ path: file.path,
308
+ absolute: file.absolute,
309
+ source: fixed.source,
310
+ annotated: fixed.annotated,
311
+ });
312
+ }
313
+ remaining.push(...analyzeSource(file.path, fixed.source, options)
314
+ .filter((item) => !allowlisted(item, settings.allowlist)));
315
+ }
316
+ edits.sort((left, right) => left.path.localeCompare(right.path));
317
+ for (const edit of edits) {
318
+ await writeFile(edit.absolute, edit.source, "utf8");
319
+ }
320
+ return {
321
+ files: edits.map(({ path, annotated }) => ({ path, annotated })),
322
+ findings: sortFindings(remaining),
323
+ };
324
+ }
package/src/sarif.mjs ADDED
@@ -0,0 +1,52 @@
1
+ const SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
2
+
3
+ const KIND_DESCRIPTIONS = {
4
+ "localized-jsx-text": "Customer-visible JSX text must come from a translation key.",
5
+ "localized-attribute": "Customer-visible JSX attributes must come from a translation key.",
6
+ "forbidden-pattern": "A configured forbidden pattern is present in the source.",
7
+ };
8
+
9
+ function normalizeRepository(repository) {
10
+ const url = typeof repository === "string" ? repository : repository?.url;
11
+ if (typeof url !== "string") return undefined;
12
+ return url.replace(/^git\+/, "").replace(/\.git$/, "");
13
+ }
14
+
15
+ /**
16
+ * buildSarif renders findings as one SARIF 2.1.0 document.
17
+ */
18
+ export function buildSarif(findings, meta = {}) {
19
+ const rules = [];
20
+ const results = [];
21
+ for (const item of findings) {
22
+ if (!rules.some((rule) => rule.id === item.kind)) {
23
+ rules.push({
24
+ id: item.kind,
25
+ name: item.kind,
26
+ shortDescription: { text: KIND_DESCRIPTIONS[item.kind] ?? item.kind },
27
+ });
28
+ }
29
+ results.push({
30
+ ruleId: item.kind,
31
+ level: "error",
32
+ message: { text: item.message },
33
+ locations: [{
34
+ physicalLocation: {
35
+ artifactLocation: { uri: item.path },
36
+ region: { startLine: item.line },
37
+ },
38
+ }],
39
+ });
40
+ }
41
+ const driver = {
42
+ name: "localization-drift",
43
+ version: meta.version,
44
+ informationUri: normalizeRepository(meta.repository),
45
+ rules,
46
+ };
47
+ return {
48
+ $schema: SCHEMA,
49
+ version: "2.1.0",
50
+ runs: [{ tool: { driver }, results }],
51
+ };
52
+ }