@vodmal/vdx-cli 0.24.0 → 0.26.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/src/facts.ts CHANGED
@@ -2,21 +2,47 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as TOML from 'smol-toml';
4
4
  import * as YAML from 'js-yaml';
5
+ import { at, dict, str } from './narrow.ts';
5
6
 
6
7
  export interface Ctx {
7
8
  projectRoot: string;
8
9
  stack: string;
9
10
  cache: Map<string, unknown>;
11
+ /**
12
+ * Meta repo: the other half, where a file missing here is looked for — the repo root
13
+ * for an axis evaluated in the primary subpackage (`.github/` lives there), the
14
+ * subpackage for an axis evaluated in a root without a stack manifest of its own.
15
+ */
16
+ fallbackRoot?: string;
17
+ }
18
+
19
+ /** Absolute path of `rel`: here, else in `fallbackRoot` when it is there. */
20
+ function locate(ctx: Ctx, rel: string): string {
21
+ const here = path.join(ctx.projectRoot, rel);
22
+ if (ctx.fallbackRoot === undefined || fs.existsSync(here)) return here;
23
+ const there = path.join(ctx.fallbackRoot, rel);
24
+ return fs.existsSync(there) ? there : here;
25
+ }
26
+
27
+ /** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
28
+ function ownCtx(ctx: Ctx): Ctx {
29
+ if (ctx.fallbackRoot === undefined) return ctx;
30
+ let own = ctx.cache.get('ctx:own') as Ctx | undefined;
31
+ if (!own) {
32
+ own = { projectRoot: ctx.projectRoot, stack: ctx.stack, cache: new Map() };
33
+ ctx.cache.set('ctx:own', own);
34
+ }
35
+ return own;
10
36
  }
11
37
 
12
38
  export function fileExists(ctx: Ctx, rel: string): boolean {
13
- return fs.existsSync(path.join(ctx.projectRoot, rel));
39
+ return fs.existsSync(locate(ctx, rel));
14
40
  }
15
41
 
16
42
  export function readText(ctx: Ctx, rel: string): string | null {
17
43
  const key = `file:${rel}`;
18
44
  if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
19
- const abs = path.join(ctx.projectRoot, rel);
45
+ const abs = locate(ctx, rel);
20
46
  try {
21
47
  const t = fs.readFileSync(abs, 'utf8');
22
48
  ctx.cache.set(key, t);
@@ -27,7 +53,7 @@ export function readText(ctx: Ctx, rel: string): string | null {
27
53
  }
28
54
  }
29
55
 
30
- export function readJson(ctx: Ctx, rel: string): any {
56
+ export function readJson(ctx: Ctx, rel: string): unknown {
31
57
  const key = `json:${rel}`;
32
58
  if (ctx.cache.has(key)) return ctx.cache.get(key);
33
59
  const text = readText(ctx, rel);
@@ -36,7 +62,7 @@ export function readJson(ctx: Ctx, rel: string): any {
36
62
  return null;
37
63
  }
38
64
  try {
39
- const data = JSON.parse(text);
65
+ const data: unknown = JSON.parse(text);
40
66
  ctx.cache.set(key, data);
41
67
  return data;
42
68
  } catch {
@@ -45,7 +71,7 @@ export function readJson(ctx: Ctx, rel: string): any {
45
71
  }
46
72
  }
47
73
 
48
- export function readToml(ctx: Ctx, rel: string): any {
74
+ export function readToml(ctx: Ctx, rel: string): unknown {
49
75
  const key = `toml:${rel}`;
50
76
  if (ctx.cache.has(key)) return ctx.cache.get(key);
51
77
  const text = readText(ctx, rel);
@@ -63,7 +89,7 @@ export function readToml(ctx: Ctx, rel: string): any {
63
89
  }
64
90
  }
65
91
 
66
- export function readYaml(ctx: Ctx, rel: string): any {
92
+ export function readYaml(ctx: Ctx, rel: string): unknown {
67
93
  const key = `yaml:${rel}`;
68
94
  if (ctx.cache.has(key)) return ctx.cache.get(key);
69
95
  const text = readText(ctx, rel);
@@ -82,7 +108,7 @@ export function readYaml(ctx: Ctx, rel: string): any {
82
108
  }
83
109
 
84
110
  /** Parse a structured file by extension. Returns parsed object/array or null. */
