nativeapptemplate-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/CLAUDE.md +137 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -0
  4. package/dist/adb.d.ts +2 -0
  5. package/dist/adb.d.ts.map +1 -0
  6. package/dist/adb.js +31 -0
  7. package/dist/adb.js.map +1 -0
  8. package/dist/agents/contract-extract.d.ts +26 -0
  9. package/dist/agents/contract-extract.d.ts.map +1 -0
  10. package/dist/agents/contract-extract.js +205 -0
  11. package/dist/agents/contract-extract.js.map +1 -0
  12. package/dist/agents/judge.d.ts +21 -0
  13. package/dist/agents/judge.d.ts.map +1 -0
  14. package/dist/agents/judge.js +119 -0
  15. package/dist/agents/judge.js.map +1 -0
  16. package/dist/agents/planner.d.ts +3 -0
  17. package/dist/agents/planner.d.ts.map +1 -0
  18. package/dist/agents/planner.js +154 -0
  19. package/dist/agents/planner.js.map +1 -0
  20. package/dist/agents/reviewer.d.ts +9 -0
  21. package/dist/agents/reviewer.d.ts.map +1 -0
  22. package/dist/agents/reviewer.js +108 -0
  23. package/dist/agents/reviewer.js.map +1 -0
  24. package/dist/agents/types.d.ts +54 -0
  25. package/dist/agents/types.d.ts.map +1 -0
  26. package/dist/agents/types.js +2 -0
  27. package/dist/agents/types.js.map +1 -0
  28. package/dist/agents/workers/android.d.ts +3 -0
  29. package/dist/agents/workers/android.d.ts.map +1 -0
  30. package/dist/agents/workers/android.js +116 -0
  31. package/dist/agents/workers/android.js.map +1 -0
  32. package/dist/agents/workers/ios.d.ts +3 -0
  33. package/dist/agents/workers/ios.d.ts.map +1 -0
  34. package/dist/agents/workers/ios.js +110 -0
  35. package/dist/agents/workers/ios.js.map +1 -0
  36. package/dist/agents/workers/rails.d.ts +3 -0
  37. package/dist/agents/workers/rails.d.ts.map +1 -0
  38. package/dist/agents/workers/rails.js +145 -0
  39. package/dist/agents/workers/rails.js.map +1 -0
  40. package/dist/dispatch.d.ts +3 -0
  41. package/dist/dispatch.d.ts.map +1 -0
  42. package/dist/dispatch.js +38 -0
  43. package/dist/dispatch.js.map +1 -0
  44. package/dist/env.d.ts +3 -0
  45. package/dist/env.d.ts.map +1 -0
  46. package/dist/env.js +48 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +26 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/ruby.d.ts +2 -0
  53. package/dist/ruby.d.ts.map +1 -0
  54. package/dist/ruby.js +21 -0
  55. package/dist/ruby.js.map +1 -0
  56. package/dist/slug.d.ts +3 -0
  57. package/dist/slug.d.ts.map +1 -0
  58. package/dist/slug.js +11 -0
  59. package/dist/slug.js.map +1 -0
  60. package/dist/stub.d.ts +3 -0
  61. package/dist/stub.d.ts.map +1 -0
  62. package/dist/stub.js +6 -0
  63. package/dist/stub.js.map +1 -0
  64. package/dist/trace.d.ts +3 -0
  65. package/dist/trace.d.ts.map +1 -0
  66. package/dist/trace.js +13 -0
  67. package/dist/trace.js.map +1 -0
  68. package/dist/validation/capture.d.ts +15 -0
  69. package/dist/validation/capture.d.ts.map +1 -0
  70. package/dist/validation/capture.js +141 -0
  71. package/dist/validation/capture.js.map +1 -0
  72. package/dist/validation/discover.d.ts +11 -0
  73. package/dist/validation/discover.d.ts.map +1 -0
  74. package/dist/validation/discover.js +113 -0
  75. package/dist/validation/discover.js.map +1 -0
  76. package/dist/validation/index.d.ts +17 -0
  77. package/dist/validation/index.d.ts.map +1 -0
  78. package/dist/validation/index.js +9 -0
  79. package/dist/validation/index.js.map +1 -0
  80. package/dist/validation/launch.d.ts +21 -0
  81. package/dist/validation/launch.d.ts.map +1 -0
  82. package/dist/validation/launch.js +109 -0
  83. package/dist/validation/launch.js.map +1 -0
  84. package/dist/validation/layer1.d.ts +16 -0
  85. package/dist/validation/layer1.d.ts.map +1 -0
  86. package/dist/validation/layer1.js +98 -0
  87. package/dist/validation/layer1.js.map +1 -0
  88. package/dist/validation/layer2.d.ts +17 -0
  89. package/dist/validation/layer2.d.ts.map +1 -0
  90. package/dist/validation/layer2.js +168 -0
  91. package/dist/validation/layer2.js.map +1 -0
  92. package/dist/validation/layer3.d.ts +22 -0
  93. package/dist/validation/layer3.d.ts.map +1 -0
  94. package/dist/validation/layer3.js +96 -0
  95. package/dist/validation/layer3.js.map +1 -0
  96. package/dist/validation/stage1.d.ts +17 -0
  97. package/dist/validation/stage1.d.ts.map +1 -0
  98. package/dist/validation/stage1.js +71 -0
  99. package/dist/validation/stage1.js.map +1 -0
  100. package/dist/validation/visual-judge.d.ts +25 -0
  101. package/dist/validation/visual-judge.d.ts.map +1 -0
  102. package/dist/validation/visual-judge.js +89 -0
  103. package/dist/validation/visual-judge.js.map +1 -0
  104. package/package.json +69 -0
  105. package/scripts/ruby/.gitkeep +0 -0
  106. package/scripts/ruby/rename.rb +221 -0
