actions-warden 0.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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Upgrade command - bump pinned (or tagged) actions to the newest version
3
+ * permitted by the chosen policy.
4
+ *
5
+ * For SHA-pinned refs, the human-readable version is read from the inline
6
+ * comment (e.g. `# v3.1.0`). If absent, the action is reported as `unknown`
7
+ * and skipped.
8
+ */
9
+
10
+ import { readFile } from 'node:fs/promises';
11
+ import { createHash } from 'node:crypto';
12
+ import semver from 'semver';
13
+ import { parseWorkflowSource, collectUses } from '../lib/parser.js';
14
+ import { discoverWorkflows, resolveWorkflowArg } from '../lib/paths.js';
15
+ import { listTags, pickLatestTag, resolveRefToSha, resolveToken, getCommitDate } from '../lib/resolver.js';
16
+ import { writeFileGuarded } from '../lib/writer.js';
17
+ import { parseIgnoreDirectives, isIgnored } from '../lib/ignore.js';
18
+ import { rewriteUses } from './pin.js';
19
+ import { format } from '../lib/formatter.js';
20
+
21
+ const SHA_RE = /^[0-9a-f]{40}$/i;
22
+ const INLINE_COMMENT_RE = /uses\s*:[^\n#]*#\s*([^\s]+)/;
23
+
24
+ /**
25
+ * @typedef {object} UpgradeChange
26
+ * @property {string} id
27
+ * @property {string} file
28
+ * @property {string} action
29
+ * @property {string} fromRef
30
+ * @property {string|null} fromVersion
31
+ * @property {string} toTag
32
+ * @property {string} toSha
33
+ * @property {'major'|'minor'|'patch'|'unknown'} level
34
+ * @property {number} line
35
+ */
36
+
37
+ /**
38
+ * @param {object} opts
39
+ * @param {string} [opts.cwd]
40
+ * @param {string[]} [opts.workflows]
41
+ * @param {boolean} [opts.dryRun]
42
+ * @param {string} [opts.token]
43
+ * @param {'major'|'minor'|'patch'} [opts.mode]
44
+ * @param {string} [opts.fix]
45
+ * @param {number} [opts.minAgeDays] - skip tags newer than this many days
46
+ * @returns {Promise<{changes: UpgradeChange[], errors: object[], skipped: object[], status: 'OK'|'FAIL'}>}
47
+ */
48
+ export async function upgrade({
49
+ cwd = process.cwd(),
50
+ workflows,
51
+ dryRun = true,
52
+ token,
53
+ mode = 'minor',
54
+ fix,
55
+ minAgeDays = 7,
56
+ } = {}) {
57
+ const files = await resolveTargets(workflows, cwd);
58
+ const tok = resolveToken(token);
59
+ /** @type {UpgradeChange[]} */
60
+ const changes = [];
61
+ const errors = [];
62
+ /** @type {object[]} */
63
+ const skipped = [];
64
+ const cooldownMs = Math.max(minAgeDays, 0) * 86_400_000;
65
+
66
+ for (const file of files) {
67
+ let source;
68
+ try {
69
+ source = await readFile(file, 'utf8');
70
+ } catch (err) {
71
+ errors.push({ file, error: String(err.message ?? err) });
72
+ continue;
73
+ }
74
+ const lines = source.split('\n');
75
+ let doc;
76
+ try {
77
+ doc = parseWorkflowSource(source, file);
78
+ } catch (err) {
79
+ errors.push({ file, error: String(err.message ?? err) });
80
+ continue;
81
+ }
82
+
83
+ const ignore = parseIgnoreDirectives(source);
84
+ const planned = [];
85
+ for (const { ref } of collectUses(doc)) {
86
+ if (ref.kind !== 'external' && ref.kind !== 'reusable-workflow') continue;
87
+ if (isIgnored(ignore, ref.line, 'unpinned-action')) continue;
88
+ const usesLine = lines[ref.line - 1] ?? '';
89
+ const inlineVersion = readInlineVersion(usesLine);
90
+ const currentVersion = ref.ref && SHA_RE.test(ref.ref) ? inlineVersion : ref.ref;
91
+ if (!currentVersion) continue;
92
+
93
+ let tags;
94
+ try {
95
+ tags = await listTags({ owner: ref.owner, repo: ref.repo, token: tok, cwd });
96
+ } catch (err) {
97
+ errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
98
+ continue;
99
+ }
100
+ const latest = await pickAgedTag({
101
+ tags,
102
+ currentRef: currentVersion,
103
+ mode,
104
+ cooldownMs,
105
+ owner: ref.owner,
106
+ repo: ref.repo,
107
+ token: tok,
108
+ cwd,
109
+ skipped,
110
+ file,
111
+ ref,
112
+ });
113
+ if (!latest) continue;
114
+ if (latest.name === currentVersion) continue;
115
+ const level = bumpLevel(currentVersion, latest.name);
116
+ let resolved;
117
+ try {
118
+ resolved = await resolveRefToSha({
119
+ owner: ref.owner,
120
+ repo: ref.repo,
121
+ ref: latest.name,
122
+ token: tok,
123
+ cwd,
124
+ });
125
+ } catch (err) {
126
+ errors.push({ file, action: ref.raw, error: String(err.message ?? err) });
127
+ continue;
128
+ }
129
+ planned.push({ ref, latest, sha: resolved.sha, level, currentVersion });
130
+ }
131
+
132
+ let newSource = source;
133
+ for (const { ref, latest, sha, level, currentVersion } of planned) {
134
+ const change = {
135
+ id: changeId(file, ref.raw, latest.name),
136
+ file,
137
+ action: `${ref.owner}/${ref.repo}`,
138
+ fromRef: ref.ref,
139
+ fromVersion: currentVersion,
140
+ toTag: latest.name,
141
+ toSha: sha,
142
+ level,
143
+ line: ref.line,
144
+ };
145
+ if (fix && fix !== change.id) continue;
146
+ newSource = rewriteUses(newSource, ref, sha);
147
+ newSource = fixInlineComment(newSource, ref, latest.name, sha);
148
+ changes.push(change);
149
+ }
150
+ if (newSource !== source) {
151
+ await writeFileGuarded({ path: file, content: newSource, dryRun, cwd });
152
+ }
153
+ }
154
+ return { changes, errors, skipped, status: errors.length === 0 ? 'OK' : 'FAIL' };
155
+ }
156
+
157
+ /**
158
+ * Walk candidate tags newest-first and return the first whose commit is older
159
+ * than the cooldown threshold. Skipped candidates are recorded.
160
+ */
161
+ async function pickAgedTag({ tags, currentRef, mode, cooldownMs, owner, repo, token, cwd, skipped, file, ref }) {
162
+ if (cooldownMs <= 0) {
163
+ return pickLatestTag({ tags, currentRef, mode });
164
+ }
165
+ const remaining = [...tags];
166
+ const cutoff = Date.now() - cooldownMs;
167
+ for (;;) {
168
+ const candidate = pickLatestTag({ tags: remaining, currentRef, mode });
169
+ if (!candidate) return null;
170
+ let dateMs;
171
+ try {
172
+ dateMs = await getCommitDate({ owner, repo, sha: candidate.sha, token, cwd });
173
+ } catch {
174
+ return candidate;
175
+ }
176
+ if (dateMs <= cutoff) return candidate;
177
+ skipped.push({
178
+ file,
179
+ action: `${ref.owner}/${ref.repo}`,
180
+ tag: candidate.name,
181
+ reason: 'cooldown',
182
+ ageDays: Math.round((Date.now() - dateMs) / 86_400_000),
183
+ });
184
+ const idx = remaining.findIndex(t => t.name === candidate.name);
185
+ if (idx === -1) return null;
186
+ remaining.splice(idx, 1);
187
+ }
188
+ }
189
+
190
+ function readInlineVersion(line) {
191
+ const m = INLINE_COMMENT_RE.exec(line);
192
+ return m ? m[1] : null;
193
+ }
194
+
195
+ function bumpLevel(from, to) {
196
+ const a = semver.coerce(from);
197
+ const b = semver.coerce(to);
198
+ if (!a || !b) return 'unknown';
199
+ if (a.major !== b.major) return 'major';
200
+ if (a.minor !== b.minor) return 'minor';
201
+ return 'patch';
202
+ }
203
+
204
+ function fixInlineComment(source, ref, newTag, sha) {
205
+ const left = ref.subpath ? `${ref.owner}/${ref.repo}/${ref.subpath}` : `${ref.owner}/${ref.repo}`;
206
+ const escLeft = escRe(left);
207
+ const escSha = escRe(sha);
208
+ const re = new RegExp(
209
+ `(uses\\s*:\\s*['"]?)${escLeft}@${escSha}(['"]?)\\s*#\\s*[^\\n]+`,
210
+ 'g',
211
+ );
212
+ return source.replace(re, (_, prefix, closingQuote) => `${prefix}${left}@${sha}${closingQuote} # ${newTag}`);
213
+ }
214
+
215
+ function escRe(s) {
216
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
217
+ }
218
+
219
+ function changeId(file, raw, target) {
220
+ return createHash('sha1').update(`up:${file}:${raw}->${target}`).digest('hex').slice(0, 10);
221
+ }
222
+
223
+ async function resolveTargets(workflows, cwd) {
224
+ if (!workflows || workflows.length === 0) return discoverWorkflows({ cwd });
225
+ const out = new Set();
226
+ for (const w of workflows) {
227
+ for (const f of await resolveWorkflowArg(w, cwd)) out.add(f);
228
+ }
229
+ return [...out].sort();
230
+ }
231
+
232
+ /**
233
+ * @param {Awaited<ReturnType<typeof upgrade>>} result
234
+ * @param {{format: 'toon'|'json'|'text', dryRun: boolean, mode: string, cwd?: string}} opts
235
+ */
236
+ export function renderUpgrade(result, opts) {
237
+ const cwd = opts.cwd ?? process.cwd();
238
+ if (opts.format === 'json') {
239
+ return format('json', [], {
240
+ status: result.status,
241
+ json: {
242
+ dryRun: opts.dryRun,
243
+ mode: opts.mode,
244
+ changes: result.changes.map(c => ({ ...c, file: rel(c.file, cwd) })),
245
+ skipped: (result.skipped ?? []).map(s => ({ ...s, file: rel(s.file ?? '', cwd) })),
246
+ errors: result.errors,
247
+ status: result.status,
248
+ },
249
+ });
250
+ }
251
+ const records = [];
252
+ for (const c of result.changes) {
253
+ records.push({
254
+ label: 'UPGRADE',
255
+ fields: {
256
+ id: c.id,
257
+ file: rel(c.file, cwd),
258
+ line: c.line,
259
+ action: c.action,
260
+ from: c.fromVersion ?? c.fromRef,
261
+ to: c.toTag,
262
+ sha: c.toSha,
263
+ level: c.level,
264
+ applied: !opts.dryRun,
265
+ },
266
+ });
267
+ }
268
+ for (const s of result.skipped ?? []) {
269
+ records.push({ label: 'SKIP', fields: { file: rel(s.file ?? '', cwd), action: s.action, tag: s.tag, reason: s.reason, age_days: s.ageDays } });
270
+ }
271
+ for (const e of result.errors) {
272
+ records.push({ label: 'ERROR', fields: { file: rel(e.file ?? '', cwd), action: e.action ?? '', msg: e.error } });
273
+ }
274
+ records.push({ label: 'SUMMARY', fields: { changes: result.changes.length, skipped: (result.skipped ?? []).length, errors: result.errors.length, mode: opts.mode, dry_run: opts.dryRun } });
275
+ return format(opts.format, records, { status: result.status });
276
+ }
277
+
278
+ function rel(p, cwd) {
279
+ if (p && p.startsWith(cwd)) return p.slice(cwd.length + 1);
280
+ return p;
281
+ }
package/src/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Public programmatic API.
3
+ *
4
+ * Each command can be invoked without spawning a subprocess:
5
+ *
6
+ * import { audit, pin, upgrade, report } from 'actions-warden';
7
+ * const result = await audit({ cwd: '/repo' });
8
+ */
9
+
10
+ export { audit, renderAudit } from './commands/audit.js';
11
+ export { pin, renderPin, rewriteUses } from './commands/pin.js';
12
+ export { upgrade, renderUpgrade } from './commands/upgrade.js';
13
+ export { report, renderReport } from './commands/report.js';
14
+ export { listRules } from './rules/index.js';
15
+ export { parseWorkflowFile, parseWorkflowSource, collectUses, parseActionRef } from './lib/parser.js';
16
+ export { format, renderToon, renderJson, renderText, summarize, SEVERITY_ORDER } from './lib/formatter.js';
17
+ export { discoverWorkflows } from './lib/paths.js';
18
+ export { redact } from './lib/redact.js';
19
+ export { parseIgnoreDirectives, isIgnored } from './lib/ignore.js';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * On-disk cache for GitHub API responses.
3
+ *
4
+ * Keyed by sha1 of the request URL. TTL stored in the cache entry. Files live
5
+ * in `.actions-warden-cache/` inside the working directory.
6
+ */
7
+
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { resolve, join } from 'node:path';
10
+ import { createHash } from 'node:crypto';
11
+
12
+ /**
13
+ * @typedef {object} CacheEntry
14
+ * @property {number} savedAt - ms epoch
15
+ * @property {number} ttlMs
16
+ * @property {unknown} value
17
+ */
18
+
19
+ /**
20
+ * @param {string} cwd
21
+ * @returns {string}
22
+ */
23
+ export function cacheDir(cwd = process.cwd()) {
24
+ return resolve(cwd, '.actions-warden-cache');
25
+ }
26
+
27
+ /**
28
+ * @param {string} key
29
+ * @returns {string}
30
+ */
31
+ function digest(key) {
32
+ return createHash('sha1').update(key).digest('hex');
33
+ }
34
+
35
+ /**
36
+ * @param {object} opts
37
+ * @param {string} opts.key
38
+ * @param {number} [opts.ttlMs] - default 1h
39
+ * @param {string} [opts.cwd]
40
+ * @returns {Promise<unknown|undefined>}
41
+ */
42
+ export async function readCache({ key, ttlMs = 3600 * 1000, cwd = process.cwd() }) {
43
+ const path = join(cacheDir(cwd), `${digest(key)}.json`);
44
+ try {
45
+ const raw = await readFile(path, 'utf8');
46
+ /** @type {CacheEntry} */
47
+ const entry = JSON.parse(raw);
48
+ if (Date.now() - entry.savedAt > Math.min(entry.ttlMs, ttlMs)) return undefined;
49
+ return entry.value;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @param {object} opts
57
+ * @param {string} opts.key
58
+ * @param {unknown} opts.value
59
+ * @param {number} [opts.ttlMs]
60
+ * @param {string} [opts.cwd]
61
+ */
62
+ export async function writeCache({ key, value, ttlMs = 3600 * 1000, cwd = process.cwd() }) {
63
+ const dir = cacheDir(cwd);
64
+ await mkdir(dir, { recursive: true });
65
+ /** @type {CacheEntry} */
66
+ const entry = { savedAt: Date.now(), ttlMs, value };
67
+ await writeFile(join(dir, `${digest(key)}.json`), JSON.stringify(entry), 'utf8');
68
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Output formatter supporting TOON (Token-Oriented Object Notation), JSON, and
3
+ * plain text.
4
+ *
5
+ * TOON output rules:
6
+ * - One record per line: `LABEL: key=value key=value`
7
+ * - Values containing spaces or `=` are quoted: `msg="hello world"`
8
+ * - Empty/null values are omitted
9
+ * - Trailing `STATUS: OK` or `STATUS: FAIL` signal for machine consumers
10
+ */
11
+
12
+ import { redact } from './redact.js';
13
+
14
+ /**
15
+ * Severity ordering, lowest-to-highest.
16
+ */
17
+ export const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
18
+
19
+ /**
20
+ * @param {string} value
21
+ * @returns {string}
22
+ */
23
+ function quoteIfNeeded(value) {
24
+ if (value === '') return '""';
25
+ if (/[\s="]/.test(value)) {
26
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
27
+ }
28
+ return value;
29
+ }
30
+
31
+ /**
32
+ * Serialize a record to a single TOON line.
33
+ *
34
+ * @param {string} label
35
+ * @param {Record<string, unknown>} fields
36
+ * @returns {string}
37
+ */
38
+ export function toonLine(label, fields) {
39
+ const parts = [];
40
+ for (const [k, v] of Object.entries(fields)) {
41
+ if (v === null || v === undefined || v === '') continue;
42
+ const value = typeof v === 'string' ? v : String(v);
43
+ parts.push(`${k}=${quoteIfNeeded(redact(value))}`);
44
+ }
45
+ return parts.length === 0 ? `${label}:` : `${label}: ${parts.join(' ')}`;
46
+ }
47
+
48
+ /**
49
+ * Render a TOON document.
50
+ *
51
+ * @param {Array<{label: string, fields: Record<string, unknown>}>} records
52
+ * @param {{status?: 'OK'|'FAIL'}} [options]
53
+ * @returns {string}
54
+ */
55
+ export function renderToon(records, options = {}) {
56
+ const lines = records.map(r => toonLine(r.label, r.fields));
57
+ if (options.status) lines.push(`STATUS: ${options.status}`);
58
+ return lines.join('\n') + '\n';
59
+ }
60
+
61
+ /**
62
+ * Render JSON with stable key ordering and 2-space indent.
63
+ *
64
+ * @param {unknown} payload
65
+ * @returns {string}
66
+ */
67
+ export function renderJson(payload) {
68
+ return JSON.stringify(payload, null, 2) + '\n';
69
+ }
70
+
71
+ /**
72
+ * Render plain text (human-readable summary).
73
+ *
74
+ * @param {Array<{label: string, fields: Record<string, unknown>}>} records
75
+ * @param {{status?: 'OK'|'FAIL'}} [options]
76
+ * @returns {string}
77
+ */
78
+ export function renderText(records, options = {}) {
79
+ const lines = records.map(r => {
80
+ const kv = Object.entries(r.fields)
81
+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
82
+ .map(([k, v]) => `${k}=${redact(String(v))}`)
83
+ .join(' ');
84
+ return `[${r.label}] ${kv}`;
85
+ });
86
+ if (options.status) lines.push(`==> ${options.status}`);
87
+ return lines.join('\n') + '\n';
88
+ }
89
+
90
+ /**
91
+ * Format records into the requested output mode.
92
+ *
93
+ * @param {'toon'|'json'|'text'} format
94
+ * @param {Array<{label: string, fields: Record<string, unknown>}>} records
95
+ * @param {{status?: 'OK'|'FAIL', json?: unknown}} [options]
96
+ * @returns {string}
97
+ */
98
+ export function format(format_, records, options = {}) {
99
+ switch (format_) {
100
+ case 'json':
101
+ return renderJson(options.json ?? recordsToJson(records, options));
102
+ case 'text':
103
+ return renderText(records, options);
104
+ case 'toon':
105
+ default:
106
+ return renderToon(records, options);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Convert records to a generic JSON payload when no custom JSON is supplied.
112
+ *
113
+ * @param {Array<{label: string, fields: Record<string, unknown>}>} records
114
+ * @param {{status?: string}} options
115
+ * @returns {object}
116
+ */
117
+ function recordsToJson(records, options) {
118
+ return {
119
+ records: records.map(r => ({ label: r.label, ...r.fields })),
120
+ status: options.status ?? null,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Aggregate findings into a summary record.
126
+ *
127
+ * @param {Array<{severity: string}>} findings
128
+ * @returns {Record<string, number>}
129
+ */
130
+ export function summarize(findings) {
131
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
132
+ for (const f of findings) {
133
+ if (counts[f.severity] !== undefined) counts[f.severity] += 1;
134
+ }
135
+ return counts;
136
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Inline ignore directives in workflow sources.
3
+ *
4
+ * Directives (accept either `actions-warden-` or `aw-` prefix):
5
+ *
6
+ * # actions-warden-ignore-file - silence the entire file
7
+ * # actions-warden-ignore-start - start a block (until -end)
8
+ * # actions-warden-ignore-end - end the current block
9
+ * # actions-warden-ignore-next-line - silence the next non-comment line
10
+ * # actions-warden-ignore - silence the same line (inline)
11
+ *
12
+ * Optional rule filter: append rule ids after a colon, e.g.
13
+ * # actions-warden-ignore: unpinned-action,secrets-in-env
14
+ *
15
+ * Without a filter the directive silences every rule.
16
+ */
17
+
18
+ const PREFIX = '(?:actions-warden|aw)';
19
+ const TAIL = '(?::\\s*([\\w,\\s-]+))?\\s*$';
20
+ const RE_FILE = new RegExp(`#\\s*${PREFIX}-ignore-file${TAIL}`);
21
+ const RE_START = new RegExp(`#\\s*${PREFIX}-ignore-start${TAIL}`);
22
+ const RE_END = new RegExp(`#\\s*${PREFIX}-ignore-end\\s*$`);
23
+ const RE_NEXT = new RegExp(`#\\s*${PREFIX}-ignore-next-line${TAIL}`);
24
+ const RE_INLINE = new RegExp(`#\\s*${PREFIX}-ignore(?!-)${TAIL}`);
25
+
26
+ /**
27
+ * @typedef {object} IgnoreScope
28
+ * @property {boolean} wholeFile
29
+ * @property {Set<string>|null} fileRules - null means "all rules"
30
+ * @property {Array<{start: number, end: number, rules: Set<string>|null}>} ranges
31
+ * @property {Map<number, Set<string>|null>} lines - line -> ignored rule set (or null = all)
32
+ */
33
+
34
+ /**
35
+ * @param {string|null|undefined} list comma- or whitespace-separated rule ids
36
+ * @returns {Set<string>|null} null = match every rule
37
+ */
38
+ function parseRuleList(list) {
39
+ if (!list) return null;
40
+ const ids = list.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
41
+ if (ids.length === 0) return null;
42
+ return new Set(ids);
43
+ }
44
+
45
+ /**
46
+ * @param {string} source
47
+ * @returns {IgnoreScope}
48
+ */
49
+ export function parseIgnoreDirectives(source) {
50
+ /** @type {IgnoreScope} */
51
+ const scope = {
52
+ wholeFile: false,
53
+ fileRules: null,
54
+ ranges: [],
55
+ lines: new Map(),
56
+ };
57
+ const lines = source.split('\n');
58
+ /** @type {{start: number, rules: Set<string>|null}|null} */
59
+ let openBlock = null;
60
+
61
+ lines.forEach((text, i) => {
62
+ const lineNo = i + 1;
63
+ const fileMatch = text.match(RE_FILE);
64
+ if (fileMatch) {
65
+ scope.wholeFile = true;
66
+ scope.fileRules = parseRuleList(fileMatch[1]);
67
+ return;
68
+ }
69
+ const startMatch = text.match(RE_START);
70
+ if (startMatch) {
71
+ openBlock = { start: lineNo, rules: parseRuleList(startMatch[1]) };
72
+ return;
73
+ }
74
+ if (RE_END.test(text)) {
75
+ if (openBlock) {
76
+ scope.ranges.push({ start: openBlock.start, end: lineNo, rules: openBlock.rules });
77
+ openBlock = null;
78
+ }
79
+ return;
80
+ }
81
+ const nextMatch = text.match(RE_NEXT);
82
+ if (nextMatch) {
83
+ for (let j = i + 1; j < lines.length; j += 1) {
84
+ const next = lines[j].trim();
85
+ if (next === '' || next.startsWith('#')) continue;
86
+ mergeLine(scope.lines, j + 1, parseRuleList(nextMatch[1]));
87
+ break;
88
+ }
89
+ return;
90
+ }
91
+ const inlineMatch = text.match(RE_INLINE);
92
+ if (inlineMatch) {
93
+ mergeLine(scope.lines, lineNo, parseRuleList(inlineMatch[1]));
94
+ }
95
+ });
96
+
97
+ if (openBlock) {
98
+ scope.ranges.push({ start: openBlock.start, end: lines.length, rules: openBlock.rules });
99
+ }
100
+ return scope;
101
+ }
102
+
103
+ function mergeLine(map, line, rules) {
104
+ const existing = map.get(line);
105
+ if (existing === undefined) {
106
+ map.set(line, rules);
107
+ return;
108
+ }
109
+ if (existing === null || rules === null) {
110
+ map.set(line, null);
111
+ return;
112
+ }
113
+ for (const r of rules) existing.add(r);
114
+ }
115
+
116
+ /**
117
+ * @param {IgnoreScope} scope
118
+ * @param {number} line
119
+ * @param {string} ruleId
120
+ * @returns {boolean}
121
+ */
122
+ export function isIgnored(scope, line, ruleId) {
123
+ if (scope.wholeFile && matches(scope.fileRules, ruleId)) return true;
124
+ for (const range of scope.ranges) {
125
+ if (line >= range.start && line <= range.end && matches(range.rules, ruleId)) return true;
126
+ }
127
+ const lineRules = scope.lines.get(line);
128
+ if (lineRules !== undefined && matches(lineRules, ruleId)) return true;
129
+ return false;
130
+ }
131
+
132
+ function matches(rules, ruleId) {
133
+ return rules === null ? true : rules.has(ruleId);
134
+ }