jackherd 1.16.0 → 1.18.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 +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- 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 +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- 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 +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -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 +10 -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 +29 -48
- 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 +90 -32
- package/src/check/skill-budgets.ts +96 -18
- 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
|
@@ -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
|
+
}
|
|
@@ -1,36 +1,47 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, join, posix } from 'node:path'
|
|
3
3
|
import { boardPlansOf, isInsideRepository } from '../../kit/hooks/hooks/shared/board-lanes'
|
|
4
|
-
import { planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
|
|
4
|
+
import { boardsIn, planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
|
|
5
|
+
import { readTextOrNull } from '../../kit/hooks/hooks/shared/files'
|
|
5
6
|
import { codesOfPlans } from '../../kit/hooks/hooks/shared/plan-codes'
|
|
6
7
|
import {
|
|
7
8
|
boardRowsWithoutPhase,
|
|
8
9
|
boardRowsWithProse,
|
|
10
|
+
currentPhase,
|
|
9
11
|
hasLanesTable,
|
|
10
12
|
type LongPhaseTitle,
|
|
13
|
+
laneProgress,
|
|
11
14
|
longPhaseTitles,
|
|
12
|
-
type MergedPhaseWithOpenBoxes,
|
|
13
15
|
mergedPhasesWithOpenBoxes,
|
|
14
16
|
newPhasesWithoutReviewGate,
|
|
17
|
+
openBoxesOfPhase,
|
|
15
18
|
PHASE_CELL_LIMIT,
|
|
16
19
|
PHASE_TITLE_LIMIT,
|
|
20
|
+
type PhaseWithOpenBoxes,
|
|
17
21
|
type PhaseWithoutGate,
|
|
18
22
|
type PhaseWithoutRow,
|
|
19
23
|
PLAN_VOCABULARY,
|
|
20
24
|
type PlanLanguage,
|
|
25
|
+
type PlanPhase,
|
|
21
26
|
PROSE_REASON,
|
|
27
|
+
parsePlanPhases,
|
|
28
|
+
parseSprintLanes,
|
|
22
29
|
phaseIdsOfPlan,
|
|
23
30
|
phasesWithoutDependencyRow,
|
|
24
31
|
type RowWithoutPhase,
|
|
25
32
|
type RowWithProse,
|
|
33
|
+
resolvePlanPath,
|
|
26
34
|
SHORT_NAME_LIMIT,
|
|
27
35
|
SHORT_NAME_PROBLEM,
|
|
28
36
|
type ShortNameFinding,
|
|
37
|
+
type SprintLane,
|
|
29
38
|
shortNameFindings,
|
|
30
39
|
} from '../plan-format'
|
|
31
40
|
import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
|
|
41
|
+
import { mergePlanPhases } from '../plans/phase-source'
|
|
32
42
|
import { changedPlansOf } from './deferral-arrows'
|
|
33
43
|
import { CHECK_STATUS, type CheckResult } from './result'
|
|
44
|
+
import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
|
|
34
45
|
|
|
35
46
|
/** The content of a file at a ref; null where the ref lacks the file. */
|
|
36
47
|
export type ContentAt = (path: string) => string | null
|
|
@@ -72,6 +83,22 @@ type Report<T> = {
|
|
|
72
83
|
clean: string
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
/** The lane a checkout is for and where its boards live, as the check reads them from the checkout. */
|
|
87
|
+
export type LaneBoxesInput = {
|
|
88
|
+
root: string
|
|
89
|
+
lane: string
|
|
90
|
+
planDirectory: string
|
|
91
|
+
board: { pattern: string; file: string }
|
|
92
|
+
language: PlanLanguage
|
|
93
|
+
/** A plan as the base has it (the pull request's parent in the watcher); null where it lacks the file. */
|
|
94
|
+
baseOf: ContentAt
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type BuiltPhase = { planPath: string; phaseId: string; partial: boolean }
|
|
98
|
+
|
|
99
|
+
/** The phase a lane builds with the plan it is in and its open boxes; `open` is null for a phase without any. */
|
|
100
|
+
export type LaneBuild = { path: string; open: PhaseWithOpenBoxes | null }
|
|
101
|
+
|
|
75
102
|
const changedCount = (count: number, noun: string): string => `${count} changed ${count === 1 ? noun : `${noun}s`}`
|
|
76
103
|
|
|
77
104
|
const findingsResult = <T>(
|
|
@@ -132,7 +159,7 @@ const bareCodeReport: Report<RowWithBareCode> = {
|
|
|
132
159
|
clean: 'no changed waiting row names a plan code bare',
|
|
133
160
|
}
|
|
134
161
|
|
|
135
|
-
const mergedReport: Report<
|
|
162
|
+
const mergedReport: Report<PhaseWithOpenBoxes> = {
|
|
136
163
|
name: 'Merged phases with open boxes',
|
|
137
164
|
noun: 'plan',
|
|
138
165
|
rule: 'a merged phase ticks or moves on its open boxes',
|
|
@@ -205,7 +232,7 @@ const bareCodesOfBoard = (roots: readonly string[], file: Changed, language: Pla
|
|
|
205
232
|
codesOfPlans(planTextsOf(roots, file, language), language),
|
|
206
233
|
).map(withPath(file.path))
|
|
207
234
|
|
|
208
|
-
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<
|
|
235
|
+
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
|
|
209
236
|
base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
|
|
210
237
|
|
|
211
238
|
const unlistedOf = ({ path, current }: Changed, language: PlanLanguage): Found<PhaseWithoutRow>[] | null =>
|
|
@@ -227,6 +254,91 @@ const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput,
|
|
|
227
254
|
return { path, current, previous, previousLines, base: baseOf(path), board: hasLanesTable(current, language) }
|
|
228
255
|
})
|
|
229
256
|
|
|
257
|
+
const openBoxesText = (lane: string, partialWord: string, path: string, found: PhaseWithOpenBoxes): string =>
|
|
258
|
+
`${path}:${found.line} Phase ${found.id} ${found.title} is built by lane ${lane} but has open boxes at ${found.openLines.join(', ')}; tick them, end them with → P<n> or mark the row ${partialWord}`
|
|
259
|
+
|
|
260
|
+
/** The open lane `input.lane` of the newest board that lists it, with the directory its plan links are relative to. */
|
|
261
|
+
const laneOnBoards = (input: LaneBoxesInput): { lane: SprintLane; directory: string } | null => {
|
|
262
|
+
const boards = boardsIn(input.root, input).sort((left, right) => right.directory.localeCompare(left.directory))
|
|
263
|
+
for (const { directory, text } of boards) {
|
|
264
|
+
const lane = parseSprintLanes(text, input.language).find(({ name, closed }) => name === input.lane && !closed)
|
|
265
|
+
if (lane) return { lane, directory }
|
|
266
|
+
}
|
|
267
|
+
return null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The lane has work of its own in the checkout, so a phase it ticked further than the base is marked touched, as the herd reads it. */
|
|
271
|
+
const CHECKOUT_WORK = { ahead: 1, dirty: 0 }
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The phases of the plan at `path` in the checkout; with the plan on the base too, those the checkout ticked further
|
|
275
|
+
* are marked touched, which is how the herd tells the phase a lane builds from the first open phase of its row.
|
|
276
|
+
*/
|
|
277
|
+
const phasesInCheckout = (input: LaneBoxesInput, path: string): PlanPhase[] => {
|
|
278
|
+
const text = readTextOrNull(join(input.root, path))
|
|
279
|
+
const base = input.baseOf(path)
|
|
280
|
+
const own = text === null ? [] : parsePlanPhases(text, input.language)
|
|
281
|
+
return base === null ? own : mergePlanPhases(parsePlanPhases(base, input.language), own, [], CHECKOUT_WORK)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The phase the lane's newest board row names, the plan it is in and whether the row marks it as built in parts. */
|
|
285
|
+
const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
|
|
286
|
+
const found = laneOnBoards(input)
|
|
287
|
+
if (!found) return null
|
|
288
|
+
const pathOf = (href: string): string => resolvePlanPath(found.directory, href)
|
|
289
|
+
const phasesByHref = Object.fromEntries(
|
|
290
|
+
found.lane.steps.flatMap(({ planHref }) =>
|
|
291
|
+
planHref === null ? [] : [[planHref, phasesInCheckout(input, pathOf(planHref))] as const],
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
const progress = laneProgress(found.lane, phasesByHref)
|
|
295
|
+
const phase = currentPhase(progress)
|
|
296
|
+
const step = progress.current === null ? null : progress.steps[progress.current.stepIndex].step
|
|
297
|
+
if (!phase || !step?.planHref) return null
|
|
298
|
+
// with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
|
|
299
|
+
if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
|
|
300
|
+
const partial = step.label.includes(PLAN_VOCABULARY[input.language].partialPhase)
|
|
301
|
+
return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The phase the lane's board row names and its open boxes, read from the checkout whether or not a commit changes the
|
|
306
|
+
* plan. Null for a lane the checkout's board does not list, for a row marked with the `partialPhase` word (`(Teil)`),
|
|
307
|
+
* which builds part of the phase, and for a phase ticked to the end, which is no longer the lane's current one.
|
|
308
|
+
*/
|
|
309
|
+
export const laneBuild = (input: LaneBoxesInput): LaneBuild | null => {
|
|
310
|
+
const built = phaseBuiltBy(input)
|
|
311
|
+
const plan = built && !built.partial ? readTextOrNull(join(input.root, built.planPath)) : null
|
|
312
|
+
return built && plan !== null
|
|
313
|
+
? { path: built.planPath, open: openBoxesOfPhase(plan, input.language, built.phaseId) }
|
|
314
|
+
: null
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** What the level makes of a finding: `fail` fails, `warn` warns; `off` never reaches here. */
|
|
318
|
+
const statusOf = (level: OpenBoxesLevel): typeof CHECK_STATUS.fail | typeof CHECK_STATUS.warn =>
|
|
319
|
+
level === OPEN_BOXES_LEVEL.warn ? CHECK_STATUS.warn : CHECK_STATUS.fail
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The open boxes of the phase the lane builds (`laneBuild`) as a check: `level` decides whether they fail, warn or are
|
|
323
|
+
* not looked at (`off` returns null).
|
|
324
|
+
*/
|
|
325
|
+
export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLevel }): CheckResult | null => {
|
|
326
|
+
if (input.level === OPEN_BOXES_LEVEL.off) return null
|
|
327
|
+
const started = Date.now()
|
|
328
|
+
const build = laneBuild(input)
|
|
329
|
+
if (!build) return null
|
|
330
|
+
const { open } = build
|
|
331
|
+
return {
|
|
332
|
+
name: 'Open boxes of the lane phase',
|
|
333
|
+
command: `lane ${input.lane}, the phase its board row names has no open box`,
|
|
334
|
+
status: open === null ? CHECK_STATUS.pass : statusOf(input.level),
|
|
335
|
+
detail: open
|
|
336
|
+
? openBoxesText(input.lane, PLAN_VOCABULARY[input.language].partialPhase, build.path, open)
|
|
337
|
+
: 'the phase the lane builds has no open box left',
|
|
338
|
+
durationMs: Date.now() - started,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
230
342
|
/**
|
|
231
343
|
* The structure of the changed plans and boards:
|
|
232
344
|
* - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
|
package/src/check/report.ts
CHANGED
|
@@ -143,12 +143,18 @@ export const formatPlanHeader = (
|
|
|
143
143
|
'',
|
|
144
144
|
]
|
|
145
145
|
|
|
146
|
+
const ADVISORY_LINE = /^(hint|note):/
|
|
147
|
+
|
|
148
|
+
/** A passing check shows only its advisory lines (`hint:`, `note:`); any other status shows its whole detail. */
|
|
149
|
+
const shownDetail = ({ status, detail }: CheckResult): string[] => {
|
|
150
|
+
const lines = detail ? detail.split('\n') : []
|
|
151
|
+
return status === CHECK_STATUS.pass ? lines.filter((line) => ADVISORY_LINE.test(line)) : lines
|
|
152
|
+
}
|
|
153
|
+
|
|
146
154
|
export const formatResults = (results: readonly CheckResult[]): string[] =>
|
|
147
155
|
results.flatMap((result) => [
|
|
148
156
|
`${STATUS_ICON[result.status]} ${result.name} (${result.command}, ${result.durationMs}ms)`,
|
|
149
|
-
...(result.
|
|
150
|
-
? result.detail.split('\n').map((line) => ` ${line}`)
|
|
151
|
-
: []),
|
|
157
|
+
...shownDetail(result).map((line) => ` ${line}`),
|
|
152
158
|
])
|
|
153
159
|
|
|
154
160
|
const warnings = (count: number): string => (count > 0 ? `${count} ${count === 1 ? 'warning' : 'warnings'}, ` : '')
|
package/src/check/schema.ts
CHANGED
|
@@ -68,6 +68,30 @@ const publicContent = z.strictObject({
|
|
|
68
68
|
/** A budget in words per path or glob from the project root; a key joining two paths with ` + ` budgets them together. */
|
|
69
69
|
const skillBudgets = z.record(glob, z.number().int().positive())
|
|
70
70
|
|
|
71
|
+
/** What `jh check` does with open boxes in the phase a lane builds: fail, warn or leave the check out. */
|
|
72
|
+
export const OPEN_BOXES_LEVEL = { fail: 'fail', warn: 'warn', off: 'off' } as const
|
|
73
|
+
|
|
74
|
+
export type OpenBoxesLevel = (typeof OPEN_BOXES_LEVEL)[keyof typeof OPEN_BOXES_LEVEL]
|
|
75
|
+
|
|
76
|
+
/** What `jh check` does with a lint-shaped rule block that names no lint rule: warn (the default), fail or leave the check out. */
|
|
77
|
+
export const LINT_POINTERS_LEVEL = { warn: 'warn', fail: 'fail', off: 'off' } as const
|
|
78
|
+
|
|
79
|
+
export type LintPointersLevel = (typeof LINT_POINTERS_LEVEL)[keyof typeof LINT_POINTERS_LEVEL]
|
|
80
|
+
|
|
81
|
+
const lintPointers = z.strictObject({
|
|
82
|
+
/** `warn` reports a block and lets the commit through; `fail` stops it; `off` skips the check. */
|
|
83
|
+
level: z
|
|
84
|
+
.enum([LINT_POINTERS_LEVEL.warn, LINT_POINTERS_LEVEL.fail, LINT_POINTERS_LEVEL.off])
|
|
85
|
+
.default(LINT_POINTERS_LEVEL.warn),
|
|
86
|
+
/** Rule files left unread, as globs from the project root; the kit copies in the lock file are always left out. */
|
|
87
|
+
exclude: z.array(glob).default([]),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
export type LintPointersConfig = z.infer<typeof lintPointers>
|
|
91
|
+
|
|
92
|
+
/** The check without a `lintPointers` block: warn, nothing excluded. */
|
|
93
|
+
export const DEFAULT_LINT_POINTERS: LintPointersConfig = { level: LINT_POINTERS_LEVEL.warn, exclude: [] }
|
|
94
|
+
|
|
71
95
|
/**
|
|
72
96
|
* The `check` block of `.jackherd/config.json`. Test and type commands come from the top-level
|
|
73
97
|
* `commands` block; this block holds what only the check runner reads.
|
|
@@ -89,6 +113,19 @@ export const checkConfigSchema = z.strictObject({
|
|
|
89
113
|
* path and sets one for the project's own skills; a file with none is measured against the kit's default.
|
|
90
114
|
*/
|
|
91
115
|
skillBudgets: skillBudgets.optional(),
|
|
116
|
+
/**
|
|
117
|
+
* What a lane's check does when the phase its board row names still has open boxes without `→ P<n>`: `fail` (the
|
|
118
|
+
* default) stops the commit, `warn` reports, `off` skips. A row carrying the `partialPhase` word (`(Teil)` in German) builds part of the phase and is
|
|
119
|
+
* left alone; a branch whose directory is no lane's is not checked.
|
|
120
|
+
*/
|
|
121
|
+
openBoxes: z
|
|
122
|
+
.enum([OPEN_BOXES_LEVEL.fail, OPEN_BOXES_LEVEL.warn, OPEN_BOXES_LEVEL.off])
|
|
123
|
+
.default(OPEN_BOXES_LEVEL.fail),
|
|
124
|
+
/**
|
|
125
|
+
* Whether a new or changed block of a rule file (or `CLAUDE.md`, `AGENTS.md`) that a linter could check, such
|
|
126
|
+
* as a forbidden import path or call, must name its lint rule with `` `lint:<rule id>` `` (or `` `lint:none` `` and a reason).
|
|
127
|
+
*/
|
|
128
|
+
lintPointers: lintPointers.optional(),
|
|
92
129
|
/** Also run tests in directories that hold test files but belong to no package. */
|
|
93
130
|
looseTests: z.boolean().default(false),
|
|
94
131
|
/**
|
package/src/check/settings.ts
CHANGED
|
@@ -25,6 +25,8 @@ export type CheckSettings = {
|
|
|
25
25
|
baseBranch: string
|
|
26
26
|
/** Where the lane worktrees live, below the main checkout: a board's plans are read there too. */
|
|
27
27
|
worktreeDirectory: string
|
|
28
|
+
/** Where the boards live: the directories under `planDirectory` and the file in each. */
|
|
29
|
+
board: { pattern: string; file: string }
|
|
28
30
|
/** Put before the kit's skill and agent names in this project; empty without one. */
|
|
29
31
|
skillPrefix: string
|
|
30
32
|
/** What the lock files (without one, the manifests) decide: one stack, none, or several (ambiguous); `stackOf` reads the stack from it. */
|
|
@@ -45,6 +47,7 @@ const planLanguageSchema = jackherdConfigSchema.shape.planLanguage
|
|
|
45
47
|
const baseBranchSchema = jackherdConfigSchema.shape.baseBranch
|
|
46
48
|
const worktreeDirectorySchema = jackherdConfigSchema.shape.worktreeDirectory
|
|
47
49
|
const skillPrefixSchema = jackherdConfigSchema.shape.skillPrefix
|
|
50
|
+
const boardSchema = jackherdConfigSchema.shape.board
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* The check runner's view of `.jackherd/config.json`: the shared `commands` block, `planDirectory`, `planLanguage` and `baseBranch` plus its own
|
|
@@ -62,6 +65,7 @@ export const parseCheckSettings = (raw: unknown, facts: RepositoryFacts): CheckS
|
|
|
62
65
|
planLanguage: planLanguageSchema.parse(object.planLanguage),
|
|
63
66
|
baseBranch: baseBranchSchema.parse(object.baseBranch),
|
|
64
67
|
worktreeDirectory: worktreeDirectorySchema.parse(object.worktreeDirectory),
|
|
68
|
+
board: boardSchema.parse(object.board),
|
|
65
69
|
skillPrefix: skillPrefixSchema.parse(object.skillPrefix),
|
|
66
70
|
stack: detectStack(facts.lockFiles, facts.manifests),
|
|
67
71
|
notes: detected.notes,
|