@ticket-gate/engine 0.0.0 → 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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +82 -0
  3. package/bin/ticket-gate.mjs +261 -0
  4. package/checks/assertions-unguarded.mjs +31 -0
  5. package/checks/assertions.mjs +319 -0
  6. package/checks/cache-report.mjs +313 -0
  7. package/checks/docs-stale.mjs +69 -0
  8. package/checks/duplication.mjs +258 -0
  9. package/checks/ratchet-options.mjs +51 -0
  10. package/checks/surface.mjs +397 -0
  11. package/cli.mjs +379 -0
  12. package/commands-config.mjs +55 -0
  13. package/config.mjs +597 -0
  14. package/digest.mjs +195 -0
  15. package/env-name.mjs +24 -0
  16. package/failures.mjs +256 -0
  17. package/fu.mjs +458 -0
  18. package/graph/js.mjs +517 -0
  19. package/graph/load-time.mjs +309 -0
  20. package/graph/typescript.mjs +28 -0
  21. package/hooks/pre-tool-use.mjs +213 -0
  22. package/hooks/stop.mjs +495 -0
  23. package/is-main.mjs +15 -0
  24. package/kill-group.mjs +13 -0
  25. package/ledger.mjs +186 -0
  26. package/lock.d.mts +72 -0
  27. package/lock.mjs +329 -0
  28. package/move-proof.mjs +206 -0
  29. package/mutations/check.mjs +506 -0
  30. package/mutations/cli.mjs +361 -0
  31. package/mutations/exec.mjs +120 -0
  32. package/mutations/gate.mjs +148 -0
  33. package/mutations/harness.mjs +240 -0
  34. package/mutations/kinds.mjs +148 -0
  35. package/mutations/locate.mjs +71 -0
  36. package/mutations/project.mjs +23 -0
  37. package/mutations/registry.mjs +173 -0
  38. package/mutations/rotate.mjs +419 -0
  39. package/mutations/rotation-status.mjs +93 -0
  40. package/mutations/run.mjs +252 -0
  41. package/mutations/select.mjs +333 -0
  42. package/mutations/served.mjs +216 -0
  43. package/mutations/validate.mjs +86 -0
  44. package/mutations/verdict.mjs +159 -0
  45. package/nightly/cli.mjs +30 -0
  46. package/nightly/define.mjs +111 -0
  47. package/nightly/e2e-runs.mjs +73 -0
  48. package/nightly/gate.mjs +25 -0
  49. package/nightly/io.mjs +109 -0
  50. package/nightly/launchd.mjs +42 -0
  51. package/nightly/record.mjs +231 -0
  52. package/nightly/run.mjs +470 -0
  53. package/nightly/schedule.mjs +76 -0
  54. package/nightly/seed.mjs +78 -0
  55. package/nightly/status.mjs +189 -0
  56. package/nightly/suites.mjs +43 -0
  57. package/nightly-config.mjs +38 -0
  58. package/nightly-window.mjs +6 -0
  59. package/pack-hooks-all.mjs +15 -0
  60. package/pack-hooks.mjs +26 -0
  61. package/package.json +21 -1
  62. package/plan.mjs +272 -0
  63. package/precheck.mjs +90 -0
  64. package/project.mjs +21 -0
  65. package/ratchet.mjs +431 -0
  66. package/receipt.mjs +34 -0
  67. package/review-map.mjs +432 -0
  68. package/root.mjs +33 -0
  69. package/run.mjs +291 -0
  70. package/runners/playwright.mjs +132 -0
  71. package/score/adapters/stryker.mjs +111 -0
  72. package/score/base.mjs +82 -0
  73. package/score/cli.mjs +182 -0
  74. package/score/judge.mjs +107 -0
  75. package/score/lines.mjs +40 -0
  76. package/score/schema.mjs +68 -0
  77. package/score/survivors.mjs +103 -0
  78. package/score/targets.mjs +34 -0
  79. package/score-config.mjs +120 -0
  80. package/selection/check.mjs +45 -0
  81. package/selection/cli.mjs +267 -0
  82. package/selection/define.mjs +55 -0
  83. package/selection/for.mjs +235 -0
  84. package/selection/graphs.mjs +38 -0
  85. package/selection/project.mjs +11 -0
  86. package/selection/select.mjs +159 -0
  87. package/selection/units.mjs +646 -0
  88. package/selection/value-rules.mjs +142 -0
  89. package/selection-config.mjs +76 -0
  90. package/serve.mjs +142 -0
  91. package/summary.mjs +34 -0
  92. package/tree.mjs +211 -0
