@joshuafolkken/kit 1.972.0 → 1.983.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.claude/skills/epic-commands/SKILL.md +6 -5
  2. package/.claude/skills/workflow-commands/SKILL.md +9 -9
  3. package/.claude/skills/workflow-commands/background-commands.md +1 -1
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +8 -8
  5. package/.claude/skills/workflow-commands/fullrun.md +1 -0
  6. package/.claude/skills/workflow-commands/halfrun.md +16 -12
  7. package/.claude/skills/workflow-commands/issue-comments.md +1 -1
  8. package/.claude/skills/workflow-commands/issue-scout.md +21 -22
  9. package/.claude/skills/workflow-commands/kickoff.md +8 -9
  10. package/.claude/skills/workflow-commands/observation-filing.md +9 -10
  11. package/.claude/skills/workflow-commands/prerequisite.md +7 -6
  12. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  13. package/.claude/skills/workflow-commands/rule-residency.md +2 -2
  14. package/.claude/skills/workflow-commands/split-assessment.md +4 -3
  15. package/.claude/skills/workflow-commands/target-repository.md +7 -6
  16. package/.claude/skills/workflow-commands/working-tree-hold.md +16 -1
  17. package/README.md +24 -20
  18. package/dist/hooks/batch-guard.js +4 -4
  19. package/dist/hooks/{chunk-ZPLXLJ7O.js → chunk-47SPMGOD.js} +16 -1
  20. package/dist/hooks/{chunk-BVMSPG3M.js → chunk-5JX6HXDX.js} +3 -3
  21. package/dist/hooks/{chunk-KQG4776P.js → chunk-APVOACVT.js} +1 -1
  22. package/dist/hooks/{chunk-2K52BRWV.js → chunk-ASUZGZFT.js} +2 -2
  23. package/dist/hooks/{chunk-CRGJTD6E.js → chunk-ERXMJJOV.js} +1 -1
  24. package/dist/hooks/{chunk-WWTMHHZU.js → chunk-IM3KM25Y.js} +61 -30
  25. package/dist/hooks/{chunk-BNWIOJFU.js → chunk-LBXQHXMC.js} +138 -358
  26. package/dist/hooks/{chunk-NXL3SBXY.js → chunk-P3UX7SFN.js} +2 -2
  27. package/dist/hooks/{chunk-F6OM6SIG.js → chunk-RFPHKU7C.js} +12 -12
  28. package/dist/hooks/{chunk-ZYYSBNR6.js → chunk-ZT65LR3U.js} +4 -4
  29. package/dist/hooks/codex-hook-adapter.js +10 -10
  30. package/dist/hooks/duplicate-read-guard.js +5 -5
  31. package/dist/hooks/format-edited.js +2 -2
  32. package/dist/hooks/investigation-guard.js +5 -5
  33. package/dist/hooks/pretool-guard.js +9 -9
  34. package/dist/hooks/stop-guard.js +4 -4
  35. package/dist/josh.js +16 -1
  36. package/package.json +2 -2
  37. package/prompts/collaboration-workflow/epic-bundle.md +8 -8
  38. package/prompts/collaboration-workflow/issue-template.md +5 -5
  39. package/prompts/collaboration-workflow/rule-delivery.md +43 -30
  40. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  41. package/prompts/collaboration-workflow/wip-cap.md +3 -3
  42. package/prompts/review.md +11 -9
  43. package/scripts/ci/pr-classification.ts +13 -12
  44. package/scripts/delegation/delegation-policy.ts +9 -9
  45. package/scripts/document/document-byte-budget.ts +1 -1
  46. package/scripts/git/issue-labels.ts +52 -5
  47. package/scripts/init/init-bootstrap.ts +92 -0
  48. package/scripts/init/init-install.ts +4 -1
  49. package/scripts/init/init-logic-workspace.ts +1 -1
  50. package/scripts/init/init.ts +23 -5
  51. package/scripts/init/start-plan.ts +1 -1
  52. package/scripts/init/start-steps.ts +5 -28
  53. package/scripts/issue/issue-file-cli.ts +188 -0
  54. package/scripts/issue/issue-file.ts +192 -0
  55. package/scripts/issue/issue-lint-cli.ts +1 -1
  56. package/scripts/issue/issue-scout-cli.ts +41 -97
  57. package/scripts/issue/issue-scout-closed.ts +102 -0
  58. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +9 -1
  59. package/scripts/josh/josh-command-map.ts +1 -0
  60. package/scripts/josh/josh-commands-issue.ts +11 -0
  61. package/scripts/josh/josh-commands-project.ts +5 -1
  62. package/scripts/propagate/propagate-run.ts +1 -0
  63. package/scripts/propagate/propagate-select.ts +72 -0
  64. package/scripts/propagate/propagate-targets.ts +5 -3
  65. package/scripts/propagate/propagate.ts +86 -24
  66. package/scripts/repo/repository-labels.ts +91 -0
  67. package/scripts/rules/bash-triggers.ts +19 -6
  68. package/scripts/rules/delivered-rules.ts +28 -32
  69. package/scripts/rules/direct-filing.ts +38 -0
  70. package/scripts/rules/filing-cap.ts +11 -8
  71. package/scripts/rules/issue-fold.ts +4 -4
  72. package/scripts/rules/oracle-consulted.ts +4 -4
  73. package/scripts/rules/oracle-firing.ts +13 -16
  74. package/scripts/rules/rule-body-guard.ts +6 -9
  75. package/scripts/rules/stop-rules.ts +3 -2
  76. package/scripts/run/run-entry-cli.ts +34 -1
  77. package/scripts/run/run-halfrun-resume.ts +104 -0
  78. package/scripts/run/run-hold-cli.ts +27 -4
  79. package/scripts/run/run-hold.ts +11 -0
  80. package/scripts/security/security-audit.ts +12 -1
  81. package/scripts/sync/sync.ts +6 -0
  82. package/scripts/test/pilot-files.ts +0 -1
  83. package/scripts/version/fresh-metadata-cache.ts +36 -0
  84. package/scripts/version/latest-update.ts +2 -1
  85. package/scripts/rules/issue-bug-label.ts +0 -85
  86. package/scripts/rules/issue-filing-args.ts +0 -50
  87. package/scripts/rules/issue-scout.ts +0 -64
