@williamthorsen/nmr 0.19.0 → 0.21.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.
Files changed (44) hide show
  1. package/AGENTS.md +5 -96
  2. package/README.md +303 -99
  3. package/bin/nmr-fmt.js +11 -0
  4. package/dist/esm/cli-ensure-prepublish-hooks.js +1 -1
  5. package/dist/esm/cli-fmt.js +3 -0
  6. package/dist/esm/cli-report-overrides.js +1 -1
  7. package/dist/esm/cli-sync-agent-files.js +1 -1
  8. package/dist/esm/commands/clean.js +4 -3
  9. package/dist/esm/commands/ensure-prepublish-hooks.js +1 -1
  10. package/dist/esm/commands/fmt.d.ts +23 -0
  11. package/dist/esm/commands/fmt.js +172 -0
  12. package/dist/esm/context.d.ts +0 -2
  13. package/dist/esm/context.js +7 -60
  14. package/dist/esm/default-scripts.d.ts +1 -3
  15. package/dist/esm/default-scripts.js +17 -21
  16. package/dist/esm/help.js +3 -2
  17. package/dist/esm/helpers/workspace-patterns.d.ts +1 -0
  18. package/dist/esm/helpers/workspace-patterns.js +29 -0
  19. package/dist/esm/index.d.ts +0 -1
  20. package/dist/esm/resolve-scripts.d.ts +1 -1
  21. package/dist/esm/resolve-scripts.js +3 -6
  22. package/dist/esm/resolver.d.ts +1 -2
  23. package/dist/esm/resolver.js +3 -6
  24. package/dist/esm/runCli.js +17 -19
  25. package/dist/esm/vitest.d.ts +15 -0
  26. package/dist/esm/vitest.js +62 -0
  27. package/dist/esm/workspace.d.ts +2 -0
  28. package/dist/esm/workspace.js +38 -0
  29. package/package.json +27 -12
  30. package/bin/nmr-sync-pnpm-version.js +0 -11
  31. package/dist/esm/cli-sync-pnpm-version.js +0 -11
  32. package/dist/esm/commands/sync-pnpm-version.d.ts +0 -3
  33. package/dist/esm/commands/sync-pnpm-version.js +0 -40
  34. package/dist/esm/helpers/code-quality-pnpm-action.d.ts +0 -13
  35. package/dist/esm/helpers/code-quality-pnpm-action.js +0 -22
  36. package/dist/esm/tests/consistency.d.ts +0 -7
  37. package/dist/esm/tests/consistency.js +0 -57
  38. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.d.ts +0 -1
  39. package/dist/esm/tests/helpers/get-runtime-version-from-asdf.js +0 -12
  40. package/dist/esm/tests/helpers/get-string-from-yaml-file.d.ts +0 -1
  41. package/dist/esm/tests/helpers/get-string-from-yaml-file.js +0 -12
  42. package/dist/esm/tests/helpers/get-value-at-path.d.ts +0 -1
  43. package/dist/esm/tests/helpers/get-value-at-path.js +0 -34
  44. /package/dist/esm/{cli-sync-pnpm-version.d.ts → cli-fmt.d.ts} +0 -0
