jackherd 0.0.1 → 1.0.1
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 +65 -0
- package/LICENSE +21 -0
- package/NOTICE +38 -0
- package/README.md +1122 -3
- package/assets/branding/README.md +62 -0
- package/assets/branding/jackherd-icon-256.png +0 -0
- package/assets/branding/jackherd-icon-512.png +0 -0
- package/assets/branding/jackherd-icon-64.png +0 -0
- package/assets/branding/jackherd-icon-small-32.png +0 -0
- package/assets/branding/jackherd-icon-small-64.png +0 -0
- package/assets/branding/jackherd-icon-small.svg +1 -0
- package/assets/branding/jackherd-icon.svg +1 -0
- package/assets/branding/jackherd-logo-1024.png +0 -0
- package/assets/branding/jackherd-logo-512.png +0 -0
- package/assets/branding/jackherd-logo-dark-1024.png +0 -0
- package/assets/branding/jackherd-logo-dark-512.png +0 -0
- package/assets/branding/jackherd-logo-dark.svg +1 -0
- package/assets/branding/jackherd-logo-mark-1024.png +0 -0
- package/assets/branding/jackherd-logo-mark-512.png +0 -0
- package/assets/branding/jackherd-logo-mark.svg +1 -0
- package/assets/branding/jackherd-logo.svg +1 -0
- package/dist/chunk-R6MGHCD7.js +93 -0
- package/dist/config.d.ts +167 -0
- package/dist/config.js +401 -0
- package/dist/plan-format.d.ts +325 -0
- package/dist/plan-format.js +636 -0
- package/docs/lanes.mmd +9 -0
- package/docs/lanes.svg +1 -0
- package/docs/skills.mmd +10 -0
- package/docs/skills.svg +1 -0
- package/docs/workflow.svg +172 -0
- package/herdr-plugin/herdr-plugin.toml +22 -0
- package/herdr-plugin/open-plans.ts +43 -0
- package/herdr-plugin/restore-plans.ts +107 -0
- package/install.sh +157 -0
- package/kit/hooks/hooks/git-guard/decision.ts +438 -0
- package/kit/hooks/hooks/git-guard/index.ts +38 -0
- package/kit/hooks/hooks/git-guard/shell.ts +407 -0
- package/kit/hooks/hooks/watch-guard/background.ts +36 -0
- package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
- package/kit/hooks/hooks/watch-guard/index.ts +11 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
- package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
- package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
- package/kit/hooks/hooks.json +30 -0
- package/kit/hooks/statusline.sh +72 -0
- package/kit/review/agents/devops.md +11 -0
- package/kit/review/agents/public-content.md +39 -0
- package/kit/review/agents/security-audit.md +11 -0
- package/kit/review/agents/uncle-bob.md +11 -0
- package/kit/review/skills/devops/SKILL.md +50 -0
- package/kit/review/skills/diagnose/SKILL.md +140 -0
- package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
- package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
- package/kit/review/skills/pre-commit/SKILL.md +100 -0
- package/kit/review/skills/review/SKILL.md +43 -0
- package/kit/review/skills/security-audit/SKILL.md +65 -0
- package/kit/review/skills/uncle-bob/SKILL.md +43 -0
- package/kit/rules/rules/kit/arc42.md +89 -0
- package/kit/rules/rules/kit/architecture-files.md +87 -0
- package/kit/rules/rules/kit/code-style.md +159 -0
- package/kit/rules/rules/kit/pull-requests.md +59 -0
- package/kit/rules/rules/kit/testing.md +91 -0
- package/kit/rules/rules/kit/writing-for-agents.md +110 -0
- package/kit/workflow/agents/explore.md +25 -0
- package/kit/workflow/agents/implement.md +16 -0
- package/kit/workflow/agents/plan.md +13 -0
- package/kit/workflow/agents/spec-review.md +96 -0
- package/kit/workflow/skills/follow/SKILL.md +48 -0
- package/kit/workflow/skills/grill/SKILL.md +60 -0
- package/kit/workflow/skills/implement/SKILL.md +107 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
- package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
- package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
- package/kit/workflow/skills/plan/SKILL.md +118 -0
- package/kit/workflow/skills/plan/references/template-English.md +92 -0
- package/kit/workflow/skills/plan/references/template-German.md +92 -0
- package/kit/workflow/skills/start/SKILL.md +47 -0
- package/package.json +63 -5
- package/schemas/jackherd.schema.json +601 -0
- package/src/check/changes.ts +45 -0
- package/src/check/command-line.ts +12 -0
- package/src/check/command.ts +136 -0
- package/src/check/deferral-arrows.ts +41 -0
- package/src/check/execute.ts +29 -0
- package/src/check/glob.ts +15 -0
- package/src/check/plan-structure-check.ts +135 -0
- package/src/check/plan.ts +106 -0
- package/src/check/public-content-check.ts +32 -0
- package/src/check/public-content-facts.ts +177 -0
- package/src/check/public-content.ts +216 -0
- package/src/check/report.ts +55 -0
- package/src/check/result.ts +42 -0
- package/src/check/roots.ts +36 -0
- package/src/check/routing.ts +22 -0
- package/src/check/schema.ts +83 -0
- package/src/check/settings.ts +44 -0
- package/src/claude/paths.ts +14 -0
- package/src/cli.ts +46 -0
- package/src/config/command.ts +45 -0
- package/src/config/detect.ts +196 -0
- package/src/config/index.ts +2 -0
- package/src/config/json-schema.ts +5 -0
- package/src/config/legacy-names.ts +56 -0
- package/src/config/link-kinds.ts +25 -0
- package/src/config/location.ts +46 -0
- package/src/config/read-config.ts +42 -0
- package/src/config/repository-facts.ts +19 -0
- package/src/config/repository-values.ts +46 -0
- package/src/config/schema.ts +302 -0
- package/src/context/command.ts +39 -0
- package/src/context/coordinator-name.ts +16 -0
- package/src/context/fill.ts +69 -0
- package/src/context/index.ts +10 -0
- package/src/context/level.ts +22 -0
- package/src/context/options.ts +29 -0
- package/src/context/report.ts +42 -0
- package/src/context/session-log.ts +91 -0
- package/src/plan-format/drift.ts +69 -0
- package/src/plan-format/header.ts +27 -0
- package/src/plan-format/index.ts +9 -0
- package/src/plan-format/lanes.ts +128 -0
- package/src/plan-format/markdown.ts +98 -0
- package/src/plan-format/phase-tally.ts +55 -0
- package/src/plan-format/phases.ts +224 -0
- package/src/plan-format/plan-structure.ts +150 -0
- package/src/plan-format/progress.ts +202 -0
- package/src/plan-format/sprint-document.ts +69 -0
- package/src/plan-format/terminal-text.ts +28 -0
- package/src/plans/board-choice.ts +60 -0
- package/src/plans/board-pick.ts +72 -0
- package/src/plans/board-rows.ts +62 -0
- package/src/plans/command.ts +60 -0
- package/src/plans/ensure-panes.ts +71 -0
- package/src/plans/error-text.ts +5 -0
- package/src/plans/fetch.ts +96 -0
- package/src/plans/git.ts +145 -0
- package/src/plans/github.ts +35 -0
- package/src/plans/lane-discovery.ts +34 -0
- package/src/plans/lane-plans.ts +80 -0
- package/src/plans/layout.ts +881 -0
- package/src/plans/link-chips.ts +74 -0
- package/src/plans/live.ts +336 -0
- package/src/plans/markdown-html.ts +140 -0
- package/src/plans/options.ts +15 -0
- package/src/plans/phase-source.ts +71 -0
- package/src/plans/plan-chips.ts +75 -0
- package/src/plans/plan-panes.ts +206 -0
- package/src/plans/planned.ts +117 -0
- package/src/plans/pull-requests.ts +107 -0
- package/src/plans/restart-decision.ts +42 -0
- package/src/plans/selection.ts +352 -0
- package/src/plans/settings.ts +68 -0
- package/src/plans/single-flight.ts +25 -0
- package/src/plans/snapshot.ts +300 -0
- package/src/plans/sprint-export.ts +103 -0
- package/src/plans/sprint-html.ts +301 -0
- package/src/plans/sprint-page.ts +287 -0
- package/src/plans/supervisor.ts +124 -0
- package/src/plans/symbols.ts +54 -0
- package/src/plans/texts.ts +103 -0
- package/src/plans/unlisted-lanes.ts +45 -0
- package/src/plans/view.ts +68 -0
- package/src/plans/worked-phases.ts +53 -0
- package/src/sync/checksum.ts +13 -0
- package/src/sync/choice.ts +14 -0
- package/src/sync/command-context.ts +113 -0
- package/src/sync/command.ts +94 -0
- package/src/sync/error-message.ts +1 -0
- package/src/sync/gitignore.ts +45 -0
- package/src/sync/global-kit.ts +176 -0
- package/src/sync/herdr-plugin.ts +175 -0
- package/src/sync/if-changed.ts +109 -0
- package/src/sync/init-command.ts +239 -0
- package/src/sync/init.ts +136 -0
- package/src/sync/kit-hooks.ts +18 -0
- package/src/sync/kit-path.ts +77 -0
- package/src/sync/kit-source.ts +169 -0
- package/src/sync/kit.ts +9 -0
- package/src/sync/legacy-files.ts +116 -0
- package/src/sync/manifest.ts +44 -0
- package/src/sync/next-steps.ts +42 -0
- package/src/sync/plan-file.ts +154 -0
- package/src/sync/plan-hooks.ts +165 -0
- package/src/sync/project-fs.ts +132 -0
- package/src/sync/render.ts +60 -0
- package/src/sync/report.ts +96 -0
- package/src/sync/settings-hooks.ts +90 -0
- package/src/sync/settings-migration.ts +94 -0
- package/src/sync/settings-offer.ts +168 -0
- package/src/sync/setup-questions.ts +70 -0
- package/src/sync/setup.ts +52 -0
- package/src/sync/sync-plan.ts +120 -0
- package/src/sync/sync-result.ts +9 -0
- package/src/sync/sync.ts +307 -0
- package/src/sync/tools.ts +52 -0
- package/src/terminal/herdr.ts +91 -0
- package/src/terminal/index.ts +13 -0
- package/src/terminal/none.ts +7 -0
- package/src/terminal/open.ts +39 -0
- package/src/terminal/text.ts +159 -0
- package/src/terminal/types.ts +30 -0
- package/src/terminal/width.ts +90 -0
- package/src/update/command.ts +65 -0
- package/src/update/package-manager.ts +56 -0
- package/src/update/update.ts +105 -0
- package/src/version/installed-version.ts +32 -0
- package/src/version/release.ts +22 -0
- package/src/version/skill-diff.ts +63 -0
- package/src/version/started-kit.ts +14 -0
- package/src/vocabulary/index.ts +132 -0
- package/src/watch/base-tip.ts +18 -0
- package/src/watch/command.ts +919 -0
- package/src/watch/coordinator-fill.ts +76 -0
- package/src/watch/feedback-read.ts +73 -0
- package/src/watch/feedback.ts +308 -0
- package/src/watch/gh.ts +22 -0
- package/src/watch/github-read.ts +175 -0
- package/src/watch/herdr.ts +11 -0
- package/src/watch/json-guards.ts +9 -0
- package/src/watch/kit-update.ts +18 -0
- package/src/watch/lane-fill.ts +36 -0
- package/src/watch/lane-message.ts +99 -0
- package/src/watch/lane-progress.ts +44 -0
- package/src/watch/lane-report-file.ts +53 -0
- package/src/watch/lane-report.ts +270 -0
- package/src/watch/lane-search.ts +41 -0
- package/src/watch/lane-sources.ts +46 -0
- package/src/watch/lane-watch.ts +121 -0
- package/src/watch/monitor-tool.ts +19 -0
- package/src/watch/options.ts +79 -0
- package/src/watch/plan-badges-read.ts +30 -0
- package/src/watch/plan-badges.ts +66 -0
- package/src/watch/plan-views.ts +59 -0
- package/src/watch/preview-read.ts +60 -0
- package/src/watch/previews.ts +234 -0
- package/src/watch/pull-requests.ts +117 -0
- package/src/watch/single-report.ts +15 -0
- package/src/watch/skill-change.ts +61 -0
- package/src/watch/source-dry-run-command.ts +35 -0
- package/src/watch/source-dry-run.ts +124 -0
- package/src/watch/source-fields.ts +9 -0
- package/src/watch/source-path.ts +40 -0
- package/src/watch/source-run.ts +90 -0
- package/src/watch/sources.ts +344 -0
- package/src/watch/stream-output.ts +14 -0
- package/src/watch/texts.ts +76 -0
- package/src/watch/watch-events.ts +399 -0
- package/src/watch/watcher-lock.ts +145 -0
- package/src/watch/workflow-runs.ts +96 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { PLAN_VOCABULARY } from '../vocabulary'
|
|
3
|
+
import { escapeRegExp } from './markdown'
|
|
4
|
+
|
|
5
|
+
export type DriftFinding = { file: string; reason: string }
|
|
6
|
+
|
|
7
|
+
type DriftWords = {
|
|
8
|
+
/** `Status:` lines starting with one of these describe a plan in flight, where open items are expected. */
|
|
9
|
+
inFlight: RegExp
|
|
10
|
+
completion: RegExp
|
|
11
|
+
open: RegExp
|
|
12
|
+
architecture: RegExp
|
|
13
|
+
/** Level 2-4 heading words that mark the architecture documentation closing phase. */
|
|
14
|
+
architectureHeading: string
|
|
15
|
+
selfReported: (statusText: string) => string
|
|
16
|
+
silent: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const OPEN_TABLE_CELL = /⬜/
|
|
20
|
+
|
|
21
|
+
const DRIFT_WORDS: Record<PlanLanguage, DriftWords> = {
|
|
22
|
+
de: {
|
|
23
|
+
inFlight: /^(Draft|In Progress|Proposed|Open|Planned|Review-Ready|Aktiv)\b/i,
|
|
24
|
+
completion: /\b(done|abgeschlossen|umgesetzt|implementiert|implemented|fertig)\b/i,
|
|
25
|
+
open: /\b(offen|ausstehend|deferred|zurückgestellt)\b/i,
|
|
26
|
+
architecture: /\b(arc42|ADR|Architekturdokumentation|Dokumentation)\b/i,
|
|
27
|
+
architectureHeading: 'Architekturdokumentation',
|
|
28
|
+
selfReported: (statusText) => `Status nennt offene Doku-Arbeit: "${statusText.trim()}"`,
|
|
29
|
+
silent: 'arc42-Abschlussphase hat offene ⬜-Zeilen trotz abgeschlossenem Status',
|
|
30
|
+
},
|
|
31
|
+
en: {
|
|
32
|
+
inFlight: /^(Draft|In Progress|Proposed|Open|Planned|Review-Ready|Active)\b/i,
|
|
33
|
+
completion: /\b(done|completed|implemented|finished)\b/i,
|
|
34
|
+
open: /\b(open|outstanding|pending|deferred)\b/i,
|
|
35
|
+
architecture: /\b(arc42|ADR|architecture documentation|documentation)\b/i,
|
|
36
|
+
architectureHeading: 'Architecture documentation',
|
|
37
|
+
selfReported: (statusText) => `Status names open documentation work: "${statusText.trim()}"`,
|
|
38
|
+
silent: 'The arc42 closing phase has open ⬜ rows despite a completed status',
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Single line on purpose: the drift check always read only the first line of the field, and the
|
|
43
|
+
// 270-plan parity run depends on it. `headerField` follows soft wraps and would classify some plans differently.
|
|
44
|
+
const statusLine = (content: string, name: string): string | undefined =>
|
|
45
|
+
content.match(new RegExp(`^\\*\\*${escapeRegExp(name)}:\\*\\*\\s*(.+)$`, 'm'))?.[1]
|
|
46
|
+
|
|
47
|
+
const architectureSection = (content: string, heading: string): string | undefined =>
|
|
48
|
+
content.match(new RegExp(`#{2,4}[^\\n]*${escapeRegExp(heading)}[^\\n]*\\n([\\s\\S]*?)(?=\\n#{1,4} |$)`))?.[1]
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Plans whose own status text or closing documentation phase admits unfinished architecture work.
|
|
52
|
+
* Plans in flight are skipped: open items there are expected, not drift.
|
|
53
|
+
*/
|
|
54
|
+
export const findDrift = (file: string, content: string, language: PlanLanguage): DriftFinding[] => {
|
|
55
|
+
const statusText = statusLine(content, PLAN_VOCABULARY[language].header.status)
|
|
56
|
+
const words = DRIFT_WORDS[language]
|
|
57
|
+
if (!statusText || words.inFlight.test(statusText.trim())) return []
|
|
58
|
+
|
|
59
|
+
const completed = words.completion.test(statusText)
|
|
60
|
+
const caveat = words.open.test(statusText)
|
|
61
|
+
const selfReported =
|
|
62
|
+
completed && caveat && words.architecture.test(statusText) ? words.selfReported(statusText) : null
|
|
63
|
+
const section = completed && !caveat ? architectureSection(content, words.architectureHeading) : undefined
|
|
64
|
+
const silent = section && OPEN_TABLE_CELL.test(section) ? words.silent : null
|
|
65
|
+
|
|
66
|
+
return [selfReported, silent]
|
|
67
|
+
.filter((reason): reason is string => reason !== null)
|
|
68
|
+
.map((reason) => ({ file, reason }))
|
|
69
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { classifyStatus, PLAN_VOCABULARY, type PlanLanguage, type PlanStatus } from '../vocabulary'
|
|
2
|
+
import { firstHeading, headerField } from './markdown'
|
|
3
|
+
|
|
4
|
+
export type PlanHeader = {
|
|
5
|
+
title: string
|
|
6
|
+
date: string
|
|
7
|
+
status: string
|
|
8
|
+
statusWord: PlanStatus
|
|
9
|
+
scope: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DECISION_ROW = /^\|\s*\*?\*?D\d+/gm
|
|
13
|
+
|
|
14
|
+
export const readPlanHeader = (markdown: string, language: PlanLanguage, fallbackTitle: string): PlanHeader => {
|
|
15
|
+
const { header } = PLAN_VOCABULARY[language]
|
|
16
|
+
const status = headerField(markdown, header.status)
|
|
17
|
+
return {
|
|
18
|
+
title: firstHeading(markdown, fallbackTitle),
|
|
19
|
+
date: headerField(markdown, header.date),
|
|
20
|
+
status,
|
|
21
|
+
statusWord: classifyStatus(language, status),
|
|
22
|
+
scope: headerField(markdown, header.scope),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rows of the decision table, counted by their `D<number>` first cell. */
|
|
27
|
+
export const countDecisions = (markdown: string): number => (markdown.match(DECISION_ROW) ?? []).length
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from '../vocabulary'
|
|
2
|
+
export * from './drift'
|
|
3
|
+
export * from './header'
|
|
4
|
+
export * from './lanes'
|
|
5
|
+
export * from './phase-tally'
|
|
6
|
+
export * from './phases'
|
|
7
|
+
export * from './plan-structure'
|
|
8
|
+
export * from './progress'
|
|
9
|
+
export * from './sprint-document'
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { firstHeading, firstTable, sectionLines } from './markdown'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
|
|
7
|
+
* named them; `withoutRow` marks a step no board row wrote: the phases a lane worked on without a row of their own.
|
|
8
|
+
*/
|
|
9
|
+
export type PlanStep = {
|
|
10
|
+
planTitle: string
|
|
11
|
+
planHref: string | null
|
|
12
|
+
phaseIds: string[]
|
|
13
|
+
label: string
|
|
14
|
+
startsAfter: string
|
|
15
|
+
model: string
|
|
16
|
+
namedPhaseIds?: string[]
|
|
17
|
+
withoutRow?: true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A lane of the sprint table; `closed` when its name cell says the lane was cleaned up. */
|
|
21
|
+
export type SprintLane = { name: string; port: number | null; steps: PlanStep[]; closed: boolean }
|
|
22
|
+
|
|
23
|
+
const LANE_NAME = /`([^`]+)`/
|
|
24
|
+
const CONTINUATION = '↳'
|
|
25
|
+
const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
|
|
26
|
+
/** Sprint tables name a phase `P4`; the sidebar title prints it the same way. */
|
|
27
|
+
export const PHASE_ID_PREFIX = 'P'
|
|
28
|
+
|
|
29
|
+
const CLAUSE_SEPARATOR = /([;,]|\.(?=\s))\s*/
|
|
30
|
+
const ENTRY_END = new Set([';', '.'])
|
|
31
|
+
|
|
32
|
+
type PhaseIdPatterns = { leading: RegExp; afterConnector: RegExp; connector: RegExp }
|
|
33
|
+
|
|
34
|
+
const phaseIdPatterns = (language: PlanLanguage): PhaseIdPatterns => {
|
|
35
|
+
const connectors = PLAN_VOCABULARY[language].phaseConnectors.join('|')
|
|
36
|
+
const id = `(?:\\*\\*)?${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`
|
|
37
|
+
return {
|
|
38
|
+
leading: new RegExp(`^\\s*(?:(?:${connectors})\\s+)*${id}`),
|
|
39
|
+
afterConnector: new RegExp(`^\\s*(?:${connectors})\\s`),
|
|
40
|
+
connector: new RegExp(`\\b(?:${connectors})\\s+${id}`, 'g'),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The phase ids of a phases cell. An id counts where an entry starts: at the start of the cell or of a
|
|
46
|
+
* sentence, after `;`, after a connector word (`P1 Index und P2 Suche`), or after `,` while a list that began
|
|
47
|
+
* with an id is running (`P1 (#<number>), P2 (#<number>)`). A `P6` inside a sentence (`geht in P6 auf`, `abgeräumt,
|
|
48
|
+
* P6 startet neu`) is prose.
|
|
49
|
+
*/
|
|
50
|
+
const phaseIdsOf = (cell: string, patterns: PhaseIdPatterns): string[] => {
|
|
51
|
+
const parts = cell.split(CLAUSE_SEPARATOR)
|
|
52
|
+
const ids: string[] = []
|
|
53
|
+
let listRunning = false
|
|
54
|
+
for (let index = 0; index < parts.length; index += 2) {
|
|
55
|
+
const separator = index > 0 ? parts[index - 1] : null
|
|
56
|
+
const clause = parts[index]
|
|
57
|
+
const entryStart = separator === null || ENTRY_END.has(separator)
|
|
58
|
+
if (entryStart) listRunning = false
|
|
59
|
+
const leading = clause.match(patterns.leading)
|
|
60
|
+
const startsEntry: boolean = entryStart || listRunning || patterns.afterConnector.test(clause)
|
|
61
|
+
const counts: boolean = leading !== null && startsEntry
|
|
62
|
+
if (leading && counts) ids.push(leading[1])
|
|
63
|
+
ids.push(...[...clause.matchAll(patterns.connector)].map((match) => match[1]))
|
|
64
|
+
listRunning ||= counts
|
|
65
|
+
}
|
|
66
|
+
return [...new Set(ids)]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The phase ids a phases cell of the lanes table names, read as `parseSprintLanes` reads them. */
|
|
70
|
+
export const phaseIdsOfCell = (cell: string, language: PlanLanguage): string[] =>
|
|
71
|
+
phaseIdsOf(cell, phaseIdPatterns(language))
|
|
72
|
+
|
|
73
|
+
export const laneNameOf = (cell: string, previous: string | null): string | null =>
|
|
74
|
+
cell.match(LANE_NAME)?.[1] ?? (cell.startsWith(CONTINUATION) ? previous : null)
|
|
75
|
+
|
|
76
|
+
const toStep = (
|
|
77
|
+
[, , plan = '', phases = '', startsAfter = '', model = '']: string[],
|
|
78
|
+
phaseId: PhaseIdPatterns,
|
|
79
|
+
): PlanStep => {
|
|
80
|
+
const link = plan.match(PLAN_LINK)
|
|
81
|
+
return {
|
|
82
|
+
planTitle: link ? link[1] : plan,
|
|
83
|
+
planHref: link ? link[2] : null,
|
|
84
|
+
phaseIds: phaseIdsOf(phases, phaseId),
|
|
85
|
+
label: phases,
|
|
86
|
+
startsAfter,
|
|
87
|
+
model,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The step without the phases an earlier step of the lane already named from the same plan, which `namedPhaseIds` keeps. */
|
|
92
|
+
export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
|
|
93
|
+
const named = new Set(
|
|
94
|
+
lane.steps.filter((earlier) => earlier.planHref === step.planHref).flatMap((earlier) => earlier.phaseIds),
|
|
95
|
+
)
|
|
96
|
+
const phaseIds = step.phaseIds.filter((id) => !named.has(id))
|
|
97
|
+
if (phaseIds.length === step.phaseIds.length) return step
|
|
98
|
+
return { ...step, phaseIds, namedPhaseIds: step.namedPhaseIds ?? step.phaseIds }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The lanes of the sprint document's lanes table (`## Spuren` in `de`) in table order. A row whose
|
|
103
|
+
* first cell names a lane in backticks belongs to that lane; a `↳` row without a name continues
|
|
104
|
+
* the lane above it.
|
|
105
|
+
*/
|
|
106
|
+
export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
|
|
107
|
+
const lanes = new Map<string, SprintLane>()
|
|
108
|
+
const phaseId = phaseIdPatterns(language)
|
|
109
|
+
const { laneClosed } = PLAN_VOCABULARY[language]
|
|
110
|
+
let previous: string | null = null
|
|
111
|
+
for (const cells of firstTable(sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes))) {
|
|
112
|
+
const name = laneNameOf(cells[0] ?? '', previous)
|
|
113
|
+
if (!name) continue
|
|
114
|
+
const port = Number.parseInt(cells[1] ?? '', 10)
|
|
115
|
+
const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
|
|
116
|
+
lane.closed ||= (cells[0] ?? '').includes(laneClosed)
|
|
117
|
+
lane.steps.push(withoutRepeats(lane, toStep(cells, phaseId)))
|
|
118
|
+
lanes.set(name, lane)
|
|
119
|
+
previous = name
|
|
120
|
+
}
|
|
121
|
+
return [...lanes.values()]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const sprintTitleOf = (markdown: string, fallback: string): string => firstHeading(markdown, fallback)
|
|
125
|
+
|
|
126
|
+
/** The repository path of a plan link, relative to the sprint document's directory. */
|
|
127
|
+
export const resolvePlanPath = (sprintDirectory: string, href: string): string =>
|
|
128
|
+
posix.normalize(posix.join(sprintDirectory, href.split('#')[0]))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { terminalSafe } from './terminal-text'
|
|
2
|
+
|
|
3
|
+
const SECTION_END = /^##\s/
|
|
4
|
+
const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
|
|
5
|
+
|
|
6
|
+
export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
7
|
+
|
|
8
|
+
/** The index of the line after a level-2 heading with exactly this text; -1 when there is none. */
|
|
9
|
+
export const sectionStart = (markdown: string, headingText: string): number => {
|
|
10
|
+
const heading = new RegExp(`^##\\s+${escapeRegExp(headingText)}\\s*$`)
|
|
11
|
+
const start = markdown.split('\n').findIndex((line) => heading.test(line))
|
|
12
|
+
return start === -1 ? -1 : start + 1
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The lines under a level-2 heading with exactly this text, up to the next level-2 heading. */
|
|
16
|
+
export const sectionLines = (markdown: string, headingText: string): string[] => {
|
|
17
|
+
const start = sectionStart(markdown, headingText)
|
|
18
|
+
if (start === -1) return []
|
|
19
|
+
const rest = markdown.split('\n').slice(start)
|
|
20
|
+
const end = rest.findIndex((line) => SECTION_END.test(line))
|
|
21
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const tableCells = (line: string): string[] =>
|
|
25
|
+
line
|
|
26
|
+
.slice(1, line.endsWith('|') ? -1 : undefined)
|
|
27
|
+
.split('|')
|
|
28
|
+
.map((cell) => terminalSafe(cell).trim())
|
|
29
|
+
|
|
30
|
+
/** A body row of a table: its index among the lines it was read from, the line as written, and its cells. */
|
|
31
|
+
export type TableRow = { index: number; line: string; cells: string[] }
|
|
32
|
+
|
|
33
|
+
/** Body rows of the first table among `lines` with their index, header and separator dropped, cells trimmed and terminal-safe. */
|
|
34
|
+
export const firstTableRows = (lines: string[]): TableRow[] => {
|
|
35
|
+
const start = lines.findIndex((line) => line.startsWith('|'))
|
|
36
|
+
if (start === -1) return []
|
|
37
|
+
const rest = lines.slice(start)
|
|
38
|
+
const end = rest.findIndex((line) => !line.startsWith('|'))
|
|
39
|
+
return (end === -1 ? rest : rest.slice(0, end))
|
|
40
|
+
.map((line, offset) => ({ index: start + offset, line, cells: tableCells(line) }))
|
|
41
|
+
.slice(1)
|
|
42
|
+
.filter(({ line }) => !TABLE_SEPARATOR.test(line))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Body rows of the first table among `lines`, header and separator dropped, cells trimmed and terminal-safe. */
|
|
46
|
+
export const firstTable = (lines: string[]): string[][] => firstTableRows(lines).map(({ cells }) => cells)
|
|
47
|
+
|
|
48
|
+
/** Value of a `**Name:** value` header line; continues over soft-wrapped lines until the next field or blank line. */
|
|
49
|
+
export const headerField = (markdown: string, name: string): string => {
|
|
50
|
+
const match = new RegExp(`^\\*\\*${escapeRegExp(name)}:\\*\\*\\s*(.+?)(?=\\n\\*\\*|\\n\\n)`, 'ms').exec(markdown)
|
|
51
|
+
return match ? match[1].replace(/\s+/g, ' ').trim() : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const firstHeading = (markdown: string, fallback: string): string =>
|
|
55
|
+
terminalSafe(markdown.match(/^#\s+(.+)$/m)?.[1] ?? fallback).trim()
|
|
56
|
+
|
|
57
|
+
const HEADING = /^(#{2,4})\s+(.*?)\s*$/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The lines under the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as
|
|
61
|
+
* `(PFLICHT ab 3 Phasen)` is allowed), up to the next heading of the same or a higher level.
|
|
62
|
+
*/
|
|
63
|
+
export const subsectionLines = (markdown: string, headingText: string): string[] => {
|
|
64
|
+
const lines = markdown.split('\n')
|
|
65
|
+
const start = lines.findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
|
|
66
|
+
if (start === -1) return []
|
|
67
|
+
const level = HEADING.exec(lines[start])?.[1].length ?? 2
|
|
68
|
+
const rest = lines.slice(start + 1)
|
|
69
|
+
const end = rest.findIndex((line) => {
|
|
70
|
+
const heading = HEADING.exec(line)
|
|
71
|
+
return heading !== null && heading[1].length <= level
|
|
72
|
+
})
|
|
73
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The header cells and the body rows of the first table among `lines`. */
|
|
77
|
+
export const firstTableWithHeader = (lines: string[]): { header: string[]; rows: string[][] } => {
|
|
78
|
+
const start = lines.findIndex((line) => line.startsWith('|'))
|
|
79
|
+
if (start === -1) return { header: [], rows: [] }
|
|
80
|
+
return { header: tableCells(lines[start]), rows: firstTable(lines) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type TableWithCaption = { caption: string | null; header: string[]; rows: string[][] }
|
|
84
|
+
|
|
85
|
+
/** Every table among `lines` with its header cells and body rows, and the paragraph line right above it as its caption. */
|
|
86
|
+
export const allTables = (lines: string[]): TableWithCaption[] => {
|
|
87
|
+
const tables: TableWithCaption[] = []
|
|
88
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
89
|
+
if (!lines[index].startsWith('|') || (index > 0 && lines[index - 1].startsWith('|'))) continue
|
|
90
|
+
const above = lines
|
|
91
|
+
.slice(0, index)
|
|
92
|
+
.reverse()
|
|
93
|
+
.find((line) => line.trim() !== '')
|
|
94
|
+
const caption = above && !above.startsWith('|') ? above.trim() : null
|
|
95
|
+
tables.push({ caption, ...firstTableWithHeader(lines.slice(index)) })
|
|
96
|
+
}
|
|
97
|
+
return tables
|
|
98
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { escapeRegExp } from './markdown'
|
|
3
|
+
import { deferralTarget } from './phases'
|
|
4
|
+
|
|
5
|
+
/** Per heading that carries checkboxes or table status cells: how many are done and open. */
|
|
6
|
+
export type PhaseTally = { title: string; done: number; open: number; model: string | undefined }
|
|
7
|
+
|
|
8
|
+
/** A tally with the id of its phase and the targets of the checkboxes it defers. */
|
|
9
|
+
type Section = PhaseTally & { phaseId: string; targets: string[] }
|
|
10
|
+
|
|
11
|
+
const DONE_MARK = /^\s*[-*]\s\[x\]|\|\s*✅\s*\|/gim
|
|
12
|
+
const OPEN_MARK = /^\s*[-*]\s\[ \]|\|\s*⬜\s*\|/gm
|
|
13
|
+
const TRAILING_SEPARATOR = /[:—–-]\s*$/
|
|
14
|
+
|
|
15
|
+
const PHASE_ID = /^(\d+[a-z]?)/
|
|
16
|
+
|
|
17
|
+
const modelFor = (markdown: string, phaseWord: string, phaseTitle: string): string | undefined => {
|
|
18
|
+
const prefix = new RegExp(`^${escapeRegExp(phaseWord)}\\s*`, 'i')
|
|
19
|
+
const number = /^(\d+)/.exec(phaseTitle.replace(prefix, ''))?.[1]
|
|
20
|
+
if (!number) return undefined
|
|
21
|
+
const row = new RegExp(`^\\|\\s*${number}\\s*[—–-][^|]*\\|\\s*([^|]+?)\\s*\\|`, 'm').exec(markdown)
|
|
22
|
+
return row?.[1].trim()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sectionOf = (section: string, heading: RegExp, markdown: string, phaseWord: string): Section | null => {
|
|
26
|
+
const match = heading.exec(section)
|
|
27
|
+
if (!match) return null
|
|
28
|
+
const title = match[1].replace(TRAILING_SEPARATOR, '').trim()
|
|
29
|
+
const phaseId = PHASE_ID.exec(title)?.[1]?.toLowerCase() ?? ''
|
|
30
|
+
const targets = section.split('\n').flatMap((line) => deferralTarget(line, phaseId) ?? [])
|
|
31
|
+
const done = (section.match(DONE_MARK) ?? []).length
|
|
32
|
+
const open = (section.match(OPEN_MARK) ?? []).length - targets.length
|
|
33
|
+
return { title, done, open, model: modelFor(markdown, phaseWord, title), phaseId, targets }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Every level-2 or level-3 section whose heading starts with a number (optionally after the phase
|
|
38
|
+
* word) and that holds at least one checkbox or ✅/⬜ table cell, with the model its recommendation
|
|
39
|
+
* table names for that phase. An open checkbox moved on with `→ P<n>` counts as open in the section
|
|
40
|
+
* of phase `<n>` instead of its own; with no such section it stays open where it is, as in `parsePlanPhases`.
|
|
41
|
+
*/
|
|
42
|
+
export const tallyPhases = (markdown: string, language: PlanLanguage): PhaseTally[] => {
|
|
43
|
+
const phaseWord = PLAN_VOCABULARY[language].phase
|
|
44
|
+
const heading = new RegExp(`^###?\\s+(?:${escapeRegExp(phaseWord)}\\s*)?(\\d+[^\\n]*)`)
|
|
45
|
+
const sections = markdown
|
|
46
|
+
.split(/\n(?=###?\s)/)
|
|
47
|
+
.flatMap((section) => sectionOf(section, heading, markdown, phaseWord) ?? [])
|
|
48
|
+
const ids = new Set(sections.map(({ phaseId }) => phaseId))
|
|
49
|
+
return sections.flatMap(({ phaseId, targets, ...tally }) => {
|
|
50
|
+
const stranded = targets.filter((target) => !ids.has(target)).length
|
|
51
|
+
const arriving = sections.flatMap((each) => each.targets).filter((target) => target === phaseId).length
|
|
52
|
+
const open = tally.open + stranded + arriving
|
|
53
|
+
return tally.done + open === 0 ? [] : [{ ...tally, open }]
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { PHASE_ID_PREFIX } from './lanes'
|
|
3
|
+
import { escapeRegExp } from './markdown'
|
|
4
|
+
import { terminalSafe } from './terminal-text'
|
|
5
|
+
|
|
6
|
+
/** A checkbox of a plan. `gate` is set on the ones listed under the phase's review gate, and only on those. */
|
|
7
|
+
export type ChecklistItem = { done: boolean; text: string; gate?: true }
|
|
8
|
+
|
|
9
|
+
/** An open checkbox that ends in `→ P<n>`: moved on purpose to phase `target`, its text without the marker. */
|
|
10
|
+
export type DeferredItem = { text: string; target: string }
|
|
11
|
+
|
|
12
|
+
/** An open checkbox whose phase arrow is not at the end of its line (1-based), with the form the parser reads. */
|
|
13
|
+
export type MisplacedDeferral = { line: number; target: string; corrected: string }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `touched` is set by the phase source when the worktree's ticks differ from the base branch's. `deferred` holds
|
|
17
|
+
* the checkboxes moved to another phase; they are not among `items`, so they count neither as open nor as done.
|
|
18
|
+
*/
|
|
19
|
+
export type PlanPhase = { id: string; title: string; items: ChecklistItem[]; deferred?: DeferredItem[]; touched?: true }
|
|
20
|
+
|
|
21
|
+
const REVIEW_GATE = /^\s*\*\*review[- ]gate\b/i
|
|
22
|
+
const PHASE_END = /^#{1,3}\s/
|
|
23
|
+
const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
|
|
24
|
+
const SECTION_HEADING = /^##\s+(.+?)\s*$/
|
|
25
|
+
const TOP_HEADING = /^#\s/
|
|
26
|
+
const TABLE_STATUS = { done: '✅', open: '⬜' } as const
|
|
27
|
+
const ROW_NUMBER = /^\d+(\.\d+)*[a-z]?$/
|
|
28
|
+
const NUMBERED_SECTION = /^##\s+(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
|
|
29
|
+
const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
|
|
30
|
+
const DEFERRED_TO = new RegExp(`\\s→\\s*${PHASE_ID_PREFIX}(\\d+[a-z]?)\\s*$`, 'i')
|
|
31
|
+
/** A phase arrow anywhere in a line, with the whitespace before it. */
|
|
32
|
+
const PHASE_ARROW = new RegExp(`\\s*→\\s*${PHASE_ID_PREFIX}(\\d+[a-z]?)\\b`, 'i')
|
|
33
|
+
|
|
34
|
+
/** The line that opens a phase's review gate: `**Review-Gate:**`, `**Review gate** (…)`. */
|
|
35
|
+
export const isReviewGateLine = (line: string): boolean => REVIEW_GATE.test(line)
|
|
36
|
+
|
|
37
|
+
/** A heading of level three or higher, which ends the section of a phase. */
|
|
38
|
+
export const endsPhase = (line: string): boolean => PHASE_END.test(line)
|
|
39
|
+
|
|
40
|
+
export const isCheckboxLine = (line: string): boolean => CHECKBOX.test(line)
|
|
41
|
+
|
|
42
|
+
export const isOpenCheckboxLine = (line: string): boolean => line.match(CHECKBOX)?.[1] === ' '
|
|
43
|
+
|
|
44
|
+
export const phaseHeadingPattern = (language: PlanLanguage): RegExp =>
|
|
45
|
+
new RegExp(`^###\\s+${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\b\\s*[:—–-]?\\s*(.*)$`, 'i')
|
|
46
|
+
|
|
47
|
+
const tableCells = (line: string): string[] | null => {
|
|
48
|
+
const trimmed = line.trim()
|
|
49
|
+
if (!trimmed.startsWith('|')) return null
|
|
50
|
+
return trimmed
|
|
51
|
+
.replace(/^\|/, '')
|
|
52
|
+
.replace(/\|$/, '')
|
|
53
|
+
.split('|')
|
|
54
|
+
.map((cell) => cell.trim())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A table row with a cell that is exactly ✅ or ⬜: its other cells, minus a leading row number, are the text. */
|
|
58
|
+
const tableItemOf = (line: string): ChecklistItem | null => {
|
|
59
|
+
const cells = tableCells(line)
|
|
60
|
+
const statusIndex = cells?.findIndex((cell) => cell === TABLE_STATUS.done || cell === TABLE_STATUS.open) ?? -1
|
|
61
|
+
if (!cells || statusIndex < 0) return null
|
|
62
|
+
const rest = cells.filter((_, index) => index !== statusIndex)
|
|
63
|
+
const text = (ROW_NUMBER.test(rest[0] ?? '') ? rest.slice(1) : rest).filter((cell) => cell !== '').join(' · ')
|
|
64
|
+
return { done: cells[statusIndex] === TABLE_STATUS.done, text: terminalSafe(text).trim() }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const checkboxItemOf = (line: string): ChecklistItem | null => {
|
|
68
|
+
const checkbox = line.match(CHECKBOX)
|
|
69
|
+
return checkbox ? { done: checkbox[1] !== ' ', text: terminalSafe(checkbox[2] ?? '').trim() } : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const checklistItemOf = (line: string, gate = false): ChecklistItem | null => {
|
|
73
|
+
const item = checkboxItemOf(line) ?? tableItemOf(line)
|
|
74
|
+
if (!item) return null
|
|
75
|
+
return gate ? { ...item, gate: true } : item
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The items (checkboxes and ✅/⬜ table rows) of every `## ` section by its heading, sub-headings included. */
|
|
79
|
+
const sectionItems = (markdown: string): Map<string, ChecklistItem[]> => {
|
|
80
|
+
const sections = new Map<string, ChecklistItem[]>()
|
|
81
|
+
let open: ChecklistItem[] | null = null
|
|
82
|
+
for (const line of markdown.split('\n')) {
|
|
83
|
+
const heading = line.match(SECTION_HEADING)
|
|
84
|
+
if (heading?.[1]) {
|
|
85
|
+
open = []
|
|
86
|
+
sections.set(heading[1], open)
|
|
87
|
+
} else if (TOP_HEADING.test(line)) {
|
|
88
|
+
open = null
|
|
89
|
+
} else {
|
|
90
|
+
const item = checklistItemOf(line)
|
|
91
|
+
if (item) open?.push(item)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return sections
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Gives a phase without checkboxes of its own the ones of the section it points at. Sections are
|
|
99
|
+
* cut like the orchestrate script does (to the next `# ` or `## ` heading, the last of equal headings
|
|
100
|
+
* wins), so the counts of both agree.
|
|
101
|
+
*/
|
|
102
|
+
const withPointedSections = (
|
|
103
|
+
phases: PlanPhase[],
|
|
104
|
+
pointers: ReadonlyMap<PlanPhase, string>,
|
|
105
|
+
markdown: string,
|
|
106
|
+
): PlanPhase[] => {
|
|
107
|
+
if (pointers.size === 0) return phases
|
|
108
|
+
const sections = sectionItems(markdown)
|
|
109
|
+
return phases.map((phase) => {
|
|
110
|
+
const pointer = pointers.get(phase)
|
|
111
|
+
return phase.items.length === 0 && pointer ? { ...phase, items: sections.get(pointer) ?? [] } : phase
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The phase an open checkbox line is moved to with `→ P<n>` at its end; null for any other line, a ticked box,
|
|
117
|
+
* a ✅/⬜ table row, and an arrow to the box's own phase `phaseId`. The one rule the parser and the tally share.
|
|
118
|
+
*/
|
|
119
|
+
export const deferralTarget = (line: string, phaseId: string): string | null => {
|
|
120
|
+
const checkbox = line.match(CHECKBOX)
|
|
121
|
+
if (checkbox?.[1] !== ' ') return null
|
|
122
|
+
const target = checkbox[2]?.match(DEFERRED_TO)?.[1]?.toLowerCase()
|
|
123
|
+
return target && target !== phaseId ? target : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const misplacedDeferralOf = (line: string, index: number): MisplacedDeferral[] => {
|
|
127
|
+
const checkbox = line.match(CHECKBOX)
|
|
128
|
+
if (checkbox?.[1] !== ' ' || DEFERRED_TO.test(checkbox[2])) return []
|
|
129
|
+
const arrow = checkbox[2].match(PHASE_ARROW)
|
|
130
|
+
if (!arrow) return []
|
|
131
|
+
const rest = checkbox[2].replace(PHASE_ARROW, '').trim()
|
|
132
|
+
const marker = line.slice(0, line.length - checkbox[2].length)
|
|
133
|
+
return [{ line: index + 1, target: arrow[1], corrected: `${marker}${rest} → ${PHASE_ID_PREFIX}${arrow[1]}` }]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Open checkboxes that carry `→ P<n>` somewhere other than at the end of the line. `deferralTarget` reads the arrow
|
|
138
|
+
* only there, so such a box counts as open in its own phase without a word; `jh check` refuses it. In an
|
|
139
|
+
* open checkbox the phase arrow stands only at the end, also where it would be meant as prose (`→ P2 prüfen`).
|
|
140
|
+
*/
|
|
141
|
+
export const misplacedDeferrals = (markdown: string): MisplacedDeferral[] =>
|
|
142
|
+
markdown.split('\n').flatMap(misplacedDeferralOf)
|
|
143
|
+
|
|
144
|
+
const deferredItemOf = (line: string, phaseId: string): DeferredItem | null => {
|
|
145
|
+
const target = deferralTarget(line, phaseId)
|
|
146
|
+
const text = line.match(CHECKBOX)?.[2] ?? ''
|
|
147
|
+
return target ? { text: terminalSafe(text.replace(DEFERRED_TO, '')).trim(), target } : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A deferred checkbox as its target phase lists it: an open, plain task with its origin. */
|
|
151
|
+
const arrivedItem = ({ text }: DeferredItem, origin: PlanPhase): ChecklistItem => ({
|
|
152
|
+
done: false,
|
|
153
|
+
text: `${text} ← ${PHASE_ID_PREFIX}${origin.id}`,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Appends every deferred checkbox to its target phase as an open task. One whose target the plan lacks goes back
|
|
158
|
+
* among its own phase's items, open and with its marker, so a mistyped target never drops a task from the count.
|
|
159
|
+
*/
|
|
160
|
+
const withArrivedItems = (phases: PlanPhase[]): PlanPhase[] => {
|
|
161
|
+
const ids = new Set(phases.map(({ id }) => id))
|
|
162
|
+
const moved = phases.flatMap((origin) => (origin.deferred ?? []).map((item) => ({ origin, item })))
|
|
163
|
+
if (moved.length === 0) return phases
|
|
164
|
+
return phases.map((phase) => {
|
|
165
|
+
const arriving = moved
|
|
166
|
+
.filter(({ item }) => item.target === phase.id)
|
|
167
|
+
.map(({ item, origin }) => arrivedItem(item, origin))
|
|
168
|
+
const stranded = (phase.deferred ?? []).filter(({ target }) => !ids.has(target))
|
|
169
|
+
const deferred = (phase.deferred ?? []).filter(({ target }) => ids.has(target))
|
|
170
|
+
const returned = stranded.map(({ text, target }) => ({
|
|
171
|
+
done: false,
|
|
172
|
+
text: `${text} → ${PHASE_ID_PREFIX}${target}`,
|
|
173
|
+
}))
|
|
174
|
+
const { deferred: _, ...rest } = phase
|
|
175
|
+
return { ...rest, items: [...phase.items, ...returned, ...arriving], ...(deferred.length > 0 ? { deferred } : {}) }
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Files one line of an open phase: a deferred checkbox under `deferred`, any other checkbox or table row under `items`. */
|
|
180
|
+
const fileLine = (phase: PlanPhase, line: string, gate: boolean): void => {
|
|
181
|
+
const deferred = deferredItemOf(line, phase.id)
|
|
182
|
+
if (deferred) {
|
|
183
|
+
phase.deferred = [...(phase.deferred ?? []), deferred]
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
const item = checklistItemOf(line, gate)
|
|
187
|
+
if (item) phase.items.push(item)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const notePointer = (pointers: Map<PlanPhase, string>, phase: PlanPhase | null, line: string): void => {
|
|
191
|
+
const pointer = phase && !pointers.has(phase) ? line.match(SECTION_POINTER)?.[1] : undefined
|
|
192
|
+
if (phase && pointer) pointers.set(phase, pointer)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const headingOf = (line: string, phaseHeading: RegExp): RegExpMatchArray | null =>
|
|
196
|
+
line.match(phaseHeading) ?? line.match(NUMBERED_SECTION)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every `### Phase <id>` section, and every `## <id> — title` section, with its checkboxes. A
|
|
200
|
+
* section runs to the next heading of level three or higher, so a review-gate list or a `####`
|
|
201
|
+
* sub-heading inside the phase still counts. A phase without checkboxes of its own that points at
|
|
202
|
+
* a section (``Siehe `## Dokumentation` ``) counts that section's items, so it is done when the
|
|
203
|
+
* section is. An open checkbox ending in `→ P<n>` is kept under `deferred` and listed in phase `<n>` as open.
|
|
204
|
+
*/
|
|
205
|
+
export const parsePlanPhases = (markdown: string, language: PlanLanguage): PlanPhase[] => {
|
|
206
|
+
const phaseHeading = phaseHeadingPattern(language)
|
|
207
|
+
const pointers = new Map<PlanPhase, string>()
|
|
208
|
+
const { phases } = markdown.split('\n').reduce<{ phases: PlanPhase[]; open: PlanPhase | null; gate: boolean }>(
|
|
209
|
+
({ phases, open, gate }, line) => {
|
|
210
|
+
const heading = headingOf(line, phaseHeading)
|
|
211
|
+
if (heading) {
|
|
212
|
+
const phase = { id: (heading[1] ?? '').toLowerCase(), title: terminalSafe(heading[2] ?? '').trim(), items: [] }
|
|
213
|
+
return { phases: [...phases, phase], open: phase, gate: false }
|
|
214
|
+
}
|
|
215
|
+
if (PHASE_END.test(line)) return { phases, open: null, gate: false }
|
|
216
|
+
const inGate = gate || REVIEW_GATE.test(line)
|
|
217
|
+
if (open) fileLine(open, line, inGate)
|
|
218
|
+
notePointer(pointers, open, line)
|
|
219
|
+
return { phases, open, gate: inGate }
|
|
220
|
+
},
|
|
221
|
+
{ phases: [], open: null, gate: false },
|
|
222
|
+
)
|
|
223
|
+
return withArrivedItems(withPointedSections(phases, pointers, markdown))
|
|
224
|
+
}
|