@codluv/versionguard 0.9.0 → 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.
@@ -1101,9 +1101,16 @@ function areHooksInstalled(cwd = process.cwd()) {
1101
1101
  * @param hookName - Name of the Git hook to generate.
1102
1102
  * @returns The VG block with start/end markers.
1103
1103
  */
1104
+ /** Mode description per hook for generated comments. */
1105
+ var HOOK_MODE_COMMENTS = {
1106
+ "pre-commit": "# Mode: lightweight (version + sync only — fast)",
1107
+ "pre-push": "# Mode: full (version, sync, changelog, scan, guard, publish)",
1108
+ "post-tag": "# Mode: full with post-tag actions"
1109
+ };
1104
1110
  function generateHookBlock(hookName) {
1105
1111
  return `${VG_BLOCK_START}
1106
1112
  # VersionGuard ${hookName} hook
1113
+ ${HOOK_MODE_COMMENTS[hookName] ?? "# Mode: full"}
1107
1114
  # --no-install prevents accidentally downloading an unscoped package
1108
1115
  # if @codluv/versionguard is not installed locally
1109
1116
  npx --no-install versionguard validate --hook=${hookName}
@@ -1160,6 +1167,212 @@ function removeVgBlock(content) {
1160
1167
  return content.slice(0, startIdx).replace(/\n\n$/, "\n") + content.slice(endIdx + 22).replace(/^\n\n/, "\n");
1161
1168
  }
1162
1169
  //#endregion
1170
+ //#region src/guard.ts
1171
+ var HOOK_NAMES = [
1172
+ "pre-commit",
1173
+ "pre-push",
1174
+ "post-tag"
1175
+ ];
1176
+ /**
1177
+ * Checks whether git hooks have been redirected away from the repository.
1178
+ *
1179
+ * @remarks
1180
+ * When `core.hooksPath` is set to a non-default location, git hooks installed
1181
+ * in `.git/hooks/` are silently ignored. This is a common bypass vector.
1182
+ *
1183
+ * @param cwd - Repository directory to inspect.
1184
+ * @returns A guard warning when a hooksPath override is detected.
1185
+ *
1186
+ * @example
1187
+ * ```ts
1188
+ * import { checkHooksPathOverride } from './guard';
1189
+ *
1190
+ * const warning = checkHooksPathOverride(process.cwd());
1191
+ * if (warning) console.warn(warning.message);
1192
+ * ```
1193
+ *
1194
+ * @public
1195
+ * @since 0.2.0
1196
+ */
1197
+ function checkHooksPathOverride(cwd) {
1198
+ try {
1199
+ const hooksPath = execSync("git config core.hooksPath", {
1200
+ cwd,
1201
+ encoding: "utf-8"
1202
+ }).trim();
1203
+ if (hooksPath) {
1204
+ const resolved = path.resolve(cwd, hooksPath);
1205
+ const huskyDir = path.resolve(cwd, ".husky");
1206
+ if (resolved === huskyDir || resolved.startsWith(`${huskyDir}${path.sep}`)) return {
1207
+ code: "HOOKS_PATH_HUSKY",
1208
+ severity: "warning",
1209
+ message: `Husky detected — core.hooksPath is set to "${hooksPath}". Hooks in .git/hooks/ are bypassed. Add versionguard validate to your .husky/pre-commit manually or use a tool like forge-ts that manages .husky/ hooks cooperatively.`
1210
+ };
1211
+ return {
1212
+ code: "HOOKS_PATH_OVERRIDE",
1213
+ severity: "error",
1214
+ message: `git core.hooksPath is set to "${hooksPath}" — hooks in .git/hooks/ are bypassed`,
1215
+ fix: "git config --unset core.hooksPath"
1216
+ };
1217
+ }
1218
+ } catch {}
1219
+ return null;
1220
+ }
1221
+ /**
1222
+ * Checks whether the HUSKY environment variable is disabling hooks.
1223
+ *
1224
+ * @remarks
1225
+ * Setting `HUSKY=0` is a documented way to disable Husky hooks. Since
1226
+ * VersionGuard hooks may run alongside or through Husky, this bypass
1227
+ * can silently disable enforcement.
1228
+ *
1229
+ * @returns A guard warning when the HUSKY bypass is detected.
1230
+ *
1231
+ * @example
1232
+ * ```ts
1233
+ * import { checkHuskyBypass } from './guard';
1234
+ *
1235
+ * const warning = checkHuskyBypass();
1236
+ * if (warning) console.warn(warning.message);
1237
+ * ```
1238
+ *
1239
+ * @public
1240
+ * @since 0.2.0
1241
+ */
1242
+ function checkHuskyBypass() {
1243
+ if (process.env.HUSKY === "0") return {
1244
+ code: "HUSKY_BYPASS",
1245
+ severity: "error",
1246
+ message: "HUSKY=0 is set — git hooks are disabled via environment variable",
1247
+ fix: "unset HUSKY"
1248
+ };
1249
+ return null;
1250
+ }
1251
+ /**
1252
+ * Verifies that installed hook scripts match the expected content.
1253
+ *
1254
+ * @remarks
1255
+ * This compares each hook file against what `generateHookScript` would produce.
1256
+ * Tampered hooks that still contain "versionguard" pass `areHooksInstalled` but
1257
+ * may have had critical lines removed or modified.
1258
+ *
1259
+ * @param config - VersionGuard configuration that defines which hooks should exist.
1260
+ * @param cwd - Repository directory to inspect.
1261
+ * @returns Guard warnings for each hook that has been tampered with.
1262
+ *
1263
+ * @example
1264
+ * ```ts
1265
+ * import { checkHookIntegrity } from './guard';
1266
+ *
1267
+ * const warnings = checkHookIntegrity(config, process.cwd());
1268
+ * for (const w of warnings) console.warn(w.code, w.message);
1269
+ * ```
1270
+ *
1271
+ * @public
1272
+ * @since 0.2.0
1273
+ */
1274
+ function checkHookIntegrity(config, cwd) {
1275
+ const warnings = [];
1276
+ const gitDir = findGitDir(cwd);
1277
+ if (!gitDir) return warnings;
1278
+ const hooksDir = path.join(gitDir, "hooks");
1279
+ for (const hookName of HOOK_NAMES) {
1280
+ if (!config.git.hooks[hookName]) continue;
1281
+ const hookPath = path.join(hooksDir, hookName);
1282
+ if (!fs.existsSync(hookPath)) {
1283
+ warnings.push({
1284
+ code: "HOOK_MISSING",
1285
+ severity: "error",
1286
+ message: `Required hook "${hookName}" is not installed`,
1287
+ fix: "npx versionguard hooks install"
1288
+ });
1289
+ continue;
1290
+ }
1291
+ const actual = fs.readFileSync(hookPath, "utf-8");
1292
+ if (actual !== generateHookScript(hookName)) if (!actual.includes("versionguard")) warnings.push({
1293
+ code: "HOOK_REPLACED",
1294
+ severity: "error",
1295
+ message: `Hook "${hookName}" has been replaced — versionguard invocation is missing`,
1296
+ fix: "npx versionguard hooks install"
1297
+ });
1298
+ else warnings.push({
1299
+ code: "HOOK_TAMPERED",
1300
+ severity: "warning",
1301
+ message: `Hook "${hookName}" has been modified from the expected template`,
1302
+ fix: "npx versionguard hooks install"
1303
+ });
1304
+ }
1305
+ return warnings;
1306
+ }
1307
+ /**
1308
+ * Checks whether hooks are configured as required but not enforced.
1309
+ *
1310
+ * @remarks
1311
+ * When hooks are enabled in the config but `enforceHooks` is false, validation
1312
+ * will not fail for missing hooks. In strict mode this is a policy gap.
1313
+ *
1314
+ * @param config - VersionGuard configuration to inspect.
1315
+ * @returns A guard warning when hooks are enabled but not enforced.
1316
+ *
1317
+ * @example
1318
+ * ```ts
1319
+ * import { checkEnforceHooksPolicy } from './guard';
1320
+ *
1321
+ * const warning = checkEnforceHooksPolicy(config);
1322
+ * if (warning) console.warn(warning.message);
1323
+ * ```
1324
+ *
1325
+ * @public
1326
+ * @since 0.2.0
1327
+ */
1328
+ function checkEnforceHooksPolicy(config) {
1329
+ if (HOOK_NAMES.some((name) => config.git.hooks[name]) && !config.git.enforceHooks) return {
1330
+ code: "HOOKS_NOT_ENFORCED",
1331
+ severity: "warning",
1332
+ message: "Hooks are enabled but enforceHooks is false — missing hooks will not fail validation",
1333
+ fix: "Set git.enforceHooks: true in .versionguard.yml"
1334
+ };
1335
+ return null;
1336
+ }
1337
+ /**
1338
+ * Runs all guard checks and returns a consolidated report.
1339
+ *
1340
+ * @remarks
1341
+ * This is the primary entry point for strict mode. It runs every detection
1342
+ * check and returns a report indicating whether the repository is safe from
1343
+ * known bypass patterns.
1344
+ *
1345
+ * @param config - VersionGuard configuration.
1346
+ * @param cwd - Repository directory to inspect.
1347
+ * @returns A guard report with all findings.
1348
+ *
1349
+ * @example
1350
+ * ```ts
1351
+ * import { runGuardChecks } from './guard';
1352
+ *
1353
+ * const report = runGuardChecks(config, process.cwd());
1354
+ * if (!report.safe) console.error('Guard check failed:', report.warnings);
1355
+ * ```
1356
+ *
1357
+ * @public
1358
+ * @since 0.2.0
1359
+ */
1360
+ function runGuardChecks(config, cwd) {
1361
+ const warnings = [];
1362
+ const hooksPathWarning = checkHooksPathOverride(cwd);
1363
+ if (hooksPathWarning) warnings.push(hooksPathWarning);
1364
+ const huskyWarning = checkHuskyBypass();
1365
+ if (huskyWarning) warnings.push(huskyWarning);
1366
+ const integrityWarnings = checkHookIntegrity(config, cwd);
1367
+ warnings.push(...integrityWarnings);
1368
+ const enforceWarning = checkEnforceHooksPolicy(config);
1369
+ if (enforceWarning) warnings.push(enforceWarning);
1370
+ return {
1371
+ safe: !warnings.some((w) => w.severity === "error"),
1372
+ warnings
1373
+ };
1374
+ }
1375
+ //#endregion
1163
1376
  //#region src/sources/git-tag.ts
1164
1377
  /**
1165
1378
  * Git tag-based version source for Go, Swift, and similar ecosystems.
@@ -2119,23 +2332,261 @@ function setPackageVersion(version, cwd = process.cwd(), manifest) {
2119
2332
  * for the configured manifest type. Use this when you need direct access
2120
2333
  * to the provider's `exists`, `getVersion`, and `setVersion` methods.
2121
2334
  *
2122
- * @param manifest - Manifest configuration.
2335
+ * @param manifest - Manifest configuration.
2336
+ * @param cwd - Project directory.
2337
+ * @returns The resolved provider instance.
2338
+ *
2339
+ * @example
2340
+ * ```ts
2341
+ * import { getVersionSource } from 'versionguard';
2342
+ *
2343
+ * const source = getVersionSource({ source: 'package.json' }, process.cwd());
2344
+ * const version = source.getVersion(process.cwd());
2345
+ * ```
2346
+ *
2347
+ * @public
2348
+ * @since 0.3.0
2349
+ */
2350
+ function getVersionSource(manifest, cwd = process.cwd()) {
2351
+ return resolveVersionSource(manifest, cwd);
2352
+ }
2353
+ //#endregion
2354
+ //#region src/publish.ts
2355
+ /**
2356
+ * Registry publish status verification.
2357
+ *
2358
+ * Checks whether a package version has been published to its ecosystem registry.
2359
+ * Supports npm (via execFileSync), crates.io, PyPI, Packagist, pub.dev, and Maven Central (via fetch).
2360
+ *
2361
+ * @packageDocumentation
2362
+ */
2363
+ /**
2364
+ * Maps manifest source types to their registry check implementations.
2365
+ *
2366
+ * @remarks
2367
+ * Each entry provides the registry name and a check function that returns
2368
+ * whether the given version is published. The check function receives the
2369
+ * package name, version, and publish config.
2370
+ *
2371
+ * @public
2372
+ * @since 1.0.0
2373
+ */
2374
+ var REGISTRY_TABLE = {
2375
+ "package.json": {
2376
+ registry: "npm",
2377
+ check: checkNpmPublished
2378
+ },
2379
+ "Cargo.toml": {
2380
+ registry: "crates.io",
2381
+ check: checkHttpRegistry("crates.io", (name, version) => `https://crates.io/api/v1/crates/${encodeURIComponent(name)}/${encodeURIComponent(version)}`)
2382
+ },
2383
+ "pyproject.toml": {
2384
+ registry: "pypi",
2385
+ check: checkHttpRegistry("pypi", (name, version) => `https://pypi.org/pypi/${encodeURIComponent(name)}/${encodeURIComponent(version)}/json`)
2386
+ },
2387
+ "composer.json": {
2388
+ registry: "packagist",
2389
+ check: checkHttpRegistry("packagist", (name) => `https://repo.packagist.org/p2/${encodeURIComponent(name)}.json`, (body, version) => {
2390
+ try {
2391
+ const packages = JSON.parse(body).packages;
2392
+ if (!packages) return false;
2393
+ return Object.values(packages).flat().some((v) => v.version === version);
2394
+ } catch {
2395
+ return false;
2396
+ }
2397
+ })
2398
+ },
2399
+ "pubspec.yaml": {
2400
+ registry: "pub.dev",
2401
+ check: checkHttpRegistry("pub.dev", (name, version) => `https://pub.dev/api/packages/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`)
2402
+ },
2403
+ "pom.xml": {
2404
+ registry: "maven-central",
2405
+ check: checkHttpRegistry("maven-central", (name, version) => `https://search.maven.org/solrsearch/select?q=a:"${encodeURIComponent(name)}"+AND+v:"${encodeURIComponent(version)}"&rows=1&wt=json`, (body) => {
2406
+ try {
2407
+ return (JSON.parse(body).response?.numFound ?? 0) > 0;
2408
+ } catch {
2409
+ return false;
2410
+ }
2411
+ })
2412
+ }
2413
+ };
2414
+ /** Source types that have no registry to check. */
2415
+ var SKIP_SOURCES = new Set([
2416
+ "VERSION",
2417
+ "git-tag",
2418
+ "auto",
2419
+ "custom"
2420
+ ]);
2421
+ /**
2422
+ * Checks whether a package version has been published to its ecosystem registry.
2423
+ *
2424
+ * @remarks
2425
+ * Uses the REGISTRY_TABLE to dispatch to the correct check implementation
2426
+ * based on the detected manifest source type. Fail-open on network errors:
2427
+ * returns `published: false` with an error message when the check cannot complete.
2428
+ *
2429
+ * @param manifestSource - The detected manifest source type.
2430
+ * @param packageName - Package name as read from the manifest.
2431
+ * @param version - Version string to check.
2432
+ * @param config - Publish configuration with timeout and optional registry URL.
2433
+ * @returns The publish check result.
2434
+ *
2435
+ * @example
2436
+ * ```ts
2437
+ * import { checkPublishStatus } from './publish';
2438
+ *
2439
+ * const result = await checkPublishStatus('package.json', '@codluv/vg', '1.0.0', { enabled: true, timeout: 5000 });
2440
+ * ```
2441
+ *
2442
+ * @public
2443
+ * @since 1.0.0
2444
+ */
2445
+ async function checkPublishStatus(manifestSource, packageName, version, config) {
2446
+ if (SKIP_SOURCES.has(manifestSource)) return {
2447
+ published: false,
2448
+ registry: "none",
2449
+ packageName
2450
+ };
2451
+ if (config.registryUrl && !/^https?:\/\//i.test(config.registryUrl)) return {
2452
+ published: false,
2453
+ registry: "unknown",
2454
+ packageName,
2455
+ error: `Invalid registry URL scheme — only http:// and https:// are allowed: ${config.registryUrl}`
2456
+ };
2457
+ const entry = REGISTRY_TABLE[manifestSource];
2458
+ if (!entry) return {
2459
+ published: false,
2460
+ registry: "unknown",
2461
+ packageName
2462
+ };
2463
+ try {
2464
+ return await entry.check(packageName, version, config);
2465
+ } catch (err) {
2466
+ return {
2467
+ published: false,
2468
+ registry: entry.registry,
2469
+ packageName,
2470
+ error: `Publish check failed: ${err.message}`
2471
+ };
2472
+ }
2473
+ }
2474
+ /**
2475
+ * Checks npm registry using execFileSync (shell-injection safe, inherits .npmrc auth).
2476
+ */
2477
+ function checkNpmPublished(packageName, version, config) {
2478
+ try {
2479
+ const args = [
2480
+ "view",
2481
+ `${packageName}@${version}`,
2482
+ "version"
2483
+ ];
2484
+ if (config.registryUrl) args.push(`--registry=${config.registryUrl}`);
2485
+ return {
2486
+ published: execFileSync("npm", args, {
2487
+ encoding: "utf-8",
2488
+ timeout: config.timeout,
2489
+ stdio: [
2490
+ "pipe",
2491
+ "pipe",
2492
+ "pipe"
2493
+ ]
2494
+ }).trim() === version,
2495
+ registry: "npm",
2496
+ packageName
2497
+ };
2498
+ } catch (err) {
2499
+ const message = err.message || "";
2500
+ if (message.includes("E404") || message.includes("is not in this registry")) return {
2501
+ published: false,
2502
+ registry: "npm",
2503
+ packageName
2504
+ };
2505
+ return {
2506
+ published: false,
2507
+ registry: "npm",
2508
+ packageName,
2509
+ error: `npm check failed: ${message.split("\n")[0].slice(0, 100)}`
2510
+ };
2511
+ }
2512
+ }
2513
+ /**
2514
+ * Creates an HTTP registry check function using fetch with AbortController timeout.
2515
+ */
2516
+ function checkHttpRegistry(registry, urlBuilder, bodyChecker) {
2517
+ return async (packageName, version, config) => {
2518
+ const url = config.registryUrl ? `${config.registryUrl.replace(/\/$/, "")}/${packageName}/${version}` : urlBuilder(packageName, version);
2519
+ const controller = new AbortController();
2520
+ const timeout = setTimeout(() => controller.abort(), config.timeout);
2521
+ try {
2522
+ const response = await fetch(url, {
2523
+ signal: controller.signal,
2524
+ headers: { Accept: "application/json" }
2525
+ });
2526
+ if (response.status === 404) return {
2527
+ published: false,
2528
+ registry,
2529
+ packageName
2530
+ };
2531
+ if (!response.ok) return {
2532
+ published: false,
2533
+ registry,
2534
+ packageName,
2535
+ error: `Registry returned HTTP ${response.status}`
2536
+ };
2537
+ if (bodyChecker) return {
2538
+ published: bodyChecker(await response.text(), version),
2539
+ registry,
2540
+ packageName
2541
+ };
2542
+ return {
2543
+ published: true,
2544
+ registry,
2545
+ packageName
2546
+ };
2547
+ } catch (err) {
2548
+ const message = err.message || "";
2549
+ if (message.includes("abort")) return {
2550
+ published: false,
2551
+ registry,
2552
+ packageName,
2553
+ error: `Registry check timed out after ${config.timeout}ms`
2554
+ };
2555
+ return {
2556
+ published: false,
2557
+ registry,
2558
+ packageName,
2559
+ error: `Registry check failed: ${message.slice(0, 200)}`
2560
+ };
2561
+ } finally {
2562
+ clearTimeout(timeout);
2563
+ }
2564
+ };
2565
+ }
2566
+ /**
2567
+ * Reads the package name from a manifest file for registry lookups.
2568
+ *
2569
+ * @param manifestSource - Detected manifest type.
2123
2570
  * @param cwd - Project directory.
2124
- * @returns The resolved provider instance.
2125
- *
2126
- * @example
2127
- * ```ts
2128
- * import { getVersionSource } from 'versionguard';
2129
- *
2130
- * const source = getVersionSource({ source: 'package.json' }, process.cwd());
2131
- * const version = source.getVersion(process.cwd());
2132
- * ```
2571
+ * @returns The package name, or null if it cannot be determined.
2133
2572
  *
2134
2573
  * @public
2135
- * @since 0.3.0
2574
+ * @since 1.0.0
2136
2575
  */
