@codefast/cli 0.8.1 → 0.9.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 +568 -0
- package/LICENSE +1 -1
- package/README.md +35 -9
- package/dist/audit/cli-schema.js +11 -0
- package/dist/audit/command.js +43 -3
- package/dist/audit/domain/comment-dividers.js +48 -20
- package/dist/audit/domain/display-names.js +71 -0
- package/dist/audit/output.js +41 -0
- package/dist/audit/prepare.js +31 -0
- package/dist/audit/run-comments.js +22 -18
- package/dist/audit/run-display-names.js +60 -0
- package/dist/core/config/schema.js +10 -0
- package/dist/core/workspace/source-walk.js +13 -2
- package/dist/pack-slim/command.js +3 -2
- package/dist/pack-slim/domain/transform.js +141 -15
- package/dist/pack-slim/output.js +10 -1
- package/dist/pack-slim/sync.js +10 -4
- package/package.json +5 -33
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -11,8 +11,8 @@ class strings, `audit` source conventions, `mirror` export maps from `dist/`, `p
|
|
|
11
11
|
|
|
12
12
|
`codefast` is the command line for the [codefast monorepo](https://github.com/codefastlabs/codefast). It has five
|
|
13
13
|
commands: `arrange` regroups Tailwind class strings, `audit` checks source conventions, `mirror` writes
|
|
14
|
-
`package.json#exports` from `dist/`, `pack-slim`
|
|
15
|
-
exported APIs with `@since`.
|
|
14
|
+
`package.json#exports` from `dist/`, `pack-slim` slims the publish artifact down to what a consumer reads, and `tag`
|
|
15
|
+
stamps exported APIs with `@since`.
|
|
16
16
|
|
|
17
17
|
This is repo tooling, published to npm. It runs in any pnpm workspace with a similar layout, but its flags and defaults
|
|
18
18
|
follow the codefast conventions rather than aiming to be a general-purpose product.
|
|
@@ -44,6 +44,7 @@ pnpm run cli:audit:rtl # codefast audit rtl
|
|
|
44
44
|
pnpm run cli:audit:links # codefast audit links
|
|
45
45
|
pnpm run cli:audit:comments # codefast audit comments
|
|
46
46
|
pnpm run cli:audit:react # codefast audit react
|
|
47
|
+
pnpm run cli:audit:display-names # codefast audit display-names
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
`pnpm run version-packages` runs `changeset version` and then `codefast tag`, so published APIs are stamped at release.
|
|
@@ -135,12 +136,14 @@ Exits `1` when any package fails, `0` otherwise.
|
|
|
135
136
|
|
|
136
137
|
## `pack-slim`
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
`
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
Slims published packages down to what a consumer's `tsc` and Node read, so the npm tarball ships `dist` runtime and
|
|
140
|
+
types only and its `package.json` describes nothing else. Where `mirror` writes the full exports — including the
|
|
141
|
+
`source` condition — for repo dev, `pack-slim` removes the development lane for publish: it drops `src` from `files`,
|
|
142
|
+
every `source` condition from `exports`/`imports`, every `imports` entry left pointing outside `files` (the `#/tests/*`
|
|
143
|
+
and `#/examples/*` aliases), every script that is not an install or publish lifecycle hook, `devDependencies`, and the
|
|
144
|
+
`dist` source maps plus their dangling `sourceMappingURL` directives. Private packages are skipped, since
|
|
145
|
+
`changeset publish` never publishes them. It is meant to run on an ephemeral CI checkout right before publish (the
|
|
146
|
+
release workflow runs it as its publish step), so it is never committed.
|
|
144
147
|
|
|
145
148
|
Because its result must never be committed, `pack-slim` refuses to write when the git working tree has uncommitted
|
|
146
149
|
tracked changes — guarding against an accidental local run landing on real work. `--dry-run` is exempt (it writes
|
|
@@ -234,7 +237,7 @@ Exits non-zero when violations remain so it can gate CI.
|
|
|
234
237
|
|
|
235
238
|
```bash
|
|
236
239
|
codefast audit react # whole repo
|
|
237
|
-
codefast audit react apps/
|
|
240
|
+
codefast audit react apps/web/src # explicit target
|
|
238
241
|
codefast audit react --json # machine-readable summary
|
|
239
242
|
```
|
|
240
243
|
|
|
@@ -245,6 +248,29 @@ codefast audit react --json # machine-readable summary
|
|
|
245
248
|
Configure intentional exceptions via `audit.react.allowlist` — each entry is the offending source text as written or
|
|
246
249
|
`repo/relative/path.tsx:<text>`.
|
|
247
250
|
|
|
251
|
+
## `audit display-names`
|
|
252
|
+
|
|
253
|
+
Read-only scan enforcing the display-name convention for every string a `token()`, `tag()` or module factory takes: a
|
|
254
|
+
name is spelled like the TS symbol it stands for, under its owner's namespace — `<namespace>:<Name>`. The namespace is a
|
|
255
|
+
kebab-case package, app or feature slug (or a scoped package name); a token or module name is PascalCase, because it
|
|
256
|
+
stands for a type or a unit of composition; a tag key is camelCase, because it names an attribute. Scans TypeScript and
|
|
257
|
+
markdown alike, since a doc sample is what a reader copies; skips `tests/`, `benchmarks/`, `.changeset/` and
|
|
258
|
+
`CHANGELOG.md`, where a name is scoped by its file or quoted as it was. Exits non-zero when violations remain so it can
|
|
259
|
+
gate CI.
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
codefast audit display-names # whole repo
|
|
263
|
+
codefast audit display-names packages/di/examples # explicit target
|
|
264
|
+
codefast audit display-names --json # machine-readable summary
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
| Flag | Description |
|
|
268
|
+
| -------- | --------------------------------- |
|
|
269
|
+
| `--json` | Print one JSON summary on stdout. |
|
|
270
|
+
|
|
271
|
+
Configure intentional exceptions via `audit.displayNames.allowlist` — each entry is the call as written, through its
|
|
272
|
+
closing quote (or parenthesis when the name is the only argument), or `repo/relative/path.ts:<call>`.
|
|
273
|
+
|
|
248
274
|
## `tag`
|
|
249
275
|
|
|
250
276
|
Adds `@since <version>` tags to the doc comments of exported declarations that lack one, creating the doc block when
|
package/dist/audit/cli-schema.js
CHANGED
|
@@ -45,6 +45,17 @@ export const reactAuditRunRequestSchema = z.object({
|
|
|
45
45
|
allowlist: z.array(z.string()).optional(),
|
|
46
46
|
json: z.boolean(),
|
|
47
47
|
});
|
|
48
|
+
/**
|
|
49
|
+
* Zod schema for {@link DisplayNameAuditRunRequest}.
|
|
50
|
+
*
|
|
51
|
+
* @since 0.9.0
|
|
52
|
+
*/
|
|
53
|
+
export const displayNameAuditRunRequestSchema = z.object({
|
|
54
|
+
rootDir: z.string().min(1),
|
|
55
|
+
targetPath: z.string().min(1),
|
|
56
|
+
allowlist: z.array(z.string()).optional(),
|
|
57
|
+
json: z.boolean(),
|
|
58
|
+
});
|
|
48
59
|
/**
|
|
49
60
|
* Resolves a path that may be absolute or relative to `rootDir`.
|
|
50
61
|
*
|
package/dist/audit/command.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import process from "node:process";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { commentAuditRunRequestSchema, linkAuditRunRequestSchema, reactAuditRunRequestSchema, rtlAuditRunRequestSchema, } from "#/audit/cli-schema";
|
|
4
|
-
import { exitCodeForCommentAuditResult, exitCodeForLinkAuditResult, exitCodeForReactAuditResult, exitCodeForRtlAuditResult, formatCommentAuditJsonOutput, formatLinkAuditJsonOutput, formatReactAuditJsonOutput, formatRtlAuditJsonOutput, presentCommentAuditResult, presentLinkAuditResult, presentReactAuditResult, presentRtlAuditResult, } from "#/audit/output";
|
|
5
|
-
import { prepareCommentAudit, prepareLinkAudit, prepareReactAudit, prepareRtlAudit } from "#/audit/prepare";
|
|
3
|
+
import { commentAuditRunRequestSchema, linkAuditRunRequestSchema, reactAuditRunRequestSchema, rtlAuditRunRequestSchema, displayNameAuditRunRequestSchema, } from "#/audit/cli-schema";
|
|
4
|
+
import { exitCodeForCommentAuditResult, exitCodeForLinkAuditResult, exitCodeForReactAuditResult, exitCodeForRtlAuditResult, exitCodeForDisplayNameAuditResult, formatCommentAuditJsonOutput, formatLinkAuditJsonOutput, formatReactAuditJsonOutput, formatRtlAuditJsonOutput, formatDisplayNameAuditJsonOutput, presentCommentAuditResult, presentLinkAuditResult, presentReactAuditResult, presentRtlAuditResult, presentDisplayNameAuditResult, } from "#/audit/output";
|
|
5
|
+
import { prepareCommentAudit, prepareLinkAudit, prepareReactAudit, prepareRtlAudit, prepareDisplayNameAudit, } from "#/audit/prepare";
|
|
6
6
|
import { runRtlAudit } from "#/audit/run";
|
|
7
7
|
import { runCommentAudit } from "#/audit/run-comments";
|
|
8
|
+
import { runDisplayNameAudit } from "#/audit/run-display-names";
|
|
8
9
|
import { runLinkAudit } from "#/audit/run-links";
|
|
9
10
|
import { runReactAudit } from "#/audit/run-react";
|
|
10
11
|
import { readOptionalPositionalArg } from "#/core/cli/positional";
|
|
@@ -137,6 +138,45 @@ export function createAuditCommand() {
|
|
|
137
138
|
}
|
|
138
139
|
process.exitCode = exitCodeForReactAuditResult(outcome.value);
|
|
139
140
|
});
|
|
141
|
+
cmd
|
|
142
|
+
.command("display-names")
|
|
143
|
+
.description("Report token(), tag() and module display names that break the <namespace>:<Name> convention")
|
|
144
|
+
.argument("[target]", "Directory or file to scan (default: the repo root)")
|
|
145
|
+
.option("--json", "Print one JSON summary on stdout", false)
|
|
146
|
+
.action(async (target, opts) => {
|
|
147
|
+
const prelude = await prepareDisplayNameAudit(nodeFilesystem, {
|
|
148
|
+
currentWorkingDirectory: process.cwd(),
|
|
149
|
+
rawTarget: readOptionalPositionalArg(target),
|
|
150
|
+
});
|
|
151
|
+
if (!consumeCliAppError(prelude)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const { rootDir, targetPath, allowlist } = prelude.value;
|
|
155
|
+
const parsed = parseWithSchema(displayNameAuditRunRequestSchema, {
|
|
156
|
+
rootDir,
|
|
157
|
+
targetPath,
|
|
158
|
+
allowlist,
|
|
159
|
+
json: !!opts.json,
|
|
160
|
+
});
|
|
161
|
+
if (!consumeCliAppError(parsed)) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const outcome = runDisplayNameAudit(nodeFilesystem, {
|
|
165
|
+
rootDir: parsed.value.rootDir,
|
|
166
|
+
targetPath: parsed.value.targetPath,
|
|
167
|
+
allowlist: parsed.value.allowlist ?? [],
|
|
168
|
+
});
|
|
169
|
+
if (!consumeCliAppError(outcome)) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (parsed.value.json) {
|
|
173
|
+
logger.out(formatDisplayNameAuditJsonOutput(outcome.value, rootDir));
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
presentDisplayNameAuditResult(outcome.value);
|
|
177
|
+
}
|
|
178
|
+
process.exitCode = exitCodeForDisplayNameAuditResult(outcome.value);
|
|
179
|
+
});
|
|
140
180
|
cmd
|
|
141
181
|
.command("comments")
|
|
142
182
|
.description("Report section dividers that are not in the repo's one allowed form")
|
|
@@ -11,12 +11,31 @@ const RULE_GLYPH = "─";
|
|
|
11
11
|
const LEAD_GLYPHS = "──";
|
|
12
12
|
/** Every glyph a divider has historically been drawn with, so legacy forms are recognised too. */
|
|
13
13
|
const RULE_CHARACTER_CLASS = String.raw `[-=─_*~#]`;
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
// The comment lead each syntax opens a divider with — `#` for ignore files, slash forms for code.
|
|
15
|
+
const commentLeadByLanguage = {
|
|
16
|
+
css: String.raw `\/\/|\/\*|\*`,
|
|
17
|
+
ignore: String.raw `#`,
|
|
18
|
+
js: String.raw `\/\/|\/\*|\*`,
|
|
19
|
+
};
|
|
20
|
+
// One compiled pattern set per language — the comment lead is the only part that varies.
|
|
21
|
+
const patternsByLanguage = new Map();
|
|
22
|
+
function patternsFor(language) {
|
|
23
|
+
const cached = patternsByLanguage.get(language);
|
|
24
|
+
if (cached !== undefined) {
|
|
25
|
+
return cached;
|
|
26
|
+
}
|
|
27
|
+
const lead = commentLeadByLanguage[language];
|
|
28
|
+
const built = {
|
|
29
|
+
ruleOnly: new RegExp(String.raw `^(?<indent>[ \t]*)(?:${lead})[ \t]*${RULE_CHARACTER_CLASS}{4,}[ \t]*(?:\*\/)?[ \t]*$`),
|
|
30
|
+
titled: new RegExp(String.raw `^(?<indent>[ \t]*)(?:${lead})[ \t]*${RULE_CHARACTER_CLASS}{2,}[ \t]+(?<title>.*?)[ \t]+${RULE_CHARACTER_CLASS}{2,}[ \t]*(?:\*\/)?[ \t]*$`),
|
|
31
|
+
commentLine: new RegExp(String.raw `^[ \t]*(?:${lead})`),
|
|
32
|
+
bareRuleClose: new RegExp(String.raw `^[ \t]*${RULE_CHARACTER_CLASS}{4,}[ \t]*\*\/[ \t]*$`),
|
|
33
|
+
commentPrefix: new RegExp(String.raw `^[ \t]*(?:${lead})[ \t]?`),
|
|
34
|
+
};
|
|
35
|
+
patternsByLanguage.set(language, built);
|
|
36
|
+
return built;
|
|
37
|
+
}
|
|
18
38
|
const rulesOnlyPattern = new RegExp(String.raw `^(?:${RULE_CHARACTER_CLASS}|[ \t])*$`);
|
|
19
|
-
const commentPrefixPattern = /^[ \t]*(?:\/\/|\/\*|\*)[ \t]?/;
|
|
20
39
|
const commentSuffixPattern = /[ \t]*\*\/[ \t]*$/;
|
|
21
40
|
/** A banner spanning more lines than this is prose that happens to start with a rule, not a divider. */
|
|
22
41
|
const MAX_BANNER_SPAN = 16;
|
|
@@ -36,7 +55,8 @@ export function renderDivider(indent, title, language) {
|
|
|
36
55
|
const head = `${indent}/* ${LEAD_GLYPHS} ${title} `;
|
|
37
56
|
return `${head}${RULE_GLYPH.repeat(Math.max(2, DIVIDER_COLUMN - head.length - 3))} */`;
|
|
38
57
|
}
|
|
39
|
-
const
|
|
58
|
+
const prefix = language === "ignore" ? "#" : "//";
|
|
59
|
+
const head = `${indent}${prefix} ${LEAD_GLYPHS} ${title} `;
|
|
40
60
|
return `${head}${RULE_GLYPH.repeat(Math.max(2, DIVIDER_COLUMN - head.length))}`;
|
|
41
61
|
}
|
|
42
62
|
/**
|
|
@@ -80,7 +100,8 @@ export function applyCommentDividerFixes(content, language) {
|
|
|
80
100
|
}
|
|
81
101
|
function readRegionAt(lines, index, language) {
|
|
82
102
|
const line = lines[index];
|
|
83
|
-
const
|
|
103
|
+
const patterns = patternsFor(language);
|
|
104
|
+
const titled = patterns.titled.exec(line);
|
|
84
105
|
const title = titled?.groups?.title?.trim();
|
|
85
106
|
if (titled !== null && title !== undefined && title.length > 0 && !rulesOnlyPattern.test(title)) {
|
|
86
107
|
const indent = titled.groups?.indent ?? "";
|
|
@@ -94,26 +115,29 @@ function readRegionAt(lines, index, language) {
|
|
|
94
115
|
defect: line === canonical ? null : usesCanonicalGlyphs(line) ? "bad-width" : "legacy-form",
|
|
95
116
|
};
|
|
96
117
|
}
|
|
97
|
-
const ruleOnly =
|
|
118
|
+
const ruleOnly = patterns.ruleOnly.exec(line);
|
|
98
119
|
if (ruleOnly === null) {
|
|
99
120
|
return null;
|
|
100
121
|
}
|
|
101
|
-
return readBannerAt(lines, index, ruleOnly.groups?.indent ?? "");
|
|
122
|
+
return readBannerAt(lines, index, ruleOnly.groups?.indent ?? "", language);
|
|
102
123
|
}
|
|
103
|
-
function readBannerAt(lines, index, indent) {
|
|
124
|
+
function readBannerAt(lines, index, indent, language) {
|
|
104
125
|
const line = lines[index];
|
|
126
|
+
const patterns = patternsFor(language);
|
|
105
127
|
// An unterminated `/*` opens a block whose body needs no per-line marker, so the run ends at `*/`.
|
|
106
128
|
const insideBlock = line.trimStart().startsWith("/*") && !line.includes("*/");
|
|
107
129
|
const body = [];
|
|
108
130
|
let cursor = index + 1;
|
|
109
|
-
while (cursor < lines.length &&
|
|
110
|
-
|
|
131
|
+
while (cursor < lines.length &&
|
|
132
|
+
cursor - index <= MAX_BANNER_SPAN &&
|
|
133
|
+
!isBannerClose(lines[cursor], insideBlock, language)) {
|
|
134
|
+
if (!insideBlock && !patterns.commentLine.test(lines[cursor])) {
|
|
111
135
|
break;
|
|
112
136
|
}
|
|
113
|
-
body.push(stripCommentPrefix(lines[cursor]));
|
|
137
|
+
body.push(stripCommentPrefix(lines[cursor], language));
|
|
114
138
|
cursor++;
|
|
115
139
|
}
|
|
116
|
-
const closed = cursor < lines.length && cursor - index <= MAX_BANNER_SPAN && isBannerClose(lines[cursor], insideBlock);
|
|
140
|
+
const closed = cursor < lines.length && cursor - index <= MAX_BANNER_SPAN && isBannerClose(lines[cursor], insideBlock, language);
|
|
117
141
|
const meaningful = body.filter((entry) => entry.length > 0);
|
|
118
142
|
// A frame around prose is a doc block, and an unclosed rule is prose formatting inside one.
|
|
119
143
|
if (!closed || meaningful.length !== 1 || !looksLikeTitle(meaningful[0])) {
|
|
@@ -131,15 +155,19 @@ function readBannerAt(lines, index, indent) {
|
|
|
131
155
|
function looksLikeTitle(text) {
|
|
132
156
|
return text.length <= MAX_TITLE_LENGTH && !text.endsWith(".");
|
|
133
157
|
}
|
|
134
|
-
function isBannerClose(line, insideBlock) {
|
|
135
|
-
|
|
158
|
+
function isBannerClose(line, insideBlock, language) {
|
|
159
|
+
const patterns = patternsFor(language);
|
|
160
|
+
if (patterns.ruleOnly.test(line)) {
|
|
136
161
|
return true;
|
|
137
162
|
}
|
|
138
|
-
return insideBlock &&
|
|
163
|
+
return insideBlock && patterns.bareRuleClose.test(line);
|
|
139
164
|
}
|
|
140
165
|
function usesCanonicalGlyphs(line) {
|
|
141
|
-
|
|
166
|
+
const trimmed = line.trimStart();
|
|
167
|
+
return (trimmed.startsWith(`// ${LEAD_GLYPHS} `) ||
|
|
168
|
+
trimmed.startsWith(`/* ${LEAD_GLYPHS} `) ||
|
|
169
|
+
trimmed.startsWith(`# ${LEAD_GLYPHS} `));
|
|
142
170
|
}
|
|
143
|
-
function stripCommentPrefix(line) {
|
|
144
|
-
return line.replace(
|
|
171
|
+
function stripCommentPrefix(line, language) {
|
|
172
|
+
return line.replace(patternsFor(language).commentPrefix, "").replace(commentSuffixPattern, "").trim();
|
|
145
173
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** The owner: a kebab-case package, app or feature slug, or a scoped package name. */
|
|
2
|
+
const NAMESPACE = /^(?:@[a-z0-9-]+\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
3
|
+
/** A token or module stands for a type or a unit of composition. */
|
|
4
|
+
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
|
|
5
|
+
/** A tag key stands for an attribute a request selects on, so it reads like a property. */
|
|
6
|
+
const CAMEL_CASE = /^[a-z][A-Za-z0-9]*$/;
|
|
7
|
+
/**
|
|
8
|
+
* A bare `token(…)` / `tag(…)` call, or a module factory call, whose first argument is a string literal.
|
|
9
|
+
*
|
|
10
|
+
* @remarks Generic arguments are skipped up to four levels of nesting, which is deeper than any
|
|
11
|
+
* declaration in the repo. Template literals are not matched: an interpolated name has no fixed
|
|
12
|
+
* text to check. The closing parenthesis is kept when the name is the only argument, so the
|
|
13
|
+
* reported text reads as the call was written.
|
|
14
|
+
*/
|
|
15
|
+
const DISPLAY_NAME_CALL = /(?<![\w$.])(?:(token|tag)\s*(?:<(?:[^<>]|<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)*>)?|((?:Sync|Async)?Module)\s*\.\s*create(?:Async)?)\s*\(\s*(["'])((?:(?!\3)[^\\\n]|\\.)*)\3(\s*\))?/g;
|
|
16
|
+
const KIND_LABEL = {
|
|
17
|
+
token: "token display name",
|
|
18
|
+
tag: "tag key",
|
|
19
|
+
module: "module name",
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Scans one source or markdown text for `token()`, `tag()` and module display names that break the convention.
|
|
23
|
+
*
|
|
24
|
+
* @remarks Runs on markdown as well as TypeScript because a doc sample is what a reader copies:
|
|
25
|
+
* a convention the docs break is not one the docs teach.
|
|
26
|
+
*
|
|
27
|
+
* @since 0.9.0
|
|
28
|
+
*/
|
|
29
|
+
export function auditDisplayNames(sourceText) {
|
|
30
|
+
const violations = [];
|
|
31
|
+
for (const match of sourceText.matchAll(DISPLAY_NAME_CALL)) {
|
|
32
|
+
const kind = match[1] === undefined ? "module" : match[1];
|
|
33
|
+
const reason = reasonFor(kind, match[4]);
|
|
34
|
+
if (reason === null) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
violations.push({ line: lineOfOffset(sourceText, match.index), raw: match[0], reason });
|
|
38
|
+
}
|
|
39
|
+
return violations;
|
|
40
|
+
}
|
|
41
|
+
/** Why a display name breaks the convention, or `null` when it holds. */
|
|
42
|
+
function reasonFor(kind, name) {
|
|
43
|
+
const segments = name.split(":");
|
|
44
|
+
const label = KIND_LABEL[kind];
|
|
45
|
+
if (segments.length < 2) {
|
|
46
|
+
return `${label} '${name}' has no namespace — write '<namespace>:${name}'`;
|
|
47
|
+
}
|
|
48
|
+
const local = segments.at(-1);
|
|
49
|
+
for (const namespace of segments.slice(0, -1)) {
|
|
50
|
+
if (!NAMESPACE.test(namespace)) {
|
|
51
|
+
return `namespace '${namespace}' in '${name}' is not kebab-case (or a scoped package name)`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (kind === "tag") {
|
|
55
|
+
return CAMEL_CASE.test(local)
|
|
56
|
+
? null
|
|
57
|
+
: `${label} '${local}' in '${name}' is not camelCase — a tag key names an attribute`;
|
|
58
|
+
}
|
|
59
|
+
return PASCAL_CASE.test(local)
|
|
60
|
+
? null
|
|
61
|
+
: `${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
|
+
}
|
package/dist/audit/output.js
CHANGED
|
@@ -169,4 +169,45 @@ export function formatCommentAuditJsonOutput(result, rootDir) {
|
|
|
169
169
|
}
|
|
170
170
|
function truncate(raw) {
|
|
171
171
|
return raw.length <= 60 ? raw : `${raw.slice(0, 57)}…`;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Exit `1` when any non-allowlisted display-name violation remains.
|
|
175
|
+
*
|
|
176
|
+
* @since 0.9.0
|
|
177
|
+
*/
|
|
178
|
+
export function exitCodeForDisplayNameAuditResult(result) {
|
|
179
|
+
return result.violationCount > 0 ? CLI_EXIT_GENERAL_ERROR : CLI_EXIT_SUCCESS;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Human-readable display-name report.
|
|
183
|
+
*
|
|
184
|
+
* @since 0.9.0
|
|
185
|
+
*/
|
|
186
|
+
export function presentDisplayNameAuditResult(result) {
|
|
187
|
+
for (const file of result.files) {
|
|
188
|
+
logger.out(`\n${file.relativePath}`);
|
|
189
|
+
for (const { line, raw, reason } of file.violations) {
|
|
190
|
+
logger.out(` ${line}: ${raw} → ${reason}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const allowlistSuffix = result.allowlistedCount > 0 ? ` (${result.allowlistedCount} allowlisted)` : "";
|
|
194
|
+
if (result.violationCount > 0) {
|
|
195
|
+
logger.out(`\n✖ ${result.violationCount} display name(s) off the convention${allowlistSuffix}`);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
logger.out(`✓ Every token, tag and module display name follows <namespace>:<Name> across ${result.scannedFileCount} file(s)${allowlistSuffix}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Machine-readable display-name summary for `--json`.
|
|
203
|
+
*
|
|
204
|
+
* @since 0.9.0
|
|
205
|
+
*/
|
|
206
|
+
export function formatDisplayNameAuditJsonOutput(result, rootDir) {
|
|
207
|
+
return JSON.stringify({
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
ok: result.violationCount === 0,
|
|
210
|
+
cwd: rootDir,
|
|
211
|
+
result,
|
|
212
|
+
});
|
|
172
213
|
}
|
package/dist/audit/prepare.js
CHANGED
|
@@ -131,4 +131,35 @@ export async function prepareCommentAudit(fs, args) {
|
|
|
131
131
|
targetPath: fs.canonicalPathSync(targetPath),
|
|
132
132
|
allowlist: commentsConfig.allowlist ?? [],
|
|
133
133
|
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Loads config and resolves the scan target for `audit display-names`.
|
|
137
|
+
*
|
|
138
|
+
* @remarks Defaults to the repo root: a display name collides across packages, so the convention
|
|
139
|
+
* has to hold across them.
|
|
140
|
+
*
|
|
141
|
+
* @since 0.9.0
|
|
142
|
+
*/
|
|
143
|
+
export async function prepareDisplayNameAudit(fs, args) {
|
|
144
|
+
let rootDir;
|
|
145
|
+
try {
|
|
146
|
+
rootDir = fs.canonicalPathSync(findRepoRoot(args.currentWorkingDirectory, fs));
|
|
147
|
+
}
|
|
148
|
+
catch (caughtError) {
|
|
149
|
+
return err(new AppError("INFRA_FAILURE", messageFrom(caughtError), caughtError));
|
|
150
|
+
}
|
|
151
|
+
const loadedOutcome = await loadCodefastConfig(rootDir, fs);
|
|
152
|
+
if (!loadedOutcome.ok) {
|
|
153
|
+
return loadedOutcome;
|
|
154
|
+
}
|
|
155
|
+
const displayNamesConfig = loadedOutcome.value.config.audit?.displayNames ?? {};
|
|
156
|
+
const targetPath = args.rawTarget === undefined ? rootDir : resolveRepoRelativePath(args.currentWorkingDirectory, args.rawTarget);
|
|
157
|
+
if (!fs.existsSync(targetPath)) {
|
|
158
|
+
return err(new AppError("NOT_FOUND", `Not found: ${targetPath}`));
|
|
159
|
+
}
|
|
160
|
+
return ok({
|
|
161
|
+
rootDir,
|
|
162
|
+
targetPath: fs.canonicalPathSync(targetPath),
|
|
163
|
+
allowlist: displayNamesConfig.allowlist ?? [],
|
|
164
|
+
});
|
|
134
165
|
}
|
|
@@ -68,30 +68,34 @@ export function runCommentAudit(fs, args) {
|
|
|
68
68
|
}
|
|
69
69
|
breakages.push({ line: region.startLine, raw: region.raw, reason: reasonByDefect[region.defect] });
|
|
70
70
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
breakages.push({ line: finding.line, raw: finding.raw, reason: reasonByDefect[finding.defect] });
|
|
77
|
-
}
|
|
78
|
-
const packageVersion = nearestVersion(fs, absolutePath, versionByDirectory);
|
|
79
|
-
if (packageVersion !== null) {
|
|
80
|
-
for (const finding of scanImpossibleSinceTags(content, packageVersion)) {
|
|
71
|
+
// The content rules — banned fragments, `@since`, TSDoc grammar — govern doc comments in
|
|
72
|
+
// code; an ignore file carries only the divider convention.
|
|
73
|
+
if (language !== "ignore") {
|
|
74
|
+
for (const finding of scanCommentContent(content, language)) {
|
|
81
75
|
if (allowlist.has(finding.raw) || allowlist.has(`${relativePath}:${finding.raw}`)) {
|
|
82
76
|
allowlistedCount++;
|
|
83
77
|
continue;
|
|
84
78
|
}
|
|
85
|
-
breakages.push({ line: finding.line, raw: finding.raw, reason: reasonByDefect[
|
|
79
|
+
breakages.push({ line: finding.line, raw: finding.raw, reason: reasonByDefect[finding.defect] });
|
|
86
80
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
const packageVersion = nearestVersion(fs, absolutePath, versionByDirectory);
|
|
82
|
+
if (packageVersion !== null) {
|
|
83
|
+
for (const finding of scanImpossibleSinceTags(content, packageVersion)) {
|
|
84
|
+
if (allowlist.has(finding.raw) || allowlist.has(`${relativePath}:${finding.raw}`)) {
|
|
85
|
+
allowlistedCount++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
breakages.push({ line: finding.line, raw: finding.raw, reason: reasonByDefect["since-impossible"] });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (language === "js") {
|
|
92
|
+
for (const finding of scanTsdocSyntax(content)) {
|
|
93
|
+
if (allowlist.has(finding.raw) || allowlist.has(`${relativePath}:${finding.raw}`)) {
|
|
94
|
+
allowlistedCount++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
breakages.push({ line: finding.line, raw: finding.raw, reason: finding.reason });
|
|
93
98
|
}
|
|
94
|
-
breakages.push({ line: finding.line, raw: finding.raw, reason: finding.reason });
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
101
|
if (breakages.length > 0) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { auditDisplayNames } from "#/audit/domain/display-names";
|
|
3
|
+
import { AppError, messageFrom } from "#/core/errors";
|
|
4
|
+
import { err, ok } from "#/core/result";
|
|
5
|
+
import { walkMarkdownFiles } from "#/core/workspace/markdown-walk";
|
|
6
|
+
import { walkTsxFiles } from "#/core/workspace/typescript-walk";
|
|
7
|
+
/**
|
|
8
|
+
* Trees the convention does not reach: a test or benchmark token is scoped by its file and never
|
|
9
|
+
* meets another author's, and a changelog quotes names as they were.
|
|
10
|
+
*/
|
|
11
|
+
const SKIPPED_SEGMENTS = new Set(["tests", "benchmarks", ".changeset"]);
|
|
12
|
+
const SKIPPED_BASENAMES = new Set(["CHANGELOG.md"]);
|
|
13
|
+
/**
|
|
14
|
+
* Scans a target path for `token()`, `tag()` and module display names that break the convention.
|
|
15
|
+
*
|
|
16
|
+
* @since 0.9.0
|
|
17
|
+
*/
|
|
18
|
+
export function runDisplayNameAudit(fs, args) {
|
|
19
|
+
try {
|
|
20
|
+
const allowlist = new Set(args.allowlist);
|
|
21
|
+
const { rootDir, targetPath } = args;
|
|
22
|
+
const filesToScan = collectScanPaths(fs, rootDir, targetPath);
|
|
23
|
+
const files = [];
|
|
24
|
+
let violationCount = 0;
|
|
25
|
+
let allowlistedCount = 0;
|
|
26
|
+
for (const absolutePath of filesToScan) {
|
|
27
|
+
const relativePath = toPosixPath(path.relative(rootDir, absolutePath));
|
|
28
|
+
const content = fs.readFileSync(absolutePath, "utf8");
|
|
29
|
+
const remaining = auditDisplayNames(content).filter(({ raw }) => {
|
|
30
|
+
const isAllowed = allowlist.has(raw) || allowlist.has(`${relativePath}:${raw}`);
|
|
31
|
+
if (isAllowed) {
|
|
32
|
+
allowlistedCount++;
|
|
33
|
+
}
|
|
34
|
+
return !isAllowed;
|
|
35
|
+
});
|
|
36
|
+
if (remaining.length === 0) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
violationCount += remaining.length;
|
|
40
|
+
files.push({ relativePath, violations: remaining });
|
|
41
|
+
}
|
|
42
|
+
return ok({ files, violationCount, allowlistedCount, scannedFileCount: filesToScan.length });
|
|
43
|
+
}
|
|
44
|
+
catch (caughtError) {
|
|
45
|
+
return err(new AppError("INFRA_FAILURE", messageFrom(caughtError), caughtError));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function collectScanPaths(fs, rootDir, targetPath) {
|
|
49
|
+
const stats = fs.statSync(targetPath);
|
|
50
|
+
const candidates = stats.isFile()
|
|
51
|
+
? [targetPath]
|
|
52
|
+
: [...walkTsxFiles(targetPath, fs), ...walkMarkdownFiles(targetPath, fs)];
|
|
53
|
+
return candidates.filter((absolutePath) => {
|
|
54
|
+
const relative = path.relative(rootDir, absolutePath).split(path.sep);
|
|
55
|
+
return !relative.some((segment) => SKIPPED_SEGMENTS.has(segment)) && !SKIPPED_BASENAMES.has(relative.at(-1));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function toPosixPath(filePath) {
|
|
59
|
+
return filePath.split(path.sep).join("/");
|
|
60
|
+
}
|
|
@@ -116,6 +116,15 @@ const codefastAuditReactConfigSchema = z
|
|
|
116
116
|
allowlist: z.array(z.string()).optional(),
|
|
117
117
|
})
|
|
118
118
|
.strict();
|
|
119
|
+
/**
|
|
120
|
+
* Display-name audit defaults — the scan always starts at the repo root, so only exceptions are configured.
|
|
121
|
+
*/
|
|
122
|
+
const codefastAuditDisplayNamesConfigSchema = z
|
|
123
|
+
.object({
|
|
124
|
+
/** Offending calls as written, or `repo/relative/path.ts:<call>` entries, to ignore. */
|
|
125
|
+
allowlist: z.array(z.string()).optional(),
|
|
126
|
+
})
|
|
127
|
+
.strict();
|
|
119
128
|
/**
|
|
120
129
|
* Zod schema grouping the per-audit configurations under `audit`.
|
|
121
130
|
*
|
|
@@ -127,6 +136,7 @@ const codefastAuditConfigSchema = z
|
|
|
127
136
|
links: codefastAuditLinksConfigSchema.optional(),
|
|
128
137
|
comments: codefastAuditCommentsConfigSchema.optional(),
|
|
129
138
|
react: codefastAuditReactConfigSchema.optional(),
|
|
139
|
+
displayNames: codefastAuditDisplayNamesConfigSchema.optional(),
|
|
130
140
|
})
|
|
131
141
|
.strict();
|
|
132
142
|
/**
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { defaultSkipDirectoryNames } from "#/core/workspace/skip-directories";
|
|
3
|
+
// Ignore files carry the divider convention over `#` comments — the content rules stay code-only.
|
|
4
|
+
const ignoreFileNames = new Set([
|
|
5
|
+
".gitignore",
|
|
6
|
+
".dockerignore",
|
|
7
|
+
".npmignore",
|
|
8
|
+
".prettierignore",
|
|
9
|
+
".eslintignore",
|
|
10
|
+
]);
|
|
3
11
|
/**
|
|
4
|
-
* Every hand-written source file a comment convention applies to — `.ts`, `.tsx`, and
|
|
12
|
+
* Every hand-written source file a comment convention applies to — `.ts`, `.tsx`, `.css`, and ignore files.
|
|
5
13
|
*
|
|
6
14
|
* @remarks Emitted declarations are excluded: they carry whatever the compiler copied over,
|
|
7
15
|
* and rewriting them would be undone by the next build.
|
|
@@ -25,7 +33,10 @@ export function sourceCommentLanguage(filePath) {
|
|
|
25
33
|
if (filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
|
|
26
34
|
return "js";
|
|
27
35
|
}
|
|
28
|
-
|
|
36
|
+
if (filePath.endsWith(".css")) {
|
|
37
|
+
return "css";
|
|
38
|
+
}
|
|
39
|
+
return ignoreFileNames.has(path.basename(filePath)) ? "ignore" : null;
|
|
29
40
|
}
|
|
30
41
|
function visitSourcePaths(result, entryPath, fs) {
|
|
31
42
|
const entryStats = fs.statSync(entryPath);
|
|
@@ -15,13 +15,14 @@ import { PackSlimProgressPresenter } from "#/pack-slim/output";
|
|
|
15
15
|
import { runPackSlim } from "#/pack-slim/sync";
|
|
16
16
|
import { ensureWorkingTreeClean } from "#/pack-slim/working-tree";
|
|
17
17
|
/**
|
|
18
|
-
* Creates the `pack-slim` subcommand, which
|
|
18
|
+
* Creates the `pack-slim` subcommand, which slims published packages down to what a consumer reads before publish.
|
|
19
19
|
*
|
|
20
20
|
* @since 0.8.1
|
|
21
21
|
*/
|
|
22
22
|
export function createPackSlimCommand() {
|
|
23
23
|
const cmd = new Command("pack-slim")
|
|
24
|
-
.description("Strip src, source conditions, and dist source maps from
|
|
24
|
+
.description("Strip src, source conditions, unshipped imports, dev-only scripts, devDependencies, and dist source maps from " +
|
|
25
|
+
"published packages before publish")
|
|
25
26
|
.argument("[package]", "Optional package path relative to repo root (e.g. packages/ui)")
|
|
26
27
|
.option("--dry-run", "Report what would change without touching any file", false)
|
|
27
28
|
.option("--force", "Run even if the git working tree has uncommitted tracked changes", false)
|