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.
@@ -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
- const names = skipped
40
- .map((s) => s.name.replace(CONTROL_CHARS, ''))
41
- .join(', ');
42
- return ` (${skipped.length} skipped: ${names})`;
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.