@codefast/cli 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +97 -12
  3. package/dist/arrange/domain/ast/translator.js +42 -28
  4. package/dist/arrange/simplify/process-file.d.ts +1 -1
  5. package/dist/audit/assertions/cli-result.d.ts +13 -0
  6. package/dist/audit/assertions/cli-result.js +22 -0
  7. package/dist/audit/assertions/cli-schema.d.ts +18 -0
  8. package/dist/audit/assertions/cli-schema.js +12 -0
  9. package/dist/audit/assertions/domain/double-assertion.d.ts +20 -0
  10. package/dist/audit/assertions/domain/double-assertion.js +122 -0
  11. package/dist/audit/assertions/output.d.ts +7 -0
  12. package/dist/audit/assertions/output.js +21 -0
  13. package/dist/audit/assertions/prepare.d.ts +16 -0
  14. package/dist/audit/assertions/prepare.js +12 -0
  15. package/dist/audit/assertions/run.d.ts +14 -0
  16. package/dist/audit/assertions/run.js +40 -0
  17. package/dist/audit/command.js +45 -1
  18. package/dist/audit/constants/cli-result.d.ts +13 -0
  19. package/dist/audit/constants/cli-result.js +22 -0
  20. package/dist/audit/constants/cli-schema.d.ts +18 -0
  21. package/dist/audit/constants/cli-schema.js +12 -0
  22. package/dist/audit/constants/domain/constants.d.ts +8 -0
  23. package/dist/audit/constants/domain/constants.js +66 -0
  24. package/dist/audit/constants/output.d.ts +7 -0
  25. package/dist/audit/constants/output.js +21 -0
  26. package/dist/audit/constants/prepare.d.ts +16 -0
  27. package/dist/audit/constants/prepare.js +37 -0
  28. package/dist/audit/constants/run.d.ts +14 -0
  29. package/dist/audit/constants/run.js +64 -0
  30. package/dist/audit/display-names/domain/display-names.js +1 -9
  31. package/dist/audit/domain/types.d.ts +85 -0
  32. package/dist/audit/imports/domain/import-policy.js +4 -18
  33. package/dist/audit/publish/cli-result.d.ts +1 -1
  34. package/dist/audit/publish/cli-result.js +6 -3
  35. package/dist/audit/publish/domain/stylesheet-sources.d.ts +22 -0
  36. package/dist/audit/publish/domain/stylesheet-sources.js +64 -0
  37. package/dist/audit/publish/output.js +12 -3
  38. package/dist/audit/publish/run.d.ts +2 -1
  39. package/dist/audit/publish/run.js +30 -1
  40. package/dist/audit/publish/shipped-files.d.ts +21 -0
  41. package/dist/audit/publish/shipped-files.js +36 -0
  42. package/dist/core/config/schema.d.ts +5 -0
  43. package/dist/core/config/schema.js +2 -0
  44. package/dist/core/filesystem/filesystem.d.ts +3 -4
  45. package/dist/core/filesystem/node.js +1 -7
  46. package/dist/core/oxc-node.d.ts +32 -0
  47. package/dist/core/oxc-node.js +25 -0
  48. package/dist/core/source-position.d.ts +15 -0
  49. package/dist/core/source-position.js +26 -0
  50. package/dist/mirror/dist-filesystem-node.js +4 -14
  51. package/dist/pack-slim/run.js +1 -1
  52. package/dist/tag/domain/version-summary.d.ts +5 -2
  53. package/dist/tag/writer/since-writer.js +2 -4
  54. package/package.json +4 -4
  55. package/dist/mirror/domain/dirent-guard.d.ts +0 -10
  56. package/dist/mirror/domain/dirent-guard.js +0 -15
@@ -1,6 +1,6 @@
1
1
  import type { PublishAuditResult } from "#audit/domain/types";
2
2
  /**
3
- * Exit `1` when any `#/` import or unshipped publish target remains.
3
+ * Exit `1` when any `#/` import, unshipped publish target, or unreachable stylesheet source remains.
4
4
  *
5
5
  * @since 0.12.0
6
6
  */
