@bensandee/tooling 0.64.2 → 0.66.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.
@@ -109,7 +109,7 @@ declare function createRealExecutor(): DockerCheckExecutor;
109
109
  //#endregion
110
110
  //#region src/docker-check/check.d.ts
111
111
  /** Run the full Docker check lifecycle. */
112
- declare function runDockerCheck(executor: DockerCheckExecutor, config: CheckConfig): Promise<CheckResult>;
112
+ declare function runDockerCheck(executor: DockerCheckExecutor, rawConfig: CheckConfig): Promise<CheckResult>;
113
113
  //#endregion
114
114
  //#region src/docker-check/compose.d.ts
115
115
  /** Zod schema for a single container entry from `docker compose ps --format json`. */
@@ -242,6 +242,12 @@ declare const ToolingConfigSchema: z.ZodObject<{
242
242
  historyBranch: z.ZodOptional<z.ZodString>;
243
243
  tolerance: z.ZodOptional<z.ZodNumber>;
244
244
  }, z.core.$strip>>;
245
+ docs: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<false>, z.ZodObject<{
246
+ enforced: z.ZodOptional<z.ZodUnion<readonly [z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
247
+ dirs: z.ZodOptional<z.ZodArray<z.ZodString>>;
248
+ staleDays: z.ZodOptional<z.ZodNumber>;
249
+ staleEnforced: z.ZodOptional<z.ZodBoolean>;
250
+ }, z.core.$strict>]>>;
245
251
  deploy: z.ZodOptional<z.ZodObject<{
246
252
  dispatchRepo: z.ZodString;
247
253
  stack: z.ZodOptional<z.ZodString>;
@@ -250,6 +256,22 @@ declare const ToolingConfigSchema: z.ZodObject<{
250
256
  }, z.core.$strict>>;
251
257
  pnpmWorkspace: z.ZodOptional<z.ZodLiteral<false>>;
252
258
  defaultBranch: z.ZodOptional<z.ZodString>;
259
+ unmanagedDevDependencies: z.ZodOptional<z.ZodArray<z.ZodString>>;
260
+ stack: z.ZodOptional<z.ZodEnum<{
261
+ none: "none";
262
+ typescript: "typescript";
263
+ javascript: "javascript";
264
+ }>>;
265
+ builder: z.ZodOptional<z.ZodEnum<{
266
+ none: "none";
267
+ custom: "custom";
268
+ tsdown: "tsdown";
269
+ vite: "vite";
270
+ }>>;
271
+ checks: z.ZodOptional<z.ZodObject<{
272
+ add: z.ZodOptional<z.ZodArray<z.ZodString>>;
273
+ skip: z.ZodOptional<z.ZodArray<z.ZodString>>;
274
+ }, z.core.$strict>>;
253
275
  }, z.core.$strict>;
254
276
  type ToolingConfig = z.infer<typeof ToolingConfigSchema>;
255
277
  //#endregion
@@ -1,2 +1,2 @@
1
- import { a as composeArgs, c as composePs, f as lintComposeFiles, i as getContainerHealth, l as composeUp, n as runDockerCheck, o as composeDown, r as checkHttpHealth, s as composeLogs, t as deriveProjectName, u as createRealExecutor } from "../project-name-CHPvgMTP.mjs";
1
+ import { a as composeArgs, c as composePs, d as lintComposeFiles, i as getContainerHealth, l as composeUp, n as runDockerCheck, o as composeDown, r as checkHttpHealth, s as composeLogs, t as deriveProjectName, u as createRealExecutor } from "../project-name-CNaAmafB.mjs";
2
2
  export { checkHttpHealth, composeArgs, composeDown, composeLogs, composePs, composeUp, createRealExecutor, deriveProjectName, getContainerHealth, lintComposeFiles, runDockerCheck };
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { a as LegacyConfig, i as GeneratorResult, n as Generator, o as ProjectConfig, r as GeneratorContext, t as DetectedProjectState } from "./types-CMKT2loB.mjs";
1
+ import { a as LegacyConfig, i as GeneratorResult, n as Generator, o as ProjectConfig, r as GeneratorContext, t as DetectedProjectState } from "./types-C6cLSf_a.mjs";
2
2
  export type { DetectedProjectState, Generator, GeneratorContext, GeneratorResult, LegacyConfig, ProjectConfig };
