flecto 1.0.2 → 2.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/src/policy.js CHANGED
@@ -1,57 +1,565 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { dirname, isAbsolute, join, resolve } from 'path';
3
+ import { fileURLToPath, pathToFileURL } from 'url';
4
+ import yaml from 'js-yaml';
5
+
1
6
  /**
2
7
  * @typedef {'info' | 'warn' | 'error'} PolicySeverity
3
- * @typedef {{ id: string, severity: PolicySeverity, path: string, message: string }} PolicyFinding
8
+ * @typedef {{
9
+ * id: string,
10
+ * severity: PolicySeverity,
11
+ * path: string,
12
+ * message: string,
13
+ * pack?: string
14
+ * }} PolicyFinding
15
+ *
16
+ * @typedef {{
17
+ * id: string,
18
+ * severity: PolicySeverity,
19
+ * when?: Array<'added' | 'removed' | 'changed'>,
20
+ * match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
21
+ * beforeEquals?: unknown,
22
+ * afterEquals?: unknown,
23
+ * beforeIn?: unknown[],
24
+ * afterIn?: unknown[],
25
+ * beforeTruthy?: true,
26
+ * afterTruthy?: true,
27
+ * afterMatches?: string,
28
+ * numericJump?: { minMultiple: number },
29
+ * numericDelta?: { min: number },
30
+ * allOf?: PolicyMatchClause[],
31
+ * anyOf?: PolicyMatchClause[],
32
+ * message?: string,
33
+ * messageTemplate?: string
34
+ * }} PolicyRule
35
+ *
36
+ * @typedef {{
37
+ * match?: { path?: string, pathFlags?: string, pathEquals?: string, pathPrefix?: string },
38
+ * beforeEquals?: unknown,
39
+ * afterEquals?: unknown,
40
+ * beforeIn?: unknown[],
41
+ * afterIn?: unknown[],
42
+ * beforeTruthy?: true,
43
+ * afterTruthy?: true,
44
+ * afterMatches?: string,
45
+ * numericJump?: { minMultiple: number },
46
+ * numericDelta?: { min: number }
47
+ * }} PolicyMatchClause
48
+ *
49
+ * @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
50
+ *
51
+ * @typedef {{
52
+ * cwd?: string,
53
+ * file?: string,
54
+ * profile?: string | null,
55
+ * source?: 'watch' | 'ci' | 'diff',
56
+ * policies?: string[],
57
+ * plugins?: string[],
58
+ * severityRemap?: Record<string, PolicySeverity | 'off'>
59
+ * }} PolicyEvalOptions
60
+ */
61
+
62
+ const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
63
+ const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
64
+ const CHANGE_TYPES = new Set(['added', 'removed', 'changed']);
65
+ const RULE_FIELDS = new Set([
66
+ 'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
67
+ 'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
68
+ 'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
69
+ ]);
70
+ const CLAUSE_FIELDS = new Set([
71
+ 'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
72
+ 'beforeTruthy', 'afterTruthy', 'afterMatches', 'numericJump', 'numericDelta',
73
+ ]);
74
+ const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
75
+
76
+ /**
77
+ * @param {string} path
78
+ * @param {string} message
79
+ * @returns {never}
80
+ */
81
+ function invalidPack(path, message) {
82
+ throw new Error(`Invalid policy pack at ${path}: ${message}`);
83
+ }
84
+
85
+ /**
86
+ * @param {unknown} value
87
+ * @returns {value is Record<string, unknown>}
88
+ */
89
+ function isObject(value) {
90
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} value
95
+ * @returns {boolean}
96
+ */
97
+ function isTruthyToggle(value) {
98
+ if (value === true) return true;
99
+ if (typeof value !== 'string') return false;
100
+ return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
101
+ }
102
+
103
+ /**
104
+ * Validate a parsed policy pack and reject typos before evaluation.
105
+ * @param {unknown} pack
106
+ * @param {string} path
107
+ * @returns {asserts pack is PolicyPack}
108
+ */
109
+ function validatePack(pack, path) {
110
+ if (!isObject(pack)) invalidPack(path, 'pack must be an object');
111
+
112
+ const packFields = new Set(['id', 'rules']);
113
+ for (const field of Object.keys(pack)) {
114
+ if (!packFields.has(field)) invalidPack(path, `pack.${field} is not allowed`);
115
+ }
116
+ if (Object.hasOwn(pack, 'id') && (typeof pack.id !== 'string' || !pack.id.trim())) {
117
+ invalidPack(path, 'pack.id must be a non-empty string');
118
+ }
119
+ if (!Array.isArray(pack.rules)) invalidPack(path, 'pack.rules must be an array');
120
+
121
+ for (const [index, rule] of pack.rules.entries()) {
122
+ try {
123
+ validateRule(rule, `rules[${index}]`);
124
+ } catch (error) {
125
+ const label = isObject(rule) && typeof rule.id === 'string' && rule.id
126
+ ? `rule "${rule.id}"`
127
+ : `rules[${index}]`;
128
+ const message = error.message.replace(/^Invalid policy rule at [^:]+: /, '')
129
+ .replace(/^unknown field "([^"]+)"$/, '$1 is not allowed (unknown field "$1")')
130
+ .replace(/^unknown match field "([^"]+)"$/, 'match.$1 is not allowed (unknown match field "$1")')
131
+ .replace(/^match\.path is not a valid regular expression$/, 'match.path must be a valid regular expression');
132
+ invalidPack(path, `${label}.${message}`);
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * @param {string} cwd
139
+ * @param {string} packId
140
+ * @returns {string | null}
141
+ */
142
+ function resolvePackPath(cwd, packId) {
143
+ const localJson = resolve(cwd, 'policies', `${packId}.json`);
144
+ const localYaml = resolve(cwd, 'policies', `${packId}.yaml`);
145
+ const localYml = resolve(cwd, 'policies', `${packId}.yml`);
146
+ if (existsSync(localJson)) return localJson;
147
+ if (existsSync(localYaml)) return localYaml;
148
+ if (existsSync(localYml)) return localYml;
149
+
150
+ const builtinJson = join(PACKS_DIR, `${packId}.json`);
151
+ if (existsSync(builtinJson)) return builtinJson;
152
+ return null;
153
+ }
154
+
155
+ /**
156
+ * @param {string} path
157
+ * @param {string} fallbackId
158
+ * @returns {PolicyPack}
159
+ */
160
+ function readPackFile(path, fallbackId) {
161
+ const raw = readFileSync(path, 'utf8');
162
+ let parsed;
163
+ try {
164
+ parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
165
+ } catch (error) {
166
+ invalidPack(path, `could not parse file (${error.message})`);
167
+ }
168
+ validatePack(parsed, path);
169
+ return { ...parsed, id: parsed.id ?? fallbackId };
170
+ }
171
+
172
+ /**
173
+ * Validate a rule or composition clause so pack typos fail closed at load time.
174
+ * @param {unknown} candidate
175
+ * @param {string} location
176
+ * @param {boolean} [isClause]
177
+ */
178
+ function validateRule(candidate, location, isClause = false) {
179
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
180
+ throw new Error(`Invalid policy rule at ${location}: expected an object`);
181
+ }
182
+
183
+ const allowedFields = isClause ? CLAUSE_FIELDS : RULE_FIELDS;
184
+ for (const key of Object.keys(candidate)) {
185
+ if (!allowedFields.has(key)) {
186
+ throw new Error(`Invalid policy rule at ${location}: unknown field "${key}"`);
187
+ }
188
+ }
189
+
190
+ const rule = /** @type {Record<string, unknown>} */ (candidate);
191
+ if (!isClause && (typeof rule.id !== 'string' || !rule.id)) {
192
+ throw new Error(`Invalid policy rule at ${location}: id is required`);
193
+ }
194
+ if (!isClause && (!Object.hasOwn(rule, 'severity') || !Object.hasOwn(SEVERITY_RANK, rule.severity))) {
195
+ throw new Error(`Invalid policy rule at ${location}: severity must be one of: info, warn, error`);
196
+ }
197
+ if (rule.when !== undefined
198
+ && (!Array.isArray(rule.when) || rule.when.length === 0 || rule.when.some((type) => !CHANGE_TYPES.has(type)))) {
199
+ const invalidIndex = Array.isArray(rule.when)
200
+ ? rule.when.findIndex((type) => !CHANGE_TYPES.has(type))
201
+ : -1;
202
+ throw new Error(`Invalid policy rule at ${location}: when${invalidIndex >= 0 ? `[${invalidIndex}]` : ''} must be one of: added, removed, changed`);
203
+ }
204
+ validateMatch(rule.match, location);
205
+ validateArrayPredicate(rule.beforeIn, 'beforeIn', location);
206
+ validateArrayPredicate(rule.afterIn, 'afterIn', location);
207
+ validateTruthyPredicate(rule.beforeTruthy, 'beforeTruthy', location);
208
+ validateTruthyPredicate(rule.afterTruthy, 'afterTruthy', location);
209
+ validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
210
+ validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
211
+ validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
212
+ for (const name of ['message', 'messageTemplate']) {
213
+ if (rule[name] !== undefined && typeof rule[name] !== 'string') {
214
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
215
+ }
216
+ }
217
+
218
+ if (!isClause) {
219
+ validateComposition(rule.allOf, 'allOf', location);
220
+ validateComposition(rule.anyOf, 'anyOf', location);
221
+ }
222
+ }
223
+
224
+ /** @param {unknown} match @param {string} location */
225
+ function validateMatch(match, location) {
226
+ if (match === undefined) return;
227
+ if (!match || typeof match !== 'object' || Array.isArray(match)) {
228
+ throw new Error(`Invalid policy rule at ${location}: match must be an object`);
229
+ }
230
+ for (const key of Object.keys(match)) {
231
+ if (!MATCH_FIELDS.has(key)) {
232
+ throw new Error(`Invalid policy rule at ${location}: unknown match field "${key}"`);
233
+ }
234
+ }
235
+ const typedMatch = /** @type {Record<string, unknown>} */ (match);
236
+ for (const key of MATCH_FIELDS) {
237
+ if (typedMatch[key] !== undefined && typeof typedMatch[key] !== 'string') {
238
+ throw new Error(`Invalid policy rule at ${location}: match.${key} must be a string`);
239
+ }
240
+ }
241
+ if (typedMatch.path !== undefined) {
242
+ try {
243
+ new RegExp(typedMatch.path, typedMatch.pathFlags ?? '');
244
+ } catch {
245
+ if (typedMatch.pathFlags !== undefined) {
246
+ try {
247
+ new RegExp('(?:)', typedMatch.pathFlags);
248
+ } catch {
249
+ throw new Error(`Invalid policy rule at ${location}: match.pathFlags must be valid regular expression flags`);
250
+ }
251
+ }
252
+ throw new Error(`Invalid policy rule at ${location}: match.path is not a valid regular expression`);
253
+ }
254
+ }
255
+ }
256
+
257
+ /** @param {unknown} value @param {string} name @param {string} location */
258
+ function validateArrayPredicate(value, name, location) {
259
+ if (value !== undefined && !Array.isArray(value)) {
260
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be an array`);
261
+ }
262
+ }
263
+
264
+ /** @param {unknown} value @param {string} name @param {string} location */
265
+ function validateTruthyPredicate(value, name, location) {
266
+ if (value !== undefined && value !== true) {
267
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be true`);
268
+ }
269
+ }
270
+
271
+ /** @param {unknown} value @param {string} name @param {string} location */
272
+ function validateRegexPredicate(value, name, location) {
273
+ if (value === undefined) return;
274
+ if (typeof value !== 'string') {
275
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a string`);
276
+ }
277
+ try {
278
+ new RegExp(value);
279
+ } catch {
280
+ throw new Error(`Invalid policy rule at ${location}: ${name} is not a valid regular expression`);
281
+ }
282
+ }
283
+
284
+ /** @param {unknown} value @param {string} name @param {string} property @param {string} location @param {boolean} positive */
285
+ function validateNumericPredicate(value, name, property, location, positive) {
286
+ if (value === undefined) return;
287
+ if (!value || typeof value !== 'object' || Array.isArray(value)
288
+ || typeof value[property] !== 'number' || !Number.isFinite(value[property])
289
+ || (positive ? value[property] <= 0 : value[property] < 0)) {
290
+ throw new Error(`Invalid policy rule at ${location}: ${name}.${property} must be a ${positive ? 'positive' : 'non-negative'} finite number`);
291
+ }
292
+ }
293
+
294
+ /** @param {unknown} clauses @param {string} name @param {string} location */
295
+ function validateComposition(clauses, name, location) {
296
+ if (clauses === undefined) return;
297
+ if (!Array.isArray(clauses) || clauses.length === 0) {
298
+ throw new Error(`Invalid policy rule at ${location}: ${name} must be a non-empty array of match clauses`);
299
+ }
300
+ clauses.forEach((clause, index) => validateRule(clause, `${location}.${name}[${index}]`, true));
301
+ }
302
+
303
+ /**
304
+ * Load a pack by id from policies/ then built-ins.
305
+ * @param {string} packId
306
+ * @param {string} [cwd]
307
+ * @returns {PolicyPack}
4
308
  */
309
+ export function loadPack(packId, cwd = process.cwd()) {
310
+ const id = String(packId ?? '').trim();
311
+ if (!id) throw new Error('Policy pack id is required');
312
+ const path = resolvePackPath(cwd, id);
313
+ if (!path) {
314
+ throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
315
+ }
316
+ return readPackFile(path, id);
317
+ }
5
318
 
6
- const SECRET_KEY_RE = /(secret|token|password|api[_-]?key|private[_-]?key)/i;
7
- const DANGEROUS_TOGGLE_RE = /(debug|allow_insecure|disable_tls|skip_tls_verify)/i;
319
+ /**
320
+ * List built-in pack ids.
321
+ * @returns {string[]}
322
+ */
323
+ export function listBuiltinPackIds() {
324
+ if (!existsSync(PACKS_DIR)) return [];
325
+ return readdirSync(PACKS_DIR)
326
+ .filter((f) => f.endsWith('.json'))
327
+ .map((f) => f.replace(/\.json$/, ''));
328
+ }
8
329
 
9
330
  /**
10
- * Evaluate built-in policy checks against semantic changes.
331
+ * List every policy pack resolvable from a working directory. Local packs take
332
+ * precedence over built-ins using the same order as loadPack().
333
+ * @param {string} [cwd]
334
+ * @returns {Array<{
335
+ * id: string,
336
+ * sourcePath: string,
337
+ * source: 'builtin' | 'local',
338
+ * ruleCount: number,
339
+ * overridesBuiltin: boolean
340
+ * }>}
341
+ */
342
+ export function listPolicyPacks(cwd = process.cwd()) {
343
+ const localDir = resolve(cwd, 'policies');
344
+ const localIds = existsSync(localDir)
345
+ ? readdirSync(localDir)
346
+ .filter((file) => /\.(json|yaml|yml)$/.test(file))
347
+ .map((file) => file.replace(/\.(json|yaml|yml)$/, ''))
348
+ : [];
349
+ const builtinIds = listBuiltinPackIds();
350
+ const builtinIdSet = new Set(builtinIds);
351
+
352
+ return [...new Set([...builtinIds, ...localIds])]
353
+ .sort()
354
+ .map((id) => {
355
+ const sourcePath = resolvePackPath(cwd, id);
356
+ if (!sourcePath) {
357
+ throw new Error(`Unable to resolve policy pack "${id}"`);
358
+ }
359
+ const pack = readPackFile(sourcePath, id);
360
+ const isLocal = localIds.includes(id);
361
+ return {
362
+ id,
363
+ sourcePath,
364
+ source: isLocal ? 'local' : 'builtin',
365
+ ruleCount: pack.rules.length,
366
+ overridesBuiltin: isLocal && builtinIdSet.has(id),
367
+ };
368
+ });
369
+ }
370
+
371
+ /**
372
+ * @param {PolicyRule} rule
373
+ * @param {import('./differ.js').ChangeEvent} change
374
+ * @returns {boolean}
375
+ */
376
+ function ruleMatches(rule, change) {
377
+ const when = rule.when ?? ['added', 'removed', 'changed'];
378
+ if (!when.includes(change.type)) return false;
379
+ if (!matchClause(rule, change)) return false;
380
+ if (rule.allOf?.some((clause) => !matchClause(clause, change))) return false;
381
+ if (rule.anyOf && !rule.anyOf.some((clause) => matchClause(clause, change))) return false;
382
+ return true;
383
+ }
384
+
385
+ /**
386
+ * @param {PolicyMatchClause} clause
387
+ * @param {import('./differ.js').ChangeEvent} change
388
+ * @returns {boolean}
389
+ */
390
+ function matchClause(clause, change) {
391
+ const path = change.path ?? '';
392
+ const match = clause.match;
393
+ if (match?.path && !new RegExp(match.path, match.pathFlags ?? '').test(path)) return false;
394
+ if (match?.pathEquals !== undefined && path !== match.pathEquals) return false;
395
+ if (match?.pathPrefix !== undefined && !path.startsWith(match.pathPrefix)) return false;
396
+
397
+ if (Object.prototype.hasOwnProperty.call(clause, 'beforeEquals') && change.before !== clause.beforeEquals) return false;
398
+ if (Object.prototype.hasOwnProperty.call(clause, 'afterEquals') && change.after !== clause.afterEquals) return false;
399
+ if (clause.beforeIn && !clause.beforeIn.includes(change.before)) return false;
400
+ if (clause.afterIn && !clause.afterIn.includes(change.after)) return false;
401
+ if (clause.beforeTruthy && !isTruthyToggle(change.before)) return false;
402
+ if (clause.afterTruthy && !isTruthyToggle(change.after)) return false;
403
+ if (clause.afterMatches && (typeof change.after !== 'string' || !new RegExp(clause.afterMatches).test(change.after))) return false;
404
+
405
+ if (clause.numericJump) {
406
+ const before = change.before;
407
+ const after = change.after;
408
+ if (typeof before !== 'number' || typeof after !== 'number') return false;
409
+ if (!(before > 0 && after >= before * clause.numericJump.minMultiple)) return false;
410
+ }
411
+
412
+ if (clause.numericDelta) {
413
+ const before = change.before;
414
+ const after = change.after;
415
+ if (typeof before !== 'number' || typeof after !== 'number') return false;
416
+ if (Math.abs(after - before) < clause.numericDelta.min) return false;
417
+ }
418
+
419
+ return true;
420
+ }
421
+
422
+ /**
423
+ * @param {PolicyRule} rule
424
+ * @param {import('./differ.js').ChangeEvent} change
425
+ * @returns {string}
426
+ */
427
+ function formatMessage(rule, change) {
428
+ if (rule.messageTemplate) {
429
+ return rule.messageTemplate
430
+ .replaceAll('{before}', String(change.before))
431
+ .replaceAll('{after}', String(change.after))
432
+ .replaceAll('{path}', String(change.path ?? ''));
433
+ }
434
+ return rule.message ?? `Policy ${rule.id} matched`;
435
+ }
436
+
437
+ /**
438
+ * @param {PolicyPack} pack
11
439
  * @param {import('./differ.js').ChangeEvent[]} changes
440
+ * @param {Record<string, PolicySeverity | 'off'>} [severityRemap]
12
441
  * @returns {PolicyFinding[]}
13
442
  */
14
- export function evaluatePolicies(changes) {
443
+ export function evaluatePack(pack, changes, severityRemap = {}) {
15
444
  /** @type {PolicyFinding[]} */
16
445
  const findings = [];
17
-
18
446
  for (const change of changes) {
19
- const path = change.path ?? '';
20
- const pathLower = path.toLowerCase();
21
-
22
- if (SECRET_KEY_RE.test(pathLower) && (change.type === 'changed' || change.type === 'added')) {
447
+ for (const rule of pack.rules ?? []) {
448
+ if (!ruleMatches(rule, change)) continue;
449
+ const severity = severityRemap[String(rule.id)] ?? rule.severity ?? 'warn';
450
+ if (severity === 'off') continue;
23
451
  findings.push({
24
- id: 'secret-key-changed',
25
- severity: 'error',
26
- path,
27
- message: change.type === 'added'
28
- ? 'Sensitive-looking key added. Confirm secret storage and access controls.'
29
- : 'Sensitive-looking key changed. Confirm secret rotation and access controls.',
452
+ id: String(rule.id),
453
+ severity,
454
+ path: change.path ?? '',
455
+ message: formatMessage(rule, change),
456
+ pack: pack.id,
30
457
  });
31
458
  }
459
+ }
460
+ return findings;
461
+ }
32
462
 
33
- if (DANGEROUS_TOGGLE_RE.test(pathLower) && change.type === 'changed' && change.after === true) {
34
- findings.push({
35
- id: 'dangerous-toggle-enabled',
36
- severity: 'error',
37
- path,
38
- message: 'Potentially dangerous toggle enabled.',
39
- });
463
+ /**
464
+ * Merge findings: same id+path keeps highest severity; ties keep first.
465
+ * @param {PolicyFinding[]} findings
466
+ * @returns {PolicyFinding[]}
467
+ */
468
+ export function mergeFindings(findings) {
469
+ /** @type {Map<string, PolicyFinding>} */
470
+ const byKey = new Map();
471
+ for (const finding of findings) {
472
+ const key = `${finding.id}::${finding.path}`;
473
+ const existing = byKey.get(key);
474
+ if (!existing) {
475
+ byKey.set(key, finding);
476
+ continue;
40
477
  }
478
+ const nextRank = SEVERITY_RANK[finding.severity] ?? 0;
479
+ const prevRank = SEVERITY_RANK[existing.severity] ?? 0;
480
+ if (nextRank > prevRank) byKey.set(key, finding);
481
+ }
482
+ return [...byKey.values()];
483
+ }
41
484
 
42
- if (pathLower.endsWith('pool_size') && typeof change.before === 'number' && typeof change.after === 'number') {
43
- if (change.before > 0 && change.after >= change.before * 2) {
44
- findings.push({
45
- id: 'pool-size-jump',
46
- severity: 'warn',
47
- path,
48
- message: `Pool size increased from ${change.before} to ${change.after} (>=2x).`,
49
- });
50
- }
485
+ /**
486
+ * @param {string} pluginPath
487
+ * @param {string} cwd
488
+ * @returns {string}
489
+ */
490
+ function resolvePluginPath(pluginPath, cwd) {
491
+ if (/^https?:\/\//i.test(pluginPath)) {
492
+ throw new Error(`Remote plugins are not allowed: ${pluginPath}`);
493
+ }
494
+ return isAbsolute(pluginPath) ? pluginPath : resolve(cwd, pluginPath);
495
+ }
496
+
497
+ /**
498
+ * @param {string} pluginPath
499
+ * @param {import('./differ.js').ChangeEvent[]} changes
500
+ * @param {Required<Pick<PolicyEvalOptions, 'cwd' | 'file' | 'profile' | 'source'>> & { packIds: string[] }} ctx
501
+ * @returns {Promise<PolicyFinding[]>}
502
+ */
503
+ async function runPlugin(pluginPath, changes, ctx) {
504
+ const abs = resolvePluginPath(pluginPath, ctx.cwd);
505
+ if (!existsSync(abs)) {
506
+ throw new Error(`Policy plugin not found: ${pluginPath}`);
507
+ }
508
+ const mod = await import(pathToFileURL(abs).href);
509
+ if (typeof mod.evaluate !== 'function') {
510
+ throw new Error(`Policy plugin missing export evaluate(): ${pluginPath}`);
511
+ }
512
+ const result = await mod.evaluate(changes, ctx);
513
+ if (!Array.isArray(result)) {
514
+ throw new Error(`Policy plugin must return PolicyFinding[]: ${pluginPath}`);
515
+ }
516
+ return result.map((f) => ({
517
+ id: String(f.id),
518
+ severity: f.severity,
519
+ path: String(f.path ?? ''),
520
+ message: String(f.message ?? ''),
521
+ pack: f.pack ?? `plugin:${pluginPath}`,
522
+ }));
523
+ }
524
+
525
+ /**
526
+ * Evaluate active packs then plugins.
527
+ * @param {import('./differ.js').ChangeEvent[]} changes
528
+ * @param {PolicyEvalOptions} [options]
529
+ * @returns {Promise<PolicyFinding[]>}
530
+ */
531
+ export async function evaluatePolicies(changes, options = {}) {
532
+ const cwd = options.cwd ?? process.cwd();
533
+ const packIds = options.policies?.length ? options.policies : ['default'];
534
+ const plugins = options.plugins ?? [];
535
+ const severityRemap = options.severityRemap ?? {};
536
+
537
+ /** @type {PolicyFinding[]} */
538
+ const findings = [];
539
+ const packs = packIds.map((packId) => loadPack(packId, cwd));
540
+ const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
541
+ for (const ruleId of Object.keys(severityRemap)) {
542
+ if (!knownRuleIds.has(ruleId)) {
543
+ console.warn(`Unknown policy rule id in severityRemap: "${ruleId}"`);
51
544
  }
52
545
  }
546
+ for (const pack of packs) {
547
+ findings.push(...evaluatePack(pack, changes, severityRemap));
548
+ }
53
549
 
54
- return findings;
550
+ const ctx = {
551
+ cwd,
552
+ file: options.file ?? '',
553
+ profile: options.profile ?? null,
554
+ source: options.source ?? 'watch',
555
+ packIds,
556
+ };
557
+
558
+ for (const pluginPath of plugins) {
559
+ findings.push(...await runPlugin(pluginPath, changes, ctx));
560
+ }
561
+
562
+ return mergeFindings(findings);
55
563
  }
56
564
 
57
565
  /**
@@ -64,4 +572,3 @@ export function highestSeverity(findings) {
64
572
  if (findings.some((f) => f.severity === 'warn')) return 'warn';
65
573
  return 'info';
66
574
  }
67
-