@forumone/create-throughline 0.3.1 → 0.5.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 +125 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +5 -0
- package/dist/generator.js.map +1 -1
- package/dist/post-install.d.ts.map +1 -1
- package/dist/post-install.js +10 -3
- package/dist/post-install.js.map +1 -1
- package/dist/templates/base/.env.example +32 -18
- package/dist/templates/base/CLAUDE.md +112 -0
- package/dist/templates/base/README.md +92 -7
- package/dist/templates/base/apps/web/e2e/site.ts +36 -0
- package/dist/templates/base/apps/web/e2e/smoke.spec.ts +52 -0
- package/dist/templates/base/apps/web/package.json.template +23 -7
- package/dist/templates/base/apps/web/playwright.config.ts +63 -0
- package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
- package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
- package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
- package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +36 -0
- package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +41 -4
- package/dist/templates/base/apps/web/src/instrumentation.ts +35 -0
- package/dist/templates/base/apps/web/src/lib/cache-tags.ts +23 -0
- package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
- package/dist/templates/base/apps/web/src/payload.config.ts.template +136 -19
- package/dist/templates/base/apps/web/vitest.config.ts +57 -0
- package/dist/templates/base/claude/hooks/gate.sh +45 -0
- package/dist/templates/base/claude/settings.json +16 -0
- package/dist/templates/base/github/scripts/check-generated.sh +44 -0
- package/dist/templates/base/github/workflows/ci.yml +143 -0
- package/dist/templates/base/gitignore +9 -0
- package/dist/templates/base/package.json.template +7 -1
- package/dist/templates/base/scripts/check-audit.ts +117 -0
- package/dist/templates/base/scripts/check-single-instance.ts +113 -0
- package/dist/templates/base/scripts/check-tested-packages.ts +163 -0
- package/dist/templates/base/turbo.json +16 -1
- package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
- package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +1 -0
- package/dist/versions.json +11 -11
- package/package.json +5 -4
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Two tiers.
|
|
4
|
+
#
|
|
5
|
+
# fast every pull request and every push to main. No database: every gate
|
|
6
|
+
# here needs the config to parse, not to connect.
|
|
7
|
+
# verify pushes to main, and on demand. A Postgres service, the migrations,
|
|
8
|
+
# a production build — `next build` boots Payload to collect page
|
|
9
|
+
# data, so it needs a migrated database — and the Playwright smoke
|
|
10
|
+
# pack against `next start` on that build.
|
|
11
|
+
#
|
|
12
|
+
# A green `fast` is what a pull request is reviewed on; `verify` is what says
|
|
13
|
+
# main still builds and migrates. Add a path filter to run `verify` on pull
|
|
14
|
+
# requests that touch the config, the migrations or the dependencies, once this
|
|
15
|
+
# project has enough of all three to make it worth the minutes.
|
|
16
|
+
|
|
17
|
+
on:
|
|
18
|
+
pull_request:
|
|
19
|
+
push:
|
|
20
|
+
branches: [main]
|
|
21
|
+
workflow_dispatch:
|
|
22
|
+
|
|
23
|
+
concurrency:
|
|
24
|
+
group: ci-${{ github.ref }}
|
|
25
|
+
cancel-in-progress: true
|
|
26
|
+
|
|
27
|
+
# What payload.config.ts and the plugins check at load, as placeholders. The
|
|
28
|
+
# config parses the connection string and never connects in `fast`; secrets only
|
|
29
|
+
# have to be long enough to pass the plugins' own checks. Never a real value.
|
|
30
|
+
env:
|
|
31
|
+
DATABASE_URI: postgres://ci:ci@localhost:5432/ci
|
|
32
|
+
PAYLOAD_SECRET: ci-placeholder-not-a-secret-000000000000
|
|
33
|
+
APPROVAL_TOKEN_SECRET: ci-placeholder-not-a-secret-000000000000
|
|
34
|
+
FORMS_IP_HASH_SECRET: ci-placeholder-not-a-secret-000000000000
|
|
35
|
+
RESEND_API_KEY: re_ci_placeholder
|
|
36
|
+
EMAIL_FROM_ADDRESS: ci@example.com
|
|
37
|
+
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
|
38
|
+
|
|
39
|
+
jobs:
|
|
40
|
+
fast:
|
|
41
|
+
runs-on: ubuntu-latest
|
|
42
|
+
timeout-minutes: 20
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
|
|
46
|
+
- uses: pnpm/action-setup@v4
|
|
47
|
+
|
|
48
|
+
- uses: actions/setup-node@v4
|
|
49
|
+
with:
|
|
50
|
+
node-version: 22
|
|
51
|
+
cache: pnpm
|
|
52
|
+
|
|
53
|
+
# First, because a lockfile missing an entry passes every other gate
|
|
54
|
+
# locally and fails here.
|
|
55
|
+
- name: Install
|
|
56
|
+
run: pnpm install --frozen-lockfile
|
|
57
|
+
|
|
58
|
+
- name: Typecheck
|
|
59
|
+
run: pnpm typecheck
|
|
60
|
+
|
|
61
|
+
- name: Lint
|
|
62
|
+
run: pnpm lint
|
|
63
|
+
|
|
64
|
+
- name: Check singletons resolve once
|
|
65
|
+
run: pnpm check:instances
|
|
66
|
+
|
|
67
|
+
- name: Check every package that ships code has tests
|
|
68
|
+
run: pnpm check:tested
|
|
69
|
+
|
|
70
|
+
- name: Check the migration chain
|
|
71
|
+
run: pnpm check:migrations
|
|
72
|
+
|
|
73
|
+
# Builds the design system's manifest and lints it against the contract:
|
|
74
|
+
# an unknown token, a composition rule naming a missing component, an
|
|
75
|
+
# example pointing at a story that does not exist.
|
|
76
|
+
- name: Validate the design-system manifest
|
|
77
|
+
run: pnpm validate
|
|
78
|
+
|
|
79
|
+
- name: Check generated Payload artifacts are current
|
|
80
|
+
run: bash .github/scripts/check-generated.sh
|
|
81
|
+
|
|
82
|
+
- name: Test
|
|
83
|
+
run: pnpm test
|
|
84
|
+
|
|
85
|
+
# Non-blocking: a new advisory is news, not necessarily a defect, and
|
|
86
|
+
# blocking every open pull request the morning a CVE is published is how
|
|
87
|
+
# a gate gets removed rather than fixed. Read it.
|
|
88
|
+
- name: Audit production dependencies
|
|
89
|
+
run: pnpm check:audit
|
|
90
|
+
continue-on-error: true
|
|
91
|
+
|
|
92
|
+
verify:
|
|
93
|
+
if: github.event_name != 'pull_request'
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
timeout-minutes: 25
|
|
96
|
+
|
|
97
|
+
services:
|
|
98
|
+
postgres:
|
|
99
|
+
image: postgres:17-alpine
|
|
100
|
+
env:
|
|
101
|
+
POSTGRES_USER: ci
|
|
102
|
+
POSTGRES_PASSWORD: ci
|
|
103
|
+
POSTGRES_DB: ci
|
|
104
|
+
ports:
|
|
105
|
+
- 5432:5432
|
|
106
|
+
options: >-
|
|
107
|
+
--health-cmd pg_isready
|
|
108
|
+
--health-interval 10s
|
|
109
|
+
--health-timeout 5s
|
|
110
|
+
--health-retries 5
|
|
111
|
+
|
|
112
|
+
steps:
|
|
113
|
+
- uses: actions/checkout@v4
|
|
114
|
+
|
|
115
|
+
- uses: pnpm/action-setup@v4
|
|
116
|
+
|
|
117
|
+
- uses: actions/setup-node@v4
|
|
118
|
+
with:
|
|
119
|
+
node-version: 22
|
|
120
|
+
cache: pnpm
|
|
121
|
+
|
|
122
|
+
- name: Install
|
|
123
|
+
run: pnpm install --frozen-lockfile
|
|
124
|
+
|
|
125
|
+
# A fresh database, migrated exactly as a deploy would migrate it. A
|
|
126
|
+
# migration that does not apply cleanly from empty fails here rather than
|
|
127
|
+
# on the first environment somebody creates.
|
|
128
|
+
- name: Migrate
|
|
129
|
+
run: pnpm --dir apps/web migrate
|
|
130
|
+
|
|
131
|
+
- name: Build
|
|
132
|
+
run: pnpm build
|
|
133
|
+
|
|
134
|
+
# The smoke pack (apps/web/e2e): `next start` on the build above, against
|
|
135
|
+
# the empty database migrated above, asserting only what answers without
|
|
136
|
+
# content — the front door, a 404, the admin sign-in screen, and the REST
|
|
137
|
+
# API refusing an anonymous read of private collections. Here and not in
|
|
138
|
+
# `fast`, because the server needs a database to answer at all.
|
|
139
|
+
- name: Install Chromium for the smoke pack
|
|
140
|
+
run: pnpm --dir apps/web test:smoke:install
|
|
141
|
+
|
|
142
|
+
- name: Smoke test
|
|
143
|
+
run: pnpm --dir apps/web test:smoke
|
|
@@ -13,3 +13,12 @@ dist/
|
|
|
13
13
|
.vscode/
|
|
14
14
|
|
|
15
15
|
coverage/
|
|
16
|
+
|
|
17
|
+
# Written by throughline-payload (@forumone/throughline-core): the process
|
|
18
|
+
# group of each in-flight Payload CLI run, so the next run can reap one whose
|
|
19
|
+
# runner was killed before it could clean up.
|
|
20
|
+
.payload-cli-pids
|
|
21
|
+
|
|
22
|
+
# Playwright: traces and screenshots from a failed smoke run
|
|
23
|
+
e2e/.results/
|
|
24
|
+
playwright-report/
|
|
@@ -7,14 +7,20 @@
|
|
|
7
7
|
"dev": "{{#if useReferenceDs}}pnpm --filter \"./design-system\" build && {{/if}}turbo run dev",
|
|
8
8
|
"storybook": "turbo run storybook",
|
|
9
9
|
"build-storybook": "turbo run build-storybook",
|
|
10
|
-
"validate": "turbo run validate",
|
|
10
|
+
"validate": "turbo run validate --filter=./design-system",
|
|
11
11
|
"lint": "turbo run lint",
|
|
12
12
|
"typecheck": "turbo run typecheck",
|
|
13
13
|
"test": "turbo run test",
|
|
14
|
+
"check:instances": "tsx scripts/check-single-instance.ts",
|
|
15
|
+
"check:tested": "tsx scripts/check-tested-packages.ts",
|
|
16
|
+
"check:audit": "tsx scripts/check-audit.ts",
|
|
17
|
+
"check:migrations": "pnpm --dir apps/web run check:migrations",
|
|
18
|
+
"payload:reap": "pnpm --dir apps/web run payload:reap",
|
|
14
19
|
"format": "prettier --write ."
|
|
15
20
|
},
|
|
16
21
|
"devDependencies": {
|
|
17
22
|
"prettier": "^3.4.0",
|
|
23
|
+
"tsx": "^4.21.0",
|
|
18
24
|
"turbo": "^2.3.0",
|
|
19
25
|
"typescript": "^5.6.0"
|
|
20
26
|
},
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
`pnpm audit` over the production tree, with an allowlist that cannot rot.
|
|
5
|
+
|
|
6
|
+
pnpm check:audit
|
|
7
|
+
|
|
8
|
+
**Why not `pnpm audit --prod --audit-level=high` on its own.** A Payload + Next
|
|
9
|
+
production tree is rarely clean: there are usually high advisories in it that no
|
|
10
|
+
request the site serves can reach. A gate that is red on every run is a gate
|
|
11
|
+
people stop reading.
|
|
12
|
+
|
|
13
|
+
**Why not `pnpm.auditConfig.ignoreGhsas`.** It filters, and that is all it does.
|
|
14
|
+
A list of ids in `package.json` carries no reason, and nothing notices when one
|
|
15
|
+
stops applying — the dependency is dropped or patched, and the entry sits there
|
|
16
|
+
suppressing a finding it no longer describes.
|
|
17
|
+
|
|
18
|
+
So this asserts the allowlist in both directions. An advisory that is not on the
|
|
19
|
+
list is reported. An entry that no longer appears in the audit is *also*
|
|
20
|
+
reported, as something to delete. That second half is why this is a script.
|
|
21
|
+
|
|
22
|
+
It is non-blocking in CI. A new advisory is news, not necessarily a defect, and
|
|
23
|
+
blocking every open pull request the morning a CVE is published is how a gate
|
|
24
|
+
gets removed rather than fixed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface Allowed {
|
|
28
|
+
ghsa: string
|
|
29
|
+
module: string
|
|
30
|
+
/** Which path it is on, and why that path cannot reach a request. */
|
|
31
|
+
why: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* High and critical advisories in the production tree that are accepted.
|
|
36
|
+
*
|
|
37
|
+
* "Not exploitable" is not a reason. Say which dependency path it arrives on
|
|
38
|
+
* and what would have to be true for a request to reach it.
|
|
39
|
+
*/
|
|
40
|
+
const ALLOWED: Allowed[] = []
|
|
41
|
+
|
|
42
|
+
interface Advisory {
|
|
43
|
+
severity: string
|
|
44
|
+
module_name: string
|
|
45
|
+
title: string
|
|
46
|
+
github_advisory_id?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function audit(): Advisory[] {
|
|
50
|
+
let stdout: string
|
|
51
|
+
try {
|
|
52
|
+
// Exits non-zero whenever it finds anything at or above the level, so the
|
|
53
|
+
// throw is the normal case and the output is on it.
|
|
54
|
+
stdout = execFileSync('pnpm', ['audit', '--prod', '--audit-level=high', '--json'], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
stdout = (error as { stdout?: string }).stdout ?? ''
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (stdout.trim() === '') return []
|
|
63
|
+
const parsed = JSON.parse(stdout) as { advisories?: Record<string, Advisory> }
|
|
64
|
+
// One advisory reached through several dependency paths is reported once
|
|
65
|
+
// per path; it is one finding.
|
|
66
|
+
const unique = new Map<string, Advisory>()
|
|
67
|
+
for (const advisory of Object.values(parsed.advisories ?? {})) {
|
|
68
|
+
if (advisory.severity !== 'high' && advisory.severity !== 'critical') continue
|
|
69
|
+
unique.set(advisory.github_advisory_id ?? `${advisory.module_name}:${advisory.title}`, advisory)
|
|
70
|
+
}
|
|
71
|
+
return [...unique.values()]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function main(): void {
|
|
75
|
+
const found = audit()
|
|
76
|
+
const seen = new Set(found.map((a) => a.github_advisory_id).filter(Boolean) as string[])
|
|
77
|
+
const allowed = new Map(ALLOWED.map((entry) => [entry.ghsa, entry]))
|
|
78
|
+
|
|
79
|
+
const unexpected = found.filter(
|
|
80
|
+
(a) => !a.github_advisory_id || !allowed.has(a.github_advisory_id),
|
|
81
|
+
)
|
|
82
|
+
const stale = ALLOWED.filter((entry) => !seen.has(entry.ghsa))
|
|
83
|
+
|
|
84
|
+
for (const advisory of found) {
|
|
85
|
+
if (advisory.github_advisory_id && allowed.has(advisory.github_advisory_id)) {
|
|
86
|
+
console.log(` = ${advisory.module_name.padEnd(18)} ${advisory.github_advisory_id} accepted`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (unexpected.length > 0) {
|
|
91
|
+
console.log(`\n${unexpected.length} advisory/advisories not on the allowlist:\n`)
|
|
92
|
+
for (const advisory of unexpected) {
|
|
93
|
+
console.log(` ! ${advisory.severity.padEnd(8)} ${advisory.module_name}`)
|
|
94
|
+
console.log(` ${advisory.github_advisory_id ?? '(no GHSA id)'} — ${advisory.title}`)
|
|
95
|
+
}
|
|
96
|
+
console.log(
|
|
97
|
+
'\nEither upgrade past it, or add it to ALLOWED in scripts/check-audit.ts with the\n' +
|
|
98
|
+
'dependency path it arrives on and why no request can reach it.',
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (stale.length > 0) {
|
|
103
|
+
console.log(
|
|
104
|
+
`\n${stale.length} allowlist entry/entries no longer apply and should be deleted:\n`,
|
|
105
|
+
)
|
|
106
|
+
for (const entry of stale) console.log(` - ${entry.ghsa} (${entry.module})`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (unexpected.length === 0 && stale.length === 0) {
|
|
110
|
+
console.log(`\nNothing new. ${found.length} known advisory/advisories, each with a reason.`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.exitCode = 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
One copy of each package that carries React context or is a singleton.
|
|
5
|
+
|
|
6
|
+
pnpm check:instances
|
|
7
|
+
|
|
8
|
+
**The failure this exists for.** pnpm keys a package's identity on its version
|
|
9
|
+
*and* its resolved peers. When two workspace packages resolve a peer differently
|
|
10
|
+
— one on `@types/node@^20`, another on `^24`, say — pnpm installs the same
|
|
11
|
+
version of `@payloadcms/ui` twice, in two directories. Two directories are two
|
|
12
|
+
module instances, two instances are two React contexts, and a component that
|
|
13
|
+
reads the context the provider did not populate gets `undefined`. In the admin
|
|
14
|
+
that is an intermittent 500 on one view while every other view works.
|
|
15
|
+
|
|
16
|
+
**Nothing else can see it.** `pnpm install` succeeds, typecheck, lint and every
|
|
17
|
+
unit test pass, because the two copies are byte-identical: the problem exists
|
|
18
|
+
only at module-resolution time, and only matters at render time. So this asserts
|
|
19
|
+
what no other gate can — that each package below resolves to exactly one
|
|
20
|
+
instance across the workspace. A version range is not the thing to check; the
|
|
21
|
+
instance is.
|
|
22
|
+
|
|
23
|
+
When it fails, compare the peers of the packages that pull in each copy and pin
|
|
24
|
+
the offender in `pnpm.overrides` in the root `package.json`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Packages where a second copy is a bug rather than waste: each holds React
|
|
29
|
+
* context or is a singleton by construction, so two copies fail at runtime.
|
|
30
|
+
*/
|
|
31
|
+
const SINGLETONS = ['@payloadcms/ui', 'payload', 'react', 'react-dom']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Copies that are real but cannot reach a bundle, by version, with the reason.
|
|
35
|
+
* An entry here is a claim that nothing you ship imports that copy.
|
|
36
|
+
*/
|
|
37
|
+
const ACCEPTED: Record<string, string[]> = {}
|
|
38
|
+
|
|
39
|
+
interface Instance {
|
|
40
|
+
version: string
|
|
41
|
+
path: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every resolved copy of `name`, by reading pnpm's own resolution. */
|
|
45
|
+
function instances(name: string): Instance[] {
|
|
46
|
+
const stdout = execFileSync(
|
|
47
|
+
'pnpm',
|
|
48
|
+
['ls', name, '--recursive', '--depth', 'Infinity', '--json'],
|
|
49
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 },
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const found = new Map<string, Instance>()
|
|
53
|
+
const visit = (deps: Record<string, unknown> | undefined): void => {
|
|
54
|
+
for (const [depName, raw] of Object.entries(deps ?? {})) {
|
|
55
|
+
const info = raw as {
|
|
56
|
+
version?: string
|
|
57
|
+
path?: string
|
|
58
|
+
dependencies?: Record<string, unknown>
|
|
59
|
+
}
|
|
60
|
+
if (!info || typeof info !== 'object') continue
|
|
61
|
+
if (depName === name && info.path) {
|
|
62
|
+
found.set(info.path, { version: info.version ?? '?', path: info.path })
|
|
63
|
+
}
|
|
64
|
+
visit(info.dependencies)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const pkg of JSON.parse(stdout) as Record<string, unknown>[]) {
|
|
69
|
+
visit(pkg['dependencies'] as Record<string, unknown> | undefined)
|
|
70
|
+
visit(pkg['devDependencies'] as Record<string, unknown> | undefined)
|
|
71
|
+
}
|
|
72
|
+
return [...found.values()]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function main(): void {
|
|
76
|
+
let failed = false
|
|
77
|
+
|
|
78
|
+
for (const name of SINGLETONS) {
|
|
79
|
+
const all = instances(name)
|
|
80
|
+
const accepted = ACCEPTED[name] ?? []
|
|
81
|
+
const live = all.filter((i) => !accepted.includes(i.version))
|
|
82
|
+
|
|
83
|
+
if (live.length <= 1) {
|
|
84
|
+
const note = all.length > live.length ? ` (+${all.length - live.length} accepted)` : ''
|
|
85
|
+
console.log(` = ${name.padEnd(20)} one instance${note}`)
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
failed = true
|
|
90
|
+
console.log(`\n ! ${name} resolves to ${live.length} instances:\n`)
|
|
91
|
+
for (const instance of live) console.log(` ${instance.version} ${instance.path}`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!failed) {
|
|
95
|
+
console.log('\nEvery singleton resolves once.')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(
|
|
100
|
+
[
|
|
101
|
+
'',
|
|
102
|
+
'Two copies of the same version are still two modules, and two modules holding',
|
|
103
|
+
'React context is a runtime failure no type or test can see.',
|
|
104
|
+
'',
|
|
105
|
+
'The usual cause is a peer that differs between workspace packages, which changes',
|
|
106
|
+
"pnpm's identity hash for the dependency. Compare the peers and pin the offender",
|
|
107
|
+
'in `pnpm.overrides`.',
|
|
108
|
+
].join('\n'),
|
|
109
|
+
)
|
|
110
|
+
process.exitCode = 1
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
main()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Every workspace package that ships code has at least one test.
|
|
7
|
+
|
|
8
|
+
pnpm check:tested
|
|
9
|
+
|
|
10
|
+
Deliberately not a coverage percentage. A number invites a target, and a target
|
|
11
|
+
invites tests written to move it. What this asserts is the one bit a coverage
|
|
12
|
+
report hides: a package with source and **no test file at all**, which looks
|
|
13
|
+
exactly like a package whose tests pass. Defects in a code generator, a
|
|
14
|
+
serialiser or a set of access rules then surface downstream, one at a time, long
|
|
15
|
+
after the package that caused them.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Packages with source files and no tests, keyed by their path from the repo
|
|
20
|
+
* root, with the reason.
|
|
21
|
+
*
|
|
22
|
+
* This is the whole exception list. An entry is a claim that the package holds
|
|
23
|
+
* no behaviour worth asserting, not that testing it is inconvenient. An entry
|
|
24
|
+
* whose package has since gained a test fails the check, so each one is removed
|
|
25
|
+
* by the same change that makes it untrue.
|
|
26
|
+
*/
|
|
27
|
+
const ACCEPTED: Record<string, string> = {
|
|
28
|
+
{{#if useReferenceDs}}{{else}} 'design-system':
|
|
29
|
+
'Placeholder until the design system is written. Delete this entry with its first component.',
|
|
30
|
+
{{/if}}}
|
|
31
|
+
|
|
32
|
+
/** Directories that never hold source worth testing. */
|
|
33
|
+
const IGNORED_DIRS = new Set([
|
|
34
|
+
'node_modules',
|
|
35
|
+
'dist',
|
|
36
|
+
'build',
|
|
37
|
+
'.next',
|
|
38
|
+
'.turbo',
|
|
39
|
+
'storybook-static',
|
|
40
|
+
'coverage',
|
|
41
|
+
'__snapshots__',
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
|
|
45
|
+
|
|
46
|
+
const isTest = (name: string): boolean => /\.(test|spec)\.[cm]?[jt]sx?$/.test(name)
|
|
47
|
+
|
|
48
|
+
const isSource = (name: string): boolean =>
|
|
49
|
+
SOURCE_EXTENSIONS.some((extension) => name.endsWith(extension)) &&
|
|
50
|
+
!isTest(name) &&
|
|
51
|
+
!name.endsWith('.d.ts')
|
|
52
|
+
|
|
53
|
+
interface Counts {
|
|
54
|
+
source: number
|
|
55
|
+
tests: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Source and test files under a package's own `src`. `src` rather than the
|
|
60
|
+
* package root, so a package cannot pass on the strength of its own build
|
|
61
|
+
* tooling. A package with no `src` ships no code and is not asked.
|
|
62
|
+
*/
|
|
63
|
+
function countUnder(packagePath: string): Counts | undefined {
|
|
64
|
+
const src = join(packagePath, 'src')
|
|
65
|
+
if (!existsSync(src)) return undefined
|
|
66
|
+
|
|
67
|
+
const counts: Counts = { source: 0, tests: 0 }
|
|
68
|
+
const walk = (dir: string): void => {
|
|
69
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
if (IGNORED_DIRS.has(entry.name)) continue
|
|
71
|
+
const full = join(dir, entry.name)
|
|
72
|
+
if (entry.isDirectory()) walk(full)
|
|
73
|
+
else if (isTest(entry.name)) counts.tests += 1
|
|
74
|
+
else if (isSource(entry.name)) counts.source += 1
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
walk(src)
|
|
78
|
+
return counts
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface WorkspacePackage {
|
|
82
|
+
name: string
|
|
83
|
+
path: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function workspacePackages(): WorkspacePackage[] {
|
|
87
|
+
const stdout = execFileSync('pnpm', ['ls', '--recursive', '--depth', '-1', '--json'], {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
91
|
+
})
|
|
92
|
+
return (JSON.parse(stdout) as WorkspacePackage[]).filter((pkg) => Boolean(pkg.name && pkg.path))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function main(): void {
|
|
96
|
+
const root = process.cwd()
|
|
97
|
+
const untested: string[] = []
|
|
98
|
+
const staleExceptions: string[] = []
|
|
99
|
+
let checked = 0
|
|
100
|
+
|
|
101
|
+
for (const pkg of workspacePackages()) {
|
|
102
|
+
const counts = countUnder(pkg.path)
|
|
103
|
+
if (!counts || counts.source === 0) continue
|
|
104
|
+
|
|
105
|
+
checked += 1
|
|
106
|
+
const where = relative(root, pkg.path) || '.'
|
|
107
|
+
const accepted = ACCEPTED[where]
|
|
108
|
+
|
|
109
|
+
if (counts.tests > 0) {
|
|
110
|
+
// An exception that has stopped applying reads, in review, as though
|
|
111
|
+
// somebody decided this package needs no tests.
|
|
112
|
+
if (accepted) staleExceptions.push(where)
|
|
113
|
+
console.log(
|
|
114
|
+
` = ${where.padEnd(32)} ${String(counts.tests).padStart(3)} test files ` +
|
|
115
|
+
`${String(counts.source).padStart(4)} source`,
|
|
116
|
+
)
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (accepted) {
|
|
121
|
+
console.log(` - ${where.padEnd(32)} no tests, accepted`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
untested.push(where)
|
|
126
|
+
console.log(` ! ${where.padEnd(32)} 0 test files ${String(counts.source).padStart(4)} source`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (staleExceptions.length > 0) {
|
|
130
|
+
console.log(
|
|
131
|
+
[
|
|
132
|
+
'',
|
|
133
|
+
`${staleExceptions.length} package(s) are on the accepted list and now have tests:`,
|
|
134
|
+
'',
|
|
135
|
+
...staleExceptions.map((where) => ` ${where}`),
|
|
136
|
+
'',
|
|
137
|
+
'Delete the entry from ACCEPTED in scripts/check-tested-packages.ts.',
|
|
138
|
+
].join('\n'),
|
|
139
|
+
)
|
|
140
|
+
process.exitCode = 1
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (untested.length === 0) {
|
|
144
|
+
if (!process.exitCode) console.log(`\nAll ${checked} packages that ship code have tests.`)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
console.log(
|
|
149
|
+
[
|
|
150
|
+
'',
|
|
151
|
+
`${untested.length} package(s) ship code with no test file:`,
|
|
152
|
+
'',
|
|
153
|
+
...untested.map((where) => ` ${where}`),
|
|
154
|
+
'',
|
|
155
|
+
'One test is the bar, not a coverage number. If the package genuinely holds no',
|
|
156
|
+
'runtime behaviour, add it to ACCEPTED in scripts/check-tested-packages.ts with',
|
|
157
|
+
'the reason.',
|
|
158
|
+
].join('\n'),
|
|
159
|
+
)
|
|
160
|
+
process.exitCode = 1
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
main()
|
|
@@ -5,7 +5,22 @@
|
|
|
5
5
|
"build": {
|
|
6
6
|
"dependsOn": ["^build"],
|
|
7
7
|
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
|
|
8
|
-
"env": [
|
|
8
|
+
"env": [
|
|
9
|
+
"NODE_ENV",
|
|
10
|
+
"DATABASE_URI",
|
|
11
|
+
"DATABASE_URL",
|
|
12
|
+
"DATABASE_URL_UNPOOLED",
|
|
13
|
+
"POSTGRES_URL",
|
|
14
|
+
"POSTGRES_URL_NON_POOLING",
|
|
15
|
+
"PAYLOAD_SECRET",
|
|
16
|
+
"APPROVAL_TOKEN_SECRET",
|
|
17
|
+
"FORMS_IP_HASH_SECRET",
|
|
18
|
+
"RESEND_API_KEY",
|
|
19
|
+
"EMAIL_*",
|
|
20
|
+
"INNGEST_*",
|
|
21
|
+
"BLOB_READ_WRITE_TOKEN",
|
|
22
|
+
"NEXT_PUBLIC_*"
|
|
23
|
+
]
|
|
9
24
|
},
|
|
10
25
|
"build-storybook": {
|
|
11
26
|
"dependsOn": ["^build"],
|