@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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 CodeFast Labs
3
+ Copyright (c) 2024 Codefast Labs
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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` strips the source lane from the publish artifact, and `tag` stamps
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
- Strips the source lane from published packages so the npm tarball ships `dist` runtime and types only. Where `mirror`
139
- writes the full exports — including the `source` condition — for repo dev, `pack-slim` removes it for publish: it drops
140
- `src` from `files`, every `source` condition from `exports`/`imports`, and the `dist` source maps plus their dangling
141
- `sourceMappingURL` directives. Private packages are skipped, since `changeset publish` never publishes them. It is meant
142
- to run on an ephemeral CI checkout right before publish (the release workflow runs it as its publish step), so it is
143
- never committed.
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/ui/src # explicit target
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
@@ -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
  *
@@ -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
- const ruleOnlyLinePattern = new RegExp(String.raw `^(?<indent>[ \t]*)(?:\/\/|\/\*|\*)[ \t]*${RULE_CHARACTER_CLASS}{4,}[ \t]*(?:\*\/)?[ \t]*$`);
15
- const titledLinePattern = new RegExp(String.raw `^(?<indent>[ \t]*)(?:\/\/|\/\*)[ \t]*${RULE_CHARACTER_CLASS}{2,}[ \t]+(?<title>.*?)[ \t]+${RULE_CHARACTER_CLASS}{2,}[ \t]*(?:\*\/)?[ \t]*$`);
16
- const commentLinePattern = /^[ \t]*(?:\/\/|\/\*|\*)/;
17
- const bareRuleClosePattern = new RegExp(String.raw `^[ \t]*${RULE_CHARACTER_CLASS}{4,}[ \t]*\*\/[ \t]*$`);
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 head = `${indent}// ${LEAD_GLYPHS} ${title} `;
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 titled = titledLinePattern.exec(line);
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 = ruleOnlyLinePattern.exec(line);
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 && cursor - index <= MAX_BANNER_SPAN && !isBannerClose(lines[cursor], insideBlock)) {
110
- if (!insideBlock && !commentLinePattern.test(lines[cursor])) {
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
- if (ruleOnlyLinePattern.test(line)) {
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 && bareRuleClosePattern.test(line);
163
+ return insideBlock && patterns.bareRuleClose.test(line);
139
164
  }
140
165
  function usesCanonicalGlyphs(line) {
141
- return line.trimStart().startsWith(`// ${LEAD_GLYPHS} `) || line.trimStart().startsWith(`/* ${LEAD_GLYPHS} `);
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(commentPrefixPattern, "").replace(commentSuffixPattern, "").trim();
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
+ }
@@ -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
  }
@@ -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
- for (const finding of scanCommentContent(content, language)) {
72
- if (allowlist.has(finding.raw) || allowlist.has(`${relativePath}:${finding.raw}`)) {
73
- allowlistedCount++;
74
- continue;
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["since-impossible"] });
79
+ breakages.push({ line: finding.line, raw: finding.raw, reason: reasonByDefect[finding.defect] });
86
80
  }
87
- }
88
- if (language === "js") {
89
- for (const finding of scanTsdocSyntax(content)) {
90
- if (allowlist.has(finding.raw) || allowlist.has(`${relativePath}:${finding.raw}`)) {
91
- allowlistedCount++;
92
- continue;
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 `.css`.
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
- return filePath.endsWith(".css") ? "css" : null;
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 strips the source lane from published packages before publish.
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 published packages before publish")
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)