roadmapsmith 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.
@@ -0,0 +1,436 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { walkFiles, detectLanguages, detectTestFrameworks } = require('../io');
6
+ const { createRoadmapModel, PHASE_ORDER } = require('../model');
7
+ const { slugify, ensureTrailingNewline } = require('../utils');
8
+ const { parseRoadmap, upsertManagedBlock } = require('../parser');
9
+ const { findBestTaskMatch, dedupeTasks } = require('../match');
10
+ const { collectPluginContributions } = require('../config');
11
+
12
+ function detectModules(files) {
13
+ const modules = new Set();
14
+ const roots = ['src/', 'apps/', 'packages/', 'lib/', 'cmd/', 'internal/'];
15
+
16
+ for (const file of files) {
17
+ const root = roots.find((candidate) => file.startsWith(candidate));
18
+ if (!root) {
19
+ continue;
20
+ }
21
+ const relative = file.slice(root.length);
22
+ const first = relative.split('/')[0];
23
+ if (!first || first.includes('.')) {
24
+ continue;
25
+ }
26
+ modules.add(first);
27
+ }
28
+
29
+ return Array.from(modules).sort((left, right) => left.localeCompare(right));
30
+ }
31
+
32
+ function detectCommands(files) {
33
+ const commands = new Set();
34
+ for (const file of files) {
35
+ if (file.startsWith('bin/')) {
36
+ commands.add(path.basename(file, path.extname(file)));
37
+ }
38
+ if (file.startsWith('cmd/')) {
39
+ commands.add(file.split('/')[1] || file);
40
+ }
41
+ }
42
+ return Array.from(commands).sort((left, right) => left.localeCompare(right));
43
+ }
44
+
45
+ function collectTodoHints(projectRoot, files) {
46
+ const hints = [];
47
+ const relevant = files.filter((file) => /\.(js|ts|tsx|py|go|rs|md)$/.test(file)).slice(0, 120);
48
+
49
+ for (const file of relevant) {
50
+ const absolutePath = path.resolve(projectRoot, file);
51
+ let content = '';
52
+ try {
53
+ content = fs.readFileSync(absolutePath, 'utf8');
54
+ } catch {
55
+ continue;
56
+ }
57
+
58
+ const lines = content.split(/\r?\n/);
59
+ for (let i = 0; i < lines.length; i += 1) {
60
+ if (/TODO|FIXME/i.test(lines[i])) {
61
+ hints.push({
62
+ file,
63
+ line: i + 1,
64
+ text: lines[i].trim()
65
+ });
66
+ }
67
+ if (hints.length >= 12) {
68
+ return hints;
69
+ }
70
+ }
71
+ }
72
+
73
+ return hints;
74
+ }
75
+
76
+ function scanProject(projectRoot) {
77
+ const files = walkFiles(projectRoot);
78
+ const languages = detectLanguages(files);
79
+ const testFrameworks = detectTestFrameworks(projectRoot, files);
80
+ const modules = detectModules(files);
81
+ const commands = detectCommands(files);
82
+ const todos = collectTodoHints(projectRoot, files);
83
+
84
+ const implementedFiles = files.filter((file) => /\.(js|ts|tsx|py|go|rs|java|kt|cs)$/.test(file));
85
+ const testFiles = files.filter((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\.|_test\.go$/.test(file));
86
+
87
+ return {
88
+ files,
89
+ languages,
90
+ testFrameworks,
91
+ modules,
92
+ commands,
93
+ todos,
94
+ implementedCount: implementedFiles.length,
95
+ testCount: testFiles.length
96
+ };
97
+ }
98
+
99
+ function toCandidate(text, phase, priority, source = 'default') {
100
+ return {
101
+ id: slugify(`${phase}-${text}`),
102
+ text,
103
+ phase,
104
+ priority,
105
+ checked: false,
106
+ source
107
+ };
108
+ }
109
+
110
+ function buildDefaultCandidates(scan, config) {
111
+ const languageLabel = scan.languages.length > 0 ? scan.languages.join(', ') : 'current stack';
112
+ const candidates = [];
113
+
114
+ const p0 = [
115
+ ...config.phaseTemplates.P0,
116
+ `Document measurable north star metrics for ${languageLabel}`,
117
+ 'Close critical TODO and FIXME items blocking release confidence'
118
+ ];
119
+
120
+ if (scan.testFrameworks.length === 0) {
121
+ p0.push(`Add automated test harness for ${languageLabel}`);
122
+ }
123
+
124
+ for (const item of p0) {
125
+ candidates.push(toCandidate(item, 'P0', 'P0'));
126
+ }
127
+
128
+ const p1 = [
129
+ ...config.phaseTemplates.P1,
130
+ 'Expand feature-level validation and regression checks'
131
+ ];
132
+
133
+ for (const moduleName of scan.modules.slice(0, 5)) {
134
+ p1.push(`Finalize module implementation: ${moduleName}`);
135
+ }
136
+
137
+ for (const commandName of scan.commands.slice(0, 5)) {
138
+ p1.push(`Harden command behavior and error handling: ${commandName}`);
139
+ }
140
+
141
+ for (const item of p1) {
142
+ candidates.push(toCandidate(item, 'P1', 'P1'));
143
+ }
144
+
145
+ const p2 = [
146
+ ...config.phaseTemplates.P2,
147
+ 'Complete release candidate checklist and production readiness review'
148
+ ];
149
+
150
+ for (const item of p2) {
151
+ candidates.push(toCandidate(item, 'P2', 'P2'));
152
+ }
153
+
154
+ for (const hint of scan.todos.slice(0, 5)) {
155
+ candidates.push(toCandidate(`Resolve backlog note in ${hint.file}`, 'P0', 'P0', 'todo-hint'));
156
+ }
157
+
158
+ return candidates;
159
+ }
160
+
161
+ function applyTaskMatchers(scan, config) {
162
+ const candidates = [];
163
+ for (const matcher of config.taskMatchers || []) {
164
+ if (!matcher || !matcher.pattern || !matcher.task) {
165
+ continue;
166
+ }
167
+
168
+ const regex = new RegExp(matcher.pattern, 'i');
169
+ if (!scan.files.some((file) => regex.test(file))) {
170
+ continue;
171
+ }
172
+
173
+ const phase = matcher.phase || matcher.priority || 'P1';
174
+ const priority = matcher.priority || phase;
175
+ candidates.push(toCandidate(matcher.task, phase, priority, 'task-matcher'));
176
+ }
177
+ return candidates;
178
+ }
179
+
180
+ function inferPhase(existingTask) {
181
+ const section = String(existingTask.section || '').toUpperCase();
182
+ if (section.includes('P0')) return 'P0';
183
+ if (section.includes('P1')) return 'P1';
184
+ if (section.includes('P2')) return 'P2';
185
+ return 'P1';
186
+ }
187
+
188
+ function mergeWithExisting(candidates, existingTasks) {
189
+ const matchedExistingIds = new Set();
190
+ const merged = [];
191
+
192
+ for (const candidate of candidates) {
193
+ const match = findBestTaskMatch(candidate, existingTasks);
194
+ if (match) {
195
+ matchedExistingIds.add(match.task.id);
196
+ merged.push({
197
+ ...candidate,
198
+ id: match.task.id,
199
+ checked: match.task.checked
200
+ });
201
+ continue;
202
+ }
203
+
204
+ merged.push(candidate);
205
+ }
206
+
207
+ for (const existing of existingTasks) {
208
+ if (matchedExistingIds.has(existing.id)) {
209
+ continue;
210
+ }
211
+
212
+ const phase = inferPhase(existing);
213
+ merged.push({
214
+ id: existing.id,
215
+ text: existing.text,
216
+ phase,
217
+ priority: phase,
218
+ checked: existing.checked,
219
+ source: 'existing'
220
+ });
221
+ }
222
+
223
+ return dedupeTasks(merged);
224
+ }
225
+
226
+ function groupByPhase(tasks) {
227
+ const groups = { P0: [], P1: [], P2: [] };
228
+ for (const task of tasks) {
229
+ const phase = PHASE_ORDER.includes(task.phase) ? task.phase : 'P2';
230
+ groups[phase].push(task);
231
+ }
232
+
233
+ for (const phase of PHASE_ORDER) {
234
+ groups[phase].sort((left, right) => left.text.localeCompare(right.text));
235
+ }
236
+
237
+ return groups;
238
+ }
239
+
240
+ function taskLine(task) {
241
+ return `- [${task.checked ? 'x' : ' '}] ${task.text} <!-- rs:task=${task.id} -->`;
242
+ }
243
+
244
+ function checkedState(model, id) {
245
+ return Boolean(model.checkedById && model.checkedById[id]);
246
+ }
247
+
248
+ function renderManagedBody(model) {
249
+ const lines = [];
250
+
251
+ lines.push('# Project Roadmap');
252
+ lines.push('');
253
+ lines.push('## Product North Star');
254
+ lines.push(model.northStar);
255
+ lines.push('');
256
+
257
+ lines.push('## Current State');
258
+ lines.push(`- Implemented surface: ${model.currentState.implementedSummary}`);
259
+ lines.push(`- TODO surface: ${model.currentState.todoSummary}`);
260
+ lines.push(`- Detected stacks: ${model.currentState.stackSummary}`);
261
+ lines.push('');
262
+
263
+ lines.push('## Phased Roadmap');
264
+ lines.push('');
265
+ lines.push('### Phase P0 (Critical)');
266
+ for (const task of model.phases.P0) {
267
+ lines.push(taskLine(task));
268
+ }
269
+ lines.push('');
270
+ lines.push('### Phase P1 (Important)');
271
+ for (const task of model.phases.P1) {
272
+ lines.push(taskLine(task));
273
+ }
274
+ lines.push('');
275
+ lines.push('### Phase P2 (Optimization)');
276
+ for (const task of model.phases.P2) {
277
+ lines.push(taskLine(task));
278
+ }
279
+ lines.push('');
280
+
281
+ lines.push('## Release Milestones');
282
+ for (const milestone of model.milestones) {
283
+ const id = `milestone-${slugify(milestone.version)}`;
284
+ lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] ${milestone.version}: ${milestone.goal} <!-- rs:task=${id} -->`);
285
+ }
286
+ lines.push('');
287
+
288
+ lines.push('## Command/Module Breakdown');
289
+ if (model.commandBreakdown.length === 0) {
290
+ const id = 'identify-command-module-boundaries';
291
+ lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] Identify command/module boundaries for the next increment <!-- rs:task=${id} -->`);
292
+ } else {
293
+ for (const item of model.commandBreakdown) {
294
+ const id = `module-${slugify(item)}`;
295
+ lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] ${item} <!-- rs:task=${id} -->`);
296
+ }
297
+ }
298
+ lines.push('');
299
+
300
+ lines.push('## Exit Criteria Per Phase');
301
+ for (const item of model.exitCriteria) {
302
+ const id = `exit-${slugify(item)}`;
303
+ lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] ${item} <!-- rs:task=${id} -->`);
304
+ }
305
+ lines.push('');
306
+
307
+ for (const section of model.customSections) {
308
+ lines.push(`## ${section.title}`);
309
+ for (const line of section.items) {
310
+ lines.push(line);
311
+ }
312
+ lines.push('');
313
+ }
314
+
315
+ lines.push('## Risks and Anti-goals');
316
+ lines.push('### Risks');
317
+ for (const risk of model.risks) {
318
+ const id = `risk-${slugify(risk)}`;
319
+ lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] ${risk} <!-- rs:task=${id} -->`);
320
+ }
321
+ lines.push('');
322
+ lines.push('### Anti-goals');
323
+ for (const antiGoal of model.antiGoals) {
324
+ const id = `anti-goal-${slugify(antiGoal)}`;
325
+ lines.push(`- [${checkedState(model, id) ? 'x' : ' '}] ${antiGoal} <!-- rs:task=${id} -->`);
326
+ }
327
+
328
+ return ensureTrailingNewline(lines.join('\n')).trimEnd();
329
+ }
330
+
331
+ function createModel(scan, tasks, config, customSections, checkedById) {
332
+ const phases = groupByPhase(tasks);
333
+
334
+ const currentState = {
335
+ implementedSummary: `${scan.implementedCount} implementation files detected`,
336
+ todoSummary: `${scan.todos.length} TODO/FIXME markers detected`,
337
+ stackSummary: scan.languages.length > 0 ? scan.languages.join(', ') : 'No language-specific stack detected'
338
+ };
339
+
340
+ const exitCriteria = [
341
+ 'P0: all critical checklist items validated by code/test/artifact evidence',
342
+ 'P1: reliability and regression checks green on the mainline',
343
+ 'P2: release hardening and anti-goal checks completed for v1.0'
344
+ ];
345
+
346
+ const commandBreakdown = [];
347
+ for (const moduleName of scan.modules.slice(0, 8)) {
348
+ commandBreakdown.push(`Module: ${moduleName}`);
349
+ }
350
+ for (const command of scan.commands.slice(0, 8)) {
351
+ commandBreakdown.push(`Command: ${command}`);
352
+ }
353
+
354
+ return createRoadmapModel({
355
+ northStar: 'Ship validated, high-impact increments with deterministic delivery and transparent completion evidence.',
356
+ currentState,
357
+ phases,
358
+ milestones: config.milestones,
359
+ commandBreakdown,
360
+ exitCriteria,
361
+ risks: [
362
+ 'Roadmap drift if checklist state diverges from repository evidence',
363
+ 'Silent regressions when tasks are marked complete without tests',
364
+ 'Scope creep that delays the v1.0 milestone path'
365
+ ],
366
+ antiGoals: [
367
+ 'Do not mark tasks complete without repository evidence',
368
+ 'Do not introduce non-deterministic roadmap formatting',
369
+ 'Do not hide validation failures from roadmap consumers'
370
+ ],
371
+ customSections,
372
+ checkedById
373
+ });
374
+ }
375
+
376
+ function normalizeCandidate(candidate) {
377
+ const phase = candidate.phase || candidate.priority || 'P1';
378
+ const priority = candidate.priority || phase;
379
+ return {
380
+ id: candidate.id || slugify(`${phase}-${candidate.text}`),
381
+ text: candidate.text,
382
+ phase,
383
+ priority,
384
+ checked: Boolean(candidate.checked),
385
+ source: candidate.source || 'plugin'
386
+ };
387
+ }
388
+
389
+ function generateRoadmapDocument(options) {
390
+ const projectRoot = options.projectRoot;
391
+ const config = options.config;
392
+ const plugins = options.plugins || [];
393
+ const existingContent = options.existingContent || '';
394
+
395
+ const scan = scanProject(projectRoot);
396
+ const existing = parseRoadmap(existingContent);
397
+ const existingCheckedById = {};
398
+ for (const task of existing.tasks) {
399
+ existingCheckedById[task.id] = task.checked;
400
+ }
401
+ const existingPhaseTasks = existing.tasks.filter((task) => /^Phase P[0-2]/i.test(String(task.section || '')));
402
+
403
+ const pluginTaskCandidates = collectPluginContributions(plugins, 'registerTaskDetectors', {
404
+ projectRoot,
405
+ config,
406
+ scan
407
+ }).map(normalizeCandidate);
408
+
409
+ const pluginSections = collectPluginContributions(plugins, 'registerSectionGenerators', {
410
+ projectRoot,
411
+ config,
412
+ scan
413
+ }).map((section) => ({
414
+ title: section.title,
415
+ items: section.items || []
416
+ }));
417
+
418
+ const configSections = (config.customSections || []).map((section) => ({
419
+ title: section.title,
420
+ items: section.items || []
421
+ }));
422
+
423
+ const baseCandidates = buildDefaultCandidates(scan, config);
424
+ const matcherCandidates = applyTaskMatchers(scan, config);
425
+ const merged = mergeWithExisting([...baseCandidates, ...matcherCandidates, ...pluginTaskCandidates], existingPhaseTasks);
426
+ const model = createModel(scan, merged, config, [...configSections, ...pluginSections], existingCheckedById);
427
+ const managedBody = renderManagedBody(model);
428
+
429
+ return upsertManagedBlock(existingContent, managedBody);
430
+ }
431
+
432
+ module.exports = {
433
+ generateRoadmapDocument,
434
+ renderManagedBody,
435
+ scanProject
436
+ };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ generator: require('./generator'),
5
+ parser: require('./parser'),
6
+ sync: require('./sync'),
7
+ validator: require('./validator')
8
+ };
package/src/io.js ADDED
@@ -0,0 +1,228 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { ensureTrailingNewline, toPosix } = require('./utils');
6
+
7
+ const DEFAULT_IGNORED_DIRS = new Set([
8
+ '.git',
9
+ '.idea',
10
+ '.vscode',
11
+ '.next',
12
+ '.nuxt',
13
+ '.turbo',
14
+ '.cache',
15
+ 'dist',
16
+ 'build',
17
+ 'coverage',
18
+ 'target',
19
+ 'node_modules',
20
+ '.venv',
21
+ 'venv',
22
+ '__pycache__',
23
+ '.pytest_cache',
24
+ '.mypy_cache',
25
+ '.ruff_cache'
26
+ ]);
27
+
28
+ function readTextIfExists(filePath) {
29
+ try {
30
+ return fs.readFileSync(filePath, 'utf8');
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function ensureDirForFile(filePath) {
37
+ const dir = path.dirname(filePath);
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ }
40
+
41
+ function writeText(filePath, content, options = {}) {
42
+ const next = ensureTrailingNewline(content);
43
+ const before = readTextIfExists(filePath);
44
+ const changed = before == null || before !== next;
45
+
46
+ if (!changed) {
47
+ return {
48
+ changed: false,
49
+ before,
50
+ after: next,
51
+ path: filePath
52
+ };
53
+ }
54
+
55
+ if (!options.dryRun) {
56
+ ensureDirForFile(filePath);
57
+ fs.writeFileSync(filePath, next, 'utf8');
58
+ }
59
+
60
+ return {
61
+ changed: true,
62
+ before,
63
+ after: next,
64
+ path: filePath
65
+ };
66
+ }
67
+
68
+ function walkFiles(rootPath, options = {}) {
69
+ const ignoredDirs = options.ignoredDirs || DEFAULT_IGNORED_DIRS;
70
+ const result = [];
71
+
72
+ function walk(current) {
73
+ const entries = fs.readdirSync(current, { withFileTypes: true });
74
+ entries.sort((left, right) => left.name.localeCompare(right.name));
75
+
76
+ for (const entry of entries) {
77
+ const absolutePath = path.join(current, entry.name);
78
+ const relativePath = toPosix(path.relative(rootPath, absolutePath));
79
+ if (entry.isDirectory()) {
80
+ if (ignoredDirs.has(entry.name)) {
81
+ continue;
82
+ }
83
+ walk(absolutePath);
84
+ continue;
85
+ }
86
+ result.push(relativePath);
87
+ }
88
+ }
89
+
90
+ walk(rootPath);
91
+ return result;
92
+ }
93
+
94
+ function parseJsonIfExists(filePath) {
95
+ const content = readTextIfExists(filePath);
96
+ if (!content) {
97
+ return null;
98
+ }
99
+ try {
100
+ return JSON.parse(content);
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function detectLanguages(files) {
107
+ const languageByExtension = {
108
+ '.js': 'JavaScript',
109
+ '.cjs': 'JavaScript',
110
+ '.mjs': 'JavaScript',
111
+ '.ts': 'TypeScript',
112
+ '.tsx': 'TypeScript',
113
+ '.jsx': 'JavaScript',
114
+ '.py': 'Python',
115
+ '.go': 'Go',
116
+ '.rs': 'Rust',
117
+ '.java': 'Java',
118
+ '.kt': 'Kotlin',
119
+ '.swift': 'Swift',
120
+ '.rb': 'Ruby',
121
+ '.php': 'PHP',
122
+ '.cs': 'C#',
123
+ '.cpp': 'C++',
124
+ '.c': 'C',
125
+ '.h': 'C',
126
+ '.sh': 'Shell'
127
+ };
128
+
129
+ const languages = new Set();
130
+ for (const file of files) {
131
+ const ext = path.extname(file).toLowerCase();
132
+ if (languageByExtension[ext]) {
133
+ languages.add(languageByExtension[ext]);
134
+ }
135
+ }
136
+ return Array.from(languages).sort((left, right) => left.localeCompare(right));
137
+ }
138
+
139
+ function detectTestFrameworks(projectRoot, files) {
140
+ const frameworks = new Set();
141
+
142
+ const packageJson = parseJsonIfExists(path.join(projectRoot, 'package.json'));
143
+ if (packageJson) {
144
+ const scripts = packageJson.scripts || {};
145
+ if (scripts.test) {
146
+ frameworks.add('node-test-script');
147
+ }
148
+ const deps = {
149
+ ...(packageJson.dependencies || {}),
150
+ ...(packageJson.devDependencies || {})
151
+ };
152
+ if (deps.jest) frameworks.add('jest');
153
+ if (deps.vitest) frameworks.add('vitest');
154
+ if (deps.mocha) frameworks.add('mocha');
155
+ if (deps.ava) frameworks.add('ava');
156
+ if (deps.tap) frameworks.add('tap');
157
+ }
158
+
159
+ if (files.some((file) => file.endsWith('pyproject.toml')) || files.some((file) => file.endsWith('pytest.ini'))) {
160
+ frameworks.add('pytest');
161
+ }
162
+ if (files.some((file) => /(^|\/)test_.*\.py$/.test(file)) || files.some((file) => /(^|\/)tests\//.test(file))) {
163
+ frameworks.add('python-tests');
164
+ }
165
+ if (files.some((file) => file.endsWith('go.mod'))) {
166
+ frameworks.add('go');
167
+ }
168
+ if (files.some((file) => file.endsWith('_test.go'))) {
169
+ frameworks.add('go-test');
170
+ }
171
+ if (files.some((file) => file.endsWith('Cargo.toml'))) {
172
+ frameworks.add('rust');
173
+ }
174
+ if (files.some((file) => /(^|\/)tests\//.test(file) && file.endsWith('.rs'))) {
175
+ frameworks.add('cargo-test');
176
+ }
177
+
178
+ if (files.some((file) => /(^|\/)(__tests__|tests)\//.test(file) || /\.test\.|\.spec\./.test(file))) {
179
+ frameworks.add('generic-tests');
180
+ }
181
+
182
+ return Array.from(frameworks).sort((left, right) => left.localeCompare(right));
183
+ }
184
+
185
+ function lineDiff(before, after) {
186
+ const left = (before || '').split(/\r?\n/);
187
+ const right = (after || '').split(/\r?\n/);
188
+ const max = Math.max(left.length, right.length);
189
+ const changes = [];
190
+
191
+ for (let i = 0; i < max; i += 1) {
192
+ const oldLine = left[i] == null ? '' : left[i];
193
+ const newLine = right[i] == null ? '' : right[i];
194
+ if (oldLine !== newLine) {
195
+ changes.push({ index: i + 1, oldLine, newLine });
196
+ }
197
+ if (changes.length >= 20) {
198
+ break;
199
+ }
200
+ }
201
+
202
+ return changes;
203
+ }
204
+
205
+ function printDryRunDiff(filePath, before, after) {
206
+ const changes = lineDiff(before, after);
207
+ console.log(`Dry run: ${filePath}`);
208
+ if (changes.length === 0) {
209
+ console.log('- no line changes');
210
+ return;
211
+ }
212
+
213
+ for (const change of changes) {
214
+ console.log(`L${change.index} - ${change.oldLine}`);
215
+ console.log(`L${change.index} + ${change.newLine}`);
216
+ }
217
+ }
218
+
219
+ module.exports = {
220
+ DEFAULT_IGNORED_DIRS,
221
+ detectLanguages,
222
+ detectTestFrameworks,
223
+ parseJsonIfExists,
224
+ printDryRunDiff,
225
+ readTextIfExists,
226
+ walkFiles,
227
+ writeText
228
+ };