@joshuafolkken/kit 1.1370.0 → 1.1379.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/.claude/skills/dependency-update/SKILL.md +6 -3
- package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun.md +6 -4
- package/README.md +11 -36
- package/dist/commands/document-section-cli.js +1 -1
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-NZBHG6FW.js → chunk-4RWRP3FF.js} +5 -5
- package/dist/hooks/{chunk-R6CRQEAL.js → chunk-546X4PX3.js} +3 -3
- package/dist/hooks/{chunk-DZWZ3DKY.js → chunk-7ZM464OW.js} +3 -3
- package/dist/hooks/{chunk-GG72ANKI.js → chunk-DHSKBAQ2.js} +1 -1
- package/dist/hooks/{chunk-KPM4YIIX.js → chunk-E7LZL5AN.js} +3 -3
- package/dist/hooks/{chunk-4TW4MQUL.js → chunk-HGA4EG5S.js} +12 -12
- package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-NBEAGPS4.js} +1 -1
- package/dist/hooks/{chunk-43G45XJF.js → chunk-PV3FWDSQ.js} +2 -2
- package/dist/hooks/{chunk-76RK6E3H.js → chunk-QARPP36N.js} +3 -3
- package/dist/hooks/{chunk-NZN2UFI6.js → chunk-RW4XMF47.js} +37 -1
- package/dist/hooks/{chunk-MXVQMLDP.js → chunk-TG2AB3W6.js} +50 -16
- package/dist/hooks/{chunk-TLJYAGZK.js → chunk-TORRSNDJ.js} +8 -5
- package/dist/hooks/{chunk-SYSRHRWU.js → chunk-YSDAH73S.js} +5 -33
- package/dist/hooks/codex-hook-adapter.js +13 -13
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/inputs.json +227 -227
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +12 -12
- package/dist/hooks/session-lang.js +3 -3
- package/dist/hooks/stop-guard.js +6 -6
- package/dist/josh.js +40 -5
- package/package.json +5 -5
- package/scripts/adopt/adopt-logic.ts +4 -1
- package/scripts/adopt/adopt.ts +7 -4
- package/scripts/cost-runtime/context-cut-payback.ts +9 -4
- package/scripts/cost-runtime/cost-pricing.ts +94 -33
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/document/document-scan.ts +4 -3
- package/scripts/document/read-set-pricing.ts +7 -5
- package/scripts/git/git-spawn-sync.ts +9 -1
- package/scripts/init/init-ai-copy.ts +10 -3
- package/scripts/init/init-copy-content.ts +8 -1
- package/scripts/init/security-policy.ts +79 -0
- package/scripts/josh/josh-commands-maintenance.ts +3 -0
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/josh/josh-commands-run.ts +1 -1
- package/scripts/josh/josh-commands-versioning.ts +2 -1
- package/scripts/lint/cross-file-recheck.ts +155 -0
- package/scripts/lint/lint-parallel.ts +10 -9
- package/scripts/lint/lint-related-scope.ts +4 -4
- package/scripts/lint/lint-related.ts +1 -1
- package/scripts/propagate/propagate-run.ts +17 -1
- package/scripts/propagate/propagate-steps.ts +9 -1
- package/scripts/rules/decision-oracle-batch.ts +1 -1
- package/scripts/run/run-headless.ts +1 -0
- package/scripts/run/run-step-cli.ts +51 -3
- package/scripts/run/run-step.ts +49 -4
- package/scripts/sync/sync-ai-files.ts +2 -0
- package/scripts/version/latest-update.ts +20 -6
- package/scripts/version/peer-floor.ts +99 -0
- package/scripts/version/playwright-browsers.ts +109 -0
- package/scripts/version/publishable-range-check.ts +41 -4
- package/templates/SECURITY.md +23 -0
- package/scripts/lint/cycle-recheck.ts +0 -122
|
@@ -13,6 +13,7 @@ import { overrides_check } from '#scripts/overrides/overrides-logic'
|
|
|
13
13
|
import { execaSync } from 'execa'
|
|
14
14
|
import { fresh_metadata_cache } from './fresh-metadata-cache'
|
|
15
15
|
import { latest_regression, type VersionRegression } from './latest-regression'
|
|
16
|
+
import { playwright_browsers } from './playwright-browsers'
|
|
16
17
|
import { safe_chain_version_update } from './safe-chain-version-update'
|
|
17
18
|
|
|
18
19
|
const PACKAGE_JSON_PATH = 'package.json'
|
|
@@ -164,23 +165,36 @@ function report_lockfile_overrides(): void {
|
|
|
164
165
|
process.exitCode = 1
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// The browsers follow what `node_modules` holds, not how the update ended: a later stage failing
|
|
169
|
+
// after `@playwright/test` moved, and a rollback — which restores the manifest and the lockfile but
|
|
170
|
+
// leaves the newer package installed — both leave a Playwright whose browsers were never downloaded,
|
|
171
|
+
// and the browser tests that run next would fail with "Executable doesn't exist".
|
|
172
|
+
function install_browsers(playwright_version: string | undefined): void {
|
|
173
|
+
if (playwright_browsers.install_when_needed(playwright_version) !== 0) process.exitCode = 1
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The sync acts on the versions the update settled on, so it runs neither after a failed update nor
|
|
177
|
+
// after a rollback — the latter so the files really are left exactly as they were found: it rewrites
|
|
178
|
+
// the workflows' pinned safe-chain installer, which would contradict the notice that nothing changed.
|
|
167
179
|
function main(): void {
|
|
168
180
|
const snapshot = take_snapshot()
|
|
181
|
+
const playwright_version = playwright_browsers.read_installed_version()
|
|
169
182
|
const overrides = overrides_files.read_current_overrides()
|
|
170
183
|
|
|
171
184
|
report_excluded(overrides_check.list_excluded_package_names(overrides))
|
|
172
185
|
|
|
173
186
|
const outcome = update_without_downgrading(snapshot)
|
|
174
187
|
|
|
188
|
+
// A failed `pnpm update` fails the command: the `josh latest` chain and `josh adopt` both read the
|
|
189
|
+
// exit code, and a zero here would let them carry on over dependencies that were never updated.
|
|
190
|
+
if (outcome.status !== 0) process.exitCode = outcome.status
|
|
191
|
+
|
|
175
192
|
report_overrides(overrides)
|
|
176
193
|
report_lockfile_overrides()
|
|
177
194
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (outcome.status === 0 && !outcome.is_rolled_back) {
|
|
182
|
-
safe_chain_version_update.sync()
|
|
183
|
-
}
|
|
195
|
+
if (outcome.status === 0 && !outcome.is_rolled_back) safe_chain_version_update.sync()
|
|
196
|
+
|
|
197
|
+
install_browsers(playwright_version)
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
if (process.argv[1] === fileURLToPath(import.meta.url)) main()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import semver from 'semver'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
// A package built on a toolkit publishes its own `peerDependencies`, and a toolkit raising a peer
|
|
5
|
+
// floor edits nobody's manifest but its own. `josh latest` rewrites `dependencies` and
|
|
6
|
+
// `devDependencies` only, so the consumer keeps declaring a floor it no longer develops or verifies
|
|
7
|
+
// against: a user on a version between the two floors satisfies the consumer's declaration and
|
|
8
|
+
// breaks the toolkit's.
|
|
9
|
+
|
|
10
|
+
interface LaggingPeer {
|
|
11
|
+
name: string
|
|
12
|
+
range: string
|
|
13
|
+
/** The installed toolkit as `name@version`, so the reader knows which release set the floor. */
|
|
14
|
+
toolkit: string
|
|
15
|
+
toolkit_range: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const peer_manifest_schema = z.looseObject({
|
|
19
|
+
name: z.string().optional(),
|
|
20
|
+
version: z.string().optional(),
|
|
21
|
+
peerDependencies: z.record(z.string(), z.string()).optional(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
type PeerManifest = z.infer<typeof peer_manifest_schema>
|
|
25
|
+
|
|
26
|
+
function parse_manifest(package_json_content: string): PeerManifest {
|
|
27
|
+
return peer_manifest_schema.parse(JSON.parse(package_json_content))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A range semver cannot read (`workspace:*`, `catalog:`) has no floor to compare, and neither does
|
|
31
|
+
// one nothing can satisfy; both answer "not below" rather than inventing a violation.
|
|
32
|
+
function is_floor_below(range: string, toolkit_range: string): boolean {
|
|
33
|
+
if (semver.validRange(range) === null || semver.validRange(toolkit_range) === null) return false
|
|
34
|
+
|
|
35
|
+
const floor = semver.minVersion(range)
|
|
36
|
+
const toolkit_floor = semver.minVersion(toolkit_range)
|
|
37
|
+
|
|
38
|
+
if (floor === null || toolkit_floor === null) return false
|
|
39
|
+
|
|
40
|
+
return semver.lt(floor, toolkit_floor)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function toolkit_label(toolkit: PeerManifest): string {
|
|
44
|
+
return [toolkit.name, toolkit.version].filter((part) => part !== undefined).join('@')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function to_lagging_peer(
|
|
48
|
+
[name, range]: [string, string],
|
|
49
|
+
toolkit: PeerManifest,
|
|
50
|
+
): Array<LaggingPeer> {
|
|
51
|
+
const toolkit_range = toolkit.peerDependencies?.[name]
|
|
52
|
+
|
|
53
|
+
if (toolkit_range === undefined || !is_floor_below(range, toolkit_range)) return []
|
|
54
|
+
|
|
55
|
+
return [{ name, range, toolkit: toolkit_label(toolkit), toolkit_range }]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A toolkit's own manifest is never compared with itself: there it is the source of the floors, not
|
|
59
|
+
// a consumer of them.
|
|
60
|
+
function find_lagging_against(manifest: PeerManifest, toolkit: PeerManifest): Array<LaggingPeer> {
|
|
61
|
+
if (manifest.name === toolkit.name) return []
|
|
62
|
+
|
|
63
|
+
return Object.entries(manifest.peerDependencies ?? {}).flatMap((entry) =>
|
|
64
|
+
to_lagging_peer(entry, toolkit),
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every installed toolkit is compared, not kit alone: a package built on app-kit inherits app-kit's
|
|
69
|
+
// floors exactly as app-kit inherits kit's, and the gap is the same one a tier up.
|
|
70
|
+
function find_lagging(
|
|
71
|
+
package_json_content: string,
|
|
72
|
+
toolkit_json_contents: ReadonlyArray<string>,
|
|
73
|
+
): Array<LaggingPeer> {
|
|
74
|
+
const manifest = parse_manifest(package_json_content)
|
|
75
|
+
|
|
76
|
+
return toolkit_json_contents.flatMap((content) =>
|
|
77
|
+
find_lagging_against(manifest, parse_manifest(content)),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function format_lagging(peer: LaggingPeer): string {
|
|
82
|
+
return ` ${peer.name}@${peer.range} — ${peer.toolkit} requires ${peer.toolkit_range}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function format_failure(lagging: ReadonlyArray<LaggingPeer>): string {
|
|
86
|
+
return [
|
|
87
|
+
`\n✖ ${String(lagging.length)} published peer range(s) fall below an installed toolkit's:`,
|
|
88
|
+
...lagging.map((peer) => format_lagging(peer)),
|
|
89
|
+
'',
|
|
90
|
+
' A user inside the gap satisfies this package and breaks the toolkit it is built on, so the',
|
|
91
|
+
' declaration promises a combination nobody verified. Raise each `peerDependencies` floor',
|
|
92
|
+
' in package.json to the range the toolkit requires.',
|
|
93
|
+
].join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const peer_floor = { find_lagging, format_failure }
|
|
97
|
+
|
|
98
|
+
export { peer_floor }
|
|
99
|
+
export type { LaggingPeer }
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { json_value } from '#scripts/lib/json-value'
|
|
4
|
+
import { file_reader } from '#scripts/lib/read-file'
|
|
5
|
+
import { execaSync } from 'execa'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
|
|
8
|
+
// A Playwright release drives one exact browser build, so a bumped `@playwright/test` cannot launch
|
|
9
|
+
// the browsers the previous version downloaded: every browser test fails with "Executable doesn't
|
|
10
|
+
// exist" until `playwright install` runs. `josh latest` is what moves the version, so it is also
|
|
11
|
+
// what has to leave the matching browsers behind — `josh adopt` runs the related tests right after
|
|
12
|
+
// it, with nobody in between to run the install by hand (joshuafolkken/kit#3675).
|
|
13
|
+
|
|
14
|
+
const INSTALLED_MANIFEST_PATH = path.join('node_modules', '@playwright', 'test', 'package.json')
|
|
15
|
+
const INSTALLED_MANIFEST_SCHEMA = z.object({ version: z.string() })
|
|
16
|
+
const BROWSERS_VARIABLE = 'JOSH_PLAYWRIGHT_BROWSERS'
|
|
17
|
+
// Leading letter required: a value starting with a dash would reach the command as an option.
|
|
18
|
+
const BROWSER_NAME_RE = /^[A-Za-z]/u
|
|
19
|
+
const FAILURE_EXIT_CODE = 1
|
|
20
|
+
const INSTALL_COMMAND: ReadonlyArray<string> = ['pnpm', 'exec', 'playwright', 'install']
|
|
21
|
+
const DRY_RUN_FLAG = '--dry-run'
|
|
22
|
+
const INSTALL_LOCATION_RE = /Install location: +(\S.*)/gu
|
|
23
|
+
// Written last by `playwright install`, so a directory an interrupted download left behind lacks it.
|
|
24
|
+
const COMPLETE_MARKER = 'INSTALLATION_COMPLETE'
|
|
25
|
+
|
|
26
|
+
// Chromium alone, as the CI fallback installs: a bare `playwright install` fetches chromium, firefox
|
|
27
|
+
// and webkit — roughly 1 GB — for the single chromium project kit scaffolds.
|
|
28
|
+
const DEFAULT_BROWSERS: ReadonlyArray<string> = ['chromium']
|
|
29
|
+
|
|
30
|
+
// The version actually installed, not the range in `package.json`: the browsers follow the resolved
|
|
31
|
+
// release, and a repository without the package answers `undefined` on both sides of the update.
|
|
32
|
+
function read_installed_version(): string | undefined {
|
|
33
|
+
const content = file_reader.read_file_or_empty(INSTALLED_MANIFEST_PATH)
|
|
34
|
+
|
|
35
|
+
return json_value.parse_with(content, INSTALLED_MANIFEST_SCHEMA)?.version
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The same variable name the CI workflow reads, sanitized the way its `tr -cd 'A-Za-z -'` does:
|
|
39
|
+
// everything but letters, dashes and the whitespace between names is stripped out of a name rather
|
|
40
|
+
// than the whole name dropped, so a mistyped `Chromium` or `chromium,firefox` reaches Playwright as
|
|
41
|
+
// an unknown browser and fails at the install — never silently narrowed to chromium, which would
|
|
42
|
+
// resurface later as a missing executable. A value with no usable name falls back to the default
|
|
43
|
+
// rather than to a bare install.
|
|
44
|
+
function resolve_browsers(environment: NodeJS.ProcessEnv = process.env): ReadonlyArray<string> {
|
|
45
|
+
const value = (environment[BROWSERS_VARIABLE] ?? '').replaceAll(/[^A-Za-z\s-]/gu, '')
|
|
46
|
+
const browsers = value.split(/\s+/u).filter((name) => BROWSER_NAME_RE.test(name))
|
|
47
|
+
|
|
48
|
+
return browsers.length > 0 ? browsers : DEFAULT_BROWSERS
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A branded channel (`chrome`, `msedge`) reports `<system>` instead of a directory Playwright owns;
|
|
52
|
+
// there is nothing to look for, so it never counts as missing.
|
|
53
|
+
function is_downloaded(location: string): boolean {
|
|
54
|
+
return !path.isAbsolute(location) || existsSync(path.join(location, COMPLETE_MARKER))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Asks Playwright where the named browsers belong and whether each one is completely there. A
|
|
58
|
+
// version comparison alone cannot tell: an install that failed or was interrupted leaves the next
|
|
59
|
+
// run with the same version on both sides of the update and the browsers still absent, and that run
|
|
60
|
+
// has to retry it rather than finish as if nothing were missing. A plan Playwright refuses counts as
|
|
61
|
+
// missing, so the install runs and reports the refusal.
|
|
62
|
+
function has_missing_browser(browsers: ReadonlyArray<string>): boolean {
|
|
63
|
+
const [cmd = '', ...rest] = [...INSTALL_COMMAND, DRY_RUN_FLAG, ...browsers]
|
|
64
|
+
const plan = execaSync(cmd, rest, { reject: false })
|
|
65
|
+
if (plan.exitCode !== 0) return true
|
|
66
|
+
|
|
67
|
+
const locations = [...plan.stdout.matchAll(INSTALL_LOCATION_RE)].map(
|
|
68
|
+
([, location = '']) => location,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
return locations.some((location) => !is_downloaded(location))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function run_install(version: string, browsers: ReadonlyArray<string>): number {
|
|
75
|
+
const command = [...INSTALL_COMMAND, ...browsers]
|
|
76
|
+
const [cmd = '', ...rest] = command
|
|
77
|
+
|
|
78
|
+
console.info(`\n▶ ${command.join(' ')} (browsers for @playwright/test ${version})`)
|
|
79
|
+
|
|
80
|
+
const status = execaSync(cmd, rest, { stdio: 'inherit', reject: false }).exitCode
|
|
81
|
+
if (status === 0) return 0
|
|
82
|
+
|
|
83
|
+
console.error(
|
|
84
|
+
`\n✖ Playwright browser install failed (${command.join(' ')} exited ${String(status)}); browser tests cannot run until it succeeds.`,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return FAILURE_EXIT_CODE
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `before` is the version read ahead of the update. A moved version always installs; an unmoved one
|
|
91
|
+
// installs only what an earlier run left missing, so a run that leaves Playwright alone with its
|
|
92
|
+
// browsers in place costs no download. Nothing runs when the package is absent.
|
|
93
|
+
function install_when_needed(before: string | undefined): number {
|
|
94
|
+
const after = read_installed_version()
|
|
95
|
+
if (after === undefined) return 0
|
|
96
|
+
|
|
97
|
+
const browsers = resolve_browsers()
|
|
98
|
+
if (after === before && !has_missing_browser(browsers)) return 0
|
|
99
|
+
|
|
100
|
+
return run_install(after, browsers)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const playwright_browsers = {
|
|
104
|
+
read_installed_version,
|
|
105
|
+
resolve_browsers,
|
|
106
|
+
install_when_needed,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { playwright_browsers }
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
/**
|
|
3
|
-
* Release gate: every dependency range this package publishes must resolve for a consumer
|
|
3
|
+
* Release gate: every dependency range this package publishes must resolve for a consumer, and no
|
|
4
|
+
* peer range it publishes may fall below an installed toolkit's.
|
|
4
5
|
*
|
|
5
6
|
* Usage: tsx scripts/version/publishable-range-check.ts
|
|
6
7
|
*/
|
|
7
|
-
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
8
10
|
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { adopt_toolkits } from '#scripts/adopt/adopt-toolkits'
|
|
9
12
|
import { COMMAND_TIMEOUT_MS } from '#scripts/lib/timeouts'
|
|
10
13
|
import { execaSync } from 'execa'
|
|
14
|
+
import { peer_floor } from './peer-floor'
|
|
11
15
|
import {
|
|
12
16
|
publishable_range,
|
|
13
17
|
type ProbeResult,
|
|
@@ -17,6 +21,7 @@ import {
|
|
|
17
21
|
|
|
18
22
|
const PACKAGE_JSON_PATH = 'package.json'
|
|
19
23
|
const FAILURE_EXIT_CODE = 1
|
|
24
|
+
const NODE_MODULES = 'node_modules'
|
|
20
25
|
|
|
21
26
|
// A registry query rather than a direct fetch: safe-chain installs shims ahead of the package
|
|
22
27
|
// managers on PATH, so the answer comes back already filtered by the same minimum-age policy a
|
|
@@ -64,12 +69,44 @@ function check(package_json_content: string, probe: RangeProbe): number {
|
|
|
64
69
|
return report(publishable_range.find_unsatisfiable(checked, probe), checked.length)
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/** No installed toolkit means nothing to compare against, which passes in silence. */
|
|
73
|
+
function check_peer_floors(
|
|
74
|
+
package_json_content: string,
|
|
75
|
+
toolkit_json_contents: ReadonlyArray<string>,
|
|
76
|
+
): number {
|
|
77
|
+
const lagging = peer_floor.find_lagging(package_json_content, toolkit_json_contents)
|
|
78
|
+
|
|
79
|
+
if (lagging.length === 0) return 0
|
|
80
|
+
|
|
81
|
+
console.error(peer_floor.format_failure(lagging))
|
|
82
|
+
|
|
83
|
+
return FAILURE_EXIT_CODE
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The manifest of every `@joshuafolkken/*` toolkit this project declares and has installed. The
|
|
87
|
+
// roster is `josh adopt`'s own rather than a second one kept here, so a toolkit published later is
|
|
88
|
+
// compared the day it is installed. kit's own checkout declares none, and a declared toolkit that is
|
|
89
|
+
// not installed has no manifest to read.
|
|
90
|
+
function read_installed_toolkit_manifests(project_root: string): Array<string> {
|
|
91
|
+
return adopt_toolkits
|
|
92
|
+
.all_declared_toolkits(project_root)
|
|
93
|
+
.map((package_name) => path.join(project_root, NODE_MODULES, package_name, PACKAGE_JSON_PATH))
|
|
94
|
+
.filter((manifest_path) => existsSync(manifest_path))
|
|
95
|
+
.map((manifest_path) => readFileSync(manifest_path, 'utf8'))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Both checks always run, so one invocation reports everything there is to fix.
|
|
67
99
|
function main(): void {
|
|
68
|
-
|
|
100
|
+
const package_json_content = readFileSync(PACKAGE_JSON_PATH, 'utf8')
|
|
101
|
+
const range_exit_code = check(package_json_content, probe_range)
|
|
102
|
+
const toolkit_json_contents = read_installed_toolkit_manifests(process.cwd())
|
|
103
|
+
const peer_exit_code = check_peer_floors(package_json_content, toolkit_json_contents)
|
|
104
|
+
|
|
105
|
+
process.exit(Math.max(range_exit_code, peer_exit_code))
|
|
69
106
|
}
|
|
70
107
|
|
|
71
108
|
if (process.argv[1] === fileURLToPath(import.meta.url)) main()
|
|
72
109
|
|
|
73
|
-
const publishable_range_check = { check }
|
|
110
|
+
const publishable_range_check = { check, check_peer_floors, read_installed_toolkit_manifests }
|
|
74
111
|
|
|
75
112
|
export { publishable_range_check }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported Versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
| ------- | ------------------ |
|
|
7
|
+
| Latest | :white_check_mark: |
|
|
8
|
+
| Older | :x: |
|
|
9
|
+
|
|
10
|
+
Fixes ship in a new release rather than as patches to older versions; upgrade to the latest release to receive them. Each release's changes are listed on the [Releases](https://github.com/{{REPOSITORY}}/releases) page.
|
|
11
|
+
|
|
12
|
+
## Reporting a Vulnerability
|
|
13
|
+
|
|
14
|
+
If you discover a security vulnerability, please do not report it publicly.
|
|
15
|
+
|
|
16
|
+
- **Preferred**: Open a [draft security advisory](https://github.com/{{REPOSITORY}}/security/advisories/new) on GitHub.
|
|
17
|
+
- **Alternative**: If private reporting is not enabled there, contact a maintainer privately rather than opening a public issue.
|
|
18
|
+
|
|
19
|
+
We aim to acknowledge reports within **48 hours** and provide a resolution or mitigation plan within **7 days** for confirmed vulnerabilities.
|
|
20
|
+
|
|
21
|
+
## Development Tooling
|
|
22
|
+
|
|
23
|
+
This repository takes development tooling from the [`@joshuafolkken/kit`](https://github.com/joshuafolkken/kit) package — depending on the profile it was set up with, the `josh` command, GitHub Actions workflows, Git hooks, AI agent instructions and hooks, and configuration presets. What that package ships and runs on your machine and in CI, the secrets its tooling reads, how to install safe-chain and the external MCP endpoints it configures are described in [kit's security policy](https://github.com/joshuafolkken/kit/blob/main/SECURITY.md). Report a vulnerability in that tooling to kit rather than here.
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { FAIL_EXIT_CODE, type BufferedProcessResult } from '#scripts/lib/buffered-process'
|
|
2
|
-
import type { ESLint } from 'eslint'
|
|
3
|
-
|
|
4
|
-
// `import/no-cycle` reads other files, and the lint cache keys a verdict on the linted file's own
|
|
5
|
-
// content. So once a cycle is closed by editing one side of it, the untouched side keeps its cached
|
|
6
|
-
// `Dependency cycle detected` until its own content changes (joshuafolkken/kit#3641). The opposite
|
|
7
|
-
// error cannot happen: a new cycle always contains the file that gained the import, and that file is
|
|
8
|
-
// re-linted.
|
|
9
|
-
//
|
|
10
|
-
// A lint run therefore re-reads a cached red that names the rule: the files reported for it are
|
|
11
|
-
// linted once more without the cache, and their fresh results replace the cached ones. A run that
|
|
12
|
-
// reports no cycle never reaches this, so the green path costs what it did.
|
|
13
|
-
//
|
|
14
|
-
// That holds for a run narrowed to one change's files as much as for the whole tree: the change can
|
|
15
|
-
// hold both sides of a cycle, and the side a later edit leaves alone keeps its cached report.
|
|
16
|
-
//
|
|
17
|
-
// The cached run is asked again through the Node API rather than read off the first run's report:
|
|
18
|
-
// that report is the stylish text, and taking a file out of it means re-deriving its problem
|
|
19
|
-
// counts. The second asking is answered from the cache the first one just wrote.
|
|
20
|
-
const NO_CYCLE_RULE = 'import/no-cycle'
|
|
21
|
-
const FORMATTER = 'stylish'
|
|
22
|
-
const UNCACHED_SUFFIX = '.uncached'
|
|
23
|
-
|
|
24
|
-
interface Linters {
|
|
25
|
-
cached: ESLint
|
|
26
|
-
fresh: ESLint
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface CycleRecheckOptions {
|
|
30
|
-
cache_file: string
|
|
31
|
-
// What the run linted — the whole tree, or one change's files — so the cached run is asked the
|
|
32
|
-
// question it was asked the first time.
|
|
33
|
-
patterns: ReadonlyArray<string>
|
|
34
|
-
// What the fixture test varies: the directory linted and the configuration it is linted with.
|
|
35
|
-
eslint_options?: ESLint.Options
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// The exit code is eslint's "finished and reported problems", not any failure: a run that could not
|
|
39
|
-
// finish — a configuration that does not load, a rule that threw — exits 2 and may name the rule
|
|
40
|
-
// too, and asking it again throws the same error here, ahead of both buffered reports.
|
|
41
|
-
function is_cycle_reported(result: BufferedProcessResult): boolean {
|
|
42
|
-
return result.exit_code === FAIL_EXIT_CODE && result.output.includes(NO_CYCLE_RULE)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function has_cycle_message(result: ESLint.LintResult): boolean {
|
|
46
|
-
return result.messages.some((message) => message.ruleId === NO_CYCLE_RULE)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function cached_options(options: CycleRecheckOptions): ESLint.Options {
|
|
50
|
-
return {
|
|
51
|
-
...options.eslint_options,
|
|
52
|
-
cache: true,
|
|
53
|
-
cacheStrategy: 'content',
|
|
54
|
-
cacheLocation: options.cache_file,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// A linter that runs without a cache deletes the cache file it would have used, and that defaults to
|
|
59
|
-
// the very file the lint run keeps. So the uncached linter is pointed at a sibling path nothing
|
|
60
|
-
// ever writes: the re-reading must not cost the next run its whole cache.
|
|
61
|
-
function fresh_options(options: CycleRecheckOptions): ESLint.Options {
|
|
62
|
-
return {
|
|
63
|
-
...options.eslint_options,
|
|
64
|
-
cache: false,
|
|
65
|
-
cacheLocation: `${options.cache_file}${UNCACHED_SUFFIX}`,
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// `eslint` is loaded here, only past the cycle report: it is an optional peer a basic-profile
|
|
70
|
-
// consumer may not have, and this module is imported by the entry of every `josh lint`, which has to
|
|
71
|
-
// reach its skip notice there.
|
|
72
|
-
async function create_linters(options: CycleRecheckOptions): Promise<Linters> {
|
|
73
|
-
const eslint = await import('eslint')
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
cached: new eslint.ESLint(cached_options(options)),
|
|
77
|
-
fresh: new eslint.ESLint(fresh_options(options)),
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function lint_fresh(
|
|
82
|
-
linter: ESLint,
|
|
83
|
-
cached: ReadonlyArray<ESLint.LintResult>,
|
|
84
|
-
): Promise<Map<string, ESLint.LintResult>> {
|
|
85
|
-
const suspects = cached.filter((result) => has_cycle_message(result))
|
|
86
|
-
if (suspects.length === 0) return new Map()
|
|
87
|
-
const fresh = await linter.lintFiles(suspects.map((result) => result.filePath))
|
|
88
|
-
|
|
89
|
-
return new Map(fresh.map((result) => [result.filePath, result]))
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function report(
|
|
93
|
-
linter: ESLint,
|
|
94
|
-
results: Array<ESLint.LintResult>,
|
|
95
|
-
): Promise<Pick<BufferedProcessResult, 'output' | 'exit_code'>> {
|
|
96
|
-
const formatter = await linter.loadFormatter(FORMATTER)
|
|
97
|
-
const is_failed = results.some((result) => result.errorCount > 0)
|
|
98
|
-
|
|
99
|
-
return { output: await formatter.format(results), exit_code: is_failed ? FAIL_EXIT_CODE : 0 }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function verify(
|
|
103
|
-
result: BufferedProcessResult,
|
|
104
|
-
options: CycleRecheckOptions,
|
|
105
|
-
): Promise<BufferedProcessResult> {
|
|
106
|
-
if (!is_cycle_reported(result)) return result
|
|
107
|
-
const started_at = performance.now()
|
|
108
|
-
const linters = await create_linters(options)
|
|
109
|
-
const cached = await linters.cached.lintFiles([...options.patterns])
|
|
110
|
-
const fresh = await lint_fresh(linters.fresh, cached)
|
|
111
|
-
const verified = await report(
|
|
112
|
-
linters.fresh,
|
|
113
|
-
cached.map((entry) => fresh.get(entry.filePath) ?? entry),
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
return { ...verified, elapsed_ms: result.elapsed_ms + performance.now() - started_at }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const cycle_recheck = { verify }
|
|
120
|
-
|
|
121
|
-
export type { CycleRecheckOptions }
|
|
122
|
-
export { cycle_recheck, NO_CYCLE_RULE }
|