2137
- function getVersionSource(manifest, cwd = process.cwd()) {
2138
- return resolveVersionSource(manifest, cwd);
2576
+ function readPackageName(manifestSource, cwd) {
2577
+ try {
2578
+ switch (manifestSource) {
2579
+ case "package.json": return JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8")).name || null;
2580
+ case "Cargo.toml": return fs.readFileSync(path.join(cwd, "Cargo.toml"), "utf-8").match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1] || null;
2581
+ case "pyproject.toml": return fs.readFileSync(path.join(cwd, "pyproject.toml"), "utf-8").match(/^\s*name\s*=\s*"([^"]+)"/m)?.[1] || null;
2582
+ case "composer.json": return JSON.parse(fs.readFileSync(path.join(cwd, "composer.json"), "utf-8")).name || null;
2583
+ case "pubspec.yaml": return fs.readFileSync(path.join(cwd, "pubspec.yaml"), "utf-8").match(/^name:\s*(.+)$/m)?.[1]?.trim() || null;
2584
+ case "pom.xml": return fs.readFileSync(path.join(cwd, "pom.xml"), "utf-8").match(/<artifactId>([^<]+)<\/artifactId>/)?.[1] || null;
2585
+ default: return null;
2586
+ }
2587
+ } catch {
2588
+ return null;
2589
+ }
2139
2590
  }
