@codefast/cli 0.12.0 → 0.14.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 (66) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +120 -16
  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 +46 -1
  18. package/dist/audit/comments/domain/comment-content.d.ts +12 -0
  19. package/dist/audit/comments/domain/comment-content.js +17 -4
  20. package/dist/audit/display-names/domain/display-names.js +1 -9
  21. package/dist/audit/domain/types.d.ts +86 -0
  22. package/dist/audit/imports/domain/import-policy.js +4 -18
  23. package/dist/audit/layers/cli-result.d.ts +13 -0
  24. package/dist/audit/layers/cli-result.js +22 -0
  25. package/dist/audit/layers/cli-schema.d.ts +29 -0
  26. package/dist/audit/layers/cli-schema.js +17 -0
  27. package/dist/audit/layers/domain/layering.d.ts +46 -0
  28. package/dist/audit/layers/domain/layering.js +191 -0
  29. package/dist/audit/layers/output.d.ts +7 -0
  30. package/dist/audit/layers/output.js +21 -0
  31. package/dist/audit/layers/prepare.d.ts +25 -0
  32. package/dist/audit/layers/prepare.js +66 -0
  33. package/dist/audit/layers/run.d.ts +19 -0
  34. package/dist/audit/layers/run.js +62 -0
  35. package/dist/audit/prepare.d.ts +13 -1
  36. package/dist/audit/prepare.js +16 -1
  37. package/dist/audit/publish/cli-result.d.ts +1 -1
  38. package/dist/audit/publish/cli-result.js +6 -3
  39. package/dist/audit/publish/domain/stylesheet-sources.d.ts +22 -0
  40. package/dist/audit/publish/domain/stylesheet-sources.js +64 -0
  41. package/dist/audit/publish/output.js +12 -3
  42. package/dist/audit/publish/run.d.ts +2 -1
  43. package/dist/audit/publish/run.js +30 -1
  44. package/dist/audit/publish/shipped-files.d.ts +21 -0
  45. package/dist/audit/publish/shipped-files.js +36 -0
  46. package/dist/core/config/schema.d.ts +13 -0
  47. package/dist/core/config/schema.js +14 -0
  48. package/dist/core/filesystem/filesystem.d.ts +3 -4
  49. package/dist/core/filesystem/node.js +1 -7
  50. package/dist/core/oxc-node.d.ts +32 -0
  51. package/dist/core/oxc-node.js +25 -0
  52. package/dist/core/source-position.d.ts +15 -0
  53. package/dist/core/source-position.js +26 -0
  54. package/dist/mirror/dist-filesystem-node.js +4 -14
  55. package/dist/pack-slim/run.js +1 -1
  56. package/dist/tag/cli-result.d.ts +3 -0
  57. package/dist/tag/cli-result.js +8 -2
  58. package/dist/tag/domain/types.d.ts +15 -0
  59. package/dist/tag/domain/version-summary.d.ts +5 -2
  60. package/dist/tag/output.js +13 -6
  61. package/dist/tag/run.js +2 -0
  62. package/dist/tag/writer/since-writer.d.ts +5 -0
  63. package/dist/tag/writer/since-writer.js +43 -13
  64. package/package.json +4 -4
  65. package/dist/mirror/domain/dirent-guard.d.ts +0 -10
  66. package/dist/mirror/domain/dirent-guard.js +0 -15
