@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,122 @@
1
+ import { parseSync } from "oxc-parser";
2
+ import { isOxcNode } from "#core/oxc-node";
3
+ import { firstLineOf, lineOfOffset } from "#core/source-position";
4
+ /**
5
+ * The comment that keeps one double assertion, with the reason it has to stay.
6
+ *
7
+ * @remarks It covers an assertion on its own line or the line below it, and must carry a reason
8
+ * after the colon; one that covers nothing is reported, so a kept assertion cannot outlive its cause.
9
+ *
10
+ * @since 0.13.0
11
+ */
12
+ export const DOUBLE_ASSERTION_DIRECTIVE = "codefast-allow-double-assertion";
13
+ const DOUBLE_ASSERTION_REASON = "double assertion through `unknown`/`any` — make the types agree, narrow with a type guard, or keep it " +
14
+ `with // ${DOUBLE_ASSERTION_DIRECTIVE}: <reason>`;
15
+ // Most files hold neither, so a text probe spares them the parse.
16
+ const CANDIDATE_TEXT = new RegExp(`\\bas\\s+(?:unknown|any)\\b|<(?:unknown|any)>|${DOUBLE_ASSERTION_DIRECTIVE}`);
17
+ function isAssertion(node) {
18
+ return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
19
+ }
20
+ /** The assertion an outer one wraps, through any parentheses, when it erases to `unknown` or `any`. */
21
+ function erasingInnerAssertion(outer) {
22
+ let inner = outer.expression;
23
+ while (isOxcNode(inner) && inner.type === "ParenthesizedExpression") {
24
+ inner = inner.expression;
25
+ }
26
+ if (!isOxcNode(inner) || !isAssertion(inner) || !isOxcNode(inner.typeAnnotation)) {
27
+ return undefined;
28
+ }
29
+ const erasedTo = inner.typeAnnotation.type;
30
+ return erasedTo === "TSUnknownKeyword" || erasedTo === "TSAnyKeyword" ? inner : undefined;
31
+ }
32
+ function collectDoubleAssertions(node, found) {
33
+ let next = node;
34
+ if (isAssertion(node)) {
35
+ const inner = erasingInnerAssertion(node);
36
+ if (inner !== undefined) {
37
+ found.push(node);
38
+ // The erased pair is one finding; what it wraps is walked on its own.
39
+ next = inner;
40
+ }
41
+ }
42
+ for (const value of Object.values(next)) {
43
+ if (Array.isArray(value)) {
44
+ for (const item of value) {
45
+ if (isOxcNode(item)) {
46
+ collectDoubleAssertions(item, found);
47
+ }
48
+ }
49
+ }
50
+ else if (isOxcNode(value)) {
51
+ collectDoubleAssertions(value, found);
52
+ }
53
+ }
54
+ }
55
+ function parseDirective(sourceText, comment) {
56
+ if (comment.type !== "Line") {
57
+ return undefined;
58
+ }
59
+ const text = comment.value.trim();
60
+ if (!text.startsWith(DOUBLE_ASSERTION_DIRECTIVE)) {
61
+ return undefined;
62
+ }
63
+ const reason = /^:\s*(\S.*)$/.exec(text.slice(DOUBLE_ASSERTION_DIRECTIVE.length))?.[1] ?? "";
64
+ return {
65
+ line: lineOfOffset(sourceText, comment.start),
66
+ raw: sourceText.slice(comment.start, comment.end),
67
+ reason,
68
+ isUsed: false,
69
+ };
70
+ }
71
+ /**
72
+ * Scans one TypeScript source for double assertions through `unknown` or `any`, and for directives
73
+ * that keep none or give no reason.
74
+ *
75
+ * @remarks `x as unknown as T`, `(x as unknown) as T` and `<T><unknown>x` are one shape: the
76
+ * compiler found the two types unrelated, and the pair silences it instead of reconciling them.
77
+ *
78
+ * @since 0.13.0
79
+ */
80
+ export function auditDoubleAssertionSource(filePath, sourceText) {
81
+ if (!CANDIDATE_TEXT.test(sourceText)) {
82
+ return [];
83
+ }
84
+ const { program, comments } = parseSync(filePath, sourceText);
85
+ const directives = comments.map((comment) => parseDirective(sourceText, comment)).filter((d) => d !== undefined);
86
+ const found = [];
87
+ if (isOxcNode(program)) {
88
+ collectDoubleAssertions(program, found);
89
+ }
90
+ const violations = [];
91
+ for (const assertion of found) {
92
+ const line = lineOfOffset(sourceText, assertion.start);
93
+ const directive = directives.find((candidate) => candidate.reason !== "" && (candidate.line === line || candidate.line === line - 1));
94
+ if (directive !== undefined) {
95
+ directive.isUsed = true;
96
+ continue;
97
+ }
98
+ violations.push({
99
+ line,
100
+ raw: firstLineOf(sourceText.slice(assertion.start, assertion.end)),
101
+ reason: DOUBLE_ASSERTION_REASON,
102
+ });
103
+ }
104
+ for (const directive of directives) {
105
+ if (directive.reason === "") {
106
+ violations.push({
107
+ line: directive.line,
108
+ raw: directive.raw,
109
+ reason: `a ${DOUBLE_ASSERTION_DIRECTIVE} directive states why after a colon`,
110
+ });
111
+ }
112
+ else if (!directive.isUsed) {
113
+ violations.push({
114
+ line: directive.line,
115
+ raw: directive.raw,
116
+ reason: `this ${DOUBLE_ASSERTION_DIRECTIVE} directive keeps no double assertion — remove it`,
117
+ });
118
+ }
119
+ }
120
+ violations.sort((a, b) => a.line - b.line);
121
+ return violations;
122
+ }
@@ -0,0 +1,7 @@
1
+ import type { AssertionAuditResult } from "#audit/domain/types";
2
+ /**
3
+ * Human-readable type-assertion report.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export declare function presentAssertionAuditResult(result: AssertionAuditResult): void;
@@ -0,0 +1,21 @@
1
+ import { logger } from "#core/logger";
2
+ /**
3
+ * Human-readable type-assertion report.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export function presentAssertionAuditResult(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} type-assertion violation(s)${allowlistSuffix}`);
17
+ }
18
+ else {
19
+ logger.out(`✓ No double assertions across ${result.scannedFileCount} file(s)${allowlistSuffix}`);
20
+ }
21
+ }
@@ -0,0 +1,16 @@
1
+ import type { AuditCommandPrelude } from "#audit/prepare";
2
+ import type { AppError } from "#core/errors";
3
+ import type { Filesystem } from "#core/filesystem/filesystem";
4
+ import type { Result } from "#core/result";
5
+ /**
6
+ * Loads config and resolves the scan target for `audit assertions`.
7
+ *
8
+ * @remarks Defaults to the repo root, tests included: a double assertion in a test silences the
9
+ * compiler on the code the test is meant to hold to its types.
10
+ *
11
+ * @since 0.13.0
12
+ */
13
+ export declare function prepareAssertionAudit(fs: Filesystem, args: {
14
+ readonly currentWorkingDirectory: string;
15
+ readonly rawTarget: string | undefined;
16
+ }): Promise<Result<AuditCommandPrelude, AppError>>;
@@ -0,0 +1,12 @@
1
+ import { prepareRepoRootAudit } from "#audit/prepare";
2
+ /**
3
+ * Loads config and resolves the scan target for `audit assertions`.
4
+ *
5
+ * @remarks Defaults to the repo root, tests included: a double assertion in a test silences the
6
+ * compiler on the code the test is meant to hold to its types.
7
+ *
8
+ * @since 0.13.0
9
+ */
10
+ export async function prepareAssertionAudit(fs, args) {
11
+ return prepareRepoRootAudit(fs, args, (config) => config.audit?.assertions?.allowlist ?? []);
12
+ }
@@ -0,0 +1,14 @@
1
+ import type { AssertionAuditResult } from "#audit/domain/types";
2
+ import { AppError } from "#core/errors";
3
+ import type { Filesystem } from "#core/filesystem/filesystem";
4
+ import type { Result } from "#core/result";
5
+ /**
6
+ * Scans a target path for double assertions through `unknown` or `any`, tests included.
7
+ *
8
+ * @since 0.13.0
9
+ */
10
+ export declare function runAssertionAudit(fs: Filesystem, args: {
11
+ readonly rootDir: string;
12
+ readonly targetPath: string;
13
+ readonly allowlist: ReadonlyArray<string>;
14
+ }): Result<AssertionAuditResult, AppError>;
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { auditDoubleAssertionSource } from "#audit/assertions/domain/double-assertion";
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 a target path for double assertions through `unknown` or `any`, tests included.
8
+ *
9
+ * @since 0.13.0
10
+ */
11
+ export function runAssertionAudit(fs, args) {
12
+ try {
13
+ const allowlist = new Set(args.allowlist);
14
+ const { rootDir, targetPath } = args;
15
+ const filesToScan = fs.statSync(targetPath).isFile() ? [targetPath] : walkTsxFiles(targetPath, fs);
16
+ const files = [];
17
+ let violationCount = 0;
18
+ let allowlistedCount = 0;
19
+ for (const absolutePath of filesToScan) {
20
+ const relativePath = path.relative(rootDir, absolutePath).split(path.sep).join("/");
21
+ const content = fs.readFileSync(absolutePath, "utf8");
22
+ const remaining = auditDoubleAssertionSource(absolutePath, content).filter(({ raw }) => {
23
+ const isAllowed = allowlist.has(raw) || allowlist.has(`${relativePath}:${raw}`);
24
+ if (isAllowed) {
25
+ allowlistedCount++;
26
+ }
27
+ return !isAllowed;
28
+ });
29
+ if (remaining.length === 0) {
30
+ continue;
31
+ }
32
+ violationCount += remaining.length;
33
+ files.push({ relativePath, violations: remaining });
34
+ }
35
+ return ok({ files, violationCount, allowlistedCount, scannedFileCount: filesToScan.length });
36
+ }
37
+ catch (caughtError) {
38
+ return err(new AppError("INFRA_FAILURE", messageFrom(caughtError), caughtError));
39
+ }
40
+ }
@@ -1,4 +1,9 @@
1
1
  import { Command } from "commander";