2140
2591
  //#endregion
2141
2592
  //#region src/semver.ts
@@ -3029,7 +3480,7 @@ var DEFAULT_CONFIG = {
3029
3480
  },
3030
3481
  github: { dependabot: true },
3031
3482
  scan: {
3032
- enabled: false,
3483
+ enabled: true,
3033
3484
  patterns: [
3034
3485
  "(?:version\\s*[:=]\\s*[\"'])([\\d]+\\.[\\d]+\\.[\\d]+(?:-[\\w.]+)?)[\"']",
3035
3486
  "(?:FROM\\s+\\S+:)(\\d+\\.\\d+\\.\\d+(?:-[\\w.]+)?)",
@@ -3045,6 +3496,11 @@ var DEFAULT_CONFIG = {
3045
3496
  },
3046
3497
  enforceHooks: true
3047
3498
  },
3499
+ guard: { enabled: true },
3500
+ publish: {
3501
+ enabled: true,
3502
+ timeout: 5e3
3503
+ },
3048
3504
  ignore: [
3049
3505
  "node_modules/**",
3050
3506
  "dist/**",
@@ -3204,6 +3660,16 @@ changelog:
3204
3660
  github:
3205
3661
  dependabot: true
3206
3662
 
3663
+ scan:
3664
+ enabled: true
3665
+
3666
+ guard:
3667
+ enabled: true
3668
+
3669
+ publish:
3670
+ enabled: true
3671
+ timeout: 5000
3672
+
3207
3673
  git:
3208
3674
  hooks:
3209
3675
  pre-commit: true
@@ -3816,212 +4282,6 @@ function suggestNextVersion(currentVersion, config, changeType) {
3816
4282
  return suggestions;
3817
4283
  }
3818
4284
  //#endregion
3819
- //#region src/guard.ts
3820
- var HOOK_NAMES = [
3821
- "pre-commit",
3822
- "pre-push",
3823
- "post-tag"
3824
- ];
3825
- /**
3826
- * Checks whether git hooks have been redirected away from the repository.
3827
- *
3828
- * @remarks
3829
- * When `core.hooksPath` is set to a non-default location, git hooks installed
3830
- * in `.git/hooks/` are silently ignored. This is a common bypass vector.
3831
- *
3832
- * @param cwd - Repository directory to inspect.
3833
- * @returns A guard warning when a hooksPath override is detected.
3834
- *
3835
- * @example
3836
- * ```ts
3837
- * import { checkHooksPathOverride } from './guard';
3838
- *
3839
- * const warning = checkHooksPathOverride(process.cwd());
3840
- * if (warning) console.warn(warning.message);
3841
- * ```
3842
- *
3843
- * @public
3844
- * @since 0.2.0
3845
- */
3846
- function checkHooksPathOverride(cwd) {
3847
- try {
3848
- const hooksPath = execSync("git config core.hooksPath", {
3849
- cwd,
3850
- encoding: "utf-8"
3851
- }).trim();
3852
- if (hooksPath) {
3853
- const resolved = path.resolve(cwd, hooksPath);
3854
- const huskyDir = path.resolve(cwd, ".husky");
3855
- if (resolved === huskyDir || resolved.startsWith(`${huskyDir}${path.sep}`)) return {
3856
- code: "HOOKS_PATH_HUSKY",
3857
- severity: "warning",
3858
- message: `Husky detected — core.hooksPath is set to "${hooksPath}". Hooks in .git/hooks/ are bypassed. Add versionguard validate to your .husky/pre-commit manually or use a tool like forge-ts that manages .husky/ hooks cooperatively.`
3859
- };
3860
- return {
3861
- code: "HOOKS_PATH_OVERRIDE",
3862
- severity: "error",
3863
- message: `git core.hooksPath is set to "${hooksPath}" — hooks in .git/hooks/ are bypassed`,
3864
- fix: "git config --unset core.hooksPath"
3865
- };
3866
- }
3867
- } catch {}
3868
- return null;
3869
- }
3870
- /**
3871
- * Checks whether the HUSKY environment variable is disabling hooks.
3872
- *
3873
- * @remarks
3874
- * Setting `HUSKY=0` is a documented way to disable Husky hooks. Since
3875
- * VersionGuard hooks may run alongside or through Husky, this bypass
3876
- * can silently disable enforcement.
3877
- *
3878
- * @returns A guard warning when the HUSKY bypass is detected.
3879
- *
3880
- * @example
3881
- * ```ts
3882
- * import { checkHuskyBypass } from './guard';
3883
- *
3884
- * const warning = checkHuskyBypass();
3885
- * if (warning) console.warn(warning.message);
3886
- * ```
3887
- *
3888
- * @public
3889
- * @since 0.2.0
3890
- */
3891
- function checkHuskyBypass() {
3892
- if (process.env.HUSKY === "0") return {
3893
- code: "HUSKY_BYPASS",
3894
- severity: "error",
3895
- message: "HUSKY=0 is set — git hooks are disabled via environment variable",
3896
- fix: "unset HUSKY"
3897
- };
3898
- return null;
3899
- }
3900
- /**
3901
- * Verifies that installed hook scripts match the expected content.
3902
- *
3903
- * @remarks
3904
- * This compares each hook file against what `generateHookScript` would produce.
3905
- * Tampered hooks that still contain "versionguard" pass `areHooksInstalled` but
3906
- * may have had critical lines removed or modified.
3907
- *
3908
- * @param config - VersionGuard configuration that defines which hooks should exist.
3909
- * @param cwd - Repository directory to inspect.
3910
- * @returns Guard warnings for each hook that has been tampered with.
3911
- *
3912
- * @example
3913
- * ```ts
3914
- * import { checkHookIntegrity } from './guard';
3915
- *
3916
- * const warnings = checkHookIntegrity(config, process.cwd());
3917
- * for (const w of warnings) console.warn(w.code, w.message);
3918
- * ```
3919
- *
3920
- * @public
3921
- * @since 0.2.0
3922
- */
3923
- function checkHookIntegrity(config, cwd) {
3924
- const warnings = [];
3925
- const gitDir = findGitDir(cwd);
3926
- if (!gitDir) return warnings;
3927
- const hooksDir = path.join(gitDir, "hooks");
3928
- for (const hookName of HOOK_NAMES) {
3929
- if (!config.git.hooks[hookName]) continue;
3930
- const hookPath = path.join(hooksDir, hookName);
3931
- if (!fs.existsSync(hookPath)) {
3932
- warnings.push({
3933
- code: "HOOK_MISSING",
3934
- severity: "error",
3935
- message: `Required hook "${hookName}" is not installed`,
3936
- fix: "npx versionguard hooks install"
3937
- });
3938
- continue;
3939
- }
3940
- const actual = fs.readFileSync(hookPath, "utf-8");
3941
- if (actual !== generateHookScript(hookName)) if (!actual.includes("versionguard")) warnings.push({
3942
- code: "HOOK_REPLACED",
3943
- severity: "error",
3944
- message: `Hook "${hookName}" has been replaced — versionguard invocation is missing`,
3945
- fix: "npx versionguard hooks install"
3946
- });
3947
- else warnings.push({
3948
- code: "HOOK_TAMPERED",
3949
- severity: "warning",
3950
- message: `Hook "${hookName}" has been modified from the expected template`,
3951
- fix: "npx versionguard hooks install"
3952
- });
3953
- }
3954
- return warnings;
3955
- }
3956
- /**
3957
- * Checks whether hooks are configured as required but not enforced.
3958
- *
3959
- * @remarks
3960
- * When hooks are enabled in the config but `enforceHooks` is false, validation
3961
- * will not fail for missing hooks. In strict mode this is a policy gap.
3962
- *
3963
- * @param config - VersionGuard configuration to inspect.
3964
- * @returns A guard warning when hooks are enabled but not enforced.
3965
- *
3966
- * @example
3967
- * ```ts
3968
- * import { checkEnforceHooksPolicy } from './guard';
3969
- *
3970
- * const warning = checkEnforceHooksPolicy(config);
3971
- * if (warning) console.warn(warning.message);
3972
- * ```
3973
- *
3974
- * @public
3975
- * @since 0.2.0
3976
- */
3977
- function checkEnforceHooksPolicy(config) {
3978
- if (HOOK_NAMES.some((name) => config.git.hooks[name]) && !config.git.enforceHooks) return {
3979
- code: "HOOKS_NOT_ENFORCED",
3980
- severity: "warning",
3981
- message: "Hooks are enabled but enforceHooks is false — missing hooks will not fail validation",
3982
- fix: "Set git.enforceHooks: true in .versionguard.yml"
3983
- };
3984
- return null;
3985
- }
3986
- /**
3987
- * Runs all guard checks and returns a consolidated report.
3988
- *
3989
- * @remarks
3990
- * This is the primary entry point for strict mode. It runs every detection
3991
- * check and returns a report indicating whether the repository is safe from
3992
- * known bypass patterns.
3993
- *
3994
- * @param config - VersionGuard configuration.
3995
- * @param cwd - Repository directory to inspect.
3996
- * @returns A guard report with all findings.
3997
- *
3998
- * @example
3999
- * ```ts
4000
- * import { runGuardChecks } from './guard';
4001
- *
4002
- * const report = runGuardChecks(config, process.cwd());
4003
- * if (!report.safe) console.error('Guard check failed:', report.warnings);
4004
- * ```
4005
- *
4006
- * @public
4007
- * @since 0.2.0
4008
- */
4009
- function runGuardChecks(config, cwd) {
4010
- const warnings = [];
4011
- const hooksPathWarning = checkHooksPathOverride(cwd);
4012
- if (hooksPathWarning) warnings.push(hooksPathWarning);
4013
- const huskyWarning = checkHuskyBypass();
4014
- if (huskyWarning) warnings.push(huskyWarning);
4015
- const integrityWarnings = checkHookIntegrity(config, cwd);
4016
- warnings.push(...integrityWarnings);
4017
- const enforceWarning = checkEnforceHooksPolicy(config);
4018
- if (enforceWarning) warnings.push(enforceWarning);
4019
- return {
4020
- safe: !warnings.some((w) => w.severity === "error"),
4021
- warnings
4022
- };
4023
- }
4024
- //#endregion
4025
4285
  //#region src/project-root.ts
4026
4286
  /**
4027
4287
  * Project root detection and boundary validation.
@@ -4556,20 +4816,22 @@ function validateVersion(version, config) {
4556
4816
  * @public
4557
4817
  * @since 0.1.0
4558
4818
  * @remarks
4559
- * This reads the package version from `package.json`, validates the version
4560
- * format, checks synchronized files, and optionally validates the changelog.
4819
+ * Runs version, sync, changelog, scan, guard, and publish checks based on
4820
+ * the validation mode. Full mode (default) runs all checks. Lightweight
4821
+ * mode (for pre-commit hooks) runs only version + sync.
4561
4822
  *
4562
4823
  * @param config - VersionGuard configuration to apply.
4563
4824
  * @param cwd - Project directory to inspect.
4825
+ * @param mode - Validation mode: 'full' (default) or 'lightweight'.
4564
4826
  * @returns A full validation report for the project rooted at `cwd`.
4565
4827
  * @example
4566
4828
  * ```ts
4567
4829
  * import { getDefaultConfig, validate } from 'versionguard';
4568
4830
  *
4569
- * const result = validate(getDefaultConfig(), process.cwd());
4831
+ * const result = await validate(getDefaultConfig(), process.cwd());
4570
4832
  * ```
4571
4833
  */
4572
- function validate(config, cwd = process.cwd()) {
4834
+ async function validate(config, cwd = process.cwd(), mode = "full") {
4573
4835
  const errors = [];
4574
4836
  let version;
4575
4837
  try {
@@ -4581,6 +4843,9 @@ function validate(config, cwd = process.cwd()) {
4581
4843
  versionValid: false,
4582
4844
  syncValid: false,
4583
4845
  changelogValid: false,
4846
+ scanValid: true,
4847
+ guardValid: true,
4848
+ publishValid: true,
4584
4849
  errors: [err.message]
4585
4850
  };
4586
4851
  }
@@ -4588,10 +4853,17 @@ function validate(config, cwd = process.cwd()) {
4588
4853
  if (!versionResult.valid) errors.push(...versionResult.errors.map((error) => error.message));
4589
4854
  const hardcoded = checkHardcodedVersions(version, config.sync, config.ignore, cwd);
4590
4855
  if (hardcoded.length > 0) for (const mismatch of hardcoded) errors.push(`Version mismatch in ${mismatch.file}:${mismatch.line} - found "${mismatch.found}" but expected "${version}"`);
4591
- if (config.scan?.enabled) {
4592
- const scanFindings = scanRepoForVersions(version, config.scan, config.ignore, cwd);
4593
- for (const finding of scanFindings) errors.push(`Stale version in ${finding.file}:${finding.line} - found "${finding.found}" but expected "${version}"`);
4594
- }
4856
+ if (mode === "lightweight") return {
4857
+ valid: errors.length === 0,
4858
+ version,
4859
+ versionValid: versionResult.valid,
4860
+ syncValid: hardcoded.length === 0,
4861
+ changelogValid: true,
4862
+ scanValid: true,
4863
+ guardValid: true,
4864
+ publishValid: true,
4865
+ errors
4866
+ };
4595
4867
  let changelogValid = true;
4596
4868
  if (config.changelog.enabled) {
4597
4869
  const changelogResult = validateChangelog(path.join(cwd, config.changelog.file), version, config.changelog.strict, config.changelog.requireEntry, {
@@ -4603,12 +4875,46 @@ function validate(config, cwd = process.cwd()) {
4603
4875
  errors.push(...changelogResult.errors);
4604
4876
  }
4605
4877
  }
4878
+ let scanValid = true;
4879
+ if (config.scan?.enabled) {
4880
+ const scanFindings = scanRepoForVersions(version, config.scan, config.ignore, cwd);
4881
+ if (scanFindings.length > 0) {
4882
+ scanValid = false;
4883
+ for (const finding of scanFindings) errors.push(`Stale version in ${finding.file}:${finding.line} - found "${finding.found}" but expected "${version}"`);
4884
+ }
4885
+ }
4886
+ let guardValid = true;
4887
+ let guardReport;
4888
+ if (config.guard?.enabled) {
4889
+ guardReport = runGuardChecks(config, cwd);
4890
+ if (!guardReport.safe) {
4891
+ guardValid = false;
4892
+ for (const warning of guardReport.warnings) if (warning.severity === "error") errors.push(`[${warning.code}] ${warning.message}`);
4893
+ }
4894
+ }
4895
+ const publishValid = true;
4896
+ let publishCheck;
4897
+ if (config.publish?.enabled) {
4898
+ const manifestSource = config.manifest.source !== "auto" ? config.manifest.source : detectManifests(cwd)[0] ?? null;
4899
+ if (manifestSource) {
4900
+ const packageName = readPackageName(manifestSource, cwd);
4901
+ if (packageName) {
4902
+ publishCheck = await checkPublishStatus(manifestSource, packageName, version, config.publish);
4903
+ if (publishCheck.error) errors.push(`Publish check warning: ${publishCheck.error}`);
4904
+ }
4905
+ }
4906
+ }
4606
4907
  return {
4607
4908
  valid: errors.length === 0,
4608
4909
  version,
4609
4910
  versionValid: versionResult.valid,
4610
4911
  syncValid: hardcoded.length === 0,
4611
4912
  changelogValid,
4913
+ scanValid,
4914
+ guardValid,
4915
+ publishValid,
4916
+ publishCheck,
4917
+ guardReport,
4612
4918
  errors
4613
4919
  };
4614
4920
  }
@@ -4631,8 +4937,8 @@ function validate(config, cwd = process.cwd()) {
4631
4937
  * const report = doctor(getDefaultConfig(), process.cwd());
4632
4938
  * ```
4633
4939
  */
4634
- function doctor(config, cwd = process.cwd()) {
4635
- const validation = validate(config, cwd);
4940
+ async function doctor(config, cwd = process.cwd()) {
4941
+ const validation = await validate(config, cwd);
4636
4942
  const gitRepository = findGitDir(cwd) !== null;
4637
4943
  const hooksInstalled = gitRepository ? areHooksInstalled(cwd) : false;
4638
4944
  const worktreeClean = gitRepository ? isWorktreeClean(cwd) : true;
@@ -4646,6 +4952,9 @@ function doctor(config, cwd = process.cwd()) {
4646
4952
  versionValid: validation.versionValid,
4647
4953
  syncValid: validation.syncValid,
4648
4954
  changelogValid: validation.changelogValid,
4955
+ scanValid: validation.scanValid,
4956
+ guardValid: validation.guardValid,
4957
+ publishValid: validation.publishValid,
4649
4958
  gitRepository,
4650
4959
  hooksInstalled,
4651
4960
  worktreeClean,
@@ -4736,6 +5045,6 @@ function isWorktreeClean(cwd) {
4736
5045
  }
4737
5046
  }
4738
5047
  //#endregion
4739
- export { generateDependabotConfig as $, createCkmEngine as A, detectManifests as B, suggestNextVersion as C, getVersionFeedback as D, getTagFeedback as E, syncVersion as F, RegexVersionSource as G, YamlVersionSource as H, semver_exports as I, areHooksInstalled as J, JsonVersionSource as K, getPackageVersion as L, getSemVerConfig as M, checkHardcodedVersions as N, getConfig as O, scanRepoForVersions as P, dependabotConfigExists as Q, getVersionSource as R, fixSyncIssues as S, getSyncFeedback as T, VersionFileSource as U, resolveVersionSource as V, TomlVersionSource as W, uninstallHooks as X, installHooks as Y, MANIFEST_TO_ECOSYSTEM as Z, checkHuskyBypass as _, validateVersion as a, isValidCalVerFormat as at, fixChangelog as b, getLatestTag as c, validateTagForPush as d, writeDependabotConfig as et, findProjectRoot as f, checkHooksPathOverride as g, checkHookIntegrity as h, validate as i, calver_exports as it, getCalVerConfig as j, initConfig as k, handlePostTag as l, checkEnforceHooksPolicy as m, doctor as n, isChangesetMangled as nt, createTag as o, formatNotProjectError as p, GitTagSource as q, sync as r, validateChangelog as rt, getAllTags as s, canBump as t, fixChangesetMangling as tt, suggestTagMessage as u, runGuardChecks as v, getChangelogFeedback as w, fixPackageVersion as x, fixAll as y, setPackageVersion as z };
5048
+ export { uninstallHooks as $, syncVersion as A, YamlVersionSource as B, getConfig as C, getSemVerConfig as D, getCalVerConfig as E, getPackageVersion as F, GitTagSource as G, TomlVersionSource as H, getVersionSource as I, checkHooksPathOverride as J, checkEnforceHooksPolicy as K, setPackageVersion as L, REGISTRY_TABLE as M, checkPublishStatus as N, checkHardcodedVersions as O, readPackageName as P, installHooks as Q, detectManifests as R, getVersionFeedback as S, createCkmEngine as T, RegexVersionSource as U, VersionFileSource as V, JsonVersionSource as W, runGuardChecks as X, checkHuskyBypass as Y, areHooksInstalled as Z, fixSyncIssues as _, validateVersion as a, isChangesetMangled as at, getSyncFeedback as b, getLatestTag as c, isValidCalVerFormat as ct, validateTagForPush as d, MANIFEST_TO_ECOSYSTEM as et, findProjectRoot as f, fixPackageVersion as g, fixChangelog as h, validate as i, fixChangesetMangling as it, semver_exports as j, scanRepoForVersions as k, handlePostTag as l, fixAll as m, doctor as n, generateDependabotConfig as nt, createTag as o, validateChangelog as ot, formatNotProjectError as p, checkHookIntegrity as q, sync as r, writeDependabotConfig as rt, getAllTags as s, calver_exports as st, canBump as t, dependabotConfigExists as tt, suggestTagMessage as u, suggestNextVersion as v, initConfig as w, getTagFeedback as x, getChangelogFeedback as y, resolveVersionSource as z };
4740
5049
 
4741
- //# sourceMappingURL=src-BPMDUQfR.js.map
5050
+ //# sourceMappingURL=src-Bofo3tVH.js.map