@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
@@ -0,0 +1,46 @@
1
+ import type { LayerViolation } from "#audit/domain/types";
2
+ /**
3
+ * The key a layer entry and a module path share: the first path segment, without a module extension.
4
+ *
5
+ * @remarks `errors.ts`, `errors/` and `errors/taxonomy.ts` all key as `errors`, so a layer entry names
6
+ * a family directly under the root — a directory, or a lone module sitting flat.
7
+ *
8
+ * @since 0.14.0
9
+ */
10
+ export declare function layerKeyOf(modulePath: string): string;
11
+ /**
12
+ * Returns why a layer list breaks its contract, or `undefined` when every entry is a family under the root, placed once.
13
+ *
14
+ * @since 0.14.0
15
+ */
16
+ export declare function invalidLayerEntry(layers: ReadonlyArray<ReadonlyArray<string>>): string | undefined;
17
+ /**
18
+ * Where a module sits: its layer's position from the bottom, and the entry that placed it there.
19
+ *
20
+ * @since 0.14.0
21
+ */
22
+ export interface LayerPlacement {
23
+ readonly index: number;
24
+ readonly entry: string;
25
+ }
26
+ /**
27
+ * A package's layers, bottom to top, answering the placement of any module path under the root.
28
+ *
29
+ * @since 0.14.0
30
+ */
31
+ export declare class LayerMap {
32
+ #private;
33
+ constructor(layers: ReadonlyArray<ReadonlyArray<string>>);
34
+ /** The placement of a root-relative module path, or `undefined` when no layer names its family. */
35
+ placementOf(modulePath: string): LayerPlacement | undefined;
36
+ }
37
+ /**
38
+ * Scans one module against its package's layers and returns the violations: the module sitting in no
39
+ * layer, or a value import or re-export whose target sits in a higher layer, or in none.
40
+ *
41
+ * @remarks Type-only imports and re-exports erase at build time and couple nothing, so they pass
42
+ * whichever way they point. Dynamic `import()` counts as a value import wherever it sits.
43
+ *
44
+ * @since 0.14.0
45
+ */
46
+ export declare function auditLayeringSource(filePath: string, modulePath: string, sourceText: string, layers: LayerMap): Array<LayerViolation>;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The layering rule one package's `src/` follows: a value import never points up the configured layers.
3
+ */
4
+ import path from "node:path";
5
+ import { parseSync } from "oxc-parser";
6
+ import { isOxcNode, programStatements } from "#core/oxc-node";
7
+ import { firstLineOf, lineOfOffset } from "#core/source-position";
8
+ const MODULE_EXTENSIONS = [".tsx", ".ts", ".js"];
9
+ function withoutModuleExtension(name) {
10
+ for (const extension of MODULE_EXTENSIONS) {
11
+ if (name.endsWith(extension)) {
12
+ return name.slice(0, -extension.length);
13
+ }
14
+ }
15
+ return name;
16
+ }
17
+ /**
18
+ * The key a layer entry and a module path share: the first path segment, without a module extension.
19
+ *
20
+ * @remarks `errors.ts`, `errors/` and `errors/taxonomy.ts` all key as `errors`, so a layer entry names
21
+ * a family directly under the root — a directory, or a lone module sitting flat.
22
+ *
23
+ * @since 0.14.0
24
+ */
25
+ export function layerKeyOf(modulePath) {
26
+ const [first = ""] = modulePath.split("/");
27
+ return withoutModuleExtension(first);
28
+ }
29
+ /**
30
+ * Returns why a layer list breaks its contract, or `undefined` when every entry is a family under the root, placed once.
31
+ *
32
+ * @since 0.14.0
33
+ */
34
+ export function invalidLayerEntry(layers) {
35
+ const seen = new Map();
36
+ for (const layer of layers) {
37
+ for (const entry of layer) {
38
+ const trimmed = entry.replace(/\/$/, "");
39
+ if (trimmed === "" || trimmed.includes("/")) {
40
+ return `layer entry "${entry}" must name a directory or a module file directly under the root`;
41
+ }
42
+ const key = layerKeyOf(trimmed);
43
+ const earlier = seen.get(key);
44
+ if (earlier !== undefined) {
45
+ return `layer entry "${entry}" is already placed by "${earlier}"`;
46
+ }
47
+ seen.set(key, entry);
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ /**
53
+ * A package's layers, bottom to top, answering the placement of any module path under the root.
54
+ *
55
+ * @since 0.14.0
56
+ */
57
+ export class LayerMap {
58
+ #placementByKey = new Map();
59
+ constructor(layers) {
60
+ layers.forEach((layer, index) => {
61
+ for (const entry of layer) {
62
+ this.#placementByKey.set(layerKeyOf(entry.replace(/\/$/, "")), { index, entry });
63
+ }
64
+ });
65
+ }
66
+ /** The placement of a root-relative module path, or `undefined` when no layer names its family. */
67
+ placementOf(modulePath) {
68
+ return this.#placementByKey.get(layerKeyOf(modulePath));
69
+ }
70
+ }
71
+ function sourceValueOf(node) {
72
+ const source = node.source;
73
+ return isOxcNode(source) && typeof source.value === "string" ? source.value : undefined;
74
+ }
75
+ function everySpecifierIsTypeOnly(node, kindField) {
76
+ const specifiers = Array.isArray(node.specifiers) ? node.specifiers.filter(isOxcNode) : [];
77
+ return specifiers.length > 0 && specifiers.every((specifier) => specifier[kindField] === "type");
78
+ }
79
+ /**
80
+ * The module a top-level statement imports or re-exports at runtime, or `undefined` when it erases at build time.
81
+ */
82
+ function valueSpecifierOf(statement) {
83
+ if (statement.type === "ImportDeclaration") {
84
+ if (statement.importKind === "type" || everySpecifierIsTypeOnly(statement, "importKind")) {
85
+ return undefined;
86
+ }
87
+ return sourceValueOf(statement);
88
+ }
89
+ if (statement.type === "ExportNamedDeclaration") {
90
+ if (statement.exportKind === "type" || everySpecifierIsTypeOnly(statement, "exportKind")) {
91
+ return undefined;
92
+ }
93
+ return sourceValueOf(statement);
94
+ }
95
+ if (statement.type === "ExportAllDeclaration") {
96
+ return statement.exportKind === "type" ? undefined : sourceValueOf(statement);
97
+ }
98
+ return undefined;
99
+ }
100
+ /**
101
+ * The root-relative module path a specifier names, or `undefined` for one outside the root (a package, a
102
+ * relative path climbing out).
103
+ *
104
+ * @remarks A `#` subpath import maps to the root directly, which is how every package's `#*` imports
105
+ * field is declared; a relative import resolves against the importing module.
106
+ */
107
+ function moduleTargetOf(specifier, fromModulePath) {
108
+ if (specifier.startsWith("#")) {
109
+ const target = specifier.slice(1);
110
+ return target === "" ? undefined : target;
111
+ }
112
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
113
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(fromModulePath), specifier));
114
+ return resolved.startsWith("../") ? undefined : resolved;
115
+ }
116
+ return undefined;
117
+ }
118
+ function collectDynamicImports(node, visit) {
119
+ if (node.type === "ImportExpression") {
120
+ const specifier = sourceValueOf(node);
121
+ if (specifier !== undefined) {
122
+ visit(node, specifier);
123
+ }
124
+ }
125
+ for (const value of Object.values(node)) {
126
+ if (Array.isArray(value)) {
127
+ for (const item of value) {
128
+ if (isOxcNode(item)) {
129
+ collectDynamicImports(item, visit);
130
+ }
131
+ }
132
+ }
133
+ else if (isOxcNode(value)) {
134
+ collectDynamicImports(value, visit);
135
+ }
136
+ }
137
+ }
138
+ /**
139
+ * Scans one module against its package's layers and returns the violations: the module sitting in no
140
+ * layer, or a value import or re-export whose target sits in a higher layer, or in none.
141
+ *
142
+ * @remarks Type-only imports and re-exports erase at build time and couple nothing, so they pass
143
+ * whichever way they point. Dynamic `import()` counts as a value import wherever it sits.
144
+ *
145
+ * @since 0.14.0
146
+ */
147
+ export function auditLayeringSource(filePath, modulePath, sourceText, layers) {
148
+ const own = layers.placementOf(modulePath);
149
+ if (own === undefined) {
150
+ return [
151
+ {
152
+ line: 1,
153
+ raw: modulePath,
154
+ reason: `module sits in no configured layer — place "${layerKeyOf(modulePath)}" in one`,
155
+ },
156
+ ];
157
+ }
158
+ const violations = [];
159
+ const check = (node, specifier) => {
160
+ const target = moduleTargetOf(specifier, modulePath);
161
+ if (target === undefined) {
162
+ return;
163
+ }
164
+ const placement = layers.placementOf(target);
165
+ if (placement === undefined) {
166
+ violations.push(violationAt(sourceText, node, `imports "${specifier}", which sits in no configured layer`));
167
+ }
168
+ else if (placement.index > own.index) {
169
+ violations.push(violationAt(sourceText, node, `value import of "${specifier}" points up the layers — ${own.entry} (layer ${String(own.index + 1)}) reaches ${placement.entry} (layer ${String(placement.index + 1)})`));
170
+ }
171
+ };
172
+ const { program } = parseSync(filePath, sourceText);
173
+ for (const statement of programStatements(program)) {
174
+ const specifier = valueSpecifierOf(statement);
175
+ if (specifier !== undefined) {
176
+ check(statement, specifier);
177
+ }
178
+ }
179
+ if (isOxcNode(program)) {
180
+ collectDynamicImports(program, check);
181
+ }
182
+ violations.sort((a, b) => a.line - b.line);
183
+ return violations;
184
+ }
185
+ function violationAt(sourceText, node, reason) {
186
+ return {
187
+ line: lineOfOffset(sourceText, node.start),
188
+ raw: firstLineOf(sourceText.slice(node.start, node.end)),
189
+ reason,
190
+ };
191
+ }
@@ -0,0 +1,7 @@
1
+ import type { LayersAuditResult } from "#audit/domain/types";
2
+ /**
3
+ * Human-readable layering report.
4
+ *
5
+ * @since 0.14.0
6
+ */
7
+ export declare function presentLayersAuditResult(result: LayersAuditResult): void;
@@ -0,0 +1,21 @@
1
+ import { logger } from "#core/logger";
2
+ /**
3
+ * Human-readable layering report.
4
+ *
5
+ * @since 0.14.0
6
+ */
7
+ export function presentLayersAuditResult(result) {
8
+ for (const file of result.files) {
9
+ logger.out(`\n${file.relativePath}`);
10
+ for (const { line, raw, reason } of file.violations) {
11
+ logger.out(` ${line}: ${raw} → ${reason}`);
12
+ }
13
+ }
14
+ const allowlistSuffix = result.allowlistedCount > 0 ? ` (${result.allowlistedCount} allowlisted)` : "";
15
+ if (result.violationCount > 0) {
16
+ logger.out(`\n✖ ${result.violationCount} layering violation(s)${allowlistSuffix}`);
17
+ }
18
+ else {
19
+ logger.out(`✓ Every value import points down the layers across ${result.scannedFileCount} file(s) in ${result.packageCount} package(s)${allowlistSuffix}`);
20
+ }
21
+ }
@@ -0,0 +1,25 @@
1
+ import type { LayersAuditPackage } from "#audit/layers/cli-schema";
2
+ import type { AuditCommandPrelude } from "#audit/prepare";
3
+ import { AppError } from "#core/errors";
4
+ import type { Filesystem } from "#core/filesystem/filesystem";
5
+ import type { Result } from "#core/result";
6
+ /**
7
+ * The shared audit prelude plus the layered packages `audit.layers.packages` names, resolved to their roots.
8
+ *
9
+ * @since 0.14.0
10
+ */
11
+ export type LayersAuditPrelude = AuditCommandPrelude & {
12
+ readonly packages: ReadonlyArray<LayersAuditPackage>;
13
+ };
14
+ /**
15
+ * Loads config and resolves every package `audit.layers.packages` names to the root its layers sit under.
16
+ *
17
+ * @remarks A configured name no workspace package carries, an entry nested below the root or placed
18
+ * twice, and a root that does not exist are each reported here, before anything is scanned.
19
+ *
20
+ * @since 0.14.0
21
+ */
22
+ export declare function prepareLayersAudit(fs: Filesystem, args: {
23
+ readonly currentWorkingDirectory: string;
24
+ readonly rawTarget: string | undefined;
25
+ }): Promise<Result<LayersAuditPrelude, AppError>>;
@@ -0,0 +1,66 @@
1
+ import path from "node:path";
2
+ import { invalidLayerEntry } from "#audit/layers/domain/layering";
3
+ import { prepareRepoRootAuditWith } from "#audit/prepare";
4
+ import { AppError, messageFrom } from "#core/errors";
5
+ import { err, ok } from "#core/result";
6
+ import { listWorkspacePackageDirectories } from "#core/workspace/resolver";
7
+ import { packageJsonFileName } from "#core/workspace/well-known-files";
8
+ const DEFAULT_LAYERS_ROOT = "src";
9
+ async function workspacePackageDirectoriesByName(rootDir, fs) {
10
+ const layout = await listWorkspacePackageDirectories(rootDir, fs, true);
11
+ const byName = new Map();
12
+ for (const directory of layout.packageDirectoryPathsAbsolute) {
13
+ const manifestPath = path.join(directory, packageJsonFileName);
14
+ if (!fs.existsSync(manifestPath)) {
15
+ continue;
16
+ }
17
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
18
+ if (typeof manifest === "object" && manifest !== null && "name" in manifest && typeof manifest.name === "string") {
19
+ byName.set(manifest.name, directory);
20
+ }
21
+ }
22
+ return byName;
23
+ }
24
+ /**
25
+ * Loads config and resolves every package `audit.layers.packages` names to the root its layers sit under.
26
+ *
27
+ * @remarks A configured name no workspace package carries, an entry nested below the root or placed
28
+ * twice, and a root that does not exist are each reported here, before anything is scanned.
29
+ *
30
+ * @since 0.14.0
31
+ */
32
+ export async function prepareLayersAudit(fs, args) {
33
+ return prepareRepoRootAuditWith(fs, args, async (config, rootDir) => {
34
+ const layersConfig = config.audit?.layers;
35
+ const allowlist = layersConfig?.allowlist ?? [];
36
+ const configured = Object.entries(layersConfig?.packages ?? {});
37
+ if (configured.length === 0) {
38
+ return ok({ allowlist, packages: [] });
39
+ }
40
+ let directoryByName;
41
+ try {
42
+ directoryByName = await workspacePackageDirectoriesByName(rootDir, fs);
43
+ }
44
+ catch (caughtError) {
45
+ return err(new AppError("INFRA_FAILURE", messageFrom(caughtError), caughtError));
46
+ }
47
+ const packages = [];
48
+ for (const [name, packageConfig] of configured) {
49
+ const directory = directoryByName.get(name);
50
+ if (directory === undefined) {
51
+ return err(new AppError("VALIDATION_ERROR", `audit.layers.packages["${name}"]: no workspace package has that name`));
52
+ }
53
+ const invalid = invalidLayerEntry(packageConfig.layers);
54
+ if (invalid !== undefined) {
55
+ return err(new AppError("VALIDATION_ERROR", `audit.layers.packages["${name}"]: ${invalid}`));
56
+ }
57
+ const rootPath = path.join(directory, packageConfig.root ?? DEFAULT_LAYERS_ROOT);
58
+ if (!fs.existsSync(rootPath)) {
59
+ return err(new AppError("NOT_FOUND", `Not found: ${rootPath}`));
60
+ }
61
+ packages.push({ name, rootPath: fs.canonicalPathSync(rootPath), layers: packageConfig.layers });
62
+ }
63
+ packages.sort((left, right) => left.name.localeCompare(right.name));
64
+ return ok({ allowlist, packages });
65
+ });
66
+ }
@@ -0,0 +1,19 @@
1
+ import type { LayersAuditResult } from "#audit/domain/types";
2
+ import type { LayersAuditPackage } from "#audit/layers/cli-schema";
3
+ import { AppError } from "#core/errors";
4
+ import type { Filesystem } from "#core/filesystem/filesystem";
5
+ import type { Result } from "#core/result";
6
+ /**
7
+ * Scans every layered package the target reaches for value imports that point up its layers.
8
+ *
9
+ * @remarks The target narrows the scan: the repo root reaches every package, a package directory
10
+ * reaches that package, and a path under a package's root reaches the modules beneath it.
11
+ *
12
+ * @since 0.14.0
13
+ */
14
+ export declare function runLayersAudit(fs: Filesystem, args: {
15
+ readonly rootDir: string;
16
+ readonly targetPath: string;
17
+ readonly allowlist: ReadonlyArray<string>;
18
+ readonly packages: ReadonlyArray<LayersAuditPackage>;
19
+ }): Result<LayersAuditResult, AppError>;
@@ -0,0 +1,62 @@
1
+ import path from "node:path";
2
+ import { auditLayeringSource, LayerMap } from "#audit/layers/domain/layering";
3
+ import { AppError, messageFrom } from "#core/errors";
4
+ import { err, ok } from "#core/result";
5
+ import { walkTsxFiles } from "#core/workspace/typescript-walk";
6
+ /**
7
+ * Scans every layered package the target reaches for value imports that point up its layers.
8
+ *
9
+ * @remarks The target narrows the scan: the repo root reaches every package, a package directory
10
+ * reaches that package, and a path under a package's root reaches the modules beneath it.
11
+ *
12
+ * @since 0.14.0
13
+ */
14
+ export function runLayersAudit(fs, args) {
15
+ try {
16
+ const allowlist = new Set(args.allowlist);
17
+ const files = [];
18
+ let violationCount = 0;
19
+ let allowlistedCount = 0;
20
+ let scannedFileCount = 0;
21
+ let packageCount = 0;
22
+ for (const layeredPackage of args.packages) {
23
+ const targetInsideRoot = isWithin(layeredPackage.rootPath, args.targetPath);
24
+ if (!targetInsideRoot && !isWithin(args.targetPath, layeredPackage.rootPath)) {
25
+ continue;
26
+ }
27
+ packageCount++;
28
+ const layers = new LayerMap(layeredPackage.layers);
29
+ const scanRoot = targetInsideRoot ? args.targetPath : layeredPackage.rootPath;
30
+ const filesToScan = fs.statSync(scanRoot).isFile() ? [scanRoot] : walkTsxFiles(scanRoot, fs);
31
+ for (const absolutePath of filesToScan) {
32
+ scannedFileCount++;
33
+ const relativePath = toPosixPath(path.relative(args.rootDir, absolutePath));
34
+ const modulePath = toPosixPath(path.relative(layeredPackage.rootPath, absolutePath));
35
+ const content = fs.readFileSync(absolutePath, "utf8");
36
+ const remaining = auditLayeringSource(absolutePath, modulePath, content, layers).filter(({ raw }) => {
37
+ const isAllowed = allowlist.has(raw) || allowlist.has(`${relativePath}:${raw}`);
38
+ if (isAllowed) {
39
+ allowlistedCount++;
40
+ }
41
+ return !isAllowed;
42
+ });
43
+ if (remaining.length === 0) {
44
+ continue;
45
+ }
46
+ violationCount += remaining.length;
47
+ files.push({ relativePath, violations: remaining });
48
+ }
49
+ }
50
+ return ok({ files, violationCount, allowlistedCount, scannedFileCount, packageCount });
51
+ }
52
+ catch (caughtError) {
53
+ return err(new AppError("INFRA_FAILURE", messageFrom(caughtError), caughtError));
54
+ }
55
+ }
56
+ function isWithin(parentPath, childPath) {
57
+ const relative = path.relative(parentPath, childPath);
58
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
59
+ }
60
+ function toPosixPath(filePath) {
61
+ return filePath.split(path.sep).join("/");
62
+ }
@@ -28,4 +28,16 @@ export declare function resolveRepoRelativePath(rootDir: string, maybeRelative:
28
28
  export declare function prepareRepoRootAudit(fs: Filesystem, args: {
29
29
  readonly currentWorkingDirectory: string;
30
30
  readonly rawTarget: string | undefined;
31
- }, selectAllowlist: (config: CodefastConfig) => ReadonlyArray<string>): Promise<Result<AuditCommandPrelude, AppError>>;
31
+ }, selectAllowlist: (config: CodefastConfig) => ReadonlyArray<string>): Promise<Result<AuditCommandPrelude, AppError>>;
32
+ /**
33
+ * Loads config and resolves the repo root as the scan target, taking whatever the caller selects from the config.
34
+ *
35
+ * @remarks The selection runs with the root resolved, so it may read the workspace and refuse a
36
+ * config that names what the workspace does not hold, before anything is scanned.
37
+ *
38
+ * @since 0.14.0
39
+ */
40
+ export declare function prepareRepoRootAuditWith<Selected extends Pick<AuditCommandPrelude, "allowlist">>(fs: Filesystem, args: {
41
+ readonly currentWorkingDirectory: string;
42
+ readonly rawTarget: string | undefined;
43
+ }, select: (config: CodefastConfig, rootDir: string) => Promise<Result<Selected, AppError>>): Promise<Result<Omit<AuditCommandPrelude, "allowlist"> & Selected, AppError>>;
@@ -19,6 +19,17 @@ export function resolveRepoRelativePath(rootDir, maybeRelative) {
19
19
  * @since 0.11.0
20
20
  */
21
21
  export async function prepareRepoRootAudit(fs, args, selectAllowlist) {
22
+ return prepareRepoRootAuditWith(fs, args, (config) => Promise.resolve(ok({ allowlist: selectAllowlist(config) })));
23
+ }
24
+ /**
25
+ * Loads config and resolves the repo root as the scan target, taking whatever the caller selects from the config.
26
+ *
27
+ * @remarks The selection runs with the root resolved, so it may read the workspace and refuse a
28
+ * config that names what the workspace does not hold, before anything is scanned.
29
+ *
30
+ * @since 0.14.0
31
+ */
32
+ export async function prepareRepoRootAuditWith(fs, args, select) {
22
33
  let rootDir;
23
34
  try {
24
35
  rootDir = fs.canonicalPathSync(resolveProjectRoot(args.currentWorkingDirectory, fs).rootDir);
@@ -34,9 +45,13 @@ export async function prepareRepoRootAudit(fs, args, selectAllowlist) {
34
45
  if (!fs.existsSync(targetPath)) {
35
46
  return err(new AppError("NOT_FOUND", `Not found: ${targetPath}`));
36
47
  }
48
+ const selected = await select(loadedOutcome.value.config, rootDir);
49
+ if (!selected.ok) {
50
+ return selected;
51
+ }
37
52
  return ok({
38
53
  rootDir,
39
54
  targetPath: fs.canonicalPathSync(targetPath),
40
- allowlist: selectAllowlist(loadedOutcome.value.config),
55
+ ...selected.value,
41
56
  });
42
57
  }
@@ -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
  */