@joshuafolkken/kit 1.1373.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.
Files changed (46) hide show
  1. package/.claude/skills/dependency-update/SKILL.md +6 -3
  2. package/dist/hooks/batch-guard.js +6 -6
  3. package/dist/hooks/{chunk-BZMPVFDY.js → chunk-4RWRP3FF.js} +5 -5
  4. package/dist/hooks/{chunk-V4TKZHER.js → chunk-546X4PX3.js} +3 -3
  5. package/dist/hooks/{chunk-JGW24KKM.js → chunk-7ZM464OW.js} +3 -3
  6. package/dist/hooks/{chunk-GG72ANKI.js → chunk-DHSKBAQ2.js} +1 -1
  7. package/dist/hooks/{chunk-7WKYHEGU.js → chunk-E7LZL5AN.js} +3 -3
  8. package/dist/hooks/{chunk-AKGCP4LX.js → chunk-HGA4EG5S.js} +11 -11
  9. package/dist/hooks/{chunk-Z5TMJIPW.js → chunk-NBEAGPS4.js} +1 -1
  10. package/dist/hooks/{chunk-7UBFU3CL.js → chunk-PV3FWDSQ.js} +2 -2
  11. package/dist/hooks/{chunk-JCRDBQXB.js → chunk-QARPP36N.js} +3 -3
  12. package/dist/hooks/{chunk-NZN2UFI6.js → chunk-RW4XMF47.js} +37 -1
  13. package/dist/hooks/{chunk-YLBBSSTF.js → chunk-TG2AB3W6.js} +50 -16
  14. package/dist/hooks/{chunk-SLSFIWTX.js → chunk-TORRSNDJ.js} +7 -4
  15. package/dist/hooks/{chunk-UTHMB4O2.js → chunk-YSDAH73S.js} +4 -33
  16. package/dist/hooks/codex-hook-adapter.js +13 -13
  17. package/dist/hooks/duplicate-read-guard.js +7 -7
  18. package/dist/hooks/format-edited.js +3 -3
  19. package/dist/hooks/inputs.json +209 -209
  20. package/dist/hooks/investigation-guard.js +7 -7
  21. package/dist/hooks/pretool-guard.js +12 -12
  22. package/dist/hooks/session-lang.js +3 -3
  23. package/dist/hooks/stop-guard.js +6 -6
  24. package/dist/josh.js +39 -4
  25. package/package.json +4 -4
  26. package/scripts/adopt/adopt-logic.ts +4 -1
  27. package/scripts/adopt/adopt.ts +7 -4
  28. package/scripts/cost-runtime/context-cut-payback.ts +9 -4
  29. package/scripts/cost-runtime/cost-pricing.ts +94 -33
  30. package/scripts/document/document-byte-budget.ts +1 -1
  31. package/scripts/document/read-set-pricing.ts +7 -5
  32. package/scripts/git/git-spawn-sync.ts +9 -1
  33. package/scripts/init/init-ai-copy.ts +10 -3
  34. package/scripts/init/init-copy-content.ts +8 -1
  35. package/scripts/init/security-policy.ts +79 -0
  36. package/scripts/josh/josh-commands-maintenance.ts +3 -0
  37. package/scripts/josh/josh-commands-project.ts +1 -1
  38. package/scripts/josh/josh-commands-versioning.ts +2 -1
  39. package/scripts/propagate/propagate-run.ts +17 -1
  40. package/scripts/propagate/propagate-steps.ts +9 -1
  41. package/scripts/sync/sync-ai-files.ts +2 -0
  42. package/scripts/version/latest-update.ts +20 -6
  43. package/scripts/version/peer-floor.ts +99 -0
  44. package/scripts/version/playwright-browsers.ts +109 -0
  45. package/scripts/version/publishable-range-check.ts +41 -4
  46. package/templates/SECURITY.md +23 -0
