@timeax/scaffold 0.0.4 → 0.0.6

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.mjs CHANGED
@@ -204,6 +204,199 @@ async function importTsConfig(configPath) {
204
204
  }
205
205
 
206
206
  // src/ast/parser.ts
207
+ function parseStructureAst(text, opts = {}) {
208
+ const indentStep = opts.indentStep ?? 2;
209
+ const mode = opts.mode ?? "loose";
210
+ const diagnostics = [];
211
+ const lines = [];
212
+ const rawLines = text.split(/\r?\n/);
213
+ for (let i = 0; i < rawLines.length; i++) {
214
+ const raw = rawLines[i];
215
+ const lineNo = i + 1;
216
+ const m = raw.match(/^(\s*)(.*)$/);
217
+ const indentRaw = m ? m[1] : "";
218
+ const content = m ? m[2] : "";
219
+ const { indentSpaces, hasTabs } = measureIndent(indentRaw, indentStep);
220
+ if (hasTabs) {
221
+ diagnostics.push({
222
+ line: lineNo,
223
+ message: "Tabs detected in indentation. Consider using spaces only for consistent levels.",
224
+ severity: mode === "strict" ? "warning" : "info",
225
+ code: "indent-tabs"
226
+ });
227
+ }
228
+ const trimmed = content.trim();
229
+ let kind;
230
+ if (!trimmed) {
231
+ kind = "blank";
232
+ } else if (trimmed.startsWith("#") || trimmed.startsWith("//")) {
233
+ kind = "comment";
234
+ } else {
235
+ kind = "entry";
236
+ }
237
+ lines.push({
238
+ index: i,
239
+ lineNo,
240
+ raw,
241
+ kind,
242
+ indentSpaces,
243
+ content
244
+ });
245
+ }
246
+ const rootNodes = [];
247
+ const stack = [];
248
+ const depthCtx = {
249
+ lastIndentSpaces: null,
250
+ lastDepth: null,
251
+ lastWasFile: false
252
+ };
253
+ for (const line of lines) {
254
+ if (line.kind !== "entry") continue;
255
+ const { entry, depth, diags } = parseEntryLine(
256
+ line,
257
+ indentStep,
258
+ mode,
259
+ depthCtx
260
+ );
261
+ diagnostics.push(...diags);
262
+ if (!entry) {
263
+ continue;
264
+ }
265
+ attachNode(entry, depth, line, rootNodes, stack, diagnostics, mode);
266
+ depthCtx.lastWasFile = !entry.isDir;
267
+ }
268
+ return {
269
+ rootNodes,
270
+ lines,
271
+ diagnostics,
272
+ options: {
273
+ indentStep,
274
+ mode
275
+ }
276
+ };
277
+ }
278
+ function measureIndent(rawIndent, indentStep) {
279
+ let spaces = 0;
280
+ let hasTabs = false;
281
+ for (const ch of rawIndent) {
282
+ if (ch === " ") {
283
+ spaces += 1;
284
+ } else if (ch === " ") {
285
+ hasTabs = true;
286
+ spaces += indentStep;
287
+ }
288
+ }
289
+ return { indentSpaces: spaces, hasTabs };
290
+ }
291
+ function computeDepth(line, indentStep, mode, ctx, diagnostics) {
292
+ let spaces = line.indentSpaces;
293
+ if (spaces < 0) spaces = 0;
294
+ let depth;
295
+ if (ctx.lastIndentSpaces == null || ctx.lastDepth == null) {
296
+ depth = 0;
297
+ } else {
298
+ const prevSpaces = ctx.lastIndentSpaces;
299
+ const prevDepth = ctx.lastDepth;
300
+ if (spaces > prevSpaces) {
301
+ const diff = spaces - prevSpaces;
302
+ if (ctx.lastWasFile) {
303
+ diagnostics.push({
304
+ line: line.lineNo,
305
+ message: "Entry appears indented under a file; treating it as a sibling of the file instead of a child.",
306
+ severity: mode === "strict" ? "error" : "warning",
307
+ code: "child-of-file-loose"
308
+ });
309
+ depth = prevDepth;
310
+ } else {
311
+ if (diff > indentStep) {
312
+ diagnostics.push({
313
+ line: line.lineNo,
314
+ message: `Indentation jumps from ${prevSpaces} to ${spaces} spaces; treating as one level deeper.`,
315
+ severity: mode === "strict" ? "error" : "warning",
316
+ code: "indent-skip-level"
317
+ });
318
+ }
319
+ depth = prevDepth + 1;
320
+ }
321
+ } else if (spaces === prevSpaces) {
322
+ depth = prevDepth;
323
+ } else {
324
+ const diff = prevSpaces - spaces;
325
+ const steps = Math.round(diff / indentStep);
326
+ if (diff % indentStep !== 0) {
327
+ diagnostics.push({
328
+ line: line.lineNo,
329
+ message: `Indentation decreases from ${prevSpaces} to ${spaces} spaces, which is not a multiple of indent step (${indentStep}).`,
330
+ severity: mode === "strict" ? "error" : "warning",
331
+ code: "indent-misaligned"
332
+ });
333
+ }
334
+ depth = Math.max(prevDepth - steps, 0);
335
+ }
336
+ }
337
+ ctx.lastIndentSpaces = spaces;
338
+ ctx.lastDepth = depth;
339
+ return depth;
340
+ }
341
+ function parseEntryLine(line, indentStep, mode, ctx) {
342
+ const diags = [];
343
+ const depth = computeDepth(line, indentStep, mode, ctx, diags);
344
+ const { contentWithoutComment } = extractInlineCommentParts(line.content);
345
+ const trimmed = contentWithoutComment.trim();
346
+ if (!trimmed) {
347
+ return { entry: null, depth, diags };
348
+ }
349
+ const parts = trimmed.split(/\s+/);
350
+ const pathToken = parts[0];
351
+ const annotationTokens = parts.slice(1);
352
+ if (pathToken.includes(":")) {
353
+ diags.push({
354
+ line: line.lineNo,
355
+ message: 'Path token contains ":" which is reserved for annotations. This is likely a mistake.',
356
+ severity: mode === "strict" ? "error" : "warning",
357
+ code: "path-colon"
358
+ });
359
+ }
360
+ const isDir = pathToken.endsWith("/");
361
+ const segmentName = pathToken;
362
+ let stub;
363
+ const include = [];
364
+ const exclude = [];
365
+ for (const token of annotationTokens) {
366
+ if (token.startsWith("@stub:")) {
367
+ stub = token.slice("@stub:".length);
368
+ } else if (token.startsWith("@include:")) {
369
+ const val = token.slice("@include:".length);
370
+ if (val) {
371
+ include.push(
372
+ ...val.split(",").map((s) => s.trim()).filter(Boolean)
373
+ );
374
+ }
375
+ } else if (token.startsWith("@exclude:")) {
376
+ const val = token.slice("@exclude:".length);
377
+ if (val) {
378
+ exclude.push(
379
+ ...val.split(",").map((s) => s.trim()).filter(Boolean)
380
+ );
381
+ }
382
+ } else if (token.startsWith("@")) {
383
+ diags.push({
384
+ line: line.lineNo,
385
+ message: `Unknown annotation token "${token}".`,
386
+ severity: "info",
387
+ code: "unknown-annotation"
388
+ });
389
+ }
390
+ }
391
+ const entry = {
392
+ segmentName,
393
+ isDir,
394
+ stub,
395
+ include: include.length ? include : void 0,
396
+ exclude: exclude.length ? exclude : void 0
397
+ };
398
+ return { entry, depth, diags };
399
+ }
207
400
  function mapThrough(content) {
208
401
  let cutIndex = -1;
209
402
  const len = content.length;
@@ -226,6 +419,214 @@ function mapThrough(content) {
226
419
  }
227
420
  return cutIndex;
228
421
  }