@@ -1,11 +1,11 @@
1
1
  import { CLI_EXIT_GENERAL_ERROR, CLI_EXIT_SUCCESS } from "#core/exit-codes";
2
2
  /**
3
- * Exit `1` when any `#/` import or unshipped publish target remains.
3
+ * Exit `1` when any `#/` import, unshipped publish target, or unreachable stylesheet source remains.
4
4
  *
5
5
  * @since 0.12.0
6
6
  */
7
7
  export function exitCodeForPublishAuditResult(result) {
8
- return result.legacyImportCount > 0 || result.unshipped.length > 0 ? CLI_EXIT_GENERAL_ERROR : CLI_EXIT_SUCCESS;
8
+ return isCleanPublishAudit(result) ? CLI_EXIT_SUCCESS : CLI_EXIT_GENERAL_ERROR;
9
9
  }
10
10
  /**
11
11
  * Machine-readable publish audit summary for `--json`.
@@ -15,8 +15,11 @@ export function exitCodeForPublishAuditResult(result) {
15
15
  export function formatPublishAuditJsonOutput(result, rootDir) {
16
16
  return JSON.stringify({
17
17
  schemaVersion: 1,
18
- ok: result.legacyImportCount === 0 && result.unshipped.length === 0,
18
+ ok: isCleanPublishAudit(result),
19
19
  cwd: rootDir,
20
20
  result,
21
21
  });
22
+ }
23
+ function isCleanPublishAudit(result) {
24
+ return result.legacyImportCount === 0 && result.unshipped.length === 0 && result.unreachableStylesheets.length === 0;
22
25
  }
@@ -0,0 +1,22 @@
1
+ /** Reads the paths a stylesheet registers with Tailwind's `@source` and checks them against a shipped file list. */
2
+ import type { StylesheetSource } from "#audit/domain/types";
3
+ /**
4
+ * Every path a stylesheet registers with `@source`, with the line it sits on.
5
+ *
6
+ * @since 0.13.0
7
+ */
8
+ export declare function scanStylesheetSources(content: string): Array<StylesheetSource>;
9
+ /**
10
+ * Whether a stylesheet's `@source` paths all miss the files its package ships.
11
+ *
12
+ * @remarks Paths are package-relative and POSIX. A path that leaves the package names another package's files, which
13
+ * this tarball cannot vouch for, so it is not judged. One reachable path is enough: a workspace-only lane beside the
14
+ * published one is expected.
15
+ *
16
+ * @param stylesheetPath - The stylesheet, relative to its package root.
17
+ * @param sources - What {@link scanStylesheetSources} found in it.
18
+ * @param shippedFiles - Every file the package's tarball ships, relative to the package root.
19
+ *
20
+ * @since 0.13.0
21
+ */
22
+ export declare function missesShippedFiles(stylesheetPath: string, sources: ReadonlyArray<StylesheetSource>, shippedFiles: ReadonlyArray<string>): boolean;
@@ -0,0 +1,64 @@
1
+ /** Reads the paths a stylesheet registers with Tailwind's `@source` and checks them against a shipped file list. */
2
+ import path from "node:path";
3
+ import { createAnyGlobMatcher } from "#core/glob";
4
+ import { lineOfOffset } from "#core/source-position";
5
+ // A plain `@source "<path>";` — `@source not …` excludes paths and `@source inline(…)` names classes, so neither
6
+ // registers files.
7
+ const SOURCE_DIRECTIVE = /@source\s+(["'])([^"']+)\1\s*;/g;
8
+ // A string or a comment. Strings match first, so the `/**/` of a quoted glob is never read as a comment.
9
+ const STRING_OR_COMMENT = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|\/\*[\s\S]*?\*\//g;
10
+ const GLOB_CHARACTERS = /[*?[\]{}]/;
11
+ /**
12
+ * Every path a stylesheet registers with `@source`, with the line it sits on.
13
+ *
14
+ * @since 0.13.0
15
+ */
16
+ export function scanStylesheetSources(content) {
17
+ // Blanking comments keeps every offset in place, so a commented-out directive drops out and lines still count.
18
+ const code = content.replaceAll(STRING_OR_COMMENT, (match, quoted) => quoted ?? match.replaceAll(/[^\n]/g, " "));
19
+ const found = [];
20
+ for (const match of code.matchAll(SOURCE_DIRECTIVE)) {
21
+ const pattern = match[2];
22
+ if (pattern !== undefined) {
23
+ found.push({ line: lineOfOffset(code, match.index), pattern });
24
+ }
25
+ }
26
+ return found;
27
+ }
28
+ /**
29
+ * Whether a stylesheet's `@source` paths all miss the files its package ships.
30
+ *
31
+ * @remarks Paths are package-relative and POSIX. A path that leaves the package names another package's files, which
32
+ * this tarball cannot vouch for, so it is not judged. One reachable path is enough: a workspace-only lane beside the
33
+ * published one is expected.
34
+ *
35
+ * @param stylesheetPath - The stylesheet, relative to its package root.
36
+ * @param sources - What {@link scanStylesheetSources} found in it.
37
+ * @param shippedFiles - Every file the package's tarball ships, relative to the package root.
38
+ *
39
+ * @since 0.13.0
40
+ */
41
+ export function missesShippedFiles(stylesheetPath, sources, shippedFiles) {
42
+ const patterns = sources.flatMap((source) => packagePatternsFor(stylesheetPath, source.pattern));
43
+ if (patterns.length === 0) {
44
+ return false;
45
+ }
46
+ const isReached = createAnyGlobMatcher(patterns, { dot: true });
47
+ return !shippedFiles.some(isReached);
48
+ }
49
+ // Tailwind resolves a `@source` path against the stylesheet's directory; a path without a glob is a file or a
50
+ // directory, and a directory registers everything beneath it.
51
+ function packagePatternsFor(stylesheetPath, pattern) {
52
+ if (path.posix.isAbsolute(pattern)) {
53
+ return [];
54
+ }
55
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(stylesheetPath), pattern));
56
+ if (resolved === ".." || resolved.startsWith("../")) {
57
+ return [];
58
+ }
59
+ if (GLOB_CHARACTERS.test(resolved)) {
60
+ return [resolved];
61
+ }
62
+ const withoutTrailingSlash = resolved.replace(/\/+$/, "");
63
+ return withoutTrailingSlash === "." ? ["**"] : [withoutTrailingSlash, `${withoutTrailingSlash}/**`];
64
+ }
@@ -14,11 +14,20 @@ export function presentPublishAuditResult(result) {
14
14
  for (const { packageName, field, subpath, target } of result.unshipped) {
15
15
  logger.out(`\n${packageName}: ${field}["${subpath}"] → ${target} is not shipped by "files"`);
16
16
  }
17
- const problems = result.legacyImportCount + result.unshipped.length;
17
+ for (const { packageName, stylesheet, sources, missingFilesEntries } of result.unreachableStylesheets) {
18
+ logger.out(`\n${packageName}: ${stylesheet} registers no file the tarball ships`);
19
+ for (const { line, pattern } of sources) {
20
+ logger.out(` ${line}: @source "${pattern}"`);
21
+ }
22
+ if (missingFilesEntries.length > 0) {
23
+ logger.out(` not on disk: ${missingFilesEntries.join(", ")} — build the package first`);
24
+ }
25
+ }
26
+ const problems = result.legacyImportCount + result.unshipped.length + result.unreachableStylesheets.length;
18
27
  if (problems > 0) {
19
- logger.out(`\n✖ ${result.legacyImportCount} legacy "#/" import(s), ${result.unshipped.length} unshipped target(s)`);
28
+ logger.out(`\n✖ ${result.legacyImportCount} legacy "#/" import(s), ${result.unshipped.length} unshipped target(s), ${result.unreachableStylesheets.length} stylesheet(s) registering nothing shipped`);
20
29
  }
21
30
  else {
22
- logger.out(`✓ No "#/" imports across ${result.scannedFileCount} file(s); every publish target ships across ${result.packageCount} package(s)`);
31
+ logger.out(`✓ No "#/" imports across ${result.scannedFileCount} file(s); every publish target and stylesheet source ships across ${result.packageCount} package(s)`);
23
32
  }
24
33
  }