2
+ import { exitCodeForAssertionAuditResult, formatAssertionAuditJsonOutput } from "#audit/assertions/cli-result";
3
+ import { assertionAuditRunRequestSchema } from "#audit/assertions/cli-schema";
4
+ import { presentAssertionAuditResult } from "#audit/assertions/output";
5
+ import { prepareAssertionAudit } from "#audit/assertions/prepare";
6
+ import { runAssertionAudit } from "#audit/assertions/run";
2
7
  import { exitCodeForCommentAuditResult, formatCommentAuditJsonOutput } from "#audit/comments/cli-result";
3
8
  import { commentAuditRunRequestSchema } from "#audit/comments/cli-schema";
4
9
  import { presentCommentAuditResult } from "#audit/comments/output";
@@ -14,6 +19,11 @@ import { importsAuditRunRequestSchema } from "#audit/imports/cli-schema";
14
19
  import { presentImportsAuditResult } from "#audit/imports/output";
15
20
  import { prepareImportsAudit } from "#audit/imports/prepare";
16
21
  import { runImportsAudit } from "#audit/imports/run";
22
+ import { exitCodeForLayersAuditResult, formatLayersAuditJsonOutput } from "#audit/layers/cli-result";
23
+ import { layersAuditRunRequestSchema } from "#audit/layers/cli-schema";
24
+ import { presentLayersAuditResult } from "#audit/layers/output";
25
+ import { prepareLayersAudit } from "#audit/layers/prepare";
26
+ import { runLayersAudit } from "#audit/layers/run";
17
27
  import { exitCodeForLinkAuditResult, formatLinkAuditJsonOutput } from "#audit/links/cli-result";
