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,136 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { findProjectRoot, globalConfigPath, locateConfigFile } from '../config/location'
|
|
6
|
+
import { readRepositoryFacts } from '../config/repository-facts'
|
|
7
|
+
import { baseRefOf } from '../plans/settings'
|
|
8
|
+
import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
|
|
9
|
+
import { deferralArrowCheck } from './deferral-arrows'
|
|
10
|
+
import { runCheck, runProcess } from './execute'
|
|
11
|
+
import { type CheckPlan, type Filesystem, isSlow, planChecks } from './plan'
|
|
12
|
+
import { planStructureChecks } from './plan-structure-check'
|
|
13
|
+
import { publicContentCheck } from './public-content-check'
|
|
14
|
+
import { artifactResult, formatPlanHeader, formatResults, skippedSlowResult, summarize } from './report'
|
|
15
|
+
import { type CheckResult, SKIP_SLOW_FLAG } from './result'
|
|
16
|
+
import { type CheckSettings, CONFIG_PATH, parseCheckSettings } from './settings'
|
|
17
|
+
|
|
18
|
+
type CheckTarget = { root: string; configPath: string }
|
|
19
|
+
|
|
20
|
+
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where the checks run and which config they read: the nearest project with `.jackherd/config.json`, else the
|
|
24
|
+
* repository's top level with `~/.jackherd/config.json` (a global install in a repository without the kit).
|
|
25
|
+
*/
|
|
26
|
+
export const resolveCheckTarget = (
|
|
27
|
+
start: string,
|
|
28
|
+
home: string,
|
|
29
|
+
exists: (path: string) => boolean,
|
|
30
|
+
gitTopLevel: (start: string) => string | null,
|
|
31
|
+
): CheckTarget | null => {
|
|
32
|
+
const project = findProjectRoot(start, home, exists)
|
|
33
|
+
const projectConfig = project && locateConfigFile([project], home, exists)
|
|
34
|
+
if (project && projectConfig) return { root: project, configPath: projectConfig }
|
|
35
|
+
const global = globalConfigPath(home)
|
|
36
|
+
if (!exists(global)) return null
|
|
37
|
+
const root = gitTopLevel(start)
|
|
38
|
+
return root ? { root, configPath: global } : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const gitTopLevelOf = (start: string): string | null => {
|
|
42
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: start, encoding: 'utf8' })
|
|
43
|
+
return result.status === 0 ? result.stdout.trim() : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A file as `ref` holds it; null when the ref lacks it (a new file) or git cannot answer. */
|
|
47
|
+
const contentAt =
|
|
48
|
+
(root: string, ref: string) =>
|
|
49
|
+
(path: string): string | null => {
|
|
50
|
+
const result = spawnSync('git', ['show', `${ref}:${path}`], { cwd: root, encoding: 'utf8' })
|
|
51
|
+
return result.status === 0 ? result.stdout : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const filesystemAt = (root: string): Filesystem => ({
|
|
55
|
+
hasPackageJson: (dir) => existsSync(join(root, dir, 'package.json')),
|
|
56
|
+
hasTsconfig: (dir) => existsSync(join(root, dir, 'tsconfig.json')),
|
|
57
|
+
hasTests: (dir) => existsSync(join(root, dir)) && readdirSync(join(root, dir)).some((entry) => TEST_FILE.test(entry)),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const print = (lines: readonly string[]): void => {
|
|
61
|
+
for (const line of lines) console.log(line)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const loadSettings = (configPath: string, root: string): CheckSettings | string => {
|
|
65
|
+
try {
|
|
66
|
+
return parseCheckSettings(JSON.parse(readFileSync(configPath, 'utf8')), readRepositoryFacts(root))
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return `${configPath} is not valid: ${error instanceof Error ? error.message : String(error)}`
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const changedFiles = async (root: string): Promise<ChangedFile[] | string> => {
|
|
73
|
+
const status = await runProcess(STATUS_COMMAND, root)
|
|
74
|
+
return status.ok ? parsePorcelain(status.output) : status.output
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const executePlan = async (
|
|
78
|
+
plan: CheckPlan,
|
|
79
|
+
root: string,
|
|
80
|
+
skipSlow: boolean,
|
|
81
|
+
ownChecks: readonly (CheckResult | null)[],
|
|
82
|
+
): Promise<CheckResult[]> => {
|
|
83
|
+
const [slow, quick] = [plan.checks.filter(isSlow), plan.checks.filter((check) => !isSlow(check))]
|
|
84
|
+
return [
|
|
85
|
+
artifactResult(plan.artifacts),
|
|
86
|
+
...ownChecks.filter((result) => result !== null),
|
|
87
|
+
...(await Promise.all(quick.map((check) => runCheck(check, root)))),
|
|
88
|
+
...(skipSlow ? [skippedSlowResult()] : await Promise.all(slow.map((check) => runCheck(check, root)))),
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Runs the configured checks over the files `git status` reports. Exit code 1 when any check fails
|
|
94
|
+
* or the configuration cannot be read.
|
|
95
|
+
*/
|
|
96
|
+
export const run = async (args: string[], home: string = homedir()): Promise<number> => {
|
|
97
|
+
const target = resolveCheckTarget(process.cwd(), home, existsSync, gitTopLevelOf)
|
|
98
|
+
if (!target) {
|
|
99
|
+
console.error(`No ${CONFIG_PATH} found in this directory or above it, and no ~/${CONFIG_PATH} in a git repository.`)
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
const { root, configPath } = target
|
|
103
|
+
const settings = loadSettings(configPath, root)
|
|
104
|
+
const files = await changedFiles(root)
|
|
105
|
+
if (typeof settings === 'string' || typeof files === 'string') {
|
|
106
|
+
console.error(typeof settings === 'string' ? settings : files)
|
|
107
|
+
return 1
|
|
108
|
+
}
|
|
109
|
+
print(settings.notes)
|
|
110
|
+
if (files.length === 0) {
|
|
111
|
+
console.log('Nothing changed.')
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const plan = planChecks(files, settings.commands, settings.check, filesystemAt(root))
|
|
116
|
+
print(formatPlanHeader(files, plan))
|
|
117
|
+
const publicContent = publicContentCheck(root, plan.paths, settings.check.publicContent, home)
|
|
118
|
+
const deferralArrows = deferralArrowCheck(root, plan.paths, settings.planDirectory)
|
|
119
|
+
const planStructure = planStructureChecks({
|
|
120
|
+
root,
|
|
121
|
+
paths: plan.paths,
|
|
122
|
+
planDirectory: settings.planDirectory,
|
|
123
|
+
language: settings.planLanguage,
|
|
124
|
+
previousOf: contentAt(root, 'HEAD'),
|
|
125
|
+
baseOf: contentAt(root, baseRefOf(settings)),
|
|
126
|
+
})
|
|
127
|
+
const results = await executePlan(plan, root, args.includes(SKIP_SLOW_FLAG), [
|
|
128
|
+
publicContent,
|
|
129
|
+
deferralArrows,
|
|
130
|
+
...planStructure,
|
|
131
|
+
])
|
|
132
|
+
print(formatResults(results))
|
|
133
|
+
const summary = summarize(results)
|
|
134
|
+
console.log(`\n${summary.line}`)
|
|
135
|
+
return summary.failed === 0 ? 0 : 1
|
|
136
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, posix } from 'node:path'
|
|
3
|
+
import { type MisplacedDeferral, misplacedDeferrals } from '../plan-format'
|
|
4
|
+
import { CHECK_STATUS, type CheckResult } from './result'
|
|
5
|
+
|
|
6
|
+
type DeferralFinding = MisplacedDeferral & { path: string }
|
|
7
|
+
|
|
8
|
+
/** The changed Markdown files under the plan directory: the plans (and boards) this diff touches. */
|
|
9
|
+
export const changedPlansOf = (paths: readonly string[], planDirectory: string): string[] => {
|
|
10
|
+
const directory = posix.normalize(planDirectory).replace(/\/+$/, '')
|
|
11
|
+
return paths.filter((path) => path.startsWith(`${directory}/`) && path.endsWith('.md'))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const deferralArrowResult = (findings: readonly DeferralFinding[], plans: number, durationMs: number): CheckResult => ({
|
|
15
|
+
name: 'Plan deferral arrows',
|
|
16
|
+
command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, open checkboxes with → P<n>`,
|
|
17
|
+
status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.fail,
|
|
18
|
+
detail:
|
|
19
|
+
findings
|
|
20
|
+
.map(({ path, line, corrected }) => `${path}:${line} has the phase arrow inside the line; write: ${corrected}`)
|
|
21
|
+
.join('\n') || 'every phase arrow ends its line',
|
|
22
|
+
durationMs,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open checkboxes in the changed plans whose `→ P<n>` is not at the end of the line, where the plans view reads
|
|
27
|
+
* it. Plans outside the diff are not read. Null when no plan changed.
|
|
28
|
+
*/
|
|
29
|
+
export const deferralArrowCheck = (
|
|
30
|
+
root: string,
|
|
31
|
+
paths: readonly string[],
|
|
32
|
+
planDirectory: string,
|
|
33
|
+
): CheckResult | null => {
|
|
34
|
+
const plans = changedPlansOf(paths, planDirectory)
|
|
35
|
+
if (plans.length === 0) return null
|
|
36
|
+
const started = Date.now()
|
|
37
|
+
const findings = plans
|
|
38
|
+
.filter((path) => existsSync(join(root, path)))
|
|
39
|
+
.flatMap((path) => misplacedDeferrals(readFileSync(join(root, path), 'utf8')).map((found) => ({ ...found, path })))
|
|
40
|
+
return deferralArrowResult(findings, plans.length, Date.now() - started)
|
|
41
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import type { PlannedCheck } from './plan'
|
|
3
|
+
import { type CheckResult, outcomeOf, type ProcessResult } from './result'
|
|
4
|
+
|
|
5
|
+
/** Output is `trimEnd`ed only: a leading space can be significant (git status columns). */
|
|
6
|
+
export const runProcess = async (argv: readonly string[], cwd: string): Promise<ProcessResult> => {
|
|
7
|
+
try {
|
|
8
|
+
const proc = Bun.spawn([...argv], { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
9
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
10
|
+
new Response(proc.stdout).text(),
|
|
11
|
+
new Response(proc.stderr).text(),
|
|
12
|
+
proc.exited,
|
|
13
|
+
])
|
|
14
|
+
return { ok: exitCode === 0, output: `${stdout}${stderr}`.trimEnd() }
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return { ok: false, output: error instanceof Error ? error.message : String(error) }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const runCheck = async (check: PlannedCheck, workspaceRoot: string): Promise<CheckResult> => {
|
|
21
|
+
const startedAt = performance.now()
|
|
22
|
+
const processResult = await runProcess(check.argv, join(workspaceRoot, check.cwd))
|
|
23
|
+
return {
|
|
24
|
+
name: check.name,
|
|
25
|
+
command: check.command,
|
|
26
|
+
...outcomeOf(check, processResult),
|
|
27
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const GLOB_TOKENS: Record<string, string> = {
|
|
2
|
+
'**/': '(?:.*/)?',
|
|
3
|
+
'**': '.*',
|
|
4
|
+
'*': '[^/]*',
|
|
5
|
+
'?': '[^/]',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** One pass over the glob, so a literal `.` before a `*` is never mistaken for the `.*` a `**` produced. */
|
|
9
|
+
export const matchesGlob = (path: string, glob: string): boolean => {
|
|
10
|
+
const pattern = glob.replace(/\*\*\/|\*\*|\*|\?|[.+^${}()|[\]\\]/g, (token) => GLOB_TOKENS[token] ?? `\\${token}`)
|
|
11
|
+
return new RegExp(`^${pattern}$`).test(path)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const matchesAnyGlob = (path: string, globs: readonly string[]): boolean =>
|
|
15
|
+
globs.some((glob) => matchesGlob(path, glob))
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
boardRowsWithoutPhase,
|
|
5
|
+
hasLanesTable,
|
|
6
|
+
type MergedPhaseWithOpenBoxes,
|
|
7
|
+
mergedPhasesWithOpenBoxes,
|
|
8
|
+
newPhasesWithoutReviewGate,
|
|
9
|
+
type PhaseWithoutGate,
|
|
10
|
+
PLAN_VOCABULARY,
|
|
11
|
+
type PlanLanguage,
|
|
12
|
+
phaseIdsOfPlan,
|
|
13
|
+
type RowWithoutPhase,
|
|
14
|
+
} from '../plan-format'
|
|
15
|
+
import { changedPlansOf } from './deferral-arrows'
|
|
16
|
+
import { CHECK_STATUS, type CheckResult } from './result'
|
|
17
|
+
|
|
18
|
+
/** The content of a file at a ref; null where the ref lacks the file. */
|
|
19
|
+
export type ContentAt = (path: string) => string | null
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What the plan-structure checks read: the changed paths under `root`, the plan directory and language, and each
|
|
23
|
+
* changed file as it was before the change (`previousOf`, at `HEAD`) and on the base branch (`baseOf`).
|
|
24
|
+
*/
|
|
25
|
+
export type PlanCheckInput = {
|
|
26
|
+
root: string
|
|
27
|
+
paths: readonly string[]
|
|
28
|
+
planDirectory: string
|
|
29
|
+
language: PlanLanguage
|
|
30
|
+
previousOf: ContentAt
|
|
31
|
+
baseOf: ContentAt
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Changed = { path: string; current: string; previous: string | null; base: string | null; board: boolean }
|
|
35
|
+
|
|
36
|
+
type Found<T> = T & { path: string }
|
|
37
|
+
|
|
38
|
+
const gateResult = (
|
|
39
|
+
findings: readonly Found<PhaseWithoutGate>[],
|
|
40
|
+
plans: number,
|
|
41
|
+
language: PlanLanguage,
|
|
42
|
+
durationMs: number,
|
|
43
|
+
): CheckResult => ({
|
|
44
|
+
name: 'Plan review gates',
|
|
45
|
+
command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, new phases need a review gate`,
|
|
46
|
+
status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.fail,
|
|
47
|
+
detail:
|
|
48
|
+
findings
|
|
49
|
+
.map(
|
|
50
|
+
({ path, line, id, title }) =>
|
|
51
|
+
`${path}:${line} Phase ${id} ${title} has tasks but no review gate; add one, or a **${PLAN_VOCABULARY[language].noReviewGate}:** line with the reason`,
|
|
52
|
+
)
|
|
53
|
+
.join('\n') || 'every new phase has a review gate',
|
|
54
|
+
durationMs,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const rowResult = (findings: readonly Found<RowWithoutPhase>[], boards: number, durationMs: number): CheckResult => ({
|
|
58
|
+
name: 'Board rows without a phase',
|
|
59
|
+
command: `${boards} changed ${boards === 1 ? 'board' : 'boards'}, changed lane rows name a phase`,
|
|
60
|
+
status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.warn,
|
|
61
|
+
detail:
|
|
62
|
+
findings
|
|
63
|
+
.map(
|
|
64
|
+
({ path, line, lane, phases }) =>
|
|
65
|
+
`${path}:${line} lane ${lane}: "${phases}" names no phase (P<n>), so the view cannot tie the step to one`,
|
|
66
|
+
)
|
|
67
|
+
.join('\n') || 'every changed lane row names a phase',
|
|
68
|
+
durationMs,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const mergedResult = (
|
|
72
|
+
findings: readonly Found<MergedPhaseWithOpenBoxes>[],
|
|
73
|
+
plans: number,
|
|
74
|
+
durationMs: number,
|
|
75
|
+
): CheckResult => ({
|
|
76
|
+
name: 'Merged phases with open boxes',
|
|
77
|
+
command: `${plans} changed ${plans === 1 ? 'plan' : 'plans'}, a merged phase ticks or moves on its open boxes`,
|
|
78
|
+
status: findings.length === 0 ? CHECK_STATUS.pass : CHECK_STATUS.warn,
|
|
79
|
+
detail:
|
|
80
|
+
findings
|
|
81
|
+
.map(
|
|
82
|
+
({ path, line, id, title, openLines }) =>
|
|
83
|
+
`${path}:${line} Phase ${id} ${title} is merged but has open boxes at ${openLines.join(', ')}; tick them or end them with → P<n>`,
|
|
84
|
+
)
|
|
85
|
+
.join('\n') || 'no merged phase has an open box left',
|
|
86
|
+
durationMs,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const withPath =
|
|
90
|
+
<T>(path: string) =>
|
|
91
|
+
(found: T): Found<T> => ({ ...found, path })
|
|
92
|
+
|
|
93
|
+
const gatesOf = ({ path, current, previous }: Changed, language: PlanLanguage): Found<PhaseWithoutGate>[] =>
|
|
94
|
+
newPhasesWithoutReviewGate(current, language, previous ? phaseIdsOfPlan(previous, language) : new Set()).map(
|
|
95
|
+
withPath(path),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const rowsOf = ({ path, current, previous }: Changed, language: PlanLanguage): Found<RowWithoutPhase>[] =>
|
|
99
|
+
boardRowsWithoutPhase(current, language, new Set(previous?.split('\n') ?? [])).map(withPath(path))
|
|
100
|
+
|
|
101
|
+
const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
|
|
102
|
+
base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
|
|
103
|
+
|
|
104
|
+
const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput, plans: readonly string[]): Changed[] =>
|
|
105
|
+
plans
|
|
106
|
+
.filter((path) => existsSync(join(root, path)))
|
|
107
|
+
.map((path) => {
|
|
108
|
+
const current = readFileSync(join(root, path), 'utf8')
|
|
109
|
+
return { path, current, previous: previousOf(path), base: baseOf(path), board: hasLanesTable(current, language) }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The structure of the changed plans and boards: a phase the change added (against `HEAD`) with tasks and without
|
|
114
|
+
* a review gate fails; a lane row the change added or edited that names no phase warns; a phase whose tasks are
|
|
115
|
+
* all ticked on the base branch and that still has open boxes without `→ P<n>` warns. Empty when no plan changed;
|
|
116
|
+
* the row result is left out when no changed file has a lanes table, the merged result when every one has.
|
|
117
|
+
*/
|
|
118
|
+
export const planStructureChecks = (input: PlanCheckInput): CheckResult[] => {
|
|
119
|
+
const files = changedPlansOf(input.paths, input.planDirectory)
|
|
120
|
+
if (files.length === 0) return []
|
|
121
|
+
const { language } = input
|
|
122
|
+
const started = Date.now()
|
|
123
|
+
const changed = changedContents(input, files)
|
|
124
|
+
const boards = changed.filter(({ board }) => board)
|
|
125
|
+
const plans = changed.filter(({ board }) => !board)
|
|
126
|
+
const gates = changed.flatMap((file) => gatesOf(file, language))
|
|
127
|
+
const rows = boards.flatMap((file) => rowsOf(file, language))
|
|
128
|
+
const merged = plans.flatMap((file) => mergedOf(file, language))
|
|
129
|
+
const durationMs = Date.now() - started
|
|
130
|
+
return [
|
|
131
|
+
gateResult(gates, files.length, language, durationMs),
|
|
132
|
+
...(boards.length > 0 ? [rowResult(rows, boards.length, durationMs)] : []),
|
|
133
|
+
...(plans.length > 0 ? [mergedResult(merged, plans.length, durationMs)] : []),
|
|
134
|
+
]
|
|
135
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { JackherdConfig } from '../config'
|
|
2
|
+
import { type ChangedFile, generatorOwnedFiles, stagedBuildArtifacts } from './changes'
|
|
3
|
+
import { tokenize } from './command-line'
|
|
4
|
+
import { looseDirectories, packageRootsFor } from './roots'
|
|
5
|
+
import { projectChecksFor, publicContentReviewers, reviewersFor } from './routing'
|
|
6
|
+
import type { CheckConfig } from './schema'
|
|
7
|
+
|
|
8
|
+
export const CHECK_KIND = { lint: 'lint', project: 'project', types: 'types', tests: 'tests' } as const
|
|
9
|
+
|
|
10
|
+
export type CheckKind = (typeof CHECK_KIND)[keyof typeof CHECK_KIND]
|
|
11
|
+
|
|
12
|
+
export type PlannedCheck = {
|
|
13
|
+
name: string
|
|
14
|
+
/** What the report shows; `argv` is what runs. */
|
|
15
|
+
command: string
|
|
16
|
+
argv: string[]
|
|
17
|
+
/** Relative to the workspace root. */
|
|
18
|
+
cwd: string
|
|
19
|
+
kind: CheckKind
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type PackageCommands = Pick<JackherdConfig['commands'], 'test' | 'typecheck'>
|
|
23
|
+
|
|
24
|
+
export type Filesystem = {
|
|
25
|
+
hasPackageJson: (dir: string) => boolean
|
|
26
|
+
hasTsconfig: (dir: string) => boolean
|
|
27
|
+
hasTests: (dir: string) => boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CheckPlan = {
|
|
31
|
+
paths: string[]
|
|
32
|
+
roots: string[]
|
|
33
|
+
reviewers: string[]
|
|
34
|
+
generated: { path: string; generator: string }[]
|
|
35
|
+
artifacts: string[]
|
|
36
|
+
/** Fast checks first, then the ones that scale with the package; the runner starts each group together. */
|
|
37
|
+
checks: PlannedCheck[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const isSlow = (check: PlannedCheck): boolean =>
|
|
41
|
+
check.kind === CHECK_KIND.types || check.kind === CHECK_KIND.tests
|
|
42
|
+
|
|
43
|
+
const lintCheck = (config: CheckConfig, paths: readonly string[]): PlannedCheck => ({
|
|
44
|
+
name: 'Lint & unused imports',
|
|
45
|
+
command: `${config.lintFilesCommand} (${paths.length} files)`,
|
|
46
|
+
argv: [...tokenize(config.lintFilesCommand), ...paths],
|
|
47
|
+
cwd: '.',
|
|
48
|
+
kind: CHECK_KIND.lint,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const projectChecks = (config: CheckConfig, paths: readonly string[]): PlannedCheck[] =>
|
|
52
|
+
projectChecksFor(paths, config.checks).map(({ name, command }) => ({
|
|
53
|
+
name,
|
|
54
|
+
command,
|
|
55
|
+
argv: tokenize(command),
|
|
56
|
+
cwd: '.',
|
|
57
|
+
kind: CHECK_KIND.project,
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
const inDirectory = (kind: CheckKind, label: string, command: string, dir: string): PlannedCheck => ({
|
|
61
|
+
name: `${label} (${dir})`,
|
|
62
|
+
command: `cd ${dir} && ${command}`,
|
|
63
|
+
argv: tokenize(command),
|
|
64
|
+
cwd: dir,
|
|
65
|
+
kind,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const typeChecks = (
|
|
69
|
+
roots: readonly string[],
|
|
70
|
+
command: string,
|
|
71
|
+
hasTsconfig: Filesystem['hasTsconfig'],
|
|
72
|
+
): PlannedCheck[] => roots.filter(hasTsconfig).map((root) => inDirectory(CHECK_KIND.types, 'Types', command, root))
|
|
73
|
+
|
|
74
|
+
const testChecks = (dirs: readonly string[], command: string): PlannedCheck[] =>
|
|
75
|
+
dirs.map((dir) => inDirectory(CHECK_KIND.tests, 'Tests', command, dir))
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Decides, without touching the disk or running anything, what a set of changed files calls for:
|
|
79
|
+
* which package roots to type-check and test, which project checks fire, which reviewers to start.
|
|
80
|
+
*/
|
|
81
|
+
export const planChecks = (
|
|
82
|
+
files: readonly ChangedFile[],
|
|
83
|
+
commands: PackageCommands,
|
|
84
|
+
config: CheckConfig,
|
|
85
|
+
filesystem: Filesystem,
|
|
86
|
+
): CheckPlan => {
|
|
87
|
+
const paths = files.map((file) => file.path)
|
|
88
|
+
const roots = packageRootsFor(paths, filesystem.hasPackageJson).filter((root) => root !== '.')
|
|
89
|
+
const looseDirs = config.looseTests ? looseDirectories(paths, roots, filesystem.hasTests) : []
|
|
90
|
+
return {
|
|
91
|
+
paths,
|
|
92
|
+
roots,
|
|
93
|
+
reviewers: [...reviewersFor(paths, config.reviewerRoutes), ...publicContentReviewers(paths, config.publicContent)],
|
|
94
|
+
generated: generatorOwnedFiles(paths, config.generatorOwned),
|
|
95
|
+
artifacts: stagedBuildArtifacts(files, config.buildArtifacts),
|
|
96
|
+
checks:
|
|
97
|
+
paths.length === 0
|
|
98
|
+
? []
|
|
99
|
+
: [
|
|
100
|
+
lintCheck(config, paths),
|
|
101
|
+
...projectChecks(config, paths),
|
|
102
|
+
...typeChecks(roots, commands.typecheck, filesystem.hasTsconfig),
|
|
103
|
+
...testChecks([...roots, ...looseDirs], commands.test),
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { deriveTerms, findPublicLeaks, publicContentResult, withoutVocabulary } from './public-content'
|
|
2
|
+
import {
|
|
3
|
+
readAddedLines,
|
|
4
|
+
readLocalPublicContent,
|
|
5
|
+
readPublicContentFacts,
|
|
6
|
+
readVocabulary,
|
|
7
|
+
trackedFiles,
|
|
8
|
+
} from './public-content-facts'
|
|
9
|
+
import type { CheckResult } from './result'
|
|
10
|
+
import { isPublicPath, publicPathsOf } from './routing'
|
|
11
|
+
import type { PublicContentConfig } from './schema'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Added lines of changed published files against the names of the repositories around this one and of this
|
|
15
|
+
* project (`deriveTerms`), minus the words the published files already use. Null when no published file changed.
|
|
16
|
+
*/
|
|
17
|
+
export const publicContentCheck = (
|
|
18
|
+
root: string,
|
|
19
|
+
paths: readonly string[],
|
|
20
|
+
config: PublicContentConfig | undefined,
|
|
21
|
+
home: string,
|
|
22
|
+
): CheckResult | null => {
|
|
23
|
+
const changed = publicPathsOf(paths, config)
|
|
24
|
+
if (changed.length === 0) return null
|
|
25
|
+
const started = Date.now()
|
|
26
|
+
const isPublic = (path: string) => isPublicPath(path, config)
|
|
27
|
+
const tracked = trackedFiles(root)
|
|
28
|
+
const facts = readPublicContentFacts(root, tracked, isPublic, readLocalPublicContent(home))
|
|
29
|
+
const terms = withoutVocabulary(deriveTerms(facts), readVocabulary(root, tracked.filter(isPublic)))
|
|
30
|
+
const leaks = findPublicLeaks(readAddedLines(root, changed, new Set(tracked)), terms)
|
|
31
|
+
return publicContentResult(leaks, { files: changed.length, terms: terms.length }, Date.now() - started)
|
|
32
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { legacyPublicContentPath } from '../config/legacy-names'
|
|
5
|
+
import { TOOL_DIRECTORY } from '../config/location'
|
|
6
|
+
import type { AddedLine, Neighbour, PublicContentFacts } from './public-content'
|
|
7
|
+
import { addedLines } from './public-content'
|
|
8
|
+
|
|
9
|
+
/** The user's own additions, outside every repository: work directories to treat as neighbours, and terms. */
|
|
10
|
+
export type LocalPublicContent = { workDirectories: string[]; terms: string[] }
|
|
11
|
+
|
|
12
|
+
const PUBLIC_CONTENT_FILE = 'public-content.json'
|
|
13
|
+
|
|
14
|
+
const APP_FOLDERS = ['apps', 'services']
|
|
15
|
+
|
|
16
|
+
const PROJECT_FOLDERS = [...APP_FOLDERS, 'packages']
|
|
17
|
+
|
|
18
|
+
const ENV_EXAMPLE = /(^|\/)\.env[^/]*\.example$|(^|\/)[^/]*\.env\.example$/
|
|
19
|
+
|
|
20
|
+
const git = (cwd: string, args: string[]): string | null => {
|
|
21
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
22
|
+
return result.status === 0 ? result.stdout : null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines = (text: string | null): string[] => (text ?? '').split('\n').filter((line) => line !== '')
|
|
26
|
+
|
|
27
|
+
const directoriesIn = (directory: string): string[] => {
|
|
28
|
+
try {
|
|
29
|
+
return readdirSync(directory, { withFileTypes: true })
|
|
30
|
+
.filter((entry) => entry.isDirectory())
|
|
31
|
+
.map((entry) => join(directory, entry.name))
|
|
32
|
+
} catch {
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const readJson = (path: string): unknown => {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stringsOf = (value: unknown): string[] =>
|
|
46
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `~/.jackherd/public-content.json`, else the old `agent-kit/public-content.json` below `XDG_CONFIG_HOME` (or
|
|
50
|
+
* `~/.config`) until the global sync moves it; a missing or broken file adds nothing.
|
|
51
|
+
*/
|
|
52
|
+
export const readLocalPublicContent = (home: string, configHome = process.env.XDG_CONFIG_HOME): LocalPublicContent => {
|
|
53
|
+
const raw =
|
|
54
|
+
readJson(join(home, TOOL_DIRECTORY, PUBLIC_CONTENT_FILE)) ?? readJson(legacyPublicContentPath(home, configHome))
|
|
55
|
+
const object = typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
56
|
+
const expand = (path: string) => (path.startsWith('~/') ? join(home, path.slice(2)) : path)
|
|
57
|
+
return { workDirectories: stringsOf(object.workDirectories).map(expand), terms: stringsOf(object.terms) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The main checkout of the repository at `root`, also from inside a linked worktree. */
|
|
61
|
+
const mainCheckoutOf = (root: string): string => {
|
|
62
|
+
const common = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
63
|
+
return common ? dirname(common.trim()) : resolve(root)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const neighbourAt = (directory: string): Neighbour => ({
|
|
67
|
+
name: directory.split('/').at(-1) ?? directory,
|
|
68
|
+
remotes: lines(git(directory, ['remote', '-v'])).map((line) => line.split(/\s+/)[1] ?? ''),
|
|
69
|
+
branches: lines(git(directory, ['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'])),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/** Git repositories beside this one and in the work directories, never this one itself. */
|
|
73
|
+
const neighboursOf = (mainCheckout: string, workDirectories: readonly string[]): Neighbour[] => {
|
|
74
|
+
const candidates = [dirname(mainCheckout), ...workDirectories].flatMap(directoriesIn)
|
|
75
|
+
const unique = [...new Set(candidates.map((path) => resolve(path)))]
|
|
76
|
+
return unique.filter((path) => path !== mainCheckout && existsSync(join(path, '.git'))).map(neighbourAt)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const packageScope = (path: string): string | null => {
|
|
80
|
+
const name = (readJson(path) as { name?: unknown } | null)?.name
|
|
81
|
+
return typeof name === 'string' && name.startsWith('@') ? name.split('/')[0] : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `package.json` of the root and of every folder below `apps/`, `services/` and `packages/`, relative to the root. */
|
|
85
|
+
const manifestsOf = (root: string): string[] =>
|
|
86
|
+
['', ...PROJECT_FOLDERS.flatMap((folder) => directoriesIn(join(root, folder)).map((path) => relative(root, path)))]
|
|
87
|
+
.map((directory) => join(directory, 'package.json'))
|
|
88
|
+
.filter((path) => existsSync(join(root, path)))
|
|
89
|
+
|
|
90
|
+
/** Scopes of the project's package manifests, and of those inside the published paths (`isPublic`). */
|
|
91
|
+
const scopesOf = (root: string, isPublic: (path: string) => boolean) => {
|
|
92
|
+
const scoped = manifestsOf(root).flatMap((path) => {
|
|
93
|
+
const scope = packageScope(join(root, path))
|
|
94
|
+
return scope ? [{ scope, isOwn: isPublic(path) }] : []
|
|
95
|
+
})
|
|
96
|
+
return {
|
|
97
|
+
scopes: scoped.map(({ scope }) => scope),
|
|
98
|
+
ownScopes: scoped.filter(({ isOwn }) => isOwn).map(({ scope }) => scope),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Blobs as `git cat-file --batch` prints them, headers included: `HEAD:<path>` for the last commit, `:<path>` for
|
|
104
|
+
* the index. Read from git, never through the working tree, so a symlink or a deleted file reads as git has it.
|
|
105
|
+
*/
|
|
106
|
+
const readBlobs = (root: string, specs: readonly string[]): string => {
|
|
107
|
+
if (specs.length === 0) return ''
|
|
108
|
+
const result = spawnSync('git', ['cat-file', '--batch'], {
|
|
109
|
+
cwd: root,
|
|
110
|
+
input: specs.join('\n'),
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
113
|
+
})
|
|
114
|
+
return result.status === 0 ? result.stdout : ''
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const envExamplesOf = (root: string, tracked: readonly string[]): string[] => [
|
|
118
|
+
readBlobs(
|
|
119
|
+
root,
|
|
120
|
+
tracked.filter((path) => ENV_EXAMPLE.test(path)).map((path) => `:${path}`),
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* What `deriveTerms` reads: the repositories around this one, the project's apps, scopes and env examples, and
|
|
126
|
+
* the user's local file. Read fresh on every run and kept in memory only.
|
|
127
|
+
*/
|
|
128
|
+
export const readPublicContentFacts = (
|
|
129
|
+
root: string,
|
|
130
|
+
tracked: readonly string[],
|
|
131
|
+
isPublic: (path: string) => boolean,
|
|
132
|
+
local: LocalPublicContent,
|
|
133
|
+
): PublicContentFacts => ({
|
|
134
|
+
neighbours: neighboursOf(mainCheckoutOf(root), local.workDirectories),
|
|
135
|
+
apps: APP_FOLDERS.flatMap((folder) => directoriesIn(join(root, folder))).map((path) => path.split('/').at(-1) ?? ''),
|
|
136
|
+
...scopesOf(root, isPublic),
|
|
137
|
+
envExamples: envExamplesOf(root, tracked),
|
|
138
|
+
localTerms: local.terms,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/** The text of the published files as `HEAD` has them: the words the kit already uses. */
|
|
142
|
+
export const readVocabulary = (root: string, publicFiles: readonly string[]): string =>
|
|
143
|
+
readBlobs(
|
|
144
|
+
root,
|
|
145
|
+
publicFiles.map((path) => `HEAD:${path}`),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
/** Every file git tracks in the repository, read once per check; `-z` keeps unusual names unquoted. */
|
|
149
|
+
export const trackedFiles = (root: string): string[] =>
|
|
150
|
+
(git(root, ['ls-files', '-z']) ?? '').split('\0').filter((path) => path !== '')
|
|
151
|
+
|
|
152
|
+
/** A NUL byte near the start marks a binary file (an image, a video); SVG and other text files have none. */
|
|
153
|
+
export const isBinary = (content: Buffer): boolean => content.subarray(0, 8000).includes(0)
|
|
154
|
+
|
|
155
|
+
/** Every line of a new regular file; a binary file, a directory or one that cannot be read has none. */
|
|
156
|
+
const linesOfTextFile = (root: string, path: string): AddedLine[] => {
|
|
157
|
+
try {
|
|
158
|
+
const target = join(root, path)
|
|
159
|
+
if (!lstatSync(target).isFile()) return []
|
|
160
|
+
const content = readFileSync(target)
|
|
161
|
+
if (isBinary(content)) return []
|
|
162
|
+
return content
|
|
163
|
+
.toString('utf8')
|
|
164
|
+
.split('\n')
|
|
165
|
+
.map((text, index) => ({ path, line: index + 1, text }))
|
|
166
|
+
} catch {
|
|
167
|
+
return []
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Lines a change adds to the published files: the diff against `HEAD`, and every line of a new, untracked file. */
|
|
172
|
+
export const readAddedLines = (root: string, changed: readonly string[], tracked: ReadonlySet<string>): AddedLine[] => {
|
|
173
|
+
const known = changed.filter((path) => tracked.has(path))
|
|
174
|
+
const diff = known.length > 0 ? git(root, ['diff', 'HEAD', '--unified=0', '--no-color', '--', ...known]) : ''
|
|
175
|
+
const untracked = changed.filter((path) => !tracked.has(path)).flatMap((path) => linesOfTextFile(root, path))
|
|
176
|
+
return [...addedLines(diff ?? ''), ...untracked]
|
|
177
|
+
}
|