@@ -4,7 +4,8 @@ import type { Filesystem } from "#core/filesystem/filesystem";
4
4
  import type { Result } from "#core/result";
5
5
  /**
6
6
  * Reports what would break a consumer's install: a `#/`-prefixed import Node's ESM resolver rejects on
7
- * the floor, and an `exports`/`imports` target the slimmed publish manifest does not ship.
7
+ * the floor, an `exports`/`imports` target the slimmed publish manifest does not ship, and a shipped
8
+ * stylesheet whose `@source` paths reach none of the files that do ship.
8
9
  *
9
10
  * @since 0.12.0
10
11
  */
@@ -1,5 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { scanLegacySubpathImports } from "#audit/publish/domain/legacy-subpath";
3
+ import { missesShippedFiles, scanStylesheetSources } from "#audit/publish/domain/stylesheet-sources";
4
+ import { listShippedFiles } from "#audit/publish/shipped-files";
3
5
  import { AppError, messageFrom } from "#core/errors";
4
6
  import { err, ok } from "#core/result";
5
7
  import { listWorkspacePackageDirectories } from "#core/workspace/resolver";
@@ -8,7 +10,8 @@ import { packageJsonFileName } from "#core/workspace/well-known-files";
8
10
  import { unshippedPublishTargets } from "#pack-slim/domain/transform";