18
28
  import { linkAuditRunRequestSchema } from "#audit/links/cli-schema";
19
29
  import { presentLinkAuditResult } from "#audit/links/output";
@@ -79,6 +89,22 @@ const importsCheck = {
79
89
  formatJson: formatImportsAuditJsonOutput,
80
90
  exitCode: exitCodeForImportsAuditResult,
81
91
  };
92
+ const assertionsCheck = {
93
+ name: "assertions",
94
+ description: "Report double type assertions through unknown or any (x as unknown as T), tests included",
95
+ targetHelp: "Directory or file to scan (default: the repo root)",
96
+ schema: assertionAuditRunRequestSchema,
97
+ prepare: prepareAssertionAudit,
98
+ buildRequest: baseAuditRequest,
99
+ run: (fs, request) => runAssertionAudit(fs, {
100
+ rootDir: request.rootDir,
101
+ targetPath: request.targetPath,
102
+ allowlist: request.allowlist ?? [],
103
+ }),
104
+ present: presentAssertionAuditResult,
105
+ formatJson: formatAssertionAuditJsonOutput,
106
+ exitCode: exitCodeForAssertionAuditResult,
107
+ };
82
108
  const displayNamesCheck = {
83
109
  name: "display-names",
84
110
  description: "Report token(), tag() and module display names that break the <namespace>:<Name> convention",
@@ -115,9 +141,26 @@ const commentsCheck = {
115
141
  command.option("--fix", "Rewrite every mechanically fixable divider in place", false);
116
142
  },
117
143
  };
