@codefast/cli 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +97 -12
  3. package/dist/arrange/domain/ast/translator.js +42 -28
  4. package/dist/arrange/simplify/process-file.d.ts +1 -1
  5. package/dist/audit/assertions/cli-result.d.ts +13 -0
  6. package/dist/audit/assertions/cli-result.js +22 -0
  7. package/dist/audit/assertions/cli-schema.d.ts +18 -0
  8. package/dist/audit/assertions/cli-schema.js +12 -0
  9. package/dist/audit/assertions/domain/double-assertion.d.ts +20 -0
  10. package/dist/audit/assertions/domain/double-assertion.js +122 -0
  11. package/dist/audit/assertions/output.d.ts +7 -0
  12. package/dist/audit/assertions/output.js +21 -0
  13. package/dist/audit/assertions/prepare.d.ts +16 -0
  14. package/dist/audit/assertions/prepare.js +12 -0
  15. package/dist/audit/assertions/run.d.ts +14 -0
  16. package/dist/audit/assertions/run.js +40 -0
  17. package/dist/audit/command.js +45 -1
  18. package/dist/audit/constants/cli-result.d.ts +13 -0
  19. package/dist/audit/constants/cli-result.js +22 -0
  20. package/dist/audit/constants/cli-schema.d.ts +18 -0
  21. package/dist/audit/constants/cli-schema.js +12 -0
  22. package/dist/audit/constants/domain/constants.d.ts +8 -0
  23. package/dist/audit/constants/domain/constants.js +66 -0
  24. package/dist/audit/constants/output.d.ts +7 -0
  25. package/dist/audit/constants/output.js +21 -0
  26. package/dist/audit/constants/prepare.d.ts +16 -0
  27. package/dist/audit/constants/prepare.js +37 -0
  28. package/dist/audit/constants/run.d.ts +14 -0
  29. package/dist/audit/constants/run.js +64 -0
  30. package/dist/audit/display-names/domain/display-names.js +1 -9
  31. package/dist/audit/domain/types.d.ts +85 -0
  32. package/dist/audit/imports/domain/import-policy.js +4 -18
  33. package/dist/audit/publish/cli-result.d.ts +1 -1
  34. package/dist/audit/publish/cli-result.js +6 -3
  35. package/dist/audit/publish/domain/stylesheet-sources.d.ts +22 -0
  36. package/dist/audit/publish/domain/stylesheet-sources.js +64 -0
  37. package/dist/audit/publish/output.js +12 -3
  38. package/dist/audit/publish/run.d.ts +2 -1
  39. package/dist/audit/publish/run.js +30 -1
  40. package/dist/audit/publish/shipped-files.d.ts +21 -0
  41. package/dist/audit/publish/shipped-files.js +36 -0
  42. package/dist/core/config/schema.d.ts +5 -0
  43. package/dist/core/config/schema.js +2 -0
  44. package/dist/core/filesystem/filesystem.d.ts +3 -4
  45. package/dist/core/filesystem/node.js +1 -7
  46. package/dist/core/oxc-node.d.ts +32 -0
  47. package/dist/core/oxc-node.js +25 -0
  48. package/dist/core/source-position.d.ts +15 -0
  49. package/dist/core/source-position.js +26 -0
  50. package/dist/mirror/dist-filesystem-node.js +4 -14
  51. package/dist/pack-slim/run.js +1 -1
  52. package/dist/tag/domain/version-summary.d.ts +5 -2
  53. package/dist/tag/writer/since-writer.js +2 -4
  54. package/package.json +4 -4
  55. package/dist/mirror/domain/dirent-guard.d.ts +0 -10
  56. package/dist/mirror/domain/dirent-guard.js +0 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # @codefast/cli
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#951](https://github.com/codefastlabs/codefast/pull/951) Add `codefast audit assertions`, which reports every double type assertion through `unknown` or `any` —
8
+ `x as unknown as T`, `(x as unknown) as T`, `<T><unknown>x` and the `any` spellings — in `.ts`/`.tsx` files, tests
9
+ included. Where the erasure is the point, keep one with `// codefast-allow-double-assertion: <reason>` on its line or
10
+ the line above; a directive with no reason, or one that keeps no assertion, is reported too. Exceptions can also go in
11
+ `audit.assertions.allowlist`.
12
+
13
+ `Filesystem.readdir` is replaced by `readdirEntries(path, { recursive })`, which always returns directory entries: every
14
+ caller asked for them, and the old `string[] | DirectoryEntry[]` union forced each one to cast or guard.
15
+
16
+ - [#894](https://github.com/codefastlabs/codefast/pull/894) `codefast audit constants` reports every upper-case `const` bound to a number in a library's sources whose comment names
17
+ none of the three kinds a number may be — a constant of the machine, a value the contract fixes, or one derived from
18
+ bind-time data. Sentinel values (`0`, `1`, `-1`) are exempt; `audit.constants.target` and `audit.constants.allowlist` in
19
+ `codefast.config` scope and except it.
20
+
21
+ - [#980](https://github.com/codefastlabs/codefast/pull/980) `audit publish` also reports a shipped stylesheet whose Tailwind `@source` paths reach none of the files the slimmed
22
+ tarball ships, naming any `files` entry missing on disk so an unbuilt `dist` reads as such. A workspace resolves those
23
+ paths against `src`, so only the published layout used to show the failure.
24
+
25
+ - [#976](https://github.com/codefastlabs/codefast/pull/976) `engines.node` is now `>=24.0.0`, up from `>=22.12.0`, and Node 22 is no longer supported. Node 24.0.0 is the first
26
+ release with explicit resource management built in (`using`, `await using`, `DisposableStack`, `AsyncDisposableStack`,
27
+ `SuppressedError`) and all of ES2025, so the packages use both as the platform ships them instead of shimming them for
28
+ an older line, and the CI matrix runs the unit suite on 24.0.0 itself. Move to Node 24, or stay on the current minor
29
+ while a deployment still runs Node 22.
30
+
31
+ ### Patch Changes
32
+
33
+ - [#965](https://github.com/codefastlabs/codefast/pull/965) The optional `typescript` peer is now `>=7.0.0`. It was published as the repository's own `^7.0.2` pin, which excluded
34
+ TypeScript 8 and would have risen with each pin bump instead of staying at the supported floor.
35
+
36
+ - [#965](https://github.com/codefastlabs/codefast/pull/965) `README.md` now states TypeScript 7 or later as the floor for the package's types — the one compiler every `@codefast/*`
37
+ package is built and checked with. No code or declaration changed.
38
+
3
39
  ## 0.12.0
4
40
 
5
41
  ### Minor Changes
package/README.md CHANGED
@@ -22,7 +22,9 @@ encode an opinionated house style (called out below) that you can adopt, ignore,
22
22
 
23
23
  ## Requirements
24
24
 
25
- - **Node.js ≥ 22.12** (the CLI is published as ESM).
25
+ - **Node.js ≥ 24** (the CLI is published as ESM).
26
+ - **TypeScript ≥ 7** to type-check a config written with `defineConfig`, and for
27
+ `arrange simplify --fold-variant-classname`, the one command that loads `typescript` (an optional peer).
26
28
  - **A project root — workspace or single package.** Commands resolve their root by walking up from the current
27
29
  directory: the nearest `pnpm-workspace.yaml` marks a **workspace** (every package under it is in scope), and with no
28
30
  workspace file the nearest `package.json` marks a **single package** (that one package is the whole scope). Only
@@ -108,7 +110,10 @@ codefast # Codefast monorepo developer CLI
108
110
  │ ├─ rtl [target] # physical-direction Tailwind classes to make logical / rtl:-paired
109
111
  │ ├─ links [target] # markdown links pointing at a missing path/anchor
110
112
  │ ├─ imports [target] # banned import forms (React by-name, Zod namespace in front-end, …)
113
+ │ ├─ assertions [target] # double type assertions through unknown/any (x as unknown as T)
114
+ │ ├─ constants [target] # numeric constants whose comment names none of the three kinds
111
115
  │ ├─ display-names [target] # token()/tag()/module names breaking the <namespace>:<Name> convention
116
+ │ ├─ publish [target] # what breaks a consumer's install: #/ imports, unshipped targets, @source paths
112
117
  │ └─ comments [target] # section dividers not in the one allowed form
113
118
  │ └─ --fix # rewrite every fixable divider in place (the only audit that writes)
114
119
  │ (each audit also takes [target] + --json)
@@ -139,14 +144,17 @@ Every command also responds to `--help`; each command's section below explains w
139
144
  | `audit rtl` | Report physical-direction Tailwind classes that should be logical | no |
140
145
  | `audit links` | Report markdown cross-references that resolve to nothing | no |
141
146
  | `audit imports` | Enforce the import policy (React by-name, Zod namespace in front-end) | no (report only) |
147
+ | `audit assertions` | Report double type assertions through `unknown` / `any`, tests included | no |
148
+ | `audit constants` | Require every tuned numeric constant to name the kind of number it is | no |
142
149
  | `audit display-names` | Enforce the `namespace:Name` display-name convention | no |
150
+ | `audit publish` | Report what would break a consumer's install of a published package | no |
143
151
  | `audit comments` | Check doc-comment conventions; repair section dividers | `--fix` only |
144
152
 
145
- **Which of these are for you?** `arrange`, `mirror`, `pack-slim`, `tag`, and `audit links` are general-purpose — they
146
- work for any pnpm workspace or single package that builds with `tsc`. The other four audits encode codefast's own house
147
- style (logical Tailwind directions, named React imports, a specific comment/divider grammar, a `namespace:Name` scheme
148
- for `@codefast/di` tokens). Adopt them if they fit your project; otherwise skip them, or use an allowlist to narrow
149
- their scope.
153
+ **Which of these are for you?** `arrange`, `mirror`, `pack-slim`, `tag`, `audit links`, `audit assertions`, and
154
+ `audit publish` are general-purpose — they work for any pnpm workspace or single package that builds with `tsc`. The
155
+ other five audits encode codefast's own house style (logical Tailwind directions, named React imports, a specific
156
+ comment/divider grammar, a `namespace:Name` scheme for `@codefast/di` tokens, a named kind for every tuned numeric
157
+ constant). Adopt them if they fit your project; otherwise skip them, or use an allowlist to narrow their scope.
150
158
 
151
159
  ## `arrange`
152
160
 
@@ -244,12 +252,13 @@ apply (see the [Configuration](#configuration) example for their shape):
244
252
  ## `pack-slim`
245
253
 
246
254
  Slims a published package down to what a consumer's `tsc` and Node actually read, so the npm tarball ships `dist`
247
- runtime and types only. Where `mirror` writes the full exports — including the `source` condition — for local
248
- development, `pack-slim` removes that development lane for publish: it drops `src` from `files`, every `source`
249
- condition from `exports`/`imports`, every `imports` entry left pointing outside `files`, every script that is not an
250
- install or publish lifecycle hook, `devDependencies`, and the `dist` source maps plus their dangling `sourceMappingURL`
251
- directives. Private packages are skipped. It is meant to run on an ephemeral CI checkout right before publish, so its
252
- result is **never committed**.
255
+ runtime and types, plus any `src` subtree an export still points into (a raw stylesheet export such as `./css/*`). Where
256
+ `mirror` writes the full exports — including the `source` condition — for local development, `pack-slim` removes that
257
+ development lane for publish: it drops the rest of `src` from `files`, every `source` condition from
258
+ `exports`/`imports`, every `imports` entry left pointing outside `files`, every script that is not an install or publish
259
+ lifecycle hook, `devDependencies`, and the `dist` source maps plus their dangling `sourceMappingURL` directives. Private
260
+ packages are skipped. It is meant to run on an ephemeral CI checkout right before publish, so its result is **never
261
+ committed**.
253
262
 
254
263
  Because that result must never be committed, `pack-slim` refuses to write when the git working tree has uncommitted
255
264
  tracked changes — a guard against an accidental local run landing on real work. `--dry-run` is exempt (it writes
@@ -332,6 +341,50 @@ codefast audit imports --json # machine-readable summary
332
341
  Configure exceptions via `audit.imports.allowlist` — each entry is the offending source text as written or
333
342
  `repo/relative/path.tsx:<text>`.
334
343
 
344
+ ### `audit assertions`
345
+
346
+ Reports every double type assertion through `unknown` or `any` in `.ts`/`.tsx` files, tests included —
347
+ `x as unknown as T`, `(x as unknown) as T`, `<T><unknown>x` and the `any` spellings. The pair tells the compiler two
348
+ types are unrelated and silences it anyway; no oxlint rule targets it (`typescript/no-unnecessary-type-assertion`
349
+ catches the redundant ones only). Make the types agree, narrow with a type guard, or — where the erasure is the point —
350
+ keep it with a directive that states why, on the assertion's line or the line above:
351
+
352
+ ```ts
353
+ // codefast-allow-double-assertion: the host stores each plugin's config untyped; the plugin owns its shape
354
+ const config = host.configFor(plugin.id) as unknown as PluginConfig;
355
+ ```
356
+
357
+ A directive with no reason, or one that keeps no assertion, is itself reported, so a kept assertion cannot outlive its
358
+ cause.
359
+
360
+ ```bash
361
+ codefast audit assertions # whole repo
362
+ codefast audit assertions packages/di # explicit target
363
+ codefast audit assertions --json # machine-readable summary
364
+ ```
365
+
366
+ Configure exceptions via `audit.assertions.allowlist` — each entry is the assertion as written or
367
+ `repo/relative/path.ts:<assertion>` — though the inline directive keeps the reason beside the code it excuses.
368
+
369
+ ### `audit publish`
370
+
371
+ Reports what would break a consumer's install of a published package, while it can still be fixed:
372
+
373
+ - a `#/`-prefixed internal import, which Node's ESM resolver rejects on Node 24 before 24.14 while every in-repo runner
374
+ accepts it;
375
+ - an `exports`/`imports` target the slimmed manifest does not ship, found by applying the same slim as `pack-slim`;
376
+ - a shipped stylesheet whose Tailwind `@source` paths reach none of the files the tarball ships. A workspace still
377
+ resolves them against `src`, so only the published layout shows it: the consumer's Tailwind registers no class names
378
+ and the components render unstyled.
379
+
380
+ It reads the built output, so run it after the build; a `files` entry missing on disk, such as an unbuilt `dist`, is
381
+ named in the report. Private packages are skipped, and it takes no allowlist.
382
+
383
+ ```bash
384
+ codefast audit publish # every published package in the workspace
385
+ codefast audit publish --json # machine-readable summary
386
+ ```
387
+
335
388
  ### `audit comments`
336
389
 
337
390
  _House style._ Checks doc-comment conventions. Section dividers not in the one allowed form are mechanical, so `--fix`
@@ -367,6 +420,30 @@ codefast audit display-names --json # machine-readable summary
367
420
  Configure exceptions via `audit.displayNames.allowlist` — each entry is the call as written, through its closing quote
368
421
  (or parenthesis when the name is the only argument), or `repo/relative/path.ts:<call>`.
369
422
 
423
+ ### `audit constants`
424
+
425
+ _House style._ Holds library sources to one rule for tuned numbers: a numeric constant says which kind of number it is.
426
+ It flags an upper-case `const NAME = <number>` in a `.ts`/`.tsx` file under a `src` directory — tests, benchmarks, apps,
427
+ and examples are out of scope — unless the comment directly above it, a `/* … */` block or a run of `//` lines, names
428
+ one of three kinds:
429
+
430
+ - `a constant of the machine` — a width of the platform the code runs on;
431
+ - `a value the contract fixes` — a number the documented contract promises;
432
+ - `derived from bind-time data` — a figure computed from what a caller hands in.
433
+
434
+ A count that merely looks reasonable is none of them, so it is reported. `0`, `1`, and `-1` are skipped: they stand for
435
+ absence or identity, not for a tuned size.
436
+
437
+ ```bash
438
+ codefast audit constants # uses audit.constants.target from config
439
+ codefast audit constants packages/di/src # explicit target
440
+ codefast audit constants --json # machine-readable summary
441
+ ```
442
+
443
+ With no `[target]`, the scan root is `audit.constants.target` from the config; when neither is set the command fails.
444
+ Configure exceptions via `audit.constants.allowlist` — each entry is the constant's name or `repo/relative/path.ts:NAME`
445
+ — for a measured policy that has to stay a tuned number.
446
+
370
447
  ## Configuration
371
448
 
372
449
  **You do not need a config file.** Every command has sensible defaults and works with none. Add a `codefast.config.*`
@@ -500,7 +577,12 @@ export default {
500
577
  links: { allowlist: [] }, // bare link target, or `repo/relative/doc.md:target`
501
578
  comments: { allowlist: [] }, // divider as written, or `repo/relative/path.ts:<divider>`
502
579
  imports: { allowlist: [] }, // offending import text as written, or `repo/relative/path.tsx:<text>`
580
+ assertions: { allowlist: [] }, // assertion as written, or `repo/relative/path.ts:<assertion>`
503
581
  displayNames: { allowlist: [] }, // call as written, or `repo/relative/path.ts:<call>`
582
+ constants: {
583
+ target: "packages/core/src", // default scan root when no CLI arg is passed
584
+ allowlist: [], // constant name, or `repo/relative/path.ts:NAME`
585
+ },
504
586
  },
505
587
  };
506
588
  ```
@@ -551,7 +633,10 @@ pnpm run cli:audit:links # codefast audit links
551
633
  pnpm run cli:audit:rtl # codefast audit rtl
552
634
  pnpm run cli:audit:comments # codefast audit comments
553
635
  pnpm run cli:audit:imports # codefast audit imports
636
+ pnpm run cli:audit:assertions # codefast audit assertions
554
637
  pnpm run cli:audit:display-names # codefast audit display-names
638
+ pnpm run cli:audit:constants # codefast audit constants
639
+ pnpm run cli:audit:publish # codefast audit publish
555
640
  ```
556
641
 
557
642
  `pnpm run version-packages` runs `changeset version` and then `codefast tag`, so published APIs are stamped at release,
@@ -4,9 +4,26 @@
4
4
  */
5
5
  import { parseSync } from "oxc-parser";
6
6
  import { DomainBinaryOperator, DomainSyntaxKind } from "#arrange/domain/ast/ast-node";
7
- function isOxcNode(value) {
8
- return typeof value === "object" && value !== null && typeof value.type === "string";
9
- }
7
+ import { isOxcNode, programStatements } from "#core/oxc-node";
8
+ /**
9
+ * Stands in for a child while its parent is built first, so the child has a parent to point at; the
10
+ * build replaces it before the node leaves the translator.
11
+ */
12
+ const PENDING_NODE = Object.freeze({
13
+ kind: DomainSyntaxKind.Unknown,
14
+ pos: -1,
15
+ end: -1,
16
+ parent: null,
17
+ children: [],
18
+ });
19
+ /** The same stand-in for a child typed as an identifier. */
20
+ const PENDING_IDENTIFIER = Object.freeze({
21
+ kind: DomainSyntaxKind.Identifier,
22
+ pos: -1,
23
+ end: -1,
24
+ parent: null,
25
+ text: "",
26
+ });
10
27
  function nodeName(node) {
11
28
  return typeof node.name === "string" ? node.name : "";
12
29
  }
@@ -87,15 +104,13 @@ export class TypeScriptAstTranslator {
87
104
  end: node.end,
88
105
  parent,
89
106
  importClause: undefined,
90
- moduleSpecifier: undefined,
107
+ moduleSpecifier: PENDING_NODE,
91
108
  };
92
109
  if (specifiers.length > 0) {
93
110
  self.importClause = this.buildImportClause(node, specifiers, self);
94
111
  }
95
112
  const source = isOxcNode(node.source) ? node.source : undefined;
96
- self.moduleSpecifier = source
97
- ? this.translateNode(source, self)
98
- : this.translateUnknown(node, self);
113
+ self.moduleSpecifier = source ? this.translateNode(source, self) : this.translateUnknown(node, self);
99
114
  return self;
100
115
  }
101
116
  buildImportClause(declaration, specifiers, parent) {
@@ -131,7 +146,7 @@ export class TypeScriptAstTranslator {
131
146
  pos: specifier.start,
132
147
  end: specifier.end,
133
148
  parent,
134
- name: undefined,
149
+ name: PENDING_IDENTIFIER,
135
150
  };
136
151
  self.name = this.translateIdentifier(local, self);
137
152
  return self;
@@ -158,7 +173,7 @@ export class TypeScriptAstTranslator {
158
173
  end: specifier.end,
159
174
  parent,
160
175
  propertyName: undefined,
161
- name: undefined,
176
+ name: PENDING_IDENTIFIER,
162
177
  };
163
178
  if (imported && imported.type === "Identifier" && nodeName(imported) !== nodeName(local)) {
164
179
  self.propertyName = this.translateIdentifier(imported, self);
@@ -220,7 +235,7 @@ export class TypeScriptAstTranslator {
220
235
  pos,
221
236
  end,
222
237
  parent,
223
- expression: undefined,
238
+ expression: PENDING_NODE,
224
239
  arguments: [],
225
240
  };
226
241
  self.expression = this.translateNode(callee, self);
@@ -238,8 +253,8 @@ export class TypeScriptAstTranslator {
238
253
  pos,
239
254
  end,
240
255
  parent,
241
- expression: undefined,
242
- name: undefined,
256
+ expression: PENDING_NODE,
257
+ name: PENDING_IDENTIFIER,
243
258
  };
244
259
  self.expression = this.translateNode(object, self);
245
260
  self.name = this.translateNode(property, self);
@@ -273,8 +288,8 @@ export class TypeScriptAstTranslator {
273
288
  pos,
274
289
  end,
275
290
  parent,
276
- name: undefined,
277
- initializer: undefined,
291
+ name: PENDING_NODE,
292
+ initializer: PENDING_NODE,
278
293
  };
279
294
  self.name = this.translateNode(key, self);
280
295
  self.initializer = this.translateNode(value, self);
@@ -305,7 +320,7 @@ export class TypeScriptAstTranslator {
305
320
  pos,
306
321
  end,
307
322
  parent,
308
- expression: undefined,
323
+ expression: PENDING_NODE,
309
324
  };
310
325
  self.expression = this.translateNode(argument, self);
311
326
  return self;
@@ -317,7 +332,7 @@ export class TypeScriptAstTranslator {
317
332
  pos,
318
333
  end,
319
334
  parent,
320
- expression: undefined,
335
+ expression: PENDING_NODE,
321
336
  };
322
337
  self.expression = this.translateNode(expression, self);
323
338
  return self;
@@ -329,7 +344,7 @@ export class TypeScriptAstTranslator {
329
344
  pos,
330
345
  end,
331
346
  parent,
332
- expression: undefined,
347
+ expression: PENDING_NODE,
333
348
  };
334
349
  self.expression = this.translateNode(expression, self);
335
350
  return self;
@@ -341,7 +356,7 @@ export class TypeScriptAstTranslator {
341
356
  pos,
342
357
  end,
343
358
  parent,
344
- expression: undefined,
359
+ expression: PENDING_NODE,
345
360
  };
346
361
  self.expression = this.translateNode(expression, self);
347
362
  return self;
@@ -353,7 +368,7 @@ export class TypeScriptAstTranslator {
353
368
  pos,
354
369
  end,
355
370
  parent,
356
- expression: undefined,
371
+ expression: PENDING_NODE,
357
372
  };
358
373
  self.expression = this.translateNode(expression, self);
359
374
  return self;
@@ -367,9 +382,9 @@ export class TypeScriptAstTranslator {
367
382
  pos,
368
383
  end,
369
384
  parent,
370
- condition: undefined,
371
- whenTrue: undefined,
372
- whenFalse: undefined,
385
+ condition: PENDING_NODE,
386
+ whenTrue: PENDING_NODE,
387
+ whenFalse: PENDING_NODE,
373
388
  };
374
389
  self.condition = this.translateNode(test, self);
375
390
  self.whenTrue = this.translateNode(consequent, self);
@@ -384,9 +399,9 @@ export class TypeScriptAstTranslator {
384
399
  pos,
385
400
  end,
386
401
  parent,
387
- left: undefined,
402
+ left: PENDING_NODE,
388
403
  operator: this.mapBinaryOperator(node.operator),
389
- right: undefined,
404
+ right: PENDING_NODE,
390
405
  };
391
406
  self.left = this.translateNode(left, self);
392
407
  self.right = this.translateNode(right, self);
@@ -399,7 +414,7 @@ export class TypeScriptAstTranslator {
399
414
  pos,
400
415
  end,
401
416
  parent,
402
- expression: undefined,
417
+ expression: PENDING_NODE,
403
418
  };
404
419
  self.expression = this.translateNode(expression, self);
405
420
  return self;
@@ -412,7 +427,7 @@ export class TypeScriptAstTranslator {
412
427
  pos,
413
428
  end,
414
429
  parent,
415
- name: undefined,
430
+ name: PENDING_NODE,
416
431
  initializer: undefined,
417
432
  };
418
433
  self.name = this.translateNode(name, self);
@@ -442,8 +457,7 @@ export class TypeScriptAstTranslator {
442
457
  parseDomainSourceFile(filePath, sourceText) {
443
458
  // Best-effort parse to mirror `ts.createSourceFile` leniency — recoverable syntax
444
459
  // errors still yield a usable tree, so `parseSync` errors are intentionally ignored.
445
- const program = parseSync(filePath, sourceText).program;
446
- const statements = program.body.map((statement) => this.translateNode(statement, null));
460
+ const statements = programStatements(parseSync(filePath, sourceText).program).map((statement) => this.translateNode(statement, null));
447
461
  return {
448
462
  fileName: filePath,
449
463
  text: sourceText,
@@ -6,7 +6,7 @@ import type { Filesystem } from "#core/filesystem/filesystem";
6
6
  *
7
7
  * @since 0.3.16-canary.0
8
8
  */
9
- export declare function processArrangeSimplifyFile(fs: Filesystem, args: {
9
+ export declare function processArrangeSimplifyFile(fs: Pick<Filesystem, "readFileSync" | "writeFileSync">, args: {
10
10
  readonly filePath: string;
11
11
  readonly write: boolean;
12
12
  readonly probe?: VariantClassNameProbe | null;
@@ -0,0 +1,13 @@
1
+ import type { AssertionAuditResult } from "#audit/domain/types";
2
+ /**
3
+ * Exit `1` when any non-allowlisted type-assertion violation remains.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export declare function exitCodeForAssertionAuditResult(result: AssertionAuditResult): number;
8
+ /**
9
+ * Machine-readable type-assertion summary for `--json`.
10
+ *
11
+ * @since 0.13.0
12
+ */
13
+ export declare function formatAssertionAuditJsonOutput(result: AssertionAuditResult, rootDir: string): string;
@@ -0,0 +1,22 @@
1
+ import { CLI_EXIT_GENERAL_ERROR, CLI_EXIT_SUCCESS } from "#core/exit-codes";
2
+ /**
3
+ * Exit `1` when any non-allowlisted type-assertion violation remains.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export function exitCodeForAssertionAuditResult(result) {
8
+ return result.violationCount > 0 ? CLI_EXIT_GENERAL_ERROR : CLI_EXIT_SUCCESS;
9
+ }
10
+ /**
11
+ * Machine-readable type-assertion summary for `--json`.
12
+ *
13
+ * @since 0.13.0
14
+ */
15
+ export function formatAssertionAuditJsonOutput(result, rootDir) {
16
+ return JSON.stringify({
17
+ schemaVersion: 1,
18
+ ok: result.violationCount === 0,
19
+ cwd: rootDir,
20
+ result,
21
+ });
22
+ }
@@ -0,0 +1,18 @@
1
+ import * as z from "zod";
2
+ /**
3
+ * Resolved request for a single type-assertion audit run.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export type AssertionAuditRunRequest = {
8
+ readonly rootDir: string;
9
+ readonly targetPath: string;
10
+ readonly allowlist?: ReadonlyArray<string> | undefined;
11
+ readonly json: boolean;
12
+ };
13
+ /**
14
+ * Zod schema for {@link AssertionAuditRunRequest}.
15
+ *
16
+ * @since 0.13.0
17
+ */
18
+ export declare const assertionAuditRunRequestSchema: z.ZodType<AssertionAuditRunRequest>;
@@ -0,0 +1,12 @@
1
+ import * as z from "zod";
2
+ /**
3
+ * Zod schema for {@link AssertionAuditRunRequest}.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export const assertionAuditRunRequestSchema = z.object({
8
+ rootDir: z.string().min(1),
9
+ targetPath: z.string().min(1),
10
+ allowlist: z.array(z.string()).optional(),
11
+ json: z.boolean(),
12
+ });
@@ -0,0 +1,20 @@
1
+ import type { AssertionViolation } from "#audit/domain/types";
2
+ /**
3
+ * The comment that keeps one double assertion, with the reason it has to stay.
4
+ *
5
+ * @remarks It covers an assertion on its own line or the line below it, and must carry a reason
6
+ * after the colon; one that covers nothing is reported, so a kept assertion cannot outlive its cause.
7
+ *
8
+ * @since 0.13.0
9
+ */
10
+ export declare const DOUBLE_ASSERTION_DIRECTIVE = "codefast-allow-double-assertion";
11
+ /**
12
+ * Scans one TypeScript source for double assertions through `unknown` or `any`, and for directives
13
+ * that keep none or give no reason.
14
+ *
15
+ * @remarks `x as unknown as T`, `(x as unknown) as T` and `<T><unknown>x` are one shape: the
16
+ * compiler found the two types unrelated, and the pair silences it instead of reconciling them.
17
+ *
18
+ * @since 0.13.0
19
+ */
20
+ export declare function auditDoubleAssertionSource(filePath: string, sourceText: string): Array<AssertionViolation>;
@@ -0,0 +1,122 @@
1
+ import { parseSync } from "oxc-parser";
2
+ import { isOxcNode } from "#core/oxc-node";
3
+ import { firstLineOf, lineOfOffset } from "#core/source-position";
4
+ /**
5
+ * The comment that keeps one double assertion, with the reason it has to stay.
6
+ *
7
+ * @remarks It covers an assertion on its own line or the line below it, and must carry a reason
8
+ * after the colon; one that covers nothing is reported, so a kept assertion cannot outlive its cause.
9
+ *
10
+ * @since 0.13.0
11
+ */
12
+ export const DOUBLE_ASSERTION_DIRECTIVE = "codefast-allow-double-assertion";
13
+ const DOUBLE_ASSERTION_REASON = "double assertion through `unknown`/`any` — make the types agree, narrow with a type guard, or keep it " +
14
+ `with // ${DOUBLE_ASSERTION_DIRECTIVE}: <reason>`;
15
+ // Most files hold neither, so a text probe spares them the parse.
16
+ const CANDIDATE_TEXT = new RegExp(`\\bas\\s+(?:unknown|any)\\b|<(?:unknown|any)>|${DOUBLE_ASSERTION_DIRECTIVE}`);
17
+ function isAssertion(node) {
18
+ return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
19
+ }
20
+ /** The assertion an outer one wraps, through any parentheses, when it erases to `unknown` or `any`. */
21
+ function erasingInnerAssertion(outer) {
22
+ let inner = outer.expression;
23
+ while (isOxcNode(inner) && inner.type === "ParenthesizedExpression") {
24
+ inner = inner.expression;
25
+ }
26
+ if (!isOxcNode(inner) || !isAssertion(inner) || !isOxcNode(inner.typeAnnotation)) {
27
+ return undefined;
28
+ }
29
+ const erasedTo = inner.typeAnnotation.type;
30
+ return erasedTo === "TSUnknownKeyword" || erasedTo === "TSAnyKeyword" ? inner : undefined;
31
+ }
32
+ function collectDoubleAssertions(node, found) {
33
+ let next = node;
34
+ if (isAssertion(node)) {
35
+ const inner = erasingInnerAssertion(node);
36
+ if (inner !== undefined) {
37
+ found.push(node);
38
+ // The erased pair is one finding; what it wraps is walked on its own.
39
+ next = inner;
40
+ }
41
+ }
42
+ for (const value of Object.values(next)) {
43
+ if (Array.isArray(value)) {
44
+ for (const item of value) {
45
+ if (isOxcNode(item)) {
46
+ collectDoubleAssertions(item, found);
47
+ }
48
+ }
49
+ }
50
+ else if (isOxcNode(value)) {
51
+ collectDoubleAssertions(value, found);
52
+ }
53
+ }
54
+ }
55
+ function parseDirective(sourceText, comment) {
56
+ if (comment.type !== "Line") {
57
+ return undefined;
58
+ }
59
+ const text = comment.value.trim();
60
+ if (!text.startsWith(DOUBLE_ASSERTION_DIRECTIVE)) {
61
+ return undefined;
62
+ }
63
+ const reason = /^:\s*(\S.*)$/.exec(text.slice(DOUBLE_ASSERTION_DIRECTIVE.length))?.[1] ?? "";
64
+ return {
65
+ line: lineOfOffset(sourceText, comment.start),
66
+ raw: sourceText.slice(comment.start, comment.end),
67
+ reason,
68
+ isUsed: false,
69
+ };
70
+ }
71
+ /**
72
+ * Scans one TypeScript source for double assertions through `unknown` or `any`, and for directives
73
+ * that keep none or give no reason.
74
+ *
75
+ * @remarks `x as unknown as T`, `(x as unknown) as T` and `<T><unknown>x` are one shape: the
76
+ * compiler found the two types unrelated, and the pair silences it instead of reconciling them.
77
+ *
78
+ * @since 0.13.0
79
+ */
80
+ export function auditDoubleAssertionSource(filePath, sourceText) {
81
+ if (!CANDIDATE_TEXT.test(sourceText)) {
82
+ return [];
83
+ }
84
+ const { program, comments } = parseSync(filePath, sourceText);
85
+ const directives = comments.map((comment) => parseDirective(sourceText, comment)).filter((d) => d !== undefined);
86
+ const found = [];
87
+ if (isOxcNode(program)) {
88
+ collectDoubleAssertions(program, found);
89
+ }
90
+ const violations = [];
91
+ for (const assertion of found) {
92
+ const line = lineOfOffset(sourceText, assertion.start);
93
+ const directive = directives.find((candidate) => candidate.reason !== "" && (candidate.line === line || candidate.line === line - 1));
94
+ if (directive !== undefined) {
95
+ directive.isUsed = true;
96
+ continue;
97
+ }
98
+ violations.push({
99
+ line,
100
+ raw: firstLineOf(sourceText.slice(assertion.start, assertion.end)),
101
+ reason: DOUBLE_ASSERTION_REASON,
102
+ });
103
+ }
104
+ for (const directive of directives) {
105
+ if (directive.reason === "") {
106
+ violations.push({
107
+ line: directive.line,
108
+ raw: directive.raw,
109
+ reason: `a ${DOUBLE_ASSERTION_DIRECTIVE} directive states why after a colon`,
110
+ });
111
+ }
112
+ else if (!directive.isUsed) {
113
+ violations.push({
114
+ line: directive.line,
115
+ raw: directive.raw,
116
+ reason: `this ${DOUBLE_ASSERTION_DIRECTIVE} directive keeps no double assertion — remove it`,
117
+ });
118
+ }
119
+ }
120
+ violations.sort((a, b) => a.line - b.line);
121
+ return violations;
122
+ }
@@ -0,0 +1,7 @@
1
+ import type { AssertionAuditResult } from "#audit/domain/types";
2
+ /**
3
+ * Human-readable type-assertion report.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export declare function presentAssertionAuditResult(result: AssertionAuditResult): void;
@@ -0,0 +1,21 @@
1
+ import { logger } from "#core/logger";
2
+ /**
3
+ * Human-readable type-assertion report.
4
+ *
5
+ * @since 0.13.0
6
+ */
7
+ export function presentAssertionAuditResult(result) {
8
+ for (const file of result.files) {
9
+ logger.out(`\n${file.relativePath}`);
10
+ for (const { line, raw, reason } of file.violations) {
11
+ logger.out(` ${line}: ${raw} → ${reason}`);
12
+ }
13
+ }
14
+ const allowlistSuffix = result.allowlistedCount > 0 ? ` (${result.allowlistedCount} allowlisted)` : "";
15
+ if (result.violationCount > 0) {
16
+ logger.out(`\n✖ ${result.violationCount} type-assertion violation(s)${allowlistSuffix}`);
17
+ }
18
+ else {
19
+ logger.out(`✓ No double assertions across ${result.scannedFileCount} file(s)${allowlistSuffix}`);
20
+ }
21
+ }