422
+ function extractInlineCommentParts(content) {
423
+ const cutIndex = mapThrough(content);
424
+ if (cutIndex === -1) {
425
+ return {
426
+ contentWithoutComment: content,
427
+ inlineComment: null
428
+ };
429
+ }
430
+ return {
431
+ contentWithoutComment: content.slice(0, cutIndex),
432
+ inlineComment: content.slice(cutIndex)
433
+ };
434
+ }
435
+ function attachNode(entry, depth, line, rootNodes, stack, diagnostics, mode) {
436
+ const lineNo = line.lineNo;
437
+ while (stack.length > depth) {
438
+ stack.pop();
439
+ }
440
+ let parent = null;
441
+ if (depth > 0) {
442
+ const candidate = stack[depth - 1];
443
+ if (!candidate) {
444
+ diagnostics.push({
445
+ line: lineNo,
446
+ message: `Entry has indent depth ${depth} but no parent at depth ${depth - 1}. Treating as root.`,
447
+ severity: mode === "strict" ? "error" : "warning",
448
+ code: "missing-parent"
449
+ });
450
+ } else if (candidate.type === "file") {
451
+ if (mode === "strict") {
452
+ diagnostics.push({
453
+ line: lineNo,
454
+ message: `Cannot attach child under file "${candidate.path}".`,
455
+ severity: "error",
456
+ code: "child-of-file"
457
+ });
458
+ } else {
459
+ diagnostics.push({
460
+ line: lineNo,
461
+ message: `Entry appears under file "${candidate.path}". Attaching as sibling at depth ${candidate.depth}.`,
462
+ severity: "warning",
463
+ code: "child-of-file-loose"
464
+ });
465
+ while (stack.length > candidate.depth) {
466
+ stack.pop();
467
+ }
468
+ }
469
+ } else {
470
+ parent = candidate;
471
+ }
472
+ }
473
+ const parentPath = parent ? parent.path.replace(/\/$/, "") : "";
474
+ const normalizedSegment = toPosixPath(entry.segmentName.replace(/\/+$/, ""));
475
+ const fullPath = parentPath ? `${parentPath}/${normalizedSegment}${entry.isDir ? "/" : ""}` : `${normalizedSegment}${entry.isDir ? "/" : ""}`;
476
+ const baseNode = {
477
+ type: entry.isDir ? "dir" : "file",
478
+ name: entry.segmentName,
479
+ depth,
480
+ line: lineNo,
481
+ path: fullPath,
482
+ parent,
483
+ ...entry.stub ? { stub: entry.stub } : {},
484
+ ...entry.include ? { include: entry.include } : {},
485
+ ...entry.exclude ? { exclude: entry.exclude } : {}
486
+ };
487
+ if (entry.isDir) {
488
+ const dirNode = {
489
+ ...baseNode,
490
+ type: "dir",
491
+ children: []
492
+ };
493
+ if (parent) {
494
+ parent.children.push(dirNode);
495
+ } else {
496
+ rootNodes.push(dirNode);
497
+ }
498
+ while (stack.length > depth) {
499
+ stack.pop();
500
+ }
501
+ stack[depth] = dirNode;
502
+ } else {
503
+ const fileNode = {
504
+ ...baseNode,
505
+ type: "file"
506
+ };
507
+ if (parent) {
508
+ parent.children.push(fileNode);
509
+ } else {
510
+ rootNodes.push(fileNode);
511
+ }
512
+ }
513
+ }
514
+
515
+ // src/ast/format.ts
516
+ function formatStructureText(text, options = {}) {
517
+ const indentStep = options.indentStep ?? 2;
518
+ const mode = options.mode ?? "loose";
519
+ const normalizeNewlines = options.normalizeNewlines === void 0 ? true : options.normalizeNewlines;
520
+ const trimTrailingWhitespace = options.trimTrailingWhitespace === void 0 ? true : options.trimTrailingWhitespace;
521
+ const normalizeAnnotations = options.normalizeAnnotations === void 0 ? true : options.normalizeAnnotations;
522
+ const ast = parseStructureAst(text, {
523
+ indentStep,
524
+ mode
525
+ });
526
+ const rawLines = text.split(/\r?\n/);
527
+ const lineCount = rawLines.length;
528
+ if (ast.lines.length !== lineCount) {
529
+ return {
530
+ text: basicNormalize(text, { normalizeNewlines, trimTrailingWhitespace }),
531
+ ast
532
+ };
533
+ }
534
+ const entryLineIndexes = [];
535
+ const inlineComments = [];
536
+ for (let i = 0; i < lineCount; i++) {
537
+ const lineMeta = ast.lines[i];
538
+ if (lineMeta.kind === "entry") {
539
+ entryLineIndexes.push(i);
540
+ const { inlineComment } = extractInlineCommentParts(lineMeta.content);
541
+ inlineComments.push(inlineComment);
542
+ }
543
+ }
544
+ const flattened = [];
545
+ flattenAstNodes(ast.rootNodes, 0, flattened);
546
+ if (flattened.length !== entryLineIndexes.length) {
547
+ return {
548
+ text: basicNormalize(text, { normalizeNewlines, trimTrailingWhitespace }),
549
+ ast
550
+ };
551
+ }
552
+ const canonicalEntryLines = flattened.map(
553
+ ({ node, level }) => formatAstNodeLine(node, level, indentStep, normalizeAnnotations)
554
+ );
555
+ const resultLines = [];
556
+ let entryIdx = 0;
557
+ for (let i = 0; i < lineCount; i++) {
558
+ const lineMeta = ast.lines[i];
559
+ const originalLine = rawLines[i];
560
+ if (lineMeta.kind === "entry") {
561
+ const base = canonicalEntryLines[entryIdx].replace(/[ \t]+$/g, "");
562
+ const inline = inlineComments[entryIdx];
563
+ entryIdx++;
564
+ if (inline) {
565
+ resultLines.push(base + " " + inline);
566
+ } else {
567
+ resultLines.push(base);
568
+ }
569
+ } else {
570
+ let out = originalLine;
571
+ if (trimTrailingWhitespace) {
572
+ out = out.replace(/[ \t]+$/g, "");
573
+ }
574
+ resultLines.push(out);
575
+ }
576
+ }
577
+ const eol = normalizeNewlines ? detectPreferredEol(text) : getRawEol(text);
578
+ return {
579
+ text: resultLines.join(eol),
580
+ ast
581
+ };
582
+ }
583
+ function basicNormalize(text, opts) {
584
+ const lines = text.split(/\r?\n/);
585
+ const normalizedLines = opts.trimTrailingWhitespace ? lines.map((line) => line.replace(/[ \t]+$/g, "")) : lines;
586
+ const eol = opts.normalizeNewlines ? detectPreferredEol(text) : getRawEol(text);
587
+ return normalizedLines.join(eol);
588
+ }
589
+ function detectPreferredEol(text) {
590
+ const crlfCount = (text.match(/\r\n/g) || []).length;
591
+ const lfCount = (text.match(/(?<!\r)\n/g) || []).length;
592
+ if (crlfCount === 0 && lfCount === 0) {
593
+ return "\n";
594
+ }
595
+ if (crlfCount > lfCount) {
596
+ return "\r\n";
597
+ }
598
+ return "\n";
599
+ }
600
+ function getRawEol(text) {
601
+ return text.includes("\r\n") ? "\r\n" : "\n";
602
+ }
603
+ function flattenAstNodes(nodes, level, out) {
604
+ for (const node of nodes) {
605
+ out.push({ node, level });
606
+ if (node.type === "dir" && node.children && node.children.length) {
607
+ flattenAstNodes(node.children, level + 1, out);
608
+ }
609
+ }
610
+ }
611
+ function formatAstNodeLine(node, level, indentStep, normalizeAnnotations) {
612
+ const indent = " ".repeat(indentStep * level);
613
+ const baseName = node.name;
614
+ if (!normalizeAnnotations) {
615
+ return indent + baseName;
616
+ }
617
+ const tokens = [];
618
+ if (node.stub) {
619
+ tokens.push(`@stub:${node.stub}`);
620
+ }
621
+ if (node.include && node.include.length > 0) {
622
+ tokens.push(`@include:${node.include.join(",")}`);
623
+ }
624
+ if (node.exclude && node.exclude.length > 0) {
625
+ tokens.push(`@exclude:${node.exclude.join(",")}`);
626
+ }
627
+ const annotations = tokens.length ? " " + tokens.join(" ") : "";
628
+ return indent + baseName + annotations;
629
+ }
229
630
 