@@ -1,27 +1,11 @@
1
- import * as clack from "@clack/prompts";
2
1
  import path from "node:path";
3
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
- import { execFileSync } from "node:child_process";
3
+ import { execFileSync, spawnSync } from "node:child_process";
5
4
  import { z } from "zod";
6
5
  import { parse } from "yaml";
7
- //#region src/utils/log.ts
8
- const out = (msg) => console.log(msg);
9
- const isCI = Boolean(process.env["CI"]);
10
- const log = isCI ? {
11
- info: out,
12
- warn: (msg) => out(`[warn] ${msg}`),
13
- error: (msg) => out(`[error] ${msg}`),
14
- success: (msg) => out(`✓ ${msg}`)
15
- } : clack.log;
16
- function note(body, title) {
17
- if (isCI) {
18
- if (title) out(`--- ${title} ---`);
19
- out(body);
20
- } else clack.note(body, title);
21
- }
22
- //#endregion
23
6
  //#region src/utils/debug.ts
24
7
  const REDACT_PATTERNS = [/x-access-token:[^\s@]+/g, /Authorization:\s*(?:token|Bearer)\s+\S+/gi];
8
+ const debugOut = (msg) => console.error(msg);
25
9
  /** Redact secrets (tokens, credentials) from text before logging. */