144
+ const layersCheck = {
145
+ name: "layers",
146
+ description: "Report value imports that point up a package's configured layers, and modules sitting in no layer",
147
+ targetHelp: "Directory or file to scan (default: the repo root, reaching every package audit.layers names)",
148
+ schema: layersAuditRunRequestSchema,
149
+ prepare: prepareLayersAudit,
150
+ buildRequest: (prelude, opts) => ({ ...baseAuditRequest(prelude, opts), packages: prelude.packages }),
151
+ run: (fs, request) => runLayersAudit(fs, {
152
+ rootDir: request.rootDir,
153
+ targetPath: request.targetPath,
154
+ allowlist: request.allowlist ?? [],
155
+ packages: request.packages,
156
+ }),
157
+ present: presentLayersAuditResult,
158
+ formatJson: formatLayersAuditJsonOutput,
159
+ exitCode: exitCodeForLayersAuditResult,
160
+ };
118
161
  const publishCheck = {
119
162
  name: "publish",
120
- description: "Report what breaks a consumer's install: #/ imports and exports/imports targets not shipped",
163
+ description: "Report what breaks a consumer's install: #/ imports, unshipped exports/imports targets, and stylesheet @source paths reaching nothing shipped",
121
164
  targetHelp: "Directory or file to scan (default: the repo root)",
122
165
  schema: publishAuditRunRequestSchema,
123
166
  prepare: preparePublishAudit,
@@ -156,8 +199,10 @@ export function createAuditCommand() {
156
199
  registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(rtlCheck));
157
200
  registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(linksCheck));
158
201
  registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(importsCheck));
202
+ registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(assertionsCheck));
159
203
  registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(displayNamesCheck));
160
204
  registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(commentsCheck));
205
+ registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(layersCheck));
161
206
  registerPipelineSubcommand(cmd, nodeFilesystem, auditCheckToPipeline(publishCheck));
162
207
  return cmd;
163
208
  }
@@ -18,6 +18,18 @@ export interface CommentContentFinding {
18
18
  readonly raw: string;
19
19
  readonly defect: CommentContentDefectKind;
20
20
  }
