flecto 1.0.2 → 2.0.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,267 @@
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 },
21
+ * afterEquals?: unknown,
22
+ * numericJump?: { minMultiple: number },
23
+ * message?: string,
24
+ * messageTemplate?: string
25
+ * }} PolicyRule
26
+ *
27
+ * @typedef {{ id: string, rules: PolicyRule[] }} PolicyPack
28
+ *
29
+ * @typedef {{
30
+ * cwd?: string,
31
+ * file?: string,
32
+ * profile?: string | null,
33
+ * source?: 'watch' | 'ci' | 'diff',
34
+ * policies?: string[],
35
+ * plugins?: string[]
36
+ * }} PolicyEvalOptions
37
+ */
38
+
39
+ const SEVERITY_RANK = { info: 1, warn: 2, error: 3 };
40
+ const PACKS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'packs');
41
+
42
+ /**
43
+ * @param {string} cwd
44
+ * @param {string} packId
45
+ * @returns {string | null}
4
46
  */
47
+ function resolvePackPath(cwd, packId) {
48
+ const localJson = resolve(cwd, 'policies', `${packId}.json`);
49
+ const localYaml = resolve(cwd, 'policies', `${packId}.yaml`);
50
+ const localYml = resolve(cwd, 'policies', `${packId}.yml`);
51
+ if (existsSync(localJson)) return localJson;
52
+ if (existsSync(localYaml)) return localYaml;
53
+ if (existsSync(localYml)) return localYml;
5
54
 
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;
55
+ const builtinJson = join(PACKS_DIR, `${packId}.json`);
56
+ if (existsSync(builtinJson)) return builtinJson;
57
+ return null;
58
+ }
8
59
 