230
631
  // src/core/structure-txt.ts
231
632
  function stripInlineComment(content) {
@@ -646,6 +1047,43 @@ async function applyStructure(opts) {
646
1047
  }
647
1048
  }
648
1049
  }
1050
+ function getStructureFilesFromConfig(projectRoot, scaffoldDir, config) {
1051
+ const baseDir = path2.resolve(projectRoot, scaffoldDir || SCAFFOLD_ROOT_DIR);
1052
+ const files = [];
1053
+ if (config.groups && config.groups.length > 0) {
1054
+ for (const group of config.groups) {
1055
+ const structureFile = group.structureFile && group.structureFile.trim().length ? group.structureFile : `${group.name}.txt`;
1056
+ files.push(path2.join(baseDir, structureFile));
1057
+ }
1058
+ } else {
1059
+ const structureFile = config.structureFile || "structure.txt";
1060
+ files.push(path2.join(baseDir, structureFile));
1061
+ }
1062
+ return files;
1063
+ }
1064
+ async function formatStructureFilesFromConfig(projectRoot, scaffoldDir, config, opts = {}) {
1065
+ const formatCfg = config.format;
1066
+ const enabled = !!(formatCfg?.enabled || opts.force);
1067
+ if (!enabled) return;
1068
+ const files = getStructureFilesFromConfig(projectRoot, scaffoldDir, config);
1069
+ const indentStep = formatCfg?.indentStep ?? config.indentStep ?? 2;
1070
+ const mode = formatCfg?.mode ?? "loose";
1071
+ !!formatCfg?.sortEntries;
1072
+ for (const filePath of files) {
1073
+ let text;
1074
+ try {
1075
+ text = fs2.readFileSync(filePath, "utf8");
1076
+ } catch {
1077
+ continue;
1078
+ }
1079
+ const { text: formatted } = formatStructureText(text, {
1080
+ indentStep,
1081
+ mode});
1082
+ if (formatted !== text) {
1083
+ fs2.writeFileSync(filePath, formatted, "utf8");
1084
+ }
1085
+ }
1086
+ }
649
1087
 
650
1088
  // src/core/runner.ts
651
1089
  async function runOnce(cwd, options = {}) {
@@ -654,6 +1092,7 @@ async function runOnce(cwd, options = {}) {
654
1092
  scaffoldDir: options.scaffoldDir,
655
1093
  configPath: options.configPath
656
1094
  });
1095
+ await formatStructureFilesFromConfig(projectRoot, scaffoldDir, config, { force: options.format });
657
1096
  const cachePath = config.cacheFile ?? ".scaffold-cache.json";
658
1097
  const cache = new CacheManager(projectRoot, cachePath);
659
1098
  cache.load();