@ticket-gate/engine 0.0.0-stage → 0.1.0-rc.1
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/LICENSE +202 -0
- package/README.md +81 -2
- package/bin/ticket-gate.mjs +261 -0
- package/checks/assertions-unguarded.mjs +31 -0
- package/checks/assertions.mjs +319 -0
- package/checks/cache-report.mjs +313 -0
- package/checks/docs-stale.mjs +69 -0
- package/checks/duplication.mjs +258 -0
- package/checks/ratchet-options.mjs +51 -0
- package/checks/surface.mjs +397 -0
- package/cli.mjs +379 -0
- package/commands-config.mjs +55 -0
- package/config.mjs +597 -0
- package/digest.mjs +195 -0
- package/env-name.mjs +24 -0
- package/failures.mjs +256 -0
- package/fu.mjs +458 -0
- package/graph/js.mjs +517 -0
- package/graph/load-time.mjs +309 -0
- package/graph/typescript.mjs +28 -0
- package/hooks/pre-tool-use.mjs +213 -0
- package/hooks/stop.mjs +495 -0
- package/is-main.mjs +15 -0
- package/kill-group.mjs +13 -0
- package/ledger.mjs +186 -0
- package/lock.d.mts +72 -0
- package/lock.mjs +329 -0
- package/move-proof.mjs +206 -0
- package/mutations/check.mjs +506 -0
- package/mutations/cli.mjs +361 -0
- package/mutations/exec.mjs +120 -0
- package/mutations/gate.mjs +148 -0
- package/mutations/harness.mjs +240 -0
- package/mutations/kinds.mjs +148 -0
- package/mutations/locate.mjs +71 -0
- package/mutations/project.mjs +23 -0
- package/mutations/registry.mjs +173 -0
- package/mutations/rotate.mjs +419 -0
- package/mutations/rotation-status.mjs +93 -0
- package/mutations/run.mjs +252 -0
- package/mutations/select.mjs +333 -0
- package/mutations/served.mjs +216 -0
- package/mutations/validate.mjs +86 -0
- package/mutations/verdict.mjs +159 -0
- package/nightly/cli.mjs +30 -0
- package/nightly/define.mjs +111 -0
- package/nightly/e2e-runs.mjs +73 -0
- package/nightly/gate.mjs +25 -0
- package/nightly/io.mjs +109 -0
- package/nightly/launchd.mjs +42 -0
- package/nightly/record.mjs +231 -0
- package/nightly/run.mjs +470 -0
- package/nightly/schedule.mjs +76 -0
- package/nightly/seed.mjs +78 -0
- package/nightly/status.mjs +189 -0
- package/nightly/suites.mjs +43 -0
- package/nightly-config.mjs +38 -0
- package/nightly-window.mjs +6 -0
- package/pack-hooks-all.mjs +15 -0
- package/pack-hooks.mjs +26 -0
- package/package.json +19 -4
- package/plan.mjs +272 -0
- package/precheck.mjs +90 -0
- package/project.mjs +21 -0
- package/ratchet.mjs +431 -0
- package/receipt.mjs +34 -0
- package/review-map.mjs +432 -0
- package/root.mjs +33 -0
- package/run.mjs +291 -0
- package/runners/playwright.mjs +132 -0
- package/score/adapters/stryker.mjs +111 -0
- package/score/base.mjs +82 -0
- package/score/cli.mjs +182 -0
- package/score/judge.mjs +107 -0
- package/score/lines.mjs +40 -0
- package/score/schema.mjs +68 -0
- package/score/survivors.mjs +103 -0
- package/score/targets.mjs +34 -0
- package/score-config.mjs +120 -0
- package/selection/check.mjs +45 -0
- package/selection/cli.mjs +267 -0
- package/selection/define.mjs +55 -0
- package/selection/for.mjs +235 -0
- package/selection/graphs.mjs +38 -0
- package/selection/project.mjs +11 -0
- package/selection/select.mjs +159 -0
- package/selection/units.mjs +646 -0
- package/selection/value-rules.mjs +142 -0
- package/selection-config.mjs +76 -0
- package/serve.mjs +142 -0
- package/summary.mjs +34 -0
- package/tree.mjs +211 -0
package/move-proof.mjs
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Move proof for move-only tickets. Usage: <moveCheck command> [--base=REF]
|
|
2
|
+
//
|
|
3
|
+
// Compares the lines a branch adds with the lines it removes, outside tests,
|
|
4
|
+
// docs and generated files (excludedFromSize in tools/gate/review-map.mjs). Every
|
|
5
|
+
// added line must match a removed line (a multiset: one removed line pays for
|
|
6
|
+
// one added line), after trimming and dropping a leading `export `. Blank lines
|
|
7
|
+
// and import or re-export statements are exempt. `unmatched` is the count that
|
|
8
|
+
// does not match: 0 means the diff only moved code. the Stop hook
|
|
9
|
+
// reads it from the receipt for a contract's `oversize-accepted: move-only`.
|
|
10
|
+
//
|
|
11
|
+
// Exit: 0 when nothing is unmatched, 1 when something is, 2 on a bad flag or
|
|
12
|
+
// a git error. Dependency-free: node and git only.
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { DEFAULT_COMMANDS } from './config.mjs';
|
|
17
|
+
import { excludedFromSize } from './review-map.mjs';
|
|
18
|
+
import { ROOT } from './root.mjs';
|
|
19
|
+
import { baseRef } from './tree.mjs';
|
|
20
|
+
|
|
21
|
+
const git = (root, args) =>
|
|
22
|
+
execFileSync('git', ['-c', 'core.quotepath=off', ...args], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
maxBuffer: 1 << 28,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Trim; strip a leading `export ` unless it opens a re-export (`export {`, `export *`). */
|
|
29
|
+
export function normalise(text) {
|
|
30
|
+
return text.trim().replace(/^export (?![{*])/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const IMPORT_START =
|
|
34
|
+
/^(?:import(?:\s+|\s*[{'"*])|export\s*(?:type\s+)?[{*])/;
|
|
35
|
+
|
|
36
|
+
/** Only JS/TS has import statements; every line of any other file is code. */
|
|
37
|
+
export const JS_FILE = /\.(?:[cm]?[jt]sx?|mts|cts)$/;
|
|
38
|
+
|
|
39
|
+
/** 1-based numbers of every line inside an import or re-export statement. */
|
|
40
|
+
export function importLineNumbers(lines) {
|
|
41
|
+
const inside = new Set();
|
|
42
|
+
let open = false;
|
|
43
|
+
lines.forEach((raw, i) => {
|
|
44
|
+
const t = raw.trim().replace(/(['"`;])\s*\/\/.*$/, '$1');
|
|
45
|
+
if (!open && !IMPORT_START.test(t)) return;
|
|
46
|
+
open = true;
|
|
47
|
+
inside.add(i + 1);
|
|
48
|
+
if (
|
|
49
|
+
/;$/.test(t) ||
|
|
50
|
+
/from\s*['"][^'"]+['"]$/.test(t) ||
|
|
51
|
+
/^import\s*['"][^'"]+['"]$/.test(t)
|
|
52
|
+
)
|
|
53
|
+
open = false;
|
|
54
|
+
});
|
|
55
|
+
return inside;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Multiset match: the added entries no removed line pays for, in input order. */
|
|
59
|
+
export function matchMoves(added, removed) {
|
|
60
|
+
const pool = new Map();
|
|
61
|
+
for (const r of removed) {
|
|
62
|
+
const key = normalise(r);
|
|
63
|
+
pool.set(key, (pool.get(key) ?? 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
const unmatched = [];
|
|
66
|
+
for (const a of added) {
|
|
67
|
+
const key = normalise(a.text);
|
|
68
|
+
const left = pool.get(key) ?? 0;
|
|
69
|
+
if (left === 0) unmatched.push(a);
|
|
70
|
+
else pool.set(key, left - 1);
|
|
71
|
+
}
|
|
72
|
+
return unmatched;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Added `{path, line, text}` and removed `{path, line, text}` from a -U0 diff. */
|
|
76
|
+
function parseDiff(diff) {
|
|
77
|
+
const added = [];
|
|
78
|
+
const removed = [];
|
|
79
|
+
let oldPath = null;
|
|
80
|
+
let newPath = null;
|
|
81
|
+
let oldLine = 0;
|
|
82
|
+
let newLine = 0;
|
|
83
|
+
let inHunk = false;
|
|
84
|
+
for (const l of diff.split('\n')) {
|
|
85
|
+
if (l.startsWith('diff --git ')) {
|
|
86
|
+
inHunk = false;
|
|
87
|
+
oldPath = newPath = null;
|
|
88
|
+
} else if (!inHunk && l.startsWith('--- ')) {
|
|
89
|
+
oldPath = l === '--- /dev/null' ? null : l.slice(6);
|
|
90
|
+
} else if (!inHunk && l.startsWith('+++ ')) {
|
|
91
|
+
newPath = l === '+++ /dev/null' ? null : l.slice(6);
|
|
92
|
+
} else if (l.startsWith('@@')) {
|
|
93
|
+
const m = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
|
|
94
|
+
if (m) {
|
|
95
|
+
oldLine = Number(m[1]);
|
|
96
|
+
newLine = Number(m[2]);
|
|
97
|
+
inHunk = true;
|
|
98
|
+
}
|
|
99
|
+
} else if (inHunk && l.startsWith('+') && newPath) {
|
|
100
|
+
added.push({ path: newPath, line: newLine++, text: l.slice(1) });
|
|
101
|
+
} else if (inHunk && l.startsWith('-') && oldPath) {
|
|
102
|
+
removed.push({ path: oldPath, line: oldLine++, text: l.slice(1) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { added, removed };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isBinary = (text) => text.includes('\0');
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Full proof over `git diff -U0` (base to working tree) plus untracked files.
|
|
112
|
+
* Import statements are classified against the whole file, never hunk text.
|
|
113
|
+
*/
|
|
114
|
+
export function moveProof(base, root = ROOT) {
|
|
115
|
+
const { added: diffAdded, removed: diffRemoved } = parseDiff(
|
|
116
|
+
git(root, ['diff', '-U0', '--no-renames', '--no-color', base]),
|
|
117
|
+
);
|
|
118
|
+
const untracked = git(root, [
|
|
119
|
+
'ls-files',
|
|
120
|
+
'-z',
|
|
121
|
+
'--others',
|
|
122
|
+
'--exclude-standard',
|
|
123
|
+
])
|
|
124
|
+
.split('\0')
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
const counted = (e) => !excludedFromSize(e.path);
|
|
127
|
+
const memo = new Map();
|
|
128
|
+
const imports = (key, load) => {
|
|
129
|
+
if (!memo.has(key)) {
|
|
130
|
+
let text = '';
|
|
131
|
+
try {
|
|
132
|
+
text = load();
|
|
133
|
+
} catch {
|
|
134
|
+
/* file gone or unreadable: nothing exempt */
|
|
135
|
+
}
|
|
136
|
+
const path = key.slice(key.indexOf(':') + 1);
|
|
137
|
+
memo.set(
|
|
138
|
+
key,
|
|
139
|
+
JS_FILE.test(path) ? importLineNumbers(text.split('\n')) : new Set(),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return memo.get(key);
|
|
143
|
+
};
|
|
144
|
+
const keep = (e, set) => e.text.trim() !== '' && !set.has(e.line);
|
|
145
|
+
const added = diffAdded.filter(counted).filter((e) =>
|
|
146
|
+
keep(
|
|
147
|
+
e,
|
|
148
|
+
imports(`w:${e.path}`, () => readFileSync(join(root, e.path), 'utf8')),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
for (const path of untracked.filter((p) => !excludedFromSize(p))) {
|
|
152
|
+
let text;
|
|
153
|
+
try {
|
|
154
|
+
text = readFileSync(join(root, path), 'utf8');
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (isBinary(text)) continue;
|
|
159
|
+
const set = imports(`w:${path}`, () => text);
|
|
160
|
+
text.split('\n').forEach((t, i) => {
|
|
161
|
+
const e = { path, line: i + 1, text: t };
|
|
162
|
+
if (keep(e, set)) added.push(e);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const removed = diffRemoved
|
|
166
|
+
.filter(counted)
|
|
167
|
+
.filter((e) =>
|
|
168
|
+
keep(
|
|
169
|
+
e,
|
|
170
|
+
imports(`b:${e.path}`, () => git(root, ['show', `${base}:${e.path}`])),
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
.map((e) => e.text);
|
|
174
|
+
const lines = matchMoves(added, removed).sort((a, b) =>
|
|
175
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line,
|
|
176
|
+
);
|
|
177
|
+
return { unmatched: lines.length, base, lines };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The CLI body; `moveCheck` is the project's `commands.moveCheck`, named in the usage line. */
|
|
181
|
+
export function main({
|
|
182
|
+
moveCheck = DEFAULT_COMMANDS.moveCheck,
|
|
183
|
+
args = process.argv.slice(2),
|
|
184
|
+
} = {}) {
|
|
185
|
+
const stray = args.find((a) => !a.startsWith('--base='));
|
|
186
|
+
if (stray) {
|
|
187
|
+
console.error(
|
|
188
|
+
`${moveCheck.split(' ').pop()}: unknown argument ${stray}. Usage: ${moveCheck} [--base=REF]`,
|
|
189
|
+
);
|
|
190
|
+
process.exit(2);
|
|
191
|
+
}
|
|
192
|
+
const baseArg = args[0]?.slice('--base='.length);
|
|
193
|
+
let proof;
|
|
194
|
+
try {
|
|
195
|
+
proof = moveProof(baseArg || baseRef());
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(
|
|
198
|
+
`move:check: ${e.message.split('\n')[0]}. Check the ref with \`git rev-parse <ref>\` and pass --base=<ref>.`,
|
|
199
|
+
);
|
|
200
|
+
process.exit(2);
|
|
201
|
+
}
|
|
202
|
+
for (const l of proof.lines)
|
|
203
|
+
console.log(`${l.path}:${l.line} ${l.text.trim()}`);
|
|
204
|
+
console.log(`unmatched: ${proof.unmatched}`);
|
|
205
|
+
process.exit(proof.unmatched === 0 ? 0 : 1);
|
|
206
|
+
}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
readFileSync,
|
|
4
|
+
rmdirSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
CARET_FILTER,
|
|
13
|
+
CARET_HINT,
|
|
14
|
+
FILTER_RE,
|
|
15
|
+
KEY_RE,
|
|
16
|
+
areaFor,
|
|
17
|
+
compareKeys,
|
|
18
|
+
loadRegistry,
|
|
19
|
+
locate,
|
|
20
|
+
registryAt,
|
|
21
|
+
} from './registry.mjs';
|
|
22
|
+
import { ROOT } from '../root.mjs';
|
|
23
|
+
import { checkProtects } from './kinds.mjs';
|
|
24
|
+
import { projectSetup } from './project.mjs';
|
|
25
|
+
|
|
26
|
+
export const PROBLEM_KINDS = [
|
|
27
|
+
'stale',
|
|
28
|
+
'misfiled',
|
|
29
|
+
'unsorted',
|
|
30
|
+
'vanished',
|
|
31
|
+
'retired',
|
|
32
|
+
'duplicate',
|
|
33
|
+
'shape',
|
|
34
|
+
'legacy',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const isStr = (v) => typeof v === 'string' && v !== '';
|
|
38
|
+
const git = (root, args) =>
|
|
39
|
+
execFileSync('git', args, {
|
|
40
|
+
cwd: root,
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
43
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function readText(root, rel) {
|
|
47
|
+
try {
|
|
48
|
+
return readFileSync(join(root, rel), 'utf8');
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A readFile for a kind's shape and stale rules: the text of a repo file, throwing when it is missing. */
|
|
55
|
+
const readerOf = (root) => (rel) => {
|
|
56
|
+
const text = readText(root, rel);
|
|
57
|
+
if (text === null) throw new Error(`${rel} does not exist`);
|
|
58
|
+
return text;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function ticketFromBranch(branch) {
|
|
62
|
+
const m = /^[a-z]+\/([A-Z][A-Z0-9]*(?:-\d+[a-z0-9]*)?)(?=-|$)/.exec(
|
|
63
|
+
branch ?? '',
|
|
64
|
+
);
|
|
65
|
+
return m ? m[1] : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** An area file's bytes: keys in compareKeys order, fields in `setup.fieldOrder`. */
|
|
69
|
+
export function serializeArea(obj, setup = projectSetup()) {
|
|
70
|
+
const order = setup.fieldOrder;
|
|
71
|
+
const rank = (f) => {
|
|
72
|
+
const i = order.indexOf(f);
|
|
73
|
+
return i === -1 ? order.length : i;
|
|
74
|
+
};
|
|
75
|
+
const sorted = {};
|
|
76
|
+
for (const key of Object.keys(obj).sort(compareKeys)) {
|
|
77
|
+
const e = obj[key];
|
|
78
|
+
sorted[key] =
|
|
79
|
+
e && typeof e === 'object' && !Array.isArray(e)
|
|
80
|
+
? Object.fromEntries(
|
|
81
|
+
Object.keys(e)
|
|
82
|
+
.sort((a, b) => rank(a) - rank(b))
|
|
83
|
+
.map((f) => [f, e[f]]),
|
|
84
|
+
)
|
|
85
|
+
: e;
|
|
86
|
+
}
|
|
87
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolveBase(root, base) {
|
|
91
|
+
const ref = base ?? git(root, ['merge-base', 'main', 'HEAD']).trim();
|
|
92
|
+
return git(root, ['rev-parse', '--verify', `${ref}^{commit}`]).trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeAreaFor(entry, setup) {
|
|
96
|
+
try {
|
|
97
|
+
return areaFor(entry, setup);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Why an entry's shape is wrong, one string each. */
|
|
104
|
+
function shapeReasons(key, e, keys, root, setup) {
|
|
105
|
+
const out = [];
|
|
106
|
+
if (!KEY_RE.test(key)) out.push('key must look like <ID>/<Mn>');
|
|
107
|
+
if (!e || typeof e !== 'object' || Array.isArray(e))
|
|
108
|
+
return [...out, 'entry must be an object'];
|
|
109
|
+
const kind = setup.kinds.get(e.kind);
|
|
110
|
+
if (!kind) {
|
|
111
|
+
const names = [...setup.kinds.keys()];
|
|
112
|
+
return [
|
|
113
|
+
...out,
|
|
114
|
+
`kind must be ${names.slice(0, -1).join(', ')} or ${names.at(-1)}`,
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
out.push(...kind.shape(e, readerOf(root)));
|
|
118
|
+
if (kind.runs && isStr(e.protects)) {
|
|
119
|
+
const why = checkProtects(e.protects);
|
|
120
|
+
if (why) out.push(why);
|
|
121
|
+
}
|
|
122
|
+
if (kind.runs && isStr(e.test)) {
|
|
123
|
+
if (CARET_FILTER.test(e.test)) out.push(CARET_HINT);
|
|
124
|
+
const filtering = FILTER_RE.test(e.test);
|
|
125
|
+
if (filtering && !isStr(e.testName))
|
|
126
|
+
out.push('testName is required because test filters by name');
|
|
127
|
+
if (!filtering && e.testName !== undefined)
|
|
128
|
+
out.push('testName is set but test has no name filter');
|
|
129
|
+
}
|
|
130
|
+
for (const k of setup.kinds.values()) out.push(...(k.strays?.(e) ?? []));
|
|
131
|
+
if (
|
|
132
|
+
e.retired &&
|
|
133
|
+
typeof e.retired === 'object' &&
|
|
134
|
+
e.retired.supersededBy !== undefined &&
|
|
135
|
+
!keys.has(e.retired.supersededBy)
|
|
136
|
+
)
|
|
137
|
+
out.push(`retired.supersededBy ${e.retired.supersededBy} is not a key`);
|
|
138
|
+
if (e.reaimed !== undefined) {
|
|
139
|
+
if (
|
|
140
|
+
!Array.isArray(e.reaimed) ||
|
|
141
|
+
e.reaimed.some((r) => !isStr(r?.by) || !isStr(r?.why))
|
|
142
|
+
)
|
|
143
|
+
out.push('reaimed must be a list of { by, why }');
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A patch entry whose file is gone or whose find does not occur exactly once. Checked for new keys too once their file exists (ten dead finds once reached review). */
|
|
149
|
+
function findReasons(root, entry, { fileOptional = false } = {}) {
|
|
150
|
+
const out = [];
|
|
151
|
+
if (entry.kind === 'patch' && isStr(entry.file) && isStr(entry.find)) {
|
|
152
|
+
const src = readText(root, entry.file);
|
|
153
|
+
// A new key may name a file a later slice of its ticket adds (CM-10).
|
|
154
|
+
if (src === null && fileOptional) return out;
|
|
155
|
+
if (src === null) out.push(`file ${entry.file} does not exist`);
|
|
156
|
+
else {
|
|
157
|
+
const hit = locate(src, entry.find, entry.within);
|
|
158
|
+
if (hit.anchorCount !== undefined)
|
|
159
|
+
out.push(
|
|
160
|
+
`within anchor occurs ${hit.anchorCount} times in ${entry.file}`,
|
|
161
|
+
);
|
|
162
|
+
else if (hit.count !== 1)
|
|
163
|
+
out.push(`find occurs ${hit.count} times in ${entry.file}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Full-check failures of a live entry, one string each (no key prefix). */
|
|
170
|
+
function staleReasons(root, entry, setup) {
|
|
171
|
+
const out = findReasons(root, entry);
|
|
172
|
+
const kind = setup.kinds.get(entry.kind);
|
|
173
|
+
if (kind?.runs && isStr(entry.testFile)) {
|
|
174
|
+
const testText = readText(root, entry.testFile);
|
|
175
|
+
if (testText === null)
|
|
176
|
+
out.push(`testFile ${entry.testFile} does not exist`);
|
|
177
|
+
else if (isStr(entry.testName)) {
|
|
178
|
+
if (!testText.includes(entry.testName))
|
|
179
|
+
out.push(`testName "${entry.testName}" not found in ${entry.testFile}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (kind?.stale) out.push(...kind.stale(entry, readerOf(root)));
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const STALE_TAIL = ' - re-aim (file/find/within) or retire with a citation';
|
|
187
|
+
|
|
188
|
+
export function checkRegistry(opts, setup = projectSetup()) {
|
|
189
|
+
const root = opts?.root ?? ROOT;
|
|
190
|
+
let baseSha;
|
|
191
|
+
try {
|
|
192
|
+
baseSha = resolveBase(root, opts?.base);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return {
|
|
195
|
+
code: 2,
|
|
196
|
+
lines: [
|
|
197
|
+
`check:mutations: cannot resolve the base (${String(err.message).split('\n')[0]}); pass --base=<sha>`,
|
|
198
|
+
],
|
|
199
|
+
problems: [],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const reg = loadRegistry(root, setup);
|
|
203
|
+
const baseReg = registryAt(baseSha, root, setup);
|
|
204
|
+
const isNew = (key) => !baseReg.entries.has(key);
|
|
205
|
+
const problems = [];
|
|
206
|
+
const lines = [];
|
|
207
|
+
const report = (kind, key, path, text) => {
|
|
208
|
+
problems.push({ kind, key, path, line: 0 });
|
|
209
|
+
lines.push(`${kind}: ${text}`);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
for (const err of reg.errors) {
|
|
213
|
+
const dup = /^(.+): duplicate key (\S+) \(also in (.+)\)$/.exec(err);
|
|
214
|
+
if (dup)
|
|
215
|
+
report(
|
|
216
|
+
'duplicate',
|
|
217
|
+
dup[2],
|
|
218
|
+
dup[1],
|
|
219
|
+
`${dup[2]} in ${dup[3]} and ${dup[1]}`,
|
|
220
|
+
);
|
|
221
|
+
else report('shape', err, err.split(':')[0], err);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const keys = new Set(reg.entries.keys());
|
|
225
|
+
const perFile = new Map();
|
|
226
|
+
for (const [key, { path, entry }] of reg.entries) {
|
|
227
|
+
perFile.set(path, [...(perFile.get(path) ?? []), key]);
|
|
228
|
+
for (const why of shapeReasons(key, entry, keys, root, setup))
|
|
229
|
+
report('shape', key, path, `${key} ${why}`);
|
|
230
|
+
const ok = entry && typeof entry === 'object';
|
|
231
|
+
if (
|
|
232
|
+
ok &&
|
|
233
|
+
entry.retired !== undefined &&
|
|
234
|
+
!(isStr(entry.retired?.by) && isStr(entry.retired?.why))
|
|
235
|
+
)
|
|
236
|
+
report('retired', key, path, `${key} needs retired.by and retired.why`);
|
|
237
|
+
const want = ok ? safeAreaFor(entry, setup) : null;
|
|
238
|
+
if (want && want !== path)
|
|
239
|
+
report('misfiled', key, path, `${key} in ${path} - move to ${want}`);
|
|
240
|
+
// A new key is shape-checked, plus its find must occur once in a file that exists.
|
|
241
|
+
if (isNew(key) && ok && !entry.retired)
|
|
242
|
+
for (const why of findReasons(root, entry, { fileOptional: true }))
|
|
243
|
+
report('stale', key, path, `${key} ${why}${STALE_TAIL}`);
|
|
244
|
+
if (isNew(key)) continue;
|
|
245
|
+
if (!ok || entry.retired) continue;
|
|
246
|
+
for (const why of staleReasons(root, entry, setup))
|
|
247
|
+
report('stale', key, path, `${key} ${why}${STALE_TAIL}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const [path, list] of perFile) {
|
|
251
|
+
const bad = list.findIndex(
|
|
252
|
+
(k, i) => i > 0 && compareKeys(list[i - 1], k) > 0,
|
|
253
|
+
);
|
|
254
|
+
if (bad !== -1)
|
|
255
|
+
report(
|
|
256
|
+
'unsorted',
|
|
257
|
+
list[bad],
|
|
258
|
+
path,
|
|
259
|
+
`${path} - first out of order: ${list[bad]}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const [key, { path }] of baseReg.entries)
|
|
264
|
+
if (!keys.has(key))
|
|
265
|
+
report(
|
|
266
|
+
'vanished',
|
|
267
|
+
key,
|
|
268
|
+
path,
|
|
269
|
+
`${key} was in ${path} at ${baseSha.slice(0, 7)} - restore it and retire it with a citation`,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
for (const path of setup.legacy?.files(root) ?? [])
|
|
273
|
+
report(
|
|
274
|
+
'legacy',
|
|
275
|
+
path,
|
|
276
|
+
path,
|
|
277
|
+
`${path} - move its entries into ${setup.dir}/ (README)`,
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const all = [...reg.entries.values()];
|
|
281
|
+
const retired = all.filter((v) => v.entry?.retired).length;
|
|
282
|
+
lines.push(
|
|
283
|
+
`check:mutations: ${all.length} entries (${all.length - retired} live, ${retired} retired) in ${perFile.size} files, ${problems.length} problem(s)`,
|
|
284
|
+
);
|
|
285
|
+
return { code: problems.length ? 1 : 0, lines, problems };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** { renames: Map<old, new>, added: string[] } from tracked changes against the base. */
|
|
289
|
+
function diffAgainst(root, baseSha) {
|
|
290
|
+
const parts = git(root, ['diff', '-M', '--name-status', '-z', baseSha])
|
|
291
|
+
.split('\0')
|
|
292
|
+
.filter((p, i, a) => p !== '' || i < a.length - 1);
|
|
293
|
+
const renames = new Map();
|
|
294
|
+
const added = [];
|
|
295
|
+
for (let i = 0; i < parts.length;) {
|
|
296
|
+
const status = parts[i];
|
|
297
|
+
if (status === '') break;
|
|
298
|
+
if (status[0] === 'R' || status[0] === 'C') {
|
|
299
|
+
if (status[0] === 'R') renames.set(parts[i + 1], parts[i + 2]);
|
|
300
|
+
i += 3;
|
|
301
|
+
} else {
|
|
302
|
+
if (status[0] === 'A') added.push(parts[i + 1]);
|
|
303
|
+
i += 2;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { renames, added };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function repair(root, diff, ticket, key, entry, setup) {
|
|
310
|
+
const leave = (why, hint) => ({
|
|
311
|
+
left: { key, why, ...(hint ? { hint } : {}) },
|
|
312
|
+
});
|
|
313
|
+
let next = structuredClone(entry);
|
|
314
|
+
const notes = [];
|
|
315
|
+
const moved = (p) => diff.renames.get(p);
|
|
316
|
+
if (next.kind === 'patch' && moved(next.file)) {
|
|
317
|
+
notes.push([next.file, moved(next.file)]);
|
|
318
|
+
next.file = moved(next.file);
|
|
319
|
+
}
|
|
320
|
+
if (isStr(next.testFile) && moved(next.testFile)) {
|
|
321
|
+
notes.push([next.testFile, moved(next.testFile)]);
|
|
322
|
+
next.testFile = moved(next.testFile);
|
|
323
|
+
}
|
|
324
|
+
const kindRepair = setup.kinds.get(next.kind)?.repair;
|
|
325
|
+
if (kindRepair) {
|
|
326
|
+
const r = kindRepair(next, diff);
|
|
327
|
+
if (r.left) return leave(r.left);
|
|
328
|
+
next = r.entry;
|
|
329
|
+
notes.push(...(r.notes ?? []));
|
|
330
|
+
}
|
|
331
|
+
if (!notes.length) {
|
|
332
|
+
let hint;
|
|
333
|
+
if (next.kind === 'patch')
|
|
334
|
+
for (const p of diff.added) {
|
|
335
|
+
const src = readText(root, p);
|
|
336
|
+
if (src !== null && locate(src, next.find, next.within).count === 1) {
|
|
337
|
+
hint = `find occurs once in added ${p}`;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return leave(
|
|
342
|
+
hint
|
|
343
|
+
? 'no git rename covers it; the guard may have been copied'
|
|
344
|
+
: 'no git rename covers it (git add a moved file so it shows as a rename)',
|
|
345
|
+
hint,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (next.kind === 'patch') {
|
|
349
|
+
const src = readText(root, next.file);
|
|
350
|
+
if (src === null) return leave(`file ${next.file} does not exist`);
|
|
351
|
+
const located = locate(src, next.find, next.within);
|
|
352
|
+
const why = `find occurs ${located.count} times in ${next.file}`;
|
|
353
|
+
if (located.count !== 1) return leave(why);
|
|
354
|
+
}
|
|
355
|
+
const rest = staleReasons(root, next, setup);
|
|
356
|
+
if (rest.length) return leave(rest[0]);
|
|
357
|
+
next.reaimed = [
|
|
358
|
+
...(next.reaimed ?? []),
|
|
359
|
+
...notes.map(([a, b]) => ({ by: ticket, why: `moved: ${a} -> ${b}` })),
|
|
360
|
+
];
|
|
361
|
+
return { entry: next };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function pruneEmptyDirs(root, rel, registryDir) {
|
|
365
|
+
let dir = dirname(rel);
|
|
366
|
+
while (dir.startsWith(`${registryDir}/`)) {
|
|
367
|
+
try {
|
|
368
|
+
rmdirSync(join(root, dir));
|
|
369
|
+
} catch {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
dir = dirname(dir);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function fixRegistry(opts, setup = projectSetup()) {
|
|
377
|
+
const root = opts?.root ?? ROOT;
|
|
378
|
+
let ticket = opts?.ticket;
|
|
379
|
+
if (!ticket) {
|
|
380
|
+
let branch = '';
|
|
381
|
+
try {
|
|
382
|
+
branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
383
|
+
} catch {
|
|
384
|
+
/* no branch */
|
|
385
|
+
}
|
|
386
|
+
ticket = ticketFromBranch(branch);
|
|
387
|
+
}
|
|
388
|
+
if (!ticket)
|
|
389
|
+
return {
|
|
390
|
+
code: 2,
|
|
391
|
+
lines: [
|
|
392
|
+
'check:mutations --fix: no ticket id; pass --ticket=<ID> (the branch name does not carry one)',
|
|
393
|
+
],
|
|
394
|
+
fixed: [],
|
|
395
|
+
left: [],
|
|
396
|
+
};
|
|
397
|
+
let baseSha;
|
|
398
|
+
try {
|
|
399
|
+
baseSha = resolveBase(root, opts?.base);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
return {
|
|
402
|
+
code: 2,
|
|
403
|
+
lines: [
|
|
404
|
+
`check:mutations --fix: cannot resolve the base (${String(err.message).split('\n')[0]}); pass --base=<sha>`,
|
|
405
|
+
],
|
|
406
|
+
fixed: [],
|
|
407
|
+
left: [],
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
const reg = loadRegistry(root, setup);
|
|
411
|
+
const baseReg = registryAt(baseSha, root, setup);
|
|
412
|
+
const isNew = (key) => !baseReg.entries.has(key);
|
|
413
|
+
const diff = diffAgainst(root, baseSha);
|
|
414
|
+
const keys = new Set(reg.entries.keys());
|
|
415
|
+
const fixed = [];
|
|
416
|
+
const left = [];
|
|
417
|
+
const changes = [];
|
|
418
|
+
|
|
419
|
+
for (const [key, { path, entry }] of reg.entries) {
|
|
420
|
+
if (isNew(key)) continue;
|
|
421
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
422
|
+
if (entry.retired || setup.kinds.get(entry.kind)?.runs === false) continue;
|
|
423
|
+
if (shapeReasons(key, entry, keys, root, setup).length) continue;
|
|
424
|
+
const want = safeAreaFor(entry, setup);
|
|
425
|
+
if (!staleReasons(root, entry, setup).length && want === path) continue;
|
|
426
|
+
const r = repair(root, diff, ticket, key, entry, setup);
|
|
427
|
+
if (r.left) {
|
|
428
|
+
left.push(r.left);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
const to = areaFor(r.entry, setup);
|
|
432
|
+
fixed.push({ key, from: path, to });
|
|
433
|
+
changes.push({ key, from: path, to, entry: r.entry });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const contents = new Map();
|
|
437
|
+
const fileObj = (p) => {
|
|
438
|
+
if (!contents.has(p))
|
|
439
|
+
contents.set(
|
|
440
|
+
p,
|
|
441
|
+
Object.fromEntries(
|
|
442
|
+
[...reg.entries]
|
|
443
|
+
.filter(([, v]) => v.path === p)
|
|
444
|
+
.map(([k, v]) => [k, v.entry]),
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
return contents.get(p);
|
|
448
|
+
};
|
|
449
|
+
for (const c of changes) {
|
|
450
|
+
delete fileObj(c.from)[c.key];
|
|
451
|
+
fileObj(c.to)[c.key] = c.entry;
|
|
452
|
+
}
|
|
453
|
+
for (const [p, obj] of contents) {
|
|
454
|
+
if (Object.keys(obj).length === 0) {
|
|
455
|
+
rmSync(join(root, p), { force: true });
|
|
456
|
+
pruneEmptyDirs(root, p, setup.dir);
|
|
457
|
+
} else {
|
|
458
|
+
mkdirSync(dirname(join(root, p)), { recursive: true });
|
|
459
|
+
writeFileSync(join(root, p), serializeArea(obj, setup));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const lines = [
|
|
464
|
+
...fixed.map((f) => `fixed: ${f.key} ${f.from} -> ${f.to}`),
|
|
465
|
+
...left.map(
|
|
466
|
+
(l) => `left: ${l.key} ${l.why}${l.hint ? ` (${l.hint})` : ''}`,
|
|
467
|
+
),
|
|
468
|
+
`check:mutations --fix: ${fixed.length} re-aimed, ${left.length} left`,
|
|
469
|
+
];
|
|
470
|
+
const check = checkRegistry({ root, base: baseSha }, setup);
|
|
471
|
+
return {
|
|
472
|
+
code: check.code === 0 ? 0 : check.code === 2 ? 2 : 1,
|
|
473
|
+
lines: [...lines, ...check.lines],
|
|
474
|
+
fixed,
|
|
475
|
+
left,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function main(argv, setup = projectSetup()) {
|
|
480
|
+
const arg = (name) => {
|
|
481
|
+
const hit = argv.find((a) => a.startsWith(`--${name}=`));
|
|
482
|
+
return hit ? hit.slice(name.length + 3) : undefined;
|
|
483
|
+
};
|
|
484
|
+
const unknown = argv.filter(
|
|
485
|
+
(a) =>
|
|
486
|
+
a !== '--fix' && !a.startsWith('--ticket=') && !a.startsWith('--base='),
|
|
487
|
+
);
|
|
488
|
+
if (unknown.length) {
|
|
489
|
+
console.error(
|
|
490
|
+
`check:mutations: unknown argument ${unknown[0]}. Usage: ${setup.commands.checkMutationsScript} [--fix] [--ticket=<ID>] [--base=<sha>]`,
|
|
491
|
+
);
|
|
492
|
+
return 2;
|
|
493
|
+
}
|
|
494
|
+
const opts = { root: ROOT, base: arg('base'), ticket: arg('ticket') };
|
|
495
|
+
const r = argv.includes('--fix')
|
|
496
|
+
? fixRegistry(opts, setup)
|
|
497
|
+
: checkRegistry(opts, setup);
|
|
498
|
+
for (const l of r.lines) console.log(l);
|
|
499
|
+
if (r.code === 1 && !argv.includes('--fix'))
|
|
500
|
+
console.log(
|
|
501
|
+
`Next: ${setup.commands.checkMutations} --fix --ticket=<ID> repairs moved files; anything else needs a hand edit (see ${setup.dir}/README.md).`,
|
|
502
|
+
);
|
|
503
|
+
return r.code;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export { main };
|