9
60
  /**
10
- * Evaluate built-in policy checks against semantic changes.
61
+ * @param {string} path
62
+ * @returns {PolicyPack}
63
+ */
64
+ function readPackFile(path) {
65
+ const raw = readFileSync(path, 'utf8');
66
+ const parsed = path.endsWith('.json') ? JSON.parse(raw) : yaml.load(raw);
67
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.rules)) {
68
+ throw new Error(`Invalid policy pack at ${path}: expected { id, rules[] }`);
69
+ }
70
+ return {
71
+ id: String(parsed.id ?? ''),
72
+ rules: parsed.rules,
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Load a pack by id from policies/ then built-ins.
78
+ * @param {string} packId
79
+ * @param {string} [cwd]
80
+ * @returns {PolicyPack}
81
+ */
82
+ export function loadPack(packId, cwd = process.cwd()) {
83
+ const id = String(packId ?? '').trim();
84
+ if (!id) throw new Error('Policy pack id is required');
85
+ const path = resolvePackPath(cwd, id);
86
+ if (!path) {
87
+ throw new Error(`Unknown policy pack "${id}". Add policies/${id}.json or use a built-in pack.`);
88
+ }
89
+ const pack = readPackFile(path);
90
+ if (!pack.id) pack.id = id;
91
+ return pack;
92
+ }
93
+
94
+ /**
95
+ * List built-in pack ids.
96
+ * @returns {string[]}
97
+ */
98
+ export function listBuiltinPackIds() {
99
+ if (!existsSync(PACKS_DIR)) return [];
100
+ return readdirSync(PACKS_DIR)
101
+ .filter((f) => f.endsWith('.json'))
102
+ .map((f) => f.replace(/\.json$/, ''));
103
+ }
104
+
105
+ /**
106
+ * @param {PolicyRule} rule
107
+ * @param {import('./differ.js').ChangeEvent} change
108
+ * @returns {boolean}
109
+ */
110
+ function ruleMatches(rule, change) {
111
+ const when = rule.when ?? ['added', 'removed', 'changed'];
112
+ if (!when.includes(change.type)) return false;
113
+
114
+ if (rule.match?.path) {
115
+ const flags = rule.match.pathFlags ?? '';
116
+ const re = new RegExp(rule.match.path, flags);
117
+ if (!re.test(change.path ?? '')) return false;
118
+ }
119
+
120
+ if (Object.prototype.hasOwnProperty.call(rule, 'afterEquals')) {
121
+ if (change.after !== rule.afterEquals) return false;
122
+ }
123
+
124
+ if (rule.numericJump) {
125
+ const before = change.before;
126
+ const after = change.after;
127
+ if (typeof before !== 'number' || typeof after !== 'number') return false;
128
+ if (!(before > 0 && after >= before * rule.numericJump.minMultiple)) return false;
129
+ }
130
+
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * @param {PolicyRule} rule
136
+ * @param {import('./differ.js').ChangeEvent} change
137
+ * @returns {string}
138
+ */
139
+ function formatMessage(rule, change) {
140
+ if (rule.messageTemplate) {
141
+ return rule.messageTemplate
142
+ .replaceAll('{before}', String(change.before))
143
+ .replaceAll('{after}', String(change.after))
144
+ .replaceAll('{path}', String(change.path ?? ''));
145
+ }
146
+ return rule.message ?? `Policy ${rule.id} matched`;
147
+ }
148
+
149
+ /**
150
+ * @param {PolicyPack} pack
11
151
  * @param {import('./differ.js').ChangeEvent[]} changes
12
152
  * @returns {PolicyFinding[]}
13
153
  */
14
- export function evaluatePolicies(changes) {
154
+ export function evaluatePack(pack, changes) {
15
155
  /** @type {PolicyFinding[]} */
16
156
  const findings = [];
17
-
18
157
  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')) {
158
+ for (const rule of pack.rules ?? []) {
159
+ if (!ruleMatches(rule, change)) continue;
23
160
  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.',
161
+ id: String(rule.id),
162
+ severity: rule.severity ?? 'warn',
163
+ path: change.path ?? '',
164
+ message: formatMessage(rule, change),
165
+ pack: pack.id,
30
166
  });
31
167
  }
168
+ }
169
+ return findings;
170
+ }
32
171
 
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
- });
172
+ /**
173
+ * Merge findings: same id+path keeps highest severity; ties keep first.
174
+ * @param {PolicyFinding[]} findings
175
+ * @returns {PolicyFinding[]}
176
+ */
177
+ export function mergeFindings(findings) {
178
+ /** @type {Map<string, PolicyFinding>} */
179
+ const byKey = new Map();
180
+ for (const finding of findings) {
181
+ const key = `${finding.id}::${finding.path}`;
182
+ const existing = byKey.get(key);
183
+ if (!existing) {
184
+ byKey.set(key, finding);
185
+ continue;
40
186
  }
187
+ const nextRank = SEVERITY_RANK[finding.severity] ?? 0;
188
+ const prevRank = SEVERITY_RANK[existing.severity] ?? 0;
189
+ if (nextRank > prevRank) byKey.set(key, finding);
190
+ }
191
+ return [...byKey.values()];
192
+ }
41
193
 
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
- }
51
- }
194
+ /**
195
+ * @param {string} pluginPath
196
+ * @param {string} cwd
197
+ * @returns {string}
198
+ */
199
+ function resolvePluginPath(pluginPath, cwd) {
200
+ if (/^https?:\/\//i.test(pluginPath)) {
201
+ throw new Error(`Remote plugins are not allowed: ${pluginPath}`);
52
202
  }
203
+ return isAbsolute(pluginPath) ? pluginPath : resolve(cwd, pluginPath);
204
+ }
53
205
 