26
10
  function redact(text) {
27
11
  let result = text;
@@ -34,7 +18,7 @@ function isEnvVerbose() {
34
18
  }
35
19
  /** Log a debug message when verbose mode is enabled. */
36
20
  function debug(config, message) {
37
- if (config.verbose) log.info(`[debug] ${message}`);
21
+ if (config.verbose) debugOut(`[debug] ${message}`);
38
22
  }
39
23
  /** Log the result of an exec call when verbose mode is enabled. */
40
24
  function debugExec(config, label, result) {
@@ -42,7 +26,33 @@ function debugExec(config, label, result) {
42
26
  const lines = [`[debug] ${redact(label)} (exit code ${String(result.exitCode)})`];
43
27
  if (result.stdout.trim()) lines.push(` stdout: ${redact(result.stdout.trim())}`);
44
28
  if (result.stderr.trim()) lines.push(` stderr: ${redact(result.stderr.trim())}`);
45
- log.info(lines.join("\n"));
29
+ debugOut(lines.join("\n"));
30
+ }
31
+ //#endregion
32
+ //#region src/docker-check/compose-yaml.ts
33
+ /**
34
+ * Parse a Docker Compose file as plain data.
35
+ *
36
+ * Compose defines its own merge tags — `!reset` and `!override` — which control how
37
+ * values combine across layered `-f` files, and they may tag a scalar, a map or a
38
+ * sequence (`ipam: !reset null`, `ports: !override [...]`). `yaml` doesn't know them,
39
+ * and for an unknown tag it does not throw: it calls `process.emitWarning()` with code
40
+ * `TAG_RESOLVE_FAILED` and then falls back to the default schema. That warning reached
41
+ * every `bst repo:sync --check` and `bst docker:check` run in any repo whose overlay
42
+ * used a merge tag — noise in every CI log, from a file bst went looking for itself.
43
+ *
44
+ * `logLevel: "silent"` suppresses those non-fatal warnings. It does not weaken parsing:
45
+ * genuine syntax errors still throw, and a tagged value still resolves to its underlying
46
+ * data. Callers here only read compose as data (service names, images, ports,
47
+ * healthchecks, the top-level `name:`) and never re-serialize it, so the tag semantics
48
+ * are irrelevant to them — which is why silencing beats registering the tags, whose
49
+ * scalar/map/seq forms would need a six-entry matrix to model correctly.
50
+ *
51
+ * Do not drop the option; `test/docker-check/detect.test.ts` and `compose-lint.test.ts`
52
+ * pin it by asserting no process warning is emitted.
53
+ */
54
+ function parseComposeYaml(content) {
55
+ return parse(content, { logLevel: "silent" });
46
56
  }
47
57
  //#endregion
48
58
  //#region src/docker-check/compose-lint.ts
@@ -54,7 +64,7 @@ const ComposeFileShape = z.looseObject({ name: z.unknown().optional() });
54
64
  */
55
65
  const COMPOSE_DIR_CANDIDATES = [".", "docker"];
56
66
  /** Base compose file names whose project namespace defaults to dir basename. */
57
- const BASE_COMPOSE_FILES = new Set([
67
+ const BASE_COMPOSE_FILES = /* @__PURE__ */ new Set([
58
68
  "docker-compose.yml",
59
69
  "docker-compose.yaml",
60
70
  "compose.yml",
@@ -176,7 +186,7 @@ function readTopLevelName(absPath) {
176
186
  }
177
187
  let yamlValue;
178
188
  try {
179
- yamlValue = parse(raw);
189
+ yamlValue = parseComposeYaml(raw);
180
190
  } catch (_error) {
181
191
  return;
182
192
  }
@@ -187,47 +197,29 @@ function readTopLevelName(absPath) {
187
197
  return value;
188
198
  }
189
199
  //#endregion
190
- //#region src/utils/exec.ts
191
- /** Type guard for `execSync` errors that carry stdout/stderr/status. */
192
- function isExecSyncError(err) {
193
- return err instanceof Error && "stdout" in err && typeof err.stdout === "string" && "stderr" in err && typeof err.stderr === "string" && "status" in err && typeof err.status === "number";
194
- }
195
- //#endregion
196
200
  //#region src/docker-check/executor.ts
197
201
  /** Create an executor that runs real commands, fetches, and manages process signals. */
198
202
  function createRealExecutor() {
199
203
  return {
200
204
  exec(file, args, options) {
201
- try {
202
- return {
203
- stdout: execFileSync(file, args, {
204
- cwd: options?.cwd,
205
- env: options?.env ? {
206
- ...process.env,
207
- ...options.env
208
- } : void 0,
209
- encoding: "utf-8",
210
- stdio: [
211
- "pipe",
212
- "pipe",
213
- "pipe"
214
- ]
215
- }),
216
- stderr: "",
217
- exitCode: 0
218
- };
219
- } catch (err) {
220
- if (isExecSyncError(err)) return {
221
- stdout: err.stdout,
222
- stderr: err.stderr,
223
- exitCode: err.status
224
- };
225
- return {
226
- stdout: "",
227
- stderr: "",
228
- exitCode: 1
229
- };
230
- }
205
+ const result = spawnSync(file, args, {
206
+ cwd: options?.cwd,
207
+ env: options?.env ? {
208
+ ...process.env,
209
+ ...options.env
210
+ } : void 0,
211
+ encoding: "utf-8",
212
+ stdio: [
213
+ "pipe",
214
+ "pipe",
215
+ "pipe"
216
+ ]
217
+ });
218
+ return {
219
+ stdout: result.stdout ?? "",
220
+ stderr: result.stderr ?? "",
221
+ exitCode: result.status ?? 1
222
+ };
231
223
  },
232
224
  execInherit(file, args, options) {
233
225
  execFileSync(file, args, {
@@ -270,6 +262,55 @@ function composeArgs(config) {
270
262
  if (config.envFile) args.push("--env-file", config.envFile);
271
263
  return args;
272
264
  }
265
+ /**
266
+ * Compose's unset-interpolation warning. The variable name is quoted, and the
267
+ * quotes arrive backslash-escaped when stderr is a pipe (which it always is
268
+ * here) but bare when it is a TTY — so both forms are accepted.
269
+ */
270
+ const UNSET_VARIABLE_WARNING = /The\s+\\?"([^"\\]+)\\?"\s+variable is not set/g;
271
+ /** Names of the interpolation variables compose warned were unset, in order, de-duplicated. */
272
+ function parseUnsetVariables(stderr) {
273
+ const names = /* @__PURE__ */ new Set();
274
+ for (const match of stderr.matchAll(UNSET_VARIABLE_WARNING)) {
275
+ const name = match[1];
276
+ if (name) names.add(name);
277
+ }
278
+ return [...names];
279
+ }
280
+ /**
281
+ * Ask compose which interpolation variables are unset, and map each to an empty
282
+ * string so it can be injected into the child env of every later compose call.
283
+ *
284
+ * Compose warns `The "FOO" variable is not set` once per file per invocation —
285
+ * including on `down` — for a `${FOO}` with no default. Nothing expressible in
286
+ * the check overlay silences that: interpolation happens per-file, before the
287
+ * merge. Setting the variable does silence it, and setting it to the *empty
288
+ * string* is what compose already substitutes, so the value each container sees
289
+ * is unchanged. A non-empty placeholder would not be: a service that dials
290
+ * `${LYRION_HOST}` would start resolving a hostname where today it gets "".
291
+ *
292
+ * Detection is delegated to compose rather than re-implementing its grammar:
293
+ * `config` has already consulted `--env-file` and the process env, so it reports
294
+ * exactly the variables that still need filling, in every position (`image:`,
295
+ * `ports:`, `volumes:`, …) and every form, with `$$` escapes and `${FOO:-def}`
296
+ * defaults already accounted for. If the wording ever changes we fill nothing
297
+ * and the warnings simply come back — a `${FOO:?required}` error is never masked.
298
+ *
299
+ * Variables already present in `config.env` are left alone: the free-port logic
300
+ * owns PORT, and an empty string there would unbind the published port.
301
+ */
302
+ function fillUnsetVariables(executor, config) {
303
+ const result = executor.exec("docker", [...composeArgs(config), "config"], {
304
+ cwd: config.cwd,
305
+ env: config.env
306
+ });
307
+ const filled = {};
308
+ for (const name of parseUnsetVariables(result.stderr)) {
309
+ if (config.env && name in config.env) continue;
310
+ filled[name] = "";
311
+ }
312
+ return filled;
313
+ }
273
314
  /** Run the build command if configured. */
274
315
  function buildImages(executor, config) {
275
316
  if (!config.buildCommand) return;
@@ -372,6 +413,33 @@ async function checkHttpHealth(executor, check) {
372
413
  //#region src/docker-check/check.ts
373
414
  const DEFAULT_TIMEOUT_MS = 12e4;
374
415
  const DEFAULT_POLL_INTERVAL_MS = 5e3;
416
+ /**
417
+ * Fill any compose interpolation variable that is still unset with an empty
418
+ * string, so `${FOO}` with no default stops warning on every compose call —
419
+ * `up` and, after the check has already passed, teardown. See
420
+ * `fillUnsetVariables` for why empty (not a placeholder) and why compose itself
421
+ * decides which variables need it.
422
+ *
423
+ * This runs here rather than in the `docker:check` command so the env reaches
424
+ * every compose invocation *and* the smoketest through one `CheckConfig`, and so
425
+ * programmatic consumers of the `docker-check` export get it too.
426
+ */
427
+ function withFilledVariables(executor, config) {
428
+ const filled = fillUnsetVariables(executor, config.compose);
429
+ const names = Object.keys(filled);
430
+ if (names.length === 0) return config;
431
+ executor.log(`Unset compose variables set to an empty string: ${names.join(", ")}. To supply real values, put them in ${config.compose.envFile ?? "docker-compose.check.env"} beside the compose file.`);
432
+ return {
433
+ ...config,
434
+ compose: {
435
+ ...config.compose,
436
+ env: {
437
+ ...config.compose.env,
438
+ ...filled
439
+ }
440
+ }
441
+ };
442
+ }
375
443
  /** Probe any not-yet-healthy HTTP endpoints, flipping them to healthy in `healthStatus`. */
376
444
  async function updateHttpHealth(executor, healthChecks, healthStatus) {
377
445
  for (const check of healthChecks) {
@@ -409,7 +477,8 @@ function runSmoketestStep(executor, config, cleanup, startTime) {
409
477
  }
410
478
  }
411
479
  /** Run the full Docker check lifecycle. */
412
- async function runDockerCheck(executor, config) {
480
+ async function runDockerCheck(executor, rawConfig) {
481
+ const config = withFilledVariables(executor, rawConfig);
413
482
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
414
483
  const pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
415
484
  const { compose } = config;
@@ -540,4 +609,4 @@ function sanitize(name) {
540
609
  return name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "repo";
541
610
  }
542
611
  //#endregion
543
- export { log as _, composeArgs as a, composePs as c, isExecSyncError as d, lintComposeFiles as f, redact as g, isEnvVerbose as h, getContainerHealth as i, composeUp as l, debugExec as m, runDockerCheck as n, composeDown as o, debug as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u, note as v };
612
+ export { composeArgs as a, composePs as c, lintComposeFiles as d, parseComposeYaml as f, redact as g, isEnvVerbose as h, getContainerHealth as i, composeUp as l, debugExec as m, runDockerCheck as n, composeDown as o, debug as p, checkHttpHealth as r, composeLogs as s, deriveProjectName as t, createRealExecutor as u };
@@ -9,6 +9,7 @@ declare const PackageJsonSchema: z.ZodObject<{
9
9
  scripts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
10
10
  dependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
11
11
  devDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
+ peerDependencies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
12
13
  bin: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>]>>;
13
14
  exports: z.ZodOptional<z.ZodUnknown>;
14
15
  main: z.ZodOptional<z.ZodString>;
@@ -37,6 +38,10 @@ type PackageJson = z.infer<typeof PackageJsonSchema>;
37
38
  type CiPlatform = "github" | "forgejo" | "none";
38
39
  type ReleaseStrategy = "release-it" | "simple" | "changesets" | "none";
39
40
  type Formatter = "oxfmt" | "prettier";
41
+ /** Whether the repo has TS/JS source at all. Never inferred from absence. */
42
+ type Stack = "typescript" | "javascript" | "none";
43
+ /** What compiles the project, if anything. */
44
+ type Builder = "tsdown" | "vite" | "custom" | "none";
40
45
  /** User's answers from the interactive prompt or CLI flags. */
41
46
  interface ProjectConfig {
42
47
  /** Project name (from package.json name or user input) */
@@ -47,6 +52,10 @@ interface ProjectConfig {
47
52
  useEslintPlugin: boolean;
48
53
  /** Formatter choice */
49
54
  formatter: Formatter;
55
+ /** Whether the repo has TS/JS source at all */
56
+ stack: Stack;
57
+ /** What compiles the project */
58
+ builder: Builder;
50
59
  /** Set up vitest with a starter test */
51
60
  setupVitest: boolean;
52
61
  /** CI platform choice */
@@ -88,6 +97,13 @@ interface GeneratorContext {
88
97
  write: (relativePath: string, content: string) => void;
89
98
  /** Remove a file from the target directory (no-op if not found) */
90
99
  remove: (relativePath: string) => void;
100
+ /**
101
+ * Rewrite generated content the way the target repo's own formatter would — so a
102
+ * generated file is a fixed point of `pnpm format` and can't drift against
103
+ * `repo:sync --check`. Returns the content unchanged when the formatter isn't
104
+ * installed yet. See `utils/format.ts`.
105
+ */
106
+ format: (relativePath: string, content: string) => string;
91
107
  /** Prompt user for conflict resolution on non-mergeable files */
92
108
  confirmOverwrite: (relativePath: string) => Promise<"overwrite" | "skip">;
93
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.64.2",
3
+ "version": "0.66.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -47,7 +47,7 @@
47
47
  "zod": "^4.4.3"
48
48
  },
49
49
  "devDependencies": {
50
- "@bensandee/config": "0.12.0",
50
+ "@bensandee/config": "0.13.1",
51
51
  "@changesets/cli": "2.31.0",
52
52
  "@release-it/bumper": "7.0.6",
53
53
  "@types/node": "24.13.2",
@@ -55,8 +55,8 @@
55
55
  "commit-and-tag-version": "12.7.3",
56
56
  "prettier": "3.8.4",
57
57
  "release-it": "20.2.0",
58
- "tsdown": "0.22.2",
59
- "@bensandee/claude-hooks": "0.8.3"
58
+ "tsdown": "0.22.3",
59
+ "@bensandee/claude-hooks": "0.10.0"
60
60
  },
61
61
  "scripts": {
62
62
  "build": "tsdown",
@@ -314,6 +314,52 @@
314
314
  },
315
315
  "additionalProperties": false
316
316
  },
317
+ "docs": {
318
+ "description": "Plan/spec lifecycle gate (`bst docs:check`). Overrides only — dirs are discovered by convention. Set to false to disable. Violations are advisory until a pair is named in `enforced`.",
319
+ "anyOf": [
320
+ {
321
+ "type": "boolean",
322
+ "const": false
323
+ },
324
+ {
325
+ "type": "object",
326
+ "properties": {
327
+ "enforced": {
328
+ "description": "Which discovered pairs fail the build. true = all; an array names active dirs (e.g. [\"docs/plans\"]); default false = every violation is advisory.",
329
+ "anyOf": [
330
+ {
331
+ "type": "boolean"
332
+ },
333
+ {
334
+ "type": "array",
335
+ "items": {
336
+ "type": "string"
337
+ }
338
+ }
339
+ ]
340
+ },
341
+ "dirs": {
342
+ "description": "Extra active doc dirs beyond convention (e.g. [\"docs/rfcs\"]). Each retires into its own completed/ subdir.",
343
+ "type": "array",
344
+ "items": {
345
+ "type": "string"
346
+ }
347
+ },
348
+ "staleDays": {
349
+ "description": "Days before an untouched active doc is flagged (default 30; 0 disables the rule).",
350
+ "type": "integer",
351
+ "minimum": 0,
352
+ "maximum": 9007199254740991
353
+ },
354
+ "staleEnforced": {
355
+ "description": "Make the staleness rule fatal inside an enforced pair (default false: staleness is always advisory).",
356
+ "type": "boolean"
357
+ }
358
+ },
359
+ "additionalProperties": false
360
+ }
361
+ ]
362
+ },
317
363
  "deploy": {
318
364
  "description": "Generate a deploy-dispatch step in the release workflow that triggers a workflow_dispatch event in another repo after a successful release. The resolved release token must have rights on `dispatchRepo`.",
319
365
  "type": "object",
@@ -346,6 +392,44 @@
346
392
  "defaultBranch": {
347
393
  "description": "Repository default branch name. Used by `checks:run` to decide when to enable coverage automatically. Default: detected via git (origin/HEAD), falling back to 'main'.",
348
394
  "type": "string"
395
+ },
396
+ "unmanagedDevDependencies": {
397
+ "description": "devDependency names repo:sync must never add or re-pin. Escape hatch for holding a tooling-managed dep (oxlint, vitest, knip, ...) at a deliberately off-canonical version; without it, repo:sync raises any managed devDep whose version has drifted below the canonical pin.",
398
+ "type": "array",
399
+ "items": {
400
+ "type": "string"
401
+ }
402
+ },
403
+ "stack": {
404
+ "description": "Whether the repo has TS/JS source. Detected from source evidence; TypeScript is never inferred from absence.",
405
+ "type": "string",
406
+ "enum": ["typescript", "javascript", "none"]
407
+ },
408
+ "builder": {
409
+ "description": "What compiles the project. Detected from the package.json build script, then config-file presence.",
410
+ "type": "string",
411
+ "enum": ["tsdown", "vite", "custom", "none"]
412
+ },
413
+ "checks": {
414
+ "description": "Check-pipeline overrides: extra steps to add, and steps to skip (unioned with CI defaults).",
415
+ "type": "object",
416
+ "properties": {
417
+ "add": {
418
+ "description": "Extra package.json scripts to run as first-class check steps, appended to the built-in list (e.g. ['lint:php']). An added step with no matching script is a hard failure.",
419
+ "type": "array",
420
+ "items": {
421
+ "type": "string"
422
+ }
423
+ },
424
+ "skip": {
425
+ "description": "Check steps to skip, unioned with the built-in CI defaults (never replacing them). Supports glob patterns.",
426
+ "type": "array",
427
+ "items": {
428
+ "type": "string"
429
+ }
430
+ }
431
+ },
432
+ "additionalProperties": false
349
433
  }
350
434
  },
351
435
  "additionalProperties": false