@aspruyt/xfg 3.3.2 → 3.4.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/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { program, Command } from "commander";
3
3
  import { resolve, join, dirname } from "node:path";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import chalk from "chalk";
6
7
  import { loadRawConfig, normalizeConfig, } from "./config.js";
7
8
  import { validateForSync, validateForSettings } from "./config-validator.js";
8
9
  // Get version from package.json
@@ -215,6 +216,11 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
215
216
  let successCount = 0;
216
217
  let failCount = 0;
217
218
  let skipCount = 0;
219
+ // Tracking for multi-repo summary in dry-run mode
220
+ let totalCreates = 0;
221
+ let totalUpdates = 0;
222
+ let totalDeletes = 0;
223
+ let reposWithChanges = 0;
218
224
  for (let i = 0; i < reposWithRulesets.length; i++) {
219
225
  const repoConfig = reposWithRulesets[i];
220
226
  let repoInfo;
@@ -248,6 +254,27 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
248
254
  managedRulesets,
249
255
  noDelete: options.noDelete,
250
256
  });
257
+ // Display plan output for dry-run mode
258
+ if (options.dryRun && result.planOutput) {
259
+ if (result.planOutput.lines.length > 0) {
260
+ logger.rulesetPlan(repoName, result.planOutput.lines, {
261
+ creates: result.planOutput.creates,
262
+ updates: result.planOutput.updates,
263
+ deletes: result.planOutput.deletes,
264
+ unchanged: result.planOutput.unchanged,
265
+ });
266
+ }
267
+ // Accumulate totals for multi-repo summary
268
+ totalCreates += result.planOutput.creates;
269
+ totalUpdates += result.planOutput.updates;
270
+ totalDeletes += result.planOutput.deletes;
271
+ if (result.planOutput.creates +
272
+ result.planOutput.updates +
273
+ result.planOutput.deletes >
274
+ 0) {
275
+ reposWithChanges++;
276
+ }
277
+ }
251
278
  if (result.skipped) {
252
279
  logger.skip(i + 1, repoName, result.message);
253
280
  skipCount++;
@@ -292,6 +319,19 @@ export async function runSettings(options, processorFactory = defaultRulesetProc
292
319
  failCount++;
293
320
  }
294
321
  }
322
+ // Multi-repo summary for dry-run mode
323
+ if (options.dryRun && reposWithChanges > 0) {
324
+ console.log("");
325
+ console.log(chalk.gray("─".repeat(40)));
326
+ const totalParts = [];
327
+ if (totalCreates > 0)
328
+ totalParts.push(chalk.green(`${totalCreates} to create`));
329
+ if (totalUpdates > 0)
330
+ totalParts.push(chalk.yellow(`${totalUpdates} to update`));
331
+ if (totalDeletes > 0)
332
+ totalParts.push(chalk.red(`${totalDeletes} to delete`));
333
+ console.log(chalk.bold(`Total: ${totalParts.join(", ")} across ${reposWithChanges} ${reposWithChanges === 1 ? "repository" : "repositories"}`));
334
+ }
295
335
  // Summary
296
336
  console.log("\n" + "=".repeat(50));
297
337
  console.log(`Completed: ${successCount} succeeded, ${skipCount} skipped, ${failCount} failed`);
package/dist/logger.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import { FileStatus } from "./diff-utils.js";
2
+ export interface RulesetPlanCounts {
3
+ creates: number;
4
+ updates: number;
5
+ deletes: number;
6
+ unchanged: number;
7
+ }
2
8
  export interface ILogger {
3
9
  info(message: string): void;
4
10
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
5
11
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
12
+ rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
6
13
  setTotal(total: number): void;
7
14
  progress(current: number, repoName: string, message: string): void;
8
15
  success(current: number, repoName: string, message: string): void;
@@ -34,6 +41,10 @@ export declare class Logger implements ILogger {
34
41
  * Display summary statistics for dry-run diff.
35
42
  */
36
43
  diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
44
+ /**
45
+ * Display ruleset plan output for dry-run mode.
46
+ */
47
+ rulesetPlan(repoName: string, planLines: string[], counts: RulesetPlanCounts): void;
37
48
  summary(): void;
38
49
  hasFailures(): boolean;
39
50
  }
package/dist/logger.js CHANGED
@@ -62,6 +62,29 @@ export class Logger {
62
62
  console.log(chalk.gray(` Summary: ${parts.join(", ")}`));
63
63
  }
64
64
  }
