@rtorcato/repo-tooling 3.33.0 → 3.35.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.
|
@@ -22,7 +22,7 @@ import { LOCKFILE_VERSION, readLockfile } from '../utils/lockfile.js';
|
|
|
22
22
|
import { compareRulesWithReference } from '../utils/reference-rules.js';
|
|
23
23
|
import { declinedInLock, getFixTargetForCheck } from './fix-targets.js';
|
|
24
24
|
import { checkAiSetup, checkBrand, checkCodeowners, checkClaudeSkills, checkCodeQL, checkCommunityHealth, checkCoverageUpload, checkDependabot, checkEditorConfig, checkFile, checkGitHooks, checkGitHubActions, checkGitLabCI, checkNestedLanguages, checkPrePushHook, checkReadmeBadges, checkRecommendedMcp, checkRequiredSkills, COMMITLINT_FILE_CHECK, } from '../../base/checks.js';
|
|
25
|
-
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
25
|
+
import { allDeps, checkAreTheTypesWrong, checkBiome, checkClaudeWorktreeSettings, checkConfigSchemaVersions, checkDocsSite, checkEnginesNode, checkExportsBuildable, checkGitDependencies, checkKnip, checkLintStaged, checkNodeVersionConsistency, checkNodeVersionPin, checkPackageJson, checkPackageManager, checkPublint, checkSemanticRelease, checkSizeLimit, checkTailwind, checkBuildApprovals, checkPnpmWorkspace, checkTreeshakeSetup, checkTurborepo, checkTypedoc, checkVerifyScript, checkVscodeExtensions, evaluateNodeVersion, FILE_CHECKS, findDocsAppDir, jsBadgeAudience, jsGitHooksProfile, readPackageJson, } from '../../languages/js/checks.js';
|
|
26
26
|
export { evaluateNodeVersion };
|
|
27
27
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
28
28
|
// Detects the broken-release-on-protected-main footgun: a workflow that runs
|
|
@@ -385,6 +385,8 @@ export async function runDoctor(dir, skillsDir) {
|
|
|
385
385
|
results.push(await checkTypedoc(targetDir, pkg));
|
|
386
386
|
results.push(await checkAreTheTypesWrong(targetDir, pkg));
|
|
387
387
|
results.push(await checkPublint(targetDir, pkg));
|
|
388
|
+
// The setup/doctor-time half of what publint catches at release time (#578).
|
|
389
|
+
results.push(await checkExportsBuildable(targetDir, pkg));
|
|
388
390
|
results.push(await checkTreeshakeSetup(targetDir, pkg));
|
|
389
391
|
results.push(await checkPnpmWorkspace(targetDir, pkg));
|
|
390
392
|
results.push(await checkBuildApprovals(targetDir, pkg));
|
|
@@ -14,9 +14,17 @@ import { shellQuote } from '../utils/shell.js';
|
|
|
14
14
|
import { isNewerVersion, resolveShippedVersion } from '../utils/version.js';
|
|
15
15
|
/**
|
|
16
16
|
* Skills this package owns the content of and keeps up to date. The loop first —
|
|
17
|
-
* it is the pipeline; the
|
|
17
|
+
* it is the pipeline; the next three are its drivers (burst, on-ramp, status).
|
|
18
|
+
* `dogfood` stands apart: it tests the consuming repo's own tooling rather than
|
|
19
|
+
* driving the loop, and it is the only one that writes nothing outside a temp dir.
|
|
18
20
|
*/
|
|
19
|
-
export const SHIPPED_SKILLS = [
|
|
21
|
+
export const SHIPPED_SKILLS = [
|
|
22
|
+
'ai-issue-loop',
|
|
23
|
+
'ai-workflow',
|
|
24
|
+
'ai-issue',
|
|
25
|
+
'ai-loop-status',
|
|
26
|
+
'dogfood',
|
|
27
|
+
];
|
|
20
28
|
/** The primary skill — the default everywhere a single name is accepted. */
|
|
21
29
|
export const SHIPPED_SKILL = 'ai-issue-loop';
|
|
22
30
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { checkFile, hookHasUncommented } from '../../base/checks.js';
|
|
4
|
+
import { realGitExec } from '../../base/git-identity.js';
|
|
4
5
|
import { CLAUDE_SETTINGS_FILE, readClaudeSettings, workspaceSymlinkDirs, worktreeSymlinkDirs, } from '../../cli/generators/agent-rules.js';
|
|
5
6
|
import { WORKSPACE_FILE, dependsOnEsbuild, familyGlob, missingPnpmSettings, } from '../../cli/generators/pnpm-workspace.js';
|
|
6
7
|
const PACKAGE = '@rtorcato/repo-tooling';
|
|
@@ -964,6 +965,202 @@ export async function checkPublint(_dir, pkg) {
|
|
|
964
965
|
hint: 'Run `npx @rtorcato/repo-tooling fix publint` to lint your package before publishing',
|
|
965
966
|
};
|
|
966
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* The file extensions each recognised build command drops in the output
|
|
970
|
+
* directory. Inferring this from an arbitrary shell command is inherently
|
|
971
|
+
* partial, so the table is deliberately small and anything missing from it
|
|
972
|
+
* makes the whole check stand down (see `emittedExtensions`) — a doctor check
|
|
973
|
+
* that cries wolf on a valid config is worse than one with a stated blind spot.
|
|
974
|
+
*/
|
|
975
|
+
const BUILD_EMITS = {
|
|
976
|
+
// tsc mirrors its input extension: .ts → .js + .d.ts. It reaches .cjs/.d.cts
|
|
977
|
+
// only from .cts sources, which is why a repo that has any stands the check
|
|
978
|
+
// down below.
|
|
979
|
+
tsc: ['.js', '.d.ts'],
|
|
980
|
+
// tsup's real set is whatever `format`/`dts` say in a config file this does
|
|
981
|
+
// not parse, so it claims the broadest set tsup could emit. A tsup build is
|
|
982
|
+
// therefore passed rather than guessed at, while `rimraf dist && tsup` still
|
|
983
|
+
// resolves instead of standing the check down.
|
|
984
|
+
tsup: ['.js', '.cjs', '.mjs', '.d.ts', '.d.cts', '.d.mts'],
|
|
985
|
+
// Cleaners emit nothing. Recognised only so the very common
|
|
986
|
+
// `rimraf dist && tsc` is still judged on its tsc half.
|
|
987
|
+
rimraf: [],
|
|
988
|
+
rm: [],
|
|
989
|
+
del: [],
|
|
990
|
+
};
|
|
991
|
+
/** Package-manager noise to strip before the command name: `pnpm exec tsup`. */
|
|
992
|
+
const RUNNER_TOKENS = new Set([
|
|
993
|
+
'npx',
|
|
994
|
+
'npm',
|
|
995
|
+
'pnpm',
|
|
996
|
+
'yarn',
|
|
997
|
+
'bun',
|
|
998
|
+
'exec',
|
|
999
|
+
'dlx',
|
|
1000
|
+
'run',
|
|
1001
|
+
'x',
|
|
1002
|
+
'-s',
|
|
1003
|
+
'--silent',
|
|
1004
|
+
]);
|
|
1005
|
+
/**
|
|
1006
|
+
* Extensions this check will judge, longest suffix first. Anything else — a
|
|
1007
|
+
* package publishing `./src/index.ts` directly, a `./dist/style.css` from a
|
|
1008
|
+
* non-JS step — is left alone rather than measured against a JS emit table.
|
|
1009
|
+
*/
|
|
1010
|
+
const ARTEFACT_EXTENSIONS = ['.d.cts', '.d.mts', '.d.ts', '.cjs', '.mjs', '.js'];
|
|
1011
|
+
function artefactExtension(p) {
|
|
1012
|
+
return ARTEFACT_EXTENSIONS.find((ext) => p.endsWith(ext)) ?? null;
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Every file path the publish contract names. Shared with the #570 regression
|
|
1016
|
+
* test so doctor and that test agree on what the contract is.
|
|
1017
|
+
*/
|
|
1018
|
+
export function declaredEntryPoints(pkg) {
|
|
1019
|
+
const found = [];
|
|
1020
|
+
const walk = (node) => {
|
|
1021
|
+
if (typeof node === 'string')
|
|
1022
|
+
found.push(node);
|
|
1023
|
+
else if (node && typeof node === 'object')
|
|
1024
|
+
Object.values(node).forEach(walk);
|
|
1025
|
+
};
|
|
1026
|
+
walk(pkg.exports);
|
|
1027
|
+
for (const field of ['main', 'module', 'types']) {
|
|
1028
|
+
const value = pkg[field];
|
|
1029
|
+
if (typeof value === 'string')
|
|
1030
|
+
found.push(value);
|
|
1031
|
+
}
|
|
1032
|
+
return [...new Set(found)];
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* What `command` puts in the output directory, or null when any part of it is
|
|
1036
|
+
* absent from BUILD_EMITS. Null means "don't know", and every caller stands
|
|
1037
|
+
* down on it rather than guessing.
|
|
1038
|
+
*/
|
|
1039
|
+
function emittedExtensions(scripts, command, seen = new Set()) {
|
|
1040
|
+
// Only a plain `a && b` chain is read. Pipes, `||`, subshells and redirects
|
|
1041
|
+
// are not something to infer an output set from.
|
|
1042
|
+
if (/[|;`<>]|\$\(/.test(command))
|
|
1043
|
+
return null;
|
|
1044
|
+
const emitted = new Set();
|
|
1045
|
+
for (const segment of command.split('&&')) {
|
|
1046
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
1047
|
+
const before = tokens.length;
|
|
1048
|
+
while (tokens.length > 0 && RUNNER_TOKENS.has(tokens[0]))
|
|
1049
|
+
tokens.shift();
|
|
1050
|
+
const name = tokens[0];
|
|
1051
|
+
if (!name)
|
|
1052
|
+
return null;
|
|
1053
|
+
// `"build": "pnpm build-cli"` — one script delegating to another. Followed
|
|
1054
|
+
// only when a runner was actually stripped, because a bare `tsc` runs the
|
|
1055
|
+
// binary even in a repo that also happens to have a script by that name.
|
|
1056
|
+
const nested = tokens.length < before ? scripts[name] : undefined;
|
|
1057
|
+
if (nested !== undefined) {
|
|
1058
|
+
if (seen.has(name))
|
|
1059
|
+
return null;
|
|
1060
|
+
seen.add(name);
|
|
1061
|
+
const inner = emittedExtensions(scripts, nested, seen);
|
|
1062
|
+
if (!inner)
|
|
1063
|
+
return null;
|
|
1064
|
+
for (const ext of inner)
|
|
1065
|
+
emitted.add(ext);
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
const known = BUILD_EMITS[name];
|
|
1069
|
+
if (!known)
|
|
1070
|
+
return null;
|
|
1071
|
+
for (const ext of known)
|
|
1072
|
+
emitted.add(ext);
|
|
1073
|
+
}
|
|
1074
|
+
return [...emitted];
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Catches a publish contract naming files the repo's own `build` cannot produce
|
|
1078
|
+
* (#578) — the class of bug that shipped `main: ./dist/index.cjs` alongside
|
|
1079
|
+
* `build: tsc`, which emits only .js and .d.ts, so every CJS consumer of the
|
|
1080
|
+
* published package got a 404 (#570). #577 fixed the preset that wrote that
|
|
1081
|
+
* pair; this catches it however else it arises, e.g. a repo editing `build` or
|
|
1082
|
+
* the contract by hand afterwards.
|
|
1083
|
+
*
|
|
1084
|
+
* Judged from the build *script*, never from `dist/` on disk: a clean checkout
|
|
1085
|
+
* has no dist/ and a stale one has whatever the last build left, so neither
|
|
1086
|
+
* answers the question. Git is what separates the two kinds of path a contract
|
|
1087
|
+
* may name — a tracked `./tooling/preset.mjs` ships as committed source and
|
|
1088
|
+
* needs no build, while an untracked `./dist/index.cjs` has to come out of
|
|
1089
|
+
* `build` or it will not exist at publish time.
|
|
1090
|
+
*
|
|
1091
|
+
* This overlaps publint, which the generated `verify` already runs — but not in
|
|
1092
|
+
* time. publint fires at release, against a dist/ just built on a machine where
|
|
1093
|
+
* it happens to work; this fires at setup/doctor time, on a checkout, before
|
|
1094
|
+
* anything is built.
|
|
1095
|
+
*/
|
|
1096
|
+
export async function checkExportsBuildable(dir, pkg, exec) {
|
|
1097
|
+
const check = 'Exports buildable';
|
|
1098
|
+
if (!pkg || !isPublishableLibrary(pkg)) {
|
|
1099
|
+
return { check, status: 'ok', detail: 'not applicable (private or no published exports)' };
|
|
1100
|
+
}
|
|
1101
|
+
const scripts = pkg.scripts ?? {};
|
|
1102
|
+
const build = scripts.build;
|
|
1103
|
+
if (!build) {
|
|
1104
|
+
return { check, status: 'ok', detail: 'no build script — nothing is expected to be generated' };
|
|
1105
|
+
}
|
|
1106
|
+
const emitted = emittedExtensions(scripts, build);
|
|
1107
|
+
if (!emitted) {
|
|
1108
|
+
return {
|
|
1109
|
+
check,
|
|
1110
|
+
status: 'ok',
|
|
1111
|
+
detail: `not checked — cannot infer what \`${build}\` emits (recognised: ${Object.keys(BUILD_EMITS).join(', ')})`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const declared = declaredEntryPoints(pkg).filter((p) => artefactExtension(p));
|
|
1115
|
+
if (declared.length === 0) {
|
|
1116
|
+
return { check, status: 'ok', detail: 'no JS entry points named in main/module/types/exports' };
|
|
1117
|
+
}
|
|
1118
|
+
if (!(await fs.pathExists(path.join(dir, '.git')))) {
|
|
1119
|
+
return {
|
|
1120
|
+
check,
|
|
1121
|
+
status: 'ok',
|
|
1122
|
+
detail: 'not checked — not a git repository, so committed assets and build output are indistinguishable',
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
// Declared paths are `./dist/index.js`; git speaks `dist/index.js`.
|
|
1126
|
+
const declaredByPath = new Map(declared.map((p) => [p.replace(/^\.\//, ''), p]));
|
|
1127
|
+
const git = exec ?? ((args) => realGitExec(args, dir));
|
|
1128
|
+
// One call answers both questions: which declared paths are committed, and
|
|
1129
|
+
// whether the repo has .cts/.mts sources. Every argument is a pathspec behind
|
|
1130
|
+
// `--`, so a package.json path beginning with `-` cannot become a flag.
|
|
1131
|
+
const listed = await git(['ls-files', '-z', '--', ...declaredByPath.keys(), '*.cts', '*.mts']);
|
|
1132
|
+
if (listed === null) {
|
|
1133
|
+
return { check, status: 'ok', detail: 'not checked — `git ls-files` is unavailable here' };
|
|
1134
|
+
}
|
|
1135
|
+
const tracked = new Set(listed.split('\0').filter(Boolean));
|
|
1136
|
+
const unbuildable = [...declaredByPath]
|
|
1137
|
+
.filter(([bare]) => !tracked.has(bare))
|
|
1138
|
+
.map(([, declaredPath]) => declaredPath)
|
|
1139
|
+
.filter((p) => !emitted.includes(artefactExtension(p)));
|
|
1140
|
+
if (unbuildable.length === 0) {
|
|
1141
|
+
return {
|
|
1142
|
+
check,
|
|
1143
|
+
status: 'ok',
|
|
1144
|
+
detail: `every declared entry point is committed or emitted by \`${build}\``,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
// tsc emits .cjs/.d.cts from a .cts input, so its narrow BUILD_EMITS entry is
|
|
1148
|
+
// wrong for a repo that has any. Checked last, and only when there is
|
|
1149
|
+
// something to report, so the common repo pays nothing for it.
|
|
1150
|
+
if ([...tracked].some((p) => !declaredByPath.has(p) && /\.[cm]ts$/.test(p))) {
|
|
1151
|
+
return {
|
|
1152
|
+
check,
|
|
1153
|
+
status: 'ok',
|
|
1154
|
+
detail: 'not checked — repo has .cts/.mts sources, whose emitted extensions this cannot infer',
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
check,
|
|
1159
|
+
status: 'drift',
|
|
1160
|
+
detail: `\`${build}\` emits ${emitted.join(', ')}; package.json names ${unbuildable.length} entry point(s) nothing produces: ${unbuildable.join(', ')}`,
|
|
1161
|
+
hint: 'The published package will 404 on those paths. Either point `build` at a bundler that emits those formats (tsup with `format: ["cjs", "esm"]`), or narrow main/module/exports to the formats the current build produces. No autofix — both directions are valid and only the package author knows which one it promises.',
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
967
1164
|
/** Who this package's README badges are for — feeds the base badge check (#309). */
|
|
968
1165
|
export function jsBadgeAudience(pkg) {
|
|
969
1166
|
if (!pkg || pkg.private === true)
|
package/package.json
CHANGED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dogfood
|
|
3
|
+
description: |
|
|
4
|
+
Run this repo's own tooling against throwaway greenfield fixtures in a temp
|
|
5
|
+
directory and report what breaks. Use when the user asks to "dogfood",
|
|
6
|
+
"test the tool on a fresh repo", "see what happens on a new project", or
|
|
7
|
+
invokes `/dogfood`. Asks what to exercise before it starts. Writes only
|
|
8
|
+
under a temp directory, never touches the repo's working tree, and never
|
|
9
|
+
deletes anything — it hands the path back for the user to remove.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# dogfood
|
|
13
|
+
|
|
14
|
+
Point the repo's own tooling at repos it has never seen and find out what it
|
|
15
|
+
does wrong. Arguments: $ARGUMENTS
|
|
16
|
+
|
|
17
|
+
**The bugs live where the tool meets content it did not write.** An empty
|
|
18
|
+
directory finds nothing — every finding from the run this skill is based on came
|
|
19
|
+
from a fixture that already had a `package.json`, a manifest, or a source file
|
|
20
|
+
with an opinion in it. Scaffolding onto nothing is the one case the authors
|
|
21
|
+
already tested.
|
|
22
|
+
|
|
23
|
+
## What this never does
|
|
24
|
+
|
|
25
|
+
- **Never writes outside its temp directory.** Not the repo's working tree, not
|
|
26
|
+
`~/.claude`, not a global git config (`git config --global` writes a stowed
|
|
27
|
+
dotfile on this machine).
|
|
28
|
+
- **Never deletes.** `rm` is often permission-blocked for an agent, and a
|
|
29
|
+
half-deleted fixture is worse than a kept one. Report the path and size at the
|
|
30
|
+
end; the user removes it when they are done reading it.
|
|
31
|
+
- **Never files an issue without asking**, and never labels one `ai-ready` —
|
|
32
|
+
that label is the human's gate into `ai-issue-loop`.
|
|
33
|
+
|
|
34
|
+
## Step 1 — ask what to exercise
|
|
35
|
+
|
|
36
|
+
Use `AskUserQuestion`. Look at the repo first so the options are real — read its
|
|
37
|
+
`package.json` `bin`, its CLI's `--help`, or its presets/templates directory —
|
|
38
|
+
then ask:
|
|
39
|
+
|
|
40
|
+
1. **What to exercise.** Offer the actual entry points found (multiSelect).
|
|
41
|
+
For a scaffolding tool that is its presets; for a linter its rule sets; for a
|
|
42
|
+
codemod its transforms.
|
|
43
|
+
2. **Fixture shape.** *Realistic pre-existing repos* (recommended — this is what
|
|
44
|
+
finds bugs) vs *empty directories* (only worth it to check the happy path
|
|
45
|
+
still works).
|
|
46
|
+
3. **What to do with findings.** *Report in the transcript only* (recommended
|
|
47
|
+
for a first run) vs *also file GitHub issues*. If they choose issues, every
|
|
48
|
+
one opens with `🤖 *Filed by an agent via dogfood.*` and carries no
|
|
49
|
+
`ai-ready` label.
|
|
50
|
+
|
|
51
|
+
Skip a question the arguments already answer.
|
|
52
|
+
|
|
53
|
+
## Step 2 — pin the build and the version
|
|
54
|
+
|
|
55
|
+
**A finding with no version stamp is unreproducible and will be argued with.**
|
|
56
|
+
Build from source, and record the commit — not the version in `package.json`,
|
|
57
|
+
which under semantic-release without `@semantic-release/git` never moves:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
ROOT=$(git rev-parse --show-toplevel)
|
|
61
|
+
cd "$ROOT" && pnpm build-cli # or whatever CLAUDE.md says the build is
|
|
62
|
+
REF=$(git -C "$ROOT" rev-parse --short HEAD)
|
|
63
|
+
DIRTY=$(git -C "$ROOT" status --porcelain | head -1)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Say in every finding: *Reproduced with `dist/` built from `<REF>`*, and mention
|
|
67
|
+
it if the tree was dirty. Invoke the built artefact directly
|
|
68
|
+
(`node "$ROOT/dist/cli/index.js" …`) — never `npx <package>`, which silently
|
|
69
|
+
tests the *published* version instead of the working tree.
|
|
70
|
+
|
|
71
|
+
## Step 3 — make the temp root, once
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
BASE="${TMPDIR:-/tmp}/dogfood-$REF-$$"
|
|
75
|
+
mkdir -p "$BASE"
|
|
76
|
+
echo "$BASE"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Pin `$BASE` as an absolute path and reuse that literal for the rest of the
|
|
80
|
+
run.** `$TMPDIR` resolves differently inside and outside the command sandbox, so
|
|
81
|
+
re-expanding it in a later command lands in a different directory and the run
|
|
82
|
+
silently splits in two. A unique suffix means a re-run never collides, which is
|
|
83
|
+
what makes never-deleting safe.
|
|
84
|
+
|
|
85
|
+
## Step 4 — build each fixture, and commit it
|
|
86
|
+
|
|
87
|
+
A fixture is a *plausible* repo, not a stub. Give it the things the tool will
|
|
88
|
+
read and be tempted to rewrite: a real name (scoped and unscoped both matter —
|
|
89
|
+
a scope is a code path), a source file, a manifest that already declares
|
|
90
|
+
something, an existing script.
|
|
91
|
+
|
|
92
|
+
Then **`git init` and commit it**. That baseline commit is the whole trick:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git -C "$BASE/$NAME" init -q
|
|
96
|
+
git -C "$BASE/$NAME" add -A
|
|
97
|
+
git -C "$BASE/$NAME" -c user.email=dogfood@local -c user.name=dogfood commit -qm baseline
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`-c` on the command, never `git config --global`. Without the commit, "what did
|
|
101
|
+
the tool overwrite" is a question nobody can answer afterwards.
|
|
102
|
+
|
|
103
|
+
## Step 5 — before, run, after
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
node "$ROOT/dist/cli/index.js" doctor --json > "$BASE/$NAME.before.json" 2>&1 || true
|
|
107
|
+
node "$ROOT/dist/cli/index.js" setup --preset <p> --yes > "$BASE/$NAME.setup.log" 2>&1; echo "exit=$?"
|
|
108
|
+
node "$ROOT/dist/cli/index.js" doctor --json > "$BASE/$NAME.after.json" 2>&1 || true
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`> file 2>&1`, in that order — `2>&1 > file` leaves stderr on the terminal, which
|
|
112
|
+
is where the interesting output usually is. `|| true` because a diagnostic
|
|
113
|
+
command exiting non-zero *is* data, not a reason to abort the run.
|
|
114
|
+
|
|
115
|
+
## Step 6 — the three checks that actually find things
|
|
116
|
+
|
|
117
|
+
Run all three on every fixture. Each one found a distinct real bug.
|
|
118
|
+
|
|
119
|
+
### a. What did it overwrite?
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
git -C "$BASE/$NAME" diff --stat
|
|
123
|
+
git -C "$BASE/$NAME" diff -- <every file the tool claims to merge rather than replace>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
A tool that says it preserves your `name`, `version`, and scripts is making a
|
|
127
|
+
promise; the diff is where you check it. This is how a preset was caught
|
|
128
|
+
renaming a package after its directory and orphaning the sources — **and the
|
|
129
|
+
build still exited 0**, because the build system ignored the now-undeclared
|
|
130
|
+
directory. A green build is not evidence.
|
|
131
|
+
|
|
132
|
+
### b. Does it pass its own check?
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
node "$ROOT/dist/cli/index.js" doctor; echo "exit=$?"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Non-zero on a repo the tool itself just created is a bug every time, however
|
|
139
|
+
small the detail. "Run setup, you're aligned" either holds or the promise is
|
|
140
|
+
worthless — and a user's CI or pre-commit hook keys on exactly that exit code.
|
|
141
|
+
Diff `before.json` against `after.json` too: a check that *stopped* running is
|
|
142
|
+
invisible in the exit code.
|
|
143
|
+
|
|
144
|
+
### c. Does the contract it wrote actually hold?
|
|
145
|
+
|
|
146
|
+
The subtlest class, and the most damaging. The tool writes a *declaration* —
|
|
147
|
+
`exports`, `main`, entry points, a target list — and separately preserves a
|
|
148
|
+
*producer* — the build script. Nothing checks that the producer emits what the
|
|
149
|
+
declaration promises. So:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
cd "$BASE/$NAME" && <the repo's own build> && ls -R dist 2>/dev/null
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
then read the manifest and confirm every path it names exists on disk. A package
|
|
156
|
+
whose `main` points at a file its own `build` cannot emit publishes green and
|
|
157
|
+
breaks every consumer.
|
|
158
|
+
|
|
159
|
+
## Step 7 — report
|
|
160
|
+
|
|
161
|
+
Per fixture, one short block: preset, exit codes, and each finding as
|
|
162
|
+
*what happened → why it matters → the evidence*. Paste the diff hunk or the JSON
|
|
163
|
+
line; a paraphrase is not a reproduction.
|
|
164
|
+
|
|
165
|
+
Rank by **silence, not severity**. A loud failure gets noticed by whoever hits
|
|
166
|
+
it. A wrong result that exits 0 does not, and that is the finding worth the
|
|
167
|
+
user's attention — lead with it.
|
|
168
|
+
|
|
169
|
+
If the user chose to file issues, one issue per finding, ≤30 lines each: what,
|
|
170
|
+
the minimal reproduction, the version stamp, and a `## What to change` section
|
|
171
|
+
that names the options rather than picking one. Attach no `ai-ready`.
|
|
172
|
+
|
|
173
|
+
**Say what you could not test.** A preset you skipped, a build you could not
|
|
174
|
+
run, an area still in flight — an unexamined corner reported as unexamined is
|
|
175
|
+
useful; one left silent reads as covered.
|
|
176
|
+
|
|
177
|
+
## Step 8 — hand back the temp directory
|
|
178
|
+
|
|
179
|
+
Last line of the run, always:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
du -sh "$BASE"
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Tell the user the path and the size, and give them the exact command:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
! rm -rf <BASE>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The `!` prefix runs it in their session. Do not run it yourself, do not offer to
|
|
192
|
+
run it later, and do not delete it on a subsequent invocation — the fixtures are
|
|
193
|
+
the evidence behind every finding, and they are worth more than the disk.
|
|
194
|
+
|
|
195
|
+
## Gotchas that cost real time
|
|
196
|
+
|
|
197
|
+
- **Shell aliases corrupt captured output.** `ls` aliased to a colouriser emits
|
|
198
|
+
escape codes into anything you parse. `unalias ls` / `unalias g` first, or use
|
|
199
|
+
`command ls`.
|
|
200
|
+
- **Ambient `GIT_DIR` / `GIT_WORK_TREE` / `GIT_CONFIG*` outrank `-C`.** If any is
|
|
201
|
+
exported, every fixture git command silently operates on the wrong repo.
|
|
202
|
+
`env -u GIT_DIR -u GIT_WORK_TREE git …` when in doubt.
|
|
203
|
+
- **A function, not a variable, for a wrapped command.** `G="env -u X git"` does
|
|
204
|
+
not word-split under zsh; define `g() { env -u X git "$@"; }`.
|
|
205
|
+
- **`pnpm install` may need the sandbox disabled** — it writes to a store outside
|
|
206
|
+
the working directory. That is a legitimate escalation for this one command.
|
|
207
|
+
- **Never reuse a fixture between runs.** A second `setup` over an
|
|
208
|
+
already-set-up repo tests idempotency, which is a different question; mixing
|
|
209
|
+
the two makes both answers unreliable.
|