@perrylink/dsh-github 0.6.0 → 0.6.2

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.
@@ -1,138 +1,152 @@
1
- // Generates the dsh headless profile overlay and the task text for the
2
- // composite action. Zero-dependency Node: every input arrives through the
3
- // runner's INPUT_* environment variables (composite-action inputs), and every
4
- // value is emitted as a JSON string (a valid YAML scalar) so arbitrary user
5
- // input can never break the YAML structure.
6
- //
7
- // Writes to $INPUT_OUTPUT_DIR:
8
- // dsh-github-ci.cordis.yml — the --patch overlay (Minimal persona + role
9
- // line, model selection, sandbox, the minimal bash/editor toolset, and
10
- // the dsh-github row with the full ci.* configuration).
11
- // task.txt — the headless task for the selected mode.
12
- import { mkdirSync, writeFileSync } from 'node:fs'
13
- import { join } from 'node:path'
14
-
15
- const env = (name) => process.env[name] ?? ''
16
- const boolOf = (value, fallback) => value.trim() === '' ? fallback : value.trim() === 'true' || value.trim() === '1'
17
- const listOf = (value) => value.split(',').map(item => item.trim()).filter(item => item.length > 0)
18
- const y = (value) => JSON.stringify(value)
19
-
20
- const task = env('INPUT_TASK').trim() || 'review'
21
- const pr = env('INPUT_PR').trim()
22
- const ownerRepo = env('INPUT_OWNER_REPO').trim()
23
- const model = env('INPUT_MODEL').trim() || 'deepseek-v4-flash'
24
- const engine = env('INPUT_ENGINE').trim() === 'model' ? 'model' : 'static'
25
- const checkName = env('INPUT_CHECK_NAME').trim() || 'dsh-github-review'
26
- const blocking = boolOf(env('INPUT_BLOCKING'), true)
27
- const failOn = env('INPUT_FAIL_ON').trim() === 'warning' ? 'warning' : 'error'
28
- const labelFilters = listOf(env('INPUT_LABEL_FILTERS'))
29
- const pathFilters = listOf(env('INPUT_PATH_FILTERS'))
30
- const maxDiffChars = Number(env('INPUT_MAX_DIFF_CHARS')) > 0 ? Math.floor(Number(env('INPUT_MAX_DIFF_CHARS'))) : 8000
31
- const postComments = boolOf(env('INPUT_POST_COMMENTS'), true)
32
- const postCheck = boolOf(env('INPUT_POST_CHECK'), true)
33
- const requestTimeoutMs = Number(env('INPUT_REQUEST_TIMEOUT_MS')) > 0 ? Math.floor(Number(env('INPUT_REQUEST_TIMEOUT_MS'))) : 30000
34
- const outputDir = env('INPUT_OUTPUT_DIR').trim() || join(process.env.RUNNER_TEMP ?? '.', 'dsh-github')
35
- const customTask = env('INPUT_TASK_PROMPT').trim()
36
-
37
- if ((task === 'review' || task === 'fix-ci') && pr === '') {
38
- console.error('dsh-github: the `pr` input is required for the review and fix-ci tasks (defaults to github.event.pull_request.number on pull_request events)')
39
- process.exit(1)
40
- }
41
- if (ownerRepo === '') {
42
- console.error('dsh-github: the `owner-repo` input is required (defaults to github.repository)')
43
- process.exit(1)
44
- }
45
-
46
- mkdirSync(outputDir, { recursive: true })
47
-
48
- const callJson = (extra) => JSON.stringify({ task: extra.task, pr, ownerRepo, ...extra })
49
-
50
- let taskText
51
- if (customTask !== '') {
52
- taskText = customTask
53
- } else if (task === 'review' && engine === 'static') {
54
- taskText = `You are the dsh-github CI reviewer for this repository. Call the ci_run tool once with ${callJson({ task: 'review' })} and then reply with only the verdict and the summary line from its result.`
55
- } else if (task === 'review') {
56
- taskText = `You are a careful software engineer reviewing a GitHub pull request. First call ci_run with ${callJson({ task: 'analyze' })}. Review the diff yourself: correctness, security, style, and maintainability. Then call ci_run with ${callJson({ task: 'publish' })} plus a "body" holding your review (Markdown, verdict first, findings grouped by file) and a "findings" array for line-anchored problems you found. Finish by replying with only the verdict and the summary line from the publish result.`
57
- } else if (task === 'fix-ci') {
58
- taskText = `You are the dsh-github CI engineer for this repository. Investigate the CI failures of PR #${pr} in ${ownerRepo}: read the failing checks with gh_review (fields "ci"), inspect the code with gh_file and gh_repo, and use the bash tool only to read logs or run read-only diagnostics. Do NOT modify files. Write a fix plan as your final answer and call ci_run with ${callJson({ task: 'publish' })} plus a "body" holding that plan so it is posted as a review comment.`
59
- } else {
60
- taskText = `You are the dsh-github CI reporter for this repository. Generate a concise, factual report about ${ownerRepo}${pr !== '' ? ` PR #${pr}` : ''} using the read tools (gh_repo, gh_file, gh_review, gh_search). Write the report as your final answer and, if a pull request is being analyzed, call ci_run with ${callJson({ task: 'publish' })} plus a "body" holding the report so it is posted as a review comment.`
61
- }
62
-
63
- const personaText = 'You are a helpful software engineer assistant.\nYou are the dsh-github CI assistant for this repository.'
64
-
65
- const overlay = `# dsh-github CI overlay — generated by the composite action; every value is
66
- # emitted by scripts/action-patch.mjs from the action inputs. Do not edit.
67
- # Rows that already exist in dsh-base are replaced by id; new rows ride an
68
- # insert block (non-insert patches only replace existing rows).
69
- - id: persona
70
- name: '@deepseek-ai/dsh-persona'
71
- config:
72
- text: ${y(personaText)}
73
- complete: true
74
- includeRuntimeContext: false
75
-
76
- - id: agent-default-model
77
- name: '@deepseek-ai/dsh-agent-default-model'
78
- config:
79
- provider: deepseek-official
80
- model: ${y(model)}
81
-
82
- - id: llm-deepseek
83
- name: '@deepseek-ai/dsh-llm-deepseek'
84
- config:
85
- apiKeyEnv: DEEPSEEK_API_KEY
86
-
87
- - id: sandbox-policy
88
- name: '@deepseek-ai/dsh-sandbox-policy'
89
- config:
90
- mode: workspace-write
91
- workspaceRoot: !!js process.env.GITHUB_WORKSPACE ?? process.cwd()
92
-
93
- # The minimal toolset (persistent bash + editor), matching the official
94
- # minimal preset: enough to investigate CI failures and generate reports,
95
- # bounded by the workspace-write sandbox above.
96
- - insert:
97
- - id: terminal
98
- name: '@deepseek-ai/dsh-terminal'
99
-
100
- - id: terminal-bash
101
- name: '@deepseek-ai/dsh-terminal-bash'
102
- config:
103
- timeoutMs: 300000
104
-
105
- - id: persistent-bash
106
- name: '@deepseek-ai/dsh-tool-bash-persistent'
107
- config:
108
- timeoutMs: 300000
109
-
110
- - id: str-replace-editor
111
- name: '@deepseek-ai/dsh-tool-str-replace-editor'
112
- config:
113
- maxOutputChars: 16000
114
-
115
- - id: dsh-github
116
- name: '@perrylink/dsh-github'
117
- config:
118
- tokenSource: env
119
- tokenRef: DSH_GITHUB_TOKEN
120
- maxDiffChars: ${maxDiffChars}
121
- requestTimeoutMs: ${requestTimeoutMs}
122
- ci:
123
- enabled: true
124
- engine: ${y(engine)}
125
- autoApprove: ['ci.run']
126
- checkName: ${y(checkName)}
127
- blocking: ${blocking}
128
- failOn: ${y(failOn)}
129
- pollIntervalMs: 0
130
- labelFilters: ${y(labelFilters)}
131
- pathFilters: ${y(pathFilters)}
132
- postComments: ${postComments}
133
- reportDir: ${y(outputDir)}
134
- `
135
-
136
- writeFileSync(join(outputDir, 'dsh-github-ci.cordis.yml'), overlay)
137
- writeFileSync(join(outputDir, 'task.txt'), taskText + '\n')
138
- console.log(`dsh-github: overlay and task written to ${outputDir} (task: ${task}, engine: ${engine})`)
1
+ // Generates the dsh headless profile overlay and the task text for the
2
+ // composite action. Zero-dependency Node: every input arrives through the
3
+ // runner's INPUT_* environment variables (composite-action inputs), and every
4
+ // value is emitted as a JSON string (a valid YAML scalar) so arbitrary user
5
+ // input can never break the YAML structure.
6
+ //
7
+ // Writes to $INPUT_OUTPUT_DIR:
8
+ // dsh-github-ci.cordis.yml — the --patch overlay (Minimal persona + role
9
+ // line, model selection, sandbox, the minimal bash/editor toolset, and
10
+ // the dsh-github row with the full ci.* configuration).
11
+ // task.txt — the headless task for the selected mode.
12
+ import { mkdirSync, writeFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+
15
+ // Local-machine protection: the composite action resolves every path from the
16
+ // runner environment (RUNNER_TEMP for the output directory, GITHUB_WORKSPACE
17
+ // for the sandbox root). Outside a GitHub Actions runner those variables do
18
+ // not exist, and every fallback would silently write the profile overlay and
19
+ // task into the current working directory while a locally spawned `dsh`
20
+ // inherits the developer's real DSH_HOME. Refuse to run instead of guessing.
21
+ const runnerTemp = (process.env.RUNNER_TEMP ?? '').trim()
22
+ const workspace = (process.env.GITHUB_WORKSPACE ?? '').trim()
23
+ if (runnerTemp === '' || workspace === '') {
24
+ console.error('dsh-github: refusing to run outside a GitHub Actions runner — both RUNNER_TEMP and GITHUB_WORKSPACE must be set.')
25
+ console.error('dsh-github: to exercise the action locally, use `node scripts/local-test.mjs`; it pins DSH_HOME, DSH_PROFILE_DIR, and the output directory under the system temp directory.')
26
+ process.exit(1)
27
+ }
28
+
29
+ const env = (name) => process.env[name] ?? ''
30
+ const boolOf = (value, fallback) => value.trim() === '' ? fallback : value.trim() === 'true' || value.trim() === '1'
31
+ const listOf = (value) => value.split(',').map(item => item.trim()).filter(item => item.length > 0)
32
+ const y = (value) => JSON.stringify(value)
33
+
34
+ const task = env('INPUT_TASK').trim() || 'review'
35
+ const pr = env('INPUT_PR').trim()
36
+ const ownerRepo = env('INPUT_OWNER_REPO').trim()
37
+ const model = env('INPUT_MODEL').trim() || 'deepseek-v4-flash'
38
+ const engine = env('INPUT_ENGINE').trim() === 'model' ? 'model' : 'static'
39
+ const checkName = env('INPUT_CHECK_NAME').trim() || 'dsh-github-review'
40
+ const blocking = boolOf(env('INPUT_BLOCKING'), true)
41
+ const failOn = env('INPUT_FAIL_ON').trim() === 'warning' ? 'warning' : 'error'
42
+ const labelFilters = listOf(env('INPUT_LABEL_FILTERS'))
43
+ const pathFilters = listOf(env('INPUT_PATH_FILTERS'))
44
+ const maxDiffChars = Number(env('INPUT_MAX_DIFF_CHARS')) > 0 ? Math.floor(Number(env('INPUT_MAX_DIFF_CHARS'))) : 8000
45
+ const postComments = boolOf(env('INPUT_POST_COMMENTS'), true)
46
+ const postCheck = boolOf(env('INPUT_POST_CHECK'), true)
47
+ const requestTimeoutMs = Number(env('INPUT_REQUEST_TIMEOUT_MS')) > 0 ? Math.floor(Number(env('INPUT_REQUEST_TIMEOUT_MS'))) : 30000
48
+ const outputDir = env('INPUT_OUTPUT_DIR').trim() || join(process.env.RUNNER_TEMP ?? '.', 'dsh-github')
49
+ const customTask = env('INPUT_TASK_PROMPT').trim()
50
+
51
+ if ((task === 'review' || task === 'fix-ci') && pr === '') {
52
+ console.error('dsh-github: the `pr` input is required for the review and fix-ci tasks (defaults to github.event.pull_request.number on pull_request events)')
53
+ process.exit(1)
54
+ }
55
+ if (ownerRepo === '') {
56
+ console.error('dsh-github: the `owner-repo` input is required (defaults to github.repository)')
57
+ process.exit(1)
58
+ }
59
+
60
+ mkdirSync(outputDir, { recursive: true })
61
+
62
+ const callJson = (extra) => JSON.stringify({ task: extra.task, pr, ownerRepo, ...extra })
63
+
64
+ let taskText
65
+ if (customTask !== '') {
66
+ taskText = customTask
67
+ } else if (task === 'review' && engine === 'static') {
68
+ taskText = `You are the dsh-github CI reviewer for this repository. Call the ci_run tool once with ${callJson({ task: 'review' })} and then reply with only the verdict and the summary line from its result.`
69
+ } else if (task === 'review') {
70
+ taskText = `You are a careful software engineer reviewing a GitHub pull request. First call ci_run with ${callJson({ task: 'analyze' })}. Review the diff yourself: correctness, security, style, and maintainability. Then call ci_run with ${callJson({ task: 'publish' })} plus a "body" holding your review (Markdown, verdict first, findings grouped by file) and a "findings" array for line-anchored problems you found. Finish by replying with only the verdict and the summary line from the publish result.`
71
+ } else if (task === 'fix-ci') {
72
+ taskText = `You are the dsh-github CI engineer for this repository. Investigate the CI failures of PR #${pr} in ${ownerRepo}: read the failing checks with gh_review (fields "ci"), inspect the code with gh_file and gh_repo, and use the bash tool only to read logs or run read-only diagnostics. Do NOT modify files. Write a fix plan as your final answer and call ci_run with ${callJson({ task: 'publish' })} plus a "body" holding that plan so it is posted as a review comment.`
73
+ } else {
74
+ taskText = `You are the dsh-github CI reporter for this repository. Generate a concise, factual report about ${ownerRepo}${pr !== '' ? ` PR #${pr}` : ''} using the read tools (gh_repo, gh_file, gh_review, gh_search). Write the report as your final answer and, if a pull request is being analyzed, call ci_run with ${callJson({ task: 'publish' })} plus a "body" holding the report so it is posted as a review comment.`
75
+ }
76
+
77
+ const personaText = 'You are a helpful software engineer assistant.\nYou are the dsh-github CI assistant for this repository.'
78
+
79
+ const overlay = `# dsh-github CI overlay — generated by the composite action; every value is
80
+ # emitted by scripts/action-patch.mjs from the action inputs. Do not edit.
81
+ # Rows that already exist in dsh-base are replaced by id; new rows ride an
82
+ # insert block (non-insert patches only replace existing rows).
83
+ - id: persona
84
+ name: '@deepseek-ai/dsh-persona'
85
+ config:
86
+ text: ${y(personaText)}
87
+ complete: true
88
+ includeRuntimeContext: false
89
+
90
+ - id: agent-default-model
91
+ name: '@deepseek-ai/dsh-agent-default-model'
92
+ config:
93
+ provider: deepseek-official
94
+ model: ${y(model)}
95
+
96
+ - id: llm-deepseek
97
+ name: '@deepseek-ai/dsh-llm-deepseek'
98
+ config:
99
+ apiKeyEnv: DEEPSEEK_API_KEY
100
+
101
+ - id: sandbox-policy
102
+ name: '@deepseek-ai/dsh-sandbox-policy'
103
+ config:
104
+ mode: workspace-write
105
+ workspaceRoot: !!js process.env.GITHUB_WORKSPACE ?? process.cwd()
106
+
107
+ # The minimal toolset (persistent bash + editor), matching the official
108
+ # minimal preset: enough to investigate CI failures and generate reports,
109
+ # bounded by the workspace-write sandbox above.
110
+ - insert:
111
+ - id: terminal
112
+ name: '@deepseek-ai/dsh-terminal'
113
+
114
+ - id: terminal-bash
115
+ name: '@deepseek-ai/dsh-terminal-bash'
116
+ config:
117
+ timeoutMs: 300000
118
+
119
+ - id: persistent-bash
120
+ name: '@deepseek-ai/dsh-tool-bash-persistent'
121
+ config:
122
+ timeoutMs: 300000
123
+
124
+ - id: str-replace-editor
125
+ name: '@deepseek-ai/dsh-tool-str-replace-editor'
126
+ config:
127
+ maxOutputChars: 16000
128
+
129
+ - id: dsh-github
130
+ name: '@perrylink/dsh-github'
131
+ config:
132
+ tokenSource: env
133
+ tokenRef: DSH_GITHUB_TOKEN
134
+ maxDiffChars: ${maxDiffChars}
135
+ requestTimeoutMs: ${requestTimeoutMs}
136
+ ci:
137
+ enabled: true
138
+ engine: ${y(engine)}
139
+ autoApprove: ['ci.run']
140
+ checkName: ${y(checkName)}
141
+ blocking: ${blocking}
142
+ failOn: ${y(failOn)}
143
+ pollIntervalMs: 0
144
+ labelFilters: ${y(labelFilters)}
145
+ pathFilters: ${y(pathFilters)}
146
+ postComments: ${postComments}
147
+ reportDir: ${y(outputDir)}
148
+ `
149
+
150
+ writeFileSync(join(outputDir, 'dsh-github-ci.cordis.yml'), overlay)
151
+ writeFileSync(join(outputDir, 'task.txt'), taskText + '\n')
152
+ console.log(`dsh-github: overlay and task written to ${outputDir} (task: ${task}, engine: ${engine})`)
@@ -1,48 +1,61 @@
1
- // Post-process the CI run for the composite action: publish the step outputs
2
- // from dsh-github-ci-result.json and enforce the blocking gate.
3
- //
4
- // Exit codes:
5
- // 0 — report exists and the gate passed (or the verdict is non-blocking).
6
- // 1 — report missing (the headless run never produced one), or
7
- // blocking=true and the verdict is "needs-changes".
8
- // The status check itself (published by the pipeline) is what gates branch
9
- // protection; this exit code fails the CI job for blocking=false-style
10
- // workflow-level enforcement and for downstream status reporting.
11
- import { readFileSync, appendFileSync } from 'node:fs'
12
- import { join } from 'node:path'
13
-
14
- const outputDir = process.env.INPUT_OUTPUT_DIR?.trim() || join(process.env.RUNNER_TEMP ?? '.', 'dsh-github')
15
- const resultPath = join(outputDir, 'dsh-github-ci-result.json')
16
- const blocking = (process.env.INPUT_BLOCKING ?? 'true').trim() === 'true'
17
- const outputFile = process.env.GITHUB_OUTPUT ?? ''
18
-
19
- let result
20
- try {
21
- result = JSON.parse(readFileSync(resultPath, 'utf8'))
22
- } catch (error) {
23
- console.error(`dsh-github: no CI result at ${resultPath} — the headless run did not produce a review (${error instanceof Error ? error.message : String(error)}). Check the dsh stderr log next to it.`)
24
- if (outputFile !== '') appendFileSync(outputFile, 'verdict=error\n')
25
- process.exit(1)
26
- }
27
-
28
- const markdownPath = join(outputDir, 'dsh-github-ci-summary.md')
29
- const verdict = typeof result.verdict === 'string' ? result.verdict : 'error'
30
- if (outputFile !== '') {
31
- appendFileSync(outputFile,
32
- `verdict=${verdict}\n`
33
- + `report-json=${resultPath}\n`
34
- + `report-markdown=${markdownPath}\n`
35
- + `${result.checkRun?.url !== undefined ? `check-url=${result.checkRun.url}\n` : ''}`)
36
- }
37
-
38
- console.log(`dsh-github: verdict ${verdict} — PR #${result.pr} in ${result.repo}${result.alreadyReviewed ? ' (already reviewed at this head commit)' : ''}`)
39
- console.log(`dsh-github: ${result.summary}`)
40
-
41
- if (blocking && verdict === 'needs-changes') {
42
- console.error(`dsh-github: blocking gate failed — the review verdict is "needs-changes". See ${markdownPath}`)
43
- process.exit(1)
44
- }
45
- if (verdict === 'error') {
46
- console.error('dsh-github: the pipeline reported an error verdict')
47
- process.exit(1)
48
- }
1
+ // Post-process the CI run for the composite action: publish the step outputs
2
+ // from dsh-github-ci-result.json and enforce the blocking gate.
3
+ //
4
+ // Exit codes:
5
+ // 0 — report exists and the gate passed (or the verdict is non-blocking).
6
+ // 1 — report missing (the headless run never produced one), or
7
+ // blocking=true and the verdict is "needs-changes".
8
+ // The status check itself (published by the pipeline) is what gates branch
9
+ // protection; this exit code fails the CI job for blocking=false-style
10
+ // workflow-level enforcement and for downstream status reporting.
11
+ import { readFileSync, appendFileSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+
14
+ // Local-machine protection: the post step reads the CI result from the output
15
+ // directory and publishes step outputs through GITHUB_OUTPUT, both of which
16
+ // only exist on a GitHub Actions runner. Outside a runner the fallback would
17
+ // read an arbitrary `./dsh-github` directory next to the caller's cwd and
18
+ // report phantom verdicts. Refuse to run instead of guessing.
19
+ const runnerTemp = (process.env.RUNNER_TEMP ?? '').trim()
20
+ const workspace = (process.env.GITHUB_WORKSPACE ?? '').trim()
21
+ if (runnerTemp === '' || workspace === '') {
22
+ console.error('dsh-github: refusing to run outside a GitHub Actions runner — both RUNNER_TEMP and GITHUB_WORKSPACE must be set.')
23
+ console.error('dsh-github: to exercise the action locally, use `node scripts/local-test.mjs`; it pins DSH_HOME, DSH_PROFILE_DIR, and the output directory under the system temp directory.')
24
+ process.exit(1)
25
+ }
26
+
27
+ const outputDir = process.env.INPUT_OUTPUT_DIR?.trim() || join(process.env.RUNNER_TEMP ?? '.', 'dsh-github')
28
+ const resultPath = join(outputDir, 'dsh-github-ci-result.json')
29
+ const blocking = (process.env.INPUT_BLOCKING ?? 'true').trim() === 'true'
30
+ const outputFile = process.env.GITHUB_OUTPUT ?? ''
31
+
32
+ let result
33
+ try {
34
+ result = JSON.parse(readFileSync(resultPath, 'utf8'))
35
+ } catch (error) {
36
+ console.error(`dsh-github: no CI result at ${resultPath} — the headless run did not produce a review (${error instanceof Error ? error.message : String(error)}). Check the dsh stderr log next to it.`)
37
+ if (outputFile !== '') appendFileSync(outputFile, 'verdict=error\n')
38
+ process.exit(1)
39
+ }
40
+
41
+ const markdownPath = join(outputDir, 'dsh-github-ci-summary.md')
42
+ const verdict = typeof result.verdict === 'string' ? result.verdict : 'error'
43
+ if (outputFile !== '') {
44
+ appendFileSync(outputFile,
45
+ `verdict=${verdict}\n`
46
+ + `report-json=${resultPath}\n`
47
+ + `report-markdown=${markdownPath}\n`
48
+ + `${result.checkRun?.url !== undefined ? `check-url=${result.checkRun.url}\n` : ''}`)
49
+ }
50
+
51
+ console.log(`dsh-github: verdict ${verdict} — PR #${result.pr} in ${result.repo}${result.alreadyReviewed ? ' (already reviewed at this head commit)' : ''}`)
52
+ console.log(`dsh-github: ${result.summary}`)
53
+
54
+ if (blocking && verdict === 'needs-changes') {
55
+ console.error(`dsh-github: blocking gate failed — the review verdict is "needs-changes". See ${markdownPath}`)
56
+ process.exit(1)
57
+ }
58
+ if (verdict === 'error') {
59
+ console.error('dsh-github: the pipeline reported an error verdict')
60
+ process.exit(1)
61
+ }
@@ -16,11 +16,13 @@ import { dirname, join } from 'node:path'
16
16
  import { fileURLToPath } from 'node:url'
17
17
 
18
18
  const root = dirname(dirname(fileURLToPath(import.meta.url)))
19
- const FILES = ['README.md', 'README.zh-CN.md', 'README.es.md', 'README.pt.md', 'README.hi.md']
19
+ const FILES = ['README.md', 'README.zh.md', 'README.es.md', 'README.pt.md', 'README.hi.md']
20
20
 
21
21
  /** GitHub-slugger-compatible slug for one heading line (without the `## `). */
22
22
  function slug(heading) {
23
23
  return heading
24
+ .replace(/\r/g, '')
25
+ .trim()
24
26
  .toLowerCase()
25
27
  .replace(/\s+/g, '-')
26
28
  .replace(/[.,:;!?()'"·—•*]/g, '')