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