@@ -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
+ }
@@ -62,6 +62,17 @@ export interface CodefastArrangeConfig {
62
62
  interface CodefastAuditAllowlistConfig {
63
63
  allowlist?: Array<string> | undefined;
64
64
  }
65
+ /** One package's layering: `layers` bottom to top, each naming the directories and module files it holds under `root`. */
66
+ interface CodefastAuditLayersPackageConfig {
67
+ /** The directory the layers sit under, relative to the package directory. Defaults to `src`. */
68
+ root?: string | undefined;
69
+ layers: Array<Array<string>>;
70
+ }
71
+ /** The `audit layers` defaults: the layered packages by name, and the entries to ignore. */
72
+ interface CodefastAuditLayersConfig {
73
+ packages?: Record<string, CodefastAuditLayersPackageConfig> | undefined;
74
+ allowlist?: Array<string> | undefined;
75
+ }
65
76
  /** Per-audit defaults grouped under `audit`; the scan always starts at the repo root. */
66
77
  interface CodefastAuditConfig {
67
78
  rtl?: {
@@ -71,7 +82,9 @@ interface CodefastAuditConfig {
71
82
  links?: CodefastAuditAllowlistConfig | undefined;
72
83
  comments?: CodefastAuditAllowlistConfig | undefined;
73
84
  imports?: CodefastAuditAllowlistConfig | undefined;
85
+ assertions?: CodefastAuditAllowlistConfig | undefined;
74
86
  displayNames?: CodefastAuditAllowlistConfig | undefined;
87
+ layers?: CodefastAuditLayersConfig | undefined;
75
88
  }
76
89
  /**
77
90
  * The validated root `codefast.config` shape.
@@ -52,13 +52,27 @@ const codefastAuditAllowlistConfigSchema = z
52
52
  allowlist: z.array(z.string()).optional(),
53
53
  })
54
54
  .strict();
55
+ const codefastAuditLayersPackageConfigSchema = z
56
+ .object({
57
+ root: z.string().min(1).optional(),
58
+ layers: z.array(z.array(z.string().min(1)).min(1)).min(1),
59
+ })
60
+ .strict();
61
+ const codefastAuditLayersConfigSchema = z
62
+ .object({
63
+ packages: z.record(z.string(), codefastAuditLayersPackageConfigSchema).optional(),
64
+ allowlist: z.array(z.string()).optional(),
65
+ })
66
+ .strict();
55
67
  const codefastAuditConfigSchema = z
56
68
  .object({
57
69
  rtl: codefastAuditRtlConfigSchema.optional(),
58
70
  links: codefastAuditAllowlistConfigSchema.optional(),
59
71
  comments: codefastAuditAllowlistConfigSchema.optional(),
60
72
  imports: codefastAuditAllowlistConfigSchema.optional(),
73
+ assertions: codefastAuditAllowlistConfigSchema.optional(),
61
74
  displayNames: codefastAuditAllowlistConfigSchema.optional(),
75
+ layers: codefastAuditLayersConfigSchema.optional(),
62
76
  })
63
77
  .strict();
64
78
  /**
@@ -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;
@@ -2,6 +2,9 @@ import type { TagResult } from "#tag/domain/types";
2
2
  /**
3
3
  * Maps a tag run's result to the process exit code.
4
4
  *
5
+ * @remarks A blocked declaration fails the run: once its release ships unstamped, the only stamp a
6
+ * later run can add names a version that did not introduce it.
7
+ *
5
8
  * @since 0.3.16-canary.0
6
9
  */
7
10
  export declare function exitCodeForTagResult(result: TagResult): number;
@@ -2,6 +2,9 @@ import { CLI_EXIT_GENERAL_ERROR, CLI_EXIT_SUCCESS } from "#core/exit-codes";
2
2
  /**
3
3
  * Maps a tag run's result to the process exit code.
4
4
  *
5
+ * @remarks A blocked declaration fails the run: once its release ships unstamped, the only stamp a
6
+ * later run can add names a version that did not introduce it.
7
+ *
5
8
  * @since 0.3.16-canary.0
6
9
  */
7
10
  export function exitCodeForTagResult(result) {
@@ -9,7 +12,10 @@ export function exitCodeForTagResult(result) {
9
12
  return CLI_EXIT_GENERAL_ERROR;
10
13
  }
11
14
  const hasRunErrors = result.targetResults.some((targetResult) => targetResult.runError !== null);
12
- return hasRunErrors || result.hookError !== null ? CLI_EXIT_GENERAL_ERROR : CLI_EXIT_SUCCESS;
15
+ const hasBlockedDeclarations = result.blockedDeclarations.length > 0;
16
+ return hasRunErrors || hasBlockedDeclarations || result.hookError !== null
17
+ ? CLI_EXIT_GENERAL_ERROR
18
+ : CLI_EXIT_SUCCESS;
13
19
  }
14
20
  /**
15
21
  * Serializes a tag run's result as the `--json` output string.
@@ -19,7 +25,7 @@ export function exitCodeForTagResult(result) {
19
25
  export function formatTagJsonOutput(result, rootDir) {
20
26
  return JSON.stringify({
21
27
  schemaVersion: 1,
22
- ok: result.hookError === null,
28
+ ok: exitCodeForTagResult(result) === CLI_EXIT_SUCCESS,
23
29
  cwd: rootDir,
24
30
  result,
25
31
  });
@@ -1,4 +1,17 @@
1
1
  import type { CodefastConfig } from "#core/config/schema";
2
+ /**
3
+ * An exported declaration left unstamped because it has no doc block and a `//` comment holds the line above it.
4
+ *
5
+ * @remarks A block written there would stack under a note or split a directive from the code it governs, so the
6
+ * writer leaves the whole file as it is until a person writes that doc block.
7
+ *
8
+ * @since 0.14.0
9
+ */
10
+ export type TagBlockedDeclaration = {
11
+ filePath: string;
12
+ line: number;
13
+ name: string;
14
+ };
2
15
  /**
3
16
  * Per-file outcome of a tag run.
4
17
  *
@@ -7,6 +20,7 @@ import type { CodefastConfig } from "#core/config/schema";
7
20
  export type TagFileResult = {
8
21
  filePath: string;
9
22
  taggedDeclarations: number;
23
+ blockedDeclarations: Array<TagBlockedDeclaration>;
10
24
  changed: boolean;
11
25
  };
12
26
  /**
@@ -93,6 +107,7 @@ export type TagResult = {
93
107
  filesScanned: number;
94
108
  filesChanged: number;
95
109
  taggedDeclarations: number;
110
+ blockedDeclarations: Array<TagBlockedDeclaration>;
96
111
  versionSummary: string;
97
112
  distinctVersions: Array<string>;
98
113
  modifiedFiles: Array<string>;
@@ -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,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import { logger } from "#core/logger";
2
3
  /**
3
4
  * A progress listener that prints a line as each tag target starts and completes.
@@ -37,7 +38,7 @@ export function presentTagResult(result, rootDir) {
37
38
  logger.err("No packages found in workspace. Check your pnpm-workspace.yaml or provide an explicit target path.");
38
39
  return;
39
40
  }
40
- const warningsAndErrorsSection = formatWarningsAndErrors(result);
41
+ const warningsAndErrorsSection = formatWarningsAndErrors(result, rootDir);
41
42
  if (warningsAndErrorsSection) {
42
43
  logger.err(warningsAndErrorsSection);
43
44
  }
@@ -46,13 +47,16 @@ export function presentTagResult(result, rootDir) {
46
47
  function withColorizedLine(line, colorCode) {
47
48
  return `${colorCode}${line}${colorReset}`;
48
49
  }
49
- function warningsAndErrorsFromResult(result) {
50
+ function warningsAndErrorsFromResult(result, rootDir) {
50
51
  const entries = [];
51
52
  for (const targetResult of result.targetResults) {
52
53
  if (targetResult.runError) {
53
54
  entries.push(targetResult.runError);
54
55
  }
55
56
  }
57
+ for (const blocked of result.blockedDeclarations) {
58
+ entries.push(`${path.relative(rootDir, blocked.filePath)}:${blocked.line} \`${blocked.name}\` left unstamped: it has no doc block and a // comment holds the line above it — write the block by hand, then rerun`);
59
+ }
56
60
  if (result.hookError) {
57
61
  entries.push(result.hookError);
58
62
  }
@@ -72,8 +76,8 @@ function formatTargetTable(targets, rootDir) {
72
76
  }
73
77
  return lines.join("\n");
74
78
  }
75
- function formatWarningsAndErrors(result) {
76
- const entries = warningsAndErrorsFromResult(result);
79
+ function formatWarningsAndErrors(result, rootDir) {
80
+ const entries = warningsAndErrorsFromResult(result, rootDir);
77
81
  if (entries.length === 0) {
78
82
  return null;
79
83
  }
@@ -89,10 +93,13 @@ function formatSummary(result) {
89
93
  const versionSuffix = result.versionSummary === "mixed" && result.distinctVersions.length > 0
90
94
  ? ` [${result.distinctVersions.join(", ")}]`
91
95
  : "";
92
- const hasError = result.targetResults.some((targetResult) => targetResult.runError !== null) || result.hookError !== null;
96
+ const hasError = result.targetResults.some((targetResult) => targetResult.runError !== null) ||
97
+ result.blockedDeclarations.length > 0 ||
98
+ result.hookError !== null;
93
99
  const summaryColor = hasError ? colors.red : isDryRun ? colors.yellow : colors.green;
100
+ const blockedSuffix = result.blockedDeclarations.length > 0 ? ` blocked=${result.blockedDeclarations.length}` : "";
94
101
  const lines = [
95
- withColorizedLine(`${summaryPrefix} version=${result.versionSummary}${versionSuffix} files=${result.filesChanged}/${result.filesScanned} declarations=${result.taggedDeclarations}`, summaryColor),
102
+ withColorizedLine(`${summaryPrefix} version=${result.versionSummary}${versionSuffix} files=${result.filesChanged}/${result.filesScanned} declarations=${result.taggedDeclarations}${blockedSuffix}`, summaryColor),
96
103
  ];
97
104
  if (result.skippedPackages.length > 0) {
98
105
  lines.push(`[tag] Skipped: ${result.skippedPackages.length} package(s)`);
package/dist/tag/run.js CHANGED
@@ -33,6 +33,7 @@ export async function runTag(fs, input) {
33
33
  taggedDeclarations += runResult.taggedDeclarations;
34
34
  }
35
35
  const modifiedFiles = allFileResults.filter((entry) => entry.changed).map((entry) => entry.filePath);
36
+ const blockedDeclarations = allFileResults.flatMap((entry) => entry.blockedDeclarations);
36
37
  const hookError = input.write && modifiedFiles.length > 0
37
38
  ? await runTagOnAfterWriteHook(tagConfig?.onAfterWrite, modifiedFiles)
38
39
  : null;
@@ -46,6 +47,7 @@ export async function runTag(fs, input) {
46
47
  filesScanned,
47
48
  filesChanged,
48
49
  taggedDeclarations,
50
+ blockedDeclarations,
49
51
  versionSummary: summarizeVersions(versionsSet),
50
52
  distinctVersions,
51
53
  modifiedFiles,
@@ -28,5 +28,10 @@ export declare class TagSinceWriter {
28
28
  private jsDocHasSinceTag;
29
29
  private makeJSDocSinceLine;
30
30
  private makeSinceOnlyJSDocBlock;
31
+ /**
32
+ * Returns whether the line above `anchor` holds a `//` note, which a fresh block would stack under,
33
+ * or a directive, which would then govern the block instead of the declaration.
34
+ */
35
+ private isDocBlockLineTaken;
31
36
  private makeDeclarationSinceLine;
32
37
  }
@@ -1,26 +1,35 @@
1
1
  import { parseSync } from "oxc-parser";
2
+ import { isDirectiveLine, isNoteLine } from "#audit/comments/domain/comment-content";
3
+ import { isOxcNode, programStatements } from "#core/oxc-node";
4
+ import { lineOfOffset } from "#core/source-position";
2
5
  import { applyEditsDescending, indentOfLineContaining } from "#core/source-text-edit";
3
6
  /**
4
- * Top-level statement kinds that carry a `@since` tag: function, class, interface,
5
- * type alias, enum, and variable declaration.
7
+ * Top-level statement kinds that carry a `@since` tag. `TSDeclareFunction` is a function with no body —
8
+ * an overload signature or a `declare function` — which the `.d.ts` keeps with its own doc block.
6
9
  */
7
10
  const TAGGABLE_DECLARATION_TYPES = new Set([
8
11
  "FunctionDeclaration",
12
+ "TSDeclareFunction",
9
13
  "ClassDeclaration",
10
14
  "TSInterfaceDeclaration",
11
15
  "TSTypeAliasDeclaration",
12
16
  "TSEnumDeclaration",
13
17
  "VariableDeclaration",
14
18
  ]);
15
- function isOxcNode(value) {
16
- return typeof value === "object" && value !== null && typeof value.type === "string";
17
- }
18
19
  function identifierName(node) {
19
20
  if (isOxcNode(node) && node.type === "Identifier" && typeof node.name === "string") {
20
21
  return node.name;
21
22
  }
22
23
  return undefined;
23
24
  }
25
+ function lineAboveOffset(sourceText, offset) {
26
+ const lineStart = sourceText.lastIndexOf("\n", offset - 1) + 1;
27
+ if (lineStart === 0) {
28
+ return undefined;
29
+ }
30
+ const lineAboveStart = lineStart >= 2 ? sourceText.lastIndexOf("\n", lineStart - 2) + 1 : 0;
31
+ return sourceText.slice(lineAboveStart, lineStart - 1);
32
+ }
24
33
  /**
25
34
  * The writer that adds missing `@since` tags to a file's exported declarations.
26
35
  *
@@ -35,23 +44,37 @@ export class TagSinceWriter {
35
44
  applySinceTagsToFile(filePath, version, write) {
36
45
  const sourceText = this.fs.readFileSync(filePath, "utf8");
37
46
  const { program, comments } = parseSync(filePath, sourceText);
38
- const statements = program.body;
47
+ const statements = programStatements(program);
39
48
  const jsDocComments = comments.filter((comment) => comment.type === "Block" && comment.value.startsWith("*"));
40
49
  const edits = [];
50
+ const blockedDeclarations = [];
41
51
  for (const declaration of this.collectExportedDeclarations(statements)) {
42
- const edit = this.makeDeclarationSinceLine(declaration, jsDocComments, sourceText, version);
52
+ const existing = this.associatedJsDoc(declaration, jsDocComments, sourceText);
53
+ if (existing === undefined && this.isDocBlockLineTaken(declaration, sourceText)) {
54
+ const names = this.taggableDeclarationOf(declaration)?.names ?? [];
55
+ blockedDeclarations.push({
56
+ filePath,
57
+ line: lineOfOffset(sourceText, declaration.start),
58
+ name: names.length > 0 ? names.join(", ") : "default",
59
+ });
60
+ continue;
61
+ }
62
+ const edit = this.makeDeclarationSinceLine(declaration, existing, sourceText, version);
43
63
  if (edit) {
44
64
  edits.push(edit);
45
65
  }
46
66
  }
47
- if (edits.length > 0 && write) {
48
- const updated = applyEditsDescending(sourceText, edits);
67
+ // A blocked declaration holds its whole file back, so every reported line matches the file on disk.
68
+ const appliedEdits = blockedDeclarations.length > 0 ? [] : edits;
69
+ if (appliedEdits.length > 0 && write) {
70
+ const updated = applyEditsDescending(sourceText, appliedEdits);
49
71
  this.fs.writeFileSync(filePath, updated, "utf8");
50
72
  }
51
73
  return {
52
74
  filePath,
53
- taggedDeclarations: edits.length,
54
- changed: edits.length > 0,
75
+ taggedDeclarations: appliedEdits.length,
76
+ blockedDeclarations,
77
+ changed: appliedEdits.length > 0,
55
78
  };
56
79
  }
57
80
  /**
@@ -213,8 +236,15 @@ export class TagSinceWriter {
213
236
  const tag = this.sinceDocumentationTag;
214
237
  return `/**\n${declarationIndent} * ${tag} ${version}\n${declarationIndent} */`;
215
238
  }
216
- makeDeclarationSinceLine(anchor, jsDocComments, sourceText, version) {
217
- const existing = this.associatedJsDoc(anchor, jsDocComments, sourceText);
239
+ /**
240
+ * Returns whether the line above `anchor` holds a `//` note, which a fresh block would stack under,
241
+ * or a directive, which would then govern the block instead of the declaration.
242
+ */
243
+ isDocBlockLineTaken(anchor, sourceText) {
244
+ const lineAbove = lineAboveOffset(sourceText, anchor.start);
245
+ return lineAbove !== undefined && (isNoteLine(lineAbove) || isDirectiveLine(lineAbove));
246
+ }
247
+ makeDeclarationSinceLine(anchor, existing, sourceText, version) {
218
248
  if (existing) {
219
249
  if (this.jsDocHasSinceTag(existing)) {
220
250
  return undefined;