@@ -20,7 +20,8 @@ const VERSIONING_COMMANDS: Record<string, CommandEntry> = {
20
20
  // before chaining here, exactly as it already does for `latest:update`.
21
21
  ranges: {
22
22
  script: 'scripts/version/publishable-range-check.ts',
23
- description: 'Check that every published dependency range still resolves for a consumer',
23
+ description:
24
+ 'Check that every published dependency range still resolves for a consumer and no peer range falls below an installed toolkit',
24
25
  category: 'Versioning',
25
26
  reference: ['', 'developer', ['network']],
26
27
  },
@@ -13,6 +13,7 @@ import type { PropagateTarget } from './propagate-targets'
13
13
  const STEP_PRECHECK = 'working tree check'
14
14
  const STEP_UPGRADE = 'josh vu'
15
15
  const STEP_SYNC = 'josh sync'
16
+ const STEP_LATEST = 'josh latest'
16
17
  const STEP_LINT_RELATED = 'related lint check'
17
18
  const STEP_TEST_RELATED = 'related test check'
18
19
  const STEP_VERIFY = 'verification gate'
@@ -32,6 +33,18 @@ const STEP_ORDER: ReadonlyArray<string> = [
32
33
  STEP_PR,
33
34
  STEP_RETURN,
34
35
  ]
36
+ // The same sequence with the consumer's other dependencies, its audit and its pnpm pin brought up
37
+ // to date after the sync. Only `josh adopt` runs it: `josh latest` moves every dependency to the
38
+ // registry's newest, which is what adoption is for and exactly what propagation's exact-version wait
39
+ // exists to prevent — and an unrelated dependency's breakage would stop a release reaching a
40
+ // consumer. Derived from `STEP_ORDER` rather than listed, so the two cannot drift apart; behind the
41
+ // sync, because the sync can add devDependencies to `package.json` without installing them, and a
42
+ // pull request opened over that fails CI's frozen install — the step installs before it updates
43
+ // (`propagate_steps`), so the lockfile is re-resolved even when `josh latest` rolls its update back.
44
+ // The related checks and the gate that follow then verify the tree as it will be committed.
45
+ const ADOPT_STEP_ORDER: ReadonlyArray<string> = STEP_ORDER.flatMap((step) =>
46
+ step === STEP_SYNC ? [step, STEP_LATEST] : step,
47
+ )
35
48
  const PROPAGATED_REASON = 'opened a pull request'
36
49
 
37
50
  type RunOutcome = 'propagated' | 'failed' | 'skipped'
@@ -130,10 +143,11 @@ function run_target(
130
143
  target: PropagateTarget,
131
144
  run_step: RunStep,
132
145
  success_reason: string = PROPAGATED_REASON,
146
+ order: ReadonlyArray<string> = STEP_ORDER,
133
147
  ): TargetResult {
134
148
  const steps: Array<StepResult> = []
135
149
 
136
- for (const step of STEP_ORDER) {
150
+ for (const step of order) {
137
151
  const result = run_step(target, step)
138
152
 
139
153
  steps.push(result)
@@ -192,11 +206,13 @@ const propagate_run = {
192
206
  STEP_ISSUE,
193
207
  STEP_UPGRADE,
194
208
  STEP_SYNC,
209
+ STEP_LATEST,
195
210
  STEP_LINT_RELATED,
196
211
  STEP_TEST_RELATED,
197
212
  STEP_VERIFY,
198
213
  STEP_PR,
199
214
  STEP_ORDER,
215
+ ADOPT_STEP_ORDER,
200
216
  skip_reason,
201
217
  run_target,
202
218
  run_targets,
@@ -27,10 +27,17 @@ const SUCCESS_EXIT_CODE = 0
27
27
  // pass — so the chain is not repeated here. It resolves in the consumer's directory, which by this
28
28
  // point has already been upgraded to the version being propagated, so the command is present.
29
29
  const VERIFY_SCRIPT = `pnpm josh ${GATE_COMMAND}`
30
+ // `josh latest` behind an install of its own. The sync can add devDependencies to `package.json`
31
+ // without installing them, and `josh latest` does not always re-resolve the lockfile: when the age
32
+ // gate would downgrade a dependency it restores the files it found and exits zero. Installing first
33
+ // makes the tree it finds a consistent one, so the lockfile matches `package.json` on either path.
34
+ // `--no-frozen-lockfile` because the install exists to write the lockfile, which pnpm refuses under CI.
35
+ const LATEST_SCRIPT = 'pnpm install --no-frozen-lockfile && pnpm josh latest'
30
36
 
31
37
  // The commands the release-independent spawning steps run inside the target's own directory. The
32
38
  // upgrade and the sync are not here: both are per toolkit, so they are built from the plan instead.
33
39
  const STEP_COMMANDS: Readonly<Record<string, ReadonlyArray<string>>> = {
40
+ [propagate_run.STEP_LATEST]: ['sh', '-c', LATEST_SCRIPT],
34
41
  [propagate_run.STEP_LINT_RELATED]: ['pnpm', 'josh', 'lint:related'],
35
42
  [propagate_run.STEP_TEST_RELATED]: ['pnpm', 'josh', 'test:related'],
36
43
  [propagate_run.STEP_VERIFY]: ['sh', '-c', VERIFY_SCRIPT],
@@ -353,7 +360,8 @@ interface Release {
353
360
  // `josh propagate` always passes exactly one release; `josh adopt` passes every toolkit installed in
354
361
  // the repository it runs in. Only the upgrade and the sync repeat per
355
362
  // release — the pre-check, the verification gate, the issue, the pull request and the return to the
356
- // default branch happen once, which is why the step order itself is unchanged.
363
+ // default branch happen once, which is why the step order does not depend on the plan — `josh adopt`
364
+ // runs `propagate_run.ADOPT_STEP_ORDER`, the same order with `josh latest` added.
357
365
  interface ReleasePlan {
358
366
  releases: ReadonlyArray<Release>
359
367
  origin: string
@@ -5,6 +5,7 @@ import { transform_copied_content } from '#scripts/init/init-copy-content'
5
5
  import { init_logic } from '#scripts/init/init-logic'
6
6
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
7
7
  import type { ProjectShape } from '#scripts/init/project-profile'
8
+ import { security_policy } from '#scripts/init/security-policy'
8
9
  import { file_reader } from '#scripts/lib/read-file'
9
10
  import { pack_hook } from '#scripts/safe-chain/pack-hook'
10
11
  import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
@@ -80,6 +81,7 @@ function sync_workspace_yaml(
80
81
 
81
82
  // The source is the profile's: a basic project syncs from its own templates, as `josh init` wrote it.
82
83
  function sync_ai_copy_file(filename: string, is_force: boolean, shape?: ProjectShape): void {
84
+ if (security_policy.did_skip_unresolved(filename, PROJECT_ROOT)) return
83
85
  const source = init_ai_copy.ai_file_source(filename, shape)
84
86
 
85
87
  if (filename === WORKSPACE_YAML) {
@@ -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
- // Skipped after a rollback so the tree really is left exactly as it was found — this sync rewrites
179
- // the workflows' pinned safe-chain installer, which would contradict the notice that
180
- // nothing changed.
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
- process.exit(check(readFileSync(PACKAGE_JSON_PATH, 'utf8'), probe_range))
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.