@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { repo_origin } from '#scripts/discovery/repo-origin'
|
|
3
|
+
import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
|
|
4
|
+
import { file_reader } from '#scripts/lib/read-file'
|
|
5
|
+
|
|
6
|
+
// A consumer's `SECURITY.md` is its own policy, not kit's: the advisory and release links have to
|
|
7
|
+
// name the repository the file lands in, and kit's account of its own published package is not a
|
|
8
|
+
// fact about that repository. So the consumer copy is written from a template of its own, with the
|
|
9
|
+
// repository filled in, and kit's root `SECURITY.md` stays kit's.
|
|
10
|
+
const SECURITY_MD = 'SECURITY.md'
|
|
11
|
+
const SECURITY_TEMPLATE = 'templates/SECURITY.md'
|
|
12
|
+
const REPOSITORY_PLACEHOLDER = '{{REPOSITORY}}'
|
|
13
|
+
const KIT_ADVISORY_URL = 'https://github.com/joshuafolkken/kit/security/advisories/new'
|
|
14
|
+
const UNRESOLVED_REASON = '`origin` is not a GitHub repository `owner/repo` can be read from'
|
|
15
|
+
const LINK_DELIMITER = /[\s()<>[\]]+/u
|
|
16
|
+
|
|
17
|
+
// Read from the local `origin` rather than asked of GitHub: the basic profile decides whether to
|
|
18
|
+
// write the file from the same remote, and a policy file must not depend on `gh` being signed in.
|
|
19
|
+
function repository_at(root: string): string | undefined {
|
|
20
|
+
const url = git_spawn_sync.origin_url(root)
|
|
21
|
+
const identity = url === undefined ? undefined : repo_origin.parse_origin_url(url)
|
|
22
|
+
|
|
23
|
+
return identity === undefined ? undefined : `${identity.owner}/${identity.repo}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function apply_repository(content: string, repository: string): string {
|
|
27
|
+
return content.replaceAll(REPOSITORY_PLACEHOLDER, () => repository)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Throws rather than writing the placeholder through: both copy paths ask `did_skip_unresolved`
|
|
31
|
+
// first, so reaching here without a repository is a caller that skipped the question.
|
|
32
|
+
function apply_repository_for_destination(destination_path: string, content: string): string {
|
|
33
|
+
if (path.basename(destination_path) !== SECURITY_MD) return content
|
|
34
|
+
const repository = repository_at(path.dirname(destination_path))
|
|
35
|
+
|
|
36
|
+
if (repository === undefined) {
|
|
37
|
+
throw new Error(`${SECURITY_MD} needs a GitHub origin to name the repository it reports to`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return apply_repository(content, repository)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A full project synced before the template existed holds kit's own policy under this name, and the
|
|
44
|
+
// skip leaves that file on disk — still sending its readers to kit's advisory form. The form is
|
|
45
|
+
// matched as a whole link target, not as a substring: a longer address that merely starts with it
|
|
46
|
+
// is another address.
|
|
47
|
+
function does_report_to_kit(root: string): boolean {
|
|
48
|
+
const existing = file_reader.read_if_readable(path.join(root, SECURITY_MD)) ?? ''
|
|
49
|
+
|
|
50
|
+
return new Set(existing.split(LINK_DELIMITER)).has(KIT_ADVISORY_URL)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unresolved_warning(root: string): string {
|
|
54
|
+
if (!does_report_to_kit(root)) return ` ⚠ skipped ${SECURITY_MD} (${UNRESOLVED_REASON})`
|
|
55
|
+
|
|
56
|
+
return ` ⚠ stale ${SECURITY_MD} still reports vulnerabilities to joshuafolkken/kit — replace it with your own policy or delete it (${UNRESOLVED_REASON})`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Without a GitHub `origin` there is no repository for the advisory link to name, so the file is
|
|
60
|
+
// left unwritten — the next `josh sync` after the remote is added writes it.
|
|
61
|
+
function did_skip_unresolved(filename: string, root: string): boolean {
|
|
62
|
+
if (filename !== SECURITY_MD || repository_at(root) !== undefined) return false
|
|
63
|
+
|
|
64
|
+
console.warn(unresolved_warning(root))
|
|
65
|
+
|
|
66
|
+
return true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const security_policy = {
|
|
70
|
+
SECURITY_MD,
|
|
71
|
+
SECURITY_TEMPLATE,
|
|
72
|
+
REPOSITORY_PLACEHOLDER,
|
|
73
|
+
KIT_ADVISORY_URL,
|
|
74
|
+
repository_at,
|
|
75
|
+
apply_repository_for_destination,
|
|
76
|
+
did_skip_unresolved,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export { security_policy }
|
|
@@ -80,6 +80,9 @@ const MAINTENANCE_COMMANDS: Record<string, CommandEntry> = {
|
|
|
80
80
|
description: 'Update all dependencies to latest',
|
|
81
81
|
category: 'Maintenance',
|
|
82
82
|
reference: ['', 'developer', ['files', 'network']],
|
|
83
|
+
// `JOSH_PLAYWRIGHT_BROWSERS` widens the browsers a Playwright bump installs, and a project
|
|
84
|
+
// keeps that setting in its `.env`.
|
|
85
|
+
tsx_arguments: OPTIONAL_ENV_FILE_FLAGS,
|
|
83
86
|
},
|
|
84
87
|
}
|
|
85
88
|
/* eslint-enable @typescript-eslint/naming-convention */
|
|
@@ -88,7 +88,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
|
|
|
88
88
|
},
|
|
89
89
|
adopt: {
|
|
90
90
|
script: 'scripts/adopt/adopt.ts',
|
|
91
|
-
description: 'Upgrade
|
|
91
|
+
description: 'Upgrade the @joshuafolkken toolkits and other dependencies here, open the PR',
|
|
92
92
|
category: 'Project',
|
|
93
93
|
reference: ['[--dry-run]', 'developer', ['files', 'git', 'network', 'processes']],
|
|
94
94
|
},
|
|
@@ -125,7 +125,7 @@ const RUN_COMMANDS: Record<string, CommandEntry> = {
|
|
|
125
125
|
description:
|
|
126
126
|
'Print the run’s next single action, computed from the event stream, carry record and issue state',
|
|
127
127
|
category: 'AI tools',
|
|
128
|
-
reference: ['<issue>', 'automation', ['network', 'files']],
|
|
128
|
+
reference: ['[<issue>]', 'automation', ['network', 'files']],
|
|
129
129
|
},
|
|
130
130
|
}
|
|
131
131
|
/* eslint-enable @typescript-eslint/naming-convention */
|
|
@@ -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:
|
|
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
|
},
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { FAIL_EXIT_CODE, type BufferedProcessResult } from '#scripts/lib/buffered-process'
|
|
2
|
+
import type { ESLint } from 'eslint'
|
|
3
|
+
|
|
4
|
+
// Some rules read other files, and the lint cache keys a verdict on the linted file's own content.
|
|
5
|
+
// So once the other file is fixed, the untouched one keeps its cached error until its own content
|
|
6
|
+
// changes. `import/no-cycle` did it first (joshuafolkken/kit#3641): a cycle closed by editing one
|
|
7
|
+
// side stayed reported on the other. The type-aware rules do it too (joshuafolkken/kit#3667): a call
|
|
8
|
+
// to a function its module did not export yet stays `no-unsafe-call` after the export is added.
|
|
9
|
+
//
|
|
10
|
+
// A red lint run therefore re-reads its cached report: the files reported for a rule that reads
|
|
11
|
+
// other files are linted once more without the cache, and their fresh results replace the cached
|
|
12
|
+
// ones. A green run never reaches this, so the green path costs what it did.
|
|
13
|
+
//
|
|
14
|
+
// Which rules those are is the rule's own statement, never a list kept here: a type-aware rule
|
|
15
|
+
// declares `requiresTypeChecking`, and the `import` plugin's rules resolve the modules they name.
|
|
16
|
+
// That plugin answers to two names: the base config registers it as `import`, and its own preset —
|
|
17
|
+
// whose rules a hand-authored `.js` file keeps — as `import-x`.
|
|
18
|
+
//
|
|
19
|
+
// Only a stale red is re-read. A stale green — the other file broke, the untouched one still
|
|
20
|
+
// answers clean — is not something a report can point at; the type check is what catches it.
|
|
21
|
+
//
|
|
22
|
+
// That holds for a run narrowed to one change's files as much as for the whole tree: the change can
|
|
23
|
+
// hold both files, and the one a later edit leaves alone keeps its cached report.
|
|
24
|
+
//
|
|
25
|
+
// The cached run is asked again through the Node API rather than read off the first run's report:
|
|
26
|
+
// that report is the stylish text, and taking a file out of it means re-deriving its problem
|
|
27
|
+
// counts. The second asking is answered from the cache the first one just wrote.
|
|
28
|
+
const IMPORT_RULE_PATTERN = /^import(?:-x)?\//u
|
|
29
|
+
const FORMATTER = 'stylish'
|
|
30
|
+
const UNCACHED_SUFFIX = '.uncached'
|
|
31
|
+
|
|
32
|
+
type RulesMeta = ESLint.LintResultData['rulesMeta']
|
|
33
|
+
|
|
34
|
+
interface Linters {
|
|
35
|
+
cached: ESLint
|
|
36
|
+
fresh: ESLint
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CrossFileRecheckOptions {
|
|
40
|
+
cache_file: string
|
|
41
|
+
// What the run linted — the whole tree, or one change's files — so the cached run is asked the
|
|
42
|
+
// question it was asked the first time.
|
|
43
|
+
patterns: ReadonlyArray<string>
|
|
44
|
+
// What the fixture test varies: the directory linted and the configuration it is linted with.
|
|
45
|
+
eslint_options?: ESLint.Options
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The exit code is eslint's "finished and reported problems", not any failure: a run that could not
|
|
49
|
+
// finish — a configuration that does not load, a rule that threw — exits 2, and asking it again
|
|
50
|
+
// throws the same error here, ahead of both buffered reports.
|
|
51
|
+
function is_red(result: BufferedProcessResult): boolean {
|
|
52
|
+
return result.exit_code === FAIL_EXIT_CODE
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// `requiresTypeChecking` is typescript-eslint's convention, which other type-aware plugins follow;
|
|
56
|
+
// eslint's own type for a rule's docs does not name it.
|
|
57
|
+
function is_type_aware(documentation: object | undefined): boolean {
|
|
58
|
+
if (documentation === undefined) return false
|
|
59
|
+
|
|
60
|
+
return 'requiresTypeChecking' in documentation && documentation.requiresTypeChecking === true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A problem with no rule is the parser's, about the file itself.
|
|
64
|
+
function reads_other_files(rule_id: string | null, rules_meta: RulesMeta): boolean {
|
|
65
|
+
if (rule_id === null) return false
|
|
66
|
+
|
|
67
|
+
return IMPORT_RULE_PATTERN.test(rule_id) || is_type_aware(rules_meta[rule_id]?.docs)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function suspects_of(linter: ESLint, cached: Array<ESLint.LintResult>): Array<string> {
|
|
71
|
+
const rules_meta = linter.getRulesMetaForResults(cached)
|
|
72
|
+
|
|
73
|
+
return cached
|
|
74
|
+
.filter((result) =>
|
|
75
|
+
result.messages.some((message) => reads_other_files(message.ruleId, rules_meta)),
|
|
76
|
+
)
|
|
77
|
+
.map((result) => result.filePath)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cached_options(options: CrossFileRecheckOptions): ESLint.Options {
|
|
81
|
+
return {
|
|
82
|
+
...options.eslint_options,
|
|
83
|
+
cache: true,
|
|
84
|
+
cacheStrategy: 'content',
|
|
85
|
+
cacheLocation: options.cache_file,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A linter that runs without a cache deletes the cache file it would have used, and that defaults to
|
|
90
|
+
// the very file the lint run keeps. So the uncached linter is pointed at a sibling path nothing
|
|
91
|
+
// ever writes: the re-reading must not cost the next run its whole cache.
|
|
92
|
+
function fresh_options(options: CrossFileRecheckOptions): ESLint.Options {
|
|
93
|
+
return {
|
|
94
|
+
...options.eslint_options,
|
|
95
|
+
cache: false,
|
|
96
|
+
cacheLocation: `${options.cache_file}${UNCACHED_SUFFIX}`,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `eslint` is loaded here, only past a red report: it is an optional peer a basic-profile
|
|
101
|
+
// consumer may not have, and this module is imported by the entry of every `josh lint`, which has to
|
|
102
|
+
// reach its skip notice there.
|
|
103
|
+
async function create_linters(options: CrossFileRecheckOptions): Promise<Linters> {
|
|
104
|
+
const eslint = await import('eslint')
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
cached: new eslint.ESLint(cached_options(options)),
|
|
108
|
+
fresh: new eslint.ESLint(fresh_options(options)),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function lint_fresh(
|
|
113
|
+
linter: ESLint,
|
|
114
|
+
suspects: Array<string>,
|
|
115
|
+
): Promise<Map<string, ESLint.LintResult>> {
|
|
116
|
+
const fresh = await linter.lintFiles(suspects)
|
|
117
|
+
|
|
118
|
+
return new Map(fresh.map((result) => [result.filePath, result]))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function report(
|
|
122
|
+
linter: ESLint,
|
|
123
|
+
results: Array<ESLint.LintResult>,
|
|
124
|
+
): Promise<Pick<BufferedProcessResult, 'output' | 'exit_code'>> {
|
|
125
|
+
const formatter = await linter.loadFormatter(FORMATTER)
|
|
126
|
+
const is_failed = results.some((result) => result.errorCount > 0)
|
|
127
|
+
|
|
128
|
+
return { output: await formatter.format(results), exit_code: is_failed ? FAIL_EXIT_CODE : 0 }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A red about rules that read nothing else is handed back as the run reported it: its cached
|
|
132
|
+
// verdicts are about the files they are keyed on.
|
|
133
|
+
async function verify(
|
|
134
|
+
result: BufferedProcessResult,
|
|
135
|
+
options: CrossFileRecheckOptions,
|
|
136
|
+
): Promise<BufferedProcessResult> {
|
|
137
|
+
if (!is_red(result)) return result
|
|
138
|
+
const started_at = performance.now()
|
|
139
|
+
const linters = await create_linters(options)
|
|
140
|
+
const cached = await linters.cached.lintFiles([...options.patterns])
|
|
141
|
+
const suspects = suspects_of(linters.cached, cached)
|
|
142
|
+
if (suspects.length === 0) return result
|
|
143
|
+
const fresh = await lint_fresh(linters.fresh, suspects)
|
|
144
|
+
const verified = await report(
|
|
145
|
+
linters.fresh,
|
|
146
|
+
cached.map((entry) => fresh.get(entry.filePath) ?? entry),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return { ...verified, elapsed_ms: result.elapsed_ms + performance.now() - started_at }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cross_file_recheck = { verify }
|
|
153
|
+
|
|
154
|
+
export type { CrossFileRecheckOptions }
|
|
155
|
+
export { cross_file_recheck }
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
FAIL_EXIT_CODE,
|
|
9
9
|
type BufferedProcessResult,
|
|
10
10
|
} from '#scripts/lib/buffered-process'
|
|
11
|
-
import {
|
|
11
|
+
import { cross_file_recheck, type CrossFileRecheckOptions } from './cross-file-recheck'
|
|
12
12
|
|
|
13
13
|
const PRETTIER_ARGS = ['exec', 'prettier', '--check', '.'] as const
|
|
14
14
|
// This — not the `lint:eslint` map entry — is the eslint invocation `josh gate` reaches, so the
|
|
@@ -17,16 +17,16 @@ const PRETTIER_ARGS = ['exec', 'prettier', '--check', '.'] as const
|
|
|
17
17
|
// moves, with every test still green.
|
|
18
18
|
const WHOLE_TREE = '.'
|
|
19
19
|
const ESLINT_ARGS = ['exec', 'eslint', WHOLE_TREE, ...ESLINT_CACHE_FLAGS] as const
|
|
20
|
-
const WHOLE_TREE_RECHECK:
|
|
20
|
+
const WHOLE_TREE_RECHECK: CrossFileRecheckOptions = {
|
|
21
21
|
cache_file: ESLINT_CACHE_FILE,
|
|
22
22
|
patterns: [WHOLE_TREE],
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// What one eslint run is made of: the child's arguments, and what re-reading a cached
|
|
26
|
-
// (
|
|
25
|
+
// What one eslint run is made of: the child's arguments, and what re-reading a cached cross-file report
|
|
26
|
+
// (`cross-file-recheck.ts`) needs to ask the same question again — the cache file and the targets.
|
|
27
27
|
interface EslintRun {
|
|
28
28
|
args: ReadonlyArray<string>
|
|
29
|
-
recheck:
|
|
29
|
+
recheck: CrossFileRecheckOptions
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// The finished report is re-read before it is returned, inside the lane's cache sharing, because
|
|
@@ -35,7 +35,7 @@ async function run_eslint(eslint: EslintRun): Promise<BufferedProcessResult> {
|
|
|
35
35
|
return await lane_cache_run.run(eslint.recheck.cache_file, async () => {
|
|
36
36
|
const result = await buffered_process.run_buffered_process(eslint.args)
|
|
37
37
|
|
|
38
|
-
return await
|
|
38
|
+
return await cross_file_recheck.verify(result, eslint.recheck)
|
|
39
39
|
})
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -80,12 +80,13 @@ function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcess
|
|
|
80
80
|
// run would have skipped. The skip reasons answer nothing for a full
|
|
81
81
|
// project, so the one path serves both profiles.
|
|
82
82
|
//
|
|
83
|
-
// A narrowed run re-reads a cached
|
|
84
|
-
// hold both
|
|
83
|
+
// A narrowed run re-reads a cached cross-file report too (`cross-file-recheck.ts`): one change's
|
|
84
|
+
// files can hold both the reported file and the one it reads, and fixing the second leaves the
|
|
85
|
+
// first's cached report standing.
|
|
85
86
|
async function run_lint_checks(
|
|
86
87
|
prettier_args: ReadonlyArray<string>,
|
|
87
88
|
eslint_args: ReadonlyArray<string>,
|
|
88
|
-
recheck:
|
|
89
|
+
recheck: CrossFileRecheckOptions,
|
|
89
90
|
): Promise<number> {
|
|
90
91
|
const directory = process.cwd()
|
|
91
92
|
const [prettier, eslint] = await Promise.all([
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
ESLINT_RELATED_CACHE_FILE,
|
|
10
10
|
ESLINT_RELATED_CACHE_FLAGS,
|
|
11
11
|
} from '#scripts/josh/josh-command-types'
|
|
12
|
-
import type {
|
|
12
|
+
import type { CrossFileRecheckOptions } from './cross-file-recheck'
|
|
13
13
|
|
|
14
14
|
// `josh lint` reads the whole repository on every call, and an implementation loop calls it after
|
|
15
15
|
// every few edits — re-reading files the change never touched.
|
|
@@ -113,9 +113,9 @@ function eslint_arguments(files: ReadonlyArray<string>): ReadonlyArray<string> {
|
|
|
113
113
|
return [...ESLINT_ARGS, ...files, ...ESLINT_RELATED_CACHE_FLAGS, ...ESLINT_SCOPED_FLAGS]
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
// What re-reading a cached
|
|
116
|
+
// What re-reading a cached cross-file report (`cross-file-recheck.ts`) needs to ask the narrowed run's question
|
|
117
117
|
// again: the same targets, the same cache file, and the Node API's spelling of `--no-warn-ignored`.
|
|
118
|
-
function
|
|
118
|
+
function cross_file_recheck_options(files: ReadonlyArray<string>): CrossFileRecheckOptions {
|
|
119
119
|
return {
|
|
120
120
|
cache_file: ESLINT_RELATED_CACHE_FILE,
|
|
121
121
|
patterns: files,
|
|
@@ -125,7 +125,7 @@ function cycle_recheck_options(files: ReadonlyArray<string>): CycleRecheckOption
|
|
|
125
125
|
|
|
126
126
|
const lint_related_scope = {
|
|
127
127
|
COMMAND_LABEL,
|
|
128
|
-
|
|
128
|
+
cross_file_recheck_options,
|
|
129
129
|
NOTHING_LINTABLE_REASON,
|
|
130
130
|
describe_scope,
|
|
131
131
|
eslint_arguments,
|
|
@@ -36,7 +36,7 @@ async function lint_exit_code(scope: ChangedFileScope): Promise<number> {
|
|
|
36
36
|
return await lint_parallel.run_lint_checks(
|
|
37
37
|
lint_related_scope.prettier_arguments(scope.files),
|
|
38
38
|
lint_related_scope.eslint_arguments(scope.files),
|
|
39
|
-
lint_related_scope.
|
|
39
|
+
lint_related_scope.cross_file_recheck_options(scope.files),
|
|
40
40
|
)
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -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
|
|
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
|
|
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
|
|
@@ -106,7 +106,7 @@ const BATCH_ORACLES: ReadonlyArray<DecisionOracle> = [
|
|
|
106
106
|
{
|
|
107
107
|
name: 'run:step',
|
|
108
108
|
decision: 'The run’s next single action, computed from the event stream, carry and issue state',
|
|
109
|
-
args: ISSUE_N_ARG
|
|
109
|
+
args: `[${ISSUE_N_ARG}]`,
|
|
110
110
|
// The non-command answers: `run:step` prints a runnable command for a phase with one to run, and
|
|
111
111
|
// one of these verdicts otherwise. A command line is not a vocabulary token, exactly as an issue
|
|
112
112
|
// number is not one for `backlog:next`.
|
|
@@ -13,9 +13,9 @@ import { run_headless } from './run-headless'
|
|
|
13
13
|
import { run_prep, type PrepParts } from './run-prep'
|
|
14
14
|
import { run_prep_cli } from './run-prep-cli'
|
|
15
15
|
import { run_retrospective } from './run-retrospective'
|
|
16
|
-
import { run_step, type StepInput } from './run-step'
|
|
16
|
+
import { run_step, type ParentInput, type StepInput } from './run-step'
|
|
17
17
|
|
|
18
|
-
// `josh run:step <N
|
|
18
|
+
// `josh run:step [<N>]` — print the run's next single action, computed from the event stream, the carry
|
|
19
19
|
// record and the issue state. It reads exactly those three, never the
|
|
20
20
|
// conversation: the issue facts through `run:prep`'s own gather (so this and `run:next` never answer
|
|
21
21
|
// from different reads), the newest event off the stream, and the carry record's kind. The reader runs
|
|
@@ -25,7 +25,8 @@ import { run_step, type StepInput } from './run-step'
|
|
|
25
25
|
const SUCCESS_EXIT_CODE = 0
|
|
26
26
|
const FAILURE_EXIT_CODE = 1
|
|
27
27
|
const ARGV_OFFSET = 2
|
|
28
|
-
const USAGE =
|
|
28
|
+
const USAGE =
|
|
29
|
+
'Usage: josh run:step [<issue-number>] — without a number it answers for the `backlogrun` carry record this session drives'
|
|
29
30
|
|
|
30
31
|
interface RunReads {
|
|
31
32
|
carry_kind: CarryRead['kind']
|
|
@@ -118,7 +119,54 @@ async function gather(issue_number: string): Promise<Gathered> {
|
|
|
118
119
|
return { input: step_input(parts, run_reads, is_lane_child), ship_problems: parts.ship_problems }
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
const UNREADABLE_CARRY: CarryRead = { kind: 'unreadable' }
|
|
123
|
+
|
|
124
|
+
// The parent's own position — a run asked about with no issue. The last event is the run's newest in
|
|
125
|
+
// scope, whichever issue it names: a parent is positioned by the whole batch, never by one child.
|
|
126
|
+
function parent_reads(directory: string): Pick<ParentInput, 'carry_kind' | 'last_event'> & {
|
|
127
|
+
carry: CarryRead
|
|
128
|
+
} {
|
|
129
|
+
const carry = run_carry.read_carry(run_carry.carry_path(directory))
|
|
130
|
+
const events = run_event_stream.read_events(run_event_stream.target_of(directory))
|
|
131
|
+
const last = run_event_scope.last_scoped_event(events, run_event_scope.scope_of(carry))
|
|
132
|
+
|
|
133
|
+
return { carry, carry_kind: carry.kind, last_event: last?.kind }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parent_input(directory: string | undefined): ParentInput {
|
|
137
|
+
const { carry, ...position } =
|
|
138
|
+
directory === undefined
|
|
139
|
+
? { carry: UNREADABLE_CARRY, carry_kind: UNREADABLE_CARRY.kind, last_event: undefined }
|
|
140
|
+
: parent_reads(directory)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
...position,
|
|
144
|
+
is_driving: run_headless.driven_here(carry, lane_reap.own_ancestry) !== undefined,
|
|
145
|
+
is_handed_off: is_handed_off_here(carry),
|
|
146
|
+
is_at_cut_cap: carry.kind === 'carried' && run_headless.is_cut_capped(carry.carry),
|
|
147
|
+
is_lane_child: lane_child_marker.is_child_of(process.cwd()),
|
|
148
|
+
is_retrospective_done: run_carry.retrospective_done_of(carry),
|
|
149
|
+
is_consumer: doctor_consumer.is_kit_consumer(find_package_directory(process.cwd())),
|
|
150
|
+
is_retrospective_enabled: run_retrospective.is_enabled(),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// `unknown` is the one answer with nothing to run — no record this session drives — so it exits non-zero
|
|
155
|
+
// with the usage, exactly as a position missing its issue state does.
|
|
156
|
+
async function run_parent(): Promise<number> {
|
|
157
|
+
const { line } = run_step.parent_action(parent_input(await run_carry.repository_directory()))
|
|
158
|
+
|
|
159
|
+
console.info(line)
|
|
160
|
+
if (line !== run_step.UNKNOWN) return SUCCESS_EXIT_CODE
|
|
161
|
+
|
|
162
|
+
console.error(USAGE)
|
|
163
|
+
|
|
164
|
+
return FAILURE_EXIT_CODE
|
|
165
|
+
}
|
|
166
|
+
|
|
121
167
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
168
|
+
if (argv.length === 0) return await run_parent()
|
|
169
|
+
|
|
122
170
|
const issue_number = run_prep_cli.parse_number(argv)
|
|
123
171
|
|
|
124
172
|
if (issue_number === undefined) {
|
package/scripts/run/run-step.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { session_cite } from '#scripts/issue/session-cite'
|
|
|
3
3
|
import type { CarryRead } from '#scripts/run/carry/run-carry'
|
|
4
4
|
import { run_event_stream } from '#scripts/run/event/run-event-stream'
|
|
5
5
|
import { latest_scope_cli } from '#scripts/version/latest-scope-cli'
|
|
6
|
-
import { run_retrospective } from './run-retrospective'
|
|
6
|
+
import { run_retrospective, type RetrospectiveFacts } from './run-retrospective'
|
|
7
7
|
|
|
8
8
|
// `josh run:step <N>` — the run's next single action, computed from three mechanical inputs and nothing
|
|
9
9
|
// else, so the run's loop is not a state machine written in English that the reader rebuilds every
|
|
@@ -278,16 +278,19 @@ function closed_action(input: StepInput): StepAction {
|
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// What the carry record alone says — the part an issue's position and the parent's own share.
|
|
282
|
+
type CarryFacts = Pick<StepInput, 'carry_kind' | 'is_handed_off' | 'is_lane_child'>
|
|
283
|
+
|
|
281
284
|
// **A cut hands the run on, so the session that took it stops** — otherwise it would go on answering
|
|
282
285
|
// `wait` or the next offer after `run:carry --cut`, launching children in the same conversation. A
|
|
283
286
|
// lane child never holds the carry record, so its own position is not decided here.
|
|
284
|
-
function is_handed_off_parent(input:
|
|
287
|
+
function is_handed_off_parent(input: CarryFacts): boolean {
|
|
285
288
|
return input.is_handed_off && input.carry_kind === 'carried' && !input.is_lane_child
|
|
286
289
|
}
|
|
287
290
|
|
|
288
291
|
// The carry record's terminal answers: an unreadable record leaves the position unknowable, a handed-off
|
|
289
292
|
// run is its successor's, and a spent budget is the person's call.
|
|
290
|
-
function carry_terminal_action(input:
|
|
293
|
+
function carry_terminal_action(input: CarryFacts): StepAction | undefined {
|
|
291
294
|
if (input.carry_kind === 'unreadable') return verdict(UNKNOWN)
|
|
292
295
|
if (is_handed_off_parent(input)) return verdict(STOP)
|
|
293
296
|
if (input.carry_kind === 'expired') return { kind: 'decide', line: EXPIRED_DECISION }
|
|
@@ -308,6 +311,47 @@ function terminal_action(input: StepInput): StepAction | undefined {
|
|
|
308
311
|
return undefined
|
|
309
312
|
}
|
|
310
313
|
|
|
314
|
+
// **A run with no issue still has a next action** — the `backlogrun` parent's own position, read off the
|
|
315
|
+
// carry record alone. Without it a run that dispatched nothing — one begun on an empty backlog — has no
|
|
316
|
+
// position to ask about, and "the backlog is empty, so the run is over" is decided in the conversation
|
|
317
|
+
// instead of by the idle watch.
|
|
318
|
+
interface ParentInput extends RetrospectiveFacts {
|
|
319
|
+
carry_kind: CarryRead['kind']
|
|
320
|
+
// Whether this session declared the live record and no cut has handed it off yet.
|
|
321
|
+
is_driving: boolean
|
|
322
|
+
is_handed_off: boolean
|
|
323
|
+
is_at_cut_cap: boolean
|
|
324
|
+
last_event: string | undefined
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The positions the end-of-run retrospective fires at, whoever asks.
|
|
328
|
+
const RETROSPECTIVE_POSITIONS: ReadonlySet<string> = new Set([KIND.DRAIN, KIND.STOP])
|
|
329
|
+
|
|
330
|
+
function is_retrospective_position(input: ParentInput): boolean {
|
|
331
|
+
return RETROSPECTIVE_POSITIONS.has(input.last_event ?? '') && run_retrospective.is_owed(input)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// **The loop belongs to the supervisor, so a driving parent's next action is the hand-off whatever the
|
|
335
|
+
// plan held.** The supervisor starts no driver while a live owner holds the record, and only the driver
|
|
336
|
+
// runs the idle watch. A record at the cut cap has no cut left to take, so its session carries on.
|
|
337
|
+
function driving_action(input: ParentInput): StepAction {
|
|
338
|
+
if (is_retrospective_position(input)) return command(RETROSPECTIVE_COMMAND)
|
|
339
|
+
|
|
340
|
+
return input.is_at_cut_cap ? verdict(WAIT) : command(HAND_OFF_COMMAND)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A lane child's position is its issue's, and a record this session does not drive — absent, or another
|
|
344
|
+
// session's — is not its to hand off: both are `unknown`, never a guess at someone else's run.
|
|
345
|
+
function parent_action(input: ParentInput): StepAction {
|
|
346
|
+
if (input.is_lane_child) return verdict(UNKNOWN)
|
|
347
|
+
|
|
348
|
+
const terminal = carry_terminal_action(input)
|
|
349
|
+
|
|
350
|
+
if (terminal !== undefined) return terminal
|
|
351
|
+
|
|
352
|
+
return input.is_driving ? driving_action(input) : verdict(UNKNOWN)
|
|
353
|
+
}
|
|
354
|
+
|
|
311
355
|
// The one entry point: the run's next action, from the three inputs and nothing else. A terminal answer
|
|
312
356
|
// wins first; otherwise a run with nothing emitted yet is at its pre-implementation position, and a run
|
|
313
357
|
// that has begun is placed by its newest event.
|
|
@@ -339,8 +383,9 @@ const run_step = {
|
|
|
339
383
|
VOCABULARY,
|
|
340
384
|
WAIT,
|
|
341
385
|
next_action,
|
|
386
|
+
parent_action,
|
|
342
387
|
pre_verdict,
|
|
343
388
|
}
|
|
344
389
|
|
|
345
|
-
export type { PreInput, PreVerdict, StepAction, StepInput }
|
|
390
|
+
export type { ParentInput, PreInput, PreVerdict, StepAction, StepInput }
|
|
346
391
|
export { run_step }
|
|
@@ -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) {
|