@starci/jest-preset 2.2.7 → 2.3.1

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.1 - 2026-10-18
4
+
5
+ - Fixed: coverage excludes only compiler-emitted constructor `design:paramtypes` metadata guards injected by the preset transform; application source is never excluded, and the generated anchor is omitted from LCOV.
6
+ - Changed: unit transforms use isolated transpilation instead of a per-file type program. A bundled ts-jest after-transformer preserves constructor metadata guards while mapping only those generated calls to a dedicated generated-metadata coverage anchor, keeping per-file 100 coverage reachable without hiding source branches. `importHelpers` stays enabled; type checking remains a separate gate. The bundled LCOV reporter gives repeated callback names unique identities so the app coverage gate can verify function totals.
7
+
8
+ ## 2.3.0 - 2026-10-16
9
+
10
+ - Added: a declared lite app (the edition of the `hfs.json` above its `be/`) runs its existing integration and contract specs without a test world: those projects exist only when their spec files do, and neither the world runner nor the world global setup and teardown is attached to a lite run.
11
+ - Changed: a full app without a test world keeps only the unit project (an unfinished full scaffold), as before; the cited ruling is now ruling:R212/case-1 amended by ruling:R212/case-4.
12
+
3
13
  ## 2.2.7 - 2026-10-13
4
14
 
5
15
  - Fixed: the ESM-only runtime dependency `jose` (the Supabase access-token verifier of every lite back end) is compiled to CommonJS by ts-jest instead of being skipped with node_modules, so a lite app's unit run can load its guard (owner ruling 2026-10-12: lite keeps full's unit run).
package/README.md CHANGED
@@ -36,11 +36,14 @@ and the exclusions. A repository has no other jest setting to tune.
36
36
  `{ lines: 100, branches: 100, functions: 100, statements: 100 }` on each of those globs — a glob key applies to each file on its own, so every file of a
37
37
  `ruleParams.be.logicRoles` role inside a measured slot must reach 100 on every metric (an average never passes). The
38
38
  managed `test` script runs the unit project with `--coverage` and fails below it. Coverage is measured by v8, which counts the
39
- real source. The unit project overlays `isolatedModules: false` and `importHelpers: true` on the repository tsconfig (ts-jest merges an
40
- inline `tsconfig` object over the `tsconfig.json` it finds): with `isolatedModules` on, TypeScript emits an unreachable
41
- `typeof Dep !== "undefined" ? Dep : Object` guard per class-typed constructor parameter, and inlined decorator helpers carry branches
42
- of their own, so 100 would be unreachable. The repository lists `tslib` in its devDependencies (`UNIT_COMPILER_OPTIONS`). Reporters are `text-summary`,
43
- `text` and `lcov`: the run writes `coverage/lcov.info` under the repository (be/) root.
39
+ real source. The unit project overlays `isolatedModules: true`, `importHelpers: true` and `sourceMap: true` on the repository tsconfig, so ts-jest
40
+ transpiles each file without building a type program. `unit-metadata.cjs`, a ts-jest wrapper and after-transformer, maps
41
+ only synthetic `typeof value === "function" ? value : Object` guards inside TypeScript's generated `__metadata("design:paramtypes", ...)` calls to the dedicated `unit-metadata.generated.cjs` coverage anchor. Their runtime guards remain unchanged, including
42
+ the fallback to `Object` for an undefined or non-class dependency; generated branches are not attributed to source parameters.
43
+ The wrapper restores that second source after ts-jest emits its map. The anchor ignores generated guards only and is omitted from LCOV. Application source calls and guards remain measured. Source maps are enabled even when the build config disables them.
44
+ Source conditionals, including branches on the same constructor line, still count toward per-file 100 coverage. Decorator helpers
45
+ come from `tslib`, which the repository lists in its devDependencies (`UNIT_COMPILER_OPTIONS`). Reporters are `text-summary`,
46
+ `text` and the bundled LCOV reporter (which gives same-named callbacks unique function identities): the run writes `coverage/lcov.info` under the repository (be/) root.
44
47
  - **Sonar and Codecov import that lcov, scoped to the same measured files.** `starci app sync` renders `sonar.coverage.exclusions` (the complement of the measured files: SonarQube has no coverage inclusions) and the `codecov.yml`
45
48
  status paths and components from the one derivation (`scripts/hfs/coverage-scope.mjs`), so jest, Sonar and Codecov measure the same files;