@@ -0,0 +1,258 @@
1
+ // Duplication ratchet, generic part. Runs jscpd (settings in a config file) and compares the clones it
2
+ // finds with a baseline file. Every project value (package prefixes, limits, paths, message texts) is an
3
+ // option of duplication(); the project's values live in its ratchet-values file.
4
+ //
5
+ // Only "gated" clones count: any clone whose two sides sit in different packages, and any clone inside
6
+ // one package that is longer than withinMax. A gated clone that is not in the baseline fails the check,
7
+ // so a copy cannot be added without a person reading the baseline diff. A baseline entry whose clone is
8
+ // gone also fails, so the baseline can only shrink by itself.
9
+ //
10
+ // check() arguments (a CLI shim passes process.argv.slice(2)):
11
+ // --update rewrite the baseline from the current clones
12
+ // --seed with --update only: new entries get no `allow` (debt)
13
+ // --report=<path> read this jscpd JSON report instead of running jscpd (tests)
14
+ // --baseline=<path> default <root>/<baseline>
15
+ //
16
+ // Result: { code, lines, err, out }. code 0 green, 1 a new, stale or unexplained clone, 2 jscpd gave no
17
+ // report. err and out hold the lines for stderr and stdout; lines is err followed by out.
18
+ import { spawnSync } from 'node:child_process';
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { isAbsolute, join, relative, resolve } from 'node:path';
22
+
23
+ /** Trim each line, collapse whitespace runs, drop empty lines. */
24
+ export function normaliseFragment(text) {
25
+ return text
26
+ .split('\n')
27
+ .map((l) => l.trim().replace(/\s+/g, ' '))
28
+ .filter(Boolean)
29
+ .join('\n');
30
+ }
31
+
32
+ const relPath = (name, root) => {
33
+ const p = isAbsolute(name) ? relative(root, name) : name;
34
+ return p.split('\\').join('/').replace(/^\.\//, '');
35
+ };
36
+
37
+ const pairOf = (key) => key.slice(0, key.lastIndexOf('|'));
38
+
39
+ export function duplication({
40
+ packages,
41
+ withinMax,
42
+ config,
43
+ report,
44
+ baseline,
45
+ bin,
46
+ comment,
47
+ name,
48
+ fix,
49
+ }) {
50
+ /** The package a path belongs to: first prefix of `packages` that matches, else 'other'. */
51
+ function packageOf(path) {
52
+ for (const [pkg, prefix] of Object.entries(packages))
53
+ if (path.startsWith(prefix)) return pkg;
54
+ return 'other';
55
+ }
56
+
57
+ /** One jscpd duplicate -> a clone with a stable key (no line numbers). */
58
+ function cloneOf(dup, root) {
59
+ const first = { ...dup.firstFile, path: relPath(dup.firstFile.name, root) };
60
+ const second = {
61
+ ...dup.secondFile,
62
+ path: relPath(dup.secondFile.name, root),
63
+ };
64
+ const [x, y] =
65
+ first.path <= second.path ? [first, second] : [second, first];
66
+ const hash = createHash('sha256')
67
+ .update(normaliseFragment(dup.fragment ?? ''))
68
+ .digest('hex')
69
+ .slice(0, 16);
70
+ const pa = packageOf(x.path);
71
+ const pb = packageOf(y.path);
72
+ const names = [pa, pb].sort();
73
+ return {
74
+ a: x.path,
75
+ b: y.path,
76
+ hash,
77
+ key: `${x.path}|${y.path}|${hash}`,
78
+ lines: dup.lines,
79
+ cross: pa !== pb,
80
+ kind: pa !== pb ? `${names[0]}<->${names[1]}` : `within ${pa}`,
81
+ loc: [`${x.path}:${x.start}-${x.end}`, `${y.path}:${y.start}-${y.end}`],
82
+ };
83
+ }
84
+
85
+ const gated = (c) => c.cross || c.lines > withinMax;
86
+
87
+ const groupByKey = (clones) => {
88
+ const groups = new Map();
89
+ for (const c of clones.filter(gated)) {
90
+ const g = groups.get(c.key) ?? [];
91
+ g.push(c);
92
+ groups.set(c.key, g);
93
+ }
94
+ return groups;
95
+ };
96
+
97
+ /**
98
+ * fresh: clones beyond what the baseline allows. stale: baseline entries with
99
+ * fewer current clones than their count. unreasoned: entries with allow "".
100
+ */
101
+ function compare(clones, base) {
102
+ const groups = groupByKey(clones);
103
+ const entries = new Map(base.clones.map((e) => [e.key, e]));
104
+ const fresh = [];
105
+ for (const [key, group] of groups) {
106
+ const e = entries.get(key);
107
+ fresh.push(...group.slice(e ? (e.count ?? 1) : 0));
108
+ }
109
+ const stale = [];
110
+ for (const e of base.clones) {
111
+ const now = groups.get(e.key)?.length ?? 0;
112
+ if (now < (e.count ?? 1)) stale.push(e);
113
+ }
114
+ const unreasoned = base.clones.filter((e) => e.allow === '');
115
+ const counts = {
116
+ gated: [...groups.values()].reduce((n, g) => n + g.length, 0),
117
+ allowed: base.clones.filter((e) => e.allow).length,
118
+ debt: base.clones.filter((e) => e.allow === undefined).length,
119
+ };
120
+ return { fresh, stale, unreasoned, counts };
121
+ }
122
+
123
+ /** Next baseline for the current clones (rules in the header). */
124
+ function updateBaseline(clones, base, { seed = false } = {}) {
125
+ const groups = groupByKey(clones);
126
+ const old = new Map(base.clones.map((e) => [e.key, e]));
127
+ const dropped = base.clones.filter((e) => !groups.has(e.key));
128
+ const out = [];
129
+ const added = [];
130
+ for (const [key, group] of groups) {
131
+ const prev = old.get(key);
132
+ const entry = { key, lines: group[0].lines };
133
+ if (group.length > 1) entry.count = group.length;
134
+ if (prev) {
135
+ if (prev.allow !== undefined) entry.allow = prev.allow;
136
+ } else {
137
+ const replaces = dropped.some(
138
+ (d) =>
139
+ d.allow === undefined &&
140
+ pairOf(d.key) === pairOf(key) &&
141
+ group[0].lines <= d.lines,
142
+ );
143
+ if (!seed && !replaces) entry.allow = '';
144
+ added.push(entry);
145
+ }
146
+ out.push(entry);
147
+ }
148
+ out.sort((p, q) => (p.key < q.key ? -1 : p.key > q.key ? 1 : 0));
149
+ return { next: { comment, clones: out }, added, removed: dropped };
150
+ }
151
+
152
+ function loadBaseline(path) {
153
+ try {
154
+ return JSON.parse(readFileSync(path, 'utf8'));
155
+ } catch {
156
+ return { clones: [] };
157
+ }
158
+ }
159
+
160
+ /** The jscpd report, or null when there is none. */
161
+ function readReport(root, reportPath) {
162
+ if (!reportPath) {
163
+ rmSync(join(root, report), { recursive: true, force: true });
164
+ const r = spawnSync(bin, ['--config', join(root, config)], {
165
+ cwd: root,
166
+ encoding: 'utf8',
167
+ });
168
+ if (r.status !== 0) return null;
169
+ reportPath = join(root, report, 'jscpd-report.json');
170
+ }
171
+ try {
172
+ const parsed = JSON.parse(readFileSync(reportPath, 'utf8'));
173
+ return Array.isArray(parsed.duplicates) ? parsed : null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ function check({ root, args = [], update = false }) {
180
+ const opt = (key) =>
181
+ args.find((a) => a.startsWith(`--${key}=`))?.slice(key.length + 3);
182
+ const done = (code, err, out) => ({
183
+ code,
184
+ lines: [...err, ...out],
185
+ err,
186
+ out,
187
+ });
188
+ const parsed = readReport(root, opt('report'));
189
+ if (!parsed)
190
+ return done(
191
+ 2,
192
+ [`${name}: jscpd produced no report. ${fix.noReport}`],
193
+ [],
194
+ );
195
+ const baselinePath = resolve(opt('baseline') ?? join(root, baseline));
196
+ const clones = parsed.duplicates.map((d) => cloneOf(d, root));
197
+ const base = loadBaseline(baselinePath);
198
+
199
+ if (update || args.includes('--update')) {
200
+ const { next, added, removed } = updateBaseline(clones, base, {
201
+ seed: args.includes('--seed'),
202
+ });
203
+ writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n');
204
+ const out = [
205
+ ...added.map((e) => ` ADDED ${e.key} (${e.lines} lines)`),
206
+ ...removed.map((e) => ` REMOVED ${e.key} (${e.lines} lines)`),
207
+ `${name}: baseline rewritten, ${next.clones.length} entries (${added.length} added, ${removed.length} removed).`,
208
+ ];
209
+ return done(0, [], out);
210
+ }
211
+
212
+ const { fresh, stale, unreasoned, counts } = compare(clones, base);
213
+ const err = [];
214
+ if (fresh.length > 0) {
215
+ err.push(`${name}: ${fresh.length} new clone(s). ${fix.fresh}`);
216
+ for (const c of fresh)
217
+ err.push(
218
+ ` NEW ${c.kind} ${c.lines} lines: ${c.loc[0]} <-> ${c.loc[1]}`,
219
+ );
220
+ }
221
+ if (stale.length > 0) {
222
+ err.push(
223
+ `${name}: ${stale.length} baseline entr${stale.length === 1 ? 'y' : 'ies'} no longer match a clone. ${fix.stale}`,
224
+ );
225
+ for (const e of stale)
226
+ err.push(` STALE ${e.key} (${e.lines} lines): the clone is gone`);
227
+ }
228
+ if (unreasoned.length > 0) {
229
+ err.push(
230
+ `${name}: ${unreasoned.length} baseline entr${unreasoned.length === 1 ? 'y has' : 'ies have'} an empty "allow". ${fix.unreasoned}`,
231
+ );
232
+ for (const e of unreasoned)
233
+ err.push(
234
+ ` NO REASON ${e.key}: write why the copy is deliberate in "allow"`,
235
+ );
236
+ }
237
+ let code = 0;
238
+ if (fresh.length > 0) code = 1;
239
+ if (stale.length > 0) code = 1;
240
+ if (unreasoned.length > 0) code = 1;
241
+ const out = code
242
+ ? []
243
+ : [
244
+ `${name}: ${counts.gated} gated clone(s), all in ${baseline} (${counts.allowed} allowed, ${counts.debt} debt).`,
245
+ ];
246
+ return done(code, err, out);
247
+ }
248
+
249
+ return {
250
+ baseline,
251
+ withinMax,
252
+ packageOf,
253
+ cloneOf,
254
+ compare,
255
+ updateBaseline,
256
+ check,
257
+ };
258
+ }
@@ -0,0 +1,51 @@
1
+ // Option forms over the engine's ratchet module: the project passes values (globs, limits, texts) and
2
+ // these functions build the engine ratchets from them. No project value lives here.
3
+ import { existsSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import {
6
+ createModuleSize,
7
+ createSkillRefs,
8
+ isDir,
9
+ } from '../ratchet.mjs';
10
+ import { globMatch } from './surface.mjs';
11
+
12
+ /** Existing files under root that match any glob: posix relative paths, unique, sorted. */
13
+ export function expandSources(root, sources) {
14
+ const out = new Set();
15
+ const walk = (rel, into) => {
16
+ for (const e of readdirSync(join(root, rel))) {
17
+ if (e === 'node_modules') continue;
18
+ const p = rel ? `${rel}/${e}` : e;
19
+ if (isDir(join(root, p))) walk(p, into);
20
+ else into.push(p);
21
+ }
22
+ };
23
+ for (const glob of sources) {
24
+ if (!glob.includes('*')) {
25
+ if (existsSync(join(root, glob)) && !isDir(join(root, glob)))
26
+ out.add(glob);
27
+ continue;
28
+ }
29
+ const segs = glob.split('/');
30
+ const start = segs
31
+ .slice(
32
+ 0,
33
+ segs.findIndex((s) => s.includes('*')),
34
+ )
35
+ .join('/');
36
+ if (!isDir(join(root, start))) continue;
37
+ const all = [];
38
+ walk(start, all);
39
+ for (const p of all) if (globMatch(glob, p)) out.add(p);
40
+ }
41
+ return [...out].sort();
42
+ }
43
+
44
+ export function moduleSize({ baseline, ...rest }) {
45
+ return createModuleSize({ ...rest, baselineRel: baseline });
46
+ }
47
+
48
+ export function skillRefs({ sources, ...rest }) {
49
+ const sourceFiles = (root) => expandSources(root, sources);
50
+ return { ...createSkillRefs({ ...rest, sourceFiles }), sourceFiles };
51
+ }