@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
// Heavy commands (issue #325): which shell commands must run under the
|
|
2
|
+
// machine-wide heavy-check lease (core/resources.ts). The shell hook asks
|
|
3
|
+
// `heavyCommand` about every command an agent runs; on a heavy one it
|
|
4
|
+
// rewrites the command through `workit check --heavy` (hosts that can
|
|
5
|
+
// rewrite) or denies it with that exact rewrite (hosts that cannot).
|
|
6
|
+
//
|
|
7
|
+
// The built-in catalog covers test runners, builds and bundlers, lint and
|
|
8
|
+
// typecheck, installs, image builds, Sonar and browser test runners across
|
|
9
|
+
// JS/Go/Rust/Java/.NET/Python/Ruby/PHP/Elixir/Swift/C. Wrappers are seen
|
|
10
|
+
// through: package runners (`npm run`, `npx`, `pnpm exec`, `uv run`,
|
|
11
|
+
// `bundle exec`, `python -m`), `make` targets, and everything the hooks'
|
|
12
|
+
// shell reader already unwraps (`bash -c`, `flock`, `nice`, `env`, `timeout`,
|
|
13
|
+
// `xargs`, chains and substitutions).
|
|
14
|
+
//
|
|
15
|
+
// Extensions, both adding only: patterns in `resources.heavyCommands` of the
|
|
16
|
+
// repo's checks config (check-config.ts), and machine-wide patterns `workit lock heavy add`
|
|
17
|
+
// writes to `<state>/heavy-commands.json`. A pattern is words matched as a
|
|
18
|
+
// prefix of a command line (`mvn verify`), `*` inside a word matching
|
|
19
|
+
// anything (`test:*`).
|
|
20
|
+
//
|
|
21
|
+
// Long-running servers (`npm run dev`, `vite`, `next dev`, `docker compose
|
|
22
|
+
// up`) are not heavy here: a lease held for a server's lifetime would starve
|
|
23
|
+
// every other heavy check. They belong to the dev-server registry (#328).
|
|
24
|
+
//
|
|
25
|
+
// Hook-safe: node built-ins and the shell reader only, no spawn.
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { stateDir as workitStateDir } from "./workit-paths.mjs";
|
|
29
|
+
import { commandLines, simpleCommand } from "../hooks/raw-git";
|
|
30
|
+
import type { ShellDialect } from "../hooks/shell-words";
|
|
31
|
+
import { repoHeavyCommands } from "../check-config";
|
|
32
|
+
import { gitTop, wrapAllowed, type RuleRead } from "./heavy-rules";
|
|
33
|
+
|
|
34
|
+
/** Workit's state dir for `env` (core/workit-paths.mjs). */
|
|
35
|
+
export const stateDir = (env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform) =>
|
|
36
|
+
workitStateDir(env, platform);
|
|
37
|
+
|
|
38
|
+
export const HEAVY_COMMANDS_FILE = "heavy-commands.json";
|
|
39
|
+
export const heavyCommandsFile = (env: NodeJS.ProcessEnv): string =>
|
|
40
|
+
path.join(stateDir(env), HEAVY_COMMANDS_FILE);
|
|
41
|
+
|
|
42
|
+
/** Script and target names that test, lint, typecheck, build or serve. */
|
|
43
|
+
const HEAVY_SCRIPT =
|
|
44
|
+
/^(?:test|tests|t|tst|unit|integration|e2e|coverage|cov|lint|eslint|typecheck|type-check|types|tsc|check|verify|validate|build|compile|bundle|ci|sonar|cypress|playwright|jest|vitest|mocha|bench|benchmark|all)(?:$|[:_.-])/iu;
|
|
45
|
+
|
|
46
|
+
const INFO_FLAGS = new Set(["--version", "-v", "-V", "--help", "-h", "help", "version"]);
|
|
47
|
+
|
|
48
|
+
/** Watch modes and scripts never end: they would hold the lease for good (#328). */
|
|
49
|
+
const WATCH_FLAG = /^(?:--watch(?:All)?(?:=.*)?|-w|--serve)$/u;
|
|
50
|
+
const WATCH_SCRIPT = /(?:^|[:_.-])(?:watch|dev|serve|start|storybook)(?:$|[:_.-])/iu;
|
|
51
|
+
|
|
52
|
+
type Rule = "any" | ReadonlySet<string>;
|
|
53
|
+
const sub = (...names: string[]): Rule => new Set(names);
|
|
54
|
+
|
|
55
|
+
/** Tools whose run is heavy: always (`any`), or when the subcommand is listed. */
|
|
56
|
+
const TOOLS: Record<string, Rule> = {
|
|
57
|
+
// JS test runners, typecheck, lint, bundlers, frameworks, browsers
|
|
58
|
+
jest: "any",
|
|
59
|
+
vitest: "any",
|
|
60
|
+
mocha: "any",
|
|
61
|
+
ava: "any",
|
|
62
|
+
tap: "any",
|
|
63
|
+
karma: "any",
|
|
64
|
+
jasmine: "any",
|
|
65
|
+
nyc: "any",
|
|
66
|
+
c8: "any",
|
|
67
|
+
tsc: "any",
|
|
68
|
+
"vue-tsc": "any",
|
|
69
|
+
"svelte-check": "any",
|
|
70
|
+
eslint: "any",
|
|
71
|
+
stylelint: "any",
|
|
72
|
+
tslint: "any",
|
|
73
|
+
webpack: "any",
|
|
74
|
+
"webpack-cli": "any",
|
|
75
|
+
rollup: "any",
|
|
76
|
+
parcel: "any",
|
|
77
|
+
tsup: "any",
|
|
78
|
+
vite: sub("build", "optimize"),
|
|
79
|
+
next: sub("build", "lint"),
|
|
80
|
+
nuxt: sub("build", "generate"),
|
|
81
|
+
nuxi: sub("build", "generate"),
|
|
82
|
+
astro: sub("build", "check"),
|
|
83
|
+
ng: sub("build", "b", "test", "t", "e2e", "lint"),
|
|
84
|
+
"react-scripts": sub("build", "test"),
|
|
85
|
+
playwright: sub("test", "install"),
|
|
86
|
+
cypress: sub("run"),
|
|
87
|
+
wdio: "any",
|
|
88
|
+
testcafe: "any",
|
|
89
|
+
turbo: "any",
|
|
90
|
+
lerna: sub("run", "exec", "bootstrap"),
|
|
91
|
+
"sonar-scanner": "any",
|
|
92
|
+
sonarless: sub("scan"),
|
|
93
|
+
"sonarless-mcp": sub("scan"),
|
|
94
|
+
// Go
|
|
95
|
+
go: sub("test", "build", "vet", "install", "generate"),
|
|
96
|
+
"golangci-lint": sub("run"),
|
|
97
|
+
gotestsum: "any",
|
|
98
|
+
staticcheck: "any",
|
|
99
|
+
// Rust
|
|
100
|
+
cargo: sub(
|
|
101
|
+
"build",
|
|
102
|
+
"b",
|
|
103
|
+
"test",
|
|
104
|
+
"t",
|
|
105
|
+
"check",
|
|
106
|
+
"c",
|
|
107
|
+
"clippy",
|
|
108
|
+
"bench",
|
|
109
|
+
"nextest",
|
|
110
|
+
"doc",
|
|
111
|
+
"install",
|
|
112
|
+
"tarpaulin",
|
|
113
|
+
"llvm-cov",
|
|
114
|
+
),
|
|
115
|
+
// JVM
|
|
116
|
+
mvn: "any",
|
|
117
|
+
mvnw: "any",
|
|
118
|
+
gradle: "any",
|
|
119
|
+
gradlew: "any",
|
|
120
|
+
ant: "any",
|
|
121
|
+
sbt: "any",
|
|
122
|
+
bazel: sub("build", "test", "run", "coverage"),
|
|
123
|
+
bazelisk: sub("build", "test", "run", "coverage"),
|
|
124
|
+
buck2: sub("build", "test", "run"),
|
|
125
|
+
// .NET
|
|
126
|
+
dotnet: sub("build", "test", "publish", "restore", "pack", "msbuild"),
|
|
127
|
+
msbuild: "any",
|
|
128
|
+
// Python
|
|
129
|
+
pytest: "any",
|
|
130
|
+
"py.test": "any",
|
|
131
|
+
tox: "any",
|
|
132
|
+
nox: "any",
|
|
133
|
+
mypy: "any",
|
|
134
|
+
pyright: "any",
|
|
135
|
+
pylint: "any",
|
|
136
|
+
coverage: sub("run"),
|
|
137
|
+
maturin: sub("build", "develop"),
|
|
138
|
+
pip: sub("install", "wheel"),
|
|
139
|
+
pip3: sub("install", "wheel"),
|
|
140
|
+
// Ruby, PHP, Elixir, Swift, Dart
|
|
141
|
+
rspec: "any",
|
|
142
|
+
|
|
143
|
+
rails: sub("test", "assets:precompile"),
|
|
144
|
+
phpunit: "any",
|
|
145
|
+
pest: "any",
|
|
146
|
+
phpstan: "any",
|
|
147
|
+
psalm: "any",
|
|
148
|
+
composer: sub("install", "update", "require", "test"),
|
|
149
|
+
mix: sub("test", "compile", "deps.get", "dialyzer", "credo"),
|
|
150
|
+
swift: sub("build", "test"),
|
|
151
|
+
xcodebuild: "any",
|
|
152
|
+
flutter: sub("build", "test"),
|
|
153
|
+
dart: sub("test", "compile"),
|
|
154
|
+
pod: sub("install"),
|
|
155
|
+
// C and C++
|
|
156
|
+
cmake: sub("--build"),
|
|
157
|
+
ninja: "any",
|
|
158
|
+
meson: sub("compile", "test"),
|
|
159
|
+
ctest: "any",
|
|
160
|
+
scons: "any",
|
|
161
|
+
// Containers
|
|
162
|
+
"docker-compose": sub("build"),
|
|
163
|
+
buildah: sub("bud", "build"),
|
|
164
|
+
earthly: "any",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Options of package managers that take a value before the subcommand. */
|
|
168
|
+
const PM_VALUES = new Set([
|
|
169
|
+
"--prefix",
|
|
170
|
+
"-w",
|
|
171
|
+
"--workspace",
|
|
172
|
+
"-C",
|
|
173
|
+
"--dir",
|
|
174
|
+
"--filter",
|
|
175
|
+
"-F",
|
|
176
|
+
"--cwd",
|
|
177
|
+
"-p",
|
|
178
|
+
"--package",
|
|
179
|
+
"--cache",
|
|
180
|
+
"--registry",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/** The index of the first positional word after `from`, skipping options and their values. */
|
|
184
|
+
function positional(words: string[], from: number, values: ReadonlySet<string> = PM_VALUES) {
|
|
185
|
+
let index = from;
|
|
186
|
+
while (index < words.length && words[index].startsWith("-")) {
|
|
187
|
+
if (words[index] === "--") return index + 1;
|
|
188
|
+
index += values.has(words[index]) ? 2 : 1;
|
|
189
|
+
}
|
|
190
|
+
return index;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const baseName = (word: string | undefined): string =>
|
|
194
|
+
(word?.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/u, "");
|
|
195
|
+
|
|
196
|
+
const WORKIT = /^(?:workit|@workit-dev\/cli(?:@.*)?)$/u;
|
|
197
|
+
const PYTHONS = /^(?:python[\d.]*|py)$/u;
|
|
198
|
+
|
|
199
|
+
const NPM_INSTALL = new Set([
|
|
200
|
+
"ci",
|
|
201
|
+
"clean-install",
|
|
202
|
+
"cit",
|
|
203
|
+
"install-test",
|
|
204
|
+
"it",
|
|
205
|
+
"install",
|
|
206
|
+
"i",
|
|
207
|
+
"in",
|
|
208
|
+
"add",
|
|
209
|
+
"rebuild",
|
|
210
|
+
"update",
|
|
211
|
+
"up",
|
|
212
|
+
]);
|
|
213
|
+
const NPM_RUN = new Set(["run", "run-script", "rum", "urn"]);
|
|
214
|
+
const NPM_TEST = new Set(["test", "t", "tst"]);
|
|
215
|
+
const EXEC = new Set(["exec", "x", "dlx"]);
|
|
216
|
+
|
|
217
|
+
/** Why `words` (one command line, prefixes stripped) is heavy, or null. */
|
|
218
|
+
function builtIn(words: string[], depth = 0): string | null {
|
|
219
|
+
if (depth > 6 || !words.length) return null;
|
|
220
|
+
const name = baseName(words[0]);
|
|
221
|
+
const label = () => words.slice(0, 3).join(" ");
|
|
222
|
+
const inner = (from: number) => builtIn(words.slice(from), depth + 1);
|
|
223
|
+
if (WORKIT.test(name)) return null;
|
|
224
|
+
if (INFO_FLAGS.has(words[1] ?? "") && words.length === 2) return null;
|
|
225
|
+
if (
|
|
226
|
+
words.some((word) => word === "--watch" || word.startsWith("--watch=") || word === "--watchAll")
|
|
227
|
+
)
|
|
228
|
+
return null;
|
|
229
|
+
if (name === "npx" || name === "bunx" || name === "pnpx") return inner(positional(words, 1));
|
|
230
|
+
if (name === "corepack") return inner(1);
|
|
231
|
+
if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun" || name === "deno") {
|
|
232
|
+
const at = positional(words, 1);
|
|
233
|
+
const command = words[at];
|
|
234
|
+
if (command === undefined) return name === "yarn" ? "yarn (install)" : null;
|
|
235
|
+
if (name === "deno") {
|
|
236
|
+
if (["test", "bench", "check", "compile", "lint"].includes(command)) return label();
|
|
237
|
+
if (command === "task" && HEAVY_SCRIPT.test(words[at + 1] ?? "")) return label();
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (NPM_INSTALL.has(command) || NPM_TEST.has(command)) return label();
|
|
241
|
+
if (name === "bun" && command === "build") return label();
|
|
242
|
+
if (EXEC.has(command)) return inner(positional(words, at + 1));
|
|
243
|
+
if (name === "yarn" && command === "workspace") return inner(at + 2);
|
|
244
|
+
if (name === "yarn" && command === "workspaces")
|
|
245
|
+
return words.slice(at + 1).some((word) => HEAVY_SCRIPT.test(word)) ? label() : null;
|
|
246
|
+
if (NPM_RUN.has(command)) {
|
|
247
|
+
const script = words[positional(words, at + 1)];
|
|
248
|
+
if (script === undefined || WATCH_SCRIPT.test(script)) return null;
|
|
249
|
+
if (HEAVY_SCRIPT.test(script)) return label();
|
|
250
|
+
return name === "bun" ? inner(positional(words, at + 1)) : null;
|
|
251
|
+
}
|
|
252
|
+
if (name === "npm") return null;
|
|
253
|
+
// `pnpm <script>`, `yarn <script>`, `bun <script>`, or a package binary (`yarn jest`).
|
|
254
|
+
if (WATCH_SCRIPT.test(command)) return null;
|
|
255
|
+
if (HEAVY_SCRIPT.test(command) && !/\.[cm]?[jt]sx?$/u.test(command)) return label();
|
|
256
|
+
return inner(at);
|
|
257
|
+
}
|
|
258
|
+
if (PYTHONS.test(name)) {
|
|
259
|
+
const at = words.indexOf("-m");
|
|
260
|
+
return at > 0 && words[at + 1] ? inner(at + 1) : null;
|
|
261
|
+
}
|
|
262
|
+
if (["uv", "poetry", "pdm", "pipenv", "hatch", "rye"].includes(name)) {
|
|
263
|
+
const at = positional(words, 1);
|
|
264
|
+
const command = words[at];
|
|
265
|
+
if (command === "run") return inner(positional(words, at + 1));
|
|
266
|
+
if (command === "pip") return inner(at);
|
|
267
|
+
return ["sync", "install", "build", "test", "add"].includes(command ?? "") ? label() : null;
|
|
268
|
+
}
|
|
269
|
+
if (name === "bundle") {
|
|
270
|
+
const command = words[positional(words, 1)];
|
|
271
|
+
if (command === "exec") return inner(positional(words, 2));
|
|
272
|
+
return command === "install" || command === undefined ? label() : null;
|
|
273
|
+
}
|
|
274
|
+
if (name === "make" || name === "gmake") {
|
|
275
|
+
const targets = words.slice(1).filter((word) => !word.startsWith("-") && !word.includes("="));
|
|
276
|
+
if (targets.some((target) => WATCH_SCRIPT.test(target))) return null;
|
|
277
|
+
return !targets.length || targets.some((target) => HEAVY_SCRIPT.test(target)) ? label() : null;
|
|
278
|
+
}
|
|
279
|
+
if (name === "rake") {
|
|
280
|
+
const targets = words.slice(1).filter((word) => !word.startsWith("-") && !word.includes("="));
|
|
281
|
+
return targets.some((target) => HEAVY_SCRIPT.test(target) || /^spec(?:$|:)/u.test(target))
|
|
282
|
+
? label()
|
|
283
|
+
: null;
|
|
284
|
+
}
|
|
285
|
+
if (name === "nx") {
|
|
286
|
+
const at = positional(words, 1);
|
|
287
|
+
const command = words[at] ?? "";
|
|
288
|
+
if (["run-many", "affected"].includes(command))
|
|
289
|
+
return words.some((word) => /^(?:-t|--targets?)(?:=|$)/u.test(word)) &&
|
|
290
|
+
!words.some((word) => WATCH_SCRIPT.test(word.replace(/^--?targets?=/u, "")))
|
|
291
|
+
? label()
|
|
292
|
+
: null;
|
|
293
|
+
if (command === "run") {
|
|
294
|
+
const target = (words[at + 1] ?? "").split(":")[1] ?? "";
|
|
295
|
+
return HEAVY_SCRIPT.test(target) && !WATCH_SCRIPT.test(target) ? label() : null;
|
|
296
|
+
}
|
|
297
|
+
return HEAVY_SCRIPT.test(command) && !WATCH_SCRIPT.test(command) ? label() : null;
|
|
298
|
+
}
|
|
299
|
+
if (name === "docker" || name === "podman" || name === "nerdctl") {
|
|
300
|
+
const at = positional(words, 1, new Set(["-H", "--host", "--context", "-c", "--config"]));
|
|
301
|
+
const command = words[at];
|
|
302
|
+
if (command === "build") return label();
|
|
303
|
+
if (command === "buildx" || command === "image" || command === "builder")
|
|
304
|
+
return words[positional(words, at + 1)] === "build" ? label() : null;
|
|
305
|
+
if (command === "compose") {
|
|
306
|
+
const at2 = positional(words, at + 1, new Set(["-f", "--file", "-p"]));
|
|
307
|
+
const action = words[at2];
|
|
308
|
+
if (action === "build") return label();
|
|
309
|
+
return action === "up" && words.slice(at2 + 1).includes("--build") ? label() : null;
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
const rule = TOOLS[name];
|
|
314
|
+
if (!rule) return null;
|
|
315
|
+
// A tool's own watch mode (`tsc -w`, `jest --watch`, `vitest watch`).
|
|
316
|
+
if (words.slice(1).some((word) => WATCH_FLAG.test(word) || word === "watch" || word === "dev"))
|
|
317
|
+
return null;
|
|
318
|
+
if (rule === "any") return label();
|
|
319
|
+
// `cargo +nightly build`: a toolchain selector is not the subcommand.
|
|
320
|
+
const command = words
|
|
321
|
+
.slice(1)
|
|
322
|
+
.find((word) => (!word.startsWith("-") && !word.startsWith("+")) || rule.has(word));
|
|
323
|
+
return command !== undefined && rule.has(command) ? label() : null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const globWord = (word: string): RegExp =>
|
|
327
|
+
new RegExp(`^${word.replace(/[.+?^${}()|[\]\\]/gu, "\\$&").replaceAll("*", ".*")}$`, "u");
|
|
328
|
+
|
|
329
|
+
/** A pattern as matchers for the words of a command line (the first by its base name). */
|
|
330
|
+
export function parsePattern(pattern: string): RegExp[] | null {
|
|
331
|
+
const words = pattern.trim().split(/\s+/u).filter(Boolean);
|
|
332
|
+
return words.length ? words.map(globWord) : null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const matches = (matchers: RegExp[], words: string[]): boolean =>
|
|
336
|
+
matchers.length <= words.length &&
|
|
337
|
+
matchers.every((matcher, index) =>
|
|
338
|
+
index === 0
|
|
339
|
+
? matcher.test(words[0]) || matcher.test(baseName(words[0]))
|
|
340
|
+
: matcher.test(words[index]),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const readJson = (file: string): unknown => {
|
|
344
|
+
try {
|
|
345
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
346
|
+
} catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
352
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
353
|
+
|
|
354
|
+
const stringList = (value: unknown): string[] =>
|
|
355
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
356
|
+
|
|
357
|
+
/** The machine-wide patterns (`workit lock heavy add`). Lenient: unreadable means none. */
|
|
358
|
+
export function machinePatterns(env: NodeJS.ProcessEnv): string[] {
|
|
359
|
+
const value = readJson(heavyCommandsFile(env));
|
|
360
|
+
return isRecord(value) ? stringList(value.patterns) : [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** `resources.heavyCommands` of the repo's checks config (the checks-config loader's file). */
|
|
364
|
+
export function repoPatterns(cwd: string): string[] {
|
|
365
|
+
return repoHeavyCommands(gitTop(cwd) ?? path.resolve(cwd));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export type HeavyMatch = {
|
|
369
|
+
/** The command line that made it heavy. */
|
|
370
|
+
line: string[];
|
|
371
|
+
/** `catalog` (built in), or the pattern that matched. */
|
|
372
|
+
source: "catalog" | { pattern: string; from: "machine" | "repo" };
|
|
373
|
+
reason: string;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
export type HeavyOptions = {
|
|
377
|
+
cwd: string;
|
|
378
|
+
env?: NodeJS.ProcessEnv;
|
|
379
|
+
dialect?: ShellDialect;
|
|
380
|
+
/** Extra patterns (tests, or a caller that already read them). */
|
|
381
|
+
patterns?: Array<{ pattern: string; from: "machine" | "repo" }>;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/** Every heavy command line of `lines`: the catalog first, then the patterns. */
|
|
385
|
+
function heavyLines(lines: string[][], options: HeavyOptions): HeavyMatch[] {
|
|
386
|
+
const found: HeavyMatch[] = [];
|
|
387
|
+
let patterns: Array<{ pattern: string; from: "machine" | "repo"; matchers: RegExp[] }> | null =
|
|
388
|
+
null;
|
|
389
|
+
for (const line of lines) {
|
|
390
|
+
const reason = builtIn(line);
|
|
391
|
+
if (reason) {
|
|
392
|
+
found.push({ line, source: "catalog", reason });
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
patterns ??= (
|
|
396
|
+
options.patterns ?? [
|
|
397
|
+
...machinePatterns(options.env ?? process.env).map((pattern) => ({
|
|
398
|
+
pattern,
|
|
399
|
+
from: "machine" as const,
|
|
400
|
+
})),
|
|
401
|
+
...repoPatterns(options.cwd).map((pattern) => ({ pattern, from: "repo" as const })),
|
|
402
|
+
]
|
|
403
|
+
).flatMap((entry) => {
|
|
404
|
+
const matchers = parsePattern(entry.pattern);
|
|
405
|
+
return matchers ? [{ ...entry, matchers }] : [];
|
|
406
|
+
});
|
|
407
|
+
const hit = patterns.find((entry) => matches(entry.matchers, line));
|
|
408
|
+
if (hit)
|
|
409
|
+
found.push({
|
|
410
|
+
line,
|
|
411
|
+
source: { pattern: hit.pattern, from: hit.from },
|
|
412
|
+
reason: `${hit.from} pattern "${hit.pattern}"`,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return found;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const linesOf = (command: string, dialect: ShellDialect): string[][] | null => {
|
|
419
|
+
try {
|
|
420
|
+
return commandLines(command, dialect);
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Is `command` heavy? The first command line it runs that the catalog or a
|
|
428
|
+
* pattern names, or null. Fails open: a command the shell reader cannot
|
|
429
|
+
* analyze is not heavy.
|
|
430
|
+
*/
|
|
431
|
+
export function heavyCommand(command: string, options: HeavyOptions): HeavyMatch | null {
|
|
432
|
+
const lines = linesOf(command, options.dialect ?? "posix");
|
|
433
|
+
return lines ? (heavyLines(lines, options)[0] ?? null) : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export type HeavyPlan =
|
|
437
|
+
| { kind: "none" }
|
|
438
|
+
/** One heavy command: run `command` (the lease wrapper) instead. */
|
|
439
|
+
| { kind: "rewrite"; command: string; reason: string }
|
|
440
|
+
/** A chain with heavy parts: run each part alone (`unblock` is the first heavy one). */
|
|
441
|
+
| { kind: "split"; reason: string; unblock: string | null }
|
|
442
|
+
/** A host rule names it, or wrapping would raise it: it runs as typed, saying nothing. */
|
|
443
|
+
| { kind: "passthrough"; note: "" };
|
|
444
|
+
|
|
445
|
+
export type PlanOptions = HeavyOptions & {
|
|
446
|
+
/** The host's permission rules; null where the host has none to read (Pi). */
|
|
447
|
+
rules: RuleRead | null;
|
|
448
|
+
/** The host skips its permission prompts (Claude `bypassPermissions`). */
|
|
449
|
+
bypass?: boolean;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const quoteWord = (word: string): string =>
|
|
453
|
+
/^[\w@%+=:,./-]+$/u.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
|
|
454
|
+
|
|
455
|
+
const leaseCommand = (words: readonly string[]) =>
|
|
456
|
+
`workit check --heavy -- ${words.map(quoteWord).join(" ")}`;
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* What the shell hook does with `command` (ruling mvid5onk), after every
|
|
460
|
+
* Workit gate passed on it: rewrite one heavy command through the lease,
|
|
461
|
+
* tell a chain with heavy parts to run them alone (never `bash -c`), or step
|
|
462
|
+
* aside when a host rule names it or wrapping would raise its permission.
|
|
463
|
+
* Outside a git checkout, and for PowerShell, nothing changes.
|
|
464
|
+
*/
|
|
465
|
+
export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
|
|
466
|
+
const dialect = options.dialect ?? "posix";
|
|
467
|
+
if (dialect !== "posix" || !gitTop(options.cwd)) return { kind: "none" };
|
|
468
|
+
const lines = linesOf(command, dialect);
|
|
469
|
+
if (!lines) return { kind: "none" };
|
|
470
|
+
const heavy = heavyLines(lines, options);
|
|
471
|
+
if (!heavy.length) return { kind: "none" };
|
|
472
|
+
const simple = simpleCommand(command, dialect, 0, true);
|
|
473
|
+
const single = simple && !WORKIT.test(baseName(simple[0])) && heavyLines([simple], options)[0];
|
|
474
|
+
const judged = single
|
|
475
|
+
? [{ words: lines.flat(), text: command }]
|
|
476
|
+
: heavy.map((match) => ({
|
|
477
|
+
words: match.line,
|
|
478
|
+
text: match.line.join(" "),
|
|
479
|
+
}));
|
|
480
|
+
if (options.rules !== null)
|
|
481
|
+
for (const { words, text } of judged) {
|
|
482
|
+
const verdict = wrapAllowed(options.rules, words, text, { bypass: options.bypass });
|
|
483
|
+
if (!verdict.ok)
|
|
484
|
+
return {
|
|
485
|
+
kind: "passthrough",
|
|
486
|
+
note: "",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (single) {
|
|
490
|
+
const rewrite = leaseRewrite(command);
|
|
491
|
+
return rewrite
|
|
492
|
+
? { kind: "rewrite", command: rewrite, reason: single.reason }
|
|
493
|
+
: { kind: "none" };
|
|
494
|
+
}
|
|
495
|
+
const parts = heavy.map((match) => leaseCommand(match.line));
|
|
496
|
+
return {
|
|
497
|
+
kind: "split",
|
|
498
|
+
reason: `heavy_command: \`${command.trim()}\` chains heavy work (${heavy.map((match) => match.reason).join("; ")}) with other commands, and a chain cannot run under the machine-wide heavy-check lease. Run the heavy part alone, now: \`${parts[0]}\`; then run the other parts as their own commands${parts.length > 1 ? ", each heavy one the same way" : ""}.`,
|
|
499
|
+
unblock: parts[0] ?? null,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Is a `workit` CLI on PATH? The rewrite runs it, so without one the gate stays out of the way. */
|
|
504
|
+
export function workitOnPath(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
505
|
+
const names = process.platform === "win32" ? ["workit.exe", "workit.cmd", "workit"] : ["workit"];
|
|
506
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter))
|
|
507
|
+
for (const name of names) {
|
|
508
|
+
if (!dir) continue;
|
|
509
|
+
try {
|
|
510
|
+
fs.accessSync(path.join(dir, name), fs.constants.X_OK);
|
|
511
|
+
return true;
|
|
512
|
+
} catch {
|
|
513
|
+
// Not here.
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Leading `VAR=value` words, kept in front of `workit` so the command still sees them. */
|
|
520
|
+
const ASSIGNMENTS = /^\s*((?:[A-Za-z_]\w*=(?:'[^']*'|"(?:[^"\\]|\\.)*"|[^\s'"])*\s+)*)/u;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The lease wrapper for one simple command: `[VAR=x …] workit check --heavy
|
|
524
|
+
* -- <cmd>`, the command's own text kept verbatim after `--`. It may only use
|
|
525
|
+
* flags the oldest supported CLI accepts: the plugin and the CLI update
|
|
526
|
+
* separately (docs/guides/verification.md), so a newer flag here would make
|
|
527
|
+
* an older CLI refuse every heavy command. The CLI picks the wait itself (#508).
|
|
528
|
+
*/
|
|
529
|
+
export function leaseRewrite(command: string): string | null {
|
|
530
|
+
const text = command.trim();
|
|
531
|
+
const prefix = ASSIGNMENTS.exec(text)?.[1] ?? "";
|
|
532
|
+
const rest = text.slice(prefix.length).trim();
|
|
533
|
+
if (!rest) return null;
|
|
534
|
+
return `${prefix}workit check --heavy -- ${rest}`;
|
|
535
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { changelogUnreleasedStats } from "./changelog";
|
|
5
|
+
import { assetRoot } from "./package-root";
|
|
6
|
+
|
|
7
|
+
export type HygieneFile =
|
|
8
|
+
| "CHANGELOG.md"
|
|
9
|
+
| "README.md"
|
|
10
|
+
| ".editorconfig"
|
|
11
|
+
| ".gitattributes"
|
|
12
|
+
| "LICENSE"
|
|
13
|
+
| "CONTRIBUTING.md";
|
|
14
|
+
type State = "missing" | "invalid" | "ok" | "skip";
|
|
15
|
+
|
|
16
|
+
const repoRoot = assetRoot();
|
|
17
|
+
const templatesDir = () => path.join(repoRoot, "templates", "hygiene");
|
|
18
|
+
|
|
19
|
+
const packageJson = (root: string): Record<string, unknown> | null => {
|
|
20
|
+
if (!existsSync(path.join(root, "package.json"))) return null;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const isOpenSource = (root: string): boolean => {
|
|
29
|
+
if (existsSync(path.join(root, "LICENSE"))) return true;
|
|
30
|
+
const pkg = packageJson(root);
|
|
31
|
+
if (pkg && !pkg.private) return true; // I2: missing `private` = public (npm default)
|
|
32
|
+
return path.basename(root) === "workit";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const licenseHolder = (root: string): string => {
|
|
36
|
+
const pkg = packageJson(root);
|
|
37
|
+
if (pkg) {
|
|
38
|
+
const author = pkg.author;
|
|
39
|
+
if (typeof author === "string" && author.trim()) return author.trim();
|
|
40
|
+
if (author && typeof author === "object") {
|
|
41
|
+
const name = (author as Record<string, unknown>).name;
|
|
42
|
+
if (typeof name === "string" && name.trim()) return name.trim();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return "";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const hygieneFiles = (
|
|
49
|
+
root: string,
|
|
50
|
+
): { state: Record<HygieneFile, State>; openSource: boolean } => {
|
|
51
|
+
const openSource = isOpenSource(root);
|
|
52
|
+
const state = {} as Record<HygieneFile, State>;
|
|
53
|
+
for (const file of [
|
|
54
|
+
"CHANGELOG.md",
|
|
55
|
+
"README.md",
|
|
56
|
+
".editorconfig",
|
|
57
|
+
".gitattributes",
|
|
58
|
+
"LICENSE",
|
|
59
|
+
"CONTRIBUTING.md",
|
|
60
|
+
] as HygieneFile[]) {
|
|
61
|
+
if (file === "LICENSE" || file === "CONTRIBUTING.md") {
|
|
62
|
+
state[file] = openSource ? (existsSync(path.join(root, file)) ? "ok" : "missing") : "skip";
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!existsSync(path.join(root, file))) {
|
|
66
|
+
state[file] = "missing";
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (file === "CHANGELOG.md") {
|
|
70
|
+
const stats = changelogUnreleasedStats(root);
|
|
71
|
+
state[file] = stats.exists && stats.has_unreleased ? "ok" : "invalid";
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
state[file] = "ok";
|
|
75
|
+
}
|
|
76
|
+
return { state, openSource };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type ProjectTarget = { ok: true; root: string } | { ok: false; refused: string };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Where project files (gitignore, hygiene) belong: the git toplevel of `dir`.
|
|
83
|
+
* Outside a repository there is no project to scaffold, and a folder holding
|
|
84
|
+
* several projects must never receive them, so the answer is a refusal naming
|
|
85
|
+
* the way out: the child repositories found one level down, or `cd <repo>`.
|
|
86
|
+
*/
|
|
87
|
+
export const projectTarget = (dir: string): ProjectTarget => {
|
|
88
|
+
const start = path.resolve(dir);
|
|
89
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: start, encoding: "utf8" });
|
|
90
|
+
const root = top.status === 0 ? (top.stdout ?? "").trim() : "";
|
|
91
|
+
if (root) return { ok: true, root: path.resolve(root) };
|
|
92
|
+
let children: string[] = [];
|
|
93
|
+
try {
|
|
94
|
+
children = readdirSync(start, { withFileTypes: true })
|
|
95
|
+
.filter((entry) => entry.isDirectory() && existsSync(path.join(start, entry.name, ".git")))
|
|
96
|
+
.map((entry) => path.join(start, entry.name))
|
|
97
|
+
.toSorted();
|
|
98
|
+
} catch {
|
|
99
|
+
children = [];
|
|
100
|
+
}
|
|
101
|
+
const where =
|
|
102
|
+
children.length > 0
|
|
103
|
+
? `run it inside one of its repositories: ${children
|
|
104
|
+
.slice(0, 5)
|
|
105
|
+
.map((child) => `cd ${child}`)
|
|
106
|
+
.join(", ")}${children.length > 5 ? ` (+${children.length - 5} more)` : ""}`
|
|
107
|
+
: "cd <repo> (or git init here) and run workit init again";
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
refused: `${start} is not a git repository, so no project files were added there; ${where}`,
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Pure planner: which hygiene files would be created and with what content,
|
|
115
|
+
// without touching the filesystem. Drives the wizard preview (WZ-08) and the
|
|
116
|
+
// apply path; ensureHygieneFiles is just "plan then write".
|
|
117
|
+
export const planHygieneFiles = (
|
|
118
|
+
root: string,
|
|
119
|
+
opts: { includeOpenSource?: boolean } = {},
|
|
120
|
+
): Array<{ path: string; content: string }> => {
|
|
121
|
+
const openSource = opts.includeOpenSource ?? isOpenSource(root);
|
|
122
|
+
const files = ["CHANGELOG.md", "README.md", ".editorconfig", ".gitattributes"] as HygieneFile[];
|
|
123
|
+
if (openSource) files.push("LICENSE", "CONTRIBUTING.md");
|
|
124
|
+
const planned: Array<{ path: string; content: string }> = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
if (existsSync(path.join(root, file))) continue;
|
|
127
|
+
const tpl = path.join(templatesDir(), file);
|
|
128
|
+
if (!existsSync(tpl)) continue; // skip missing template, never fail
|
|
129
|
+
const content = readFileSync(tpl, "utf8")
|
|
130
|
+
.replace(/<PROJECT>/g, path.basename(root))
|
|
131
|
+
.replace(/<YEAR>/g, String(new Date().getFullYear()))
|
|
132
|
+
.replace(/<HOLDER>\s*/g, licenseHolder(root));
|
|
133
|
+
planned.push({ path: path.join(root, file), content });
|
|
134
|
+
}
|
|
135
|
+
return planned;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const ensureHygieneFiles = (
|
|
139
|
+
root: string,
|
|
140
|
+
opts: { confirmed: boolean; includeOpenSource?: boolean },
|
|
141
|
+
): { ok: true; created: string[] } | { ok: false; error: string } => {
|
|
142
|
+
if (!opts.confirmed) return { ok: false, error: "confirmed: true required" };
|
|
143
|
+
const created: string[] = [];
|
|
144
|
+
for (const planned of planHygieneFiles(root, opts)) {
|
|
145
|
+
writeFileSync(planned.path, planned.content, "utf8");
|
|
146
|
+
created.push(path.basename(planned.path));
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, created };
|
|
149
|
+
};
|