46
49
  the Sonar gate holds coverage at 100 overall, on new code and per file. `sonarExclusions()` still renders `sonar.exclusions`
package/index.cjs CHANGED
@@ -60,7 +60,7 @@ function transform(tsconfig) {
60
60
  * The runtime dependencies of a back end that ship ECMAScript modules only: jest's CommonJS runtime cannot load them, so their
61
61
  * files are compiled to CommonJS like the repository's own (ts-jest with `allowJs`) instead of being skipped as node_modules.
62
62
  * `jose` is the Supabase access-token verifier of every lite back end (be.integrations.supabase); a lite app keeps full's unit
63
- * run (owner ruling 2026-10-12), so a spec that imports its guard must load it.
63
+ * run (ruling:R212/case-1, amended by ruling:R212/case-4), so a spec that imports its guard must load it.
64
64
  */
65
65
  const ESM_ONLY_DEPENDENCIES = Object.freeze(["jose"])
66
66
  const ESM_DEPENDENCY_TRANSFORM = String.raw`^.+[\\/]node_modules[\\/](?:${ESM_ONLY_DEPENDENCIES.join("|")})[\\/].+\.js$`
@@ -72,21 +72,15 @@ function esmDependencyTransform() {
72
72
  /** node_modules stay untransformed except the ESM-only dependencies above. */
73
73
  const TRANSFORM_IGNORE_PATTERNS = Object.freeze([String.raw`[\\/]node_modules[\\/](?!(?:${ESM_ONLY_DEPENDENCIES.join("|")})[\\/])`])
74
74
 
75
- /**
76
- * The compiler options the UNIT project overlays on the repository tsconfig (ts-jest merges an inline `tsconfig` object over the
77
- * `tsconfig.json` it finds under `rootDir`). They exist so that per-file 100 coverage is reachable for a decorated service:
78
- * - `isolatedModules: false`: with it on, TypeScript cannot tell whether an imported name is a class, and emits a
79
- * `typeof (_a = typeof Dep !== "undefined" && Dep) === "function" ? _a : Object` guard per class-typed constructor parameter for
80
- * `design:paramtypes`; the `Object` arm can never run, so the branch stays uncovered. With the full language service the
81
- * metadata is emitted as plain `Dep`.
82
- * - `importHelpers: true`: the decorator helpers (`__decorate`, `__param`, `__metadata`) come from `tslib`, not inlined per file
83
- * with branches of their own. The repository lists `tslib` in its devDependencies.
84
- * Integration, e2e and contract keep the repository setting: they measure nothing.
85
- */
86
- const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: false, importHelpers: true })
75
+ /** The unit project transpiles each file independently; helpers stay in tslib, source maps distinguish generated metadata, and type checks run separately. */
76
+ const UNIT_COMPILER_OPTIONS = Object.freeze({ isolatedModules: true, importHelpers: true, sourceMap: true })
87
77
 
78
+ /** Generated constructor metadata keeps its runtime guards, mapped to a generated-metadata coverage anchor instead of source parameters. */
88
79
  function unitTransform() {
89
- return transform({ ...UNIT_COMPILER_OPTIONS })
80
+ const transforms = transform({ ...UNIT_COMPILER_OPTIONS })
81
+ transforms[TS_TRANSFORM][0] = require.resolve("./unit-metadata.cjs")
82
+ transforms[TS_TRANSFORM][1].astTransformers = { after: [require.resolve("./unit-metadata.cjs")] }
83
+ return transforms
90
84
  }
91
85
 
