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 +27 -0
- package/README.md +61 -2
- package/package.json +2 -1
- package/src/cli.mjs +38 -8
- package/src/index.mjs +183 -19
- package/src/sarif.mjs +52 -0
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
|
|
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
|
-
| `
|
|
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.
|
|
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
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
return
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|