helm-env-delta 1.8.1 → 1.9.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/README.md CHANGED
@@ -38,6 +38,8 @@ HelmEnvDelta (`hed`) automates environment synchronization for GitOps workflows
38
38
 
39
39
  🎯 **Path Filtering** - Preserve environment-specific values (namespaces, replicas, secrets) that should never sync.
40
40
 
41
+ 📌 **Fixed Values** - Set specific fields to constant values regardless of source/destination. Enforce production settings like `debug: false` or `replicas: 3` after every sync.
42
+
41
43
  🔄 **Powerful Transforms** - Regex find/replace for both file content and paths. Load transforms from external YAML files for reusability. Change `uat-db.internal` → `prod-db.internal` automatically.
42
44
 
43
45
  🛡️ **Safety Rules** - Block major version upgrades, scaling violations, and forbidden patterns. Load validation rules from external files. Scan globally or target specific fields.
@@ -252,6 +254,21 @@ helm-env-delta --config example/5-external-files/config.yaml --dry-run --diff
252
254
  - Pattern files (`regexFile`, `regexFileKey`)
253
255
  - Global vs targeted regex validation
254
256
 
257
+ ### 📌 Example 6: Fixed Values
258
+
259
+ Set specific fields to constant values regardless of source/destination. Perfect for enforcing production settings.
260
+
261
+ ```bash
262
+ helm-env-delta --config example/6-fixed-values/config.yaml --dry-run --diff
263
+ ```
264
+
265
+ **Features shown:**
266
+
267
+ - Simple path fixed values (`debug: false`, `logLevel: warn`)
268
+ - Nested paths (`spec.replicas`)
269
+ - Array filter operators (`env[name=LOG_LEVEL].value`)
270
+ - Combining with skipPath and transforms
271
+
255
272
  ---
256
273
 
257
274
  ## 💡 Smart Configuration Suggestions (Heuristic)
@@ -421,6 +438,50 @@ skipPath:
421
438
 
422
439
  ---
423
440
 
441
+ ### 📌 Fixed Values (fixedValues)
442
+
443
+ Set specific JSONPath locations to constant values, regardless of source/destination values. Applied after merge, before formatting.
444
+
445
+ ```yaml
446
+ fixedValues:
447
+ # Apply to all YAML files
448
+ '**/*.yaml':
449
+ - path: 'debug'
450
+ value: false
451
+ - path: 'logLevel'
452
+ value: 'warn'
453
+
454
+ # Specific file patterns
455
+ 'deployment.yaml':
456
+ - path: 'spec.replicas'
457
+ value: 3
458
+ - path: 'spec.template.spec.containers[name=app].resources.limits.memory'
459
+ value: '512Mi'
460
+
461
+ # Array filter operators supported
462
+ 'configmap.yaml':
463
+ - path: 'data.env[name=LOG_LEVEL].value'
464
+ value: 'info'
465
+ - path: 'data.env[name^=FEATURE_].value' # startsWith
466
+ value: 'stable'
467
+ ```
468
+
469
+ **Supported filter operators:** `=` (equals), `^=` (startsWith), `$=` (endsWith), `*=` (contains) - updates ALL matching items
470
+
471
+ **Value types:** String, number, boolean, null, object, array
472
+
473
+ **Behavior:**
474
+
475
+ - **Filter operators update ALL matching items** (e.g., `env[name^=LOG_]` updates every item starting with `LOG_`)
476
+ - Applied during diff computation, so changes are visible in all reports (HTML, console, JSON)
477
+ - Non-existent paths are silently skipped
478
+ - Multiple rules for same path: last one wins
479
+ - Works with skipPath (fixedValues applied after skipPath restoration)
480
+
481
+ **Use cases:** Enforce production settings (`debug: false`), standardize resource limits, set mandatory environment variables, ensure consistent configuration across syncs.
482
+
483
+ ---
484
+
424
485
  ### 🔄 Transformations
425
486
 
426
487
  Regex find/replace for content and file paths. Load transforms from external files or define inline.
@@ -780,7 +841,7 @@ git push origin main
780
841
 
781
842
  ✅ **Flexibility** - Per-file patterns. Config inheritance. Regex transforms.
782
843
 
783
- ✅ **Reliability** - 990+ tests, 84% coverage. Battle-tested.
844
+ ✅ **Reliability** - 1150+ tests, 84% coverage. Battle-tested.
784
845
 
785
846
  ---
786
847
 
@@ -77,6 +77,10 @@ declare const arraySortRuleSchema: z.ZodObject<{
77
77
  desc: "desc";
78
78
  }>>;
79
79
  }, z.core.$strip>;
80
+ declare const fixedValueRuleSchema: z.ZodObject<{
81
+ path: z.ZodString;
82
+ value: z.ZodUnknown;
83
+ }, z.core.$strip>;
80
84
  declare const transformRuleSchema: z.ZodObject<{
81
85
  find: z.ZodString;
82
86
  replace: z.ZodString;
@@ -159,6 +163,10 @@ declare const baseConfigSchema: z.ZodObject<{
159
163
  forbidden: "forbidden";
160
164
  }>>;
161
165
  }, z.core.$strict>], "type">>>>;
166
+ fixedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
167
+ path: z.ZodString;
168
+ value: z.ZodUnknown;
169
+ }, z.core.$strip>>>>;
162
170
  }, z.core.$strip>;
163
171
  declare const finalConfigSchema: z.ZodObject<{
164
172
  transforms: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
@@ -208,6 +216,10 @@ declare const finalConfigSchema: z.ZodObject<{
208
216
  forbidden: "forbidden";
209
217
  }>>;
210
218
  }, z.core.$strict>], "type">>>>;
