@williamthorsen/nmr 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/.readyup/kits/default.js +467 -0
- package/.readyup/manifest.json +16 -0
- package/README.md +64 -69
- package/agents/README.md +24 -0
- package/{AGENTS.md → agents/guidance/rulebooks/nmr.md} +4 -2
- package/dist/esm/check-cache.js +4 -17
- package/dist/esm/default-scripts.js +1 -3
- package/dist/esm/helpers/duration.d.ts +2 -0
- package/dist/esm/helpers/duration.js +21 -0
- package/dist/esm/tiers.d.ts +7 -0
- package/dist/esm/tiers.js +35 -0
- package/dist/esm/vitest.d.ts +2 -3
- package/dist/esm/vitest.js +3 -7
- package/package.json +11 -5
- package/bin/nmr-sync-agent-files.js +0 -11
- package/dist/esm/cli-sync-agent-files.d.ts +0 -1
- package/dist/esm/cli-sync-agent-files.js +0 -29
- package/dist/esm/commands/sync-agent-files.d.ts +0 -13
- package/dist/esm/commands/sync-agent-files.js +0 -58
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/** @noformat — @generated. Do not edit. Compiled by rdy. */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
export const __readyupVersion = "0.23.0";
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
// .readyup/kits/default.ts
|
|
7
|
+
import { existsSync, globSync, readdirSync as readdirSync2, readFileSync } from "node:fs";
|
|
8
|
+
import { basename, join, sep } from "node:path";
|
|
9
|
+
import { defineRdyKit } from "readyup";
|
|
10
|
+
import {
|
|
11
|
+
fileContains,
|
|
12
|
+
fileExists,
|
|
13
|
+
hasDevDependency,
|
|
14
|
+
hasMinDevDependencyVersion,
|
|
15
|
+
hasPackageJsonField,
|
|
16
|
+
isRecord,
|
|
17
|
+
readFile,
|
|
18
|
+
readPackageJson
|
|
19
|
+
} from "readyup/check-utils";
|
|
20
|
+
|
|
21
|
+
// src/default-scripts.ts
|
|
22
|
+
var GATE_PROJECTS = "--project unit --project tool";
|
|
23
|
+
var workspaceScripts = {
|
|
24
|
+
build: ["compile"],
|
|
25
|
+
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
26
|
+
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
|
|
27
|
+
clean: "nmr-clean",
|
|
28
|
+
compile: "nmr-compile",
|
|
29
|
+
fix: ["lint", "fmt"],
|
|
30
|
+
"fix:check": ["fmt:check", "lint:check"],
|
|
31
|
+
fmt: "nmr-fmt --write",
|
|
32
|
+
"fmt:check": "nmr-fmt --check",
|
|
33
|
+
lint: "eslint --fix .",
|
|
34
|
+
"lint:check": "eslint .",
|
|
35
|
+
"lint:strict": "strict-lint",
|
|
36
|
+
test: `pnpm exec vitest ${GATE_PROJECTS}`,
|
|
37
|
+
"test:all": "pnpm exec vitest",
|
|
38
|
+
"test:coverage": `pnpm exec vitest ${GATE_PROJECTS} --coverage`,
|
|
39
|
+
"test:tool": "pnpm exec vitest --project tool",
|
|
40
|
+
"test:unit": "pnpm exec vitest --project unit",
|
|
41
|
+
"test:watch": `pnpm exec vitest ${GATE_PROJECTS} --watch`,
|
|
42
|
+
typecheck: "tsgo --noEmit",
|
|
43
|
+
// Without `--include-locked`, nothing would be reported in a repo that pins exact version numbers.
|
|
44
|
+
upgrade: "nmr-taze --include-locked",
|
|
45
|
+
"view-coverage": "open coverage/index.html"
|
|
46
|
+
};
|
|
47
|
+
var rootScripts = {
|
|
48
|
+
audit: ["audit:prod", "audit:dev"],
|
|
49
|
+
"audit:dev": "pnpm exec v11y --dev",
|
|
50
|
+
"audit:prod": "pnpm exec v11y --prod",
|
|
51
|
+
build: "pnpm --recursive exec nmr build",
|
|
52
|
+
check: ["typecheck", "fmt:check", "lint:check", "test"],
|
|
53
|
+
"check:strict": ["typecheck", "fmt:check", "lint:strict", "test:coverage"],
|
|
54
|
+
// Excludes the audit, which in CI has a workflow of its own.
|
|
55
|
+
ci: ["build", "check:strict"],
|
|
56
|
+
clean: "nmr-clean",
|
|
57
|
+
fix: ["lint", "fmt"],
|
|
58
|
+
"fix:check": ["fmt:check", "lint:check"],
|
|
59
|
+
fmt: "nmr-fmt --write",
|
|
60
|
+
"fmt:check": "nmr-fmt --check",
|
|
61
|
+
lint: "nmr root:lint && pnpm --recursive exec nmr lint",
|
|
62
|
+
"lint:check": "nmr root:lint:check && pnpm --recursive exec nmr lint:check",
|
|
63
|
+
"lint:strict": "nmr root:lint:strict && pnpm --recursive exec nmr lint:strict",
|
|
64
|
+
prepush: ["ci", "audit"],
|
|
65
|
+
"report-overrides": "nmr-report-overrides",
|
|
66
|
+
"root:check": ["root:typecheck", "fmt:check", "root:lint:check", "root:test"],
|
|
67
|
+
"root:lint": "eslint --fix --ignore-pattern 'packages/**' .",
|
|
68
|
+
"root:lint:check": "eslint --ignore-pattern 'packages/**' .",
|
|
69
|
+
"root:lint:strict": "strict-lint --ignore-pattern 'packages/**' .",
|
|
70
|
+
"root:test": `vitest --config ./vitest.root.config.ts ${GATE_PROJECTS}`,
|
|
71
|
+
"root:test:all": "vitest --config ./vitest.root.config.ts",
|
|
72
|
+
"root:test:tool": "vitest --config ./vitest.root.config.ts --project tool",
|
|
73
|
+
"root:test:unit": "vitest --config ./vitest.root.config.ts --project unit",
|
|
74
|
+
"root:typecheck": "tsgo --noEmit",
|
|
75
|
+
"root:upgrade": "nmr-taze --include-locked",
|
|
76
|
+
test: "nmr root:test && pnpm --recursive exec nmr test",
|
|
77
|
+
"test:all": "nmr root:test:all && pnpm --recursive exec nmr test:all",
|
|
78
|
+
"test:coverage": "nmr root:test && pnpm --recursive exec nmr test:coverage",
|
|
79
|
+
"test:tool": "nmr root:test:tool && pnpm --recursive exec nmr test:tool",
|
|
80
|
+
"test:unit": "nmr root:test:unit && pnpm --recursive exec nmr test:unit",
|
|
81
|
+
// Bare `vitest` at the monorepo root resolves the root `vitest.config.ts`, covering the whole tree in one
|
|
82
|
+
// process; a chain like the others would never advance past its first watcher.
|
|
83
|
+
"test:watch": `vitest ${GATE_PROJECTS} --watch`,
|
|
84
|
+
typecheck: "nmr root:typecheck && pnpm --recursive exec nmr typecheck",
|
|
85
|
+
// A string rather than a composite: Passthrough args attach to the chain's last command.
|
|
86
|
+
upgrade: "nmr-report-overrides && nmr-taze --include-locked --recursive"
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/resolve-scripts.ts
|
|
90
|
+
function getDefaultRootScripts() {
|
|
91
|
+
return { ...rootScripts };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/tiers.ts
|
|
95
|
+
import { readdirSync } from "node:fs";
|
|
96
|
+
import path from "node:path";
|
|
97
|
+
var TEST_DIR = "__tests__";
|
|
98
|
+
var TEST_EXTENSIONS = "{ts,tsx}";
|
|
99
|
+
var TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
|
|
100
|
+
var TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
101
|
+
var ALL_TEST_PATTERNS = [`${TEST_GLOB_PREFIX}/*.test.${TEST_EXTENSIONS}`];
|
|
102
|
+
function findTestFiles(rootDir) {
|
|
103
|
+
const found = [];
|
|
104
|
+
collectTestFiles(rootDir, "", false, found);
|
|
105
|
+
return found.toSorted();
|
|
106
|
+
}
|
|
107
|
+
function hasTierInfix(filePath) {
|
|
108
|
+
const tiers = TIER_NAMES;
|
|
109
|
+
return tiers.includes(path.basename(filePath).split(".").at(-3) ?? "");
|
|
110
|
+
}
|
|
111
|
+
var TEST_COLLECTION_EXCLUDE = [".git", "coverage", "dist", "node_modules"];
|
|
112
|
+
var TIER_NAMES = ["unit", "tool", "localhost", "remote"];
|
|
113
|
+
function collectTestFiles(dir, relativeDir, inTestDir, found) {
|
|
114
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const relativePath = relativeDir === "" ? entry.name : `${relativeDir}/${entry.name}`;
|
|
117
|
+
if (entry.isDirectory()) {
|
|
118
|
+
if (TEST_COLLECTION_EXCLUDE.includes(entry.name)) continue;
|
|
119
|
+
collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, found);
|
|
120
|
+
} else if (inTestDir && TEST_FILE_PATTERN.test(entry.name)) {
|
|
121
|
+
found.push(relativePath);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// .readyup/kits/default.ts
|
|
127
|
+
var default_default = defineRdyKit({
|
|
128
|
+
checklists: [
|
|
129
|
+
{
|
|
130
|
+
name: "nmr",
|
|
131
|
+
checks: [
|
|
132
|
+
// -- Setup ---------------------------------------------------------------
|
|
133
|
+
{
|
|
134
|
+
name: "@williamthorsen/nmr in devDependencies",
|
|
135
|
+
severity: "error",
|
|
136
|
+
check: () => hasDevDependency("@williamthorsen/nmr"),
|
|
137
|
+
fix: "pnpm add --save-dev @williamthorsen/nmr",
|
|
138
|
+
checks: [
|
|
139
|
+
{
|
|
140
|
+
get name() {
|
|
141
|
+
return `@williamthorsen/nmr >= ${getMinVersion()}`;
|
|
142
|
+
},
|
|
143
|
+
severity: "error",
|
|
144
|
+
check: () => hasMinDevDependencyVersion("@williamthorsen/nmr", getMinVersion(), {
|
|
145
|
+
exempt: (range) => range.startsWith("workspace:")
|
|
146
|
+
}),
|
|
147
|
+
get fix() {
|
|
148
|
+
return `pnpm add --save-dev @williamthorsen/nmr@^${getMinVersion()}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "pnpm-workspace.yaml exists",
|
|
155
|
+
severity: "error",
|
|
156
|
+
check: () => fileExists("pnpm-workspace.yaml"),
|
|
157
|
+
fix: "Create pnpm-workspace.yaml with workspace package globs"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "package.json has packageManager field",
|
|
161
|
+
severity: "warn",
|
|
162
|
+
check: () => hasPackageJsonField("packageManager"),
|
|
163
|
+
fix: 'Add "packageManager" field to package.json (e.g., "pnpm@10.33.0")'
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: ".tool-versions does not list pnpm",
|
|
167
|
+
severity: "warn",
|
|
168
|
+
check: toolVersionsHasNoPnpm,
|
|
169
|
+
fix: "Remove pnpm from .tool-versions \u2014 manage via packageManager field and corepack"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: ".config/nmr.config.ts uses defineConfig",
|
|
173
|
+
severity: "recommend",
|
|
174
|
+
skip: () => !fileExists(".config/nmr.config.ts") ? "no nmr config file" : false,
|
|
175
|
+
check: () => fileContains(".config/nmr.config.ts", /defineConfig/),
|
|
176
|
+
fix: "Wrap your config export with defineConfig() from @williamthorsen/nmr/config for type safety"
|
|
177
|
+
},
|
|
178
|
+
// -- Root script cleanup -------------------------------------------------
|
|
179
|
+
{
|
|
180
|
+
name: "root package.json has no nmr-provided scripts",
|
|
181
|
+
severity: "warn",
|
|
182
|
+
check: noRedundantRootScripts,
|
|
183
|
+
fix: "Remove scripts from root package.json that nmr provides as built-in root scripts \u2014 invoke via nmr directly"
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: "root:lint:strict does not use echo fallback",
|
|
187
|
+
severity: "warn",
|
|
188
|
+
skip: () => !scriptExists("root:lint:strict") ? "no root:lint:strict script" : false,
|
|
189
|
+
check: () => !scriptMatches("root:lint:strict", /\becho\b/),
|
|
190
|
+
fix: "Replace the echo fallback in root:lint:strict \u2014 strict-lint now supports path arguments"
|
|
191
|
+
},
|
|
192
|
+
// -- Workspace build readiness -------------------------------------------
|
|
193
|
+
{
|
|
194
|
+
name: "all workspace packages can build",
|
|
195
|
+
severity: "warn",
|
|
196
|
+
check: allWorkspacePackagesCanBuild,
|
|
197
|
+
fix: `Add "build": ":" to packages that don't need a build, or ensure packages that use the default nmr build have a tsconfig.json and a src/ directory`
|
|
198
|
+
},
|
|
199
|
+
// -- Vitest projects -----------------------------------------------------
|
|
200
|
+
{
|
|
201
|
+
name: "no retired Vitest config variants",
|
|
202
|
+
severity: "error",
|
|
203
|
+
check: () => noRetiredVitestConfigs(),
|
|
204
|
+
fix: "Delete every vitest.standalone.config.* and vitest.integration.config.*. nmr's test scripts select Vitest projects instead of naming config files"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "vitest.config.ts builds on @williamthorsen/nmr/vitest",
|
|
208
|
+
severity: "error",
|
|
209
|
+
check: () => vitestConfigBuildsOnSharedConfig(),
|
|
210
|
+
fix: "Replace vitest.config.ts with: import { defineVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineVitestConfig();"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "vitest.root.config.ts builds on @williamthorsen/nmr/vitest",
|
|
214
|
+
severity: "error",
|
|
215
|
+
check: () => vitestRootConfigBuildsOnSharedConfig(),
|
|
216
|
+
fix: "Replace vitest.root.config.ts with: import { defineRootVitestConfig } from '@williamthorsen/nmr/vitest'; export default defineRootVitestConfig({ monorepoRoot: import.meta.dirname });"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "every test file names its isolation tier",
|
|
220
|
+
severity: "error",
|
|
221
|
+
check: () => everyTestFileNamesItsTier(),
|
|
222
|
+
fix: `Rename each to <subject>[.<aspect>].<tier>.test.ts, naming one of ${TIER_NAMES.join(", ")}. Use tool for a test that reaches a program the environment supplies, which is where a retired .int. or .integration. file belongs. Only the segment before .test. selects a project, so an untiered file runs under the residual unit project and reports success`
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
name: "no package re-exports the ancestor Vitest config",
|
|
226
|
+
severity: "recommend",
|
|
227
|
+
check: () => noReExportOnlyVitestConfigs(),
|
|
228
|
+
fix: "Delete these files. Vitest resolves config by walking up from the run root, so a per-package re-export is redundant"
|
|
229
|
+
},
|
|
230
|
+
// -- Shared Prettier config ----------------------------------------------
|
|
231
|
+
{
|
|
232
|
+
name: "Prettier config builds on @williamthorsen/nmr/prettier",
|
|
233
|
+
severity: "error",
|
|
234
|
+
check: () => prettierConfigBuildsOnSharedConfig(),
|
|
235
|
+
fix: "Replace the Prettier config with: import { definePrettierConfig } from '@williamthorsen/nmr/prettier'; export default definePrettierConfig();"
|
|
236
|
+
},
|
|
237
|
+
// -- Audit dependency --------------------------------------------------------
|
|
238
|
+
{
|
|
239
|
+
name: "v11y-check in devDependencies",
|
|
240
|
+
severity: "warn",
|
|
241
|
+
check: () => hasDevDependency("v11y-check"),
|
|
242
|
+
fix: "pnpm add --save-dev v11y-check"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "code-quality workflow does not use nmr prepush",
|
|
246
|
+
severity: "warn",
|
|
247
|
+
skip: () => !fileExists(".github/workflows/code-quality.yaml") ? "no code-quality workflow" : false,
|
|
248
|
+
check: codeQualityWorkflowDoesNotUseNmrPrepush,
|
|
249
|
+
fix: 'Change the check-command in .github/workflows/code-quality.yaml from "pnpm exec nmr prepush" to "pnpm exec nmr ci", which runs the same checks without the audit that audit.yaml already runs'
|
|
250
|
+
},
|
|
251
|
+
// -- Legacy script runner ------------------------------------------------
|
|
252
|
+
{
|
|
253
|
+
name: "scripts/run-workspace-script.ts does not exist",
|
|
254
|
+
severity: "error",
|
|
255
|
+
check: () => !fileExists("scripts/run-workspace-script.ts"),
|
|
256
|
+
fix: "Delete scripts/run-workspace-script.ts \u2014 nmr replaces this custom script runner"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: 'no workspace packages reference run-workspace-script or "pnpm run ws"',
|
|
260
|
+
severity: "error",
|
|
261
|
+
check: noWorkspaceRunScriptReferences,
|
|
262
|
+
fix: 'Remove "ws" script entries and replace any "pnpm run ws" invocations with nmr in each packages/*/package.json'
|
|
263
|
+
}
|
|
264
|
+
]
|
|
265
|
+
}
|
|
266
|
+
]
|
|
267
|
+
});
|
|
268
|
+
var SCAN_EXCLUDE_DIRS = /* @__PURE__ */ new Set([".git", "coverage", "dist", "node_modules"]);
|
|
269
|
+
var CONFIG_EXTENSIONS = "{ts,mts,cts,js,mjs,cjs}";
|
|
270
|
+
var SHARED_VITEST_MODULE = "@williamthorsen/nmr/vitest";
|
|
271
|
+
var SHARED_PRETTIER_MODULE = "@williamthorsen/nmr/prettier";
|
|
272
|
+
var INERT_PRETTIER_CONFIGS = [".prettierrc", ".prettierrc.{json,json5,yaml,yml,toml}"];
|
|
273
|
+
var RE_EXPORT_LINE_PATTERN = /^export\s*(?:\{\s*default\s*\}|\*)\s*from\s*['"]\.\.\/[^'"]*['"];?$/;
|
|
274
|
+
function allWorkspacePackagesCanBuild() {
|
|
275
|
+
const packagesDir = join(process.cwd(), "packages");
|
|
276
|
+
if (!existsSync(packagesDir)) return true;
|
|
277
|
+
const entries = readdirSync2(packagesDir, { withFileTypes: true });
|
|
278
|
+
const failing = [];
|
|
279
|
+
for (const entry of entries) {
|
|
280
|
+
if (!entry.isDirectory()) continue;
|
|
281
|
+
const pkgPath = `packages/${entry.name}/package.json`;
|
|
282
|
+
const content = readFile(pkgPath);
|
|
283
|
+
if (!content) continue;
|
|
284
|
+
const hasBuildOverride = /"build"\s*:/.test(content);
|
|
285
|
+
const hasDefaultBuildInputs = fileExists(`packages/${entry.name}/tsconfig.json`) && existsSync(join(packagesDir, entry.name, "src"));
|
|
286
|
+
if (!hasBuildOverride && !hasDefaultBuildInputs) {
|
|
287
|
+
failing.push(entry.name);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (failing.length === 0) return true;
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
detail: `missing build override or tsconfig.json + src/: ${failing.join(", ")}`
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function checkNoMatchingFiles(patterns, cwd) {
|
|
297
|
+
const found = findFiles(patterns, cwd);
|
|
298
|
+
if (found.length === 0) return true;
|
|
299
|
+
return { ok: false, detail: formatPaths(found) };
|
|
300
|
+
}
|
|
301
|
+
function checkRootVitestConfig(baseName, exportName, cwd) {
|
|
302
|
+
const matches = findFiles([`${baseName}.${CONFIG_EXTENSIONS}`], cwd);
|
|
303
|
+
if (matches.length === 0) {
|
|
304
|
+
return { ok: false, detail: `${baseName}.ts is missing` };
|
|
305
|
+
}
|
|
306
|
+
const stale = matches.filter(
|
|
307
|
+
(relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), exportName, SHARED_VITEST_MODULE)
|
|
308
|
+
);
|
|
309
|
+
if (stale.length === 0) return true;
|
|
310
|
+
return { ok: false, detail: `does not import ${exportName} from ${SHARED_VITEST_MODULE}: ${stale.join(", ")}` };
|
|
311
|
+
}
|
|
312
|
+
function prettierConfigBuildsOnSharedConfig(cwd = process.cwd()) {
|
|
313
|
+
const configs = findFiles(
|
|
314
|
+
[`.prettierrc.${CONFIG_EXTENSIONS}`, `prettier.config.${CONFIG_EXTENSIONS}`],
|
|
315
|
+
//
|
|
316
|
+
cwd
|
|
317
|
+
);
|
|
318
|
+
if (configs.length === 0) {
|
|
319
|
+
return { ok: false, detail: describeMissingPrettierConfig(cwd) };
|
|
320
|
+
}
|
|
321
|
+
const stale = configs.filter(
|
|
322
|
+
(relativePath) => !importsSharedExport(readFileIn(cwd, relativePath), "definePrettierConfig", SHARED_PRETTIER_MODULE)
|
|
323
|
+
);
|
|
324
|
+
if (stale.length === 0) return true;
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
detail: `does not import definePrettierConfig from ${SHARED_PRETTIER_MODULE}: ${stale.join(", ")}`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function describeMissingPrettierConfig(cwd) {
|
|
331
|
+
const inert = findFiles(INERT_PRETTIER_CONFIGS, cwd);
|
|
332
|
+
if (inert.length > 0) return `holds no code to call the factory: ${inert.join(", ")}`;
|
|
333
|
+
if (hasPrettierConfigKey(cwd)) {
|
|
334
|
+
return 'holds no code to call the factory: the "prettier" key in package.json';
|
|
335
|
+
}
|
|
336
|
+
return ".prettierrc.js is missing";
|
|
337
|
+
}
|
|
338
|
+
function hasPrettierConfigKey(cwd) {
|
|
339
|
+
const manifest = readFileIn(cwd, "package.json");
|
|
340
|
+
if (manifest === void 0) return false;
|
|
341
|
+
try {
|
|
342
|
+
const parsed = JSON.parse(manifest);
|
|
343
|
+
return isRecord(parsed) && parsed.prettier !== void 0;
|
|
344
|
+
} catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function codeQualityWorkflowDoesNotUseNmrPrepush() {
|
|
349
|
+
const content = readFile(".github/workflows/code-quality.yaml");
|
|
350
|
+
if (content === void 0) return true;
|
|
351
|
+
return !/check-command:\s*pnpm exec nmr prepush(\s|$)/.test(content);
|
|
352
|
+
}
|
|
353
|
+
function everyTestFileNamesItsTier(cwd = process.cwd()) {
|
|
354
|
+
const untiered = findTestFiles(cwd).filter((path2) => !hasTierInfix(path2));
|
|
355
|
+
if (untiered.length === 0) return true;
|
|
356
|
+
return { ok: false, detail: formatPaths(untiered) };
|
|
357
|
+
}
|
|
358
|
+
function findFiles(patterns, cwd) {
|
|
359
|
+
return globSync(patterns, { cwd, exclude: (path2) => SCAN_EXCLUDE_DIRS.has(basename(path2)) }).map((path2) => path2.split(sep).join("/")).toSorted();
|
|
360
|
+
}
|
|
361
|
+
function formatPaths(paths) {
|
|
362
|
+
return `${paths.length} found:
|
|
363
|
+
${paths.map((path2) => ` ${path2}`).join("\n")}`;
|
|
364
|
+
}
|
|
365
|
+
function getMinVersion() {
|
|
366
|
+
const picked = { "version": "0.26.0" };
|
|
367
|
+
if (typeof picked.version !== "string") {
|
|
368
|
+
throw new TypeError("nmr/package.json: 'version' must be a string");
|
|
369
|
+
}
|
|
370
|
+
return picked.version;
|
|
371
|
+
}
|
|
372
|
+
function importsSharedExport(content, exportName, moduleSpecifier) {
|
|
373
|
+
if (content === void 0) return false;
|
|
374
|
+
const pattern = new RegExp(String.raw`import\s*\{[^}]*\b${exportName}\b[^}]*\}\s*from\s*['"]${moduleSpecifier}['"]`);
|
|
375
|
+
return pattern.test(content);
|
|
376
|
+
}
|
|
377
|
+
function isReExportOnly(content) {
|
|
378
|
+
if (content === void 0) return false;
|
|
379
|
+
const statements = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
380
|
+
return statements.length > 0 && statements.every((line) => RE_EXPORT_LINE_PATTERN.test(line));
|
|
381
|
+
}
|
|
382
|
+
function noReExportOnlyVitestConfigs(cwd = process.cwd()) {
|
|
383
|
+
const nonRootConfigs = findFiles([`**/vitest.config.${CONFIG_EXTENSIONS}`], cwd).filter((path2) => path2.includes("/"));
|
|
384
|
+
const reExports = nonRootConfigs.filter((path2) => isReExportOnly(readFileIn(cwd, path2)));
|
|
385
|
+
if (reExports.length === 0) return true;
|
|
386
|
+
return { ok: false, detail: formatPaths(reExports) };
|
|
387
|
+
}
|
|
388
|
+
function noRedundantRootScripts() {
|
|
389
|
+
const pkg = readPackageJson();
|
|
390
|
+
if (!pkg) return true;
|
|
391
|
+
const scripts = pkg.scripts;
|
|
392
|
+
if (!isRecord(scripts)) return true;
|
|
393
|
+
const builtInNames = Object.keys(getDefaultRootScripts());
|
|
394
|
+
const redundant = Object.keys(scripts).filter((name) => builtInNames.includes(name));
|
|
395
|
+
if (redundant.length === 0) return true;
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
detail: `redundant: ${redundant.join(", ")}`
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
function noRetiredVitestConfigs(cwd = process.cwd()) {
|
|
402
|
+
return checkNoMatchingFiles(
|
|
403
|
+
[`**/vitest.standalone.config.${CONFIG_EXTENSIONS}`, `**/vitest.integration.config.${CONFIG_EXTENSIONS}`],
|
|
404
|
+
cwd
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
function noWorkspaceRunScriptReferences() {
|
|
408
|
+
const packagesDir = join(process.cwd(), "packages");
|
|
409
|
+
if (!existsSync(packagesDir)) return true;
|
|
410
|
+
const legacyPattern = /run-workspace-script|"pnpm\s+run\s+ws\b/;
|
|
411
|
+
const entries = readdirSync2(packagesDir, { withFileTypes: true });
|
|
412
|
+
const matches = [];
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
if (!entry.isDirectory()) continue;
|
|
415
|
+
const content = readFile(`packages/${entry.name}/package.json`);
|
|
416
|
+
if (content && legacyPattern.test(content)) {
|
|
417
|
+
matches.push(entry.name);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (matches.length === 0) return true;
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
detail: `found in: ${matches.join(", ")}`
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function readFileIn(cwd, relativePath) {
|
|
427
|
+
try {
|
|
428
|
+
return readFileSync(join(cwd, relativePath), "utf8");
|
|
429
|
+
} catch {
|
|
430
|
+
return void 0;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function scriptExists(name) {
|
|
434
|
+
const pkg = readPackageJson();
|
|
435
|
+
if (!pkg) return false;
|
|
436
|
+
const scripts = pkg.scripts;
|
|
437
|
+
return isRecord(scripts) && name in scripts;
|
|
438
|
+
}
|
|
439
|
+
function scriptMatches(name, pattern) {
|
|
440
|
+
const pkg = readPackageJson();
|
|
441
|
+
if (!pkg) return false;
|
|
442
|
+
const scripts = pkg.scripts;
|
|
443
|
+
if (!isRecord(scripts)) return false;
|
|
444
|
+
const value = scripts[name];
|
|
445
|
+
return typeof value === "string" && pattern.test(value);
|
|
446
|
+
}
|
|
447
|
+
function toolVersionsHasNoPnpm() {
|
|
448
|
+
const content = readFile(".tool-versions");
|
|
449
|
+
if (content === void 0) return true;
|
|
450
|
+
return !/^pnpm\s/m.test(content);
|
|
451
|
+
}
|
|
452
|
+
function vitestConfigBuildsOnSharedConfig(cwd = process.cwd()) {
|
|
453
|
+
return checkRootVitestConfig("vitest.config", "defineVitestConfig", cwd);
|
|
454
|
+
}
|
|
455
|
+
function vitestRootConfigBuildsOnSharedConfig(cwd = process.cwd()) {
|
|
456
|
+
return checkRootVitestConfig("vitest.root.config", "defineRootVitestConfig", cwd);
|
|
457
|
+
}
|
|
458
|
+
export {
|
|
459
|
+
codeQualityWorkflowDoesNotUseNmrPrepush,
|
|
460
|
+
default_default as default,
|
|
461
|
+
everyTestFileNamesItsTier,
|
|
462
|
+
noReExportOnlyVitestConfigs,
|
|
463
|
+
noRetiredVitestConfigs,
|
|
464
|
+
prettierConfigBuildsOnSharedConfig,
|
|
465
|
+
vitestConfigBuildsOnSharedConfig,
|
|
466
|
+
vitestRootConfigBuildsOnSharedConfig
|
|
467
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"kits": [
|
|
4
|
+
{
|
|
5
|
+
"checklists": [
|
|
6
|
+
"nmr"
|
|
7
|
+
],
|
|
8
|
+
"name": "default",
|
|
9
|
+
"path": "kits/default.js",
|
|
10
|
+
"readyupVersion": "0.23.0",
|
|
11
|
+
"source": "kits/default.ts",
|
|
12
|
+
"sourceHash": "f646e7fb",
|
|
13
|
+
"targetHash": "56564046"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
package/README.md
CHANGED
|
@@ -3,47 +3,21 @@
|
|
|
3
3
|
Context-aware script runner for pnpm monorepos. Ships an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
|
|
4
4
|
|
|
5
5
|
<!-- section:release-notes -->
|
|
6
|
-
## Release notes — v0.
|
|
6
|
+
## Release notes — v0.26.0 (2026-08-04)
|
|
7
7
|
|
|
8
8
|
### 🎉 Features
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- Publish each package's readyup kit from the package that owns it (#590)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
ReadyUp checks for `@williamthorsen/nmr`, `@williamthorsen/release-kit`, and `v11y-check` are now run directly against these packages, guaranteeing a version-appropriate check, instead of against their host repo. The package must be installed as a direct dependency in the calling repo.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
The syntax is `rdy run --from npm:<package>`. `rdy run --packages` runs the kits contained in any packages listed in `.config/readyup.config.ts`. Requires `readyup` 0.23 or later.
|
|
15
15
|
|
|
16
|
-
-
|
|
16
|
+
- Ship agent guidance as CodeAssembly package content (#591)
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
A repo that consumes `@williamthorsen/nmr` can now adopt `nmr`'s agent guidance by naming the package in `.agents/codeassembly.yaml`. `codeassembly sync` then delivers that guidance to every agent harness. Guidance updates are thus delivered directly with each package, eliminating the need to keep a guidance file in sync. The `nmr sync-agent-files` command has been removed.
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Repurposes the `nmr ci` command to align with its name; it now runs the code-quality checks that run in CI: `nmr build && nmr check:strict`. A new `nmr prepush` command, combining code-quality checks and the security audit, takes the place of `nmr ci`. nmr's readyup kit is adjusted to account for the changes.
|
|
23
|
-
|
|
24
|
-
- Exclude test scaffolding from builds and make the ignore set configurable (#564)
|
|
25
|
-
|
|
26
|
-
The `build` command now ignores the directories typically used for test scaffolding, such as fixtures and mocks. The exclusion includes `test-utils/`, which is treated as the conventional name for a directory containing test utilities. A package can declare additional ignore patterns in its config file.
|
|
27
|
-
|
|
28
|
-
- Skip checks that already passed on the current working tree (#568)
|
|
29
|
-
|
|
30
|
-
`nmr` commands now skip any pure check (types, lint, tests) that has already passed on an unchanged working tree; instead, success is reported immediately. `nmr --no-cache` causes the cache check to be skipped, and `NMR_NO_CACHE=1` does the same for a whole shell. Cached results are cleared by `nmr clean`. Which commands can skip is configurable at the monorepo root, and `NMR_DEBUG=1` reports why a run didn't skip.
|
|
31
|
-
|
|
32
|
-
### 🪦 Removed
|
|
33
|
-
|
|
34
|
-
- 🚨 **Breaking:** Remove the package root entry in favor of /config (#569)
|
|
35
|
-
|
|
36
|
-
`defineConfig` is now imported from `@williamthorsen/nmr/config` instead of the bare package.
|
|
37
|
-
|
|
38
|
-
### 🐛 Bug fixes
|
|
39
|
-
|
|
40
|
-
- Stop excluding *.types.ts and mock* files from coverage
|
|
41
|
-
|
|
42
|
-
Coverage now measures modules matching `*.types.ts` and `mock*`, which the shared Vitest config previously excluded. A type module that grows a type guard, or a mock helper placed in `test-utils/` where helpers are meant to stay measured, was left out of the report with nothing saying so, and because exclusions concatenate, no consuming repo could add it back.
|
|
43
|
-
|
|
44
|
-
What remains excluded names something that cannot hold runtime code by construction: a directory of test scaffolding, a barrel, and a declaration file. A pattern resting on a filename alone suppresses nothing it could claim to, since a file with no executable statements is absent from the report either way.
|
|
45
|
-
|
|
46
|
-
A repo enforcing its own coverage thresholds may see a lower figure once these files enter the denominator.
|
|
20
|
+
Migration guide: Delete `.agents/nmr/`, remove the `@nmr/AGENTS.md` include line, and remove all uses of `check:agent-files`.
|
|
47
21
|
<!-- /section:release-notes -->
|
|
48
22
|
|
|
49
23
|
## Installation
|
|
@@ -228,23 +202,23 @@ import { defineConfig } from '@williamthorsen/nmr/config';
|
|
|
228
202
|
|
|
229
203
|
export default defineConfig({
|
|
230
204
|
workspaceScripts: {
|
|
231
|
-
'build:pre': '
|
|
205
|
+
'build:pre': 'node scripts/generate-manifest.ts',
|
|
232
206
|
},
|
|
233
207
|
});
|
|
234
208
|
```
|
|
235
209
|
|
|
236
|
-
Attach a
|
|
210
|
+
Attach a step to one package's hook:
|
|
237
211
|
|
|
238
212
|
```jsonc
|
|
239
|
-
// packages/
|
|
213
|
+
// packages/my-package/package.json
|
|
240
214
|
{
|
|
241
215
|
"scripts": {
|
|
242
|
-
"
|
|
216
|
+
"upgrade:post": "nmr-report-overrides",
|
|
243
217
|
},
|
|
244
218
|
}
|
|
245
219
|
```
|
|
246
220
|
|
|
247
|
-
The second example calls the bin directly
|
|
221
|
+
The second example calls the bin directly: `report-overrides` is a root-registry command, so `nmr report-overrides` from inside a package fails with `Unknown command`, while the bin runs from either scope.
|
|
248
222
|
|
|
249
223
|
## Check-result cache
|
|
250
224
|
|
|
@@ -255,7 +229,7 @@ $ nmr ci
|
|
|
255
229
|
# ... four minutes of build, typecheck, lint, and coverage ...
|
|
256
230
|
|
|
257
231
|
$ nmr ci
|
|
258
|
-
⏭️ ci: passed 2m ago on this tree (saved ~4m). Re-run with --no-cache.
|
|
232
|
+
⏭️ ci: passed 2m ago on this tree (🚀 saved ~4m). Re-run with --no-cache.
|
|
259
233
|
```
|
|
260
234
|
|
|
261
235
|
Composite commands expand into child `nmr` processes, so one green `nmr ci` records a pass for every cacheable command in the chain, at every scope it ran at. A later `nmr check`, `nmr typecheck`, or `nmr -F core test` on the same tree skips too. The skip line is suppressed by `-q`.
|
|
@@ -264,7 +238,7 @@ The cache is a working-tree cache, not a build cache: it answers "has this exact
|
|
|
264
238
|
|
|
265
239
|
### What is cached
|
|
266
240
|
|
|
267
|
-
Cacheable by default: `check`, `check:
|
|
241
|
+
Cacheable by default: `check`, `check:strict`, `ci`, `fix:check`, `fmt:check`, `lint:check`, `lint:strict`, `test`, `test:coverage`, `test:tool`, `test:unit`, `typecheck`, and the `root:` variants `root:check`, `root:lint:check`, `root:lint:strict`, `root:test`, `root:test:tool`, `root:test:unit`, and `root:typecheck`.
|
|
268
242
|
|
|
269
243
|
Everything else runs every time:
|
|
270
244
|
|
|
@@ -464,11 +438,10 @@ Neither is bound to a git hook. `prepush` is named for when you run it, not for
|
|
|
464
438
|
|
|
465
439
|
#### Check and quality
|
|
466
440
|
|
|
467
|
-
| Command
|
|
468
|
-
|
|
|
469
|
-
| `check`
|
|
470
|
-
| `check:
|
|
471
|
-
| `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage`, `check:agent-files` |
|
|
441
|
+
| Command | Runs |
|
|
442
|
+
| -------------- | -------------------------------------------------------- |
|
|
443
|
+
| `check` | `typecheck`, `fmt:check`, `lint:check`, `test` |
|
|
444
|
+
| `check:strict` | `typecheck`, `fmt:check`, `lint:strict`, `test:coverage` |
|
|
472
445
|
|
|
473
446
|
#### Fix
|
|
474
447
|
|
|
@@ -558,7 +531,6 @@ These scripts operate on root-level code only (not workspace packages):
|
|
|
558
531
|
| Command | Runs |
|
|
559
532
|
| ------------------ | ---------------------- |
|
|
560
533
|
| `report-overrides` | `nmr-report-overrides` |
|
|
561
|
-
| `sync-agent-files` | `nmr-sync-agent-files` |
|
|
562
534
|
|
|
563
535
|
## CLI reference
|
|
564
536
|
|
|
@@ -612,35 +584,33 @@ Report any active `pnpm.overrides` in the root `package.json`, reminding develop
|
|
|
612
584
|
nmr report-overrides
|
|
613
585
|
```
|
|
614
586
|
|
|
615
|
-
|
|
587
|
+
## Agent guidance
|
|
616
588
|
|
|
617
|
-
|
|
589
|
+
nmr ships the rules an agent needs in order to invoke it, as [CodeAssembly](https://github.com/williamthorsen/codeassembly) package content. Adopt it by naming the package in the consuming repo's `.agents/codeassembly.yaml`:
|
|
618
590
|
|
|
619
|
-
```
|
|
620
|
-
|
|
621
|
-
|
|
591
|
+
```yaml
|
|
592
|
+
packages:
|
|
593
|
+
use:
|
|
594
|
+
- '@williamthorsen/nmr'
|
|
622
595
|
```
|
|
623
596
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
The default root `check:strict` composite includes `check:agent-files`, which runs `--check` automatically — so any CI pipeline already running `check:strict` catches drift without per-consumer wiring.
|
|
597
|
+
Then add `codeassembly` as a devDependency and run `codeassembly sync`. The guidance is injected into the machine-local guidance file each harness loads at launch; nothing is copied into the repo or committed there. It resolves from the installed package, so upgrading nmr updates it with no second step.
|
|
627
598
|
|
|
628
|
-
|
|
599
|
+
Wiring the sync to `postinstall` keeps it current without a hand-run command:
|
|
629
600
|
|
|
630
|
-
```
|
|
631
|
-
|
|
601
|
+
```json
|
|
602
|
+
{
|
|
603
|
+
"scripts": {
|
|
604
|
+
"postinstall": "codeassembly sync --warn-only"
|
|
605
|
+
}
|
|
606
|
+
}
|
|
632
607
|
```
|
|
633
608
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
The synced file is injected at launch in every consuming repo, so every line is paid for on every task whether or not it is relevant. It is a cheatsheet, not a manual.
|
|
609
|
+
`--warn-only` reports a sync failure and exits 0; without it, a failed sync aborts `pnpm install`.
|
|
637
610
|
|
|
638
|
-
|
|
611
|
+
### Migrating from `sync-agent-files`
|
|
639
612
|
|
|
640
|
-
|
|
641
|
-
2. The obvious action goes wrong without it. Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
|
|
642
|
-
|
|
643
|
-
Everything else belongs in this README, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from `AGENTS.md` naming the topic so an agent knows to look. When adding a feature, document it here and extend that pointer rather than the cheatsheet.
|
|
613
|
+
The `nmr sync-agent-files` command, its `nmr-sync-agent-files` bin, and the `check:agent-files` script were removed in favor of the above. After upgrading, delete the generated `.agents/nmr/` directory and the `@nmr/AGENTS.md` line it was exposed through, drop `check:agent-files` from any `check:strict` override that enumerates it, and remove any direct call to the bin.
|
|
644
614
|
|
|
645
615
|
## Standalone utilities
|
|
646
616
|
|
|
@@ -707,6 +677,29 @@ ensure-prepublish-hooks
|
|
|
707
677
|
| `--dry-run` | Preview what `--fix` would do | — |
|
|
708
678
|
| `--command <command>` | Custom hook command | `"npm run build"` |
|
|
709
679
|
|
|
680
|
+
## Conformance checks
|
|
681
|
+
|
|
682
|
+
nmr publishes a `readyup` kit that checks a consuming repo against the current release: the shared Vitest and Prettier configs, the workspace layout, the root script registry, and the [test-tier convention](#test-tiers). The kit ships inside the package, so it checks against the nmr version installed rather than whatever a repository ref happens to point at, and a tier added or renamed in nmr reaches the repo on upgrade.
|
|
683
|
+
|
|
684
|
+
Add `readyup` as a devDependency, then name nmr in its config:
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
// .config/readyup.config.ts
|
|
688
|
+
import { defineRdyConfig } from 'readyup';
|
|
689
|
+
|
|
690
|
+
export default defineRdyConfig({
|
|
691
|
+
packages: ['@williamthorsen/nmr'],
|
|
692
|
+
});
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
```bash
|
|
696
|
+
rdy run --packages # every kit each listed package publishes
|
|
697
|
+
rdy run --from npm:@williamthorsen/nmr # nmr's kit alone, without the config entry
|
|
698
|
+
rdy list --from npm:@williamthorsen/nmr # what nmr publishes
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
`--packages` is the form that survives nmr publishing further kits. Both need `readyup` 0.23 or later, and `@williamthorsen/nmr` as a _direct_ devDependency: a strict pnpm layout links nothing else into the project, so a transitive copy is unreachable.
|
|
702
|
+
|
|
710
703
|
## Consumer migration
|
|
711
704
|
|
|
712
705
|
After installing, a consuming repo's root `package.json` scripts shrink to lifecycle hooks:
|
|
@@ -833,7 +826,9 @@ vitest # every project
|
|
|
833
826
|
|
|
834
827
|
**A tier is not a statement about preconditions.** A `unit` test may still need something set up before it runs: a build, a generated fixture, a seeded file. What makes it `unit` is that it reaches nothing beyond the test process _while running_. The filesystem sits in `unit` for the same reason the tiers exist: they gate on what must be available, and the filesystem always is.
|
|
835
828
|
|
|
836
|
-
|
|
829
|
+
**Every test file names its tier**, in the form `<subject>[.<aspect>].<tier>.test.ts`. Only the segment immediately before `.test.` selects a project, so an earlier one is free for documentation: `resolveConfig.packaged.unit.test.ts` reads as a companion to `resolveConfig.unit.test.ts` and still names `unit`.
|
|
830
|
+
|
|
831
|
+
`unit` is defined by subtracting the tiers rather than by an allow-list of infixes, so a file such as `parser.smoke.test.ts` runs under `unit` instead of being silently dropped. That is a safety net, not a licence to omit the tier: a file whose tier segment is missing or misspelt runs under `unit` and reports success, so no test run distinguishes it from a conformant one. [nmr's readyup kit](#conformance-checks) reports those files, which is the only thing that does.
|
|
837
832
|
|
|
838
833
|
`localhost` and `remote` have no test script of their own. They are declared anyway, so that a `*.remote.test.ts` file cannot fall into `unit` and run in the default gate unnoticed.
|
|
839
834
|
|
|
@@ -934,11 +929,11 @@ This variant reads `pnpm-workspace.yaml` and excludes every workspace package fr
|
|
|
934
929
|
|
|
935
930
|
The projects were once named `unit`, `integration`, and `app`, for what a test _covered_ rather than what it _reached_. `integration` and `app` are gone.
|
|
936
931
|
|
|
937
|
-
1. Rename every `*.int.test.ts`. A test that reaches a program the environment supplies becomes `*.tool.test.ts`; one that reaches nothing beyond the test process
|
|
938
|
-
2.
|
|
932
|
+
1. Rename every `*.int.test.ts`. A test that reaches a program the environment supplies becomes `*.tool.test.ts`; one that reaches nothing beyond the test process becomes `*.unit.test.ts`. Re-read each file rather than renaming in bulk -- the axis changed, so the old bucket does not map onto one new tier.
|
|
933
|
+
2. Rename every `*.app.test.ts` onto the convention. `app` was never a tier, so keep it as the aspect segment and add the tier after it: `scaffold.app.test.ts` becomes `scaffold.app.unit.test.ts`.
|
|
939
934
|
3. Replace `nmr test:integration` with `nmr test:tool`, and `nmr root:test:integration` with `nmr root:test:tool`, wherever a script or workflow names them.
|
|
940
935
|
|
|
941
|
-
**
|
|
936
|
+
**A file left without a tier is a silent failure.** The residual `unit` claims it and it runs in the default gate -- green, with nothing reporting that the separation was lost. [nmr's readyup kit](#conformance-checks) reports every such file; a passing test run does not. Run it before treating the migration as done.
|
|
942
937
|
|
|
943
938
|
`nmr test` also runs the `tool` tier, which the old `test` excluded. Those tests previously ran in no default selection at all: `check`, `check:strict`, and `ci` all inherited the exclusion, so a green pipeline said nothing about them. Expect `nmr test` to take longer and to surface failures that were never gated.
|
|
944
939
|
|
package/agents/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Authoring nmr's agent guidance
|
|
2
|
+
|
|
3
|
+
This directory is a CodeAssembly content root, declared by the `codeassembly.content` key in `packages/nmr/package.json`.
|
|
4
|
+
|
|
5
|
+
## The ambient cheatsheet
|
|
6
|
+
|
|
7
|
+
`guidance/rulebooks/nmr.md` carries `delivery: ambient`, so its body is injected at launch in every consuming repo. Every line is paid for on every task, whether or not it is relevant. It is a cheatsheet, not a manual.
|
|
8
|
+
|
|
9
|
+
A line earns a place in it only if both hold:
|
|
10
|
+
|
|
11
|
+
1. **It is absent from nmr's own output.** Bare `nmr` already prints the flags, every command, and the shell command each resolves to; failing checks already name their own fix. Repeating any of that costs launch tokens to say something the agent will be told anyway, at the moment it matters.
|
|
12
|
+
2. **The obvious action goes wrong without it.** Guidance that prevents a silent mistake earns its place; guidance that prevents a mistake the next error message would diagnose does not.
|
|
13
|
+
|
|
14
|
+
Everything else belongs in `packages/nmr/README.md`, reachable on demand at `node_modules/@williamthorsen/nmr/README.md`, with a pointer from the cheatsheet naming the topic so an agent knows to look. When adding a feature, document it there and extend that pointer rather than the cheatsheet.
|
|
15
|
+
|
|
16
|
+
## Constraints on rulebook bodies
|
|
17
|
+
|
|
18
|
+
Rulebook bodies carry no links to other rulebooks. CodeAssembly's renderer rewrites link targets under `skills/` and `scripts/` and rejects everything else, so a cross-reference names the deployed `consult-<slug>` in prose and declares the edge under `dependencies:`.
|
|
19
|
+
|
|
20
|
+
Anchor-only links are checked against the body they appear in, so a fragment naming a heading that lives in another document fails the run. Check `](#` before moving a section between bodies.
|
|
21
|
+
|
|
22
|
+
## Publishing
|
|
23
|
+
|
|
24
|
+
The directory is published because `files` in `packages/nmr/package.json` lists it. Dropping that entry fails nothing locally, because this repo resolves the guidance through a `workspace:*` self-link that `files` does not govern. `packaging.tool.test.ts` is what catches it.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
slug: nmr
|
|
3
|
+
description: Invocation rules for the nmr script runner in a pnpm monorepo.
|
|
4
|
+
delivery: ambient
|
|
3
5
|
---
|
|
4
6
|
|
|
5
|
-
# nmr: agent guidance
|
|
7
|
+
# nmr: agent guidance
|
|
6
8
|
|
|
7
9
|
- Use `nmr <command>`, not `pnpm run <command>`, for anything nmr provides. Run bare `nmr` to list every command and the shell command it resolves to; a wrong guess reports only `Unknown command`, with no list.
|
|
8
10
|
- Scope follows cwd, and bare `nmr` lists both registries: from the repo root a command covers root files and every workspace; from inside a package, that package alone. `nmr -F <pkg> <command>` targets one package from anywhere; `nmr root:<command>` targets root files alone, which isolates a failure to root code.
|
package/dist/esm/check-cache.js
CHANGED
|
@@ -5,6 +5,7 @@ import process from 'node:process';
|
|
|
5
5
|
import { hashWorkingTree, readHeadSha, readJsonCacheEntry, removeCacheDir, resolveCacheEntryPath, writeCacheEntry, } from '@williamthorsen/nmr-core';
|
|
6
6
|
import { hasBuildOutput, readBuildDigest } from "./commands/build-output.js";
|
|
7
7
|
import { loadWorkspaceConfig } from "./config.js";
|
|
8
|
+
import { formatDuration, formatSaving } from "./helpers/duration.js";
|
|
8
9
|
import { isObject, isStringRecord } from "./helpers/type-guards.js";
|
|
9
10
|
import { getDefaultWorkspaceScripts } from "./resolve-scripts.js";
|
|
10
11
|
import { buildWorkspaceRegistry, resolveScript } from "./resolver.js";
|
|
@@ -17,7 +18,6 @@ export const CURRENT_RUNTIME = {
|
|
|
17
18
|
export const DEBUG_ENV_VAR = 'NMR_DEBUG';
|
|
18
19
|
export const DEFAULT_CACHEABLE_COMMANDS = [
|
|
19
20
|
'check',
|
|
20
|
-
'check:agent-files',
|
|
21
21
|
'check:strict',
|
|
22
22
|
'ci',
|
|
23
23
|
'fix:check',
|
|
@@ -91,8 +91,9 @@ export function formatMisplacedNoCacheWarning(command) {
|
|
|
91
91
|
}
|
|
92
92
|
export function formatSkipLine(command, entry, now) {
|
|
93
93
|
const age = formatDuration(Math.max(0, now - Date.parse(entry.recordedAt)));
|
|
94
|
-
const
|
|
95
|
-
|
|
94
|
+
const saving = formatSaving(entry.durationMs);
|
|
95
|
+
const savingClause = saving === undefined ? '' : ` (${saving})`;
|
|
96
|
+
return `⏭️ ${command}: passed ${age} ago on this tree${savingClause}. Re-run with --no-cache.`;
|
|
96
97
|
}
|
|
97
98
|
export async function readBuildOutputState(monorepoRoot, config) {
|
|
98
99
|
const state = { missing: [], digests: {} };
|
|
@@ -165,9 +166,6 @@ export function writeDebugNote(message, env, stderr) {
|
|
|
165
166
|
stderr.write(`nmr check-cache: ${message}\n`);
|
|
166
167
|
}
|
|
167
168
|
}
|
|
168
|
-
const MILLISECONDS_PER_SECOND = 1000;
|
|
169
|
-
const SECONDS_PER_MINUTE = 60;
|
|
170
|
-
const MINUTES_PER_HOUR = 60;
|
|
171
169
|
function decodeTreeSnapshot(encoded) {
|
|
172
170
|
if (encoded === undefined) {
|
|
173
171
|
return undefined;
|
|
@@ -178,17 +176,6 @@ function decodeTreeSnapshot(encoded) {
|
|
|
178
176
|
}
|
|
179
177
|
return { hash, headSha };
|
|
180
178
|
}
|
|
181
|
-
function formatDuration(milliseconds) {
|
|
182
|
-
const seconds = Math.round(milliseconds / MILLISECONDS_PER_SECOND);
|
|
183
|
-
if (seconds < SECONDS_PER_MINUTE) {
|
|
184
|
-
return `${seconds}s`;
|
|
185
|
-
}
|
|
186
|
-
const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
|
|
187
|
-
if (minutes < MINUTES_PER_HOUR) {
|
|
188
|
-
return `${minutes}m`;
|
|
189
|
-
}
|
|
190
|
-
return `${Math.round(minutes / MINUTES_PER_HOUR)}h`;
|
|
191
|
-
}
|
|
192
179
|
function isCheckCacheEntry(value) {
|
|
193
180
|
if (!isObject(value)) {
|
|
194
181
|
return false;
|
|
@@ -28,8 +28,7 @@ export const rootScripts = {
|
|
|
28
28
|
'audit:prod': 'pnpm exec v11y --prod',
|
|
29
29
|
build: 'pnpm --recursive exec nmr build',
|
|
30
30
|
check: ['typecheck', 'fmt:check', 'lint:check', 'test'],
|
|
31
|
-
'check:
|
|
32
|
-
'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage', 'check:agent-files'],
|
|
31
|
+
'check:strict': ['typecheck', 'fmt:check', 'lint:strict', 'test:coverage'],
|
|
33
32
|
ci: ['build', 'check:strict'],
|
|
34
33
|
clean: 'nmr-clean',
|
|
35
34
|
fix: ['lint', 'fmt'],
|
|
@@ -51,7 +50,6 @@ export const rootScripts = {
|
|
|
51
50
|
'root:test:unit': 'vitest --config ./vitest.root.config.ts --project unit',
|
|
52
51
|
'root:typecheck': 'tsgo --noEmit',
|
|
53
52
|
'root:upgrade': 'nmr-taze --include-locked',
|
|
54
|
-
'sync-agent-files': 'nmr-sync-agent-files',
|
|
55
53
|
test: 'nmr root:test && pnpm --recursive exec nmr test',
|
|
56
54
|
'test:all': 'nmr root:test:all && pnpm --recursive exec nmr test:all',
|
|
57
55
|
'test:coverage': 'nmr root:test && pnpm --recursive exec nmr test:coverage',
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function formatDuration(milliseconds) {
|
|
2
|
+
const seconds = Math.round(milliseconds / MILLISECONDS_PER_SECOND);
|
|
3
|
+
if (seconds < SECONDS_PER_MINUTE) {
|
|
4
|
+
return `${seconds}s`;
|
|
5
|
+
}
|
|
6
|
+
const minutes = Math.round(seconds / SECONDS_PER_MINUTE);
|
|
7
|
+
if (minutes < MINUTES_PER_HOUR) {
|
|
8
|
+
return `${minutes}m`;
|
|
9
|
+
}
|
|
10
|
+
return `${Math.round(minutes / MINUTES_PER_HOUR)}h`;
|
|
11
|
+
}
|
|
12
|
+
export function formatSaving(milliseconds) {
|
|
13
|
+
if (!Number.isFinite(milliseconds) || milliseconds < MILLISECONDS_PER_SECOND) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return `${SPEED_BOOST_ICON} saved ~${formatDuration(milliseconds)}`;
|
|
17
|
+
}
|
|
18
|
+
const MILLISECONDS_PER_SECOND = 1000;
|
|
19
|
+
const MINUTES_PER_HOUR = 60;
|
|
20
|
+
const SECONDS_PER_MINUTE = 60;
|
|
21
|
+
const SPEED_BOOST_ICON = '🚀';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const ALL_TEST_PATTERNS: string[];
|
|
2
|
+
export declare function buildTierPatterns(tier: string): string[];
|
|
3
|
+
export declare function findTestFiles(rootDir: string): string[];
|
|
4
|
+
export declare function hasTierInfix(filePath: string): boolean;
|
|
5
|
+
export declare const TEST_COLLECTION_EXCLUDE: string[];
|
|
6
|
+
export declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
|
|
7
|
+
export type TierName = (typeof TIER_NAMES)[number];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const TEST_DIR = '__tests__';
|
|
4
|
+
const TEST_EXTENSIONS = '{ts,tsx}';
|
|
5
|
+
const TEST_GLOB_PREFIX = `**/${TEST_DIR}/**`;
|
|
6
|
+
const TEST_FILE_PATTERN = /\.test\.tsx?$/;
|
|
7
|
+
export const ALL_TEST_PATTERNS = [`${TEST_GLOB_PREFIX}/*.test.${TEST_EXTENSIONS}`];
|
|
8
|
+
export function buildTierPatterns(tier) {
|
|
9
|
+
return [`${TEST_GLOB_PREFIX}/*.${tier}.test.${TEST_EXTENSIONS}`];
|
|
10
|
+
}
|
|
11
|
+
export function findTestFiles(rootDir) {
|
|
12
|
+
const found = [];
|
|
13
|
+
collectTestFiles(rootDir, '', false, found);
|
|
14
|
+
return found.toSorted();
|
|
15
|
+
}
|
|
16
|
+
export function hasTierInfix(filePath) {
|
|
17
|
+
const tiers = TIER_NAMES;
|
|
18
|
+
return tiers.includes(path.basename(filePath).split('.').at(-3) ?? '');
|
|
19
|
+
}
|
|
20
|
+
export const TEST_COLLECTION_EXCLUDE = ['.git', 'coverage', 'dist', 'node_modules'];
|
|
21
|
+
export const TIER_NAMES = ['unit', 'tool', 'localhost', 'remote'];
|
|
22
|
+
function collectTestFiles(dir, relativeDir, inTestDir, found) {
|
|
23
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const relativePath = relativeDir === '' ? entry.name : `${relativeDir}/${entry.name}`;
|
|
26
|
+
if (entry.isDirectory()) {
|
|
27
|
+
if (TEST_COLLECTION_EXCLUDE.includes(entry.name))
|
|
28
|
+
continue;
|
|
29
|
+
collectTestFiles(path.join(dir, entry.name), relativePath, inTestDir || entry.name === TEST_DIR, found);
|
|
30
|
+
}
|
|
31
|
+
else if (inTestDir && TEST_FILE_PATTERN.test(entry.name)) {
|
|
32
|
+
found.push(relativePath);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/esm/vitest.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ViteUserConfig } from 'vitest/config';
|
|
2
2
|
import type { InlineConfig, ProjectConfig } from 'vitest/node';
|
|
3
|
+
import type { TierName } from './tiers.js';
|
|
4
|
+
export type { TierName } from './tiers.js';
|
|
3
5
|
export type RootTestOptions = Omit<InlineConfig, keyof ProjectConfig>;
|
|
4
6
|
export type RootOverrides = Omit<ViteUserConfig, 'test'> & {
|
|
5
7
|
test?: RootTestOptions;
|
|
@@ -13,8 +15,5 @@ export interface RootVitestConfigOptions extends VitestConfigOptions {
|
|
|
13
15
|
monorepoRoot: string;
|
|
14
16
|
}
|
|
15
17
|
type RootConfigLayers = [...(VitestConfigOptions | undefined)[], RootVitestConfigOptions];
|
|
16
|
-
declare const TIER_NAMES: readonly ["unit", "tool", "localhost", "remote"];
|
|
17
|
-
export type TierName = (typeof TIER_NAMES)[number];
|
|
18
18
|
export declare function defineVitestConfig(...layers: (VitestConfigOptions | undefined)[]): ViteUserConfig;
|
|
19
19
|
export declare function defineRootVitestConfig(...layers: RootConfigLayers): ViteUserConfig;
|
|
20
|
-
export {};
|
package/dist/esm/vitest.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { defaultExclude, mergeConfig } from 'vitest/config';
|
|
3
3
|
import { isObject } from "./helpers/type-guards.js";
|
|
4
|
+
import { ALL_TEST_PATTERNS, buildTierPatterns, TEST_COLLECTION_EXCLUDE, TIER_NAMES } from "./tiers.js";
|
|
4
5
|
import { getWorkspacePackageDirs } from "./workspace.js";
|
|
5
|
-
const TIER_NAMES = ['unit', 'tool', 'localhost', 'remote'];
|
|
6
6
|
const [RESIDUAL_TIER, ...NAMED_TIERS] = TIER_NAMES;
|
|
7
7
|
const TIERED_PATTERNS = NAMED_TIERS.flatMap(buildTierPatterns);
|
|
8
8
|
const TIER_TIMEOUT = 30_000;
|
|
9
|
-
const ALL_TEST_PATTERNS = ['**/__tests__/**/*.test.{ts,tsx}'];
|
|
10
9
|
const COVERAGE_EXCLUDE = ['**/__{fixtures,mocks,tests}__/**', '**/index.ts', '**/*.d.ts'];
|
|
11
|
-
const
|
|
10
|
+
const COLLECTION_EXCLUDE = [...new Set([...defaultExclude, ...TEST_COLLECTION_EXCLUDE.map((dir) => `**/${dir}/**`)])];
|
|
12
11
|
const PACKAGE_COVERAGE_INCLUDE = ['**/src/**/*.{ts,tsx}'];
|
|
13
12
|
const MISSING_MONOREPO_ROOT = 'defineRootVitestConfig requires `monorepoRoot`, an absolute path to the directory holding pnpm-workspace.yaml. Pass `import.meta.dirname` in the last options layer, from the root config.';
|
|
14
13
|
export function defineVitestConfig(...layers) {
|
|
@@ -60,7 +59,7 @@ function buildProjects(layers, extraExclude, projectRoot) {
|
|
|
60
59
|
extends: true,
|
|
61
60
|
...(projectRoot !== undefined && { root: projectRoot }),
|
|
62
61
|
test: {
|
|
63
|
-
exclude: [...
|
|
62
|
+
exclude: [...COLLECTION_EXCLUDE, ...exclude, ...extraExclude],
|
|
64
63
|
include,
|
|
65
64
|
name,
|
|
66
65
|
...(timeout !== undefined && { hookTimeout: timeout, testTimeout: timeout }),
|
|
@@ -84,9 +83,6 @@ function assertKnownTiers(layers) {
|
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
}
|
|
87
|
-
function buildTierPatterns(tier) {
|
|
88
|
-
return [`**/__tests__/**/*.${tier}.test.{ts,tsx}`];
|
|
89
|
-
}
|
|
90
86
|
function getWorkspaceExcludePatterns(monorepoRoot) {
|
|
91
87
|
return getWorkspacePackageDirs(monorepoRoot)
|
|
92
88
|
.map((dir) => `${path.relative(monorepoRoot, dir).split(path.sep).join('/')}/**`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/nmr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Context-aware script runner for pnpm monorepos",
|
|
6
6
|
"keywords": [
|
|
@@ -54,11 +54,12 @@
|
|
|
54
54
|
"nmr-compile": "bin/nmr-compile.js",
|
|
55
55
|
"nmr-fmt": "bin/nmr-fmt.js",
|
|
56
56
|
"nmr-report-overrides": "bin/nmr-report-overrides.js",
|
|
57
|
-
"nmr-sync-agent-files": "bin/nmr-sync-agent-files.js",
|
|
58
57
|
"nmr-taze": "bin/nmr-taze.js"
|
|
59
58
|
},
|
|
60
59
|
"files": [
|
|
61
|
-
"
|
|
60
|
+
".readyup/kits/*.js",
|
|
61
|
+
".readyup/manifest.json",
|
|
62
|
+
"agents",
|
|
62
63
|
"bin",
|
|
63
64
|
"dist"
|
|
64
65
|
],
|
|
@@ -67,10 +68,11 @@
|
|
|
67
68
|
"prettier-plugin-sh": "0.19.0",
|
|
68
69
|
"taze": "19.16.0",
|
|
69
70
|
"yaml": "2.9.0",
|
|
70
|
-
"@williamthorsen/nmr-core": "0.
|
|
71
|
+
"@williamthorsen/nmr-core": "0.9.0"
|
|
71
72
|
},
|
|
72
73
|
"devDependencies": {
|
|
73
74
|
"prettier": "3.9.6",
|
|
75
|
+
"readyup": "0.23.0",
|
|
74
76
|
"tinyglobby": "0.2.17",
|
|
75
77
|
"vitest": "4.1.10"
|
|
76
78
|
},
|
|
@@ -93,7 +95,11 @@
|
|
|
93
95
|
"publishConfig": {
|
|
94
96
|
"access": "public"
|
|
95
97
|
},
|
|
98
|
+
"codeassembly": {
|
|
99
|
+
"content": "agents"
|
|
100
|
+
},
|
|
96
101
|
"scripts": {
|
|
97
|
-
"build:post": "
|
|
102
|
+
"build:post": "rdy compile",
|
|
103
|
+
"build:pre": "rdy verify"
|
|
98
104
|
}
|
|
99
105
|
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
try {
|
|
3
|
-
await import('../dist/esm/cli-sync-agent-files.js');
|
|
4
|
-
} catch (error) {
|
|
5
|
-
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
|
6
|
-
process.stderr.write('nmr-sync-agent-files: build output not found — run `pnpm run build` first\n');
|
|
7
|
-
} else {
|
|
8
|
-
process.stderr.write(`nmr-sync-agent-files: failed to load: ${error.message}\n`);
|
|
9
|
-
}
|
|
10
|
-
process.exit(1);
|
|
11
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
|
|
2
|
-
import { check, sync } from "./commands/sync-agent-files.js";
|
|
3
|
-
import { findMonorepoRoot } from "./workspace.js";
|
|
4
|
-
const flagSchema = {
|
|
5
|
-
check: { long: '--check', type: 'boolean' },
|
|
6
|
-
};
|
|
7
|
-
const { flags } = parseArgsOrExit(process.argv.slice(2), flagSchema);
|
|
8
|
-
try {
|
|
9
|
-
runCommand(flags.check);
|
|
10
|
-
}
|
|
11
|
-
catch (error) {
|
|
12
|
-
reportError(error instanceof Error ? error.message : String(error));
|
|
13
|
-
process.exit(1);
|
|
14
|
-
}
|
|
15
|
-
function runCommand(checkOnly) {
|
|
16
|
-
const monorepoRoot = findMonorepoRoot();
|
|
17
|
-
if (!checkOnly) {
|
|
18
|
-
const { path: destinationPath, packageSpecifier, changed } = sync(monorepoRoot);
|
|
19
|
-
console.info(changed ? `✓ Wrote ${destinationPath} (${packageSpecifier})` : `✓ ${destinationPath} already up to date`);
|
|
20
|
-
process.exit(0);
|
|
21
|
-
}
|
|
22
|
-
const result = check(monorepoRoot);
|
|
23
|
-
if (result.ok) {
|
|
24
|
-
console.info('✓ .agents/nmr/AGENTS.md is in sync');
|
|
25
|
-
process.exit(0);
|
|
26
|
-
}
|
|
27
|
-
process.stderr.write(`${result.reason}\n`);
|
|
28
|
-
process.exit(1);
|
|
29
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export declare function check(destinationDir: string): CheckResult;
|
|
2
|
-
export interface SyncResult {
|
|
3
|
-
path: string;
|
|
4
|
-
packageSpecifier: string;
|
|
5
|
-
changed: boolean;
|
|
6
|
-
}
|
|
7
|
-
export declare function sync(destinationDir: string): SyncResult;
|
|
8
|
-
export type CheckResult = {
|
|
9
|
-
ok: true;
|
|
10
|
-
} | {
|
|
11
|
-
ok: false;
|
|
12
|
-
reason: string;
|
|
13
|
-
};
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { readPackageVersion } from '@williamthorsen/nmr-core';
|
|
5
|
-
const VERSION = readPackageVersion(import.meta.url);
|
|
6
|
-
const PACKAGE_NAME = '@williamthorsen/nmr';
|
|
7
|
-
const DESTINATION_RELATIVE_PATH = '.agents/nmr/AGENTS.md';
|
|
8
|
-
const SOURCE_FILENAME = 'AGENTS.md';
|
|
9
|
-
const FRONTMATTER_REGEX = /^---\n((?:.*\n)*?)---\n/;
|
|
10
|
-
export function check(destinationDir) {
|
|
11
|
-
const destinationPath = getDestinationPath(destinationDir);
|
|
12
|
-
if (!existsSync(destinationPath)) {
|
|
13
|
-
return {
|
|
14
|
-
ok: false,
|
|
15
|
-
reason: `${DESTINATION_RELATIVE_PATH} is missing. Run \`nmr sync-agent-files\`.`,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
const expectedBody = stripFrontmatter(readFileSync(getSourcePath(), 'utf8'));
|
|
19
|
-
const foundBody = stripFrontmatter(readFileSync(destinationPath, 'utf8'));
|
|
20
|
-
if (foundBody !== expectedBody) {
|
|
21
|
-
return {
|
|
22
|
-
ok: false,
|
|
23
|
-
reason: `${DESTINATION_RELATIVE_PATH} content is out of date. Run \`nmr sync-agent-files\`.`,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
return { ok: true };
|
|
27
|
-
}
|
|
28
|
-
export function sync(destinationDir) {
|
|
29
|
-
const body = stripFrontmatter(readFileSync(getSourcePath(), 'utf8'));
|
|
30
|
-
const packageSpecifier = buildPackageSpecifier();
|
|
31
|
-
const destinationPath = getDestinationPath(destinationDir);
|
|
32
|
-
if (existsSync(destinationPath) && stripFrontmatter(readFileSync(destinationPath, 'utf8')) === body) {
|
|
33
|
-
return { path: destinationPath, packageSpecifier, changed: false };
|
|
34
|
-
}
|
|
35
|
-
const output = `---\nsource: '${packageSpecifier}'\n---\n${body}`;
|
|
36
|
-
mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
37
|
-
writeFileSync(destinationPath, output, 'utf8');
|
|
38
|
-
return { path: destinationPath, packageSpecifier, changed: true };
|
|
39
|
-
}
|
|
40
|
-
function buildPackageSpecifier() {
|
|
41
|
-
return `${PACKAGE_NAME}@${VERSION}`;
|
|
42
|
-
}
|
|
43
|
-
function getDestinationPath(destinationDir) {
|
|
44
|
-
return path.join(destinationDir, DESTINATION_RELATIVE_PATH);
|
|
45
|
-
}
|
|
46
|
-
function getSourcePath() {
|
|
47
|
-
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
48
|
-
while (dir !== path.dirname(dir)) {
|
|
49
|
-
const candidate = path.join(dir, SOURCE_FILENAME);
|
|
50
|
-
if (existsSync(candidate))
|
|
51
|
-
return candidate;
|
|
52
|
-
dir = path.dirname(dir);
|
|
53
|
-
}
|
|
54
|
-
throw new Error(`Could not locate ${SOURCE_FILENAME} in any parent of ${fileURLToPath(import.meta.url)}`);
|
|
55
|
-
}
|
|
56
|
-
function stripFrontmatter(content) {
|
|
57
|
-
return content.replace(FRONTMATTER_REGEX, '');
|
|
58
|
-
}
|