@@ -0,0 +1,89 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { runLayer3 } from "./layer3.js";
3
+ import { captureScreenshot } from "./capture.js";
4
+ import { installAndLaunch } from "./launch.js";
5
+ const DEFAULT_RENDER_WAIT_MS = 3_000;
6
+ // End-to-end Stage 1 visual judge for one platform:
7
+ // 1. install + launch the built app on the booted sim/emulator
8
+ // 2. wait for render
9
+ // 3. capture the home screen as PNG
10
+ // 4. send PNG + rubric to the Opus 4.7 vision judge (median-of-3 per criterion)
11
+ //
12
+ // Fail-fast: any step's failure short-circuits and surfaces in the result.
13
+ // Caller is responsible for: building the artifact (Layer 2 build mode),
14
+ // resolving artifactPath / bundleId / packageName from the slug, and ensuring
15
+ // a sim/emulator is booted.
16
+ export async function runVisualJudge(input) {
17
+ const launch = await installAndLaunch(input.platform === "ios"
18
+ ? {
19
+ platform: "ios",
20
+ appPath: input.artifactPath,
21
+ bundleId: requireField(input.bundleId, "bundleId is required for platform=ios"),
22
+ }
23
+ : {
24
+ platform: "android",
25
+ apkPath: input.artifactPath,
26
+ packageName: requireField(input.packageName, "packageName is required for platform=android"),
27
+ });
28
+ if (!launch.ok) {
29
+ return {
30
+ ok: false,
31
+ launch,
32
+ error: `install/launch failed: ${launch.error ?? "unknown"}`,
33
+ };
34
+ }
35
+ await sleep(input.renderWaitMs ?? DEFAULT_RENDER_WAIT_MS);
36
+ const capture = await captureScreenshot({
37
+ platform: input.platform,
38
+ outPath: input.screenshotPath,
39
+ });
40
+ if (!capture.ok) {
41
+ return {
42
+ ok: false,
43
+ launch,
44
+ error: `screenshot capture failed: ${capture.error ?? "unknown"}`,
45
+ };
46
+ }
47
+ const layer3 = await runLayer3({
48
+ screenshotPath: capture.path,
49
+ rubric: input.rubric,
50
+ spec: input.spec,
51
+ ...(input.samplesPerCriterion !== undefined ? { samplesPerCriterion: input.samplesPerCriterion } : {}),
52
+ ...(input.model !== undefined ? { model: input.model } : {}),
53
+ });
54
+ return {
55
+ ok: layer3.pass,
56
+ launch,
57
+ screenshotPath: capture.path,
58
+ layer3,
59
+ };
60
+ }
61
+ // Default Stage 1 rubric — two Yes/No criteria covering substrate-leak
62
+ // detection and basic render sanity. Phrased so pass=true is the desired
63
+ // state on every criterion.
64
+ //
65
+ // Per docs/SPEC.md, Stage 1 captures the post-launch screen and is scoped
66
+ // to "catch egregious rename failures" — substrate-leak detection plus
67
+ // 'does anything render at all'. Domain-semantic matching (e.g. "does this
68
+ // read as a clinic queue?") requires reaching the actual domain UI past
69
+ // onboarding/login and lives in Stage 2 (mobile-mcp navigation), where
70
+ // the agent can drive the app to a list/detail/form screen before
71
+ // judging. Adding a domain-match criterion here would false-fail any
72
+ // substrate that ships with onboarding-before-domain-UI — which is the
73
+ // substrate's intentional design.
74
+ export const DEFAULT_STAGE1_RUBRIC = [
75
+ {
76
+ id: "no-substrate-leak",
77
+ question: "Is the screen free of substrate-original tokens like 'Shop', 'Shopkeeper', 'ItemTag', 'NativeAppTemplate', or 'Number Tag'?",
78
+ },
79
+ {
80
+ id: "renders-cleanly",
81
+ question: "Does the screen render without an actual rendering failure — that is, no crash dialog, no broken-image-icon glyphs, no text overlapping other text, no content cut off the side of the screen? A welcome / launch / onboarding screen with decorative graphics counts as PASS as long as nothing is technically broken; do not judge whether the screen looks 'finished' or shows the app's domain content.",
82
+ },
83
+ ];
84
+ function requireField(value, message) {
85
+ if (value === undefined)
86
+ throw new Error(message);
87
+ return value;
88
+ }
89
+ //# sourceMappingURL=visual-judge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visual-judge.js","sourceRoot":"","sources":["../../src/validation/visual-judge.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "nativeapptemplate-agent",
3
+ "version": "0.1.0",
4
+ "description": "A Claude Code agent that turns a natural-language SaaS spec into a working three-platform implementation (Rails API + SwiftUI iOS + Jetpack Compose Android).",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "nativeapptemplate-agent": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist/",
12
+ "scripts/ruby/",
13
+ "README.md",
14
+ "LICENSE",
15
+ "CLAUDE.md"
16
+ ],
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "directories": {
21
+ "doc": "docs"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "dev": "tsx src/index.ts",
26
+ "start": "node dist/index.js",
27
+ "typecheck:tests": "tsc --noEmit -p tsconfig.test.json",
28
+ "test": "NATIVEAPPTEMPLATE_STUB_ALL=1 node --import tsx --test tests/smoke.test.ts",
29
+ "ci": "npm run build && npm run typecheck:tests && npm test",
30
+ "cost": "bash scripts/cost.sh",
31
+ "prepublishOnly": "npm run ci"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/nativeapptemplate/nativeapptemplate-agent.git"
36
+ },
37
+ "homepage": "https://github.com/nativeapptemplate/nativeapptemplate-agent#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/nativeapptemplate/nativeapptemplate-agent/issues"
40
+ },
41
+ "keywords": [
42
+ "claude",
43
+ "agent",
44
+ "code-generation",
45
+ "saas",
46
+ "ios",
47
+ "android",
48
+ "rails",
49
+ "swift",
50
+ "kotlin",
51
+ "boilerplate",
52
+ "generator",
53
+ "anthropic",
54
+ "claude-agent-sdk",
55
+ "mcp"
56
+ ],
57
+ "author": "Daisuke Adachi",
58
+ "license": "MIT",
59
+ "devDependencies": {
60
+ "@types/node": "^25.6.0",
61
+ "tsx": "^4.21.0",
62
+ "typescript": "^6.0.3"
63
+ },
64
+ "dependencies": {
65
+ "@anthropic-ai/claude-agent-sdk": "^0.2.114",
66
+ "@anthropic-ai/sdk": "^0.90.0",
67
+ "yaml": "^2.8.4"
68
+ }
69
+ }
File without changes
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Apply a rename plan to a Rails project tree.
4
+ # Stdin: { "renamePlan": [{"from":"Shop","to":"Clinic"}, ...], "root": "/abs/path" }
5
+ # Stdout: { "files_scanned": N, "files_changed": N, "substitutions": N, "files_renamed": N }
6
+ #
7
+ # Rewrites file content AND renames files/directories for every
8
+ # PascalCase / snake_case / plural variant of each rename pair.
9
+ # Word-boundary matching via Regexp; crude-but-sufficient English
10
+ # pluralization. Skips .git, node_modules, tmp/, log/, vendor/bundle/.
11
+
12
+ require "json"
13
+
14
+ input = JSON.parse($stdin.read)
15
+ plan = input.fetch("renamePlan")
16
+ root = input.fetch("root")
17
+
18
+ stats = { files_scanned: 0, files_changed: 0, substitutions: 0, files_renamed: 0 }
19
+
20
+ SKIP_DIR_SEGMENTS = %w[.git node_modules tmp log DerivedData Pods Carthage xcuserdata .build build .gradle .idea .kotlin captures].freeze
21
+ SKIP_SUBPATHS = %w[vendor/bundle].freeze
22
+ TEXT_EXTS = %w[
23
+ .rb .erb .yml .yaml .json .md .gemspec .rake .ru .txt .sample .example .conf
24
+ .html .css .scss .js .mjs .tt .lock
25
+ .swift .plist .strings .xcconfig .entitlements .pbxproj .xcworkspacedata .modulemap .xcscheme
26
+ .kt .kts .xml .gradle .pro .toml .properties .cfg
27
+ .proto
28
+ ].freeze
29
+ TEXT_BASENAMES = %w[
30
+ Gemfile Gemfile.lock Rakefile Procfile Procfile.dev
31
+ .gitignore .env.sample .ruby-version .node-version
32
+ config.ru Dockerfile
33
+ Podfile Podfile.lock Package.swift Cartfile Makefile
34
+ gradlew gradlew.bat gradle.properties local.properties
35
+ brakeman.ignore
36
+ ].freeze
37
+
38
+ def pluralize(word)
39
+ if word.end_with?("y") && word.length > 1 && !%w[a e i o u].include?(word[-2])
40
+ word[0..-2] + "ies"
41
+ elsif word.end_with?("s", "sh", "ch", "x", "z")
42
+ word + "es"
43
+ else
44
+ word + "s"
45
+ end
46
+ end
47
+
48
+ def snake_case(pascal)
49
+ pascal.gsub(/([a-z\d])([A-Z])/, '\1_\2')
50
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
51
+ .downcase
52
+ end
53
+
54
+ def camel_case(pascal)
55
+ return pascal if pascal.empty?
56
+ pascal[0].downcase + pascal[1..]
57
+ end
58
+
59
+ def humanize_lower(pascal)
60
+ snake_case(pascal).tr("_", " ")
61
+ end
62
+
63
+ def humanize_title(pascal)
64
+ humanize_lower(pascal).split(" ").map(&:capitalize).join(" ")
65
+ end
66
+
67
+ def first_char_up(s)
68
+ s.empty? ? s : s[0].upcase + s[1..]
69
+ end
70
+
71
+ def build_patterns(from, to)
72
+ from_snake = snake_case(from)
73
+ to_snake = snake_case(to)
74
+ from_snake_pl = pluralize(from_snake)
75
+ to_snake_pl = pluralize(to_snake)
76
+ from_pl = pluralize(from)
77
+ to_pl = pluralize(to)
78
+ from_flat = from.downcase
79
+ to_flat = to.downcase
80
+ from_flat_pl = from_flat + "s"
81
+ to_flat_pl = to_flat + "s"
82
+ from_camel = camel_case(from)
83
+ to_camel = camel_case(to)
84
+ from_camel_pl = pluralize(from_camel)
85
+ to_camel_pl = pluralize(to_camel)
86
+ from_human_lo = humanize_lower(from)
87
+ to_human_lo = humanize_lower(to)
88
+ from_human_ti = humanize_title(from)
89
+ to_human_ti = humanize_title(to)
90
+ from_human_se = first_char_up(from_human_lo)
91
+ to_human_se = first_char_up(to_human_lo)
92
+ from_human_lo_pl = from_snake_pl.tr("_", " ")
93
+ to_human_lo_pl = to_snake_pl.tr("_", " ")
94
+ from_human_ti_pl = from_human_lo_pl.split(" ").map(&:capitalize).join(" ")
95
+ to_human_ti_pl = to_human_lo_pl.split(" ").map(&:capitalize).join(" ")
96
+ from_human_se_pl = first_char_up(from_human_lo_pl)
97
+ to_human_se_pl = first_char_up(to_human_lo_pl)
98
+
99
+ # Ruby's \b treats `_` as a word char, so \bshop\b doesn't fire
100
+ # inside `shop_id` or `accounts_shopkeeper`. Hand-rolled boundaries:
101
+ # - PascalCase token: preceded by non-letter OR lowercase letter
102
+ # (PascalCase compound like `LoggedInShopkeeper`); followed by
103
+ # non-letter OR uppercase (next PascalCase word).
104
+ # - snake_case / lowercase token: preceded by non-letter;
105
+ # followed by non-letter OR uppercase (camelCase compound
106
+ # like `shopId`).
107
+ #
108
+ # Order matters: plural forms first so "Shops" isn't partially-
109
+ # matched as "Shop" + residual "s".
110
+ pascal_l = "(?:(?<![A-Za-z])|(?<=[a-z]))"
111
+ pascal_r = "(?:(?![A-Za-z])|(?=[A-Z]))"
112
+ snake_l = "(?<![A-Za-z])"
113
+ snake_r = "(?:(?![A-Za-z])|(?=[A-Z]))"
114
+
115
+ patterns = [
116
+ [/#{pascal_l}#{Regexp.escape(from_pl)}#{pascal_r}/, to_pl],
117
+ [/#{pascal_l}#{Regexp.escape(from)}#{pascal_r}/, to],
118
+ [/#{snake_l}#{Regexp.escape(from_snake_pl)}#{snake_r}/, to_snake_pl],
119
+ [/#{snake_l}#{Regexp.escape(from_snake)}#{snake_r}/, to_snake],
120
+ # flat-lowercase (runs AFTER snake so single-word rename pairs
121
+ # where flat == snake can't win over snake's replacement; catches
122
+ # multi-word compounds collapsed in URLs / package names /
123
+ # email addresses, e.g. `nativeapptemplate.com`).
124
+ [/#{snake_l}#{Regexp.escape(from_flat_pl)}#{snake_r}/, to_flat_pl],
125
+ [/#{snake_l}#{Regexp.escape(from_flat)}#{snake_r}/, to_flat],
126
+ # camelCase — first char lowercased, rest PascalCase. Catches
127
+ # the common Kotlin/Swift field-accessor form like `itemTag`
128
+ # (from `ItemTag`) or `itemTagInfoFromNdefMessage`.
129
+ [/#{snake_l}#{Regexp.escape(from_camel_pl)}#{snake_r}/, to_camel_pl],
130
+ [/#{snake_l}#{Regexp.escape(from_camel)}#{snake_r}/, to_camel],
131
+ [/#{snake_l}#{Regexp.escape(from_pl.upcase)}(?![A-Za-z])/, to_pl.upcase],
132
+ [/#{snake_l}#{Regexp.escape(from.upcase)}(?![A-Za-z])/, to.upcase],
133
+ ]
134
+
135
+ # Humanized display forms — three case shapes per number:
136
+ # lower "item tag" / "item tags" (UI body text, prose)
137
+ # title "Item Tag" / "Item Tags" (headings, button labels)
138
+ # sentence "Item tag" / "Item tags" (sentence start, error messages,
139
+ # OpenAPI descriptions)
140
+ # Only meaningful when the PascalCase token is multi-word; for single
141
+ # words all three humanized forms collapse to the flat / Pascal forms
142
+ # already handled above. Plural patterns come first so "item tags"
143
+ # isn't partially matched as "item tag" + residual "s".
144
+ if from_human_lo.include?(" ")
145
+ boundary = "(?<![A-Za-z])"
146
+ boundary_r = "(?![A-Za-z])"
147
+ human_patterns = [
148
+ [/#{boundary}#{Regexp.escape(from_human_ti_pl)}#{boundary_r}/, to_human_ti_pl],
149
+ [/#{boundary}#{Regexp.escape(from_human_ti)}#{boundary_r}/, to_human_ti],
150
+ [/#{boundary}#{Regexp.escape(from_human_se_pl)}#{boundary_r}/, to_human_se_pl],
151
+ [/#{boundary}#{Regexp.escape(from_human_se)}#{boundary_r}/, to_human_se],
152
+ [/#{boundary}#{Regexp.escape(from_human_lo_pl)}#{boundary_r}/, to_human_lo_pl],
153
+ [/#{boundary}#{Regexp.escape(from_human_lo)}#{boundary_r}/, to_human_lo],
154
+ ]
155
+ patterns = human_patterns + patterns
156
+ end
157
+
158
+ patterns
159
+ end
160
+
161
+ all_patterns = plan.flat_map { |p| build_patterns(p.fetch("from"), p.fetch("to")) }
162
+
163
+ def skip?(path)
164
+ return true if SKIP_DIR_SEGMENTS.any? { |d| path.include?("/#{d}/") || path.end_with?("/#{d}") }
165
+ return true if SKIP_SUBPATHS.any? { |sp| path.include?("/#{sp}") }
166
+ false
167
+ end
168
+
169
+ def text_file?(path)
170
+ basename = File.basename(path)
171
+ return true if TEXT_BASENAMES.include?(basename)
172
+ return true if TEXT_EXTS.include?(File.extname(path))
173
+ false
174
+ end
175
+
176
+ # Pass 1 — rewrite file contents.
177
+ Dir.glob("#{root}/**/*", File::FNM_DOTMATCH).each do |path|
178
+ next unless File.file?(path)
179
+ next if skip?(path)
180
+ next unless text_file?(path)
181
+
182
+ stats[:files_scanned] += 1
183
+
184
+ begin
185
+ content = File.read(path, encoding: "UTF-8")
186
+ rescue StandardError
187
+ next
188
+ end
189
+
190
+ original = content.dup
191
+ local_subst = 0
192
+ all_patterns.each do |regex, replacement|
193
+ content = content.gsub(regex) { local_subst += 1; replacement }
194
+ end
195
+
196
+ next if content == original
197
+
198
+ File.write(path, content)
199
+ stats[:files_changed] += 1
200
+ stats[:substitutions] += local_subst
201
+ end
202
+
203
+ # Pass 2 — rename paths. Deepest-first so renaming a parent directory
204
+ # doesn't invalidate paths we haven't visited yet.
205
+ Dir.glob("#{root}/**/*", File::FNM_DOTMATCH).sort_by { |p| -p.length }.each do |path|
206
+ next if skip?(path)
207
+ next unless File.exist?(path)
208
+
209
+ old_name = File.basename(path)
210
+ new_name = old_name.dup
211
+ all_patterns.each { |regex, replacement| new_name = new_name.gsub(regex, replacement) }
212
+ next if new_name == old_name
213
+
214
+ new_path = File.join(File.dirname(path), new_name)
215
+ next if File.exist?(new_path)
216
+
217
+ File.rename(path, new_path)
218
+ stats[:files_renamed] += 1
219
+ end
220
+
221
+ puts JSON.generate(stats)