92
86
  /**
@@ -114,7 +108,7 @@ const underTests = (folder) => String.raw`[\\/]src[\\/]tests[\\/]` + folder + St
114
108
  * test tree compiles against `src/tests/tsconfig.json`, the nearest config of every file under `src/tests/`. The
115
109
  * integration, e2e and contract projects share the world's `global-setup.ts`/`global-teardown.ts` (`src/tests/world/`) and
116
110
  * run their spec files up to `min(--maxWorkers, slots)` at once, each in a worker process of its own bound to one data slot
117
- * of the test world (`world-runner.cjs`; a slot's data is reset when a file boots, so no two files ever share a slot); the unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
111
+ * of the test world (`world-runner.cjs`; a slot's data is reset when a file boots, so no two files ever share a slot). A declared lite app instead runs its existing integration and contract specs without a world, on jest's stock runner. The unit project alone has a `setupFilesAfterEnv` (the Outcome matchers).
118
112
  * Coverage is collected from the logic roles of the measured roots only (`coverage` option), with a per-file threshold of 100 on lines, branches, functions and
119
113
  * statements: the `test` script runs the unit project with `--coverage`, fails below it and writes `coverage/lcov.info` for Sonar. It uses v8: istanbul instruments the helpers TypeScript emits (`__decorate`, `__param`, `__awaiter`, interop wrappers)
120
114
  * as thousands of branches no spec can cover, while v8 measures the real source.
@@ -127,6 +121,16 @@ function hasTestWorld(root) {
127
121
  return require("node:fs").existsSync(require("node:path").join(root, "src", "tests", "world", "global-setup.ts"))
128
122
  }
129
123
 
124
+ /** The managed config runs in `be/`, one level below the app's edition declaration. */
125
+ function isLiteApp(root) {
126
+ try {
127
+ const file = require("node:path").join(root, "..", "hfs.json")
128
+ return JSON.parse(require("node:fs").readFileSync(file, "utf8")).edition === "lite"
129
+ } catch {
130
+ return false
131
+ }
132
+ }
133
+
130
134
  /** Every alternative a `{a,b}` pattern spells, braces expanded left to right. */