85
- export function readStructured(ctx: Ctx, rel: string): any {
111
+ export function readStructured(ctx: Ctx, rel: string): unknown {
86
112
  const lower = rel.toLowerCase();
87
113
  if (lower.endsWith('.json')) return readJson(ctx, rel);
88
114
  if (lower.endsWith('.toml')) return readToml(ctx, rel);
@@ -97,44 +123,28 @@ export function readStructured(ctx: Ctx, rel: string): any {
97
123
  */
98
124
  export function resolveConfigPath(root: unknown, jsonpath: string): unknown {
99
125
  if (root === null || root === undefined) return undefined;
100
- const parts = jsonpath.split('.');
101
- let cur: any = root;
102
- for (const part of parts) {
103
- if (cur === null || cur === undefined) return undefined;
104
- if (typeof cur !== 'object') return undefined;
105
- cur = (cur as Record<string, unknown>)[part];
106
- }
107
- return cur;
126
+ return at(root, ...jsonpath.split('.'));
108
127
  }
109
128
 
110
- /** Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile. */
129
+ /**
130
+ * Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile.
131
+ * Without the fallback: `vdx test` here runs this directory's tasks, not the other half's.
132
+ */
111
133
  export function listAllTasks(ctx: Ctx): Set<string> {
112
134
  const key = 'tasks:all';
113
135
  if (ctx.cache.has(key)) return ctx.cache.get(key) as Set<string>;
114
136
  const tasks = new Set<string>();
137
+ const own = ownCtx(ctx);
115
138
 
116
- const miseT = readToml(ctx, 'mise.toml');
117
- if (miseT?.tasks && typeof miseT.tasks === 'object') {
118
- for (const k of Object.keys(miseT.tasks as object)) tasks.add(k);
119
- }
120
-
121
- const composer = readJson(ctx, 'composer.json');
122
- if (composer?.scripts && typeof composer.scripts === 'object') {
123
- for (const k of Object.keys(composer.scripts)) tasks.add(k);
124
- }
125
-
126
- const pkg = readJson(ctx, 'package.json');
127
- if (pkg?.scripts && typeof pkg.scripts === 'object') {
128
- for (const k of Object.keys(pkg.scripts)) tasks.add(k);
129
- }
130
-
139
+ for (const k of Object.keys(dict(at(readToml(own, 'mise.toml'), 'tasks')) ?? {})) tasks.add(k);
140
+ for (const k of Object.keys(dict(at(readJson(own, 'composer.json'), 'scripts')) ?? {})) tasks.add(k);
141
+ for (const k of Object.keys(dict(at(readJson(own, 'package.json'), 'scripts')) ?? {})) tasks.add(k);
131
142
  // monorepo-ish fallback: server/package.json (см. bookmap)
132
- const serverPkg = readJson(ctx, 'server/package.json');
133
- if (serverPkg?.scripts && typeof serverPkg.scripts === 'object') {
134
- for (const k of Object.keys(serverPkg.scripts)) tasks.add(`server:${k}`);
143
+ for (const k of Object.keys(dict(at(readJson(own, 'server/package.json'), 'scripts')) ?? {})) {
144
+ tasks.add(`server:${k}`);
135
145
  }
136
146
 
137
- const make = readText(ctx, 'Makefile');
147
+ const make = readText(own, 'Makefile');
138
148
  if (make) {
139
149
  const lines = make.split('\n');
140
150
  for (const line of lines) {
@@ -151,18 +161,14 @@ export function isPackagePresent(name: string, ecosystem: string, ctx: Ctx): boo
151
161
  const eco = ecosystem ?? 'any';
152
162
  if (eco === 'any' || eco === 'composer') {
153
163
  const composer = readJson(ctx, 'composer.json');
154
- if (composer) {
155
- if (composer.require?.[name]) return true;
156
- if (composer['require-dev']?.[name]) return true;
157
- }
164
+ if (at(composer, 'require', name)) return true;
165
+ if (at(composer, 'require-dev', name)) return true;
158
166
  }
159
167
  if (eco === 'any' || eco === 'npm') {
160
168
  for (const rel of ['package.json', 'server/package.json']) {
161
169
  const pkg = readJson(ctx, rel);
162
- if (pkg) {
163
- if (pkg.dependencies?.[name]) return true;
164
- if (pkg.devDependencies?.[name]) return true;
165
- }
170
+ if (at(pkg, 'dependencies', name)) return true;
171
+ if (at(pkg, 'devDependencies', name)) return true;
166
172
  }
167
173
  }
168
174
  return false;
@@ -176,7 +182,7 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
176
182
  if (m && m[1] && m[2]) {
177
183
  const dir = m[1];
178
184
  const ext = m[2];
179
- const abs = path.join(ctx.projectRoot, dir);
185
+ const abs = locate(ctx, dir);
180
186
  try {
181
187
  const files = fs.readdirSync(abs);
182
188
  for (const f of files) {
@@ -193,19 +199,17 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
193
199
  return out;
194
200
  }
195
201
 
196
- export function readTsconfig(ctx: Ctx): any {
197
- let cfg = readJson(ctx, 'tsconfig.json');
198
- if (!cfg) cfg = readJson(ctx, 'server/tsconfig.json');
202
+ export function readTsconfig(ctx: Ctx): { compilerOptions?: Record<string, unknown> } | null {
203
+ const cfg = dict(readJson(ctx, 'tsconfig.json')) ?? dict(readJson(ctx, 'server/tsconfig.json'));
199
204
  if (!cfg) return null;
205
+ const own = dict(cfg['compilerOptions']);
200
206
  // basic one-level `extends` resolution
201
- if (cfg.extends && typeof cfg.extends === 'string' && cfg.extends.startsWith('.')) {
202
- const baseRel = cfg.extends.endsWith('.json') ? cfg.extends : `${cfg.extends}.json`;
203
- const base = readJson(ctx, baseRel);
204
- if (base?.compilerOptions) {
205
- cfg.compilerOptions = { ...base.compilerOptions, ...(cfg.compilerOptions ?? {}) };
206
- }
207
+ const ext = str(cfg['extends']);
208
+ if (ext?.startsWith('.')) {
209
+ const base = dict(at(readJson(ctx, ext.endsWith('.json') ? ext : `${ext}.json`), 'compilerOptions'));
210
+ if (base) return { compilerOptions: { ...base, ...own } };
207
211
  }
208
- return cfg;
212
+ return own ? { compilerOptions: own } : {};
209
213
  }
210
214
 
211
215
  const MANIFEST_TO_STACK: Array<[string, string]> = [
@@ -224,8 +228,19 @@ export function stackForDir(dir: string): string | null {
224
228
  }
225
229
 
226
230
  const SUBPACKAGE_SCAN_IGNORE = new Set([
227
- 'node_modules', 'vendor', 'dist', 'build', 'out', 'target',
228
- '.git', '.next', '__pycache__', '.venv', 'venv', '.cache', 'coverage',
231
+ 'node_modules',
232
+ 'vendor',
233
+ 'dist',
234
+ 'build',
235
+ 'out',
236
+ 'target',
237
+ '.git',
238
+ '.next',
239
+ '__pycache__',
240
+ '.venv',
241
+ 'venv',
242
+ '.cache',
243
+ 'coverage',
229
244
  ]);
230
245
 
231
246
  export interface SubPackage {
package/src/gha.ts CHANGED
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import YAML from 'js-yaml';
10
10
  import { fileExists, readText, type Ctx } from './facts.ts';
11
+ import { at, dict, isDict, list, str } from './narrow.ts';
11
12
  import { projectTaskCalls } from './vocabulary.ts';
12
13
 
13
14
  export interface GhaStep {
@@ -48,9 +49,8 @@ function filterMatches(pattern: string, branch: string): boolean {
48
49
  }
49
50
 
50
51
  function branchSelected(filters: unknown, branch: string): boolean {
51
- const list = Array.isArray(filters) ? filters : [filters];
52
52
  let selected = false;
53
- for (const f of list) {
53
+ for (const f of Array.isArray(filters) ? (filters as unknown[]) : [filters]) {
54
54
  if (typeof f !== 'string') continue;
55
55
  if (f.startsWith('!')) {
56
56
  if (filterMatches(f.slice(1), branch)) selected = false;
@@ -65,15 +65,16 @@ function branchSelected(filters: unknown, branch: string): boolean {
65
65
  export function pushesMain(on: unknown): boolean {
66
66
  if (on === 'push') return true;
67
67
  if (Array.isArray(on)) return on.includes('push');
68
- if (!on || typeof on !== 'object') return false;
69
- if (!('push' in on)) return false;
70
- const push = (on as Record<string, any>).push;
71
- if (push === null || push === undefined || typeof push !== 'object') return true;
72
- const hasBranchFilter = push.branches !== undefined || push['branches-ignore'] !== undefined;
73
- if (!hasBranchFilter && (push.tags !== undefined || push['tags-ignore'] !== undefined)) return false;
68
+ if (!isDict(on) || !('push' in on)) return false;
69
+ const push = dict(on['push']);
70
+ if (!push) return true;
71
+ const { branches, tags } = push;
72
+ const branchesIgnore = push['branches-ignore'];
73
+ const hasBranchFilter = branches !== undefined || branchesIgnore !== undefined;
74
+ if (!hasBranchFilter && (tags !== undefined || push['tags-ignore'] !== undefined)) return false;
74
75
  return MAIN_BRANCHES.some((b) => {
75
- if (push.branches !== undefined) return branchSelected(push.branches, b);
76
- if (push['branches-ignore'] !== undefined) return !branchSelected(push['branches-ignore'], b);
76
+ if (branches !== undefined) return branchSelected(branches, b);
77
+ if (branchesIgnore !== undefined) return !branchSelected(branchesIgnore, b);
77
78
  return true;
78
79
  });
79
80
  }
@@ -83,34 +84,41 @@ function truthy(v: unknown): boolean {
83
84
  }
84
85
 
85
86
  export function parseWorkflow(file: string, text: string): GhaWorkflow | null {
86
- let data: any;
87
+ let data: unknown;
87
88
  try {
88
89
  data = YAML.load(text);
89
90
  } catch {
90
91
  return null;
91
92
  }
93
+ // An array is an object too, and the workflow it would be has no jobs.
92
94
  if (!data || typeof data !== 'object') return null;
93
95
  const jobs: GhaJob[] = [];
94
- for (const [id, job] of Object.entries((data.jobs ?? {}) as Record<string, any>)) {
95
- if (!job || typeof job !== 'object') continue;
96
- const needs = job.needs === undefined ? [] : Array.isArray(job.needs) ? job.needs : [job.needs];
97
- const steps: GhaStep[] = (Array.isArray(job.steps) ? job.steps : [])
98
- .filter((s: unknown) => s && typeof s === 'object')
99
- .map((s: any) => ({
100
- ...(typeof s.run === 'string' ? { run: s.run } : {}),
101
- ...(typeof s.uses === 'string' ? { uses: s.uses } : {}),
102
- continueOnError: truthy(s['continue-on-error']),
103
- }));
96
+ for (const [id, value] of Object.entries(dict(at(data, 'jobs')) ?? {})) {
97
+ const job = dict(value);
98
+ if (!job) continue;
99
+ const needs = job['needs'] === undefined ? [] : Array.isArray(job['needs']) ? list(job['needs']) : [job['needs']];
100
+ const steps: GhaStep[] = list(job['steps'])
101
+ .filter(isDict)
102
+ .map((s) => {
103
+ const run = str(s['run']);
104
+ const uses = str(s['uses']);
105
+ return {
106
+ ...(run !== undefined ? { run } : {}),
107
+ ...(uses !== undefined ? { uses } : {}),
108
+ continueOnError: truthy(s['continue-on-error']),
109
+ };
110
+ });
111
+ const name = str(job['name']);
104
112
  jobs.push({
105
113
  id,
106
- ...(typeof job.name === 'string' ? { name: job.name } : {}),
107
- needs: needs.filter((n: unknown): n is string => typeof n === 'string'),
114
+ ...(name !== undefined ? { name } : {}),
115
+ needs: needs.filter((n): n is string => typeof n === 'string'),
108
116
  steps,
109
117
  continueOnError: truthy(job['continue-on-error']),
110
- matrix: Boolean(job.strategy?.matrix),
118
+ matrix: Boolean(at(job, 'strategy', 'matrix')),
111
119
  });
112
120
  }
113
- return { file, pushesMain: pushesMain(data.on), jobs };
121
+ return { file, pushesMain: pushesMain(at(data, 'on')), jobs };
114
122
  }
115
123
 
116
124
  export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
@@ -118,9 +126,12 @@ export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
118
126
  if (ctx.cache.has(key)) return ctx.cache.get(key) as GhaWorkflow[];
119
127
  const dir = path.join(ctx.projectRoot, '.github', 'workflows');
120
128
  const out: GhaWorkflow[] = [];
121
- let files: string[] = [];
129
+ let files: string[];
122
130
  try {
123
- files = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)).sort();
131
+ files = fs
132
+ .readdirSync(dir)
133
+ .filter((f) => /\.ya?ml$/.test(f))
134
+ .sort();
124
135
  } catch {
125
136
  files = [];
126
137
  }
package/src/hooks.ts CHANGED
@@ -16,6 +16,7 @@ import * as path from 'node:path';
16
16
  import { execFileSync } from 'node:child_process';
17
17
  import YAML from 'js-yaml';
18
18
  import { fileExists, isPackagePresent, readJson, readText, readToml, type Ctx } from './facts.ts';
19
+ import { at, dict, isDict, list } from './narrow.ts';
19
20
 
20
21
  export type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
21
22
 
@@ -36,11 +37,29 @@ export interface HookFramework {
36
37
  }
37
38
 
38
39
  export const GIT_HOOK_EVENTS = new Set([
39
- 'applypatch-msg', 'pre-applypatch', 'post-applypatch', 'pre-commit', 'pre-merge-commit',
40
- 'prepare-commit-msg', 'commit-msg', 'post-commit', 'pre-rebase', 'post-checkout', 'post-merge',
41
- 'pre-push', 'pre-auto-gc', 'post-rewrite', 'reference-transaction', 'push-to-checkout',
42
- 'post-index-change', 'sendemail-validate', 'fsmonitor-watchman', 'p4-changelist',
43
- 'p4-prepare-changelist', 'p4-post-changelist', 'p4-pre-submit',
40
+ 'applypatch-msg',
41
+ 'pre-applypatch',
42
+ 'post-applypatch',
43
+ 'pre-commit',
44
+ 'pre-merge-commit',
45
+ 'prepare-commit-msg',
46
+ 'commit-msg',
47
+ 'post-commit',
48
+ 'pre-rebase',
49
+ 'post-checkout',
50
+ 'post-merge',
51
+ 'pre-push',
52
+ 'pre-auto-gc',
53
+ 'post-rewrite',
54
+ 'reference-transaction',
55
+ 'push-to-checkout',
56
+ 'post-index-change',
57
+ 'sendemail-validate',
58
+ 'fsmonitor-watchman',
59
+ 'p4-changelist',
60
+ 'p4-prepare-changelist',
61
+ 'p4-post-changelist',
62
+ 'p4-pre-submit',
44
63
  ]);
45
64
 
46
65
  /** Install steps a clone runs anyway: npm-family lifecycle scripts, composer's, the vdx `up` task. */
@@ -51,7 +70,7 @@ interface InstallStep {
51
70
 
52
71
  /** Yarn 2+ does not run a project's `prepare` (husky docs, How To → Yarn). */
53
72
  export function yarnBerry(ctx: Ctx): boolean {
54
- const pm = readJson(ctx, 'package.json')?.packageManager;
73
+ const pm = at(readJson(ctx, 'package.json'), 'packageManager');
55
74
  if (typeof pm === 'string') {
56
75
  const m = pm.match(/^yarn@(\d+)/);
57
76
  if (m) return parseInt(m[1]!, 10) >= 2;
@@ -61,22 +80,24 @@ export function yarnBerry(ctx: Ctx): boolean {
61
80
 
62
81
  function installSteps(ctx: Ctx): InstallStep[] {
63
82
  const out: InstallStep[] = [];
64
- const pkg = readJson(ctx, 'package.json');
65
- const scripts = pkg?.scripts ?? {};
83
+ const scripts = dict(at(readJson(ctx, 'package.json'), 'scripts')) ?? {};
66
84
  const npmHooks = yarnBerry(ctx) ? ['postinstall'] : ['prepare', 'postinstall'];
67
85
  for (const name of npmHooks) {
68
- if (typeof scripts[name] === 'string') {
69
- out.push({ where: `package.json ${name}`, text: expandNpm(scripts, scripts[name]) });
86
+ const body = scripts[name];
87
+ if (typeof body === 'string') {
88
+ out.push({ where: `package.json ${name}`, text: expandNpm(scripts, body) });
70
89
  }
71
90
  }
72
- const composer = readJson(ctx, 'composer.json');
73
- const cmd = composer?.scripts?.['post-install-cmd'];
91
+ const composerScripts = dict(at(readJson(ctx, 'composer.json'), 'scripts')) ?? {};
92
+ const cmd = composerScripts['post-install-cmd'];
74
93
  if (cmd !== undefined) {
75
- out.push({ where: 'composer post-install-cmd', text: expandComposer(composer.scripts, cmd) });
94
+ out.push({ where: 'composer post-install-cmd', text: expandComposer(composerScripts, cmd) });
76
95
  }
77
- const up = readToml(ctx, 'mise.toml')?.tasks?.up;
78
- if (up && typeof up === 'object') {
79
- const runs = (Array.isArray(up.run) ? up.run : [up.run]).filter((r: unknown) => typeof r === 'string');
96
+ const up = dict(at(readToml(ctx, 'mise.toml'), 'tasks', 'up'));
97
+ if (up) {
98
+ const runs = (Array.isArray(up['run']) ? (up['run'] as unknown[]) : [up['run']]).filter(
99
+ (r): r is string => typeof r === 'string',
100
+ );
80
101
  if (runs.length > 0) out.push({ where: 'mise task up', text: runs.join('\n') });
81
102
  }
82
103
  return out;
@@ -117,7 +138,7 @@ function findStep(steps: InstallStep[], re: RegExp): string | null {
117
138
  /** Hook scripts in a directory: file name = event; the body is the command. */
118
139
  function hooksFromDir(ctx: Ctx, dir: string): Map<string, string[]> {
119
140
  const out = new Map<string, string[]>();
120
- let names: string[] = [];
141
+ let names: string[];
121
142
  try {
122
143
  names = fs.readdirSync(path.join(ctx.projectRoot, dir));
123
144
  } catch {
@@ -144,15 +165,14 @@ function scriptBody(text: string): string {
144
165
 
145
166
  function lefthookRuns(node: unknown): string[] {
146
167
  const out: string[] = [];
147
- if (!node || typeof node !== 'object') return out;
148
- const o = node as Record<string, any>;
149
- if (typeof o.run === 'string') out.push(o.run);
150
- if (typeof o.runner === 'string') out.push(o.runner);
168
+ if (!isDict(node)) return out;
169
+ if (typeof node['run'] === 'string') out.push(node['run']);
170
+ if (typeof node['runner'] === 'string') out.push(node['runner']);
151
171
  for (const key of ['commands', 'scripts']) {
152
- if (o[key] && typeof o[key] === 'object') for (const v of Object.values(o[key])) out.push(...lefthookRuns(v));
172
+ for (const v of Object.values(dict(node[key]) ?? {})) out.push(...lefthookRuns(v));
153
173
  }
154
- if (Array.isArray(o.jobs)) for (const j of o.jobs) out.push(...lefthookRuns(j));
155
- if (o.group && typeof o.group === 'object') out.push(...lefthookRuns(o.group));
174
+ for (const j of list(node['jobs'])) out.push(...lefthookRuns(j));
175
+ out.push(...lefthookRuns(node['group']));
156
176
  return out;
157
177
  }
158
178
 
@@ -173,7 +193,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
173
193
  id: 'husky',
174
194
  declaredIn: '.husky/',
175
195
  hooks: husky,
176
- installedBy: findStep(steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'), /(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m),
196
+ installedBy: findStep(
197
+ steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'),
198
+ /(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m,
199
+ ),
177
200
  manualInstall: 'npx husky',
178
201
  hooksPath: '.husky/_',
179
202
  copies: false,
@@ -183,14 +206,14 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
183
206
  for (const file of LEFTHOOK_FILES) {
184
207
  const text = readText(ctx, file);
185
208
  if (text === null) continue;
186
- let data: any;
209
+ let data: unknown;
187
210
  try {
188
211
  data = YAML.load(text);
189
212
  } catch {
190
213
  continue;
191
214
  }
192
215
  const hooks = new Map<string, string[]>();
193
- for (const [event, def] of Object.entries((data ?? {}) as Record<string, unknown>)) {
216
+ for (const [event, def] of Object.entries(dict(data) ?? {})) {
194
217
  if (GIT_HOOK_EVENTS.has(event)) hooks.set(event, lefthookRuns(def));
195
218
  }
196
219
  if (hooks.size === 0) continue;
@@ -210,11 +233,9 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
210
233
  break;
211
234
  }
212
235
 
213
- const sgh =
214
- (pkg?.['simple-git-hooks'] as Record<string, unknown> | undefined) ??
215
- readJson(ctx, '.simple-git-hooks.json') ??
216
- readJson(ctx, 'simple-git-hooks.json');
217
- if (sgh && typeof sgh === 'object') {
236
+ const sghInPackage = dict(at(pkg, 'simple-git-hooks'));
237
+ const sgh = sghInPackage ?? dict(readJson(ctx, '.simple-git-hooks.json') ?? readJson(ctx, 'simple-git-hooks.json'));
238
+ if (sgh) {
218
239
  const hooks = new Map<string, string[]>();
219
240
  for (const [event, cmd] of Object.entries(sgh)) {
220
241
  if (GIT_HOOK_EVENTS.has(event) && typeof cmd === 'string') hooks.set(event, [cmd]);
@@ -222,7 +243,7 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
222
243
  if (hooks.size > 0) {
223
244
  out.push({
224
245
  id: 'simple-git-hooks',
225
- declaredIn: pkg?.['simple-git-hooks'] ? 'package.json simple-git-hooks' : 'simple-git-hooks.json',
246
+ declaredIn: sghInPackage ? 'package.json simple-git-hooks' : 'simple-git-hooks.json',
226
247
  hooks,
227
248
  installedBy: isPackagePresent('simple-git-hooks', 'npm', ctx)
228
249
  ? 'simple-git-hooks package (its postinstall)'
@@ -234,10 +255,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
234
255
  }
235
256
  }
236
257
 
237
- const extra = composer?.extra?.hooks;
238
- if (extra && typeof extra === 'object') {
258
+ const extra = dict(at(composer, 'extra', 'hooks'));
259
+ if (extra) {
239
260
  const hooks = new Map<string, string[]>();
240
- for (const [event, cmd] of Object.entries(extra as Record<string, unknown>)) {
261
+ for (const [event, cmd] of Object.entries(extra)) {
241
262
  if (!GIT_HOOK_EVENTS.has(event)) continue;
242
263
  const list = (Array.isArray(cmd) ? cmd : [cmd]).filter((c): c is string => typeof c === 'string');
243
264
  if (list.length > 0) hooks.set(event, list);
@@ -247,7 +268,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
247
268
  id: 'cghooks',
248
269
  declaredIn: 'composer.json extra.hooks',
249
270
  hooks,
250
- installedBy: findStep(steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'), /\bcghooks\s+(add|update)\b/),
271
+ installedBy: findStep(
272
+ steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'),
273
+ /\bcghooks\s+(add|update)\b/,
274
+ ),
251
275
  manualInstall: 'vendor/bin/cghooks update',
252
276
  hooksPath: null,
253
277
  copies: true,
@@ -277,7 +301,7 @@ export function installCommand(fw: HookFramework, ctx: Ctx): string {
277
301
  if (fw.installedBy === null) return fw.manualInstall;
278
302
  if (fw.installedBy.startsWith('composer')) return 'composer install';
279
303
  if (fw.installedBy === 'mise task up') return 'vdx up';
280
- const pm = readJson(ctx, 'package.json')?.packageManager;
304
+ const pm = at(readJson(ctx, 'package.json'), 'packageManager');
281
305
  if (typeof pm === 'string' && pm.startsWith('pnpm')) return 'pnpm install';
282
306
  if ((typeof pm === 'string' && pm.startsWith('yarn')) || fileExists(ctx, 'yarn.lock')) return 'yarn install';
283
307
  if (fileExists(ctx, 'pnpm-lock.yaml')) return 'pnpm install';
@@ -353,7 +377,13 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
353
377
  }
354
378
  if (fw.id === 'lefthook') {
355
379
  const foreign = events.filter((e) => !/lefthook/.test(readFile(path.join(dir, e))));
356
- if (foreign.length > 0) return { framework: fw, enabled: false, problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`, remedy };
380
+ if (foreign.length > 0)
381
+ return {
382
+ framework: fw,
383
+ enabled: false,
384
+ problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`,
385
+ remedy,
386
+ };
357
387
  }
358
388
  if (fw.copies) {
359
389
  const stale = events.filter((e) => {
@@ -361,7 +391,12 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
361
391
  return fw.hooks.get(e)!.some((cmd) => !body.includes(cmd.trim()));
362
392
  });
363
393
  if (stale.length > 0) {
364
- return { framework: fw, enabled: false, problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`, remedy };
394
+ return {
395
+ framework: fw,
396
+ enabled: false,
397
+ problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`,
398
+ remedy,
399
+ };
365
400
  }
366
401
  }
367
402
  return { framework: fw, enabled: true };
@@ -386,8 +421,7 @@ function readFile(file: string): string {
386
421
  * anywhere in a script says nothing about whether the hook still does its job,
387
422
  * while an unset variable inside the guard means the guarded block never runs.
388
423
  */
389
- const GATING_VAR_RE =
390
- /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
424
+ const GATING_VAR_RE = /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
391
425
 
392
426
  export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = process.env): string[] {
393
427
  const out = new Set<string>();
@@ -406,7 +440,8 @@ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = pro
406
440
  * Only what is run: a path in an argument or an `echo` says nothing about
407
441
  * whether the hook works here.
408
442
  */
409
- const MACHINE_PROGRAM_RE = /^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
443
+ const MACHINE_PROGRAM_RE =
444
+ /^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
410
445
 
411
446
  export function missingMachinePaths(commands: string[], home: string = os.homedir()): string[] {
412
447
  const out = new Set<string>();