21
+ /**
22
+ * Returns whether a line is a `//` note: a line comment that is neither a divider nor a tooling directive.
23
+ *
24
+ * @since 0.14.0
25
+ */
26
+ export declare function isNoteLine(line: string): boolean;
27
+ /**
28
+ * Returns whether a line is a `//` tooling directive, which governs the code below it.
29
+ *
30
+ * @since 0.14.0
31
+ */
32
+ export declare function isDirectiveLine(line: string): boolean;
21
33
  /**
22
34
  * Scans a source file's comments for banned content, in source order.
23
35
  *
@@ -18,6 +18,22 @@ const declarationPattern = /^[ \t]*(?:export|const|let|var|function|class|interf
18
18
  // A divider or a tooling directive above a doc block is not a stacked note.
19
19
  const dividerLinePattern = /^[ \t]*\/\/[ \t]*[-=─_*~#]{2,}/;
20
20
  const directiveLinePattern = /^[ \t]*\/\/[ \t]*(?:oxlint-|eslint-|@ts-|prettier-)/;
21
+ /**
22
+ * Returns whether a line is a `//` note: a line comment that is neither a divider nor a tooling directive.
23
+ *
24
+ * @since 0.14.0
25
+ */
26
+ export function isNoteLine(line) {
27
+ return lineCommentPattern.test(line) && !dividerLinePattern.test(line) && !isDirectiveLine(line);
28
+ }
29
+ /**
30
+ * Returns whether a line is a `//` tooling directive, which governs the code below it.
31
+ *
32
+ * @since 0.14.0
33
+ */
34
+ export function isDirectiveLine(line) {
35
+ return directiveLinePattern.test(line);
36
+ }
21
37
  /**
22
38
  * Scans a source file's comments for banned content, in source order.
23
39
  *
@@ -37,10 +53,7 @@ export function scanCommentContent(content, language) {
37
53
  // A `//` run stacked directly above a doc block reads as a second doc — it belongs inside.
38
54
  if (language === "js" && !insideBlock && trimmed.startsWith("/**")) {
39
55
  let runStart = index;
40
- while (runStart > 0 &&
41
- lineCommentPattern.test(lines[runStart - 1]) &&
42
- !dividerLinePattern.test(lines[runStart - 1]) &&
43
- !directiveLinePattern.test(lines[runStart - 1])) {
56
+ while (runStart > 0 && isNoteLine(lines[runStart - 1])) {
44
57
  runStart--;
45
58
  }
46
59
  if (runStart < index) {
@@ -1,3 +1,4 @@
1
+ import { lineOfOffset } from "#core/source-position";
1
2
  /** The owner: a kebab-case package, app or feature slug, or a scoped package name. */
