@codefast/cli 0.13.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.
- package/CHANGELOG.md +29 -0
- package/README.md +58 -39
- package/dist/audit/command.js +23 -22
- package/dist/audit/comments/domain/comment-content.d.ts +12 -0
- package/dist/audit/comments/domain/comment-content.js +17 -4
- package/dist/audit/domain/types.d.ts +32 -31
- package/dist/audit/layers/cli-result.d.ts +13 -0
- package/dist/audit/{constants → layers}/cli-result.js +6 -6
- package/dist/audit/layers/cli-schema.d.ts +29 -0
- package/dist/audit/layers/cli-schema.js +17 -0
- package/dist/audit/layers/domain/layering.d.ts +46 -0
- package/dist/audit/layers/domain/layering.js +191 -0
- package/dist/audit/layers/output.d.ts +7 -0
- package/dist/audit/{constants → layers}/output.js +5 -5
- package/dist/audit/layers/prepare.d.ts +25 -0
- package/dist/audit/layers/prepare.js +66 -0
- package/dist/audit/layers/run.d.ts +19 -0
- package/dist/audit/layers/run.js +62 -0
- package/dist/audit/prepare.d.ts +13 -1
- package/dist/audit/prepare.js +16 -1
- package/dist/core/config/schema.d.ts +12 -4
- package/dist/core/config/schema.js +13 -1
- package/dist/tag/cli-result.d.ts +3 -0
- package/dist/tag/cli-result.js +8 -2
- package/dist/tag/domain/types.d.ts +15 -0
- package/dist/tag/output.js +13 -6
- package/dist/tag/run.js +2 -0
- package/dist/tag/writer/since-writer.d.ts +5 -0
- package/dist/tag/writer/since-writer.js +41 -9
- package/package.json +1 -1
- package/dist/audit/constants/cli-result.d.ts +0 -13
- package/dist/audit/constants/cli-schema.d.ts +0 -18
- package/dist/audit/constants/cli-schema.js +0 -12
- package/dist/audit/constants/domain/constants.d.ts +0 -8
- package/dist/audit/constants/domain/constants.js +0 -66
- package/dist/audit/constants/output.d.ts +0 -7
- package/dist/audit/constants/prepare.d.ts +0 -16
- package/dist/audit/constants/prepare.js +0 -37
- package/dist/audit/constants/run.d.ts +0 -14
- package/dist/audit/constants/run.js +0 -64
|
@@ -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
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { logger } from "#core/logger";
|
|
2
2
|
/**
|
|
3
|
-
* Human-readable
|
|
3
|
+
* Human-readable layering report.
|
|
4
4
|
*
|
|
5
|
-
* @since 0.
|
|
5
|
+
* @since 0.14.0
|
|
6
6
|
*/
|
|
7
|
-
export function
|
|
7
|
+
export function presentLayersAuditResult(result) {
|
|
8
8
|
for (const file of result.files) {
|
|
9
9
|
logger.out(`\n${file.relativePath}`);
|
|
10
10
|
for (const { line, raw, reason } of file.violations) {
|
|
@@ -13,9 +13,9 @@ export function presentConstantAuditResult(result) {
|
|
|
13
13
|
}
|
|
14
14
|
const allowlistSuffix = result.allowlistedCount > 0 ? ` (${result.allowlistedCount} allowlisted)` : "";
|
|
15
15
|
if (result.violationCount > 0) {
|
|
16
|
-
logger.out(`\n✖ ${result.violationCount}
|
|
16
|
+
logger.out(`\n✖ ${result.violationCount} layering violation(s)${allowlistSuffix}`);
|
|
17
17
|
}
|
|
18
18
|
else {
|
|
19
|
-
logger.out(`✓ Every
|
|
19
|
+
logger.out(`✓ Every value import points down the layers across ${result.scannedFileCount} file(s) in ${result.packageCount} package(s)${allowlistSuffix}`);
|
|
20
20
|
}
|
|
21
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
|
+
}
|
package/dist/audit/prepare.d.ts
CHANGED
|
@@ -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>>;
|
package/dist/audit/prepare.js
CHANGED
|
@@ -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
|
-
|
|
55
|
+
...selected.value,
|
|
41
56
|
});
|
|
42
57
|
}
|
|
@@ -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?: {
|
|
@@ -73,10 +84,7 @@ interface CodefastAuditConfig {
|
|
|
73
84
|
imports?: CodefastAuditAllowlistConfig | undefined;
|
|
74
85
|
assertions?: CodefastAuditAllowlistConfig | undefined;
|
|
75
86
|
displayNames?: CodefastAuditAllowlistConfig | undefined;
|
|
76
|
-
|
|
77
|
-
target?: string | undefined;
|
|
78
|
-
allowlist?: Array<string> | undefined;
|
|
79
|
-
} | undefined;
|
|
87
|
+
layers?: CodefastAuditLayersConfig | undefined;
|
|
80
88
|
}
|
|
81
89
|
/**
|
|
82
90
|
* The validated root `codefast.config` shape.
|
|
@@ -52,6 +52,18 @@ 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(),
|
|
@@ -60,7 +72,7 @@ const codefastAuditConfigSchema = z
|
|
|
60
72
|
imports: codefastAuditAllowlistConfigSchema.optional(),
|
|
61
73
|
assertions: codefastAuditAllowlistConfigSchema.optional(),
|
|
62
74
|
displayNames: codefastAuditAllowlistConfigSchema.optional(),
|
|
63
|
-
|
|
75
|
+
layers: codefastAuditLayersConfigSchema.optional(),
|
|
64
76
|
})
|
|
65
77
|
.strict();
|
|
66
78
|
/**
|
package/dist/tag/cli-result.d.ts
CHANGED
|
@@ -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;
|
package/dist/tag/cli-result.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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>;
|
package/dist/tag/output.js
CHANGED
|
@@ -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) ||
|
|
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,
|