jackherd 1.16.0 → 1.17.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/CHANGELOG.md +22 -0
- package/INSTALL.md +2 -1
- package/README.md +22 -21
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +3 -2
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +77 -26
- package/src/check/skill-budgets.ts +58 -10
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
// Copy to scripts/sources/sentry.ts in your repository; the recipe recipes/sentry.md says what to fill in.
|
|
2
|
+
// The watcher shows this line with the exit code; it never carries the token or a URL.
|
|
3
|
+
const fail: (reason: string) => never = (reason) => {
|
|
4
|
+
console.log(`jackherd-reason: ${reason}`)
|
|
5
|
+
return process.exit(1)
|
|
6
|
+
}
|
|
7
|
+
|
|
2
8
|
const token = process.env.SENTRY_AUTH_TOKEN
|
|
3
|
-
if (!token)
|
|
9
|
+
if (!token) fail('SENTRY_AUTH_TOKEN not set')
|
|
4
10
|
|
|
5
11
|
const response = await fetch(
|
|
6
12
|
'https://<host>/api/0/projects/<org>/<project>/issues/?query=is:unresolved&statsPeriod=24h',
|
|
7
13
|
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) },
|
|
8
14
|
)
|
|
9
|
-
if (!response.ok)
|
|
15
|
+
if (!response.ok) fail(`HTTP ${response.status}`)
|
|
10
16
|
console.log(JSON.stringify(await response.json()))
|
|
11
17
|
|
|
12
18
|
export {}
|
package/recipes/sources.md
CHANGED
|
@@ -79,7 +79,7 @@ first run of this source: baseline, nothing reported; later a new or changed ent
|
|
|
79
79
|
watcher state not read or changed; a running watcher takes a new source over at its next cycle
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
**Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources
|
|
82
|
+
**Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources`, unless it carries `"repos": ["owner/name"]` (one to twenty, unique without regard to case): then it runs only where the main checkout's `origin` names one of them, and elsewhere the watcher notes `<name>: not for this repo` (`<name>: no origin` when the checkout has none). `repos` belongs in the personal file only; the repository's config refuses it. `jh watch source <name> --dry-run` of a left-out source prints the note and exits 2 without running anything. On a name clash the repository's source wins and the personal one is skipped with a note. A personal source runs with `~/.jackherd/` as its working directory, so its script is `~/.jackherd/sources/<name>.ts` and its command `["bun", "sources/<name>.ts"]`. `jh watch source <name> --dry-run` finds it too and prints `origin: personal, <path>`; a draft file runs as a repository source, in the main checkout and without the keychain.
|
|
83
83
|
|
|
84
84
|
**Keep a secret for a source.** `"secrets": ["NAME"]` (up to 10 names, `A-Z`, digits and `_`, starting with a letter) puts those variables into the environment of the source's command, and nowhere else. For a personal source each value comes from the keychain (service `jackherd`), else from the watcher's own environment; a repository source gets them from the watcher's environment only, never from the keychain. A value that is found nowhere fails the run with `secret missing: NAME`, an unreachable keychain with `secret store unavailable`; other sources go on. The script reads the value from `process.env` and never prints it.
|
|
85
85
|
|
|
@@ -91,6 +91,8 @@ jh secret delete NAME
|
|
|
91
91
|
|
|
92
92
|
**Show entries as content.** `"show": "entries"` opens the source's entries in a herd block of their own under "Your turn", headed by its `label`, instead of the folded row in `Sources`. For a source whose entries are the day's content, such as a calendar; not together with `ticket`.
|
|
93
93
|
|
|
94
|
-
The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`.
|
|
94
|
+
The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`. `deploy`, `feedback`, `previews` and `checks` follow the same way; a change that applies prints `config reloaded: <parts>`, an invalid one keeps every value and prints `config not reloaded: <file>: <reason>`. The board and the language are read when the watcher starts.
|
|
95
|
+
|
|
96
|
+
**Say why a source failed.** A failing command may print one stdout line `jackherd-reason: <reason>` (a status, a missing variable); the watcher shows the last such line as `exit 1: <reason>`, with the values of the source's `secrets` and URL credentials masked, cut to 160 characters. stderr is never read. Name no token in it.
|
|
95
97
|
|
|
96
98
|
Three limits stand: the watcher cannot make a command read only, so name only commands that read. A source in `.jackherd/config.json` runs for everyone who starts the watcher in the repository, at the same level of trust as `check.checks` and hooks. The watcher reads the config of the main checkout, never the one of a lane's worktree; a dry run started inside a worktree reads that worktree's config first. And `jh watch source … --dry-run` runs whatever command its draft names, so it never belongs in a permission allowlist.
|
|
@@ -143,6 +143,10 @@
|
|
|
143
143
|
"lockFile": {
|
|
144
144
|
"type": "string",
|
|
145
145
|
"pattern": "^(?![/-])(?!(?:.*\\/)?\\.\\.(?:\\/|$))[A-Za-z0-9._/-]+$"
|
|
146
|
+
},
|
|
147
|
+
"minFreeGb": {
|
|
148
|
+
"type": "number",
|
|
149
|
+
"exclusiveMinimum": 0
|
|
146
150
|
}
|
|
147
151
|
},
|
|
148
152
|
"additionalProperties": false
|
|
@@ -645,6 +649,15 @@
|
|
|
645
649
|
"show": {
|
|
646
650
|
"type": "string",
|
|
647
651
|
"const": "entries"
|
|
652
|
+
},
|
|
653
|
+
"repos": {
|
|
654
|
+
"minItems": 1,
|
|
655
|
+
"maxItems": 20,
|
|
656
|
+
"type": "array",
|
|
657
|
+
"items": {
|
|
658
|
+
"type": "string",
|
|
659
|
+
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
|
|
660
|
+
}
|
|
648
661
|
}
|
|
649
662
|
},
|
|
650
663
|
"required": [
|
|
@@ -699,6 +712,28 @@
|
|
|
699
712
|
"type": "integer",
|
|
700
713
|
"minimum": 1,
|
|
701
714
|
"maximum": 9
|
|
715
|
+
},
|
|
716
|
+
"standing": {
|
|
717
|
+
"type": "object",
|
|
718
|
+
"properties": {
|
|
719
|
+
"tools": {
|
|
720
|
+
"type": "object",
|
|
721
|
+
"propertyNames": {
|
|
722
|
+
"type": "string",
|
|
723
|
+
"pattern": "^mcp__[A-Za-z0-9_-]+__execute_sql$"
|
|
724
|
+
},
|
|
725
|
+
"additionalProperties": {
|
|
726
|
+
"type": "string",
|
|
727
|
+
"enum": [
|
|
728
|
+
"stage"
|
|
729
|
+
]
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
"required": [
|
|
734
|
+
"tools"
|
|
735
|
+
],
|
|
736
|
+
"additionalProperties": false
|
|
702
737
|
}
|
|
703
738
|
},
|
|
704
739
|
"additionalProperties": false
|
|
@@ -929,6 +964,38 @@
|
|
|
929
964
|
"maximum": 9007199254740991
|
|
930
965
|
}
|
|
931
966
|
},
|
|
967
|
+
"openBoxes": {
|
|
968
|
+
"default": "fail",
|
|
969
|
+
"type": "string",
|
|
970
|
+
"enum": [
|
|
971
|
+
"fail",
|
|
972
|
+
"warn",
|
|
973
|
+
"off"
|
|
974
|
+
]
|
|
975
|
+
},
|
|
976
|
+
"lintPointers": {
|
|
977
|
+
"type": "object",
|
|
978
|
+
"properties": {
|
|
979
|
+
"level": {
|
|
980
|
+
"default": "warn",
|
|
981
|
+
"type": "string",
|
|
982
|
+
"enum": [
|
|
983
|
+
"warn",
|
|
984
|
+
"fail",
|
|
985
|
+
"off"
|
|
986
|
+
]
|
|
987
|
+
},
|
|
988
|
+
"exclude": {
|
|
989
|
+
"default": [],
|
|
990
|
+
"type": "array",
|
|
991
|
+
"items": {
|
|
992
|
+
"type": "string",
|
|
993
|
+
"minLength": 1
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
"additionalProperties": false
|
|
998
|
+
},
|
|
932
999
|
"looseTests": {
|
|
933
1000
|
"default": false,
|
|
934
1001
|
"type": "boolean"
|
|
@@ -126,6 +126,15 @@
|
|
|
126
126
|
"show": {
|
|
127
127
|
"type": "string",
|
|
128
128
|
"const": "entries"
|
|
129
|
+
},
|
|
130
|
+
"repos": {
|
|
131
|
+
"minItems": 1,
|
|
132
|
+
"maxItems": 20,
|
|
133
|
+
"type": "array",
|
|
134
|
+
"items": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"pattern": "^[\\w.-]+\\/[\\w.-]+$"
|
|
137
|
+
}
|
|
129
138
|
}
|
|
130
139
|
},
|
|
131
140
|
"required": [
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { terminalSafe } from '../plan-format/terminal-text'
|
|
3
|
+
import { isRecord } from '../shared/is-record'
|
|
4
|
+
import type { ApprovalRequestFile } from '../watch/approvals'
|
|
5
|
+
|
|
6
|
+
/** What `jh approval show|checked` prints and how it ends: 0 shown, 1 wrong arguments, 2 no such open request. */
|
|
7
|
+
export type ApprovalReport = { exitCode: number; stdout: string[]; stderr: string[] }
|
|
8
|
+
|
|
9
|
+
type RequestCall = Pick<ApprovalRequestFile, 'lane' | 'cwd' | 'tool' | 'input'>
|
|
10
|
+
|
|
11
|
+
const VERBS = new Set(['show', 'checked'])
|
|
12
|
+
const USAGE = 'Usage: jh approval show|checked <id> <hash>'
|
|
13
|
+
const ID = /^[0-9a-f]{12}$/
|
|
14
|
+
const HASH = /^[0-9a-f]{16}$/
|
|
15
|
+
|
|
16
|
+
/** JSON with the keys of every object sorted, as the approval gate hook hashes a call. */
|
|
17
|
+
const canonicalJson = (value: unknown): string => {
|
|
18
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
19
|
+
if (isRecord(value))
|
|
20
|
+
return `{${Object.keys(value)
|
|
21
|
+
.sort()
|
|
22
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
23
|
+
.join(',')}}`
|
|
24
|
+
return JSON.stringify(value) ?? 'null'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The hash of a call as the approval gate hook writes it (`callHash`, which this package does not import; contract test). */
|
|
28
|
+
export const requestHash = ({ lane, cwd, tool, input }: RequestCall): string =>
|
|
29
|
+
createHash('sha256').update(canonicalJson({ lane, cwd, tool, input })).digest('hex').slice(0, 16)
|
|
30
|
+
|
|
31
|
+
const refused = (exitCode: number, line: string): ApprovalReport => ({ exitCode, stdout: [], stderr: [line] })
|
|
32
|
+
|
|
33
|
+
/** The request as the coordinator reads it before it marks it: every field the lane wrote, made safe to print. */
|
|
34
|
+
const shown = ({ id, hash, lane, tool, cwd, input }: ApprovalRequestFile): string[] => [
|
|
35
|
+
`approval ${id} ${hash}`,
|
|
36
|
+
`lane: ${terminalSafe(lane)}`,
|
|
37
|
+
`tool: ${terminalSafe(tool)}`,
|
|
38
|
+
`cwd: ${terminalSafe(cwd)}`,
|
|
39
|
+
'input:',
|
|
40
|
+
...(JSON.stringify(input, null, 2) ?? 'null').split('\n').map(terminalSafe),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `jh approval show <id> <hash>` and `jh approval checked <id> <hash>`: print the open request whose id and hash these
|
|
45
|
+
* are, and whose content its hash covers, and end with 0; otherwise say why and end with 2. Neither writes anything.
|
|
46
|
+
* `show` is for reading; a successful `checked` in the coordinator's transcript is the mark the approval gate hook
|
|
47
|
+
* reads as "the coordinator checked exactly this call", so it comes after the check, never before.
|
|
48
|
+
*/
|
|
49
|
+
export const approvalReport = (args: readonly string[], requests: readonly ApprovalRequestFile[]): ApprovalReport => {
|
|
50
|
+
const [verb, id, hash, ...rest] = args
|
|
51
|
+
if (!VERBS.has(verb ?? '') || !ID.test(id ?? '') || !HASH.test(hash ?? '') || rest.length > 0)
|
|
52
|
+
return refused(1, USAGE)
|
|
53
|
+
const request = requests.find((candidate) => candidate.id === id && candidate.usedAt === null)
|
|
54
|
+
if (request === undefined) return refused(2, `no open request ${id}`)
|
|
55
|
+
if (request.hash !== hash)
|
|
56
|
+
return refused(2, `request ${id} has hash ${request.hash}, not ${hash}: it changed, look at it again`)
|
|
57
|
+
if (requestHash(request) !== hash)
|
|
58
|
+
return refused(2, `request ${id} does not match its hash: it was changed by hand, ask the user`)
|
|
59
|
+
return { exitCode: 0, stdout: shown(request), stderr: [] }
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { mainCheckout } from '../plans/git'
|
|
2
|
+
import { readApprovalRequests } from '../watch/approvals'
|
|
3
|
+
import { approvalReport } from './checked'
|
|
4
|
+
|
|
5
|
+
/** `jh approval show|checked <id> <hash>`: read a held call, and the coordinator's mark that it checked it (`approvalReport`). */
|
|
6
|
+
export const run = (args: string[]): number => {
|
|
7
|
+
try {
|
|
8
|
+
const report = approvalReport(args, readApprovalRequests(mainCheckout(process.cwd())))
|
|
9
|
+
for (const line of report.stdout) console.log(line)
|
|
10
|
+
for (const line of report.stderr) console.error(line)
|
|
11
|
+
return report.exitCode
|
|
12
|
+
} catch (error) {
|
|
13
|
+
console.error(`jh approval: ${error instanceof Error ? error.message : String(error)}`)
|
|
14
|
+
return 1
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/check/command.ts
CHANGED
|
@@ -3,17 +3,21 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import { planRootsIn } from '../../kit/hooks/hooks/shared/boards'
|
|
6
|
+
import { laneOf } from '../../kit/hooks/hooks/shared/lane'
|
|
6
7
|
import { findProjectRoot, globalConfigPath, locateConfigFile, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
|
|
7
8
|
import { readRepositoryFacts } from '../config/repository-facts'
|
|
9
|
+
import { installedReferenceHint } from '../lane/skill-hint'
|
|
8
10
|
import { mainCheckout } from '../plans/git'
|
|
9
11
|
import { baseRefOf } from '../plans/settings'
|
|
12
|
+
import { REFERENCE } from '../watch/reference-paths'
|
|
10
13
|
import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
|
|
11
14
|
import { deferralArrowCheck } from './deferral-arrows'
|
|
12
15
|
import { runCheck, runProcess } from './execute'
|
|
13
16
|
import { matchesAnyGlob } from './glob'
|
|
17
|
+
import { lintPointerCheck } from './lint-pointers'
|
|
14
18
|
import { parallelArgument, parallelReportLines } from './parallel-report'
|
|
15
19
|
import { type CheckPlan, type CheckStack, checkStackOf, type Filesystem, isSlow, planChecks } from './plan'
|
|
16
|
-
import { planStructureChecks } from './plan-structure-check'
|
|
20
|
+
import { laneOpenBoxesCheck, planStructureChecks } from './plan-structure-check'
|
|
17
21
|
import { publicContentCheck } from './public-content-check'
|
|
18
22
|
import {
|
|
19
23
|
artifactResult,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
} from './report'
|
|
30
34
|
import { type CheckResult, RUN_MODE, type RunMode, runModeOf } from './result'
|
|
31
35
|
import { isWorkspaceManifest, kitCopyPaths, WORKSPACE_LIST_FILE, WORKSPACE_MANIFEST } from './roots'
|
|
36
|
+
import { DEFAULT_LINT_POINTERS } from './schema'
|
|
32
37
|
import { type CheckSettings, CONFIG_PATH, noStackFallbackOf, parseCheckSettings, stackOf } from './settings'
|
|
33
38
|
import { skillBudgetCheck } from './skill-budgets'
|
|
34
39
|
|
|
@@ -199,11 +204,50 @@ const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
|
|
|
199
204
|
}
|
|
200
205
|
}
|
|
201
206
|
|
|
202
|
-
/** The
|
|
203
|
-
const
|
|
207
|
+
/** The lane a checkout is for: the first directory below the main checkout's worktree directory; null outside one. */
|
|
208
|
+
const laneOfCheckout = (root: string, worktreeDirectory: string): string | null => {
|
|
209
|
+
try {
|
|
210
|
+
return laneOf({ cwd: root, main: mainCheckout(root), worktreeDirectory })
|
|
211
|
+
} catch {
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The open boxes of the lane's phase, left out for a checkout that is no lane's. */
|
|
217
|
+
const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult | null => {
|
|
218
|
+
const lane = laneOfCheckout(root, settings.worktreeDirectory)
|
|
219
|
+
return lane === null
|
|
220
|
+
? null
|
|
221
|
+
: laneOpenBoxesCheck({
|
|
222
|
+
root,
|
|
223
|
+
lane,
|
|
224
|
+
planDirectory: settings.planDirectory,
|
|
225
|
+
board: settings.board,
|
|
226
|
+
language: settings.planLanguage,
|
|
227
|
+
level: settings.check.openBoxes,
|
|
228
|
+
baseOf: contentAt(root, baseRefOf(settings)),
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The checks jackherd runs itself beside the configured ones: public content, deferral arrows, skill budgets, lint pointers, plan structure. */
|
|
233
|
+
const ownChecksOf = (
|
|
234
|
+
root: string,
|
|
235
|
+
paths: readonly string[],
|
|
236
|
+
settings: CheckSettings,
|
|
237
|
+
home: string,
|
|
238
|
+
kitCopies: readonly string[],
|
|
239
|
+
) => [
|
|
240
|
+
openBoxesCheckOf(root, settings),
|
|
204
241
|
publicContentCheck(root, paths, settings.check.publicContent, home),
|
|
205
242
|
deferralArrowCheck(root, paths, settings.planDirectory),
|
|
206
243
|
skillBudgetCheck(root, settings.check.skillBudgets, settings.skillPrefix),
|
|
244
|
+
lintPointerCheck(
|
|
245
|
+
root,
|
|
246
|
+
paths,
|
|
247
|
+
settings.check.lintPointers ?? DEFAULT_LINT_POINTERS,
|
|
248
|
+
kitCopies,
|
|
249
|
+
contentAt(root, 'HEAD'),
|
|
250
|
+
),
|
|
207
251
|
...planStructureChecks({
|
|
208
252
|
root,
|
|
209
253
|
paths,
|
|
@@ -228,8 +272,9 @@ const checkChanges = async (
|
|
|
228
272
|
): Promise<CheckResult[] | string> => {
|
|
229
273
|
const stack = checkStackOf(stackOf(settings.stack))
|
|
230
274
|
let plan: CheckPlan
|
|
275
|
+
let kitCopies: string[]
|
|
231
276
|
try {
|
|
232
|
-
|
|
277
|
+
kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
|
|
233
278
|
plan = planChecks(
|
|
234
279
|
files,
|
|
235
280
|
{
|
|
@@ -253,7 +298,7 @@ const checkChanges = async (
|
|
|
253
298
|
testCommandResult(settings.commands.test, settings.stack),
|
|
254
299
|
missingTestCommandResult(settings.commands.test, settings.stack),
|
|
255
300
|
namedTestResult(plan.sourcesWithoutNamedTest),
|
|
256
|
-
...ownChecksOf(root, plan.paths, settings, home),
|
|
301
|
+
...ownChecksOf(root, plan.paths, settings, home, kitCopies),
|
|
257
302
|
])
|
|
258
303
|
}
|
|
259
304
|
|
|
@@ -282,7 +327,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
|
|
|
282
327
|
}
|
|
283
328
|
print(settings.notes)
|
|
284
329
|
if (files.length === 0) {
|
|
285
|
-
console.log(
|
|
330
|
+
console.log(`Nothing changed.${installedReferenceHint(root, REFERENCE.events, home)}`)
|
|
286
331
|
return 0
|
|
287
332
|
}
|
|
288
333
|
const results = await checkChanges(files, root, settings, runModeOf(args), home)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { matchesAnyGlob } from './glob'
|
|
4
|
+
import {
|
|
5
|
+
changedBlocksOf,
|
|
6
|
+
LINT_MARKER,
|
|
7
|
+
LINT_NONE,
|
|
8
|
+
LINT_POINTER_STATE,
|
|
9
|
+
LINT_POINTERS_NAME,
|
|
10
|
+
type LintFinding,
|
|
11
|
+
lintFindingsOf,
|
|
12
|
+
} from './lint-shaped'
|
|
13
|
+
import { CHECK_STATUS, type CheckResult } from './result'
|
|
14
|
+
import { LINT_POINTERS_LEVEL, type LintPointersConfig, type LintPointersLevel } from './schema'
|
|
15
|
+
|
|
16
|
+
/** The texts a project writes its rules in: rule files and the instruction files. Skills carry commands, the hooks' field. */
|
|
17
|
+
const LINT_POINTER_GLOBS = ['**/rules/**/*.md', '**/CLAUDE.md', '**/AGENTS.md'] as const
|
|
18
|
+
|
|
19
|
+
export const isLintPointerPath = (path: string): boolean => matchesAnyGlob(path, LINT_POINTER_GLOBS)
|
|
20
|
+
|
|
21
|
+
type PathFinding = LintFinding & { path: string }
|
|
22
|
+
|
|
23
|
+
const WAY_THROUGH = `Write the lint rule and name it with \`${LINT_MARKER}<rule id>\`, or write \`${LINT_MARKER}${LINT_NONE} <reason>\` where it stays prose.`
|
|
24
|
+
|
|
25
|
+
const HINTS = {
|
|
26
|
+
[LINT_POINTER_STATE.missing]: '',
|
|
27
|
+
[LINT_POINTER_STATE.noId]: ` (\`${LINT_MARKER}\` names no rule id)`,
|
|
28
|
+
[LINT_POINTER_STATE.noReason]: ` (\`${LINT_MARKER}${LINT_NONE}\` needs a reason)`,
|
|
29
|
+
[LINT_POINTER_STATE.pointed]: '',
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
const lineOf = ({ path, line, shape, excerpt, state }: PathFinding): string =>
|
|
33
|
+
`${path}:${line} ${shape}: "${excerpt}"${HINTS[state]}`
|
|
34
|
+
|
|
35
|
+
const detailOf = (findings: readonly PathFinding[]): string =>
|
|
36
|
+
findings.length === 0
|
|
37
|
+
? 'every lint-shaped rule block points at its lint rule'
|
|
38
|
+
: [...findings.map(lineOf), WAY_THROUGH].join('\n')
|
|
39
|
+
|
|
40
|
+
const statusOf = (findings: readonly PathFinding[], level: LintPointersLevel): CheckResult['status'] => {
|
|
41
|
+
if (findings.length === 0) return CHECK_STATUS.pass
|
|
42
|
+
return level === LINT_POINTERS_LEVEL.fail ? CHECK_STATUS.fail : CHECK_STATUS.warn
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The new or changed blocks of the changed rule files that carry a rule a linter could check (an import path,
|
|
47
|
+
* a literal comparison, a comment form, a banned call) and name no lint rule. Kit copies and `exclude` are not
|
|
48
|
+
* read; `previousOf` answers a path with the file as `HEAD` holds it. Null when no such file changed or the level is off.
|
|
49
|
+
*/
|
|
50
|
+
export const lintPointerCheck = (
|
|
51
|
+
root: string,
|
|
52
|
+
paths: readonly string[],
|
|
53
|
+
settings: LintPointersConfig,
|
|
54
|
+
kitCopies: readonly string[],
|
|
55
|
+
previousOf: (path: string) => string | null,
|
|
56
|
+
): CheckResult | null => {
|
|
57
|
+
const files = paths.filter(
|
|
58
|
+
(path) =>
|
|
59
|
+
isLintPointerPath(path) &&
|
|
60
|
+
!kitCopies.includes(path) &&
|
|
61
|
+
!matchesAnyGlob(path, settings.exclude) &&
|
|
62
|
+
existsSync(join(root, path)),
|
|
63
|
+
)
|
|
64
|
+
if (settings.level === LINT_POINTERS_LEVEL.off || files.length === 0) return null
|
|
65
|
+
const started = Date.now()
|
|
66
|
+
const findings = files.flatMap((path) =>
|
|
67
|
+
lintFindingsOf(changedBlocksOf(readFileSync(join(root, path), 'utf8'), previousOf(path))).map((found) => ({
|
|
68
|
+
...found,
|
|
69
|
+
path,
|
|
70
|
+
})),
|
|
71
|
+
)
|
|
72
|
+
return {
|
|
73
|
+
name: LINT_POINTERS_NAME,
|
|
74
|
+
command: `${files.length} changed rule ${files.length === 1 ? 'file' : 'files'}, new or changed blocks`,
|
|
75
|
+
status: statusOf(findings, settings.level),
|
|
76
|
+
detail: detailOf(findings),
|
|
77
|
+
durationMs: Date.now() - started,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/** The `Lint pointers` result name; the kit texts that name the check are held against it. */
|
|
2
|
+
export const LINT_POINTERS_NAME = 'Lint pointers'
|
|
3
|
+
|
|
4
|
+
/** The inline-code prefix of a pointer: `` `lint:<rule id>` `` names the linter rule that checks the block's rule. */
|
|
5
|
+
export const LINT_MARKER = 'lint:'
|
|
6
|
+
|
|
7
|
+
/** `` `lint:none` `` plus a reason: the rule stays prose on purpose. */
|
|
8
|
+
export const LINT_NONE = 'none'
|
|
9
|
+
|
|
10
|
+
/** The kinds of rule a linter can check. */
|
|
11
|
+
export const LINT_SHAPE = {
|
|
12
|
+
importPath: 'import path',
|
|
13
|
+
literalComparison: 'literal comparison',
|
|
14
|
+
commentForm: 'comment form',
|
|
15
|
+
bannedCall: 'banned call',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export type LintShape = (typeof LINT_SHAPE)[keyof typeof LINT_SHAPE]
|
|
19
|
+
|
|
20
|
+
/** Whether and how a block points at its lint rule. */
|
|
21
|
+
export const LINT_POINTER_STATE = {
|
|
22
|
+
pointed: 'pointed',
|
|
23
|
+
missing: 'missing',
|
|
24
|
+
noId: 'noId',
|
|
25
|
+
noReason: 'noReason',
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
export type LintPointerState = (typeof LINT_POINTER_STATE)[keyof typeof LINT_POINTER_STATE]
|
|
29
|
+
|
|
30
|
+
export type Block = { line: number; text: string }
|
|
31
|
+
|
|
32
|
+
export type LintFinding = { line: number; shape: LintShape; state: LintPointerState; excerpt: string }
|
|
33
|
+
|
|
34
|
+
const EXCERPT_LENGTH = 100
|
|
35
|
+
|
|
36
|
+
const GAP = 40
|
|
37
|
+
const BEFORE_SIGNAL = new RegExp(
|
|
38
|
+
`\\b(?:never|no|not|don't|avoid|instead of|forbidden)\\b([\\p{L}\\p{N}\\s,'’/*()-]{0,${GAP}})\`([^\`\\n]+)\``,
|
|
39
|
+
'giu',
|
|
40
|
+
)
|
|
41
|
+
const AFTER_SIGNAL = /`([^`\n]+)`[^`\n]{0,25}\b(?:forbidden|banned|refused|never|not allowed|stay out)\b/gi
|
|
42
|
+
const POINTER_SPAN = new RegExp(`\`${LINT_MARKER}([^\`\\n]*)\``, 'g')
|
|
43
|
+
const EXAMPLE_MARK = '❌'
|
|
44
|
+
const IMPORT_WORD = /\b(?:imports?|imported|importing|require)\b/i
|
|
45
|
+
const SCOPED_MODULE = /^(?:@[\w.-]+\/[\w./-]+|~\/[\w./-]+|\.{1,2}\/[\w./-]+)$/
|
|
46
|
+
const PLAIN_MODULE = /^[\w.-]+\/[\w./-]+$/
|
|
47
|
+
const COMPARISON = /(?:===?|!==?)\s*['"`][^'"`]+['"`]/
|
|
48
|
+
const COMMENT_SPAN = /^(?:\/\/|\/\*|<!--|#(?:\s|$)|--(?:\s|$))/
|
|
49
|
+
const CALL_SPAN = /^(?:[\w$]+(?:\.[\w$]+)*|\.[\w$]+)\(/
|
|
50
|
+
const INLINE_CODE = /`([^`\n]+)`/g
|
|
51
|
+
const FENCE = /^\s*```/
|
|
52
|
+
const HEADING = /^#{1,6}\s/
|
|
53
|
+
const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/
|
|
54
|
+
const TABLE_ROW = /^\s*\|/
|
|
55
|
+
const WORD = /[\p{L}\p{N}]{2,}/u
|
|
56
|
+
|
|
57
|
+
const spansOf = (text: string): string[] => [...text.matchAll(INLINE_CODE)].map(([, span]) => span.trim())
|
|
58
|
+
|
|
59
|
+
const frontmatterEnd = (lines: readonly string[]): number => {
|
|
60
|
+
if (lines[0]?.trim() !== '---') return 0
|
|
61
|
+
const close = lines.findIndex((line, index) => index > 0 && line.trim() === '---')
|
|
62
|
+
return close === -1 ? 0 : close + 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isSingleLine = (line: string): boolean => HEADING.test(line) || TABLE_ROW.test(line)
|
|
66
|
+
|
|
67
|
+
const startsBlock = (line: string): boolean => isSingleLine(line) || LIST_ITEM.test(line)
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The blocks of a Markdown text: a paragraph, a list item with its continuation lines, a table row or a
|
|
71
|
+
* heading, outside code. A code block belongs to the block before it; the frontmatter falls away. `line` is 1-based.
|
|
72
|
+
*/
|
|
73
|
+
export const blocksOf = (markdown: string): Block[] => {
|
|
74
|
+
const lines = markdown.split('\n')
|
|
75
|
+
const blocks: { line: number; lines: string[] }[] = []
|
|
76
|
+
let continuing = false
|
|
77
|
+
let inFence = false
|
|
78
|
+
const open = (index: number, line: string) => {
|
|
79
|
+
blocks.push({ line: index + 1, lines: [line] })
|
|
80
|
+
continuing = !isSingleLine(line)
|
|
81
|
+
}
|
|
82
|
+
for (let index = frontmatterEnd(lines); index < lines.length; index++) {
|
|
83
|
+
const line = lines[index]
|
|
84
|
+
const last = blocks.at(-1)
|
|
85
|
+
if (inFence) {
|
|
86
|
+
last?.lines.push(line)
|
|
87
|
+
inFence = !FENCE.test(line)
|
|
88
|
+
} else if (FENCE.test(line)) {
|
|
89
|
+
if (last) last.lines.push(line)
|
|
90
|
+
else open(index, line)
|
|
91
|
+
inFence = true
|
|
92
|
+
} else if (line.trim() === '') continuing = false
|
|
93
|
+
else if (startsBlock(line) || !continuing) open(index, line)
|
|
94
|
+
else last?.lines.push(line)
|
|
95
|
+
}
|
|
96
|
+
return blocks.map(({ line, lines: blockLines }) => ({ line, text: blockLines.join('\n') }))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const splitByFence = (text: string): { prose: string[]; code: string[] } => {
|
|
100
|
+
const split = { prose: [] as string[], code: [] as string[] }
|
|
101
|
+
let inFence = false
|
|
102
|
+
for (const line of text.split('\n')) {
|
|
103
|
+
if (FENCE.test(line)) inFence = !inFence
|
|
104
|
+
else (inFence ? split.code : split.prose).push(line)
|
|
105
|
+
}
|
|
106
|
+
return split
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const proseLinesOf = (text: string): string[] => splitByFence(text).prose
|
|
110
|
+
|
|
111
|
+
const codeLinesOf = (text: string): string[] => splitByFence(text).code
|
|
112
|
+
|
|
113
|
+
/** An inline-code span a rule forbids, the words between the signal and the span, and whether the signal followed it. */
|
|
114
|
+
type ForbiddenSpan = { span: string; gap: string; trailing: boolean }
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The spans a block marks as forbidden: right after a signal word (`never`, `no`, `not`, `avoid` …, at most
|
|
118
|
+
* {@link GAP} characters of plain words in between), before `forbidden` or `banned`, or on a line with a `❌`.
|
|
119
|
+
* A span far from every signal belongs to a sentence that merely mentions it.
|
|
120
|
+
*/
|
|
121
|
+
const forbiddenSpansOf = (text: string): ForbiddenSpan[] => {
|
|
122
|
+
const prose = proseLinesOf(text)
|
|
123
|
+
const marked = prose.filter((line) => line.includes(EXAMPLE_MARK)).join('\n')
|
|
124
|
+
const proseText = prose.join('\n')
|
|
125
|
+
return [
|
|
126
|
+
...[...proseText.matchAll(BEFORE_SIGNAL)].map(([, gap, span]) => ({ span: span.trim(), gap, trailing: false })),
|
|
127
|
+
...[...proseText.matchAll(AFTER_SIGNAL)].map(([, span]) => ({ span: span.trim(), gap: '', trailing: true })),
|
|
128
|
+
...spansOf(marked).map((span) => ({ span, gap: '', trailing: false })),
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isModule = ({ span, gap, trailing }: ForbiddenSpan, text: string): boolean =>
|
|
133
|
+
(SCOPED_MODULE.test(span) && (trailing || IMPORT_WORD.test(text))) ||
|
|
134
|
+
(PLAIN_MODULE.test(span) && IMPORT_WORD.test(gap))
|
|
135
|
+
|
|
136
|
+
const markedCodeComparison = (text: string): boolean =>
|
|
137
|
+
codeLinesOf(text).some((line) => line.includes(EXAMPLE_MARK) && COMPARISON.test(line))
|
|
138
|
+
|
|
139
|
+
const hasShape = {
|
|
140
|
+
[LINT_SHAPE.importPath]: (text: string, forbidden: readonly ForbiddenSpan[]) =>
|
|
141
|
+
forbidden.some((entry) => isModule(entry, text)),
|
|
142
|
+
[LINT_SHAPE.literalComparison]: (text: string, forbidden: readonly ForbiddenSpan[]) =>
|
|
143
|
+
forbidden.some(({ span }) => COMPARISON.test(span)) || markedCodeComparison(text),
|
|
144
|
+
[LINT_SHAPE.commentForm]: (_text: string, forbidden: readonly ForbiddenSpan[]) =>
|
|
145
|
+
forbidden.some(({ span }) => COMMENT_SPAN.test(span)),
|
|
146
|
+
[LINT_SHAPE.bannedCall]: (_text: string, forbidden: readonly ForbiddenSpan[]) =>
|
|
147
|
+
forbidden.some(({ span }) => CALL_SPAN.test(span)),
|
|
148
|
+
} as const
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The kinds of lint-checkable rule a block carries: an inline-code span of that kind that a signal word
|
|
152
|
+
* forbids, or, for a literal comparison, a code line marked `❌`.
|
|
153
|
+
*/
|
|
154
|
+
export const lintShapesOf = (text: string): LintShape[] => {
|
|
155
|
+
const forbidden = forbiddenSpansOf(text)
|
|
156
|
+
return Object.values(LINT_SHAPE).filter((shape) => hasShape[shape](text, forbidden))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const isNoneMarker = (id: string): boolean => id === LINT_NONE || id.startsWith(`${LINT_NONE} `)
|
|
160
|
+
|
|
161
|
+
const pointerStateOfMatch = (match: RegExpMatchArray, text: string): LintPointerState => {
|
|
162
|
+
const id = match[1].trim()
|
|
163
|
+
if (id === '') return LINT_POINTER_STATE.noId
|
|
164
|
+
if (!isNoneMarker(id)) return LINT_POINTER_STATE.pointed
|
|
165
|
+
const reason = `${id.slice(LINT_NONE.length)} ${text.slice((match.index ?? 0) + match[0].length)}`
|
|
166
|
+
return WORD.test(reason) ? LINT_POINTER_STATE.pointed : LINT_POINTER_STATE.noReason
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Whether a block names its lint rule: `` `lint:<id>` ``, or `` `lint:none` `` with a reason in the block. */
|
|
170
|
+
export const lintPointerStateOf = (text: string): LintPointerState => {
|
|
171
|
+
const marked = [...text.matchAll(POINTER_SPAN)]
|
|
172
|
+
if (marked.length === 0) return LINT_POINTER_STATE.missing
|
|
173
|
+
const states = marked.map((match) => pointerStateOfMatch(match, text))
|
|
174
|
+
return states.includes(LINT_POINTER_STATE.pointed) ? LINT_POINTER_STATE.pointed : states[0]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const excerptOf = (text: string): string => {
|
|
178
|
+
const first = text.split('\n')[0].trim()
|
|
179
|
+
return first.length > EXCERPT_LENGTH ? `${first.slice(0, EXCERPT_LENGTH - 1)}…` : first
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The blocks that carry a lint-checkable rule and do not point at its lint rule. */
|
|
183
|
+
export const lintFindingsOf = (blocks: readonly Block[]): LintFinding[] =>
|
|
184
|
+
blocks.flatMap(({ line, text }) => {
|
|
185
|
+
const [shape] = lintShapesOf(text)
|
|
186
|
+
const state = lintPointerStateOf(text)
|
|
187
|
+
return shape && state !== LINT_POINTER_STATE.pointed ? [{ line, shape, state, excerpt: excerptOf(text) }] : []
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const normalized = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
191
|
+
|
|
192
|
+
/** The blocks of `current` whose text `previous` (the file as `HEAD` holds it; null for a new file) lacks. */
|
|
193
|
+
export const changedBlocksOf = (current: string, previous: string | null): Block[] => {
|
|
194
|
+
const known = new Set(previous === null ? [] : blocksOf(previous).map(({ text }) => normalized(text)))
|
|
195
|
+
return blocksOf(current).filter(({ text }) => !known.has(normalized(text)))
|
|
196
|
+
}
|