2
3
  const NAMESPACE = /^(?:@[a-z0-9-]+\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
3
4
  /** A token or module stands for a type or a unit of composition. */
@@ -59,13 +60,4 @@ function reasonFor(kind, name) {
59
60
  return PASCAL_CASE.test(local)
60
61
  ? null
61
62
  : `${label} '${local}' in '${name}' is not PascalCase — a ${kind} stands for a ${kind === "token" ? "type" : "unit of composition"}`;
62
- }
63
- function lineOfOffset(sourceText, offset) {
64
- let line = 1;
65
- for (let index = 0; index < offset; index++) {
66
- if (sourceText.charCodeAt(index) === 10) {
67
- line++;
68
- }
69
- }
70
- return line;
71
63
  }
@@ -73,6 +73,37 @@ export type ImportsAuditResult = {
73
73
  readonly allowlistedCount: number;
74
74
  readonly scannedFileCount: number;
75
75
  };
76
+ /**
77
+ * A double assertion through `unknown` or `any`, or a directive that keeps one without cause.
78
+ *
79
+ * @since 0.13.0
80
+ */
81
+ export type AssertionViolation = {
82
+ readonly line: number;
83
+ /** The assertion or the directive as written, up to its first line break. */
84
+ readonly raw: string;
85
+ readonly reason: string;
86
+ };
87
+ /**
88
+ * The type-assertion violations found in one file.
89
+ *
90
+ * @since 0.13.0
91
+ */
92
+ export type AssertionFileViolations = {
93
+ readonly relativePath: string;
94
+ readonly violations: Array<AssertionViolation>;
95
+ };
96
+ /**
97
+ * Outcome of one `audit assertions` run.
98
+ *
99
+ * @since 0.13.0
100
+ */
101
+ export type AssertionAuditResult = {
102
+ readonly files: Array<AssertionFileViolations>;
103
+ readonly violationCount: number;
104
+ readonly allowlistedCount: number;
105
+ readonly scannedFileCount: number;
106
+ };
76
107
  /**
77
108
  * A `token()`, `tag()` or module display name that breaks the display-name convention.
78
109
  *
@@ -200,6 +231,28 @@ export type UnshippedTargetViolation = {
200
231
  readonly subpath: string;
201
232
  readonly target: string;
202
233
  };
234
+ /**
235
+ * A path a stylesheet registers with Tailwind's `@source`, as written.
236
+ *
237
+ * @since 0.13.0
238
+ */
239
+ export type StylesheetSource = {
240
+ readonly line: number;
241
+ readonly pattern: string;
242
+ };
243
+ /**
244
+ * A shipped stylesheet whose `@source` paths reach no file its package's tarball ships.
245
+ *
246
+ * @since 0.13.0
247
+ */
248
+ export type UnreachableStylesheetViolation = {
249
+ readonly packageName: string;
250
+ /** Repo-relative path of the stylesheet. */
251
+ readonly stylesheet: string;
252
+ readonly sources: Array<StylesheetSource>;
253
+ /** The `files` entries missing on disk, which is how an unbuilt `dist` shows up. */
254
+ readonly missingFilesEntries: Array<string>;
255
+ };
203
256
  /**
204
257
  * Outcome of one `audit publish` run.
205
258
  *
@@ -208,7 +261,40 @@ export type UnshippedTargetViolation = {
208
261
  export type PublishAuditResult = {
209
262
  readonly legacyImportFiles: Array<LegacySubpathFile>;
210
263
  readonly unshipped: Array<UnshippedTargetViolation>;
264
+ readonly unreachableStylesheets: Array<UnreachableStylesheetViolation>;
211
265
  readonly legacyImportCount: number;
212
266
  readonly scannedFileCount: number;
213
267
  readonly packageCount: number;
268
+ };
269
+ /**
270
+ * A module outside every configured layer, or a value import that points up the layers.
271
+ *
272
+ * @since 0.14.0
273
+ */
274
+ export type LayerViolation = {
275
+ readonly line: number;
276
+ /** The import or re-export as written, or the module path when the module itself is unplaced. */
277
+ readonly raw: string;
278
+ readonly reason: string;
279
+ };
280
+ /**
281
+ * The layering violations found in one file.
282
+ *
283
+ * @since 0.14.0
284
+ */
285
+ export type LayerFileViolations = {
286
+ readonly relativePath: string;
287
+ readonly violations: Array<LayerViolation>;
288
+ };
289
+ /**
290
+ * Outcome of one `audit layers` run.
291
+ *
292
+ * @since 0.14.0
293
+ */
294
+ export type LayersAuditResult = {
295
+ readonly files: Array<LayerFileViolations>;
296
+ readonly violationCount: number;
297
+ readonly allowlistedCount: number;
298
+ readonly scannedFileCount: number;
299
+ readonly packageCount: number;
214
300
  };
@@ -1,4 +1,6 @@
1
1
  import { parseSync } from "oxc-parser";
2
+ import { isOxcNode, programStatements } from "#core/oxc-node";
3
+ import { firstLineOf, lineOfOffset } from "#core/source-position";
2
4
  /**
3
5
  * The import policies enforced across the monorepo: React members by name (never a namespace,
4
6
  * default, or implicit `React.*` UMD global), and Zod as a namespace repo-wide — the house form,
@@ -20,9 +22,6 @@ export const defaultImportPolicyRules = [
20
22
  "locale set into any bundle that reaches it)",
21
23
  },
22
24
  ];
23
- function isOxcNode(value) {
24
- return typeof value === "object" && value !== null && typeof value.type === "string";
25
- }
26
25
  function isIdentifierNamed(node, name) {
27
26
  return isOxcNode(node) && node.type === "Identifier" && node.name === name;
28
27
  }
@@ -41,7 +40,7 @@ function importedName(specifier) {
41
40
  */
42
41
  export function auditImportPolicySource(filePath, sourceText, rules) {
43
42
  const { program } = parseSync(filePath, sourceText);
44
- const statements = program.body;
43
+ const statements = programStatements(program);
45
44
  const violations = [];
46
45
  const boundUmdNames = new Set();
47
46
  for (const rule of rules) {
@@ -89,7 +88,7 @@ export function auditImportPolicySource(filePath, sourceText, rules) {
89
88
  }
90
89
  }
91
90
  for (const rule of rules) {
92
- if (rule.umdGlobal !== undefined && !boundUmdNames.has(rule.umdGlobal)) {
91
+ if (rule.umdGlobal !== undefined && !boundUmdNames.has(rule.umdGlobal) && isOxcNode(program)) {
93
92
  collectUmdGlobalReferences(program, sourceText, rule, violations);
94
93
  }
95
94
  }
@@ -124,17 +123,4 @@ function collectUmdGlobalReferences(node, sourceText, rule, violations) {
124
123
  collectUmdGlobalReferences(value, sourceText, rule, violations);
125
124
  }
126
125
  }
127
- }
128
- function lineOfOffset(sourceText, offset) {
129
- let line = 1;
130
- for (let index = 0; index < offset; index++) {
131
- if (sourceText.charCodeAt(index) === 10) {
132
- line++;
133
- }
134
- }
135
- return line;
136
- }
137
- function firstLineOf(text) {
138
- const newlineIndex = text.indexOf("\n");
139
- return newlineIndex === -1 ? text : text.slice(0, newlineIndex);
140
126
  }
@@ -0,0 +1,13 @@
1
+ import type { LayersAuditResult } from "#audit/domain/types";
2
+ /**
3
+ * Exit `1` when any non-allowlisted layering violation remains.
4
+ *
5
+ * @since 0.14.0
6
+ */
7
+ export declare function exitCodeForLayersAuditResult(result: LayersAuditResult): number;
8
+ /**
9
+ * Machine-readable layering summary for `--json`.
10
+ *
11
+ * @since 0.14.0
12
+ */
13
+ export declare function formatLayersAuditJsonOutput(result: LayersAuditResult, rootDir: string): string;
@@ -0,0 +1,22 @@
1
+ import { CLI_EXIT_GENERAL_ERROR, CLI_EXIT_SUCCESS } from "#core/exit-codes";
2
+ /**
3
+ * Exit `1` when any non-allowlisted layering violation remains.
4
+ *
5
+ * @since 0.14.0
6
+ */
7
+ export function exitCodeForLayersAuditResult(result) {
8
+ return result.violationCount > 0 ? CLI_EXIT_GENERAL_ERROR : CLI_EXIT_SUCCESS;
9
+ }
10
+ /**
11
+ * Machine-readable layering summary for `--json`.
12
+ *
13
+ * @since 0.14.0
14
+ */
15
+ export function formatLayersAuditJsonOutput(result, rootDir) {
16
+ return JSON.stringify({
17
+ schemaVersion: 1,
18
+ ok: result.violationCount === 0,
19
+ cwd: rootDir,
20
+ result,
21
+ });
22
+ }
@@ -0,0 +1,29 @@
1
+ import * as z from "zod";
2
+ /**
3
+ * One layered package as the run reads it: its name, the absolute root the layers sit under, and the layers.
4
+ *
5
+ * @since 0.14.0
6
+ */
7
+ export type LayersAuditPackage = {
8
+ readonly name: string;
9
+ readonly rootPath: string;
10
+ readonly layers: ReadonlyArray<ReadonlyArray<string>>;
11
+ };
12
+ /**
13
+ * Resolved request for a single layering audit run.
14
+ *
15
+ * @since 0.14.0
16
+ */
17
+ export type LayersAuditRunRequest = {
18
+ readonly rootDir: string;
19
+ readonly targetPath: string;
20
+ readonly allowlist?: ReadonlyArray<string> | undefined;
21
+ readonly json: boolean;
22
+ readonly packages: ReadonlyArray<LayersAuditPackage>;
23
+ };
24
+ /**
25
+ * Zod schema for {@link LayersAuditRunRequest}.
26
+ *
27
+ * @since 0.14.0
28
+ */
29
+ export declare const layersAuditRunRequestSchema: z.ZodType<LayersAuditRunRequest>;
@@ -0,0 +1,17 @@
1
+ import * as z from "zod";
2
+ /**
3
+ * Zod schema for {@link LayersAuditRunRequest}.
4
+ *
5
+ * @since 0.14.0
6
+ */
7
+ export const layersAuditRunRequestSchema = z.object({
8
+ rootDir: z.string().min(1),
9
+ targetPath: z.string().min(1),
10
+ allowlist: z.array(z.string()).optional(),
11
+ json: z.boolean(),
12
+ packages: z.array(z.object({
13
+ name: z.string().min(1),
14
+ rootPath: z.string().min(1),
15
+ layers: z.array(z.array(z.string().min(1))),
16
+ })),
17
+ });