219
+ fixedValues: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodObject<{
220
+ path: z.ZodString;
221
+ value: z.ZodUnknown;
222
+ }, z.core.$strip>>>>;
211
223
  include: z.ZodDefault<z.ZodArray<z.ZodString>>;
212
224
  exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
213
225
  prune: z.ZodDefault<z.ZodBoolean>;
@@ -242,6 +254,8 @@ export type TransformRule = z.infer<typeof transformRuleSchema>;
242
254
  export type TransformRules = z.infer<typeof transformRulesSchema>;
243
255
  export type TransformConfig = Record<string, TransformRules>;
244
256
  export type OutputFormat = BaseConfig['outputFormat'];
257
+ export type FixedValueRule = z.infer<typeof fixedValueRuleSchema>;
258
+ export type FixedValueConfig = Record<string, FixedValueRule[]>;
245
259
  export declare const parseBaseConfig: (data: unknown, configPath?: string) => BaseConfig;
246
260
  export declare const parseFinalConfig: (data: unknown, configPath?: string) => FinalConfig;
247
261
  export declare const parseConfig: (data: unknown, configPath?: string) => FinalConfig;
@@ -75,6 +75,10 @@ const arraySortRuleSchema = zod_1.z.object({
75
75
  sortBy: zod_1.z.string().min(1),
76
76
  order: zod_1.z.enum(['asc', 'desc']).default('asc')
77
77
  });
78
+ const fixedValueRuleSchema = zod_1.z.object({
79
+ path: zod_1.z.string().min(1).describe('JSONPath to the value to set'),
80
+ value: zod_1.z.unknown().describe('The constant value to set (any type: string, number, boolean, null, object, array)')
81
+ });
78
82
  const transformRuleSchema = zod_1.z
79
83
  .object({
80
84
  find: zod_1.z.string().min(1).describe('Regex pattern to find'),
@@ -123,7 +127,8 @@ const baseConfigSchema = zod_1.z.object({
123
127
  })
124
128
  .optional(),
125
129
  transforms: zod_1.z.record(zod_1.z.string(), transformRulesSchema).optional(),
126
- stopRules: zod_1.z.record(zod_1.z.string(), zod_1.z.array(stopRuleSchema)).optional()
130
+ stopRules: zod_1.z.record(zod_1.z.string(), zod_1.z.array(stopRuleSchema)).optional(),
131
+ fixedValues: zod_1.z.record(zod_1.z.string(), zod_1.z.array(fixedValueRuleSchema)).optional()
127
132
  });
128
133
  const finalConfigSchema = baseConfigSchema
129
134
  .omit({ extends: true })