@@ -0,0 +1,62 @@
1
+ import path from 'node:path';
2
+ import { defaultExclude, mergeConfig } from 'vitest/config';
3
+ import { findMonorepoRoot, getWorkspacePackageDirs } from "./workspace.js";
4
+ const APP_PATTERNS = ['**/__tests__/**/*.app.test.{ts,tsx}'];
5
+ const INTEGRATION_PATTERNS = ['**/__tests__/**/*.int.test.{ts,tsx}'];
6
+ const ALL_TEST_PATTERNS = ['**/__tests__/**/*.test.{ts,tsx}'];
7
+ const COVERAGE_EXCLUDE = [
8
+ '**/__{mocks,tests}__/*',
9
+ '**/index.ts',
10
+ '**/mock*.{ts,tsx}',
11
+ '**/*.d.ts',
12
+ '**/*.types.ts',
13
+ ];
14
+ const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
15
+ export function defineVitestConfig(options = {}) {
16
+ return buildConfig(options, { coverageInclude: PACKAGE_COVERAGE_INCLUDE });
17
+ }
18
+ export function defineRootVitestConfig(options = {}) {
19
+ const monorepoRoot = findMonorepoRoot(options.startDir);
20
+ return buildConfig(options, {
21
+ coverageInclude: [],
22
+ projectExclude: getWorkspaceExcludePatterns(monorepoRoot),
23
+ projectRoot: monorepoRoot,
24
+ });
25
+ }
26
+ function buildConfig(options, { coverageInclude, projectExclude = [], projectRoot }) {
27
+ const config = {
28
+ test: {
29
+ coverage: {
30
+ enabled: false,
31
+ exclude: COVERAGE_EXCLUDE,
32
+ include: coverageInclude,
33
+ provider: 'v8',
34
+ },
35
+ passWithNoTests: true,
36
+ projects: buildProjects(options.project, projectExclude, projectRoot),
37
+ silent: 'passed-only',
38
+ watch: false,
39
+ },
40
+ };
41
+ return options.root ? mergeConfig(config, options.root) : config;
42
+ }
43
+ function buildProjects(overrides, extraExclude, projectRoot) {
44
+ const categories = [
45
+ { exclude: [], include: APP_PATTERNS, name: 'app' },
46
+ { exclude: [], include: INTEGRATION_PATTERNS, name: 'integration' },
47
+ { exclude: [...APP_PATTERNS, ...INTEGRATION_PATTERNS], include: ALL_TEST_PATTERNS, name: 'unit' },
48
+ ];
49
+ return categories.map(({ exclude, include, name }) => {
50
+ const project = {
51
+ extends: true,
52
+ ...(projectRoot !== undefined && { root: projectRoot }),
53
+ test: { exclude: [...defaultExclude, ...exclude, ...extraExclude], include, name },
54
+ };
55
+ return overrides ? mergeConfig(project, { test: overrides }) : project;
56
+ });
57
+ }
58
+ function getWorkspaceExcludePatterns(monorepoRoot) {
59
+ return getWorkspacePackageDirs(monorepoRoot)
60
+ .map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
61
+ .toSorted();
62
+ }
@@ -0,0 +1,2 @@
1
+ export declare function findMonorepoRoot(startDir?: string): string;
2
+ export declare function getWorkspacePackageDirs(monorepoRoot: string): string[];
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+ import { isObject } from "./helpers/type-guards.js";
5
+ import { resolvePackageDirs } from "./helpers/workspace-patterns.js";
6
+ export function findMonorepoRoot(startDir) {
7
+ let dir = path.resolve(startDir ?? process.cwd());
8
+ for (;;) {
9
+ if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) {
10
+ return dir;
11
+ }
12
+ const parent = path.dirname(dir);
13
+ if (parent === dir) {
14
+ throw new Error('Could not find monorepo root: no pnpm-workspace.yaml found in any parent directory');
15
+ }
16
+ dir = parent;
17
+ }
18
+ }
19
+ export function getWorkspacePackageDirs(monorepoRoot) {
20
+ const workspaceFile = path.join(monorepoRoot, 'pnpm-workspace.yaml');
21
+ const content = readFileSync(workspaceFile, 'utf8');
22
+ const parsed = parse(content);
23
+ const packages = getPackagesFromParsedYaml(parsed);
24
+ if (!packages) {
25
+ return [];
26
+ }
27
+ return resolvePackageDirs(monorepoRoot, packages);
28
+ }
29
+ function getPackagesFromParsedYaml(parsed) {
30
+ if (!isObject(parsed))
31
+ return undefined;
32
+ const packages = parsed.packages;
33
+ if (!Array.isArray(packages))
34
+ return undefined;
35
+ if (!packages.every((p) => typeof p === 'string'))
36
+ return undefined;
37
+ return packages;
38
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "private": false,
5
- "description": "Context-aware script runner for PNPM monorepos",
5
+ "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
7
7
  "monorepo",
8
8
  "pnpm",
@@ -34,9 +34,13 @@
34
34
  "types": "./dist/esm/taze.d.ts",
35
35
  "default": "./dist/esm/taze.js"
36
36
  },
37
- "./tests": {
38
- "types": "./dist/esm/tests/consistency.d.ts",
39
- "default": "./dist/esm/tests/consistency.js"
37
+ "./vitest": {
38
+ "types": "./dist/esm/vitest.d.ts",
39
+ "default": "./dist/esm/vitest.js"
40
+ },
41
+ "./workspace": {
42
+ "types": "./dist/esm/workspace.d.ts",
43
+ "default": "./dist/esm/workspace.js"
40
44
  }
41
45
  },