54
- return findings;
206
+ /**
207
+ * @param {string} pluginPath
208
+ * @param {import('./differ.js').ChangeEvent[]} changes
209
+ * @param {Required<Pick<PolicyEvalOptions, 'cwd' | 'file' | 'profile' | 'source'>> & { packIds: string[] }} ctx
210
+ * @returns {Promise<PolicyFinding[]>}
211
+ */
212
+ async function runPlugin(pluginPath, changes, ctx) {
213
+ const abs = resolvePluginPath(pluginPath, ctx.cwd);
214
+ if (!existsSync(abs)) {
215
+ throw new Error(`Policy plugin not found: ${pluginPath}`);
216
+ }
217
+ const mod = await import(pathToFileURL(abs).href);
218
+ if (typeof mod.evaluate !== 'function') {
219
+ throw new Error(`Policy plugin missing export evaluate(): ${pluginPath}`);
220
+ }
221
+ const result = await mod.evaluate(changes, ctx);
222
+ if (!Array.isArray(result)) {
223
+ throw new Error(`Policy plugin must return PolicyFinding[]: ${pluginPath}`);
224
+ }
225
+ return result.map((f) => ({
226
+ id: String(f.id),
227
+ severity: f.severity,
228
+ path: String(f.path ?? ''),
229
+ message: String(f.message ?? ''),
230
+ pack: f.pack ?? `plugin:${pluginPath}`,
231
+ }));
232
+ }
233
+
234
+ /**
235
+ * Evaluate active packs then plugins.
236
+ * @param {import('./differ.js').ChangeEvent[]} changes
237
+ * @param {PolicyEvalOptions} [options]
238
+ * @returns {Promise<PolicyFinding[]>}
239
+ */
240
+ export async function evaluatePolicies(changes, options = {}) {
241
+ const cwd = options.cwd ?? process.cwd();
242
+ const packIds = options.policies?.length ? options.policies : ['default'];
243
+ const plugins = options.plugins ?? [];
244
+
245
+ /** @type {PolicyFinding[]} */
246
+ const findings = [];
247
+ for (const packId of packIds) {
248
+ const pack = loadPack(packId, cwd);
249
+ findings.push(...evaluatePack(pack, changes));
250
+ }
251
+
252
+ const ctx = {
253
+ cwd,
254
+ file: options.file ?? '',
255
+ profile: options.profile ?? null,
256
+ source: options.source ?? 'watch',
257
+ packIds,
258
+ };
259
+
260
+ for (const pluginPath of plugins) {
261
+ findings.push(...await runPlugin(pluginPath, changes, ctx));
262
+ }
263
+
264
+ return mergeFindings(findings);
55
265
  }
56
266
 
57
267
  /**
@@ -64,4 +274,3 @@ export function highestSeverity(findings) {
64
274
  if (findings.some((f) => f.severity === 'warn')) return 'warn';
65
275
  return 'info';
66
276
  }
67
-
package/src/renderer.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import chalk from 'chalk';
2
2
 
3
+ const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
4
+
3
5
  /**
4
6
  * Format a scalar value for display. Strings get quoted; others are JSON-stringified.
5
7
  * @param {unknown} v
8
+ * @param {{ maskSecrets?: boolean, path?: string }} [opts]
6
9
  * @returns {string}
7
10
  */
