dflow-sdd-ddd 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.
- package/LICENSE +21 -0
- package/README.md +209 -0
- package/bin/dflow.js +72 -0
- package/lib/init.js +1206 -0
- package/package.json +42 -0
- package/templates/core/scaffolding/CLAUDE-md-snippet.md +168 -0
- package/templates/core/scaffolding/Git-principles-gitflow.md +350 -0
- package/templates/core/scaffolding/Git-principles-trunk.md +375 -0
- package/templates/core/scaffolding/_conventions.md +175 -0
- package/templates/core/scaffolding/_overview.md +165 -0
- package/templates/core/scaffolding/architecture-decisions-README.md +34 -0
- package/templates/core/templates/CLAUDE.md +172 -0
- package/templates/core/templates/_index.md +118 -0
- package/templates/core/templates/aggregate-design.md +58 -0
- package/templates/core/templates/behavior.md +62 -0
- package/templates/core/templates/context-definition.md +64 -0
- package/templates/core/templates/context-map.md +25 -0
- package/templates/core/templates/events.md +19 -0
- package/templates/core/templates/glossary.md +15 -0
- package/templates/core/templates/lightweight-spec.md +82 -0
- package/templates/core/templates/models.md +47 -0
- package/templates/core/templates/phase-spec.md +195 -0
- package/templates/core/templates/rules.md +24 -0
- package/templates/core/templates/tech-debt.md +15 -0
- package/templates/webforms/scaffolding/CLAUDE-md-snippet.md +167 -0
- package/templates/webforms/scaffolding/Git-principles-gitflow.md +333 -0
- package/templates/webforms/scaffolding/Git-principles-trunk.md +316 -0
- package/templates/webforms/scaffolding/_conventions.md +139 -0
- package/templates/webforms/scaffolding/_overview.md +109 -0
- package/templates/webforms/templates/CLAUDE.md +157 -0
- package/templates/webforms/templates/_index.md +110 -0
- package/templates/webforms/templates/behavior.md +58 -0
- package/templates/webforms/templates/context-definition.md +57 -0
- package/templates/webforms/templates/context-map.md +25 -0
- package/templates/webforms/templates/glossary.md +15 -0
- package/templates/webforms/templates/lightweight-spec.md +82 -0
- package/templates/webforms/templates/models.md +39 -0
- package/templates/webforms/templates/phase-spec.md +187 -0
- package/templates/webforms/templates/rules.md +24 -0
- package/templates/webforms/templates/tech-debt.md +15 -0
package/lib/init.js
ADDED
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const readline = require('node:readline');
|
|
4
|
+
const { TextDecoder } = require('node:util');
|
|
5
|
+
|
|
6
|
+
const MIN_NODE_VERSION = '22.0.0';
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
8
|
+
const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, 'templates');
|
|
9
|
+
|
|
10
|
+
const PROSE_LANGUAGE_PATTERN =
|
|
11
|
+
/^[A-Za-z]{2,3}(?:-[A-Za-z]{4})?(?:-(?:[A-Za-z]{2}|[0-9]{3}))?(?:-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*$/;
|
|
12
|
+
|
|
13
|
+
const INVALID_PROSE_LANGUAGE_VALUES = new Set([
|
|
14
|
+
'any',
|
|
15
|
+
'auto',
|
|
16
|
+
'detect',
|
|
17
|
+
'skip',
|
|
18
|
+
'later',
|
|
19
|
+
'same as user',
|
|
20
|
+
'same-as-user',
|
|
21
|
+
'traditional chinese',
|
|
22
|
+
'english',
|
|
23
|
+
'japanese',
|
|
24
|
+
'chinese'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
const PROJECT_TYPE_OPTIONS = [
|
|
28
|
+
{
|
|
29
|
+
key: 'greenfield',
|
|
30
|
+
label: 'Greenfield - fresh project adopting Dflow',
|
|
31
|
+
aliases: ['greenfield', 'fresh']
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: 'brownfield',
|
|
35
|
+
label: 'Brownfield - existing codebase adopting Dflow',
|
|
36
|
+
aliases: ['brownfield', 'existing']
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const EDITION_OPTIONS = [
|
|
41
|
+
{
|
|
42
|
+
key: 'core',
|
|
43
|
+
label: 'ASP.NET Core - Clean Architecture + DDD',
|
|
44
|
+
aliases: ['core', 'asp.net core', 'aspnet core']
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'webforms',
|
|
48
|
+
label: 'ASP.NET WebForms - progressive domain extraction',
|
|
49
|
+
aliases: ['webforms', 'asp.net webforms', 'aspnet webforms']
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const PROSE_LANGUAGE_OPTIONS = [
|
|
54
|
+
{
|
|
55
|
+
key: 'zh-TW',
|
|
56
|
+
label: 'zh-TW - Traditional Chinese',
|
|
57
|
+
aliases: ['zh-tw', 'zh_tw']
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: 'en',
|
|
61
|
+
label: 'en - English',
|
|
62
|
+
aliases: ['en']
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
key: 'ja-JP',
|
|
66
|
+
label: 'ja-JP - Japanese',
|
|
67
|
+
aliases: ['ja-jp', 'ja_jp']
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
key: 'custom',
|
|
71
|
+
label: 'Custom BCP-47 tag',
|
|
72
|
+
aliases: ['custom', 'bcp-47', 'bcp47']
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const OPTIONAL_FILE_OPTIONS = [
|
|
77
|
+
{
|
|
78
|
+
key: 'overview',
|
|
79
|
+
label: '_overview.md - system overview',
|
|
80
|
+
aliases: ['overview', '_overview.md']
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: 'git-trunk',
|
|
84
|
+
label: 'Git principles - trunk-based',
|
|
85
|
+
aliases: ['git principles - trunk-based', 'trunk', 'trunk-based']
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: 'git-flow',
|
|
89
|
+
label: 'Git principles - Git Flow',
|
|
90
|
+
aliases: ['git principles - git flow', 'git flow', 'gitflow']
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'claude',
|
|
94
|
+
label: 'CLAUDE.md snippet / project AI guide',
|
|
95
|
+
aliases: ['claude', 'claude.md']
|
|
96
|
+
}
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
const DEFERRED_COMMON = [
|
|
100
|
+
{
|
|
101
|
+
relativePath: 'dflow/specs/domain/{context}/behavior.md',
|
|
102
|
+
reason: 'Needs a real bounded context; created later by feature completion or baseline capture.'
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
relativePath: 'dflow/specs/domain/{context}/models.md',
|
|
106
|
+
reason: 'Needs a real bounded context.'
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
relativePath: 'dflow/specs/domain/{context}/rules.md',
|
|
110
|
+
reason: 'Needs a real bounded context.'
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
relativePath: 'dflow/specs/architecture/decisions/ADR-*.md',
|
|
114
|
+
reason: 'ADRs are created when a real architecture decision exists.'
|
|
115
|
+
}
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
class InitError extends Error {
|
|
119
|
+
constructor(message, exitCode = 1) {
|
|
120
|
+
super(message);
|
|
121
|
+
this.name = 'InitError';
|
|
122
|
+
this.exitCode = exitCode;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
class UserAbort extends Error {
|
|
127
|
+
constructor(message = 'Dflow init aborted.') {
|
|
128
|
+
super(message);
|
|
129
|
+
this.name = 'UserAbort';
|
|
130
|
+
this.exitCode = 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
class WritePhaseError extends Error {
|
|
135
|
+
constructor(message, result) {
|
|
136
|
+
super(message);
|
|
137
|
+
this.name = 'WritePhaseError';
|
|
138
|
+
this.exitCode = 1;
|
|
139
|
+
this.result = result;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function runInit(options = {}) {
|
|
144
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
145
|
+
const stdin = options.stdin || process.stdin;
|
|
146
|
+
const stdout = options.stdout || process.stdout;
|
|
147
|
+
const stderr = options.stderr || process.stderr;
|
|
148
|
+
|
|
149
|
+
let rl;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
rl = readline.createInterface({
|
|
153
|
+
input: stdin,
|
|
154
|
+
output: stdout,
|
|
155
|
+
terminal: Boolean(stdin.isTTY && stdout.isTTY)
|
|
156
|
+
});
|
|
157
|
+
rl._dflowOutput = stdout;
|
|
158
|
+
getLinePrompter(rl);
|
|
159
|
+
|
|
160
|
+
const preflight = await runPreflight(cwd);
|
|
161
|
+
writeWarnings(stderr, preflight.warnings);
|
|
162
|
+
|
|
163
|
+
const detection = await detectProjectSignals(cwd);
|
|
164
|
+
const answers = await promptForAnswers(rl, stdout, stderr, detection);
|
|
165
|
+
const warnings = [...preflight.warnings, ...buildDetectionWarnings(answers, detection)];
|
|
166
|
+
const plan = await buildFilePlan(cwd, answers);
|
|
167
|
+
|
|
168
|
+
renderPreview(stdout, plan, warnings);
|
|
169
|
+
const confirmed = await askConfirmation(rl, 'Create these files? (y/N) ');
|
|
170
|
+
|
|
171
|
+
if (!confirmed) {
|
|
172
|
+
throw new UserAbort();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
rl.close();
|
|
176
|
+
rl = undefined;
|
|
177
|
+
|
|
178
|
+
const result = await writeFilePlan(cwd, plan);
|
|
179
|
+
result.warnings.unshift(...warnings);
|
|
180
|
+
result.warnings.push(...collectUnresolvedPlaceholderWarnings(plan, result.created));
|
|
181
|
+
|
|
182
|
+
printResultReport(stdout, result, plan.deferred);
|
|
183
|
+
printNextSteps(stdout);
|
|
184
|
+
return 0;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (rl) {
|
|
187
|
+
rl.close();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (error instanceof UserAbort) {
|
|
191
|
+
stdout.write(`${error.message}\n`);
|
|
192
|
+
return error.exitCode;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (error instanceof WritePhaseError) {
|
|
196
|
+
stderr.write(`${error.message}\n`);
|
|
197
|
+
stderr.write('Files already created were kept; clean up partial output manually if needed.\n');
|
|
198
|
+
if (error.result) {
|
|
199
|
+
printResultReport(stdout, error.result, []);
|
|
200
|
+
}
|
|
201
|
+
return error.exitCode;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (error instanceof InitError) {
|
|
205
|
+
stderr.write(`${error.message}\n`);
|
|
206
|
+
return error.exitCode;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
stderr.write(`${error && error.message ? error.message : error}\n`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function runPreflight(cwd) {
|
|
215
|
+
const warnings = [];
|
|
216
|
+
const dflowSpecsPath = path.join(cwd, 'dflow', 'specs');
|
|
217
|
+
|
|
218
|
+
if (await pathExists(dflowSpecsPath)) {
|
|
219
|
+
if (await containsInitializedContent(dflowSpecsPath)) {
|
|
220
|
+
throw new InitError('Dflow already initialized at dflow/specs/.');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
warnings.push('Found empty dflow/specs/. Continuing because no initialized files were found.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const legacySpecsPath = path.join(cwd, 'specs');
|
|
227
|
+
if ((await pathExists(legacySpecsPath)) && (await containsInitializedContent(legacySpecsPath))) {
|
|
228
|
+
warnings.push(
|
|
229
|
+
'Detected legacy specs/. Dflow V1 will not migrate or modify it; new files will be created under dflow/specs/.'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await assertWritableProjectRoot(cwd);
|
|
234
|
+
|
|
235
|
+
if (compareVersions(process.versions.node, MIN_NODE_VERSION) < 0) {
|
|
236
|
+
throw new InitError(`Dflow init requires Node.js ${MIN_NODE_VERSION}+.`, 1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { warnings };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function assertWritableProjectRoot(cwd) {
|
|
243
|
+
const testPath = path.join(cwd, `.dflow-init-write-test-${process.pid}-${Date.now()}`);
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await fs.writeFile(testPath, '', { flag: 'wx' });
|
|
247
|
+
} catch {
|
|
248
|
+
throw new InitError(`Project root is not writable: ${cwd}`);
|
|
249
|
+
} finally {
|
|
250
|
+
await fs.unlink(testPath).catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function containsInitializedContent(directory) {
|
|
255
|
+
let entries;
|
|
256
|
+
try {
|
|
257
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error.code === 'ENOENT') {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const entry of entries) {
|
|
266
|
+
const absolute = path.join(directory, entry.name);
|
|
267
|
+
if (!entry.isDirectory()) {
|
|
268
|
+
return true;
|
|
269
|
+
}
|
|
270
|
+
if (await containsInitializedContent(absolute)) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function detectProjectSignals(cwd) {
|
|
279
|
+
const files = await collectProjectFiles(cwd, 4, 5000);
|
|
280
|
+
const baseNames = new Set(files.map((file) => path.basename(file).toLowerCase()));
|
|
281
|
+
const relNames = new Set(files.map((file) => normalizePath(file).toLowerCase()));
|
|
282
|
+
|
|
283
|
+
const hasSourceTree =
|
|
284
|
+
(await pathExists(path.join(cwd, 'src'))) ||
|
|
285
|
+
Array.from(baseNames).some((name) =>
|
|
286
|
+
name.endsWith('.sln') ||
|
|
287
|
+
name.endsWith('.csproj') ||
|
|
288
|
+
name === 'program.cs' ||
|
|
289
|
+
name === 'startup.cs' ||
|
|
290
|
+
name === 'package.json'
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const hasWebFormsFiles = Array.from(baseNames).some((name) =>
|
|
294
|
+
name.endsWith('.aspx') || name.endsWith('.ascx') || name.endsWith('.master')
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const csprojFiles = files.filter((file) => file.toLowerCase().endsWith('.csproj'));
|
|
298
|
+
let coreSignal = false;
|
|
299
|
+
let webFormsSignal = hasWebFormsFiles;
|
|
300
|
+
|
|
301
|
+
for (const relativeFile of csprojFiles) {
|
|
302
|
+
const absolute = path.join(cwd, relativeFile);
|
|
303
|
+
const content = await fs.readFile(absolute, 'utf8').catch(() => '');
|
|
304
|
+
if (/Microsoft\.NET\.Sdk\.Web/i.test(content) && /<TargetFramework>\s*net(?:[6-9]|[1-9][0-9])\./i.test(content)) {
|
|
305
|
+
coreSignal = true;
|
|
306
|
+
}
|
|
307
|
+
if (/System\.Web/i.test(content)) {
|
|
308
|
+
webFormsSignal = true;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let editionHint = null;
|
|
313
|
+
if (coreSignal && !webFormsSignal) {
|
|
314
|
+
editionHint = 'core';
|
|
315
|
+
} else if (webFormsSignal && !coreSignal) {
|
|
316
|
+
editionHint = 'webforms';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
hasSourceTree: hasSourceTree || relNames.has('src'),
|
|
321
|
+
editionHint
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function collectProjectFiles(cwd, maxDepth, maxFiles) {
|
|
326
|
+
const results = [];
|
|
327
|
+
const ignored = new Set(['.git', 'node_modules', 'bin', 'obj', 'dflow']);
|
|
328
|
+
|
|
329
|
+
async function walk(directory, depth, prefix) {
|
|
330
|
+
if (depth > maxDepth || results.length >= maxFiles) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let entries;
|
|
335
|
+
try {
|
|
336
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
337
|
+
} catch {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const entry of entries) {
|
|
342
|
+
if (results.length >= maxFiles) {
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (ignored.has(entry.name)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const relative = prefix ? path.join(prefix, entry.name) : entry.name;
|
|
351
|
+
const absolute = path.join(directory, entry.name);
|
|
352
|
+
|
|
353
|
+
if (entry.isDirectory()) {
|
|
354
|
+
await walk(absolute, depth + 1, relative);
|
|
355
|
+
} else {
|
|
356
|
+
results.push(relative);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await walk(cwd, 0, '');
|
|
362
|
+
return results;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function buildDetectionWarnings(answers, detection) {
|
|
366
|
+
const warnings = [];
|
|
367
|
+
|
|
368
|
+
if (answers.projectType === 'greenfield' && detection.hasSourceTree) {
|
|
369
|
+
warnings.push('Warning: source-tree signals already exist, but project type is Greenfield. Continuing with your selected project type.');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (detection.editionHint && answers.edition !== detection.editionHint) {
|
|
373
|
+
warnings.push(
|
|
374
|
+
`Warning: project signals look like ${formatEdition(detection.editionHint)}, but selected edition is ${formatEdition(answers.edition)}. Continuing with your selected edition.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return warnings;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function promptForAnswers(rl, stdout, stderr, detection) {
|
|
382
|
+
const projectTypeDefault = detection.hasSourceTree ? null : 'greenfield';
|
|
383
|
+
const projectType = await askSelect(rl, stdout, stderr, {
|
|
384
|
+
id: 'Q1',
|
|
385
|
+
question: 'What kind of project is this?',
|
|
386
|
+
options: PROJECT_TYPE_OPTIONS,
|
|
387
|
+
defaultKey: projectTypeDefault
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const edition = await askSelect(rl, stdout, stderr, {
|
|
391
|
+
id: 'Q2',
|
|
392
|
+
question: 'Which Dflow edition should initialize this project?',
|
|
393
|
+
options: EDITION_OPTIONS,
|
|
394
|
+
defaultKey: detection.editionHint
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const techStackSummary = await askText(rl, stderr, {
|
|
398
|
+
id: 'Q3',
|
|
399
|
+
question: 'Confirm the main tech stack details for placeholders.',
|
|
400
|
+
required: true,
|
|
401
|
+
maxLength: 1000,
|
|
402
|
+
allowUnknown: true
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const migrationContext = await askText(rl, stderr, {
|
|
406
|
+
id: 'Q4',
|
|
407
|
+
question: 'Is there migration or legacy context Dflow should note?',
|
|
408
|
+
required: false,
|
|
409
|
+
maxLength: 1000,
|
|
410
|
+
defaultValue: 'none'
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const proseLanguageSelection = await askSelect(rl, stdout, stderr, {
|
|
414
|
+
id: 'Q5',
|
|
415
|
+
question: 'Project prose language for generated spec content?',
|
|
416
|
+
options: PROSE_LANGUAGE_OPTIONS,
|
|
417
|
+
defaultKey: null
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
let proseLanguage = proseLanguageSelection;
|
|
421
|
+
if (proseLanguageSelection === 'custom') {
|
|
422
|
+
proseLanguage = await askCustomProseLanguage(rl, stderr);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const optionalFiles = await askOptionalFiles(rl, stdout, stderr);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
projectType,
|
|
429
|
+
edition,
|
|
430
|
+
techStackSummary,
|
|
431
|
+
migrationContext,
|
|
432
|
+
proseLanguage,
|
|
433
|
+
optionalFiles
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function askSelect(rl, stdout, stderr, config) {
|
|
438
|
+
let failedAttempts = 0;
|
|
439
|
+
|
|
440
|
+
while (true) {
|
|
441
|
+
stdout.write(`\n${config.question}\n`);
|
|
442
|
+
config.options.forEach((option, index) => {
|
|
443
|
+
const defaultMarker = option.key === config.defaultKey ? ' (default)' : '';
|
|
444
|
+
stdout.write(` ${index + 1}. ${option.label}${defaultMarker}\n`);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const answer = await askLine(rl, `Enter choice [1-${config.options.length}]${config.defaultKey ? ` (default: ${displayOptionNumber(config.options, config.defaultKey)})` : ''}: `);
|
|
448
|
+
const selected = parseSelectAnswer(answer, config.options, config.defaultKey);
|
|
449
|
+
|
|
450
|
+
if (selected) {
|
|
451
|
+
return selected;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
failedAttempts += 1;
|
|
455
|
+
if (failedAttempts >= 3) {
|
|
456
|
+
throw new InitError(`Too many invalid attempts for ${config.id}. Dflow init aborted.`);
|
|
457
|
+
}
|
|
458
|
+
stderr.write(`Invalid selection. Choose one of the listed options. (${3 - failedAttempts} attempts left)\n`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function askText(rl, stderr, config) {
|
|
463
|
+
let failedAttempts = 0;
|
|
464
|
+
|
|
465
|
+
while (true) {
|
|
466
|
+
const suffix = config.defaultValue ? ` (default: ${config.defaultValue})` : '';
|
|
467
|
+
const answer = (await askLine(rl, `\n${config.question}${suffix}\n> `)).trim();
|
|
468
|
+
const value = answer || config.defaultValue || '';
|
|
469
|
+
|
|
470
|
+
if (config.required && !value) {
|
|
471
|
+
failedAttempts += 1;
|
|
472
|
+
if (failedAttempts >= 3) {
|
|
473
|
+
throw new InitError(`Too many invalid attempts for ${config.id}. Dflow init aborted.`);
|
|
474
|
+
}
|
|
475
|
+
stderr.write(`This answer is required. (${3 - failedAttempts} attempts left)\n`);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (config.maxLength && value.length > config.maxLength) {
|
|
480
|
+
failedAttempts += 1;
|
|
481
|
+
if (failedAttempts >= 3) {
|
|
482
|
+
throw new InitError(`Too many invalid attempts for ${config.id}. Dflow init aborted.`);
|
|
483
|
+
}
|
|
484
|
+
stderr.write(`Answer is too long. Use ${config.maxLength} characters or fewer. (${3 - failedAttempts} attempts left)\n`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (config.allowUnknown && value.toLowerCase() === 'unknown') {
|
|
489
|
+
return 'unknown';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return value || 'none';
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function askCustomProseLanguage(rl, stderr) {
|
|
497
|
+
let failedAttempts = 0;
|
|
498
|
+
|
|
499
|
+
while (true) {
|
|
500
|
+
const value = (await askLine(rl, '\nEnter the explicit BCP-47 language tag for project prose.\n> ')).trim();
|
|
501
|
+
const validation = validateProseLanguage(value);
|
|
502
|
+
|
|
503
|
+
if (validation.valid) {
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
failedAttempts += 1;
|
|
508
|
+
if (failedAttempts >= 3) {
|
|
509
|
+
throw new InitError('Too many invalid attempts for Q5a. Dflow init aborted.');
|
|
510
|
+
}
|
|
511
|
+
stderr.write(`${validation.message} (${3 - failedAttempts} attempts left)\n`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function askOptionalFiles(rl, stdout, stderr) {
|
|
516
|
+
let failedAttempts = 0;
|
|
517
|
+
|
|
518
|
+
while (true) {
|
|
519
|
+
stdout.write('\nWhich optional starter files should Dflow seed?\n');
|
|
520
|
+
OPTIONAL_FILE_OPTIONS.forEach((option, index) => {
|
|
521
|
+
const defaultMarker = option.key === 'overview' || option.key === 'git-trunk' ? ' (recommended)' : '';
|
|
522
|
+
stdout.write(` ${index + 1}. ${option.label}${defaultMarker}\n`);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const answer = await askLine(rl, 'Enter comma-separated choices, "none", or press Enter for recommended [1,2]: ');
|
|
526
|
+
const parsed = parseMultiselectAnswer(answer, OPTIONAL_FILE_OPTIONS, ['overview', 'git-trunk']);
|
|
527
|
+
|
|
528
|
+
if (!parsed.valid) {
|
|
529
|
+
failedAttempts += 1;
|
|
530
|
+
if (failedAttempts >= 3) {
|
|
531
|
+
throw new InitError('Too many invalid attempts for Q6. Dflow init aborted.');
|
|
532
|
+
}
|
|
533
|
+
stderr.write(`${parsed.message} (${3 - failedAttempts} attempts left)\n`);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (parsed.values.includes('git-trunk') && parsed.values.includes('git-flow')) {
|
|
538
|
+
const keepBoth = await askConfirmation(
|
|
539
|
+
rl,
|
|
540
|
+
'You selected both Git principles templates. Most projects choose one. Keep both? (y/N) '
|
|
541
|
+
);
|
|
542
|
+
if (!keepBoth) {
|
|
543
|
+
failedAttempts = 0;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return parsed.values;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function askLine(rl, prompt) {
|
|
553
|
+
return getLinePrompter(rl).ask(prompt);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function getLinePrompter(rl) {
|
|
557
|
+
if (!rl._dflowPrompter) {
|
|
558
|
+
rl._dflowPrompter = new LinePrompter(rl);
|
|
559
|
+
}
|
|
560
|
+
return rl._dflowPrompter;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
class LinePrompter {
|
|
564
|
+
constructor(rl) {
|
|
565
|
+
this.rl = rl;
|
|
566
|
+
this.queue = [];
|
|
567
|
+
this.waiting = [];
|
|
568
|
+
this.closed = false;
|
|
569
|
+
|
|
570
|
+
if (rl.input && typeof rl.input.resume === 'function') {
|
|
571
|
+
rl.input.resume();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
rl.on('line', (line) => {
|
|
575
|
+
const waiter = this.waiting.shift();
|
|
576
|
+
if (waiter) {
|
|
577
|
+
waiter.resolve(line);
|
|
578
|
+
} else {
|
|
579
|
+
this.queue.push(line);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
rl.on('close', () => {
|
|
584
|
+
this.closed = true;
|
|
585
|
+
while (this.waiting.length > 0) {
|
|
586
|
+
this.waiting.shift().reject(new UserAbort());
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
rl.on('SIGINT', () => {
|
|
591
|
+
while (this.waiting.length > 0) {
|
|
592
|
+
this.waiting.shift().reject(new UserAbort());
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
ask(prompt) {
|
|
598
|
+
if (prompt) {
|
|
599
|
+
this.rl._dflowOutput.write(prompt);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (this.queue.length > 0) {
|
|
603
|
+
return Promise.resolve(this.queue.shift());
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (this.closed) {
|
|
607
|
+
return Promise.reject(new UserAbort());
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return new Promise((resolve, reject) => {
|
|
611
|
+
const keepAlive = setInterval(() => {}, 2147483647);
|
|
612
|
+
this.waiting.push({
|
|
613
|
+
resolve: (line) => {
|
|
614
|
+
clearInterval(keepAlive);
|
|
615
|
+
resolve(line);
|
|
616
|
+
},
|
|
617
|
+
reject: (error) => {
|
|
618
|
+
clearInterval(keepAlive);
|
|
619
|
+
reject(error);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function askConfirmation(rl, prompt) {
|
|
627
|
+
const answer = (await askLine(rl, prompt)).trim().toLowerCase();
|
|
628
|
+
return answer === 'y' || answer === 'yes';
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function parseSelectAnswer(answer, options, defaultKey) {
|
|
632
|
+
const trimmed = answer.trim();
|
|
633
|
+
if (!trimmed && defaultKey) {
|
|
634
|
+
return defaultKey;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const numeric = Number(trimmed);
|
|
638
|
+
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= options.length) {
|
|
639
|
+
return options[numeric - 1].key;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const normalized = normalizeAnswer(trimmed);
|
|
643
|
+
for (const option of options) {
|
|
644
|
+
if (
|
|
645
|
+
normalizeAnswer(option.key) === normalized ||
|
|
646
|
+
normalizeAnswer(option.label) === normalized ||
|
|
647
|
+
option.aliases.some((alias) => normalizeAnswer(alias) === normalized)
|
|
648
|
+
) {
|
|
649
|
+
return option.key;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function parseMultiselectAnswer(answer, options, defaultKeys) {
|
|
657
|
+
const trimmed = answer.trim();
|
|
658
|
+
if (!trimmed) {
|
|
659
|
+
return { valid: true, values: [...defaultKeys] };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (normalizeAnswer(trimmed) === 'none') {
|
|
663
|
+
return { valid: true, values: [] };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const tokens = trimmed.includes(',') ? trimmed.split(',') : trimmed.split(/\s+/);
|
|
667
|
+
const selected = [];
|
|
668
|
+
|
|
669
|
+
for (const token of tokens) {
|
|
670
|
+
const selectedKey = parseSelectAnswer(token, options, null);
|
|
671
|
+
if (!selectedKey) {
|
|
672
|
+
return { valid: false, message: `Invalid optional file selection: ${token}` };
|
|
673
|
+
}
|
|
674
|
+
if (!selected.includes(selectedKey)) {
|
|
675
|
+
selected.push(selectedKey);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return { valid: true, values: selected };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function validateProseLanguage(value) {
|
|
683
|
+
const trimmed = String(value || '').trim();
|
|
684
|
+
const normalized = normalizeAnswer(trimmed);
|
|
685
|
+
|
|
686
|
+
if (!trimmed) {
|
|
687
|
+
return { valid: false, message: 'Prose language is required.' };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (INVALID_PROSE_LANGUAGE_VALUES.has(normalized)) {
|
|
691
|
+
return { valid: false, message: `Prose language must be an explicit BCP-47 tag, not "${trimmed}".` };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (!PROSE_LANGUAGE_PATTERN.test(trimmed)) {
|
|
695
|
+
return { valid: false, message: `Invalid BCP-47 language tag: ${trimmed}` };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return { valid: true };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function buildFilePlan(cwd, answers) {
|
|
702
|
+
const substitution = buildSubstitutionMap(cwd, answers);
|
|
703
|
+
const items = [];
|
|
704
|
+
|
|
705
|
+
const addGenerated = (relativePath, notes) => {
|
|
706
|
+
items.push({
|
|
707
|
+
relativePath,
|
|
708
|
+
source: 'generated empty file',
|
|
709
|
+
notes,
|
|
710
|
+
content: ''
|
|
711
|
+
});
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const addTemplate = async (relativePath, sourceRel, notes, options = {}) => {
|
|
715
|
+
let content = await readPackagedTemplate(answers.edition, sourceRel);
|
|
716
|
+
if (options.extractClaudeSnippetBody) {
|
|
717
|
+
content = extractClaudeSnippetBody(content, sourceRel);
|
|
718
|
+
}
|
|
719
|
+
content = substitutePlaceholders(content, substitution);
|
|
720
|
+
if (options.injectProseLanguage) {
|
|
721
|
+
content = ensureProseLanguageSection(content, answers.proseLanguage);
|
|
722
|
+
}
|
|
723
|
+
items.push({
|
|
724
|
+
relativePath,
|
|
725
|
+
source: `packaged:${answers.edition}/${sourceRel}`,
|
|
726
|
+
notes,
|
|
727
|
+
content
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
addGenerated('dflow/specs/features/active/.gitkeep', 'mandatory');
|
|
732
|
+
addGenerated('dflow/specs/features/completed/.gitkeep', 'mandatory');
|
|
733
|
+
addGenerated('dflow/specs/features/backlog/.gitkeep', 'mandatory');
|
|
734
|
+
|
|
735
|
+
await addTemplate('dflow/specs/shared/_conventions.md', 'scaffolding/_conventions.md', 'mandatory, includes Prose Language', {
|
|
736
|
+
injectProseLanguage: true
|
|
737
|
+
});
|
|
738
|
+
await addTemplate('dflow/specs/domain/glossary.md', 'templates/glossary.md', 'mandatory');
|
|
739
|
+
|
|
740
|
+
if (answers.edition === 'core') {
|
|
741
|
+
await addTemplate('dflow/specs/domain/context-map.md', 'templates/context-map.md', 'mandatory');
|
|
742
|
+
await addTemplate('dflow/specs/architecture/tech-debt.md', 'templates/tech-debt.md', 'mandatory');
|
|
743
|
+
await addTemplate(
|
|
744
|
+
'dflow/specs/architecture/decisions/README.md',
|
|
745
|
+
'scaffolding/architecture-decisions-README.md',
|
|
746
|
+
'mandatory'
|
|
747
|
+
);
|
|
748
|
+
} else {
|
|
749
|
+
await addTemplate('dflow/specs/migration/tech-debt.md', 'templates/tech-debt.md', 'mandatory');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (answers.optionalFiles.includes('overview')) {
|
|
753
|
+
await addTemplate('dflow/specs/shared/_overview.md', 'scaffolding/_overview.md', 'selected');
|
|
754
|
+
}
|
|
755
|
+
if (answers.optionalFiles.includes('git-trunk')) {
|
|
756
|
+
await addTemplate('dflow/specs/shared/Git-principles-trunk.md', 'scaffolding/Git-principles-trunk.md', 'selected');
|
|
757
|
+
}
|
|
758
|
+
if (answers.optionalFiles.includes('git-flow')) {
|
|
759
|
+
await addTemplate('dflow/specs/shared/Git-principles-gitflow.md', 'scaffolding/Git-principles-gitflow.md', 'selected');
|
|
760
|
+
}
|
|
761
|
+
if (answers.optionalFiles.includes('claude')) {
|
|
762
|
+
const rootClaudePath = path.join(cwd, 'CLAUDE.md');
|
|
763
|
+
if (await pathExists(rootClaudePath)) {
|
|
764
|
+
await addTemplate('dflow/specs/shared/CLAUDE-md-snippet.md', 'scaffolding/CLAUDE-md-snippet.md', 'selected, root CLAUDE.md already exists');
|
|
765
|
+
} else {
|
|
766
|
+
await addTemplate('CLAUDE.md', 'scaffolding/CLAUDE-md-snippet.md', 'selected, snippet body only', {
|
|
767
|
+
extractClaudeSnippetBody: true
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
for (const item of items) {
|
|
773
|
+
const absolute = path.join(cwd, item.relativePath);
|
|
774
|
+
item.action = (await pathExists(absolute)) ? 'skip' : 'create';
|
|
775
|
+
if (item.action === 'skip') {
|
|
776
|
+
item.notes = item.notes ? `${item.notes}, already exists` : 'already exists';
|
|
777
|
+
}
|
|
778
|
+
item.size = Buffer.byteLength(item.content, 'utf8');
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
return {
|
|
782
|
+
items,
|
|
783
|
+
deferred: buildDeferredItems(answers.edition),
|
|
784
|
+
unresolvedInitPlaceholders: Array.from(substitution.entries())
|
|
785
|
+
.filter(([placeholder, value]) => placeholder === value)
|
|
786
|
+
.map(([placeholder]) => placeholder)
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function buildDeferredItems(edition) {
|
|
791
|
+
const deferred = [...DEFERRED_COMMON];
|
|
792
|
+
if (edition === 'core') {
|
|
793
|
+
deferred.splice(3, 0, {
|
|
794
|
+
relativePath: 'dflow/specs/domain/{context}/events.md',
|
|
795
|
+
reason: 'Core only, but still needs a real bounded context.'
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return deferred;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function readPackagedTemplate(edition, sourceRel) {
|
|
802
|
+
const templatePath = path.join(TEMPLATE_ROOT, edition, sourceRel);
|
|
803
|
+
const normalizedTemplateRoot = path.resolve(TEMPLATE_ROOT, edition);
|
|
804
|
+
const normalizedTemplatePath = path.resolve(templatePath);
|
|
805
|
+
|
|
806
|
+
if (!normalizedTemplatePath.startsWith(`${normalizedTemplateRoot}${path.sep}`)) {
|
|
807
|
+
throw new InitError(`Internal error: packaged template not found: templates/${edition}/${sourceRel}`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let buffer;
|
|
811
|
+
try {
|
|
812
|
+
buffer = await fs.readFile(normalizedTemplatePath);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
if (error.code === 'ENOENT') {
|
|
815
|
+
throw new InitError(`Internal error: packaged template not found: templates/${edition}/${sourceRel}`);
|
|
816
|
+
}
|
|
817
|
+
throw new InitError(`Internal error: cannot read packaged template: templates/${edition}/${sourceRel}`);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
|
822
|
+
} catch {
|
|
823
|
+
throw new InitError(`Internal error: invalid UTF-8 packaged template: templates/${edition}/${sourceRel}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function buildSubstitutionMap(cwd, answers) {
|
|
828
|
+
const extracted = extractTechStackPlaceholders(answers.techStackSummary);
|
|
829
|
+
const gitSelection = answers.optionalFiles.filter((key) => key === 'git-trunk' || key === 'git-flow');
|
|
830
|
+
const gitStyle = gitSelection.length === 1 ? (gitSelection[0] === 'git-trunk' ? 'trunk' : 'gitflow') : null;
|
|
831
|
+
const systemName = path.basename(cwd);
|
|
832
|
+
|
|
833
|
+
return new Map([
|
|
834
|
+
['{YYYY-MM-DD}', currentLocalDate()],
|
|
835
|
+
['{System Name}', systemName],
|
|
836
|
+
['{系統名稱}', systemName],
|
|
837
|
+
['{project-type}', answers.projectType],
|
|
838
|
+
['{edition}', answers.edition],
|
|
839
|
+
['{tech-stack-summary}', answers.techStackSummary],
|
|
840
|
+
['{migration-context}', answers.migrationContext],
|
|
841
|
+
['{prose-language}', answers.proseLanguage],
|
|
842
|
+
['{ASP.NET Core version}', extracted.aspNetCoreVersion || '{ASP.NET Core version}'],
|
|
843
|
+
['{EF Core version}', extracted.efCoreVersion || '{EF Core version}'],
|
|
844
|
+
['{MediatR version}', extracted.mediatRVersion || '{MediatR version}'],
|
|
845
|
+
['{Test framework}', extracted.testFramework || '{Test framework}'],
|
|
846
|
+
['{ASP.NET WebForms version}', extracted.webFormsVersion || '{ASP.NET WebForms version}'],
|
|
847
|
+
['{.NET Framework version}', extracted.dotNetFrameworkVersion || '{.NET Framework version}'],
|
|
848
|
+
['{ORM / Data Access}', extracted.ormDataAccess || '{ORM / Data Access}'],
|
|
849
|
+
['{gitflow|trunk}', gitStyle || '{gitflow|trunk}']
|
|
850
|
+
]);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function substitutePlaceholders(content, substitution) {
|
|
854
|
+
let result = content;
|
|
855
|
+
for (const [placeholder, value] of substitution.entries()) {
|
|
856
|
+
result = result.split(placeholder).join(value);
|
|
857
|
+
}
|
|
858
|
+
return result;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function extractTechStackPlaceholders(text) {
|
|
862
|
+
if (!text || text.toLowerCase() === 'unknown') {
|
|
863
|
+
return {};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
aspNetCoreVersion: firstMatch(text, /\bASP\.?NET\s+Core\s*[0-9]+(?:\.[0-9]+)?\b/i),
|
|
868
|
+
efCoreVersion: firstMatch(text, /\b(?:EF\s+Core|Entity\s+Framework\s+Core)\s*[0-9]+(?:\.[0-9]+)?\b/i),
|
|
869
|
+
mediatRVersion: firstMatch(text, /\bMediatR\s*[0-9]+(?:\.[0-9]+)?\b/i),
|
|
870
|
+
testFramework: extractTestFramework(text),
|
|
871
|
+
webFormsVersion: firstMatch(text, /\b(?:ASP\.?NET\s+WebForms|WebForms)(?:\s*[0-9]+(?:\.[0-9]+)?)?\b/i),
|
|
872
|
+
dotNetFrameworkVersion: firstMatch(text, /\b\.NET\s+Framework\s*[0-9]+(?:\.[0-9]+)?\b/i),
|
|
873
|
+
ormDataAccess: firstMatch(text, /\b(?:EF6|Entity\s+Framework\s+6|Dapper|ADO\.NET)\b/i)
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function firstMatch(text, regex) {
|
|
878
|
+
const match = text.match(regex);
|
|
879
|
+
return match ? match[0] : null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function extractTestFramework(text) {
|
|
883
|
+
if (/\bxUnit\b/i.test(text)) {
|
|
884
|
+
return 'xUnit';
|
|
885
|
+
}
|
|
886
|
+
if (/\bNUnit\b/i.test(text)) {
|
|
887
|
+
return 'NUnit';
|
|
888
|
+
}
|
|
889
|
+
if (/\bMSTest\b/i.test(text)) {
|
|
890
|
+
return 'MSTest';
|
|
891
|
+
}
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function extractClaudeSnippetBody(content, sourceRel) {
|
|
896
|
+
const match = content.match(/## Snippet to merge into `CLAUDE\.md`[\s\S]*?```markdown\r?\n([\s\S]*?)\r?\n```/);
|
|
897
|
+
if (!match) {
|
|
898
|
+
throw new InitError(`Internal error: CLAUDE.md snippet body not found in packaged template: ${sourceRel}`);
|
|
899
|
+
}
|
|
900
|
+
return `${match[1].trimEnd()}\n`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function ensureProseLanguageSection(content, proseLanguage) {
|
|
904
|
+
const section = buildProseLanguageSection(proseLanguage);
|
|
905
|
+
let stripped = stripProseLanguageSections(content);
|
|
906
|
+
stripped = stripped.replace(/\n{3,}/g, '\n\n');
|
|
907
|
+
|
|
908
|
+
const markerMatch = stripped.match(/^## Filling the Templates/m);
|
|
909
|
+
let result;
|
|
910
|
+
|
|
911
|
+
if (markerMatch && typeof markerMatch.index === 'number') {
|
|
912
|
+
const before = stripped.slice(0, markerMatch.index).replace(/\s*$/, '\n\n');
|
|
913
|
+
const after = stripped.slice(markerMatch.index).replace(/^\s*/, '');
|
|
914
|
+
result = `${before}${section}\n\n${after}`;
|
|
915
|
+
} else {
|
|
916
|
+
result = `${stripped.replace(/\s*$/, '\n\n')}${section}\n`;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const count = (result.match(/^## Prose Language$/gm) || []).length;
|
|
920
|
+
if (count !== 1) {
|
|
921
|
+
throw new InitError('Internal error: failed to inject exactly one Prose Language section.');
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function stripProseLanguageSections(content) {
|
|
928
|
+
const lines = content.split(/\r?\n/);
|
|
929
|
+
const kept = [];
|
|
930
|
+
let skipping = false;
|
|
931
|
+
|
|
932
|
+
for (const line of lines) {
|
|
933
|
+
if (/^## Prose Language\s*$/.test(line)) {
|
|
934
|
+
skipping = true;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (skipping && /^## /.test(line)) {
|
|
939
|
+
skipping = false;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (!skipping) {
|
|
943
|
+
kept.push(line);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return kept.join('\n');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function buildProseLanguageSection(proseLanguage) {
|
|
951
|
+
return `## Prose Language
|
|
952
|
+
|
|
953
|
+
Project prose language: \`${proseLanguage}\`
|
|
954
|
+
|
|
955
|
+
Dflow templates keep canonical English structural language: headings,
|
|
956
|
+
table headers, fixed labels, placeholders, IDs, anchors, and code-facing
|
|
957
|
+
terms remain English.
|
|
958
|
+
|
|
959
|
+
Free prose written inside those sections should follow the project prose
|
|
960
|
+
language:
|
|
961
|
+
|
|
962
|
+
- \`en\`: write free prose in English.
|
|
963
|
+
- \`zh-TW\`: write free prose in Traditional Chinese.
|
|
964
|
+
- \`{xx-XX}\`: write free prose in that explicit BCP-47 language.
|
|
965
|
+
|
|
966
|
+
Do not translate code identifiers, DDD pattern names, BR IDs, SPEC IDs,
|
|
967
|
+
file paths, branch names, anchors, or inline code only to satisfy the
|
|
968
|
+
prose-language setting.`;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function renderPreview(stdout, plan, warnings) {
|
|
972
|
+
stdout.write('\nFile plan:\n\n');
|
|
973
|
+
if (warnings.length > 0) {
|
|
974
|
+
stdout.write('Warnings:\n');
|
|
975
|
+
warnings.forEach((warning) => stdout.write(`- ${warning}\n`));
|
|
976
|
+
stdout.write('\n');
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
stdout.write('| Path | Action | Source | Size | Notes |\n');
|
|
980
|
+
stdout.write('|---|---|---|---:|---|\n');
|
|
981
|
+
plan.items.forEach((item) => {
|
|
982
|
+
stdout.write(
|
|
983
|
+
`| ${escapeTableCell(item.relativePath)} | ${item.action} | ${escapeTableCell(item.source)} | ${formatBytes(item.size)} | ${escapeTableCell(item.notes || '')} |\n`
|
|
984
|
+
);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
stdout.write('\nWill defer:\n\n');
|
|
988
|
+
stdout.write('| Path | Action | Source | Size | Notes |\n');
|
|
989
|
+
stdout.write('|---|---|---|---:|---|\n');
|
|
990
|
+
plan.deferred.forEach((item) => {
|
|
991
|
+
stdout.write(
|
|
992
|
+
`| ${escapeTableCell(item.relativePath)} | defer | generated later | 0 B | ${escapeTableCell(item.reason)} |\n`
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
stdout.write('\n');
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function writeFilePlan(cwd, plan) {
|
|
999
|
+
const result = {
|
|
1000
|
+
created: [],
|
|
1001
|
+
skipped: [],
|
|
1002
|
+
warnings: []
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
for (const item of plan.items) {
|
|
1006
|
+
const targetPath = path.join(cwd, item.relativePath);
|
|
1007
|
+
|
|
1008
|
+
try {
|
|
1009
|
+
if (await pathExists(targetPath)) {
|
|
1010
|
+
result.skipped.push(item.relativePath);
|
|
1011
|
+
result.warnings.push(`Skipped existing target: ${item.relativePath}`);
|
|
1012
|
+
if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
|
|
1013
|
+
result.warnings.push(
|
|
1014
|
+
'Prose language was not written because dflow/specs/shared/_conventions.md already exists. Ensure it contains exactly one ## Prose Language section before running prose-generating flows.'
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
1021
|
+
await fs.writeFile(targetPath, item.content, { flag: 'wx' });
|
|
1022
|
+
|
|
1023
|
+
if (item.relativePath === 'dflow/specs/shared/_conventions.md') {
|
|
1024
|
+
const written = await fs.readFile(targetPath, 'utf8');
|
|
1025
|
+
const count = (written.match(/^## Prose Language$/gm) || []).length;
|
|
1026
|
+
if (count !== 1) {
|
|
1027
|
+
throw new Error('written _conventions.md does not contain exactly one ## Prose Language section');
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
result.created.push(item.relativePath);
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (error.code === 'EEXIST') {
|
|
1034
|
+
let targetExists = false;
|
|
1035
|
+
try {
|
|
1036
|
+
targetExists = await pathExists(targetPath);
|
|
1037
|
+
} catch {
|
|
1038
|
+
targetExists = false;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (targetExists) {
|
|
1042
|
+
result.skipped.push(item.relativePath);
|
|
1043
|
+
result.warnings.push(`Skipped existing target: ${item.relativePath}`);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
throw new WritePhaseError(
|
|
1048
|
+
`Cannot create parent directory for ${item.relativePath}: a parent path is a file.`,
|
|
1049
|
+
result
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const message = error.code === 'ENOTDIR' || error.code === 'EEXIST'
|
|
1054
|
+
? `Cannot create parent directory for ${item.relativePath}: a parent path is a file.`
|
|
1055
|
+
: `Write failed while creating ${item.relativePath}: ${error.message}`;
|
|
1056
|
+
throw new WritePhaseError(message, result);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function collectUnresolvedPlaceholderWarnings(plan, createdPaths) {
|
|
1064
|
+
const createdSet = new Set(createdPaths);
|
|
1065
|
+
const unresolvedInitPlaceholders = new Set(plan.unresolvedInitPlaceholders || []);
|
|
1066
|
+
const placeholders = new Set();
|
|
1067
|
+
|
|
1068
|
+
if (unresolvedInitPlaceholders.size === 0) {
|
|
1069
|
+
return [];
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
for (const item of plan.items) {
|
|
1073
|
+
if (!createdSet.has(item.relativePath)) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const matches = item.content.match(/{[^{}\n]+}/g) || [];
|
|
1077
|
+
matches
|
|
1078
|
+
.filter((match) => unresolvedInitPlaceholders.has(match))
|
|
1079
|
+
.forEach((match) => placeholders.add(match));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (placeholders.size === 0) {
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const sorted = Array.from(placeholders).sort();
|
|
1087
|
+
const shown = sorted.slice(0, 25).join(', ');
|
|
1088
|
+
const suffix = sorted.length > 25 ? `, and ${sorted.length - 25} more` : '';
|
|
1089
|
+
return [`Unresolved placeholders remain for later SDD workflows: ${shown}${suffix}.`];
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function printResultReport(stdout, result, deferred) {
|
|
1093
|
+
stdout.write('\nCreated:\n');
|
|
1094
|
+
printList(stdout, result.created);
|
|
1095
|
+
|
|
1096
|
+
stdout.write('\nSkipped:\n');
|
|
1097
|
+
printList(stdout, result.skipped);
|
|
1098
|
+
|
|
1099
|
+
stdout.write('\nWarnings:\n');
|
|
1100
|
+
printList(stdout, dedupe(result.warnings));
|
|
1101
|
+
|
|
1102
|
+
if (deferred.length > 0) {
|
|
1103
|
+
stdout.write('\nDeferred:\n');
|
|
1104
|
+
deferred.forEach((item) => stdout.write(`- ${item.relativePath} - ${item.reason}\n`));
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function printNextSteps(stdout) {
|
|
1109
|
+
stdout.write(`
|
|
1110
|
+
Dflow init complete.
|
|
1111
|
+
|
|
1112
|
+
Recommended next steps:
|
|
1113
|
+
- For a new feature, use the Dflow new-feature workflow when it becomes available as a CLI command.
|
|
1114
|
+
- For brownfield changes, use the Dflow modify-existing workflow when it becomes available as a CLI command.
|
|
1115
|
+
- Before generating more specs, make sure dflow/specs/shared/_conventions.md has the correct Prose Language section.
|
|
1116
|
+
`);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function printList(stdout, values) {
|
|
1120
|
+
if (!values || values.length === 0) {
|
|
1121
|
+
stdout.write('- (none)\n');
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
values.forEach((value) => stdout.write(`- ${value}\n`));
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function writeWarnings(stderr, warnings) {
|
|
1128
|
+
warnings.forEach((warning) => stderr.write(`${warning}\n`));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async function pathExists(targetPath) {
|
|
1132
|
+
try {
|
|
1133
|
+
await fs.stat(targetPath);
|
|
1134
|
+
return true;
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
if (error.code === 'ENOENT') {
|
|
1137
|
+
return false;
|
|
1138
|
+
}
|
|
1139
|
+
throw error;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function currentLocalDate() {
|
|
1144
|
+
const now = new Date();
|
|
1145
|
+
const year = now.getFullYear();
|
|
1146
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
1147
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
1148
|
+
return `${year}-${month}-${day}`;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function compareVersions(actual, minimum) {
|
|
1152
|
+
const actualParts = actual.split('.').map(Number);
|
|
1153
|
+
const minimumParts = minimum.split('.').map(Number);
|
|
1154
|
+
|
|
1155
|
+
for (let index = 0; index < 3; index += 1) {
|
|
1156
|
+
const actualPart = actualParts[index] || 0;
|
|
1157
|
+
const minimumPart = minimumParts[index] || 0;
|
|
1158
|
+
if (actualPart > minimumPart) {
|
|
1159
|
+
return 1;
|
|
1160
|
+
}
|
|
1161
|
+
if (actualPart < minimumPart) {
|
|
1162
|
+
return -1;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
return 0;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function displayOptionNumber(options, key) {
|
|
1170
|
+
const index = options.findIndex((option) => option.key === key);
|
|
1171
|
+
return index === -1 ? '?' : String(index + 1);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function normalizeAnswer(value) {
|
|
1175
|
+
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function normalizePath(value) {
|
|
1179
|
+
return value.split(path.sep).join('/');
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function formatBytes(bytes) {
|
|
1183
|
+
if (bytes < 1024) {
|
|
1184
|
+
return `${bytes} B`;
|
|
1185
|
+
}
|
|
1186
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
function formatEdition(edition) {
|
|
1190
|
+
return edition === 'core' ? 'ASP.NET Core' : 'ASP.NET WebForms';
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function escapeTableCell(value) {
|
|
1194
|
+
return String(value).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function dedupe(values) {
|
|
1198
|
+
return Array.from(new Set(values));
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
module.exports = {
|
|
1202
|
+
runInit,
|
|
1203
|
+
validateProseLanguage,
|
|
1204
|
+
ensureProseLanguageSection,
|
|
1205
|
+
buildFilePlan
|
|
1206
|
+
};
|