42
46
  "bin": {
@@ -44,9 +48,9 @@
44
48
  "nmr": "bin/nmr.js",
45
49
  "nmr-clean": "bin/nmr-clean.js",
46
50
  "nmr-compile": "bin/nmr-compile.js",
51
+ "nmr-fmt": "bin/nmr-fmt.js",
47
52
  "nmr-report-overrides": "bin/nmr-report-overrides.js",
48
53
  "nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
49
- "nmr-sync-pnpm-version": "bin/nmr-sync-pnpm-version.js",
50
54
  "nmr-taze": "bin/nmr-taze.js"
51
55
  },
52
56
  "files": [
@@ -57,19 +61,30 @@
57
61
  "dependencies": {
58
62
  "glob": "13.0.6",
59
63
  "jiti": "2.7.0",
60
- "taze": "19.15.1",
64
+ "taze": "19.16.0",
61
65
  "yaml": "2.9.0",
62
- "zod": "4.4.3",
63
- "@williamthorsen/nmr-core": "0.7.1"
66
+ "@williamthorsen/nmr-core": "0.7.3"
64
67
  },
65
68
  "devDependencies": {
66
- "@williamthorsen/toolbelt.strings": "3.1.6"
69
+ "prettier": "3.9.6",
70
+ "tinyglobby": "0.2.17",
71
+ "vitest": "4.1.10"
67
72
  },
68
73
  "peerDependencies": {
69
- "typescript": ">=5.7.0 <7"
74
+ "prettier": ">=3.9.5 <4",
75
+ "typescript": ">=5.7.0 <7",
76
+ "vitest": ">=4.0.0 <5"
77
+ },
78
+ "peerDependenciesMeta": {
79
+ "prettier": {
80
+ "optional": true
81
+ },
82
+ "vitest": {
83
+ "optional": true
84
+ }
70
85
  },
71
86
  "engines": {
72
- "node": ">=24"
87
+ "node": ">=24.16.0"
73
88
  },
74
89
  "publishConfig": {
75
90
  "access": "public"
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env node
2
- try {
3
- await import('../dist/esm/cli-sync-pnpm-version.js');
4
- } catch (error) {
5
- if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
- process.stderr.write('nmr-sync-pnpm-version: build output not found — run `pnpm run build` first\n');
7
- } else {
8
- process.stderr.write(`nmr-sync-pnpm-version: failed to load: ${error.message}\n`);
9
- }
10
- process.exit(1);
11
- }
@@ -1,11 +0,0 @@
1
- import { reportError } from '@williamthorsen/nmr-core';
2
- import { syncPnpmVersion } from "./commands/sync-pnpm-version.js";
3
- import { findMonorepoRoot } from "./context.js";
4
- try {
5
- const monorepoRoot = findMonorepoRoot();
6
- syncPnpmVersion(monorepoRoot);
7
- }
8
- catch (error) {
9
- reportError(error instanceof Error ? error.message : String(error));
10
- process.exitCode = 1;
11
- }
@@ -1,3 +0,0 @@
1
- declare function extractPnpmVersion(packageManager: string | undefined): string | null;
2
- export declare function syncPnpmVersion(monorepoRoot: string): void;
3
- export { extractPnpmVersion };
@@ -1,40 +0,0 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { parseDocument } from 'yaml';
4
- import { CodeQualityPnpmWorkflowSchema, getPnpmVersionNodes } from "../helpers/code-quality-pnpm-action.js";
5
- import { readPackageJson } from "../helpers/package-json.js";
6
- const WORKFLOW_RELATIVE_PATH = '.github/workflows/code-quality.yaml';
7
- function extractPnpmVersion(packageManager) {
8
- if (!packageManager) {
9
- return null;
10
- }
11
- const match = /^pnpm@(.+)$/.exec(packageManager);
12
- return match?.[1] ?? null;
13
- }
14
- export function syncPnpmVersion(monorepoRoot) {
15
- console.info('Synchronizing pnpm version in GitHub workflow...');
16
- const pkg = readPackageJson(monorepoRoot);
17
- const pnpmVersion = extractPnpmVersion(pkg.packageManager);
18
- if (!pnpmVersion) {
19
- throw new Error(`Could not extract pnpm version from package.json packageManager field\npackageManager field: ${pkg.packageManager ?? '(not set)'}`);
20
- }
21
- console.info(`Package.json pnpm version: ${pnpmVersion}`);
22
- const workflowPath = path.join(monorepoRoot, WORKFLOW_RELATIVE_PATH);
23
- const doc = parseDocument(readFileSync(workflowPath, 'utf8'));
24
- const [parseError] = doc.errors;
25
- if (parseError) {
26
- throw new Error(`Failed to parse workflow file: ${workflowPath}\n${parseError.message}`);
27
- }
28
- CodeQualityPnpmWorkflowSchema.parse(doc.toJS());
29
- const outdatedNodes = getPnpmVersionNodes(doc).filter((node) => String(node.value) !== pnpmVersion);
30
- if (outdatedNodes.length === 0) {
31
- console.info('Workflow pnpm version is already up to date');
32
- return;
33
- }
34
- for (const node of outdatedNodes) {
35
- node.value = pnpmVersion;
36
- }
37
- writeFileSync(workflowPath, doc.toString(), 'utf8');
38
- console.info(`✓ Updated ${outdatedNodes.length} pnpm-version occurrence(s) → ${pnpmVersion}`);
39
- }
40
- export { extractPnpmVersion };
@@ -1,13 +0,0 @@
1
- import { type Document, type Scalar } from 'yaml';
2
- import { z } from 'zod';
3
- export declare const CodeQualityPnpmWorkflowSchema: z.ZodObject<{
4
- jobs: z.ZodObject<{
5
- 'code-quality': z.ZodObject<{
6
- with: z.ZodObject<{
7
- 'pnpm-version': z.ZodString;
8
- }, z.core.$loose>;
9
- }, z.core.$loose>;
10
- }, z.core.$loose>;
11
- }, z.core.$loose>;
12
- export type CodeQualityPnpmWorkflow = z.infer<typeof CodeQualityPnpmWorkflowSchema>;
13
- export declare function getPnpmVersionNodes(doc: Document): Scalar[];
@@ -1,22 +0,0 @@
1
- import { isScalar, visit } from 'yaml';
2
- import { z } from 'zod';
3
- export const CodeQualityPnpmWorkflowSchema = z.looseObject({
4
- jobs: z.looseObject({
5
- 'code-quality': z.looseObject({
6
- with: z.looseObject({
7
- 'pnpm-version': z.string(),
8
- }),
9
- }),
10
- }),
11
- });
12
- export function getPnpmVersionNodes(doc) {
13
- const nodes = [];
14
- visit(doc, {
15
- Pair(_, pair) {
16
- if (isScalar(pair.key) && pair.key.value === 'pnpm-version' && isScalar(pair.value)) {
17
- nodes.push(pair.value);
18
- }
19
- },
20
- });
21
- return nodes;
22
- }
@@ -1,7 +0,0 @@
1
- export { findMonorepoRoot } from '../context.js';
2
- export declare function checkPnpmVersionConsistency(monorepoRoot: string): void;
3
- export declare function checkNodeVersionConsistency(monorepoRoot: string): void;
4
- export declare function getPnpmVersionFromAction(monorepoRoot: string): Promise<string>;
5
- export declare function getPnpmVersionFromPackageJson(monorepoRoot: string): string;
6
- export declare function getNodeVersionFromAction(monorepoRoot: string): Promise<string>;
7
- export declare function runConsistencyChecks(): void;
@@ -1,57 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { describe, expect, it } from 'vitest';
4
- import { findMonorepoRoot } from "../context.js";
5
- export { findMonorepoRoot } from "../context.js";
6
- import { getRuntimeVersionFromAsdf } from "./helpers/get-runtime-version-from-asdf.js";
7
- import { getStringFromYamlFile } from "./helpers/get-string-from-yaml-file.js";
8
- import { getValueAtPathOrThrow } from "./helpers/get-value-at-path.js";
9
- const GITHUB_ACTION_FILE_PATH = '.github/workflows/code-quality.yaml';
10
- export function checkPnpmVersionConsistency(monorepoRoot) {
11
- describe('pnpm version consistency', () => {
12
- it('pnpm version is the same in GitHub action and package.json', async () => {
13
- const actionVersion = await getPnpmVersionFromAction(monorepoRoot);
14
- const packageJsonVersion = getPnpmVersionFromPackageJson(monorepoRoot);
15
- expect(actionVersion).toBe(packageJsonVersion);
16
- });
17
- });
18
- }
19
- export function checkNodeVersionConsistency(monorepoRoot) {
20
- describe('Node.js version consistency', () => {
21
- it('version is the same in GitHub action and .tool-versions', async () => {
22
- const toolVersion = await getRuntimeVersionFromAsdf('nodejs', monorepoRoot);
23
- const actionVersion = await getNodeVersionFromAction(monorepoRoot);
24
- expect(toolVersion).toBe(actionVersion);
25
- });
26
- });
27
- }
28
- export async function getPnpmVersionFromAction(monorepoRoot) {
29
- const actionPath = path.join(monorepoRoot, GITHUB_ACTION_FILE_PATH);
30
- return getStringFromYamlFile(actionPath, 'jobs.code-quality.with.pnpm-version', 'pnpm version');
31
- }
32
- export function getPnpmVersionFromPackageJson(monorepoRoot) {
33
- const pkgPath = path.join(monorepoRoot, 'package.json');
34
- const raw = fs.readFileSync(pkgPath, 'utf8');
35
- const pkg = JSON.parse(raw);
36
- const pm = getValueAtPathOrThrow(pkg, 'packageManager');
37
- if (typeof pm !== 'string') {
38
- throw new TypeError('"packageManager" field missing or not a string in package.json.');
39
- }
40
- const [name, version] = pm.split('@', 2);
41
- if (name !== 'pnpm') {
42
- throw new Error('packageManager is not pnpm.');
43
- }
44
- if (!version) {
45
- throw new Error('pnpm version missing in package.json.');
46
- }
47
- return version;
48
- }
49
- export async function getNodeVersionFromAction(monorepoRoot) {
50
- const actionPath = path.join(monorepoRoot, GITHUB_ACTION_FILE_PATH);
51
- return getStringFromYamlFile(actionPath, 'jobs.code-quality.with.node-version', 'Node.js version');
52
- }
53
- export function runConsistencyChecks() {
54
- const monorepoRoot = findMonorepoRoot();
55
- checkPnpmVersionConsistency(monorepoRoot);
56
- checkNodeVersionConsistency(monorepoRoot);
57
- }
@@ -1 +0,0 @@
1
- export declare function getRuntimeVersionFromAsdf(runtime: string, monorepoRoot: string): Promise<string>;
@@ -1,12 +0,0 @@
1
- import assert from 'node:assert';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- export async function getRuntimeVersionFromAsdf(runtime, monorepoRoot) {
5
- const toolVersionsPath = path.join(monorepoRoot, '.tool-versions');
6
- const toolVersions = await fs.promises.readFile(toolVersionsPath, { encoding: 'utf8' });
7
- const versionLine = toolVersions.split('\n').find((line) => line.trim().startsWith(runtime));
8
- assert.ok(versionLine, `${runtime} not found in .tool-versions`);
9
- const [, version] = versionLine.trim().split(/\s+/, 2);
10
- assert.ok(version, `${runtime} version missing in .tool-versions`);
11
- return version;
12
- }
@@ -1 +0,0 @@
1
- export declare function getStringFromYamlFile(filePath: string, keyPath: string, label: string): Promise<string>;
@@ -1,12 +0,0 @@
1
- import assert from 'node:assert';
2
- import fs from 'node:fs';
3
- import { parse } from 'yaml';
4
- import { getValueAtPathOrThrow } from "./get-value-at-path.js";
5
- export async function getStringFromYamlFile(filePath, keyPath, label) {
6
- const raw = await fs.promises.readFile(filePath, { encoding: 'utf8' });
7
- const parsed = parse(raw);
8
- assert.ok(parsed, `YAML content not found in ${filePath}`);
9
- const value = getValueAtPathOrThrow(parsed, keyPath);
10
- assert.ok(typeof value === 'string' && value.length > 0, `${label} not found at ${keyPath}`);
11
- return value;
12
- }
@@ -1 +0,0 @@
1
- export declare function getValueAtPathOrThrow(obj: unknown, objPath: string): unknown;
@@ -1,34 +0,0 @@
1
- function isObject(value) {
2
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3
- }
4
- export function getValueAtPathOrThrow(obj, objPath) {
5
- if (!isObject(obj)) {
6
- throw new Error('Expected an object as root value.');
7
- }
8
- const keys = objPath.split('.');
9
- let current = obj;
10
- for (const key of keys) {
11
- if (Array.isArray(current)) {
12
- const index = Number(key);
13
- if (Number.isNaN(index)) {
14
- throw new TypeError(`Expected array index at segment "${key}" in path "${objPath}"`);
15
- }
16
- if (index >= 0 && index < current.length) {
17
- current = current[index];
18
- }
19
- else {
20
- throw new Error(`Array index out of bounds: "${key}" in path "${objPath}"`);
21
- }
22
- }
23
- else if (isObject(current)) {
24
- if (!(key in current)) {
25
- throw new Error(`Missing key "${key}" in path "${objPath}"`);
26
- }
27
- current = current[key];
28
- }
29
- else {
30
- throw new Error(`Unexpected non-object/non-array at segment "${key}" in path "${objPath}"`);
31
- }
32
- }
33
- return current;
34
- }