@@ -24,6 +24,18 @@ const validateConfigWarnings = (config) => {
24
24
  for (const [pattern, rules] of Object.entries(config.transforms))
25
25
  if ((rules.content?.length ?? 0) === 0 && (rules.filename?.length ?? 0) === 0)
26
26
  warnings.push(`Transform pattern '${pattern}' has empty content and filename arrays (will have no effect)`);
27
+ if (config.fixedValues)
28
+ for (const [pattern, rules] of Object.entries(config.fixedValues))
29
+ if (rules.length === 0)
30
+ warnings.push(`fixedValues pattern '${pattern}' has empty array (will have no effect)`);
31
+ if (config.fixedValues && config.skipPath)
32
+ for (const [fixedPattern, fixedRules] of Object.entries(config.fixedValues))
33
+ for (const [skipPattern, skipPaths] of Object.entries(config.skipPath))
34
+ if (fixedPattern === skipPattern || fixedPattern === '**/*' || skipPattern === '**/*')
35
+ for (const rule of fixedRules)
36
+ for (const skipPath of skipPaths)
37
+ if (rule.path === skipPath || rule.path.startsWith(skipPath + '.'))
38
+ warnings.push(`fixedValues path '${rule.path}' overlaps with skipPath '${skipPath}' (fixedValues wins after skipPath restored)`);
27
39
  return {
28
40
  warnings,
29
41
  hasWarnings: warnings.length > 0
@@ -1,4 +1,4 @@
1
- import { Config, TransformConfig } from './configFile';
1
+ import { Config, FixedValueConfig, TransformConfig } from './configFile';
2
2
  import { FileMap } from './fileLoader';
3
3
  export interface FileDiffResult {
4
4
  addedFiles: string[];
@@ -27,6 +27,7 @@ export interface ProcessYamlOptions {
27
27
  destinationContent: string;
28
28
  skipPath?: Record<string, string[]>;
29
29
  transforms?: TransformConfig;
30
+ fixedValues?: FixedValueConfig;
30
31
  }
31
32
  declare const FileDiffErrorClass: {
32
33
  new (message: string, options?: import("./utils/errors").ErrorOptions): {
package/dist/fileDiff.js CHANGED
@@ -8,6 +8,7 @@ const yaml_1 = __importDefault(require("yaml"));
8
8
  const deepEqual_1 = require("./utils/deepEqual");
9
9
  const errors_1 = require("./utils/errors");
10
10
  const fileType_1 = require("./utils/fileType");
11
+ const fixedValues_1 = require("./utils/fixedValues");
11
12
  const jsonPath_1 = require("./utils/jsonPath");
12
13
  const patternMatcher_1 = require("./utils/patternMatcher");
13
14
  const serialization_1 = require("./utils/serialization");
@@ -105,7 +106,7 @@ const getSkipPathsForFile = (filePath, skipPath) => {
105
106
  };
106
107
  exports.getSkipPathsForFile = getSkipPathsForFile;
107
108
  const processYamlFile = (options) => {
108
- const { filePath, sourceContent, destinationContent, skipPath, transforms } = options;
109
+ const { filePath, sourceContent, destinationContent, skipPath, transforms, fixedValues } = options;
109
110
  let sourceParsed;
110
111
  let destinationParsed;
111
112
  try {
@@ -143,6 +144,9 @@ const processYamlFile = (options) => {
143
144
  throw parseError;
144
145
  }
145
146
  const sourceTransformed = (0, transformer_1.applyTransforms)(sourceParsed, filePath, transforms);
147
+ const fixedValueRules = (0, fixedValues_1.getFixedValuesForFile)(filePath, fixedValues);
148
+ if (fixedValueRules.length > 0)
149
+ (0, fixedValues_1.applyFixedValues)(sourceTransformed, fixedValueRules);
146
150
  const pathsToSkip = (0, exports.getSkipPathsForFile)(filePath, skipPath);
147
151
  const sourceFiltered = pathsToSkip.length > 0 ? applySkipPaths(sourceTransformed, pathsToSkip) : sourceTransformed;
148
152
  const destinationFiltered = pathsToSkip.length > 0 ? applySkipPaths(destinationParsed, pathsToSkip) : destinationParsed;
@@ -166,7 +170,7 @@ const processYamlFile = (options) => {
166
170
  parsedDest: destinationParsed
167
171
  };
168
172
  };
169
- const processChangedFiles = (sourceFiles, destinationFiles, skipPath, transforms, originalPaths) => {
173
+ const processChangedFiles = (sourceFiles, destinationFiles, skipPath, transforms, fixedValues, originalPaths) => {
170
174
  const changedFiles = [];
171
175
  const unchangedFiles = [];
172
176
  for (const [path, sourceContent] of sourceFiles.entries()) {
@@ -181,7 +185,8 @@ const processChangedFiles = (sourceFiles, destinationFiles, skipPath, transforms
181
185
  sourceContent,
182
186
  destinationContent,
183
187
  skipPath,
184
- transforms
188
+ transforms,
189
+ fixedValues
185
190
  });
186
191
  if (changed) {
187
192
  if (originalPath)
@@ -222,7 +227,7 @@ const computeFileDiff = (sourceFiles, destinationFiles, config, logger, original
222
227
  }
223
228
  const addedFiles = detectAddedFiles(sourceFiles, destinationFiles);
224
229
  const deletedFiles = config.prune ? detectDeletedFiles(sourceFiles, destinationFiles) : [];
225
- const { changedFiles, unchangedFiles } = processChangedFiles(sourceFiles, destinationFiles, config.skipPath, config.transforms, originalPaths);
230
+ const { changedFiles, unchangedFiles } = processChangedFiles(sourceFiles, destinationFiles, config.skipPath, config.transforms, config.fixedValues, originalPaths);
226
231
  return { addedFiles, deletedFiles, changedFiles, unchangedFiles };
227
232
  };
228
233
  exports.computeFileDiff = computeFileDiff;
@@ -8,9 +8,10 @@ const promises_1 = require("node:fs/promises");
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const yaml_1 = __importDefault(require("yaml"));
10
10
  const consoleFormatter_1 = require("./consoleFormatter");
11
+ const arrayMerger_1 = require("./utils/arrayMerger");
11
12
  const errors_1 = require("./utils/errors");
12
13
  const fileType_1 = require("./utils/fileType");
13
- const jsonPath_1 = require("./utils/jsonPath");
14
+ const fixedValues_1 = require("./utils/fixedValues");
14
15
  const transformer_1 = require("./utils/transformer");
15
16
  const yamlFormatter_1 = require("./yamlFormatter");
16
17
  const FileUpdaterErrorClass = (0, errors_1.createErrorClass)('File Updater Error', {
@@ -62,78 +63,6 @@ const ensureParentDirectory = async (filePath) => {
62
63
  });
63
64
  }
64
65
  };
65
- const getApplicableArrayFilters = (currentPath, skipPaths) => {
66
- const filters = [];
67
- for (const skipPath of skipPaths) {
68
- const segments = (0, jsonPath_1.parseJsonPath)(skipPath);
69
- if (segments.length <= currentPath.length)
70
- continue;
71
- let isPrefix = true;
72
- for (let index = 0; index < currentPath.length; index++)
73
- if (segments[index] !== currentPath[index]) {
74
- isPrefix = false;
75
- break;
76
- }
77
- if (!isPrefix)
78
- continue;
79
- const nextSegment = segments[currentPath.length];
80
- if (!nextSegment || !(0, jsonPath_1.isFilterSegment)(nextSegment))
81
- continue;
82
- const filter = (0, jsonPath_1.parseFilterSegment)(nextSegment);
83
- if (!filter)
84
- continue;
85
- const remainingPath = segments.slice(currentPath.length + 1);
86
- filters.push({ filter, remainingPath });
87
- }
88
- return filters;
89
- };
90
- const itemMatchesAnyFilter = (item, applicableFilters) => {
91
- if (!item || typeof item !== 'object')
92
- return { matches: false };
93
- const itemObject = item;
94
- for (const applicableFilter of applicableFilters) {
95
- const itemValue = itemObject[applicableFilter.filter.property];
96
- if (itemValue !== undefined && (0, jsonPath_1.matchesFilter)(itemValue, applicableFilter.filter))
97
- return { matches: true, matchedFilter: applicableFilter };
98
- }
99
- return { matches: false };
100
- };
101
- const findMatchingTargetItem = (sourceItem, fullTargetArray, applicableFilters) => {
102
- if (!sourceItem || typeof sourceItem !== 'object')
103
- return undefined;
104
- const sourceObject = sourceItem;
105
- for (const targetItem of fullTargetArray) {
106
- if (!targetItem || typeof targetItem !== 'object')
107
- continue;
108
- const targetObject = targetItem;
109
- for (const { filter } of applicableFilters)
110
- if (sourceObject[filter.property] === targetObject[filter.property])
111
- return targetItem;
112
- }
113
- return undefined;
114
- };
115
- const shouldPreserveItem = (item, applicableFilters, existingResult) => {
116
- if (!item || typeof item !== 'object')
117
- return false;
118
- const itemObject = item;
119
- const { matches } = itemMatchesAnyFilter(item, applicableFilters);
120
- if (!matches)
121
- return false;
122
- for (const existingItem of existingResult) {
123
- if (!existingItem || typeof existingItem !== 'object')
124
- continue;
125
- const existingObject = existingItem;
126
- let isDuplicate = true;
127
- for (const { filter } of applicableFilters)
128
- if (existingObject[filter.property] !== itemObject[filter.property]) {
129
- isDuplicate = false;
130
- break;
131
- }
132
- if (isDuplicate)
133
- return false;
134
- }
135
- return true;
136
- };
137
66
  const deepMerge = (fullTarget, filteredSource, filteredTarget, currentPath = [], skipPaths = []) => {
138
67
  if (filteredSource === null || filteredSource === undefined)
139
68
  return fullTarget;
@@ -144,17 +73,17 @@ const deepMerge = (fullTarget, filteredSource, filteredTarget, currentPath = [],
144
73
  if (Array.isArray(filteredSource)) {
145
74
  const fullTargetArray = Array.isArray(fullTarget) ? fullTarget : [];
146
75
  const filteredTargetArray = Array.isArray(filteredTarget) ? filteredTarget : [];
147
- const applicableFilters = getApplicableArrayFilters(currentPath, skipPaths);
76
+ const applicableFilters = (0, arrayMerger_1.getApplicableArrayFilters)(currentPath, skipPaths);
148
77
  if (applicableFilters.length === 0)
149
78
  return filteredSource;
150
79
  const hasNestedFilters = applicableFilters.some((f) => f.remainingPath.length > 0);
151
80
  const result = [];
152
81
  for (const sourceItem of filteredSource) {
153
82
  if (hasNestedFilters && sourceItem && typeof sourceItem === 'object') {
154
- const { matches, matchedFilter } = itemMatchesAnyFilter(sourceItem, applicableFilters);
83
+ const { matches, matchedFilter } = (0, arrayMerger_1.itemMatchesAnyFilter)(sourceItem, applicableFilters);
155
84
  if (matches && matchedFilter && matchedFilter.remainingPath.length > 0) {
156
- const matchingTargetItem = findMatchingTargetItem(sourceItem, fullTargetArray, applicableFilters);
157
- const matchingFilteredTargetItem = findMatchingTargetItem(sourceItem, filteredTargetArray, applicableFilters);
85
+ const matchingTargetItem = (0, arrayMerger_1.findMatchingTargetItem)(sourceItem, fullTargetArray, applicableFilters);
86
+ const matchingFilteredTargetItem = (0, arrayMerger_1.findMatchingTargetItem)(sourceItem, filteredTargetArray, applicableFilters);
158
87
  if (matchingTargetItem) {
159
88
  result.push(deepMerge(matchingTargetItem, sourceItem, matchingFilteredTargetItem, currentPath, skipPaths));
160
89
  continue;
@@ -164,7 +93,7 @@ const deepMerge = (fullTarget, filteredSource, filteredTarget, currentPath = [],
164
93
  result.push(sourceItem);
165
94
  }
166
95
  for (const item of fullTargetArray)
167
- if (shouldPreserveItem(item, applicableFilters, result))
96
+ if ((0, arrayMerger_1.shouldPreserveItem)(item, applicableFilters, result))
168
97
  result.push(item);
169
98
  return result;
170
99
  }
@@ -234,6 +163,9 @@ const addFile = async (options) => {
234
163
  try {
235
164
  const parsed = yaml_1.default.parse(content);
236
165
  const transformed = (0, transformer_1.applyTransforms)(parsed, relativePath, config.transforms);
166
+ const fixedValueRules = (0, fixedValues_1.getFixedValuesForFile)(relativePath, config.fixedValues);
167
+ if (fixedValueRules.length > 0)
168
+ (0, fixedValues_1.applyFixedValues)(transformed, fixedValueRules);
237
169
  contentToWrite = yaml_1.default.stringify(transformed);
238
170
  const effectiveOutputFormat = skipFormat ? undefined : config.outputFormat;
239
171
  contentToWrite = (0, yamlFormatter_1.formatYaml)(contentToWrite, relativePath, effectiveOutputFormat);
@@ -269,6 +201,12 @@ const updateFile = async (options) => {
269
201
  ? mergeYamlContent(changedFile.destinationContent, changedFile.rawParsedSource, changedFile.rawParsedDest, changedFile.path, changedFile.skipPaths)
270
202
  : changedFile.sourceContent;
271
203
  if ((0, fileType_1.isYamlFile)(changedFile.path)) {
204
+ const fixedValueRules = (0, fixedValues_1.getFixedValuesForFile)(changedFile.path, config.fixedValues);
205
+ if (fixedValueRules.length > 0) {
206
+ const parsed = yaml_1.default.parse(contentToWrite);
207
+ (0, fixedValues_1.applyFixedValues)(parsed, fixedValueRules);
208
+ contentToWrite = yaml_1.default.stringify(parsed);
209
+ }
272
210
  const effectiveOutputFormat = skipFormat ? undefined : config.outputFormat;
273
211
  contentToWrite = (0, yamlFormatter_1.formatYaml)(contentToWrite, changedFile.path, effectiveOutputFormat);
274
212
  }
@@ -9,10 +9,8 @@ const promises_1 = require("node:fs/promises");
9
9
  const node_os_1 = require("node:os");
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const diff2html_1 = require("diff2html");
12
- const yaml_1 = __importDefault(require("yaml"));
13
12
  const browserLauncher_1 = require("./reporters/browserLauncher");
14
13
  const htmlTemplate_1 = require("./reporters/htmlTemplate");
15
- const arrayDiffProcessor_1 = require("./utils/arrayDiffProcessor");
16
14
  const diffGenerator_1 = require("./utils/diffGenerator");
17
15
  const errors_1 = require("./utils/errors");
18
16
  const fileType_1 = require("./utils/fileType");
@@ -33,103 +31,29 @@ const generateTemporaryFilePath = () => {
33
31
  const filename = `helm-env-delta-${timestamp}-${randomName}.html`;
34
32
  return node_path_1.default.join((0, node_os_1.tmpdir)(), filename);
35
33
  };
36
- const escapeHtml = (text) => text
37
- .replaceAll('&', '&amp;')
38
- .replaceAll('<', '&lt;')
39
- .replaceAll('>', '&gt;')
40
- .replaceAll('"', '&quot;')
41
- .replaceAll("'", '&#039;');
42
34
  const DIFF2HTML_OPTIONS = {
43
35
  drawFileList: false,
44
36
  matching: 'lines',
45
37
  outputFormat: 'side-by-side'
46
38
  };
47
39
  const generateDiffHtml = (unifiedDiff) => (0, diff2html_1.html)(unifiedDiff, DIFF2HTML_OPTIONS);
48
- const generateArrayDiffHtml = (change) => {
49
- let html = '<div class="array-diff">';
50
- if (change.removed.length > 0) {
51
- html += '<div class="removed-items">';
52
- html += `<h4>Removed (${change.removed.length})</h4>`;
53
- html += '<ul>';
54
- for (const item of change.removed) {
55
- const yaml = yaml_1.default.stringify(item, { indent: 2 });
56
- html += `<li class="removed"><pre>${escapeHtml(yaml)}</pre></li>`;
57
- }
58
- html += '</ul></div>';
59
- }
60
- if (change.added.length > 0) {
61
- html += '<div class="added-items">';
62
- html += `<h4>Added (${change.added.length})</h4>`;
63
- html += '<ul>';
64
- for (const item of change.added) {
65
- const yaml = yaml_1.default.stringify(item, { indent: 2 });
66
- html += `<li class="added"><pre>${escapeHtml(yaml)}</pre></li>`;
67
- }
68
- html += '</ul></div>';
69
- }
70
- if (change.unchanged.length > 0)
71
- html += `<div class="unchanged-count">Unchanged: ${change.unchanged.length} items</div>`;
72
- html += '</div>';
73
- return html;
74
- };
75
40
  const generateFileSummary = (file) => {
76
41
  if (!file.originalPath)
77
42
  return file.path;
78
43
  return `<span class="filename-transform">${file.originalPath} → ${file.path}</span>`;
79
44
  };
80
- const generateChangedFileSection = (file) => {
45
+ const generateChangedFileSection = (file, fileId) => {
81
46
  const isYaml = (0, fileType_1.isYamlFile)(file.path);
82
47
  const summary = generateFileSummary(file);
83
- if (!isYaml) {
84
- const destinationContent = (0, serialization_1.serializeForDiff)(file.processedDestContent, false);
85
- const sourceContent = (0, serialization_1.serializeForDiff)(file.processedSourceContent, false);
86
- const unifiedDiff = (0, diffGenerator_1.generateUnifiedDiff)(file.path, destinationContent, sourceContent);
87
- const diffHtml = generateDiffHtml(unifiedDiff);
88
- return `
89
- <details class="file-section" open>
90
- <summary>${summary}</summary>
91
- <div class="diff-container">
92
- ${diffHtml}
93
- </div>
94
- </details>
95
- `;
96
- }
97
- const arrayInfo = (0, arrayDiffProcessor_1.detectArrayChanges)(file);
98
- if (!arrayInfo.hasArrays) {
99
- const destinationContent = (0, serialization_1.serializeForDiff)(file.processedDestContent, true);
100
- const sourceContent = (0, serialization_1.serializeForDiff)(file.processedSourceContent, true);
101
- const unifiedDiff = (0, diffGenerator_1.generateUnifiedDiff)(file.path, destinationContent, sourceContent);
102
- const diffHtml = generateDiffHtml(unifiedDiff);
103
- return `
104
- <details class="file-section" open>
105
- <summary>${summary}</summary>
106
- <div class="diff-container">
107
- ${diffHtml}
108
- </div>
109
- </details>
110
- `;
111
- }
112
- const destinationContent = (0, serialization_1.serializeForDiff)(file.processedDestContent, true);
113
- const sourceContent = (0, serialization_1.serializeForDiff)(file.processedSourceContent, true);
48
+ const destinationContent = (0, serialization_1.serializeForDiff)(file.processedDestContent, isYaml);
49
+ const sourceContent = (0, serialization_1.serializeForDiff)(file.processedSourceContent, isYaml);
114
50
  const unifiedDiff = (0, diffGenerator_1.generateUnifiedDiff)(file.path, destinationContent, sourceContent);
115
51
  const diffHtml = generateDiffHtml(unifiedDiff);
116
- let arrayDiffsHtml = '';
117
- if (arrayInfo.hasChanges) {
118
- arrayDiffsHtml = '<details class="array-details"><summary>Array-specific details:</summary>';
119
- for (const change of arrayInfo.changes) {
120
- const pathString = change.path.join('.');
121
- arrayDiffsHtml += `<div class="array-section"><h4>${pathString}:</h4>`;
122
- arrayDiffsHtml += generateArrayDiffHtml(change);
123
- arrayDiffsHtml += '</div>';
124
- }
125
- arrayDiffsHtml += '</details>';
126
- }
127
52
  return `
128
- <details class="file-section" open>
53
+ <details class="file-section" id="${fileId}" data-file-id="${fileId}" open>
129
54
  <summary>${summary}</summary>
130
55
  <div class="diff-container">
131
56
  ${diffHtml}
132
- ${arrayDiffsHtml}
133
57
  </div>
134
58
  </details>
135
59
  `;
@@ -163,8 +87,11 @@ const generateHtmlReport = async (diffResult, formattedFiles, config, dryRun, lo
163
87
  };
164
88
  const formattedSet = new Set(formattedFiles);
165
89
  const trulyUnchangedFiles = diffResult.unchangedFiles.filter((file) => !formattedSet.has(file));
166
- const changedSections = diffResult.changedFiles.map((file) => generateChangedFileSection(file));
167
- const htmlContent = (0, htmlTemplate_1.generateHtmlTemplate)(diffResult, formattedFiles, trulyUnchangedFiles, metadata, changedSections);
90
+ const changedFileIds = new Map();
91
+ for (const [index, file] of diffResult.changedFiles.entries())
92
+ changedFileIds.set(file.path, `file-${index}`);
93
+ const changedSections = diffResult.changedFiles.map((file, index) => generateChangedFileSection(file, `file-${index}`));
94
+ const htmlContent = (0, htmlTemplate_1.generateHtmlTemplate)(diffResult, formattedFiles, trulyUnchangedFiles, metadata, changedSections, changedFileIds);
168
95
  await writeHtmlFile(htmlContent, reportPath);
169
96
  logger?.log(`✓ HTML report generated: ${reportPath}, opening in browser...`);
170
97
  try {
@@ -5,7 +5,7 @@ export interface PatternUsageResult {
5
5
  hasWarnings: boolean;
6
6
  }
7
7
  export interface PatternUsageWarning {
8
- type: 'unused-exclude' | 'unused-skipPath' | 'unused-skipPath-jsonpath' | 'unused-stopRule-glob' | 'unused-stopRule-path';
8
+ type: 'unused-exclude' | 'unused-skipPath' | 'unused-skipPath-jsonpath' | 'unused-stopRule-glob' | 'unused-stopRule-path' | 'unused-fixedValues' | 'unused-fixedValues-jsonpath';
9
9
  pattern: string;
10
10
  message: string;
11
11
  context?: string;
@@ -11,7 +11,8 @@ const validatePatternUsage = (config, sourceFiles, destinationFiles) => {
11
11
  const warnings = [
12
12
  ...validateExcludePatterns(config, sourceFiles, destinationFiles),
13
13
  ...validateSkipPathPatterns(config, sourceFiles, destinationFiles),
14
- ...validateStopRulePatterns(config, sourceFiles, destinationFiles)
14
+ ...validateStopRulePatterns(config, sourceFiles, destinationFiles),
15
+ ...validateFixedValuesPatterns(config, sourceFiles, destinationFiles)
15
16
  ];
16
17
  return {
17
18
  warnings,
@@ -99,6 +100,38 @@ const validateStopRulePatterns = (config, sourceFiles, destinationFiles) => {
99
100
  return warnings;
100
101
  };
101
102
  const hasPathField = (rule) => 'path' in rule && typeof rule.path === 'string';
103
+ const validateFixedValuesPatterns = (config, sourceFiles, destinationFiles) => {
104
+ const warnings = [];
105
+ if (!config.fixedValues)
106
+ return warnings;
107
+ const allFiles = new Set([...sourceFiles.keys(), ...destinationFiles.keys()]);
108
+ for (const [pattern, rules] of Object.entries(config.fixedValues)) {
109
+ const matchedFiles = [...allFiles].filter((filePath) => patternMatcher_1.globalMatcher.match(filePath, pattern));
110
+ if (matchedFiles.length === 0) {
111
+ warnings.push({
112
+ type: 'unused-fixedValues',
113
+ pattern,
114
+ message: `fixedValues pattern '${pattern}' matches no files`,
115
+ context: `${rules.length} rule(s) defined`
116
+ });
117
+ continue;
118
+ }
119
+ const yamlFiles = matchedFiles.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
120
+ if (yamlFiles.length === 0)
121
+ continue;
122
+ for (const rule of rules) {
123
+ const pathExistsInAny = validateJsonPathInFiles(rule.path, yamlFiles, sourceFiles, destinationFiles);
124
+ if (!pathExistsInAny)
125
+ warnings.push({
126
+ type: 'unused-fixedValues-jsonpath',
127
+ pattern,
128
+ message: `fixedValues JSONPath '${rule.path}' not found in any matched files`,
129
+ context: `Pattern: ${pattern}, matches ${yamlFiles.length} file(s)`
130
+ });
131
+ }
132
+ }
133
+ return warnings;
134
+ };
102
135
  const pathCouldMatch = (object, pathParts) => {
103
136
  let current = object;
104
137
  for (const part of pathParts) {
@@ -1,2 +1,2 @@
1
- export declare const HTML_STYLES = "\n /* Custom styles */\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n margin: 0;\n padding: 20px;\n background: #f6f8fa;\n }\n\n header {\n background: white;\n padding: 20px;\n border-radius: 6px;\n margin-bottom: 20px;\n box-shadow: 0 1px 3px rgba(0,0,0,0.12);\n }\n\n h1 {\n margin: 0 0 10px 0;\n color: #24292e;\n }\n\n .metadata {\n display: flex;\n gap: 20px;\n margin: 10px 0;\n color: #586069;\n font-size: 14px;\n }\n\n .dry-run-badge {\n display: inline-block;\n padding: 4px 8px;\n background: #cfe2ff;\n color: #084298;\n border-radius: 4px;\n font-weight: bold;\n font-size: 12px;\n }\n\n .summary {\n display: flex;\n gap: 12px;\n margin: 15px 0;\n }\n\n .stat {\n padding: 8px 16px;\n border-radius: 6px;\n font-weight: 600;\n font-size: 14px;\n }\n\n .stat.added { background: #d4edda; color: #155724; }\n .stat.changed { background: #fff3cd; color: #856404; }\n .stat.deleted { background: #f8d7da; color: #721c24; }\n .stat.formatted { background: #d1ecf1; color: #0c5460; }\n .stat.unchanged { background: #e2e3e5; color: #383d41; }\n\n .tabs {\n display: flex;\n background: white;\n border-radius: 6px 6px 0 0;\n border-bottom: 1px solid #d0d7de;\n box-shadow: 0 1px 3px rgba(0,0,0,0.12);\n }\n\n .tab {\n padding: 12px 24px;\n border: none;\n background: transparent;\n cursor: pointer;\n font-size: 14px;\n color: #586069;\n transition: all 0.2s;\n }\n\n .tab:hover {\n color: #24292e;\n }\n\n .tab.active {\n border-bottom: 2px solid #0969da;\n color: #0969da;\n font-weight: 600;\n }\n\n main {\n background: white;\n padding: 20px;\n border-radius: 0 0 6px 6px;\n box-shadow: 0 1px 3px rgba(0,0,0,0.12);\n }\n\n .tab-content {\n display: none;\n }\n\n .tab-content.active {\n display: block;\n }\n\n .file-section {\n margin: 12px 0;\n border: 1px solid #d0d7de;\n border-radius: 6px;\n }\n\n .file-section summary {\n padding: 12px 16px;\n background: #f6f8fa;\n cursor: pointer;\n font-weight: 600;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13px;\n color: #24292e;\n }\n\n .file-section summary:hover {\n background: #eaeef2;\n }\n\n .filename-transform {\n color: #0969da;\n }\n\n .diff-container {\n padding: 0;\n }\n\n /* Hide diff2html file header with rename badge */\n .d2h-file-header {\n display: none;\n }\n\n .file-list {\n margin: 20px 0;\n }\n\n .file-list ul {\n list-style: none;\n padding: 0;\n margin: 10px 0;\n }\n\n .file-list li {\n padding: 8px 16px;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13px;\n color: #586069;\n border-bottom: 1px solid #f6f8fa;\n }\n\n .file-list li:hover {\n background: #f6f8fa;\n }\n\n .array-details {\n margin: 20px 0;\n background: #f6f8fa;\n border-radius: 6px;\n border-left: 3px solid #0969da;\n }\n\n .array-details summary {\n padding: 12px 15px;\n cursor: pointer;\n color: #0969da;\n font-size: 13px;\n font-weight: 600;\n }\n\n .array-details summary:hover {\n background: #eaeef2;\n }\n\n .array-details > .array-section {\n margin: 0 15px 15px 15px;\n }\n\n .array-section {\n margin: 15px 0;\n padding: 10px;\n background: white;\n border-radius: 4px;\n border: 1px solid #d0d7de;\n }\n\n .array-section h4 {\n margin: 0 0 10px 0;\n color: #24292e;\n font-size: 14px;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n }\n\n .array-unchanged {\n padding: 8px;\n color: #586069;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n }\n\n .added-items, .removed-items {\n margin: 10px 0;\n }\n\n .added-items h4 {\n color: #1a7f37;\n margin: 0 0 8px 0;\n font-size: 13px;\n }\n\n .removed-items h4 {\n color: #cf222e;\n margin: 0 0 8px 0;\n font-size: 13px;\n }\n\n .added-items ul, .removed-items ul {\n list-style: none;\n padding: 0;\n margin: 0;\n }\n\n .added-items li {\n padding: 6px 10px;\n background: #dafbe1;\n border-left: 3px solid #1a7f37;\n margin: 4px 0;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n }\n\n .removed-items li {\n padding: 6px 10px;\n background: #ffebe9;\n border-left: 3px solid #cf222e;\n margin: 4px 0;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n }\n\n .added-items pre, .removed-items pre {\n margin: 0;\n white-space: pre-wrap;\n word-wrap: break-word;\n }\n\n .unchanged-count {\n padding: 8px 10px;\n color: #586069;\n font-size: 12px;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n background: #f6f8fa;\n border-radius: 4px;\n margin: 10px 0;\n }\n";
2
- export declare const TAB_SCRIPT = "\n // Tab switching\n document.querySelectorAll('.tab').forEach(tab => {\n tab.addEventListener('click', () => {\n const tabName = tab.getAttribute('data-tab');\n\n // Update tabs\n document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));\n tab.classList.add('active');\n\n // Update content\n document.querySelectorAll('.tab-content').forEach(content => {\n content.classList.remove('active');\n });\n document.getElementById(tabName).classList.add('active');\n });\n });\n";
1
+ export declare const HTML_STYLES = "\n /* Custom styles */\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n margin: 0;\n padding: 20px;\n background: #f6f8fa;\n }\n\n header {\n background: white;\n padding: 20px;\n border-radius: 6px;\n margin-bottom: 20px;\n box-shadow: 0 1px 3px rgba(0,0,0,0.12);\n }\n\n h1 {\n margin: 0 0 10px 0;\n color: #24292e;\n }\n\n .metadata {\n display: flex;\n gap: 20px;\n margin: 10px 0;\n color: #586069;\n font-size: 14px;\n }\n\n .dry-run-badge {\n display: inline-block;\n padding: 4px 8px;\n background: #cfe2ff;\n color: #084298;\n border-radius: 4px;\n font-weight: bold;\n font-size: 12px;\n }\n\n .summary {\n display: flex;\n gap: 12px;\n margin: 15px 0;\n }\n\n .stat {\n padding: 8px 16px;\n border-radius: 6px;\n font-weight: 600;\n font-size: 14px;\n }\n\n .stat.added { background: #d4edda; color: #155724; }\n .stat.changed { background: #fff3cd; color: #856404; }\n .stat.deleted { background: #f8d7da; color: #721c24; }\n .stat.formatted { background: #d1ecf1; color: #0c5460; }\n .stat.unchanged { background: #e2e3e5; color: #383d41; }\n\n .tabs {\n display: flex;\n background: white;\n border-radius: 6px 6px 0 0;\n border-bottom: 1px solid #d0d7de;\n box-shadow: 0 1px 3px rgba(0,0,0,0.12);\n }\n\n .tab {\n padding: 12px 24px;\n border: none;\n background: transparent;\n cursor: pointer;\n font-size: 14px;\n color: #586069;\n transition: all 0.2s;\n }\n\n .tab:hover {\n color: #24292e;\n }\n\n .tab.active {\n border-bottom: 2px solid #0969da;\n color: #0969da;\n font-weight: 600;\n }\n\n main {\n background: white;\n padding: 20px;\n border-radius: 0 0 6px 6px;\n box-shadow: 0 1px 3px rgba(0,0,0,0.12);\n }\n\n .tab-content {\n display: none;\n }\n\n .tab-content.active {\n display: block;\n }\n\n .file-section {\n margin: 12px 0;\n border: 1px solid #d0d7de;\n border-radius: 6px;\n }\n\n .file-section summary {\n padding: 12px 16px;\n background: #f6f8fa;\n cursor: pointer;\n font-weight: 600;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13px;\n color: #24292e;\n }\n\n .file-section summary:hover {\n background: #eaeef2;\n }\n\n .filename-transform {\n color: #0969da;\n }\n\n .diff-container {\n padding: 0;\n }\n\n /* Hide diff2html file header with rename badge */\n .d2h-file-header {\n display: none;\n }\n\n .file-list {\n margin: 20px 0;\n }\n\n .file-list ul {\n list-style: none;\n padding: 0;\n margin: 10px 0;\n }\n\n .file-list li {\n padding: 8px 16px;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13px;\n color: #586069;\n border-bottom: 1px solid #f6f8fa;\n }\n\n .file-list li:hover {\n background: #f6f8fa;\n }\n\n /* Treeview styles */\n .tree-root {\n list-style: none;\n padding: 0;\n margin: 10px 0;\n font-family: ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 13px;\n }\n\n .tree-root ul {\n list-style: none;\n padding-left: 20px;\n margin: 0;\n }\n\n .tree-folder,\n .tree-file {\n padding: 4px 8px;\n border-radius: 4px;\n cursor: default;\n }\n\n .tree-folder:hover,\n .tree-file:hover {\n background: #f6f8fa;\n }\n\n .tree-toggle {\n display: inline-block;\n width: 16px;\n cursor: pointer;\n color: #586069;\n font-size: 10px;\n user-select: none;\n }\n\n .tree-folder.collapsed > .tree-toggle {\n transform: rotate(-90deg);\n }\n\n .tree-folder.collapsed > .tree-children {\n display: none;\n }\n\n .tree-folder-name {\n color: #0969da;\n font-weight: 500;\n }\n\n .tree-file-name {\n color: #586069;\n padding-left: 16px;\n }\n\n /* Sidebar styles */\n .sidebar-container {\n display: flex;\n gap: 0;\n }\n\n .sidebar {\n width: 280px;\n min-width: 280px;\n border-right: 1px solid #d0d7de;\n background: #f6f8fa;\n transition: width 0.2s, min-width 0.2s, padding 0.2s, opacity 0.2s;\n }\n\n .sidebar.collapsed {\n width: 0;\n min-width: 0;\n padding: 0;\n overflow: hidden;\n border-right: none;\n }\n\n .sidebar-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n border-bottom: 1px solid #d0d7de;\n background: #fff;\n font-weight: 600;\n font-size: 14px;\n color: #24292e;\n position: sticky;\n top: 0;\n z-index: 1;\n }\n\n .sidebar-toggle {\n background: none;\n border: 1px solid #d0d7de;\n border-radius: 4px;\n cursor: pointer;\n padding: 4px 8px;\n color: #586069;\n font-size: 12px;\n }\n\n .sidebar-toggle:hover {\n background: #f6f8fa;\n color: #24292e;\n }\n\n .sidebar-content {\n padding: 8px;\n }\n\n .sidebar-tree .tree-file-link {\n color: #586069;\n text-decoration: none;\n padding-left: 16px;\n display: block;\n }\n\n .sidebar-tree .tree-file-link:hover {\n color: #0969da;\n }\n\n .sidebar-tree .tree-file.active .tree-file-link {\n color: #0969da;\n font-weight: 600;\n }\n\n .changed-content {\n flex: 1;\n min-width: 0;\n padding-left: 20px;\n }\n\n .sidebar-expand-btn {\n display: none;\n position: fixed;\n left: 0;\n top: 50%;\n transform: translateY(-50%);\n background: #f6f8fa;\n border: 1px solid #d0d7de;\n border-left: none;\n border-radius: 0 4px 4px 0;\n padding: 8px 4px;\n cursor: pointer;\n color: #586069;\n z-index: 100;\n }\n\n .sidebar-expand-btn:hover {\n background: #eaeef2;\n color: #24292e;\n }\n\n .sidebar.collapsed ~ .sidebar-expand-btn {\n display: block;\n }\n";
2
+ export declare const TAB_SCRIPT: string;