@@ -4,6 +4,7 @@ interface InstallStep {
4
4
  label: string
5
5
  command: string
6
6
  args: ReadonlyArray<string>
7
+ env?: Readonly<Record<string, string>>
7
8
  }
8
9
 
9
10
  interface InstallRequest {
@@ -40,6 +41,7 @@ function did_step_succeed(step: InstallStep, project_root: string): boolean {
40
41
  console.info(`\n$ ${step.label}`)
41
42
  const result = execaSync(step.command, step.args, {
42
43
  cwd: project_root,
44
+ env: step.env ?? {},
43
45
  stdio: 'inherit',
44
46
  reject: false,
45
47
  })
@@ -60,5 +62,6 @@ function run_post_init_steps(project_root: string): string | undefined {
60
62
  return undefined
61
63
  }
62
64
 
63
- const init_install = { NO_INSTALL_FLAG, split_install_flag, run_post_init_steps }
65
+ const init_install = { NO_INSTALL_FLAG, split_install_flag, did_step_succeed, run_post_init_steps }
64
66
  export { init_install }
67
+ export type { InstallStep }
@@ -138,6 +138,6 @@ function merge_workspace_yaml(existing: string, template: string): string {
138
138
  return append_user_blocks(cleaned, new_keys, template)
139
139
  }
140
140
 
141
- const init_logic_workspace = { merge_workspace_yaml }
141
+ const init_logic_workspace = { merge_workspace_yaml, template_build_values }
142
142
 
143
143
  export { init_logic_workspace }
@@ -15,12 +15,13 @@ import { execaSync } from 'execa'
15
15
  import { z } from 'zod'
16
16
  import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
17
17
  import { init_ai_copy } from './init-ai-copy'
18
+ import { init_bootstrap } from './init-bootstrap'
18
19
  import { init_install } from './init-install'
19
20
  import { init_logic } from './init-logic'
20
21
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
21
22
  import { init_static } from './init-static'
22
23
  import { plugin_install_hint_module } from './plugin-install-hint'
23
- import { project_profile, type ProjectShape } from './project-profile'
24
+ import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
24
25
 
25
26
  const PACKAGE_JSON = 'package.json'
26
27
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
@@ -320,10 +321,18 @@ function finish_dependencies(shape: ProjectShape, is_install: boolean): void {
320
321
  if (failure !== undefined) throw new Error(failure)
321
322
  }
322
323
 
323
- function main(args: ReadonlyArray<string> = []): void {
324
- if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
325
- const { is_install, rest } = init_install.split_install_flag(args)
326
- const requested = project_profile.requested_profile(rest, INIT_USAGE)
324
+ // A kit run from outside the project — `pnpm dlx`, a global install — sets nothing up itself: it
325
+ // installs the project's kit and hands the whole run to that one (joshuafolkken/kit#2794). Under
326
+ // `--no-install` nothing is installed, so the running kit does the setup as before.
327
+ function did_hand_off(args: ReadonlyArray<string>): boolean {
328
+ if (init_bootstrap.is_project_kit(PACKAGE_DIR, PROJECT_ROOT)) return false
329
+ const failure = init_bootstrap.hand_off(PACKAGE_DIR, PROJECT_ROOT, args)
330
+ if (failure !== undefined) throw new Error(failure)
331
+
332
+ return true
333
+ }
334
+
335
+ function set_up_project(requested: ProjectProfile | undefined, is_install: boolean): void {
327
336
  const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
328
337
 
329
338
  initialize_project(shape)
@@ -331,6 +340,15 @@ function main(args: ReadonlyArray<string> = []): void {
331
340
  console.info('\n✅ Done.\n')
332
341
  }
333
342
 
343
+ function main(args: ReadonlyArray<string> = []): void {
344
+ if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
345
+ const { is_install, rest } = init_install.split_install_flag(args)
346
+ // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
347
+ const requested = project_profile.requested_profile(rest, INIT_USAGE)
348
+
349
+ if (!is_install || !did_hand_off(args)) set_up_project(requested, is_install)
350
+ }
351
+
334
352
  function run_cli(args: ReadonlyArray<string>): void {
335
353
  try {
336
354
  main(args)
@@ -46,7 +46,7 @@ const STEP_LABELS: Readonly<Record<StartStep, string>> = {
46
46
  initialize: 'Initialize kit (the same setup as josh init)',
47
47
  commit: 'Commit every file as the initial commit',
48
48
  repository: 'Create the GitHub repository and push main',
49
- labels: 'Add the workflow labels the repository is missing',
49
+ labels: 'Add the workflow and release-classification labels the repository is missing',
50
50
  }
51
51
 
52
52
  function refused(refusal: string): StepPlan {
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { git_gh_exec } from '#scripts/git/git-gh-exec'
3
- import { WORKFLOW_LABELS } from '#scripts/git/issue-labels'
3
+ import { repository_labels } from '#scripts/repo/repository-labels'
4
4
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
5
5
  import { execaSync } from 'execa'
6
6
  import { main as init_main } from './init'
@@ -14,10 +14,7 @@ interface StepContext {
14
14
  visibility: Visibility
15
15
  }
16
16
 
17
- type WorkflowLabel = (typeof WORKFLOW_LABELS)[number]
18
-
19
17
  const DEFAULT_BRANCH = 'main'
20
- const LABELS_ENDPOINT = 'repos/{owner}/{repo}/labels'
21
18
  const GH_INSTALL_HINT =
22
19
  'GitHub CLI (gh) is not installed. Install it from https://cli.github.com/, run gh auth login, then run josh start again. Nothing was changed.'
23
20
  const GH_AUTH_HINT =
@@ -100,29 +97,10 @@ function create_github_repository(context: StepContext): void {
100
97
  run('gh', ['repo', 'create', name, visibility, ...source], context.root)
101
98
  }
102
99
 
103
- // Labels already present are left as they are — only the missing ones are created, so a repository
104
- // that recolored a label keeps its choice. GitHub compares label names case-insensitively.
105
- function missing_workflow_labels(existing: ReadonlyArray<string>): ReadonlyArray<WorkflowLabel> {
106
- const names = new Set(existing.map((name) => name.toLowerCase()))
107
-
108
- return WORKFLOW_LABELS.filter((label) => !names.has(label.name))
109
- }
110
-
111
- function create_label(label: WorkflowLabel, root: string): void {
112
- const fields = [`name=${label.name}`, `color=${label.color}`, `description=${label.description}`]
113
-
114
- run('gh', ['api', LABELS_ENDPOINT, ...fields.flatMap((field) => ['-f', field]), '--silent'], root)
115
- console.info(` ✔ label ${label.name}`)
116
- }
117
-
118
- function add_missing_labels(context: StepContext): void {
119
- const listing = execaSync('gh', ['api', '--paginate', LABELS_ENDPOINT, '--jq', '.[].name'], {
120
- ...git_gh_exec.direct_environment(),
121
- cwd: context.root,
122
- })
123
- const existing = listing.stdout.split('\n').filter((name) => name.length > 0)
124
-
125
- for (const label of missing_workflow_labels(existing)) create_label(label, context.root)
100
+ // The repository was created by the step before, so `gh` resolves the placeholder from the
101
+ // directory `josh start` runs in, which is the root.
102
+ function add_missing_labels(): void {
103
+ repository_labels.ensure_labels('{owner}/{repo}')
126
104
  }
127
105
 
128
106
  const STEP_ACTIONS: Readonly<Record<StartStep, (context: StepContext) => void>> = {
@@ -160,7 +138,6 @@ const start_steps = {
160
138
  read_git_state,
161
139
  github_cli_refusal,
162
140
  self_run_refusal,
163
- missing_workflow_labels,
164
141
  run_steps,
165
142
  }
166
143
  export { start_steps }
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env tsx
2
+ import { readFile } from 'node:fs/promises'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { repo_party } from '#scripts/discovery/repo-party'
5
+ import { epic_bundle_cli } from '#scripts/epic/epic-bundle-cli'
6
+ import { git_gh_command } from '#scripts/git/git-gh-command'
7
+ import { git_gh_exec } from '#scripts/git/git-gh-exec'
8
+ import { git_gh_issue_write } from '#scripts/git/git-gh-issue-write'
9
+ import { github_issue_url } from '#scripts/git/github-issue-url'
10
+ import { issue_file, type FileArguments } from './issue-file'
11
+ import { issue_lint_cli } from './issue-lint-cli'
12
+ import { issue_scout_cli } from './issue-scout-cli'
13
+
14
+ // `josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <route>] [--label <name>]…
15
+ // [--repo <owner/repo>] [--distinct <N,…>]` — file an Issue with every filing step run in order
16
+ // (joshuafolkken/kit#2808): the third-party refusal, the body lint, the `## Origin` check for another
17
+ // repository, the duplicate scout, the create call carrying every label, and `epic:bundle` after it.
18
+ // A direct `gh api …/issues` filing is refused by the `direct-filing` delivered rule and pointed here.
19
+
20
+ const SUCCESS_EXIT_CODE = 0
21
+ const FAILURE_EXIT_CODE = 1
22
+ const ARGV_OFFSET = 2
23
+ const USAGE =
24
+ 'Usage: josh issue:file "<title>" --body-file <path> --depth <0|1|2> [--route <tier-a|split|interrupt|review-cap>] [--label <name>]… [--repo <owner/repo>] [--distinct <N,…>]'
25
+ const UNKNOWN_REPO_MESSAGE =
26
+ 'Could not read this repository from `git remote`, so the filing has no repository to compare against — check `gh auth status`.'
27
+ const THIRD_PARTY_MESSAGE =
28
+ '✖ third-party target: a tracker we do not own is Tier C — draft the Issue for a person to post instead (`CLAUDE.md` → "Third-party repositories are Tier C").'
29
+ // `GH_REPO` is the variable `gh` reads `{owner}/{repo}` from, so setting it points every listing the
30
+ // scout and `epic:bundle` make at the target repository rather than at this checkout.
31
+ const GH_REPO_VARIABLE = 'GH_REPO'
32
+
33
+ interface Filing {
34
+ args: FileArguments
35
+ body: string
36
+ target: string
37
+ current: string
38
+ }
39
+
40
+ async function read_body(path: string): Promise<string | undefined> {
41
+ try {
42
+ return await readFile(path, 'utf8')
43
+ } catch {
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ function is_third_party(target: string): boolean {
49
+ const party = repo_party.classify(repo_party.current_owner(), repo_party.target_owner(target))
50
+
51
+ return party === repo_party.THIRD_PARTY
52
+ }
53
+
54
+ // Every refusal the filing can meet before a request is sent, in the order the steps run — `[]` when
55
+ // the filing may go on to the scout.
56
+ function refusals_of(filing: Filing): ReadonlyArray<string> {
57
+ if (is_third_party(filing.target)) return [THIRD_PARTY_MESSAGE]
58
+ const problems = issue_lint_cli.problems(filing.body).map((problem) => `✖ ${problem}`)
59
+ const origin = issue_file.origin_problem(filing.body, filing.target, filing.current)
60
+
61
+ return origin === undefined ? problems : [...problems, `✖ ${origin}`]
62
+ }
63
+
64
+ function unacknowledged_message(numbers: ReadonlyArray<number>): string {
65
+ const listed = numbers.map(String).join(',')
66
+
67
+ return `✖ duplicate candidate(s) ${listed} not declared separate — read each (\`issue-fold-existing.md\`); fold into a duplicate, or reissue with \`--distinct ${listed}\` when each is a separate deliverable.`
68
+ }
69
+
70
+ // Set before the scout and left set for `epic:bundle`, so both read the target's backlog.
71
+ function point_listings_at(filing: Filing): void {
72
+ if (issue_file.is_same_repository(filing.target, filing.current)) return
73
+
74
+ process.env[GH_REPO_VARIABLE] = filing.target
75
+ }
76
+
77
+ // The scout's report is printed whatever it finds, and the filing is held until every candidate it
78
+ // names is declared separate.
79
+ async function is_scout_clear(filing: Filing): Promise<boolean> {
80
+ point_listings_at(filing)
81
+ const outcome = await issue_scout_cli.scout(
82
+ { title: filing.args.title, body: filing.body },
83
+ filing.target,
84
+ )
85
+
86
+ if (outcome === undefined) return false
87
+ console.info(outcome.report)
88
+ const open = issue_file.unacknowledged(outcome.candidates, filing.args.distinct)
89
+
90
+ if (open.length > 0) console.error(unacknowledged_message(open))
91
+
92
+ return open.length === 0
93
+ }
94
+
95
+ // `undefined` when the create call failed. `gh`'s standard error is captured into the thrown error
96
+ // rather than printed, so it is printed here — otherwise the filing fails with no reason given.
97
+ async function create(filing: Filing): Promise<string | undefined> {
98
+ const request = git_gh_issue_write.issue_create_request({
99
+ title: filing.args.title,
100
+ body: filing.body,
101
+ labels: issue_file.labels_of(filing.args, filing.body),
102
+ repo: filing.target,
103
+ })
104
+
105
+ try {
106
+ return await git_gh_exec.exec_gh_api(request)
107
+ } catch (error) {
108
+ const reason = error instanceof Error ? error.message : String(error)
109
+
110
+ console.error(`✖ the create call failed: ${reason}`)
111
+
112
+ return undefined
113
+ }
114
+ }
115
+
116
+ // The Issue exists once `create` returns, so a failed placement is reported rather than failed: a
117
+ // non-zero exit would read as "not filed" and invite the second filing the scout exists to stop.
118
+ async function place(url: string, target: string): Promise<void> {
119
+ const issue_number = Number(github_issue_url.parse(url)?.issue_number)
120
+ const exit_code = Number.isSafeInteger(issue_number)
121
+ ? await epic_bundle_cli.report_for(issue_number, target)
122
+ : FAILURE_EXIT_CODE
123
+
124
+ if (exit_code !== SUCCESS_EXIT_CODE) {
125
+ console.error(`⚠ epic:bundle did not answer for ${url} — run \`pnpm josh epic:bundle <N>\`.`)
126
+ }
127
+ }
128
+
129
+ async function file(filing: Filing): Promise<number> {
130
+ const refusals = refusals_of(filing)
131
+
132
+ if (refusals.length > 0) {
133
+ console.error(refusals.join('\n'))
134
+
135
+ return FAILURE_EXIT_CODE
136
+ }
137
+
138
+ if (!(await is_scout_clear(filing))) return FAILURE_EXIT_CODE
139
+ const url = await create(filing)
140
+
141
+ if (url === undefined) return FAILURE_EXIT_CODE
142
+ console.info(url)
143
+ await place(url, filing.target)
144
+
145
+ return SUCCESS_EXIT_CODE
146
+ }
147
+
148
+ async function filing_of(args: FileArguments): Promise<Filing | string> {
149
+ const body = await read_body(args.body_file)
150
+
151
+ if (body === undefined) return `Could not read the body file ${args.body_file}.`
152
+ const current = await git_gh_command.repo_get_name_with_owner()
153
+
154
+ if (current === undefined) return UNKNOWN_REPO_MESSAGE
155
+
156
+ return { args, body, target: args.repo ?? current, current }
157
+ }
158
+
159
+ async function run(argv: ReadonlyArray<string>): Promise<number> {
160
+ const args = issue_file.parse(argv)
161
+
162
+ if (args === undefined) {
163
+ console.error(USAGE)
164
+
165
+ return FAILURE_EXIT_CODE
166
+ }
167
+
168
+ const filing = await filing_of(args)
169
+
170
+ if (typeof filing === 'string') {
171
+ console.error(filing)
172
+
173
+ return FAILURE_EXIT_CODE
174
+ }
175
+
176
+ return await file(filing)
177
+ }
178
+
179
+ // `process.exitCode` rather than `process.exit()`, so the report on standard output drains first.
180
+ async function main(argv: ReadonlyArray<string>): Promise<void> {
181
+ process.exitCode = await run(argv)
182
+ }
183
+
184
+ const issue_file_cli = { USAGE, THIRD_PARTY_MESSAGE, UNKNOWN_REPO_MESSAGE, run, main }
185
+
186
+ if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
187
+
188
+ export { issue_file_cli }
@@ -0,0 +1,192 @@
1
+ import { parseArgs } from 'node:util'
2
+ import { epic_issue } from '#scripts/epic/epic-issue'
3
+ import { github_issue_url } from '#scripts/git/github-issue-url'
4
+ import { DEPTH_LABEL_ORDER, FILING_ROUTE_LABELS, has_label_name } from '#scripts/git/issue-labels'
5
+ import { issue_backlinks } from './issue-backlinks'
6
+ import { issue_classification } from './issue-classification'
7
+ import { markdown_section } from './markdown-section'
8
+
9
+ // The decisions behind `josh issue:file` (joshuafolkken/kit#2808), kept apart from the command that
10
+ // reads the network so each one is a pure answer a test can pin. Every filing used to be a hand-built
11
+ // `gh api …/issues` call beside five steps spread over as many documents, and joshuafolkken/kit#2805
12
+ // ran two of them and skipped the lint and the labels. Here the steps are the command's arguments:
13
+ // a label the filing owes is a required option rather than a line to remember.
14
+
15
+ const DEPTH_PREFIX = 'depth:'
16
+ const ROUTE_PREFIX = 'route:'
17
+ const LIST_SEPARATOR = ','
18
+ const WORD_SEPARATOR = /\s+/u
19
+ const CODE_SPAN = /`/gu
20
+ // Prose written against a reference with no space — `(owner/repo#N)`, `owner/repo#N。`, Japanese text
21
+ // after the number — is trimmed off, so the anchored parsers below still see the reference itself.
22
+ const LEADING_NON_WORD = /^[^\w]+/u
23
+ const DIGIT = /\d/u
24
+
25
+ interface FileArguments {
26
+ title: string
27
+ body_file: string
28
+ depth: string
29
+ route: string | undefined
30
+ labels: ReadonlyArray<string>
31
+ repo: string | undefined
32
+ distinct: ReadonlyArray<number>
33
+ }
34
+
35
+ const OPTIONS = {
36
+ 'body-file': { type: 'string' },
37
+ depth: { type: 'string' },
38
+ route: { type: 'string' },
39
+ label: { type: 'string', multiple: true },
40
+ repo: { type: 'string' },
41
+ distinct: { type: 'string', multiple: true },
42
+ } as const
43
+
44
+ // The label a `--depth` / `--route` value names, or `undefined` when it names none — read against the
45
+ // label sets `issue-labels.ts` defines, so the command cannot apply a depth or route nobody provisions.
46
+ function label_of(
47
+ prefix: string,
48
+ value: string | undefined,
49
+ known: ReadonlyArray<string>,
50
+ ): string | undefined {
51
+ if (value === undefined) return undefined
52
+ const label = value.startsWith(prefix) ? value : `${prefix}${value}`
53
+
54
+ return known.includes(label) ? label : undefined
55
+ }
56
+
57
+ const ROUTE_NAMES: ReadonlyArray<string> = FILING_ROUTE_LABELS.map((label) => label.name)
58
+
59
+ // `--distinct 12,34 --distinct 56` → `[12, 34, 56]`; `undefined` when any entry is not an issue number,
60
+ // so a typo is refused rather than read as an acknowledgement of nothing.
61
+ function distinct_numbers(values: ReadonlyArray<string>): ReadonlyArray<number> | undefined {
62
+ const numbers = values
63
+ .flatMap((value) => value.split(LIST_SEPARATOR))
64
+ .map((raw) => {
65
+ return Number(raw.trim().replace('#', ''))
66
+ })
67
+
68
+ return numbers.every((number) => Number.isSafeInteger(number) && number > 0) ? numbers : undefined
69
+ }
70
+
71
+ type ParsedValues = ReturnType<typeof parseArgs<{ options: typeof OPTIONS }>>['values']
72
+
73
+ function route_of(values: ParsedValues): string | undefined | false {
74
+ if (values.route === undefined) return undefined
75
+
76
+ return label_of(ROUTE_PREFIX, values.route, ROUTE_NAMES) ?? false
77
+ }
78
+
79
+ function has_text(value: string | undefined): value is string {
80
+ return value !== undefined && value.trim() !== ''
81
+ }
82
+
83
+ // The labelled half of the arguments, or `undefined` when a depth, route or `--distinct` entry is not
84
+ // one the command knows.
85
+ function labelled_of(
86
+ values: ParsedValues,
87
+ ): Pick<FileArguments, 'depth' | 'route' | 'distinct'> | undefined {
88
+ const depth = label_of(DEPTH_PREFIX, values.depth, DEPTH_LABEL_ORDER)
89
+ const route = route_of(values)
90
+ const distinct = distinct_numbers(values.distinct ?? [])
91
+
92
+ if (route === false || depth === undefined || distinct === undefined) return undefined
93
+
94
+ return { depth, route, distinct }
95
+ }
96
+
97
+ function arguments_of(values: ParsedValues, title: string | undefined): FileArguments | undefined {
98
+ const labelled = labelled_of(values)
99
+ const body_file = values['body-file']
100
+
101
+ if (labelled === undefined || !has_text(title) || !has_text(body_file)) return undefined
102
+
103
+ return { title, body_file, ...labelled, labels: values.label ?? [], repo: values.repo }
104
+ }
105
+
106
+ // An unknown flag makes `parseArgs` throw; caught so the answer is the usage line, not a stack trace.
107
+ function parse(argv: ReadonlyArray<string>): FileArguments | undefined {
108
+ try {
109
+ const { values, positionals } = parseArgs({
110
+ args: [...argv],
111
+ options: OPTIONS,
112
+ allowPositionals: true,
113
+ })
114
+
115
+ return arguments_of(values, positionals[0])
116
+ } catch {
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ // A classification label is the body's to declare, so an extra `--label` naming one the body does not
122
+ // declare is dropped rather than applied — the label then always matches what `issue:lint` reads.
123
+ function is_undeclared_classification(label: string, body: string): boolean {
124
+ const is_classification = has_label_name(issue_classification.CLASSIFICATION_LABELS, label)
125
+
126
+ return is_classification && !has_label_name(issue_classification.required_labels(body), label)
127
+ }
128
+
129
+ // Every label the filing carries: the depth, the route when there is one, the classification labels
130
+ // the body declares (`issue_classification.labels_for`, which merges case-insensitively), then any
131
+ // extra `--label`. One request applies them all, so no label is a separate step to forget.
132
+ function labels_of(args: FileArguments, body: string): ReadonlyArray<string> {
133
+ const extra = args.labels.filter((label) => !is_undeclared_classification(label, body))
134
+ const declared = [args.depth, ...(args.route === undefined ? [] : [args.route]), ...extra]
135
+
136
+ return issue_classification.labels_for(body, declared)
137
+ }
138
+
139
+ function is_same_repository(target: string, current: string): boolean {
140
+ return target.toLowerCase() === current.toLowerCase()
141
+ }
142
+
143
+ // `owner/repo#N` or a github.com issue URL — the qualified forms the issue template's backlink section
144
+ // requires, never a bare `#N`, which resolves to whichever repository renders it. Read through the
145
+ // parsers that already know both forms rather than a third pattern.
146
+ function without_trailing_prose(text: string): string {
147
+ let end = text.length
148
+
149
+ while (end > 0 && !DIGIT.test(text.charAt(end - 1))) end -= 1
150
+
151
+ return text.slice(0, end)
152
+ }
153
+
154
+ function is_qualified_reference(word: string): boolean {
155
+ const bare = without_trailing_prose(word.replaceAll(CODE_SPAN, '').replace(LEADING_NON_WORD, ''))
156
+
157
+ return (
158
+ epic_issue.parse_epic_reference(bare)?.repo !== undefined ||
159
+ github_issue_url.parse(bare) !== undefined
160
+ )
161
+ }
162
+
163
+ function cites_origin(line: string): boolean {
164
+ return line.split(WORD_SEPARATOR).some((word) => is_qualified_reference(word))
165
+ }
166
+
167
+ // Why a cross-repository filing is refused, or `undefined` when it is not refused. A filing into the
168
+ // session's own repository owes no `## Origin`; one into another repository owes the section with a
169
+ // qualified reference back to the Issue that found it (`issue-template.md`, the backlink section).
170
+ function origin_problem(body: string, target: string, current: string): string | undefined {
171
+ if (is_same_repository(target, current)) return undefined
172
+ const lines = markdown_section.section_lines(body, issue_backlinks.ORIGIN_HEADING)
173
+
174
+ if (lines.some((line) => cites_origin(line))) return undefined
175
+
176
+ return `filing into ${target} from ${current} needs a \`${issue_backlinks.ORIGIN_HEADING}\` section citing the originating Issue as \`owner/repo#N\` or its URL`
177
+ }
178
+
179
+ // The duplicate candidates the caller has not declared separate. The scan cannot tell a duplicate from
180
+ // a neighbor — that is a reading of each candidate (`issue-fold-existing.md`) — so the command holds
181
+ // the filing until every candidate it printed is named back with `--distinct`.
182
+ function unacknowledged(
183
+ candidates: ReadonlyArray<number>,
184
+ distinct: ReadonlyArray<number>,
185
+ ): ReadonlyArray<number> {
186
+ return candidates.filter((candidate) => !distinct.includes(candidate))
187
+ }
188
+
189
+ const issue_file = { parse, labels_of, origin_problem, unacknowledged, is_same_repository }
190
+
191
+ export type { FileArguments }
192
+ export { issue_file }
@@ -58,7 +58,7 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
58
58
  process.exitCode = await run(argv[0])
59
59
  }
60
60
 
61
- const issue_lint_cli = { run, OK_MESSAGE, USAGE }
61
+ const issue_lint_cli = { problems, run, OK_MESSAGE, USAGE }
62
62
 
63
63
  const ARGV_OFFSET = 2
64
64