9
11
  /**
10
12
  * Reports what would break a consumer's install: a `#/`-prefixed import Node's ESM resolver rejects on
11
- * the floor, and an `exports`/`imports` target the slimmed publish manifest does not ship.
13
+ * the floor, an `exports`/`imports` target the slimmed publish manifest does not ship, and a shipped
14
+ * stylesheet whose `@source` paths reach none of the files that do ship.
12
15
  *
13
16
  * @since 0.12.0
14
17
  */
@@ -29,6 +32,7 @@ export async function runPublishAudit(fs, args) {
29
32
  }
30
33
  const layout = await listWorkspacePackageDirectories(rootDir, fs, true);
31
34
  const unshipped = [];
35
+ const unreachableStylesheets = [];
32
36
  let packageCount = 0;
33
37
  for (const packageDir of layout.packageDirectoryPathsAbsolute) {
34
38
  const manifestPath = path.join(packageDir, packageJsonFileName);
@@ -44,10 +48,14 @@ export async function runPublishAudit(fs, args) {
44
48
  for (const target of unshippedPublishTargets(manifest)) {
45
49
  unshipped.push({ packageName, field: target.field, subpath: target.subpath, target: target.target });
46
50
  }
51
+ for (const violation of unreachableStylesheetsOf(fs, { rootDir, packageDir, manifest })) {
52
+ unreachableStylesheets.push({ packageName, ...violation });
53
+ }
47
54
  }
48
55
  return ok({
49
56
  legacyImportFiles,
50
57
  unshipped,
58
+ unreachableStylesheets,
51
59
  legacyImportCount,
52
60
  scannedFileCount: sourceFiles.length,
53
61
  packageCount,
@@ -57,6 +65,27 @@ export async function runPublishAudit(fs, args) {
57
65
  return err(new AppError("INFRA_FAILURE", messageFrom(caughtError), caughtError));
58
66
  }
59
67
  }
68
+ // The shipped stylesheets whose `@source` paths register nothing the tarball ships — what a consumer's Tailwind scans.
69
+ function unreachableStylesheetsOf(fs, args) {
70
+ const { rootDir, packageDir, manifest } = args;
71
+ const shipped = listShippedFiles(fs, packageDir, manifest);
72
+ if (shipped === null) {
73
+ return [];
74
+ }
75
+ const violations = [];
76
+ for (const stylesheet of shipped.files.filter((file) => file.endsWith(".css"))) {
77
+ const stylesheetPath = path.join(packageDir, stylesheet);
78
+ const sources = scanStylesheetSources(fs.readFileSync(stylesheetPath, "utf8"));
79
+ if (sources.length > 0 && missesShippedFiles(stylesheet, sources, shipped.files)) {
80
+ violations.push({
81
+ stylesheet: toPosixPath(path.relative(rootDir, stylesheetPath)),
82
+ sources,
83
+ missingFilesEntries: shipped.missingEntries,
84
+ });
85
+ }
86
+ }
87
+ return violations;
88
+ }
60
89
  function toPosixPath(filePath) {
61
90
  return filePath.split(path.sep).join("/");
62
91
  }
@@ -0,0 +1,21 @@
1
+ /** Lists the files a package's slimmed tarball ships, read from disk. */
2
+ import type { Filesystem } from "#core/filesystem/filesystem";
3
+ /**
4
+ * What a package's tarball ships, plus the `files` entries that match nothing on disk.
5
+ *
6
+ * @since 0.13.0
7
+ */
8
+ export interface ShippedFiles {
9
+ /** Package-relative POSIX paths, sorted. */
10
+ readonly files: Array<string>;
11
+ readonly missingEntries: Array<string>;
12
+ }
13
+ /**
14
+ * Lists the files a package ships once the publish step has slimmed it, or `null` with no `files` field to go by.
15
+ *
16
+ * @remarks Applies the same slim as `pack-slim`, so a subtree it drops is absent here too, and leaves out source maps,
17
+ * which the publish step deletes. Without a `files` field npm ships the whole directory, which leaves nothing to judge.
18
+ *
19
+ * @since 0.13.0
20
+ */
21
+ export declare function listShippedFiles(fs: Filesystem, packageDir: string, manifest: Record<string, unknown>): ShippedFiles | null;
@@ -0,0 +1,36 @@
1
+ /** Lists the files a package's slimmed tarball ships, read from disk. */
2
+ import path from "node:path";
3
+ import { isSourceMapFile, slimPublishManifest } from "#pack-slim/domain/transform";
4
+ /**
5
+ * Lists the files a package ships once the publish step has slimmed it, or `null` with no `files` field to go by.
6
+ *
7
+ * @remarks Applies the same slim as `pack-slim`, so a subtree it drops is absent here too, and leaves out source maps,
8
+ * which the publish step deletes. Without a `files` field npm ships the whole directory, which leaves nothing to judge.
9
+ *
10
+ * @since 0.13.0
11
+ */
12
+ export function listShippedFiles(fs, packageDir, manifest) {
13
+ const { manifest: slimmed } = slimPublishManifest(manifest);
14
+ if (!Array.isArray(slimmed.files)) {
15
+ return null;
16
+ }
17
+ const files = new Set();
18
+ const missingEntries = [];
19
+ for (const entry of slimmed.files) {
20
+ if (typeof entry !== "string" || entry.startsWith("!")) {
21
+ continue;
22
+ }
23
+ // An entry names a file, a directory whose whole subtree ships, or a glob.
24
+ const matches = [...fs.globSync(entry, { cwd: packageDir }), ...fs.globSync(`${entry}/**`, { cwd: packageDir })];
25
+ if (matches.length === 0) {
26
+ missingEntries.push(entry);
27
+ continue;
28
+ }
29
+ for (const match of matches) {
30
+ if (fs.statSync(path.join(packageDir, match)).isFile()) {
31
+ files.add(match.split(path.sep).join("/"));
32
+ }
33
+ }
34
+ }
35
+ return { files: [...files].filter((file) => !isSourceMapFile(file)).sort(), missingEntries };
36
+ }
@@ -71,7 +71,12 @@ interface CodefastAuditConfig {
71
71
  links?: CodefastAuditAllowlistConfig | undefined;
72
72
  comments?: CodefastAuditAllowlistConfig | undefined;
73
73
  imports?: CodefastAuditAllowlistConfig | undefined;
74
+ assertions?: CodefastAuditAllowlistConfig | undefined;
74
75
  displayNames?: CodefastAuditAllowlistConfig | undefined;
76
+ constants?: {
77
+ target?: string | undefined;
78
+ allowlist?: Array<string> | undefined;
79
+ } | undefined;
75
80
  }
76
81
  /**
77
82
  * The validated root `codefast.config` shape.
@@ -58,7 +58,9 @@ const codefastAuditConfigSchema = z
58
58
  links: codefastAuditAllowlistConfigSchema.optional(),
59
59
  comments: codefastAuditAllowlistConfigSchema.optional(),
60
60
  imports: codefastAuditAllowlistConfigSchema.optional(),
61
+ assertions: codefastAuditAllowlistConfigSchema.optional(),
61
62
  displayNames: codefastAuditAllowlistConfigSchema.optional(),
63
+ constants: codefastAuditRtlConfigSchema.optional(),
62
64
  })
63
65
  .strict();
64
66
  /**
@@ -32,10 +32,9 @@ export interface Filesystem {
32
32
  readdirSync(filePath: string): Array<string>;
33
33
  readFile(filePath: string, encoding: CliFileEncoding): Promise<string>;
34
34
  writeFile(filePath: string, data: string, encoding: CliFileEncoding): Promise<void>;
35
- readdir(filePath: string, options?: {
36
- recursive?: boolean;
37
- withFileTypes?: boolean;
38
- }): Promise<Array<string> | Array<DirectoryEntry>>;
35
+ readdirEntries(filePath: string, options?: {
36
+ readonly recursive?: boolean | undefined;
37
+ }): Promise<Array<DirectoryEntry>>;
39
38
  globSync(pattern: string, options: {
40
39
  readonly cwd: string;
41
40
  }): Array<string>;
@@ -22,13 +22,7 @@ export const nodeFilesystem = {
22
22
  },
23
23
  readFile: (filePath, enc) => fsPromises.readFile(filePath, enc),
24
24
  writeFile: (filePath, data, enc) => fsPromises.writeFile(filePath, data, enc),
25
- readdir: async (filePath, opts) => {
26
- const raw = await fsPromises.readdir(filePath, opts);
27
- if (!opts?.withFileTypes) {
28
- return raw;
29
- }
30
- return raw;
31
- },
25
+ readdirEntries: (filePath, options) => fsPromises.readdir(filePath, { recursive: options?.recursive ?? false, withFileTypes: true }),
32
26
  globSync: (pattern, options) => fsSync.globSync(pattern, options),
33
27
  rename: (oldPath, newPath) => fsPromises.rename(oldPath, newPath),
34
28
  unlink: (filePath) => fsPromises.unlink(filePath),
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The structural view every module that walks `oxc-parser`'s ESTree output reads nodes through.
3
+ */
4
+ /**
5
+ * An oxc ESTree node: a `type` discriminant, UTF-16 `start`/`end` offsets, and fields read by name.
6
+ *
7
+ * @remarks Structural on purpose: the walkers match node shapes by `type` and read the few fields
8
+ * they need, so the parser's full node union never has to be spelled out.
9
+ *
10
+ * @since 0.13.0
11
+ */
12
+ export interface OxcNode {
13
+ readonly type: string;
14
+ readonly start: number;
15
+ readonly end: number;
16
+ readonly [key: string]: unknown;
17
+ }
18
+ /**
19
+ * Returns whether a value is an ESTree node rather than a scalar, a list or a location record.
20
+ *
21
+ * @since 0.13.0
22
+ */
23
+ export declare function isOxcNode(value: unknown): value is OxcNode;
24
+ /**
25
+ * Returns a parsed program's top-level statements.
26
+ *
27
+ * @remarks Takes `parseSync(...).program` as it comes, so no caller asserts the parser's type onto
28
+ * the structural view.
29
+ *
30
+ * @since 0.13.0
31
+ */
32
+ export declare function programStatements(program: unknown): ReadonlyArray<OxcNode>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The structural view every module that walks `oxc-parser`'s ESTree output reads nodes through.
3
+ */
4
+ /**
5
+ * Returns whether a value is an ESTree node rather than a scalar, a list or a location record.
6
+ *
7
+ * @since 0.13.0
8
+ */
9
+ export function isOxcNode(value) {
10
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
11
+ }
12
+ /**
13
+ * Returns a parsed program's top-level statements.
14
+ *
15
+ * @remarks Takes `parseSync(...).program` as it comes, so no caller asserts the parser's type onto
16
+ * the structural view.
17
+ *
18
+ * @since 0.13.0
19
+ */
20
+ export function programStatements(program) {
21
+ if (!isOxcNode(program) || !Array.isArray(program.body)) {
22
+ return [];
23
+ }
24
+ return program.body.filter(isOxcNode);
25
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Where an offset sits in a source text, as the audits report it.
3
+ */
4
+ /**
5
+ * Returns the one-based line an offset falls on.
6
+ *
7
+ * @since 0.13.0
8
+ */
9
+ export declare function lineOfOffset(sourceText: string, offset: number): number;
10
+ /**
11
+ * Returns a text up to its first line break, so a multi-line node reports as one line.
12
+ *
13
+ * @since 0.13.0
14
+ */
15
+ export declare function firstLineOf(text: string): string;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where an offset sits in a source text, as the audits report it.
3
+ */
4
+ /**
5
+ * Returns the one-based line an offset falls on.
6
+ *
7
+ * @since 0.13.0
8
+ */
9
+ export function lineOfOffset(sourceText, offset) {
10
+ let line = 1;
11
+ for (let index = 0; index < offset; index++) {
12
+ if (sourceText.charCodeAt(index) === 10) {
13
+ line++;
14
+ }
15
+ }
16
+ return line;
17
+ }
18
+ /**
19
+ * Returns a text up to its first line break, so a multi-line node reports as one line.
20
+ *
21
+ * @since 0.13.0
22
+ */
23
+ export function firstLineOf(text) {
24
+ const newlineIndex = text.indexOf("\n");
25
+ return newlineIndex === -1 ? text : text.slice(0, newlineIndex);
26
+ }
@@ -1,5 +1,4 @@
1
1
  import path from "node:path";
2
- import { isDirentList } from "#mirror/domain/dirent-guard";
3
2
  import { normalizePath } from "#mirror/domain/path-normalizer";
4
3
  /**
5
4
  * Creates the `DistFilesystem` the mirror scan uses, backed by a `Filesystem`.
@@ -10,11 +9,8 @@ export function createMirrorDistFilesystem(fs) {
10
9
  return {
11
10
  async listRelativeFilesRecursively(dirPath) {
12
11
  try {
13
- const raw = await fs.readdir(dirPath, { recursive: true, withFileTypes: true });
14
- if (!isDirentList(raw)) {
15
- return [];
16
- }
17
- return raw
12
+ const entries = await fs.readdirEntries(dirPath, { recursive: true });
13
+ return entries
18
14
  .filter((dirent) => dirent.isFile())
19
15
  .map((dirent) => {
20
16
  const fullPath = path.join(dirent.parentPath, dirent.name);
@@ -31,14 +27,8 @@ export function createMirrorDistFilesystem(fs) {
31
27
  },
32
28
  async isDirectoryCssOnly(distDir, dirPath) {
33
29
  try {
34
- const raw = await fs.readdir(path.join(distDir, dirPath), { withFileTypes: true });
35
- if (!isDirentList(raw)) {
36
- return false;
37
- }
38
- if (raw.length === 0) {
39
- return true;
40
- }
41
- return raw.every((dirent) => dirent.isFile() && dirent.name.endsWith(".css"));
30
+ const entries = await fs.readdirEntries(path.join(distDir, dirPath));
31
+ return entries.every((dirent) => dirent.isFile() && dirent.name.endsWith(".css"));
42
32
  }
43
33
  catch {
44
34
  return false;
@@ -115,7 +115,7 @@ async function pruneDist(fs, distDir, write, pkgStats) {
115
115
  if (!fs.existsSync(distDir)) {
116
116
  return;
117
117
  }
118
- const entries = (await fs.readdir(distDir, { recursive: true, withFileTypes: true }));
118
+ const entries = await fs.readdirEntries(distDir, { recursive: true });
119
119
  for (const entry of entries) {
120
120
  if (!entry.isFile()) {
121
121
  continue;
@@ -1,10 +1,13 @@
1
- import type { TagTargetExecutionResult } from "#tag/domain/types";
2
1
  /**
3
2
  * Collects the distinct, non-empty package versions stamped across a run's target results.
4
3
  *
5
4
  * @since 0.11.0
6
5
  */
7
- export declare function extractDistinctVersions(targetResults: Array<TagTargetExecutionResult>): Set<string>;
6
+ export declare function extractDistinctVersions(targetResults: ReadonlyArray<{
7
+ readonly result: {
8
+ readonly version: string;
9
+ } | null;
10
+ }>): Set<string>;
8
11
  /**
9
12
  * Summarizes a set of versions as `"none"`, the single version, or `"mixed"`.
10
13
  *
@@ -1,4 +1,5 @@
1
1
  import { parseSync } from "oxc-parser";
2
+ import { isOxcNode, programStatements } from "#core/oxc-node";
2
3
  import { applyEditsDescending, indentOfLineContaining } from "#core/source-text-edit";
3
4
  /**
4
5
  * Top-level statement kinds that carry a `@since` tag: function, class, interface,
@@ -12,9 +13,6 @@ const TAGGABLE_DECLARATION_TYPES = new Set([
12
13
  "TSEnumDeclaration",
13
14
  "VariableDeclaration",
14
15
  ]);
15
- function isOxcNode(value) {
16
- return typeof value === "object" && value !== null && typeof value.type === "string";
17
- }
18
16
  function identifierName(node) {
19
17
  if (isOxcNode(node) && node.type === "Identifier" && typeof node.name === "string") {
20
18
  return node.name;
@@ -35,7 +33,7 @@ export class TagSinceWriter {
35
33
  applySinceTagsToFile(filePath, version, write) {
36
34
  const sourceText = this.fs.readFileSync(filePath, "utf8");
37
35
  const { program, comments } = parseSync(filePath, sourceText);
38
- const statements = program.body;
36
+ const statements = programStatements(program);
39
37
  const jsDocComments = comments.filter((comment) => comment.type === "Block" && comment.value.startsWith("*"));
40
38
  const edits = [];
41
39
  for (const declaration of this.collectExportedDeclarations(statements)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codefast/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Developer CLI for the Codefast monorepo (arrange, audit, mirror, pack-slim, tag)",
5
5
  "keywords": [
6
6
  "cli",
@@ -54,13 +54,13 @@
54
54
  "@microsoft/tsdoc": "^0.17.0",
55
55
  "commander": "^15.0.0",
56
56
  "jiti": "^2.7.0",
57
- "oxc-parser": "^0.150.0",
57
+ "oxc-parser": "^0.151.0",
58
58
  "picomatch": "^4.0.7",
59
59
  "yaml": "^2.9.1",
60
60
  "zod": "^4.6.5"
61
61
  },
62
62
  "peerDependencies": {
63
- "typescript": "^7.0.2"
63
+ "typescript": ">=7.0.0"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "typescript": {
@@ -68,6 +68,6 @@
68
68
  }
69
69
  },
70
70
  "engines": {
71
- "node": ">=22.12.0"
71
+ "node": ">=24.0.0"
72
72
  }
73
73
  }
@@ -1,10 +0,0 @@
1
- import type { DirectoryEntry } from "#core/filesystem/filesystem";
2
- /**
3
- * Narrow `fs.promises.readdir` overload result to `Dirent[]` when `withFileTypes: true`.
4
- * Accepts both historical checks (`isFile` / `isDirectory`) in one guard.
5
- * Returning `true` for an empty array is intentional: callers treat empty lists
6
- * the same regardless of the element type, so `[]` is safely accepted as `Dirent[]`.
7
- *
8
- * @since 0.3.16-canary.0
9
- */
10
- export declare function isDirentList(x: Array<string> | Array<DirectoryEntry>): x is Array<DirectoryEntry>;
@@ -1,15 +0,0 @@
1
- /**
2
- * Narrow `fs.promises.readdir` overload result to `Dirent[]` when `withFileTypes: true`.
3
- * Accepts both historical checks (`isFile` / `isDirectory`) in one guard.
4
- * Returning `true` for an empty array is intentional: callers treat empty lists
5
- * the same regardless of the element type, so `[]` is safely accepted as `Dirent[]`.
6
- *
7
- * @since 0.3.16-canary.0
8
- */
9
- export function isDirentList(x) {
10
- if (x.length === 0) {
11
- return true;
12
- }
13
- const first = x[0];
14
- return typeof first === "object" && first !== null && "isFile" in first && "isDirectory" in first;
15
- }