65
+ /**
66
+ * Display ruleset plan output for dry-run mode.
67
+ */
68
+ rulesetPlan(repoName, planLines, counts) {
69
+ console.log("");
70
+ console.log(chalk.bold(`Repository: ${repoName}`));
71
+ for (const line of planLines) {
72
+ console.log(line);
73
+ }
74
+ // Summary line
75
+ const parts = [];
76
+ if (counts.creates > 0)
77
+ parts.push(chalk.green(`${counts.creates} to create`));
78
+ if (counts.updates > 0)
79
+ parts.push(chalk.yellow(`${counts.updates} to update`));
80
+ if (counts.deletes > 0)
81
+ parts.push(chalk.red(`${counts.deletes} to delete`));
82
+ const unchangedPart = counts.unchanged > 0
83
+ ? chalk.gray(` (${counts.unchanged} unchanged)`)
84
+ : "";
85
+ const summaryLine = parts.length > 0 ? parts.join(", ") + unchangedPart : "No changes";
86
+ console.log(chalk.gray(`Plan: ${summaryLine}`));
87
+ }
65
88
  summary() {
66
89
  console.log("");
67
90
  console.log(chalk.bold("Summary:"));
@@ -0,0 +1,27 @@
1
+ import type { RulesetChange } from "./ruleset-diff.js";
2
+ export type DiffAction = "add" | "change" | "remove";
3
+ export interface PropertyDiff {
4
+ path: string[];
5
+ action: DiffAction;
6
+ oldValue?: unknown;
7
+ newValue?: unknown;
8
+ }
9
+ export interface RulesetPlanResult {
10
+ lines: string[];
11
+ creates: number;
12
+ updates: number;
13
+ deletes: number;
14
+ unchanged: number;
15
+ }
16
+ /**
17
+ * Recursively compute property-level diffs between two objects.
18
+ */
19
+ export declare function computePropertyDiffs(current: Record<string, unknown>, desired: Record<string, unknown>, parentPath?: string[]): PropertyDiff[];
20
+ /**
21
+ * Format property diffs as an indented tree structure.
22
+ */
23
+ export declare function formatPropertyTree(diffs: PropertyDiff[]): string[];
24
+ /**
25
+ * Format ruleset changes as a Terraform-style plan.
26
+ */
27
+ export declare function formatRulesetPlan(changes: RulesetChange[]): RulesetPlanResult;
@@ -0,0 +1,314 @@
1
+ // src/ruleset-plan-formatter.ts
2
+ import chalk from "chalk";
3
+ // =============================================================================
4
+ // Property Diff Algorithm
5
+ // =============================================================================
6
+ /**
7
+ * Recursively compute property-level diffs between two objects.
8
+ */
9
+ export function computePropertyDiffs(current, desired, parentPath = []) {
10
+ const diffs = [];
11
+ const allKeys = new Set([...Object.keys(current), ...Object.keys(desired)]);
12
+ for (const key of allKeys) {
13
+ const path = [...parentPath, key];
14
+ const currentVal = current[key];
15
+ const desiredVal = desired[key];
16
+ if (!(key in current)) {
17
+ // Added property
18
+ diffs.push({ path, action: "add", newValue: desiredVal });
19
+ }
20
+ else if (!(key in desired)) {
21
+ // Removed property
22
+ diffs.push({ path, action: "remove", oldValue: currentVal });
23
+ }
24
+ else if (!deepEqual(currentVal, desiredVal)) {
25
+ // Changed property
26
+ if (isObject(currentVal) && isObject(desiredVal)) {
27
+ // Recurse into nested objects
28
+ diffs.push(...computePropertyDiffs(currentVal, desiredVal, path));
29
+ }
30
+ else {
31
+ diffs.push({
32
+ path,
33
+ action: "change",
34
+ oldValue: currentVal,
35
+ newValue: desiredVal,
36
+ });
37
+ }
38
+ }
39
+ // Unchanged properties are not included
40
+ }
41
+ return diffs;
42
+ }
43
+ // =============================================================================
44
+ // Helpers
45
+ // =============================================================================
46
+ function isObject(val) {
47
+ return val !== null && typeof val === "object" && !Array.isArray(val);
48
+ }
49
+ function deepEqual(a, b) {
50
+ if (a === b)
51
+ return true;
52
+ if (a === null || b === null || a === undefined || b === undefined)
53
+ return a === b;
54
+ if (typeof a !== typeof b)
55
+ return false;
56
+ if (Array.isArray(a) && Array.isArray(b)) {
57
+ if (a.length !== b.length)
58
+ return false;
59
+ return a.every((val, i) => deepEqual(val, b[i]));
60
+ }
61
+ if (isObject(a) && isObject(b)) {
62
+ const keysA = Object.keys(a);
63
+ const keysB = Object.keys(b);
64
+ if (keysA.length !== keysB.length)
65
+ return false;
66
+ return keysA.every((key) => deepEqual(a[key], b[key]));
67
+ }
68
+ return false;
69
+ }
70
+ /**
71
+ * Build a tree structure from flat property diffs.
72
+ */
73
+ function buildTree(diffs) {
74
+ const root = { name: "", children: new Map() };
75
+ for (const diff of diffs) {
76
+ let current = root;
77
+ for (let i = 0; i < diff.path.length; i++) {
78
+ const segment = diff.path[i];
79
+ const isLast = i === diff.path.length - 1;
80
+ if (!current.children.has(segment)) {
81
+ current.children.set(segment, {
82
+ name: segment,
83
+ children: new Map(),
84
+ });
85
+ }
86
+ const child = current.children.get(segment);
87
+ if (isLast) {
88
+ child.action = diff.action;
89
+ child.oldValue = diff.oldValue;
90
+ child.newValue = diff.newValue;
91
+ }
92
+ else {
93
+ // Intermediate node - mark as change if any child changes
94
+ if (!child.action) {
95
+ child.action = "change";
96
+ }
97
+ }
98
+ current = child;
99
+ }
100
+ }
101
+ return root;
102
+ }
103
+ /**
104
+ * Format a value for display.
105
+ */
106
+ function formatValue(val) {
107
+ if (val === null)
108
+ return "null";
109
+ if (val === undefined)
110
+ return "undefined";
111
+ if (typeof val === "string")
112
+ return `"${val}"`;
113
+ if (Array.isArray(val)) {
114
+ if (val.length <= 3) {
115
+ return `[${val.map(formatValue).join(", ")}]`;
116
+ }
117
+ return `[${val.slice(0, 3).map(formatValue).join(", ")}, ... (${val.length - 3} more)]`;
118
+ }
119
+ if (typeof val === "object") {
120
+ return "{...}";
121
+ }
122
+ return String(val);
123
+ }
124
+ /**
125
+ * Get the symbol and color for an action.
126
+ */
127
+ function getActionStyle(action) {
128
+ switch (action) {
129
+ case "add":
130
+ return { symbol: "+", color: chalk.green };
131
+ case "remove":
132
+ return { symbol: "-", color: chalk.red };
133
+ case "change":
134
+ return { symbol: "~", color: chalk.yellow };
135
+ }
136
+ }
137
+ /**
138
+ * Recursively render tree nodes to formatted lines.
139
+ */
140
+ function renderTree(node, indent = 0) {
141
+ const lines = [];
142
+ const indentStr = " ".repeat(indent);
143
+ for (const [, child] of node.children) {
144
+ const style = child.action
145
+ ? getActionStyle(child.action)
146
+ : { symbol: " ", color: chalk.gray };
147
+ const hasChildren = child.children.size > 0;
148
+ if (hasChildren) {
149
+ // Intermediate node
150
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}:`));
151
+ lines.push(...renderTree(child, indent + 1));
152
+ }
153
+ else {
154
+ // Leaf node with value
155
+ let valuePart = "";
156
+ if (child.action === "change") {
157
+ valuePart = `: ${formatValue(child.oldValue)} → ${formatValue(child.newValue)}`;
158
+ }
159
+ else if (child.action === "add") {
160
+ valuePart = `: ${formatValue(child.newValue)}`;
161
+ }
162
+ else if (child.action === "remove") {
163
+ valuePart = ` (was: ${formatValue(child.oldValue)})`;
164
+ }
165
+ lines.push(style.color(`${indentStr}${style.symbol} ${child.name}${valuePart}`));
166
+ }
167
+ }
168
+ return lines;
169
+ }
170
+ /**
171
+ * Format property diffs as an indented tree structure.
172
+ */
173
+ export function formatPropertyTree(diffs) {
174
+ if (diffs.length === 0) {
175
+ return [];
176
+ }
177
+ const tree = buildTree(diffs);
178
+ return renderTree(tree);
179
+ }
180
+ // =============================================================================
181
+ // Ruleset Plan Formatter
182
+ // =============================================================================
183
+ /**
184
+ * Normalize a GitHubRuleset or Ruleset for comparison.
185
+ * Converts to snake_case and removes metadata fields.
186
+ */
187
+ function normalizeForDiff(obj) {
188
+ const result = {};
189
+ const ignoreFields = new Set(["id", "name", "source_type", "source"]);
190
+ for (const [key, value] of Object.entries(obj)) {
191
+ if (ignoreFields.has(key) || value === undefined)
192
+ continue;
193
+ // Convert camelCase to snake_case for consistency
194
+ const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
195
+ result[snakeKey] = normalizeNestedValue(value);
196
+ }
197
+ return result;
198
+ }
199
+ function normalizeNestedValue(value) {
200
+ if (value === null || value === undefined)
201
+ return value;
202
+ if (Array.isArray(value))
203
+ return value.map(normalizeNestedValue);
204
+ if (typeof value === "object") {
205
+ const obj = value;
206
+ const result = {};
207
+ for (const [key, val] of Object.entries(obj)) {
208
+ const snakeKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
209
+ result[snakeKey] = normalizeNestedValue(val);
210
+ }
211
+ return result;
212
+ }
213
+ return value;
214
+ }
215
+ /**
216
+ * Format a full ruleset config as tree lines (for create action).
217
+ */
218
+ function formatFullConfig(ruleset, indent = 2) {
219
+ const lines = [];
220
+ const style = getActionStyle("add");
221
+ function renderValue(key, value, currentIndent) {
222
+ const pad = " ".repeat(currentIndent);
223
+ if (value === null || value === undefined)
224
+ return;
225
+ if (Array.isArray(value)) {
226
+ if (value.length === 0) {
227
+ lines.push(style.color(`${pad}+ ${key}: []`));
228
+ }
229
+ else if (value.every((v) => typeof v !== "object")) {
230
+ lines.push(style.color(`${pad}+ ${key}: ${formatValue(value)}`));
231
+ }
232
+ else {
233
+ lines.push(style.color(`${pad}+ ${key}:`));
234
+ for (const item of value) {
235
+ if (typeof item === "object" && item !== null) {
236
+ lines.push(style.color(`${pad} - ${JSON.stringify(item)}`));
237
+ }
238
+ else {
239
+ lines.push(style.color(`${pad} - ${formatValue(item)}`));
240
+ }
241
+ }
242
+ }
243
+ }
244
+ else if (typeof value === "object") {
245
+ lines.push(style.color(`${pad}+ ${key}:`));
246
+ for (const [k, v] of Object.entries(value)) {
247
+ renderValue(k, v, currentIndent + 1);
248
+ }
249
+ }
250
+ else {
251
+ lines.push(style.color(`${pad}+ ${key}: ${formatValue(value)}`));
252
+ }
253
+ }
254
+ for (const [key, value] of Object.entries(ruleset)) {
255
+ renderValue(key, value, indent);
256
+ }
257
+ return lines;
258
+ }
259
+ /**
260
+ * Format ruleset changes as a Terraform-style plan.
261
+ */
262
+ export function formatRulesetPlan(changes) {
263
+ const lines = [];
264
+ let creates = 0;
265
+ let updates = 0;
266
+ let deletes = 0;
267
+ let unchanged = 0;
268
+ // Group by action type
269
+ const createChanges = changes.filter((c) => c.action === "create");
270
+ const updateChanges = changes.filter((c) => c.action === "update");
271
+ const deleteChanges = changes.filter((c) => c.action === "delete");
272
+ const unchangedChanges = changes.filter((c) => c.action === "unchanged");
273
+ creates = createChanges.length;
274
+ updates = updateChanges.length;
275
+ deletes = deleteChanges.length;
276
+ unchanged = unchangedChanges.length;
277
+ // Format creates
278
+ if (createChanges.length > 0) {
279
+ lines.push(chalk.bold(" Create:"));
280
+ for (const change of createChanges) {
281
+ lines.push(chalk.green(` + ruleset "${change.name}"`));
282
+ if (change.desired) {
283
+ lines.push(...formatFullConfig(change.desired, 2));
284
+ }
285
+ lines.push(""); // Blank line between rulesets
286
+ }
287
+ }
288
+ // Format updates
289
+ if (updateChanges.length > 0) {
290
+ lines.push(chalk.bold(" Update:"));
291
+ for (const change of updateChanges) {
292
+ lines.push(chalk.yellow(` ~ ruleset "${change.name}"`));
293
+ if (change.current && change.desired) {
294
+ const currentNorm = normalizeForDiff(change.current);
295
+ const desiredNorm = normalizeForDiff(change.desired);
296
+ const diffs = computePropertyDiffs(currentNorm, desiredNorm);
297
+ const treeLines = formatPropertyTree(diffs);
298
+ for (const line of treeLines) {
299
+ lines.push(` ${line}`);
300
+ }
301
+ }
302
+ lines.push(""); // Blank line between rulesets
303
+ }
304
+ }
305
+ // Format deletes
306
+ if (deleteChanges.length > 0) {
307
+ lines.push(chalk.bold(" Delete:"));
308
+ for (const change of deleteChanges) {
309
+ lines.push(chalk.red(` - ruleset "${change.name}"`));
310
+ }
311
+ lines.push(""); // Blank line after deletes
312
+ }
313
+ return { lines, creates, updates, deletes, unchanged };
314
+ }
@@ -1,6 +1,7 @@
1
1
  import type { RepoConfig } from "./config.js";
2
2
  import type { RepoInfo } from "./repo-detector.js";
3
3
  import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
4
+ import { RulesetPlanResult } from "./ruleset-plan-formatter.js";
4
5
  export interface IRulesetProcessor {
5
6
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RulesetProcessorOptions): Promise<RulesetProcessorResult>;
6
7
  }
@@ -26,6 +27,7 @@ export interface RulesetProcessorResult {
26
27
  manifestUpdate?: {
27
28
  rulesets: string[];
28
29
  };
30
+ planOutput?: RulesetPlanResult;
29
31
  }
30
32
  /**
31
33
  * Processes ruleset configuration for a repository.
@@ -1,6 +1,7 @@
1
1
  import { isGitHubRepo, getRepoDisplayName } from "./repo-detector.js";
2
2
  import { GitHubRulesetStrategy } from "./strategies/github-ruleset-strategy.js";
3
3
  import { diffRulesets } from "./ruleset-diff.js";
4
+ import { formatRulesetPlan, } from "./ruleset-plan-formatter.js";
4
5
  // =============================================================================
5
6
  // Processor Implementation
6
7
  // =============================================================================
@@ -60,12 +61,14 @@ export class RulesetProcessor {
60
61
  // Dry run mode - report planned changes without applying
61
62
  if (dryRun) {
62
63
  const summary = this.formatChangeSummary(changeCounts);
64
+ const planOutput = formatRulesetPlan(changes);
63
65
  return {
64
66
  success: true,
65
67
  repoName,
66
68
  message: `[DRY RUN] ${summary}`,
67
69
  dryRun: true,
68
70
  changes: changeCounts,
71
+ planOutput,
69
72
  manifestUpdate: this.computeManifestUpdate(desiredRulesets, deleteOrphaned),
70
73
  };
71
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.3.2",
3
+ "version": "3.4.0",
4
4
  "description": "CLI tool for repository-as-code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",