@joshuafolkken/kit 1.1370.0 → 1.1373.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/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 +3 -3
- package/dist/hooks/{chunk-43G45XJF.js → chunk-7UBFU3CL.js} +1 -1
- package/dist/hooks/{chunk-KPM4YIIX.js → chunk-7WKYHEGU.js} +1 -1
- package/dist/hooks/{chunk-4TW4MQUL.js → chunk-AKGCP4LX.js} +9 -9
- package/dist/hooks/{chunk-NZBHG6FW.js → chunk-BZMPVFDY.js} +3 -3
- package/dist/hooks/{chunk-76RK6E3H.js → chunk-JCRDBQXB.js} +2 -2
- package/dist/hooks/{chunk-DZWZ3DKY.js → chunk-JGW24KKM.js} +2 -2
- package/dist/hooks/{chunk-TLJYAGZK.js → chunk-SLSFIWTX.js} +1 -1
- package/dist/hooks/{chunk-SYSRHRWU.js → chunk-UTHMB4O2.js} +1 -0
- package/dist/hooks/{chunk-R6CRQEAL.js → chunk-V4TKZHER.js} +2 -2
- package/dist/hooks/{chunk-MXVQMLDP.js → chunk-YLBBSSTF.js} +2 -2
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +4 -4
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/inputs.json +190 -190
- package/dist/hooks/investigation-guard.js +4 -4
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/session-lang.js +1 -1
- package/dist/hooks/stop-guard.js +4 -4
- package/dist/josh.js +1 -1
- package/package.json +4 -4
- package/scripts/document/document-scan.ts +4 -3
- package/scripts/josh/josh-commands-run.ts +1 -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/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/lint/cycle-recheck.ts +0 -122
|
@@ -36,9 +36,10 @@ const BARE_ARROW_PATTERN = /→\s*(?:§\w+,\s*)?["「](?<heading>[^"「」]+)["
|
|
|
36
36
|
// arrow — the link text already carries the file the reference names.
|
|
37
37
|
const LINK_TARGET_PATTERN = /\]\([^)\s]*\)/gu
|
|
38
38
|
|
|
39
|
-
// A relative markdown link target, with an optional `#anchor
|
|
40
|
-
//
|
|
41
|
-
|
|
39
|
+
// A relative markdown link target, with an optional `#anchor` — a page, or the image a page shows
|
|
40
|
+
// (``). An external `https://…` link carries a colon the target class
|
|
41
|
+
// leaves out, so the pattern never matches one.
|
|
42
|
+
const LINK_PATTERN = /\]\(([\w./-]+\.(?:md|png|jpe?g|gif|svg|webp))(?:#[\w-]+)?\)/gu
|
|
42
43
|
|
|
43
44
|
// Filenames that appear in the documents as illustrations of the reference form itself, never as real
|
|
44
45
|
// targets — the doc-section explanation cites `` `path.md` → "Heading" `` and `` `X.md` → "Heading" ``.
|
|
@@ -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 */
|
|
@@ -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
|
|
|
@@ -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 }
|
|
@@ -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 }
|