131
135
  function braceVariants(value) {
132
136
  const match = /{([^{}]+)}/.exec(value)
@@ -189,6 +193,8 @@ function coverageOf(options) {
189
193
 
190
194
  function starciJestConfig(options) {
191
195
  const coverage = coverageOf(options)
196
+ const root = process.cwd()
197
+ const world = hasTestWorld(root)
192
198
  const shared = {
193
199
  preset: "ts-jest",
194
200
  testEnvironment: "node",
@@ -201,15 +207,22 @@ function starciJestConfig(options) {
201
207
  ...shared,
202
208
  transform: transform("src/tests/tsconfig.json"),
203
209
  displayName,
204
- // Every spec file in a process of its own (never in band): nothing one file or the globalSetup put on a process global
205
- // (a framework registry such as @nestjs/graphql's type metadata) reaches another file. See world-runner.cjs.
206
- runner: WORLD_RUNNER,
207
210
  // A glob without the root's absolute path: jest's roots (src, apps) already bound the search, and a root path that holds a dot-named folder (a checkout under `.claude`) turns its backslash separator into a glob escape on Windows, so a path-prefixed pattern would match no file.
208
211
  testMatch: [`**/src/tests/${displayName}/**/*.${suffix}.ts`],
209
- // The one test world: started once per run of a project that has a test to run, torn down after it.
210
- globalSetup: "<rootDir>/src/tests/world/global-setup.ts",
211
- globalTeardown: "<rootDir>/src/tests/world/global-teardown.ts",
212
+ // Full's world gives every spec file its own process and data slot; lite needs neither its runner nor its hooks.
213
+ ...(world ? {
214
+ runner: WORLD_RUNNER,
215
+ globalSetup: "<rootDir>/src/tests/world/global-setup.ts",
216
+ globalTeardown: "<rootDir>/src/tests/world/global-teardown.ts",
217
+ } : {}),
212
218
  })
219
+ const testProjects = world ? [suite("integration", "integration-spec"), suite("e2e", "e2e-spec"), suite("contract", "contract-spec")] : []
220
+ if (!world && isLiteApp(root)) {
221
+ for (const name of ["integration", "contract"]) {
222
+ const suffix = `${name}-spec`
223
+ if (hasCoverageSubjects(root, `src/tests/${name}/**/*.${suffix}.ts`)) testProjects.push(suite(name, suffix))
224
+ }
225
+ }
213
226
  return {
214
227
  testTimeout: 120_000,
215
228
  coverageProvider: "v8",
@@ -218,7 +231,7 @@ function starciJestConfig(options) {
218
231
  coverageThreshold: Object.fromEntries(coverage.roots.map((root) => rootGlob(root, coverage.roles)).filter((glob) => hasCoverageSubjects(process.cwd(), glob)).map((glob) => [`./${glob}`, { ...COVERAGE_THRESHOLD }])),
219
232
  coverageDirectory: "coverage",
220
233
  // lcov is what Sonar imports (coverage/lcov.info); text-summary and text are what the developer reads.
221
- coverageReporters: ["text-summary", "text", "lcov"],
234
+ coverageReporters: ["text-summary", "text", [require.resolve("./lcov.cjs"), {}]],
222
235
  projects: [
223
236
  {
224
237
  ...shared,
@@ -230,10 +243,9 @@ function starciJestConfig(options) {
230
243
  testMatch: ["**/*.spec.ts"],
231
244
  testPathIgnorePatterns: ["/node_modules/", ...TEST_KIND_FOLDERS.map(underTests)],
232
245
  },
233
- // The integration, e2e and contract projects exist only where the repository has its test world: jest validates
234
- // every project's globalSetup even under --selectProjects unit, so a repository whose world is not written yet
235
- // would otherwise fail to run its unit specs with the managed config.
236
- ...(hasTestWorld(process.cwd()) ? [suite("integration", "integration-spec"), suite("e2e", "e2e-spec"), suite("contract", "contract-spec")] : []),
246
+ // Jest validates every project's world hooks even under --selectProjects unit. An unfinished full scaffold
247
+ // keeps only unit; a lite app's present integration/contract lanes need no test world.
248
+ ...testProjects,
237
249
  ],
238
250
  }
239
251
  }
package/index.d.ts CHANGED
@@ -32,7 +32,7 @@ export declare function hasCoverageSubjects(root: string, glob: string): boolean
32
32
  export declare function sonarExclusions(): string
33
33
  export declare const COVERAGE_EXCLUDES: string[]
34
34
  /** The compiler options the unit project overlays on the repository tsconfig so per-file 100 coverage of a decorated service is reachable. */
35
- export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules: false; importHelpers: true }>
35
+ export declare const UNIT_COMPILER_OPTIONS: Readonly<{ isolatedModules: true; importHelpers: true; sourceMap: true }>
36
36
  /** The runner of the integration, e2e and contract projects (`world-runner.cjs`): every spec file in a worker process of its own. */
37
37
  export declare const WORLD_RUNNER: string
38
38
  export declare const COVERAGE_THRESHOLD: Readonly<{ lines: 100; branches: 100; functions: 100; statements: 100 }>
package/lcov.cjs ADDED
@@ -0,0 +1,25 @@
1
+ "use strict"
2
+
3
+ const { createRequire } = require("node:module")
4
+ const fromJest = createRequire(require.resolve("jest"))
5
+ const fromCore = createRequire(fromJest.resolve("@jest/core"))
6
+ const fromReporters = createRequire(fromCore.resolve("@jest/reporters"))
7
+ const LcovReport = fromReporters("istanbul-reports").create("lcov").constructor
8
+ const METADATA_SOURCE = require.resolve("./unit-metadata.generated.cjs")
9
+
10
+ /** LCOV identifies functions by name; append their stable coverage id so same-named callbacks retain separate counts. */
11
+ class StarciLcovReport extends LcovReport {
12
+ onDetail(node, context) {
13
+ if (node.getFileCoverage().path === METADATA_SOURCE) return
14
+ const entries = Object.entries(node.getFileCoverage().fnMap)
15
+ const names = entries.map(([, meta]) => meta.name)
16
+ try {
17
+ entries.forEach(([id, meta]) => { meta.name = `${meta.name}#${id}` })
18
+ super.onDetail(node, context)
19
+ } finally {
20
+ entries.forEach(([, meta], index) => { meta.name = names[index] })
21
+ }
22
+ }
23
+ }
24
+
25
+ module.exports = StarciLcovReport
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/jest-preset",
3
- "version": "2.2.7",
3
+ "version": "2.3.1",
4
4
  "description": "The StarCi jest config for Nest repositories (unit, integration, e2e and contract projects, ts-jest with diagnostics off, per-file 100 coverage of *.service.ts only) and the one shared unit-test double kit: typed mock<T>(), mockEntityManager(), fakeTransaction(), fakeCache(), fakeLock(), recordingEventBus(), recordingQueueOutbox(), fakeInbox(), FakeClock, fakeIds(), builder() and the Outcome matchers toBeRefused/toSucceedWith.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -68,11 +68,11 @@
68
68
  "access": "public"
69
69
  },
70
70
  "peerDependencies": {
71
+ "@jest/globals": ">=29",
71
72
  "@types/jest": ">=29",
72
73
  "jest": ">=29",
73
74
  "ts-jest": ">=29",
74
- "typeorm": ">=0.3",
75
- "@jest/globals": ">=29"
75
+ "typeorm": ">=0.3"
76
76
  },
77
77
  "scripts": {
78
78
  "test": "node --test \"*.spec.mjs\""
@@ -90,6 +90,7 @@
90
90
  "jest": "29.7.0",
91
91
  "jest-util": "29.7.0",
92
92
  "ts-jest": "29.4.12",
93
+ "tslib": "2.8.1",
93
94
  "typescript": "5.9.3"
94
95
  }
95
96
  }
@@ -0,0 +1,63 @@
1
+ "use strict"
2
+
3
+ const METADATA_SOURCE = require.resolve("./unit-metadata.generated.cjs")
4
+ const METADATA_TEXT = require("node:fs").readFileSync(METADATA_SOURCE, "utf8")
5
+
6
+ /** Only synthetic conditional guards inside compiler-emitted parameter metadata map to the dedicated ignore anchor. */
7
+ function factory(compiler) {
8
+ const ts = compiler.configSet.compilerModule
9
+ const source = ts.createSourceMapSource(METADATA_SOURCE, METADATA_TEXT)
10
+ return (context) => {
11
+ const visit = (node) => {
12
+ if (isGeneratedMetadata(ts, node)) {
13
+ const guard = (child) => {
14
+ if (isGeneratedGuard(ts, child)) {
15
+ ts.setSourceMapRange(child, { pos: METADATA_TEXT.indexOf("\n") + 1, end: METADATA_TEXT.length - 1, source })
16
+ ts.setEmitFlags(child, ts.getEmitFlags(child) | ts.EmitFlags.NoNestedSourceMaps)
17
+ return child
18
+ }
19
+ return ts.visitEachChild(child, guard, context)
20
+ }
21
+ return ts.visitEachChild(node, guard, context)
22
+ }
23
+ return ts.visitEachChild(node, visit, context)
24
+ }
25
+ return (file) => ts.visitNode(file, visit)
26
+ }
27
+ }
28
+
29
+ /** Only TypeScript's synthetic __metadata("design:paramtypes", ...) call is eligible; source calls remain measured. */
30
+ function isGeneratedMetadata(ts, node) {
31
+ return node.pos < 0 && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "__metadata" && node.arguments.length === 2 && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === "design:paramtypes"
32
+ }
33
+
34
+ /** A metadata exclusion requires TypeScript's synthetic `typeof value === "function" ? value : Object` guard shape. */
35
+ function isGeneratedGuard(ts, node) {
36
+ if (node.pos >= 0 || !ts.isConditionalExpression(node)) return false
37
+ const condition = node.condition
38
+ return ts.isBinaryExpression(condition) && condition.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken && ts.isTypeOfExpression(condition.left) && ts.isStringLiteral(condition.right) && condition.right.text === "function" && ts.isIdentifier(node.whenFalse) && node.whenFalse.text === "Object"
39
+ }
40
+
41
+ /** ts-jest normalizes map sources to the input file; restore the generated metadata anchor before Jest reads the map. */
42
+ function restoreMetadataSource(result) {
43
+ const prefix = "sourceMappingURL=data:application/json;charset=utf-8;base64,"
44
+ const offset = result.code.lastIndexOf(prefix)
45
+ if (offset < 0) return result
46
+ const start = offset + prefix.length
47
+ const map = JSON.parse(Buffer.from(result.code.slice(start), "base64").toString("utf8"))
48
+ if (map.sourcesContent?.length !== 2 || map.sourcesContent[1] !== METADATA_TEXT) return result
49
+ map.sources = [map.sources[0], METADATA_SOURCE]
50
+ return { ...result, code: result.code.slice(0, start) + Buffer.from(JSON.stringify(map)).toString("base64") }
51
+ }
52
+
53
+ /** Unit transforms delegate compilation and caching to ts-jest; the after-transformer changes metadata mappings only. */
54
+ function createTransformer(options) {
55
+ const delegate = require("ts-jest").default.createTransformer(options)
56
+ return {
57
+ getCacheKey: (...args) => delegate.getCacheKey(...args) + ":starci-unit-metadata-5",
58
+ process: (...args) => restoreMetadataSource(delegate.process(...args)),
59
+ processAsync: async (...args) => restoreMetadataSource(await delegate.processAsync(...args)),
60
+ }
61
+ }
62
+
63
+ module.exports = { name: "starci-unit-metadata", version: 5, factory, createTransformer }
@@ -0,0 +1,2 @@
1
+ /* c8 ignore next */
2
+ // Coverage anchor for generated design:paramtypes guards only.