canary-test-cli 6.6.0 → 6.7.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/bin/canary.js +69 -1
- package/dist/doctor-manifest.js +6 -1
- package/dist/doctor.js +7 -4
- package/dist/engine/cli-commands.js +84 -25
- package/dist/engine/cli.core.js +1 -1
- package/dist/engine/core/framework-probes.js +218 -0
- package/dist/engine/core/fs-glob.js +185 -0
- package/dist/engine/core/gate-result.js +27 -4
- package/dist/engine/core/migrator.js +240 -289
- package/dist/engine/core/static-linter.js +44 -4
- package/dist/engine/core/workspace-detect.js +0 -0
- package/dist/engine/guardian/adjudication.js +34 -30
- package/dist/engine/guardian/analysis-emit.js +13 -4
- package/dist/engine/guardian/cli.js +113 -16
- package/dist/engine/guardian/coverage.js +291 -9
- package/dist/engine/guardian/github-paging.js +97 -0
- package/dist/engine/guardian/pr-check.js +285 -26
- package/dist/engine/guardian/pr-comment.js +29 -15
- package/dist/gate-result.js +27 -4
- package/dist/overlay-commands.js +31 -3
- package/package.json +2 -2
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem and glob helpers shared by the migrator, the framework probes,
|
|
3
|
+
* and workspace detection.
|
|
4
|
+
*
|
|
5
|
+
* A leaf module by design: it imports nothing from `core/`, which is what lets
|
|
6
|
+
* `framework-probes` use `globFiles` without cycling back through `migrator`
|
|
7
|
+
* (#504 part 1). The glob subset mirrors `Path.glob` -- `**` matches zero or
|
|
8
|
+
* more directories, `*` matches within one segment.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
export function isDir(path) {
|
|
13
|
+
try {
|
|
14
|
+
return statSync(path).isDirectory();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function isFile(path) {
|
|
21
|
+
try {
|
|
22
|
+
return statSync(path).isFile();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Compile a single glob segment (with `*` -> `[^/]*`) to an anchored regex. */
|
|
29
|
+
export function segGlobRegex(seg) {
|
|
30
|
+
const body = seg
|
|
31
|
+
.replace(/[.+^${}()|[\]\\?]/g, '\\$&')
|
|
32
|
+
.replace(/\*/g, '[^/]*');
|
|
33
|
+
return new RegExp(`^${body}$`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Match files under *root* against a pathlib-style glob (`**` matches zero or
|
|
37
|
+
* more directories; `*` matches within a single segment). Mirrors the subset of
|
|
38
|
+
* `Path.glob` the migrator needs.
|
|
39
|
+
*/
|
|
40
|
+
export function globFiles(root, pattern) {
|
|
41
|
+
const segments = pattern.split('/');
|
|
42
|
+
const out = [];
|
|
43
|
+
const visit = (dir, si) => {
|
|
44
|
+
const seg = segments[si];
|
|
45
|
+
const last = si === segments.length - 1;
|
|
46
|
+
if (seg === '**') {
|
|
47
|
+
// `**` consumes zero directories -> continue at the same dir.
|
|
48
|
+
visit(dir, si + 1);
|
|
49
|
+
// `**` consumes one-or-more -> descend into each subdir, staying on `**`.
|
|
50
|
+
for (const d of subDirs(dir))
|
|
51
|
+
visit(d, si);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const re = segGlobRegex(seg);
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const e of entries) {
|
|
63
|
+
if (!re.test(e.name))
|
|
64
|
+
continue;
|
|
65
|
+
const full = join(dir, e.name);
|
|
66
|
+
if (last) {
|
|
67
|
+
if (e.isFile() || isFile(full))
|
|
68
|
+
out.push(full);
|
|
69
|
+
}
|
|
70
|
+
else if (e.isDirectory()) {
|
|
71
|
+
visit(full, si + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
visit(root, 0);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
export function subDirs(dir) {
|
|
79
|
+
try {
|
|
80
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
81
|
+
.filter((e) => e.isDirectory())
|
|
82
|
+
.map((e) => join(dir, e.name));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Directories a workspace glob must never descend into or return.
|
|
90
|
+
*
|
|
91
|
+
* `node_modules` matters twice over: a dependency ships its own
|
|
92
|
+
* `playwright.config.ts`, which would be mistaken for this repo's suite and
|
|
93
|
+
* silently suppress a scaffold the user needs -- and a `**` glob over a real
|
|
94
|
+
* monorepo would otherwise walk every installed package on disk.
|
|
95
|
+
*/
|
|
96
|
+
export const _WORKSPACE_SKIP_DIRS = new Set([
|
|
97
|
+
'node_modules',
|
|
98
|
+
'.git',
|
|
99
|
+
'.venv',
|
|
100
|
+
'venv',
|
|
101
|
+
'dist',
|
|
102
|
+
'build',
|
|
103
|
+
'.next',
|
|
104
|
+
'.turbo',
|
|
105
|
+
'coverage',
|
|
106
|
+
'__pycache__',
|
|
107
|
+
]);
|
|
108
|
+
/**
|
|
109
|
+
* Match *directories* under *root* against a workspace glob (`apps/*`).
|
|
110
|
+
*
|
|
111
|
+
* Deliberately a separate walk from `globFiles` rather than a shared one
|
|
112
|
+
* parameterised by file-vs-directory: folding the two together forced a `kind`
|
|
113
|
+
* branch through every step and pushed both the walker and its filter to
|
|
114
|
+
* cyclomatic complexity 14 (threshold 10) to save 7 lines. Two short, honest
|
|
115
|
+
* walks beat one clever one.
|
|
116
|
+
*/
|
|
117
|
+
export function globDirs(root, pattern) {
|
|
118
|
+
const segments = pattern.split('/').filter((s) => s !== '');
|
|
119
|
+
const out = [];
|
|
120
|
+
const walkable = (dir) => subDirs(dir).filter((d) => !_WORKSPACE_SKIP_DIRS.has(basename(d)));
|
|
121
|
+
const visit = (dir, si) => {
|
|
122
|
+
if (si === segments.length) {
|
|
123
|
+
if (dir !== root)
|
|
124
|
+
out.push(dir);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const seg = segments[si];
|
|
128
|
+
if (seg === '**') {
|
|
129
|
+
visit(dir, si + 1);
|
|
130
|
+
for (const d of walkable(dir))
|
|
131
|
+
visit(d, si);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const re = segGlobRegex(seg);
|
|
135
|
+
for (const d of walkable(dir)) {
|
|
136
|
+
if (re.test(basename(d)))
|
|
137
|
+
visit(d, si + 1);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
visit(root, 0);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
export function readTextOrNull(path) {
|
|
144
|
+
try {
|
|
145
|
+
return readFileSync(path, 'utf-8');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Order two POSIX-style relative paths the way Python orders `Path` objects:
|
|
153
|
+
* component-wise (`PurePath.__lt__` compares the parts list), NOT as joined
|
|
154
|
+
* strings. They differ when a directory name prefixes a sibling file name and
|
|
155
|
+
* the next char sorts below '/' (0x2F) -- most commonly the '.' extension
|
|
156
|
+
* separator, e.g. `scripts/run.sh` vs `scripts.md`. A joined-string sort places
|
|
157
|
+
* `scripts.md` first ('.' 0x2E < '/' 0x2F); Python's component sort places
|
|
158
|
+
* `scripts/run.sh` first ('scripts' < 'scripts.md'). This ordering feeds the
|
|
159
|
+
* skill-dir hash, a byte-exact contract compared against Python-written
|
|
160
|
+
* .deploy-manifest.json files on the upgrade path.
|
|
161
|
+
*/
|
|
162
|
+
export function comparePathParts(a, b) {
|
|
163
|
+
const pa = a.split('/');
|
|
164
|
+
const pb = b.split('/');
|
|
165
|
+
const n = Math.min(pa.length, pb.length);
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
if (pa[i] < pb[i])
|
|
168
|
+
return -1;
|
|
169
|
+
if (pa[i] > pb[i])
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
return pa.length - pb.length;
|
|
173
|
+
}
|
|
174
|
+
/** Parse *text* as JSON, or null if it is absent or malformed. */
|
|
175
|
+
export function parseJsonOrNull(text) {
|
|
176
|
+
if (text === null)
|
|
177
|
+
return null;
|
|
178
|
+
try {
|
|
179
|
+
return JSON.parse(text);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=fs-glob.js.map
|
|
@@ -36,10 +36,33 @@ const CONTROL_CHARS = /[\u0000-\u001F\u007F]/g;
|
|
|
36
36
|
export function skippedSuffix(skipped) {
|
|
37
37
|
if (!skipped || skipped.length === 0)
|
|
38
38
|
return '';
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// #579: `reason` used to be write-only -- every caller set one and no
|
|
40
|
+
// surface ever rendered it, so a reader could see THAT a path was dropped
|
|
41
|
+
// but never WHY. Name and cause now travel together, keeping the D7
|
|
42
|
+
// contract that the path stays visible rather than collapsing into a bare
|
|
43
|
+
// count.
|
|
44
|
+
//
|
|
45
|
+
// Grouped by reason, because producers write reasons at very different
|
|
46
|
+
// grain: the guardian uses short tokens (`skipGlobs`), doctor uses whole
|
|
47
|
+
// remedy sentences. Repeating the reason per name turned one doctor line
|
|
48
|
+
// into 183 characters that said the same thing twice. Groups keep
|
|
49
|
+
// first-appearance order, so output stays deterministic.
|
|
50
|
+
//
|
|
51
|
+
// Reasons are control-char stripped for the same line-forging reason names
|
|
52
|
+
// are -- rendering the field is what made it an injection surface.
|
|
53
|
+
const groups = new Map();
|
|
54
|
+
for (const entry of skipped) {
|
|
55
|
+
const reason = entry.reason.replace(CONTROL_CHARS, '');
|
|
56
|
+
const names = groups.get(reason);
|
|
57
|
+
if (names)
|
|
58
|
+
names.push(entry.name.replace(CONTROL_CHARS, ''));
|
|
59
|
+
else
|
|
60
|
+
groups.set(reason, [entry.name.replace(CONTROL_CHARS, '')]);
|
|
61
|
+
}
|
|
62
|
+
const rendered = [...groups]
|
|
63
|
+
.map(([reason, names]) => reason ? `${names.join(', ')} [${reason}]` : names.join(', '))
|
|
64
|
+
.join('; ');
|
|
65
|
+
return ` (${skipped.length} skipped: ${rendered})`;
|
|
43
66
|
}
|
|
44
67
|
/**
|
|
45
68
|
* The single summary-line/exit-code path for swept commands.
|