@joshuafolkken/kit 1.1435.0 → 1.1453.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-park.md +2 -0
- package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
- package/.claude/skills/workflow-commands/backlogrun.md +2 -2
- package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
- package/.claude/skills/workflow-commands/halfrun.md +1 -1
- package/.claude/skills/workflow-commands/kickoff.md +12 -13
- package/.claude/skills/workflow-commands/observation-filing.md +1 -1
- package/.claude/skills/workflow-commands/prerequisite.md +6 -0
- package/.claude/skills/workflow-commands/retrospective.md +5 -5
- package/.claude/skills/workflow-commands/split-assessment.md +2 -2
- package/README.md +15 -17
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
- package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
- package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
- package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
- package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
- package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
- package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
- package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
- package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
- package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
- package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
- package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
- package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
- 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 +241 -240
- 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 +33 -10
- package/package.json +1 -1
- package/prompts/collaboration-workflow/issue-template.md +10 -8
- package/prompts/review.md +8 -4
- package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
- package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
- package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
- package/scripts/auto-ok/auto-ok-judge.ts +67 -0
- package/scripts/backlog/backlog-drive-cli.ts +14 -0
- package/scripts/backlog/backlog-drive.ts +22 -3
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
- package/scripts/gate/project-checks.ts +5 -2
- package/scripts/gh/git-gh-issue-rest.ts +1 -0
- package/scripts/gh/git-gh-issue.ts +9 -0
- package/scripts/init/init-actions.ts +3 -3
- package/scripts/init/init-ai-copy.ts +1 -1
- package/scripts/init/init-basic.ts +1 -1
- package/scripts/init/init-bootstrap.ts +6 -2
- package/scripts/init/init-dry-run-report.ts +150 -0
- package/scripts/init/init-dry-run.ts +189 -0
- package/scripts/init/init-install.ts +8 -1
- package/scripts/init/init.ts +57 -19
- package/scripts/init/kit-setup-state.ts +11 -17
- package/scripts/init/own-formatter.ts +21 -0
- package/scripts/init/project-profile.ts +25 -6
- package/scripts/init/start-dry-run.ts +70 -0
- package/scripts/init/start-plan.ts +8 -3
- package/scripts/init/start-setup-pr.ts +13 -24
- package/scripts/init/start-steps.ts +16 -1
- package/scripts/init/start.ts +38 -18
- package/scripts/init/working-tree.ts +116 -0
- package/scripts/issue/issue-auto-ok.ts +68 -99
- package/scripts/issue/issue-file-cli.ts +17 -17
- package/scripts/issue/issue-file.ts +1 -6
- package/scripts/issue/issue-labels.ts +6 -2
- package/scripts/issue/issue-release-cli.ts +69 -23
- package/scripts/issue/issue-release.ts +3 -1
- package/scripts/josh/josh-commands-ai.ts +11 -0
- package/scripts/josh/josh-commands-issue.ts +3 -3
- package/scripts/josh/josh-commands-project.ts +2 -2
- package/scripts/josh/process-identity.ts +32 -6
- package/scripts/lane/lane-broker-server.ts +69 -0
- package/scripts/lane/lane-session-broker.ts +37 -14
- package/scripts/observations/observation-ledger-migrate.ts +3 -15
- package/scripts/release/release-issue-close.ts +133 -0
- package/scripts/release/release-publish.ts +12 -1
- package/scripts/repo/repository-labels.ts +11 -1
- package/scripts/review/comment-ranges.ts +199 -0
- package/scripts/review/review-brief-cli.ts +22 -5
- package/scripts/review/review-code-tree.ts +119 -0
- package/scripts/review/review-round2-cli.ts +16 -5
- package/scripts/review/review-round2.ts +30 -10
- package/scripts/review/review-stamps.ts +22 -1
- package/scripts/uninstall/distributed-files.ts +1 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { epic_issue } from '#scripts/epic/epic-issue'
|
|
2
|
+
import { epic_parse } from '#scripts/epic/epic-parse'
|
|
3
|
+
import type { IssueReference } from '#scripts/epic/epic-reference'
|
|
4
|
+
import { git_gh_command } from '#scripts/gh/git-gh-command'
|
|
5
|
+
import { epic_child_schema } from '#scripts/git/git-schemas'
|
|
6
|
+
import { parse_json_object_or_undefined } from '#scripts/git/parse-json-array'
|
|
7
|
+
import { issue_release_cli } from '#scripts/issue/issue-release-cli'
|
|
8
|
+
import { session_cite } from '#scripts/issue/session-cite'
|
|
9
|
+
|
|
10
|
+
// Closes the repository's open release Issue once `pnpm josh release` has published
|
|
11
|
+
// (joshuafolkken/kit#3825). Its body promises that, and an Issue waiting on the release reopens only
|
|
12
|
+
// once it closes; left open, it keeps naming published work as unreleased.
|
|
13
|
+
//
|
|
14
|
+
// **Nothing here throws or changes the release's exit code.** The version is already out by the time
|
|
15
|
+
// this runs, so a close that fails is reported for a person to finish rather than read as a failed
|
|
16
|
+
// release.
|
|
17
|
+
|
|
18
|
+
function published_comment(version: string): string {
|
|
19
|
+
return `Published in \`v${version}\` by \`pnpm josh release\`.`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolved through `epic_issue.blocker_references_of`, so a blocker in another repository keeps that
|
|
23
|
+
// repository instead of reading as the same number here.
|
|
24
|
+
function open_references_of(
|
|
25
|
+
json: string | undefined,
|
|
26
|
+
repo: string,
|
|
27
|
+
): Array<IssueReference> | undefined {
|
|
28
|
+
if (json === undefined) return undefined
|
|
29
|
+
const parsed = parse_json_object_or_undefined(json, epic_child_schema)
|
|
30
|
+
|
|
31
|
+
if (parsed === undefined) return undefined
|
|
32
|
+
|
|
33
|
+
return epic_issue
|
|
34
|
+
.blocker_references_of(parsed, repo)
|
|
35
|
+
.filter((reference) => !epic_parse.is_state_closed(reference.state))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The blockers still open at publish have not merged, so this release did not carry them.
|
|
39
|
+
// `undefined` when the relations are unreadable: closing then could strand a blocker on a closed
|
|
40
|
+
// release Issue that no open one tracks.
|
|
41
|
+
async function open_blockers_of(
|
|
42
|
+
repo: string,
|
|
43
|
+
issue_number: number,
|
|
44
|
+
): Promise<Array<IssueReference> | undefined> {
|
|
45
|
+
const json = await git_gh_command.issue_get_state_and_relations(String(issue_number))
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return open_references_of(json, repo)
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A release Issue tracks only its own repository's changes, so a blocker elsewhere is named for a
|
|
55
|
+
// person to carry rather than linked under a number that means a different Issue here.
|
|
56
|
+
function warn_foreign(repo: string, foreign: ReadonlyArray<IssueReference>): void {
|
|
57
|
+
for (const reference of foreign) {
|
|
58
|
+
const cite = session_cite.issue(reference.number, undefined, reference.repo)
|
|
59
|
+
|
|
60
|
+
console.warn(`⚠ ${cite} is not in ${repo}, so it was not carried — carry it by hand`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Each unmerged blocker joins the next release Issue — the first link files it, the rest find it
|
|
65
|
+
// open — so an open release Issue still names every change no release has published.
|
|
66
|
+
async function carry_forward(repo: string, blockers: ReadonlyArray<IssueReference>): Promise<void> {
|
|
67
|
+
const own = blockers.filter((blocker) => blocker.repo === repo)
|
|
68
|
+
|
|
69
|
+
warn_foreign(
|
|
70
|
+
repo,
|
|
71
|
+
blockers.filter((blocker) => blocker.repo !== repo),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for (const blocker of own) {
|
|
75
|
+
// eslint-disable-next-line no-await-in-loop -- the first link files the Issue the rest join
|
|
76
|
+
await issue_release_cli.link(blocker.number, repo)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function close_and_carry(
|
|
81
|
+
repo: string,
|
|
82
|
+
issue_number: number,
|
|
83
|
+
version: string,
|
|
84
|
+
blockers: ReadonlyArray<IssueReference>,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const cite = `release Issue ${session_cite.issue(issue_number, undefined, repo)}`
|
|
87
|
+
const is_closed = await git_gh_command.issue_close(
|
|
88
|
+
String(issue_number),
|
|
89
|
+
published_comment(version),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if (!is_closed) {
|
|
93
|
+
console.warn(`⚠ ${cite} could not be closed — close it by hand`)
|
|
94
|
+
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
console.info(`${cite} closed (v${version})`)
|
|
99
|
+
await carry_forward(repo, blockers)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function close_issue(repo: string, issue_number: number, version: string): Promise<void> {
|
|
103
|
+
const blockers = await open_blockers_of(repo, issue_number)
|
|
104
|
+
|
|
105
|
+
if (blockers === undefined) {
|
|
106
|
+
const cite = session_cite.issue(issue_number, undefined, repo)
|
|
107
|
+
|
|
108
|
+
console.warn(`⚠ the blockers of release Issue ${cite} could not be read, so it was left open`)
|
|
109
|
+
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await close_and_carry(repo, issue_number, version, blockers)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function close_published(version: string): Promise<void> {
|
|
117
|
+
const repo = await git_gh_command.repo_get_name_with_owner()
|
|
118
|
+
|
|
119
|
+
if (repo === undefined) {
|
|
120
|
+
console.warn('⚠ the repository is unreadable, so its release Issue was left open')
|
|
121
|
+
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const open = await issue_release_cli.find_open(repo)
|
|
126
|
+
|
|
127
|
+
if (open === undefined) console.warn(`⚠ the release Issues of ${repo} could not be listed`)
|
|
128
|
+
else if (typeof open === 'number') await close_issue(repo, open, version)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const release_issue_close = { close_published }
|
|
132
|
+
|
|
133
|
+
export { release_issue_close }
|
|
@@ -5,6 +5,7 @@ import { git_remote_branch } from '#scripts/git/git-remote-branch'
|
|
|
5
5
|
import { repository_lock } from '#scripts/git/repository-lock'
|
|
6
6
|
import { write_version } from '#scripts/version/bump-version'
|
|
7
7
|
import { version_targets } from '#scripts/version/version-targets'
|
|
8
|
+
import { release_issue_close } from './release-issue-close'
|
|
8
9
|
import type { ReleasePlan } from './release-plan'
|
|
9
10
|
import { release_progress } from './release-progress'
|
|
10
11
|
import { release_tag } from './release-tag'
|
|
@@ -121,6 +122,14 @@ async function commit_release(plan: ReleasePlan): Promise<string> {
|
|
|
121
122
|
return pr_url
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// The release Issue is closed only once the distribution answered success — a failed watch leaves it
|
|
126
|
+
// open, still naming the work as unreleased (joshuafolkken/kit#3825).
|
|
127
|
+
async function close_when_published(version: string, exit_code: number): Promise<number> {
|
|
128
|
+
if (exit_code === SUCCESS_EXIT_CODE) await release_issue_close.close_published(version)
|
|
129
|
+
|
|
130
|
+
return exit_code
|
|
131
|
+
}
|
|
132
|
+
|
|
124
133
|
// No tag means nothing downstream ran, so the npm and GitHub Release watches are not started.
|
|
125
134
|
async function merge_and_tag(
|
|
126
135
|
plan: ReleasePlan,
|
|
@@ -136,7 +145,9 @@ async function merge_and_tag(
|
|
|
136
145
|
console.info(release_tag.format_result(plan.next_version, is_tagged))
|
|
137
146
|
if (!is_tagged) return FAILURE_EXIT_CODE
|
|
138
147
|
|
|
139
|
-
|
|
148
|
+
const exit_code = await release_progress.wait_for_distribution(plan.next_version)
|
|
149
|
+
|
|
150
|
+
return await close_when_published(plan.next_version, exit_code)
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
// The whole release happens inside a work tree cut for it, so the root checkout is never touched: the
|
|
@@ -86,6 +86,16 @@ function ensure_labels(repo: string | undefined): ReadonlyArray<string> {
|
|
|
86
86
|
return create_missing(missing_labels(existing), repo)
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
// The label names `ensure_labels` would create, read without creating any — every one when the
|
|
90
|
+
// repository does not exist yet (`josh start --dry-run`). Undefined when the listing cannot be read,
|
|
91
|
+
// so an unread repository is not reported as one with no labels.
|
|
92
|
+
function planned_labels(repo: string | undefined): ReadonlyArray<string> | undefined {
|
|
93
|
+
if (repo === undefined) return label_names(REPOSITORY_LABELS)
|
|
94
|
+
const existing = read_existing(repo)
|
|
95
|
+
|
|
96
|
+
return existing === undefined ? undefined : label_names(missing_labels(existing))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const repository_labels = { ensure_labels, planned_labels }
|
|
90
100
|
|
|
91
101
|
export { repository_labels }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type ts from 'typescript'
|
|
2
|
+
|
|
3
|
+
// The text of a source file with its comments taken out, so two versions that differ only in comments
|
|
4
|
+
// read as the same text.
|
|
5
|
+
//
|
|
6
|
+
// **Every range comes from the TypeScript parser, never from a pattern.** A `//` inside a regular
|
|
7
|
+
// expression or a template literal is code, and a hand-written scanner that took it for a comment
|
|
8
|
+
// would strip a real edit and call the fix comment-only — the one wrong answer that buys a `skip`.
|
|
9
|
+
// Driven by the parse tree, a range is only ever read from the trivia before a token.
|
|
10
|
+
//
|
|
11
|
+
// **What it cannot read, it does not strip**: a file that does not parse answers `undefined`, and a
|
|
12
|
+
// comment the walk does not reach stays in the text. Both resolve to "the code changed", which costs a
|
|
13
|
+
// round rather than skipping one.
|
|
14
|
+
|
|
15
|
+
type TypeScriptApi = typeof ts
|
|
16
|
+
type Range = readonly [number, number]
|
|
17
|
+
|
|
18
|
+
// A comment's own opening — the shebang is trivia to the parser too, but it decides how the file is
|
|
19
|
+
// executed, so it stays.
|
|
20
|
+
const COMMENT_OPENINGS: ReadonlyArray<string> = ['//', '/*']
|
|
21
|
+
// A private-use character, which no source file this reads holds.
|
|
22
|
+
const MARK = ''
|
|
23
|
+
const LINE_BREAK = '\n'
|
|
24
|
+
const SEPARATOR = ' '
|
|
25
|
+
// Script blocks are parsed; style blocks are left whole, so `<!--` in a stylesheet is never read as
|
|
26
|
+
// a markup comment.
|
|
27
|
+
const RAW_BLOCK = /<(?<tag>script|style)\b[^>]*>(?<body>[\s\S]*?)<\/\k<tag>>/gu
|
|
28
|
+
const SCRIPT_TAG = 'script'
|
|
29
|
+
const MARKUP_COMMENT = /<!--[\s\S]*?-->/gu
|
|
30
|
+
const VIRTUAL_FILE_NAME = 'fix-delta.ts'
|
|
31
|
+
// A comment a tool reads is code: a fix that silences lint, the type check or coverage, or changes
|
|
32
|
+
// what a bundler or the type checker reads, does it by adding one — the edit only the review sees.
|
|
33
|
+
// JSDoc type tags count too, since a checked `.js` file takes its types from them — any tag carrying a
|
|
34
|
+
// `{…}` type expression, plus the type tags that carry none. Matching too much only costs a round.
|
|
35
|
+
const DIRECTIVE_MARKERS: ReadonlyArray<string> = [
|
|
36
|
+
'///',
|
|
37
|
+
'@ts-',
|
|
38
|
+
'eslint',
|
|
39
|
+
'jshint',
|
|
40
|
+
'global ',
|
|
41
|
+
'globals ',
|
|
42
|
+
'prettier-ignore',
|
|
43
|
+
'svelte-ignore',
|
|
44
|
+
'cspell',
|
|
45
|
+
'v8 ignore',
|
|
46
|
+
'c8 ignore',
|
|
47
|
+
'istanbul ignore',
|
|
48
|
+
'__PURE__',
|
|
49
|
+
'__NO_SIDE_EFFECTS__',
|
|
50
|
+
'@vite-ignore',
|
|
51
|
+
'webpack',
|
|
52
|
+
'@jsx',
|
|
53
|
+
'@vitest-environment',
|
|
54
|
+
]
|
|
55
|
+
const JSDOC_TYPE_TAG =
|
|
56
|
+
/@(?:type|typedef|param|returns?|template|satisfies|import|callback|overload|enum|this)\b/u
|
|
57
|
+
const JSDOC_TYPE_EXPRESSION = /@\w+\s*\{/u
|
|
58
|
+
|
|
59
|
+
function is_directive(text: string, range: Range): boolean {
|
|
60
|
+
const comment = text.slice(range[0], range[1])
|
|
61
|
+
|
|
62
|
+
if (JSDOC_TYPE_TAG.test(comment) || JSDOC_TYPE_EXPRESSION.test(comment)) return true
|
|
63
|
+
|
|
64
|
+
return DIRECTIVE_MARKERS.some((marker) => comment.includes(marker))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function is_comment(text: string, range: Range): boolean {
|
|
68
|
+
return COMMENT_OPENINGS.some((opening) => text.startsWith(opening, range[0]))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function trivia_at(api: TypeScriptApi, text: string, position: number): Array<Range> {
|
|
72
|
+
const found = [
|
|
73
|
+
...(api.getLeadingCommentRanges(text, position) ?? []),
|
|
74
|
+
...(api.getTrailingCommentRanges(text, position) ?? []),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
return found.map((range): Range => [range.pos, range.end])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// **JSDoc nodes are not descended into**: their children sit inside the comment, and a `//` there
|
|
81
|
+
// would read as a line comment running past the `*/` into the code that follows it.
|
|
82
|
+
function collect(
|
|
83
|
+
api: TypeScriptApi,
|
|
84
|
+
node: ts.Node,
|
|
85
|
+
source: ts.SourceFile,
|
|
86
|
+
into: Array<Range>,
|
|
87
|
+
): void {
|
|
88
|
+
if (api.isJSDoc(node)) return
|
|
89
|
+
|
|
90
|
+
into.push(...trivia_at(api, source.text, node.pos))
|
|
91
|
+
|
|
92
|
+
for (const child of node.getChildren(source)) collect(api, child, source, into)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parsed as TypeScript whatever the extension, because a `.js` file is valid TypeScript syntax and JSX
|
|
96
|
+
// is not — JSX text holding `//` is exactly what a trivia read would mistake for a comment, and here
|
|
97
|
+
// it is a parse error instead.
|
|
98
|
+
function script_ranges(api: TypeScriptApi, text: string): Array<Range> | undefined {
|
|
99
|
+
const source = api.createSourceFile(VIRTUAL_FILE_NAME, text, api.ScriptTarget.Latest, false)
|
|
100
|
+
const diagnostics = (source as ts.SourceFile & { parseDiagnostics?: ReadonlyArray<unknown> })
|
|
101
|
+
.parseDiagnostics
|
|
102
|
+
|
|
103
|
+
if (diagnostics === undefined || diagnostics.length > 0) return undefined
|
|
104
|
+
const ranges: Array<Range> = []
|
|
105
|
+
|
|
106
|
+
collect(api, source, source, ranges)
|
|
107
|
+
|
|
108
|
+
return ranges.filter((range) => is_comment(text, range))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shifted(ranges: ReadonlyArray<Range>, offset: number): Array<Range> {
|
|
112
|
+
return ranges.map((range): Range => [range[0] + offset, range[1] + offset])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function is_inside(blocks: ReadonlyArray<Range>, index: number): boolean {
|
|
116
|
+
return blocks.some((block) => index >= block[0] && index < block[1])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function markup_ranges(text: string, blocks: ReadonlyArray<Range>): Array<Range> {
|
|
120
|
+
return [...text.matchAll(MARKUP_COMMENT)]
|
|
121
|
+
.filter((match) => !is_inside(blocks, match.index))
|
|
122
|
+
.map((match): Range => [match.index, match.index + match[0].length])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function block_ranges(api: TypeScriptApi, match: RegExpExecArray): Array<Range> | undefined {
|
|
126
|
+
if (match.groups?.['tag'] !== SCRIPT_TAG) return []
|
|
127
|
+
const body = match.groups['body'] ?? ''
|
|
128
|
+
const ranges = script_ranges(api, body)
|
|
129
|
+
|
|
130
|
+
return ranges && shifted(ranges, match.index + match[0].indexOf(body))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function svelte_ranges(api: TypeScriptApi, text: string): Array<Range> | undefined {
|
|
134
|
+
const matches = [...text.matchAll(RAW_BLOCK)]
|
|
135
|
+
const scripts = matches.map((match) => block_ranges(api, match))
|
|
136
|
+
|
|
137
|
+
if (scripts.includes(undefined)) return undefined
|
|
138
|
+
const blocks = matches.map((match): Range => [match.index, match.index + match[0].length])
|
|
139
|
+
|
|
140
|
+
return [...scripts.flatMap((ranges) => ranges ?? []), ...markup_ranges(text, blocks)]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A comment spanning lines is a line terminator to the program — `return` followed by one returns
|
|
144
|
+
// nothing — so its break is kept, and a one-line comment in its place cannot strip to the same text.
|
|
145
|
+
function mark_for(text: string, start: number, end: number): string {
|
|
146
|
+
return text.slice(start, end).includes(LINE_BREAK) ? `${MARK}${LINE_BREAK}${MARK}` : MARK
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function marked(text: string, ranges: ReadonlyArray<Range>): string {
|
|
150
|
+
const ordered = ranges.toSorted((left, right) => left[0] - right[0])
|
|
151
|
+
let result = ''
|
|
152
|
+
let cursor = 0
|
|
153
|
+
|
|
154
|
+
for (const [start, end] of ordered) {
|
|
155
|
+
if (start < cursor) continue
|
|
156
|
+
result += `${text.slice(cursor, start)}${mark_for(text, start, end)}`
|
|
157
|
+
cursor = end
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return `${result}${text.slice(cursor)}`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A comment is whitespace to the program, so it becomes one space, and a line it alone occupied goes
|
|
164
|
+
// away. Indentation and lines without a comment are kept byte for byte — a blank line in a template
|
|
165
|
+
// literal is data.
|
|
166
|
+
function normalized_line(line: string): Array<string> {
|
|
167
|
+
if (!line.includes(MARK)) return [line]
|
|
168
|
+
const indentation = line.slice(0, line.length - line.trimStart().length)
|
|
169
|
+
const words = line
|
|
170
|
+
.split(MARK)
|
|
171
|
+
.map((part) => part.trim())
|
|
172
|
+
.filter((part) => part !== '')
|
|
173
|
+
|
|
174
|
+
return words.length === 0 ? [] : [`${indentation}${words.join(SEPARATOR)}`]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function without(text: string, ranges: ReadonlyArray<Range>): string {
|
|
178
|
+
return marked(text, ranges)
|
|
179
|
+
.split(LINE_BREAK)
|
|
180
|
+
.flatMap((line) => normalized_line(line))
|
|
181
|
+
.join(LINE_BREAK)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function strip(api: TypeScriptApi, text: string, is_svelte: boolean): string | undefined {
|
|
185
|
+
const ranges = is_svelte ? svelte_ranges(api, text) : script_ranges(api, text)
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
ranges &&
|
|
189
|
+
without(
|
|
190
|
+
text,
|
|
191
|
+
ranges.filter((range) => !is_directive(text, range)),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const comment_ranges = { strip }
|
|
197
|
+
|
|
198
|
+
export { comment_ranges }
|
|
199
|
+
export type { TypeScriptApi }
|
|
@@ -17,6 +17,7 @@ import { run_ship_review } from '#scripts/run/ship/run-ship-review'
|
|
|
17
17
|
import { review_attest } from './review-attest'
|
|
18
18
|
import { review_brief } from './review-brief'
|
|
19
19
|
import { review_checkout, type ReviewCheckout } from './review-checkout'
|
|
20
|
+
import { review_code_tree } from './review-code-tree'
|
|
20
21
|
import type { DiffParts } from './review-diff-parts'
|
|
21
22
|
import { review_diff_write } from './review-diff-write'
|
|
22
23
|
import { review_level, type ReviewLevel } from './review-level'
|
|
@@ -112,13 +113,15 @@ function kept_note(taken_at: string): string {
|
|
|
112
113
|
// `change_base`, and `change_base` is recomputed on every invocation — so without the commit it was
|
|
113
114
|
// measured against, round 2 cannot tell a fix from a base that moved under it, and reports the
|
|
114
115
|
// difference between two incomparable sets as round 1's fixes.
|
|
116
|
+
// **Answers whether it wrote**, because the comment-free code record is taken only at that moment: a
|
|
117
|
+
// code record retaken over a kept snapshot would describe the fixed tree, the exact retake above.
|
|
115
118
|
function record_round_one(
|
|
116
119
|
round: number,
|
|
117
120
|
tree: Record<string, string>,
|
|
118
121
|
base: string | undefined,
|
|
119
122
|
target?: string,
|
|
120
|
-
):
|
|
121
|
-
if (round !== FIRST_ROUND) return
|
|
123
|
+
): boolean {
|
|
124
|
+
if (round !== FIRST_ROUND) return false
|
|
122
125
|
|
|
123
126
|
try {
|
|
124
127
|
const recorded = review_stamps.round_one_stamp.read(target)
|
|
@@ -126,12 +129,15 @@ function record_round_one(
|
|
|
126
129
|
if (recorded !== undefined) {
|
|
127
130
|
console.error(kept_note(recorded.taken_at))
|
|
128
131
|
|
|
129
|
-
return
|
|
132
|
+
return false
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
review_stamps.round_one_stamp.write(tree, target, base)
|
|
136
|
+
|
|
137
|
+
return true
|
|
133
138
|
} catch {
|
|
134
139
|
/* no record widens the next round rather than narrowing it */
|
|
140
|
+
return false
|
|
135
141
|
}
|
|
136
142
|
}
|
|
137
143
|
|
|
@@ -308,6 +314,17 @@ async function read_change(): Promise<ChangeReading> {
|
|
|
308
314
|
return { base, commit, paths, tree }
|
|
309
315
|
}
|
|
310
316
|
|
|
317
|
+
async function record_review(
|
|
318
|
+
round: number,
|
|
319
|
+
tree: Record<string, string>,
|
|
320
|
+
commit: string | undefined,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
const is_snapshot_written = record_round_one(round, tree, commit)
|
|
323
|
+
|
|
324
|
+
if (is_snapshot_written) await review_code_tree.record(Object.keys(tree), commit)
|
|
325
|
+
record_briefed(round, tree, commit)
|
|
326
|
+
}
|
|
327
|
+
|
|
311
328
|
async function run_review(round: number, reading: ChangeReading): Promise<number> {
|
|
312
329
|
const { base, commit, paths, tree } = reading
|
|
313
330
|
const refusal = scoped_green.refusal_for(tree, commit)
|
|
@@ -318,8 +335,7 @@ async function run_review(round: number, reading: ChangeReading): Promise<number
|
|
|
318
335
|
if (resolved.kind === 'rejected') return report_error(resolved.note)
|
|
319
336
|
agent_role_profile.warn_of_default()
|
|
320
337
|
console.info(await compose_brief({ round, paths, tree, base, profile: resolved.profile }))
|
|
321
|
-
|
|
322
|
-
record_briefed(round, tree, commit)
|
|
338
|
+
await record_review(round, tree, commit)
|
|
323
339
|
|
|
324
340
|
return 0
|
|
325
341
|
}
|
|
@@ -343,6 +359,7 @@ const review_brief_cli = {
|
|
|
343
359
|
format_reason,
|
|
344
360
|
KEPT_NOTE_PREFIX,
|
|
345
361
|
parse_round,
|
|
362
|
+
record_review,
|
|
346
363
|
record_round_one,
|
|
347
364
|
report_error,
|
|
348
365
|
run,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { git_command } from '#scripts/git/git-command'
|
|
5
|
+
import type { FileMapStamp } from '#scripts/josh/file-map-stamp'
|
|
6
|
+
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
7
|
+
import { comment_ranges, type TypeScriptApi } from './comment-ranges'
|
|
8
|
+
import { review_stamps } from './review-stamps'
|
|
9
|
+
|
|
10
|
+
// The second map round 1 records beside `review-tree.ts`'s: for each source file, the digest of its
|
|
11
|
+
// text with the comments taken out (joshuafolkken/kit#3826).
|
|
12
|
+
//
|
|
13
|
+
// **Why a second record rather than a second reading of the first.** The round-1 snapshot holds
|
|
14
|
+
// digests, not text, so after the fixes nothing can say what a changed `.ts` file used to contain. A
|
|
15
|
+
// comment-free digest taken at the same moment is what lets round 2 tell "only the comments changed"
|
|
16
|
+
// from "the code changed" — equal digests mean equal code.
|
|
17
|
+
//
|
|
18
|
+
// **`.claude/` is outside it**: the decision on joshuafolkken/kit#3787 left `.claude/**` reviewed in
|
|
19
|
+
// full, comments included.
|
|
20
|
+
//
|
|
21
|
+
// **TypeScript is resolved from the project, and its absence records nothing.** It is an optional peer
|
|
22
|
+
// of this package, and a missing map is a comment-only arm that never fires — a round run, never one
|
|
23
|
+
// skipped.
|
|
24
|
+
|
|
25
|
+
const SCRIPT_EXTENSIONS: ReadonlyArray<string> = ['.ts', '.js']
|
|
26
|
+
const SVELTE_EXTENSION = '.svelte'
|
|
27
|
+
const EXCLUDED_PREFIX = '.claude/'
|
|
28
|
+
const TYPESCRIPT_MODULE = 'typescript'
|
|
29
|
+
const PACKAGE_JSON = 'package.json'
|
|
30
|
+
|
|
31
|
+
function is_svelte(relative: string): boolean {
|
|
32
|
+
return relative.endsWith(SVELTE_EXTENSION)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function is_source(relative: string): boolean {
|
|
36
|
+
if (relative.startsWith(EXCLUDED_PREFIX)) return false
|
|
37
|
+
|
|
38
|
+
return is_svelte(relative) || SCRIPT_EXTENSIONS.some((extension) => relative.endsWith(extension))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function load_typescript(root: string): TypeScriptApi | undefined {
|
|
42
|
+
try {
|
|
43
|
+
return createRequire(path.join(root, PACKAGE_JSON))(TYPESCRIPT_MODULE) as TypeScriptApi
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function code_digest(api: TypeScriptApi, root: string, relative: string): string | undefined {
|
|
50
|
+
try {
|
|
51
|
+
const code = comment_ranges.strip(
|
|
52
|
+
api,
|
|
53
|
+
readFileSync(path.join(root, relative), 'utf8'),
|
|
54
|
+
is_svelte(relative),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return code === undefined ? undefined : stamp_file.digest(code)
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A path that is deleted, does not parse, or is not a source file has no entry — and a path with no
|
|
64
|
+
// entry is never comment-only.
|
|
65
|
+
function tree_of(
|
|
66
|
+
root: string,
|
|
67
|
+
paths: ReadonlyArray<string>,
|
|
68
|
+
api: TypeScriptApi | undefined = load_typescript(root),
|
|
69
|
+
): Record<string, string> {
|
|
70
|
+
if (api === undefined) return {}
|
|
71
|
+
const entries = paths
|
|
72
|
+
.filter((relative) => is_source(relative))
|
|
73
|
+
.flatMap((relative) => {
|
|
74
|
+
const digest = code_digest(api, root, relative)
|
|
75
|
+
|
|
76
|
+
return digest === undefined ? [] : [[relative, digest] as const]
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return Object.fromEntries(entries)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function read(paths: ReadonlyArray<string>, root?: string): Promise<Record<string, string>> {
|
|
83
|
+
return tree_of(root ?? (await git_command.repository_root()), paths)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Written only in the same brief that wrote the round-1 snapshot, so the two describe one moment. A
|
|
87
|
+
// failure removes any earlier record rather than leaving one from another moment beside the new
|
|
88
|
+
// snapshot.
|
|
89
|
+
async function record(
|
|
90
|
+
paths: ReadonlyArray<string>,
|
|
91
|
+
base: string | undefined,
|
|
92
|
+
target?: string,
|
|
93
|
+
root?: string,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
review_stamps.round_one_code_stamp.write(await read(paths, root), target, base)
|
|
97
|
+
} catch {
|
|
98
|
+
review_stamps.clear_round_one_code(target)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The paths whose code round 1's fixes left as it was — whose difference, if any, is in comments.
|
|
103
|
+
// Nothing qualifies against a record taken on another base, for the reason the round-1 snapshot is
|
|
104
|
+
// refused on one.
|
|
105
|
+
function comment_only(
|
|
106
|
+
snapshot: FileMapStamp | undefined,
|
|
107
|
+
tree: Record<string, string>,
|
|
108
|
+
base: string | undefined,
|
|
109
|
+
): ReadonlyArray<string> {
|
|
110
|
+
if (snapshot === undefined || base === undefined || snapshot.base !== base) return []
|
|
111
|
+
|
|
112
|
+
return Object.keys(snapshot.files).filter(
|
|
113
|
+
(relative) => snapshot.files[relative] === tree[relative],
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const review_code_tree = { comment_only, read, record, tree_of }
|
|
118
|
+
|
|
119
|
+
export { review_code_tree }
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { change_base } from '#scripts/git/change-base'
|
|
4
4
|
import { path_decision } from '#scripts/josh/path-decision'
|
|
5
|
+
import { review_code_tree } from './review-code-tree'
|
|
5
6
|
import { review_round2 } from './review-round2'
|
|
6
7
|
import { review_stamps } from './review-stamps'
|
|
7
8
|
import { review_tree } from './review-tree'
|
|
@@ -44,6 +45,14 @@ function parse_options(argv: ReadonlyArray<string>): Options | undefined {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// Only the paths the code record covers are re-read, so a run without one parses nothing.
|
|
49
|
+
async function read_comment_only(base: string | undefined): Promise<ReadonlyArray<string>> {
|
|
50
|
+
const snapshot = review_stamps.round_one_code_stamp.read()
|
|
51
|
+
const tree = await review_code_tree.read(Object.keys(snapshot?.files ?? {}))
|
|
52
|
+
|
|
53
|
+
return review_code_tree.comment_only(snapshot, tree, base)
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
48
57
|
const options = parse_options(argv)
|
|
49
58
|
|
|
@@ -53,11 +62,13 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
|
53
62
|
return FAILURE_EXIT_CODE
|
|
54
63
|
}
|
|
55
64
|
|
|
65
|
+
// The commit the tree below is a diff against, so the snapshot can be refused when it was taken
|
|
66
|
+
// against a different one — and the resolved commit rather than `git_command.change_base`, whose
|
|
67
|
+
// fallback is a ref name that moves and would compare equal to itself.
|
|
68
|
+
const base = await change_base.resolved()
|
|
56
69
|
const { verdict, reason } = review_round2.decide({
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// fallback is a ref name that moves and would compare equal to itself.
|
|
60
|
-
base: await change_base.resolved(),
|
|
70
|
+
base,
|
|
71
|
+
comment_only: await read_comment_only(base),
|
|
61
72
|
is_round_one_closed: options.is_round_one_closed,
|
|
62
73
|
snapshot: review_stamps.round_one_stamp.read(),
|
|
63
74
|
// No paths argument: `read_changed_tree` reads them itself, from the one definition of
|
|
@@ -76,7 +87,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
|
|
|
76
87
|
process.exitCode = await run(argv)
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
const review_round2_cli = { CLOSED_FLAG, parse_options, run }
|
|
90
|
+
const review_round2_cli = { CLOSED_FLAG, parse_options, read_comment_only, run }
|
|
80
91
|
|
|
81
92
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
|
|
82
93
|
|