8
- function fmt(v) {
11
+ function fmt(v, opts = {}) {
9
12
  if (v === undefined) return '';
13
+ if (opts.maskSecrets && opts.path && SECRET_PATH_RE.test(opts.path)) {
14
+ return chalk.dim('"***"');
15
+ }
10
16
  if (typeof v === 'string') return JSON.stringify(v);
11
17
  if (typeof v === 'object' && v !== null) return JSON.stringify(v);
12
18
  return String(v);
@@ -24,35 +30,36 @@ function timestamp() {
24
30
  * Render a single change event as a colored string.
25
31
  * @param {import('./differ.js').ChangeEvent} event
26
32
  * @param {'compact' | 'verbose'} mode
33
+ * @param {{ maskSecrets?: boolean }} [opts]
27
34
  * @returns {string}
28
35
  */
29
- function renderEvent(event, mode) {
36
+ function renderEvent(event, mode, opts = {}) {
30
37
  const { type, path, before, after, note } = event;
38
+ const maskOpts = { maskSecrets: Boolean(opts.maskSecrets), path };
31
39
 
32
40
  if (type === 'added') {
33
- const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after))}`;
41
+ const line = ` ${chalk.green('+')} ${chalk.green(path)}: ${chalk.green(fmt(after, maskOpts))}`;
34
42
  return mode === 'verbose'
35
43
  ? `${line}\n ${chalk.dim('(key added)')}`
36
44
  : line;
37
45
  }
38
46
 
39
47
  if (type === 'removed') {
40
- const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before))}`;
48
+ const line = ` ${chalk.red('-')} ${chalk.red(path)}: ${chalk.red(fmt(before, maskOpts))}`;
41
49
  return mode === 'verbose'
42
50
  ? `${line}\n ${chalk.dim('(key removed)')}`
43
51
  : line;
44
52
  }
45
53
 
46
- // changed
47
54
  const noteStr = note ? chalk.dim(` [${note}]`) : '';
48
55
  if (mode === 'verbose') {
49
56
  return [
50
57
  ` ${chalk.yellow('~')} ${chalk.yellow(path)}${noteStr}`,
51
- ` ${chalk.dim('before:')} ${chalk.red(fmt(before))}`,
52
- ` ${chalk.dim('after: ')} ${chalk.green(fmt(after))}`,
58
+ ` ${chalk.dim('before:')} ${chalk.red(fmt(before, maskOpts))}`,
59
+ ` ${chalk.dim('after: ')} ${chalk.green(fmt(after, maskOpts))}`,
53
60
  ].join('\n');
54
61
  }
55
- return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before))} ${chalk.dim('→')} ${chalk.green(fmt(after))}${noteStr}`;
62
+ return ` ${chalk.yellow('~')} ${chalk.yellow(path)}: ${chalk.red(fmt(before, maskOpts))} ${chalk.dim('→')} ${chalk.green(fmt(after, maskOpts))}${noteStr}`;
56
63
  }
57
64
 
58
65
  /**
@@ -60,15 +67,16 @@ function renderEvent(event, mode) {
60
67
  * @param {string} filepath
61
68
  * @param {import('./differ.js').ChangeEvent[]} events
62
69
  * @param {'compact' | 'verbose'} mode
70
+ * @param {{ maskSecrets?: boolean }} [opts]
63
71
  */
64
- export function renderChanges(filepath, events, mode = 'compact') {
72
+ export function renderChanges(filepath, events, mode = 'compact', opts = {}) {
65
73
  const ts = chalk.dim(`[${timestamp()}]`);
66
74
  const file = chalk.cyan(filepath);
67
75
  const count = `${events.length} change${events.length !== 1 ? 's' : ''}`;
68
76
 
69
77
  console.log(`${ts} ${file} — ${count}`);
70
78
  for (const event of events) {
71
- console.log(renderEvent(event, mode));
79
+ console.log(renderEvent(event, mode, opts));
72
80
  }
73
81
 
74
82
  if (mode === 'verbose') {
@@ -80,8 +88,9 @@ export function renderChanges(filepath, events, mode = 'compact') {
80
88
  * Print a diff result (for --diff mode) to stdout.
81
89
  * @param {string} filepath
82
90
  * @param {import('./differ.js').ChangeEvent[]} events
91
+ * @param {{ maskSecrets?: boolean }} [opts]
83
92
  */
84
- export function renderDiff(filepath, events) {
93
+ export function renderDiff(filepath, events, opts = {}) {
85
94
  if (events.length === 0) {
86
95
  console.log(chalk.green(`✓ ${filepath} matches snapshot — no changes`));
87
96
  return;
@@ -89,7 +98,7 @@ export function renderDiff(filepath, events) {
89
98
 
90
99
  console.log(chalk.cyan(`${filepath}`) + ` — ${events.length} change${events.length !== 1 ? 's' : ''} from snapshot:`);
91
100
  for (const event of events) {
92
- console.log(renderEvent(event, 'compact'));
101
+ console.log(renderEvent(event, 'compact', opts));
93
102
  }
94
103
  }
95
104
 
@@ -129,6 +138,43 @@ export function renderPolicyFindings(findings) {
129
138
  : f.severity === 'warn'
130
139
  ? chalk.yellow('! policy(warn)')
131
140
  : chalk.blue('! policy(info)');
132
- console.log(` ${prefix} ${chalk.cyan(f.path)}: ${f.message}`);
141
+ const pack = f.pack ? chalk.dim(` [${f.pack}]`) : '';
142
+ console.log(` ${prefix}${pack} ${chalk.cyan(f.path)}: ${f.message}`);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Mask secret-like values in a plain object tree for CI output.
148
+ * @param {unknown} value
149
+ * @param {string} [path]
150
+ * @returns {unknown}
151
+ */
152
+ export function maskSensitiveValue(value, path = '') {
153
+ if (SECRET_PATH_RE.test(path)) return '***';
154
+ if (Array.isArray(value)) {
155
+ return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
133
156
  }
157
+ if (value && typeof value === 'object') {
158
+ /** @type {Record<string, unknown>} */
159
+ const out = {};
160
+ for (const [k, v] of Object.entries(value)) {
161
+ const child = path ? `${path}.${k}` : k;
162
+ out[k] = maskSensitiveValue(v, child);
163
+ }
164
+ return out;
165
+ }
166
+ return value;
167
+ }
168
+
169
+ /**
170
+ * @param {import('./differ.js').ChangeEvent} event
171
+ * @returns {import('./differ.js').ChangeEvent}
172
+ */
173
+ export function maskChangeEvent(event) {
174
+ if (!SECRET_PATH_RE.test(event.path ?? '')) return event;
175
+ return {
176
+ ...event,
177
+ before: event.before === undefined ? undefined : '***',
178
+ after: event.after === undefined ? undefined : '***',
179
+ };
134
180
  }
package/src/watcher.js CHANGED
@@ -11,6 +11,8 @@ import { renderWarn, renderInfo } from './renderer.js';
11
11
  * @property {boolean} [polling] Force polling mode (default: false)
12
12
  * @property {string} [mode] Output mode: 'compact' | 'verbose'
13
13
  * @property {string[]} [ignorePaths] Key paths to suppress in diffs
14
+ * @property {string | null} [arrayIdKey]
15
+ * @property {boolean} [arrayIgnoreOrder]
14
16
  */
15
17
 
16
18
  /**
@@ -25,6 +27,11 @@ export function startWatcher(filepath, options = {}, onEvent) {
25
27
  const interval = options.interval ?? 100;
26
28
  const ignorePaths = options.ignorePaths ?? [];
27
29
  const polling = options.polling ?? false;
30
+ const diffOpts = {
31
+ ignorePaths,
32
+ arrayIdKey: options.arrayIdKey ?? null,
33
+ arrayIgnoreOrder: Boolean(options.arrayIgnoreOrder),
34
+ };
28
35
 
29
36
  /** @type {unknown | null} */
30
37
  let lastGoodState = null;
@@ -60,7 +67,7 @@ export function startWatcher(filepath, options = {}, onEvent) {
60
67
  const scheduleRead = (reason) => {
61
68
  if (debounceTimer) clearTimeout(debounceTimer);
62
69
  debounceTimer = setTimeout(() => {
63
- handleChange(filepath, ignorePaths, lastGoodState, (newState, events, lifecycle) => {
70
+ handleChange(filepath, diffOpts, lastGoodState, (newState, events, lifecycle) => {
64
71
  if (newState !== null) {
65
72
  lastGoodState = newState;
66
73
  }
@@ -111,11 +118,11 @@ export function startWatcher(filepath, options = {}, onEvent) {
111
118
  /**
112
119
  * Internal: re-parse the file and diff against the previous state.
113
120
  * @param {string} filepath
114
- * @param {string[]} ignorePaths
121
+ * @param {{ ignorePaths?: string[], arrayIdKey?: string | null, arrayIgnoreOrder?: boolean }} diffOpts
115
122
  * @param {unknown | null} lastGoodState
116
123
  * @param {(newState: unknown | null, events: ChangeEvent[], lifecycle: { type: string, message: string } | null) => void} callback
117
124
  */
118
- function handleChange(filepath, ignorePaths, lastGoodState, callback) {
125
+ function handleChange(filepath, diffOpts, lastGoodState, callback) {
119
126
  let newState;
120
127
  try {
121
128
  newState = parseFile(filepath);
@@ -132,6 +139,6 @@ function handleChange(filepath, ignorePaths, lastGoodState, callback) {
132
139
  return;
133
140
  }
134
141
 
135
- const events = diffTrees(lastGoodState, newState, { ignorePaths });
142
+ const events = diffTrees(lastGoodState, newState, diffOpts);
136
143
  callback(newState, events, null);
137
144
  }