canary-test-cli 5.15.0 → 6.0.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/agent/frameworks/registry.json +655 -0
- package/bin/canary.js +20 -15
- package/dist/doctor-manifest.d.ts +94 -0
- package/dist/doctor.d.ts +67 -0
- package/dist/engine/analysis/cli.js +270 -0
- package/dist/engine/analysis/engine.js +146 -0
- package/dist/engine/analysis/reports.js +0 -0
- package/dist/engine/analysis/rows.js +9 -0
- package/dist/engine/cli-commands.js +618 -0
- package/dist/engine/cli-common.js +60 -0
- package/dist/engine/cli.core.js +208 -0
- package/dist/engine/cli.js +31 -0
- package/dist/engine/company-knowledge-cli.js +201 -0
- package/dist/engine/core/ci-env.js +33 -0
- package/dist/engine/core/classifier.js +192 -0
- package/dist/engine/core/company-knowledge.js +765 -0
- package/dist/engine/core/config-validation.js +74 -0
- package/dist/engine/core/detection.js +48 -0
- package/dist/engine/core/domain-scanner.js +212 -0
- package/dist/engine/core/environment-detect.js +410 -0
- package/dist/engine/core/executor.js +181 -0
- package/dist/engine/core/feedback.js +93 -0
- package/dist/engine/core/fixture-scanner.js +173 -0
- package/dist/engine/core/framework-registry.js +123 -0
- package/dist/engine/core/mcp-validator.js +218 -0
- package/dist/engine/core/metadata-scanner.js +147 -0
- package/dist/engine/core/migrator.js +1112 -0
- package/dist/engine/core/overlays.js +176 -0
- package/dist/engine/core/pattern-healer.js +147 -0
- package/dist/engine/core/pattern-matcher.js +255 -0
- package/dist/engine/core/quality-scorer.js +213 -0
- package/dist/engine/core/recommender.js +152 -0
- package/dist/engine/core/reporter.js +211 -0
- package/dist/engine/core/scaffolder.js +236 -0
- package/dist/engine/core/skill-registry.js +522 -0
- package/dist/engine/core/static-linter.js +237 -0
- package/dist/engine/core/ticket-updater.js +639 -0
- package/dist/engine/core/workflow-discovery.js +693 -0
- package/dist/engine/guardian/agent-tier.js +338 -0
- package/dist/engine/guardian/analysis-emit.js +201 -0
- package/dist/engine/guardian/cli.js +787 -0
- package/dist/engine/guardian/coverage.js +1055 -0
- package/dist/engine/guardian/delta-emitter.js +46 -0
- package/dist/engine/guardian/diff-extractor.js +257 -0
- package/dist/engine/guardian/hard-gate.js +373 -0
- package/dist/engine/guardian/impact-mapper.js +121 -0
- package/dist/engine/guardian/pr-check.js +975 -0
- package/dist/engine/guardian/pr-comment.js +200 -0
- package/dist/engine/guardian/summary-emitter.js +94 -0
- package/dist/engine/guardian/tier.js +58 -0
- package/dist/engine/history/cli.js +303 -0
- package/dist/engine/history/detector.js +68 -0
- package/dist/engine/history/ndjson-store.js +177 -0
- package/dist/engine/history/record.js +14 -0
- package/dist/engine/history/schema.js +59 -0
- package/dist/engine/history/store.js +47 -0
- package/dist/engine/history/supabase-store.js +113 -0
- package/dist/engine/main-deps.js +105 -0
- package/dist/engine/mcp-server.js +647 -0
- package/dist/engine/package.json +4 -0
- package/dist/engine/skills-cli.js +181 -0
- package/dist/engine/ui/banner.js +50 -0
- package/dist/engine/util/coalesce.js +12 -0
- package/dist/engine/util/round.js +43 -0
- package/dist/engine/workflow-cli.js +242 -0
- package/dist/engine-checks.d.ts +49 -0
- package/dist/overlay-commands.d.ts +81 -0
- package/dist/overlay-conflicts.d.ts +33 -0
- package/dist/overlay-lint.d.ts +19 -0
- package/dist/overlays-registry.d.ts +74 -0
- package/dist/reporters/testtracker.d.ts +89 -0
- package/dist/reporters/testtracker.js +195 -0
- package/dist/router.d.ts +12 -0
- package/dist/router.js +4 -4
- package/dist/skill-requirements.d.ts +57 -0
- package/dist/source-spec.d.ts +20 -0
- package/package.json +30 -6
- package/bin/canary +0 -0
- package/scripts/install.js +0 -104
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canary Migrator -- detects harness-scaffolded test-suite projects and migrates
|
|
3
|
+
* them to Canary's layout without touching existing test files.
|
|
4
|
+
*
|
|
5
|
+
* Faithful TypeScript port of `agent/core/migrator.py`.
|
|
6
|
+
*
|
|
7
|
+
* Python->TS nuances:
|
|
8
|
+
* - `_PYTHON_DEP_PATTERNS` are `re.MULTILINE | re.IGNORECASE` `^`-anchored
|
|
9
|
+
* patterns. Python `re.MULTILINE` anchors `^` on `\n` ONLY, whereas JS `^`
|
|
10
|
+
* under the `m` flag also breaks on `\r`, U+2028 and U+2029. To preserve the
|
|
11
|
+
* oracle exactly, these do NOT use the `m` flag: the `^` is expressed as
|
|
12
|
+
* `(?:^|(?<=\n))` (start-of-string OR immediately after a `\n`), and
|
|
13
|
+
* `re.IGNORECASE` maps to the `i` flag. The remaining regexes
|
|
14
|
+
* (`_PW_UI_FIXTURE_RE`, `_PACKAGE_SCRIPT_PATTERNS`) contain no `^`/`$`
|
|
15
|
+
* anchors, so `re.MULTILINE` there is a no-op and is dropped. None of these
|
|
16
|
+
* regexes carry the `g` flag, so `.test()` is not stateful.
|
|
17
|
+
* - Skill directories are copied with `cpSync(..., {recursive:true})`
|
|
18
|
+
* (shutil.copytree) and removed with `rmSync(..., {recursive:true,
|
|
19
|
+
* force:true})` (shutil.rmtree). The deploy manifest is `writeFileSync`'d as
|
|
20
|
+
* `JSON.stringify(..., 2) + "\n"` run through {@link ensureAscii} to match
|
|
21
|
+
* `json.dumps(indent=2)` (ensure_ascii defaults to True). Python text-mode
|
|
22
|
+
* writes `\n` verbatim on POSIX; `writeFileSync` does the same. The manifest
|
|
23
|
+
* is round-tripped only by this module, so the file content is an internal
|
|
24
|
+
* contract, not a cross-runtime one.
|
|
25
|
+
* - `_hash_skill_dir` hashes the sorted `(relative-posix-path, bytes)` pairs of
|
|
26
|
+
* every file under a skill dir. Hashes are only ever compared between two
|
|
27
|
+
* invocations of THIS function (overlay vs. deployed, or vs. the recorded
|
|
28
|
+
* manifest hash), so the sort must merely be internally consistent; sorting
|
|
29
|
+
* by relative-posix-path string matches Python's Path ordering for the flat /
|
|
30
|
+
* shallow ASCII skill trees in play.
|
|
31
|
+
* - Human-facing markdown keeps the exact glyphs the oracle emits (checkmark
|
|
32
|
+
* U+2705, warning sign U+26A0, em-dash U+2014); they are written as `\u{...}`
|
|
33
|
+
* escapes so this source stays ASCII while the emitted bytes are identical.
|
|
34
|
+
* - `Path.home()` (the `~/.canary/skills` overlay tier) is an injectable
|
|
35
|
+
* constructor argument (defaults to `os.homedir()`), the same test seam the
|
|
36
|
+
* skill-registry port uses -- Python patches `Path.home()` in its tests.
|
|
37
|
+
* - Python truthiness (`""`/`None`/`{}`/`[]` falsy) via {@link pyTruthy}.
|
|
38
|
+
*/
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
41
|
+
import { homedir } from 'node:os';
|
|
42
|
+
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
43
|
+
import { readJsonWithWarning } from './config-validation.js';
|
|
44
|
+
import { uncertainDetectionMessage } from './detection.js';
|
|
45
|
+
import { FrameworkRegistry } from './framework-registry.js';
|
|
46
|
+
import { Scaffolder, scaffoldableFrameworks, TEMPLATES } from './scaffolder.js';
|
|
47
|
+
import { SkillRegistry } from './skill-registry.js';
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Python-compatibility helpers (copied locally per-module, matching reporter.ts)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/** Python-truthiness (`null`/`undefined`/`false`/`0`/`""`/`[]`/`{}` falsy). */
|
|
52
|
+
function pyTruthy(value) {
|
|
53
|
+
if (value === null || value === undefined || value === false)
|
|
54
|
+
return false;
|
|
55
|
+
if (value === 0 || value === '')
|
|
56
|
+
return false;
|
|
57
|
+
if (Array.isArray(value))
|
|
58
|
+
return value.length > 0;
|
|
59
|
+
if (typeof value === 'object')
|
|
60
|
+
return Object.keys(value).length > 0;
|
|
61
|
+
return Boolean(value);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reproduce `json.dumps(..., ensure_ascii=True)` on `JSON.stringify` output:
|
|
65
|
+
* escape every code point >= 0x80 as `\uXXXX`. (Same helper as reporter.ts.)
|
|
66
|
+
*/
|
|
67
|
+
function ensureAscii(json) {
|
|
68
|
+
return json.replace(/[\u0080-\uffff]/g, (ch) => '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
|
|
69
|
+
}
|
|
70
|
+
// Non-ASCII glyphs kept out of the source text as escapes, emitted verbatim.
|
|
71
|
+
const CHECK = '\u{2705}'; // white heavy check mark
|
|
72
|
+
const WARN = '\u{26A0}'; // warning sign
|
|
73
|
+
const EMDASH = '\u{2014}'; // em dash
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Deploy manifest
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Records the content hash of each skill at deploy time so the freshness gate
|
|
78
|
+
// can tell an untouched deployment (safe to refresh) from a hand-edited one.
|
|
79
|
+
// Lives beside the deployed skills; never a deployable skill itself (leading dot
|
|
80
|
+
// -> skipped by the skill scanner).
|
|
81
|
+
export const DEPLOY_MANIFEST_NAME = '.deploy-manifest.json';
|
|
82
|
+
/**
|
|
83
|
+
* Order two POSIX-style relative paths the way Python orders `Path` objects:
|
|
84
|
+
* component-wise (`PurePath.__lt__` compares the parts list), NOT as joined
|
|
85
|
+
* strings. They differ when a directory name prefixes a sibling file name and
|
|
86
|
+
* the next char sorts below '/' (0x2F) -- most commonly the '.' extension
|
|
87
|
+
* separator, e.g. `scripts/run.sh` vs `scripts.md`. A joined-string sort places
|
|
88
|
+
* `scripts.md` first ('.' 0x2E < '/' 0x2F); Python's component sort places
|
|
89
|
+
* `scripts/run.sh` first ('scripts' < 'scripts.md'). This ordering feeds the
|
|
90
|
+
* skill-dir hash, a byte-exact contract compared against Python-written
|
|
91
|
+
* .deploy-manifest.json files on the upgrade path.
|
|
92
|
+
*/
|
|
93
|
+
function comparePathParts(a, b) {
|
|
94
|
+
const pa = a.split('/');
|
|
95
|
+
const pb = b.split('/');
|
|
96
|
+
const n = Math.min(pa.length, pb.length);
|
|
97
|
+
for (let i = 0; i < n; i++) {
|
|
98
|
+
if (pa[i] < pb[i])
|
|
99
|
+
return -1;
|
|
100
|
+
if (pa[i] > pb[i])
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
return pa.length - pb.length;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* A stable sha256 of every file under *skillDir* (component-sorted rel-path +
|
|
107
|
+
* bytes). Exported so a test can pin it byte-for-byte to the Python oracle:
|
|
108
|
+
* this hash is compared against Python-written .deploy-manifest.json files on
|
|
109
|
+
* the upgrade path, so any drift misclassifies untouched skills.
|
|
110
|
+
*/
|
|
111
|
+
export function hashSkillDir(skillDir) {
|
|
112
|
+
const h = createHash('sha256');
|
|
113
|
+
const rels = collectRelFiles(skillDir).sort(comparePathParts);
|
|
114
|
+
for (const rel of rels) {
|
|
115
|
+
h.update(Buffer.from(rel, 'utf-8'));
|
|
116
|
+
h.update(NUL);
|
|
117
|
+
try {
|
|
118
|
+
h.update(readFileSync(join(skillDir, rel.split('/').join(sep))));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// OSError -> Python `pass`; the two NUL separators are still written.
|
|
122
|
+
}
|
|
123
|
+
h.update(NUL);
|
|
124
|
+
}
|
|
125
|
+
return h.digest('hex');
|
|
126
|
+
}
|
|
127
|
+
const NUL = Buffer.from([0]);
|
|
128
|
+
/** Relative (posix-joined) paths of every file under *dir*, recursively. */
|
|
129
|
+
function collectRelFiles(dir) {
|
|
130
|
+
const out = [];
|
|
131
|
+
const walk = (d, prefix) => {
|
|
132
|
+
let entries;
|
|
133
|
+
try {
|
|
134
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
for (const e of entries) {
|
|
140
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
141
|
+
if (e.isDirectory())
|
|
142
|
+
walk(join(d, e.name), rel);
|
|
143
|
+
else if (e.isFile())
|
|
144
|
+
out.push(rel);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
walk(dir, '');
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Return `{dirName: {name, hash}}` from the manifest, or `{}` when absent or
|
|
152
|
+
* unreadable (provenance is best-effort).
|
|
153
|
+
*/
|
|
154
|
+
function readDeployManifest(targetSkillsDir) {
|
|
155
|
+
const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
|
|
156
|
+
let data;
|
|
157
|
+
try {
|
|
158
|
+
data = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data))
|
|
164
|
+
return {};
|
|
165
|
+
const skills = data['skills'];
|
|
166
|
+
if (skills === null || typeof skills !== 'object' || Array.isArray(skills)) {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
return skills;
|
|
170
|
+
}
|
|
171
|
+
function writeDeployManifest(targetSkillsDir, skills) {
|
|
172
|
+
const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
|
|
173
|
+
mkdirSync(targetSkillsDir, { recursive: true });
|
|
174
|
+
const body = ensureAscii(JSON.stringify({ schemaVersion: 1, skills }, null, 2)) + '\n';
|
|
175
|
+
writeFileSync(manifestPath, body, 'utf-8');
|
|
176
|
+
}
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Constants / probes
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
/**
|
|
181
|
+
* Frameworks a user can pass to `canary migrate --framework <name>`. Surfaced in
|
|
182
|
+
* the fail-loud message when auto-detection is uncertain (issue #295).
|
|
183
|
+
*/
|
|
184
|
+
export const KNOWN_FRAMEWORKS = [
|
|
185
|
+
'playwright',
|
|
186
|
+
'vitest',
|
|
187
|
+
'pytest',
|
|
188
|
+
'k6',
|
|
189
|
+
'wdio',
|
|
190
|
+
'locust',
|
|
191
|
+
];
|
|
192
|
+
// Layer names that mark a skills/docs *overlay* rather than a test suite (#319 C).
|
|
193
|
+
const _DOC_SKILL_LAYER_NAMES = new Set([
|
|
194
|
+
'skills',
|
|
195
|
+
'docs',
|
|
196
|
+
'guides',
|
|
197
|
+
'agents',
|
|
198
|
+
'overlays',
|
|
199
|
+
'commands',
|
|
200
|
+
'prompts',
|
|
201
|
+
]);
|
|
202
|
+
// (config_file, framework, shape, confidence)
|
|
203
|
+
const _CONFIG_PROBES = [
|
|
204
|
+
['playwright.config.ts', 'playwright', 'e2e_ui', 'config'],
|
|
205
|
+
['playwright.config.js', 'playwright', 'e2e_ui', 'config'],
|
|
206
|
+
['cypress.config.ts', 'playwright', 'e2e_ui', 'config'],
|
|
207
|
+
['cypress.config.js', 'playwright', 'e2e_ui', 'config'],
|
|
208
|
+
['vitest.config.ts', 'vitest', 'frontend_unit', 'config'],
|
|
209
|
+
['vitest.config.js', 'vitest', 'frontend_unit', 'config'],
|
|
210
|
+
['vitest.config.mts', 'vitest', 'frontend_unit', 'config'],
|
|
211
|
+
['jest.config.ts', 'vitest', 'frontend_unit', 'config'],
|
|
212
|
+
['jest.config.js', 'vitest', 'frontend_unit', 'config'],
|
|
213
|
+
['jest.config.mjs', 'vitest', 'frontend_unit', 'config'],
|
|
214
|
+
['k6.config.js', 'k6', 'performance', 'config'],
|
|
215
|
+
['pytest.ini', 'pytest', 'api', 'config'],
|
|
216
|
+
['setup.cfg', 'pytest', 'api', 'config'],
|
|
217
|
+
['axe.config.js', 'axe-core', 'accessibility', 'config'],
|
|
218
|
+
['backstop.json', 'backstopjs', 'visual', 'config'],
|
|
219
|
+
['pact.json', 'pact', 'contract', 'config'],
|
|
220
|
+
['.pact', 'pact', 'contract', 'config'],
|
|
221
|
+
['stryker.config.js', 'stryker', 'mutation', 'config'],
|
|
222
|
+
['stryker.config.mjs', 'stryker', 'mutation', 'config'],
|
|
223
|
+
['locust.conf', 'locust', 'load', 'config'],
|
|
224
|
+
['locustfile.py', 'locust', 'load', 'config'],
|
|
225
|
+
['wdio.conf.ts', 'wdio', 'mobile', 'config'],
|
|
226
|
+
['wdio.conf.js', 'wdio', 'mobile', 'config'],
|
|
227
|
+
['wdio.conf.mjs', 'wdio', 'mobile', 'config'],
|
|
228
|
+
];
|
|
229
|
+
// pyproject.toml section markers
|
|
230
|
+
const _PYPROJECT_MARKERS = [
|
|
231
|
+
['[tool.pytest.ini_options]', 'pytest', 'api'],
|
|
232
|
+
['[tool.coverage', 'pytest', 'api'],
|
|
233
|
+
];
|
|
234
|
+
// package.json test script -> (framework, shape)
|
|
235
|
+
const _PACKAGE_SCRIPT_PATTERNS = [
|
|
236
|
+
[/\bplaywright\b/, 'playwright', 'e2e_ui'],
|
|
237
|
+
[/\bcypress\b/, 'playwright', 'e2e_ui'],
|
|
238
|
+
[/\bvitest\b/, 'vitest', 'frontend_unit'],
|
|
239
|
+
[/\bjest\b/, 'vitest', 'frontend_unit'],
|
|
240
|
+
[/\bk6\b/, 'k6', 'performance'],
|
|
241
|
+
[/\blocust\b/, 'locust', 'load'],
|
|
242
|
+
[/\bstryker\b/, 'stryker', 'mutation'],
|
|
243
|
+
[/\bwdio\b/, 'wdio', 'mobile'],
|
|
244
|
+
];
|
|
245
|
+
// Python dependency -> (framework, shape). MULTILINE `^` anchored on `\n` only.
|
|
246
|
+
const _PYTHON_DEP_PATTERNS = [
|
|
247
|
+
[/(?:^|(?<=\n))pytest\b/i, 'pytest', 'api'],
|
|
248
|
+
[/(?:^|(?<=\n))locust\b/i, 'locust', 'load'],
|
|
249
|
+
[/(?:^|(?<=\n))pact\b/i, 'pact', 'contract'],
|
|
250
|
+
[/(?:^|(?<=\n))sdv\b/i, 'sdv', 'synthetic_data'],
|
|
251
|
+
[/(?:^|(?<=\n))faker\b/i, 'faker', 'synthetic_data'],
|
|
252
|
+
[/(?:^|(?<=\n))testcontainers\b/i, 'testcontainers', 'integration'],
|
|
253
|
+
];
|
|
254
|
+
// Language -> (framework, shape) fallbacks from harness.config.json
|
|
255
|
+
const _LANGUAGE_FALLBACKS = {
|
|
256
|
+
python: ['pytest', 'api'],
|
|
257
|
+
typescript: ['playwright', 'e2e_ui'],
|
|
258
|
+
javascript: ['playwright', 'e2e_ui'],
|
|
259
|
+
};
|
|
260
|
+
const _TEST_GLOBS = [
|
|
261
|
+
'tests/**/*.py',
|
|
262
|
+
'test/**/*.py',
|
|
263
|
+
'tests/**/*.spec.ts',
|
|
264
|
+
'tests/**/*.test.ts',
|
|
265
|
+
'tests/**/*.spec.js',
|
|
266
|
+
'tests/**/*.test.js',
|
|
267
|
+
'src/**/*.spec.ts',
|
|
268
|
+
'src/**/*.test.ts',
|
|
269
|
+
];
|
|
270
|
+
// Detects playwright UI fixture params. MULTILINE is a no-op (no `^`/`$`).
|
|
271
|
+
const _PW_UI_FIXTURE_RE = /async\s*\(\s*\{[^}]*\b(?:page|browser)\b/;
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Small filesystem / glob helpers
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
function isDir(path) {
|
|
276
|
+
try {
|
|
277
|
+
return statSync(path).isDirectory();
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function isFile(path) {
|
|
284
|
+
try {
|
|
285
|
+
return statSync(path).isFile();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/** Compile a single glob segment (with `*` -> `[^/]*`) to an anchored regex. */
|
|
292
|
+
function segGlobRegex(seg) {
|
|
293
|
+
const body = seg
|
|
294
|
+
.replace(/[.+^${}()|[\]\\?]/g, '\\$&')
|
|
295
|
+
.replace(/\*/g, '[^/]*');
|
|
296
|
+
return new RegExp(`^${body}$`);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Match files under *root* against a pathlib-style glob (`**` matches zero or
|
|
300
|
+
* more directories; `*` matches within a single segment). Mirrors the subset of
|
|
301
|
+
* `Path.glob` the migrator needs.
|
|
302
|
+
*/
|
|
303
|
+
function globFiles(root, pattern) {
|
|
304
|
+
const segments = pattern.split('/');
|
|
305
|
+
const out = [];
|
|
306
|
+
const visit = (dir, si) => {
|
|
307
|
+
const seg = segments[si];
|
|
308
|
+
const last = si === segments.length - 1;
|
|
309
|
+
if (seg === '**') {
|
|
310
|
+
// `**` consumes zero directories -> continue at the same dir.
|
|
311
|
+
visit(dir, si + 1);
|
|
312
|
+
// `**` consumes one-or-more -> descend into each subdir, staying on `**`.
|
|
313
|
+
for (const d of subDirs(dir))
|
|
314
|
+
visit(d, si);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const re = segGlobRegex(seg);
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
for (const e of entries) {
|
|
326
|
+
if (!re.test(e.name))
|
|
327
|
+
continue;
|
|
328
|
+
const full = join(dir, e.name);
|
|
329
|
+
if (last) {
|
|
330
|
+
if (e.isFile() || isFile(full))
|
|
331
|
+
out.push(full);
|
|
332
|
+
}
|
|
333
|
+
else if (e.isDirectory()) {
|
|
334
|
+
visit(full, si + 1);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
visit(root, 0);
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
function subDirs(dir) {
|
|
342
|
+
try {
|
|
343
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
344
|
+
.filter((e) => e.isDirectory())
|
|
345
|
+
.map((e) => join(dir, e.name));
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Return a human reason when *config* describes a skills/docs overlay (not a
|
|
353
|
+
* migratable test suite), else null. Conservative: fires only when there are no
|
|
354
|
+
* test `entryPoints` AND every declared layer is a docs/skills layer.
|
|
355
|
+
*/
|
|
356
|
+
function skillsDocsOverlayReason(config) {
|
|
357
|
+
if (pyTruthy(config['entryPoints']))
|
|
358
|
+
return null;
|
|
359
|
+
const layersRaw = config['layers'];
|
|
360
|
+
// Python does `config.get("layers") or []` then `for layer in layers`. If
|
|
361
|
+
// `layers` is a truthy non-list (e.g. a dict), Python iterates its keys --
|
|
362
|
+
// none pass the isinstance(dict) check, so `names` ends up empty and the
|
|
363
|
+
// result is unchanged from []. Casting a non-array to `unknown[]` and using
|
|
364
|
+
// for-of would instead THROW (not iterable), crashing detect()/migrate() on a
|
|
365
|
+
// malformed config the oracle tolerates. Only a real array yields entries.
|
|
366
|
+
const layers = Array.isArray(layersRaw) ? layersRaw : [];
|
|
367
|
+
const names = new Set();
|
|
368
|
+
for (const layer of layers) {
|
|
369
|
+
if (layer !== null && typeof layer === 'object' && !Array.isArray(layer)) {
|
|
370
|
+
const raw = layer['name'];
|
|
371
|
+
const name = (pyTruthy(raw) ? String(raw) : '').toLowerCase();
|
|
372
|
+
names.add(name);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
names.delete('');
|
|
376
|
+
const subsetOfDocSkill = [...names].every((n) => _DOC_SKILL_LAYER_NAMES.has(n));
|
|
377
|
+
if (names.size === 0 || !subsetOfDocSkill)
|
|
378
|
+
return null;
|
|
379
|
+
const sortedNames = [...names].sort().join(', ');
|
|
380
|
+
return ('harness.config.json and .harness/ are present, but this looks like a ' +
|
|
381
|
+
'skills/docs overlay (no test entryPoints; layers are only ' +
|
|
382
|
+
`${sortedNames}), not a test suite. \`canary migrate\` ` +
|
|
383
|
+
'scaffolds a test suite and has nothing to migrate here.');
|
|
384
|
+
}
|
|
385
|
+
export class MigrationContext {
|
|
386
|
+
project_root;
|
|
387
|
+
is_harness_project;
|
|
388
|
+
harness_config;
|
|
389
|
+
detected_framework;
|
|
390
|
+
detected_shape;
|
|
391
|
+
detection_source;
|
|
392
|
+
/** "config" | "content" | "language" | "none" */
|
|
393
|
+
detection_confidence;
|
|
394
|
+
config_warnings;
|
|
395
|
+
not_test_project_reason;
|
|
396
|
+
constructor(init) {
|
|
397
|
+
this.project_root = init.project_root;
|
|
398
|
+
this.is_harness_project = init.is_harness_project;
|
|
399
|
+
this.harness_config = init.harness_config ?? {};
|
|
400
|
+
this.detected_framework = init.detected_framework ?? null;
|
|
401
|
+
this.detected_shape = init.detected_shape ?? 'unknown';
|
|
402
|
+
this.detection_source = init.detection_source ?? 'none';
|
|
403
|
+
this.detection_confidence = init.detection_confidence ?? 'none';
|
|
404
|
+
this.config_warnings = init.config_warnings ?? [];
|
|
405
|
+
this.not_test_project_reason = init.not_test_project_reason ?? null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
export class SkillDeployResult {
|
|
409
|
+
skill_name;
|
|
410
|
+
/** "copied" | "updated" | "skipped" | "dry_run" */
|
|
411
|
+
status;
|
|
412
|
+
note;
|
|
413
|
+
constructor(skill_name, status, note = '') {
|
|
414
|
+
this.skill_name = skill_name;
|
|
415
|
+
this.status = status;
|
|
416
|
+
this.note = note;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
export class SkillFreshnessResult {
|
|
420
|
+
skill_name;
|
|
421
|
+
dir_name;
|
|
422
|
+
/** "current" | "stale" | "missing" | "local_edit" */
|
|
423
|
+
status;
|
|
424
|
+
detail;
|
|
425
|
+
constructor(skill_name, dir_name, status, detail = '') {
|
|
426
|
+
this.skill_name = skill_name;
|
|
427
|
+
this.dir_name = dir_name;
|
|
428
|
+
this.status = status;
|
|
429
|
+
this.detail = detail;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
export class FreshnessReport {
|
|
433
|
+
shape;
|
|
434
|
+
overlay_path;
|
|
435
|
+
results;
|
|
436
|
+
constructor(shape, overlay_path = null, results = []) {
|
|
437
|
+
this.shape = shape;
|
|
438
|
+
this.overlay_path = overlay_path;
|
|
439
|
+
this.results = results;
|
|
440
|
+
}
|
|
441
|
+
get stale() {
|
|
442
|
+
return this.results.filter((r) => r.status === 'stale' || r.status === 'missing');
|
|
443
|
+
}
|
|
444
|
+
get local_edits() {
|
|
445
|
+
return this.results.filter((r) => r.status === 'local_edit');
|
|
446
|
+
}
|
|
447
|
+
get has_drift() {
|
|
448
|
+
return this.stale.length > 0;
|
|
449
|
+
}
|
|
450
|
+
get has_local_edits() {
|
|
451
|
+
return this.local_edits.length > 0;
|
|
452
|
+
}
|
|
453
|
+
get in_sync() {
|
|
454
|
+
return !this.has_drift && !this.has_local_edits;
|
|
455
|
+
}
|
|
456
|
+
/** 0 in sync, 1 drift, 2 local edits (safety refusal wins). */
|
|
457
|
+
exit_code() {
|
|
458
|
+
if (this.has_local_edits)
|
|
459
|
+
return 2;
|
|
460
|
+
if (this.has_drift)
|
|
461
|
+
return 1;
|
|
462
|
+
return 0;
|
|
463
|
+
}
|
|
464
|
+
to_dict() {
|
|
465
|
+
return {
|
|
466
|
+
shape: this.shape,
|
|
467
|
+
overlay_path: this.overlay_path,
|
|
468
|
+
in_sync: this.in_sync,
|
|
469
|
+
has_drift: this.has_drift,
|
|
470
|
+
has_local_edits: this.has_local_edits,
|
|
471
|
+
exit_code: this.exit_code(),
|
|
472
|
+
skills: this.results.map((r) => ({
|
|
473
|
+
skill_name: r.skill_name,
|
|
474
|
+
dir_name: r.dir_name,
|
|
475
|
+
status: r.status,
|
|
476
|
+
detail: r.detail,
|
|
477
|
+
})),
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
to_markdown() {
|
|
481
|
+
const lines = [
|
|
482
|
+
'# Overlay Freshness',
|
|
483
|
+
'',
|
|
484
|
+
`**Shape:** ${this.shape}`,
|
|
485
|
+
'',
|
|
486
|
+
];
|
|
487
|
+
if (this.results.length === 0) {
|
|
488
|
+
lines.push("_No overlay skills match this project's shape._", '');
|
|
489
|
+
return lines.join('\n');
|
|
490
|
+
}
|
|
491
|
+
if (this.in_sync) {
|
|
492
|
+
lines.push(`${CHECK} In sync ${EMDASH} every deployed overlay skill is current.`, '');
|
|
493
|
+
}
|
|
494
|
+
const stale = this.results.filter((r) => r.status === 'stale');
|
|
495
|
+
const missing = this.results.filter((r) => r.status === 'missing');
|
|
496
|
+
const edits = this.local_edits;
|
|
497
|
+
if (missing.length > 0) {
|
|
498
|
+
lines.push('## Missing (overlay ships, target does not carry)', '');
|
|
499
|
+
lines.push(...missing.map((r) => `- \`${r.skill_name}\``), '');
|
|
500
|
+
}
|
|
501
|
+
if (stale.length > 0) {
|
|
502
|
+
lines.push('## Stale (overlay has a newer version)', '');
|
|
503
|
+
lines.push(...stale.map((r) => `- \`${r.skill_name}\` ${EMDASH} ${r.detail}`), '');
|
|
504
|
+
}
|
|
505
|
+
if (edits.length > 0) {
|
|
506
|
+
lines.push(`## ${WARN} Local edits (refused ${EMDASH} one-way ownership)`, '');
|
|
507
|
+
lines.push(...edits.map((r) => `- \`${r.skill_name}\` ${EMDASH} ${r.detail}`), '');
|
|
508
|
+
}
|
|
509
|
+
if (this.has_drift && !this.has_local_edits) {
|
|
510
|
+
lines.push('Run `canary migrate --from <overlay> --apply` to refresh.', '');
|
|
511
|
+
}
|
|
512
|
+
else if (this.has_local_edits) {
|
|
513
|
+
lines.push('Deployed skills are owned by the overlay. Reconcile the local ' +
|
|
514
|
+
'edits above (revert them, or upstream them into the overlay) ' +
|
|
515
|
+
'before the freshness gate can pass.', '');
|
|
516
|
+
}
|
|
517
|
+
return lines.join('\n');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
export class MigrationReport {
|
|
521
|
+
framework;
|
|
522
|
+
shape;
|
|
523
|
+
dry_run;
|
|
524
|
+
detection_source;
|
|
525
|
+
detection_confidence;
|
|
526
|
+
created_files;
|
|
527
|
+
created_dirs;
|
|
528
|
+
skipped_configs;
|
|
529
|
+
preserved_files;
|
|
530
|
+
would_create;
|
|
531
|
+
manual_followups;
|
|
532
|
+
deployed_skills;
|
|
533
|
+
config_warnings;
|
|
534
|
+
constructor(init) {
|
|
535
|
+
this.framework = init.framework;
|
|
536
|
+
this.shape = init.shape;
|
|
537
|
+
this.dry_run = init.dry_run;
|
|
538
|
+
this.detection_source = init.detection_source ?? '';
|
|
539
|
+
this.detection_confidence = init.detection_confidence ?? '';
|
|
540
|
+
this.created_files = init.created_files ?? [];
|
|
541
|
+
this.created_dirs = init.created_dirs ?? [];
|
|
542
|
+
this.skipped_configs = init.skipped_configs ?? [];
|
|
543
|
+
this.preserved_files = init.preserved_files ?? [];
|
|
544
|
+
this.would_create = init.would_create ?? [];
|
|
545
|
+
this.manual_followups = init.manual_followups ?? [];
|
|
546
|
+
this.deployed_skills = init.deployed_skills ?? [];
|
|
547
|
+
this.config_warnings = init.config_warnings ?? [];
|
|
548
|
+
}
|
|
549
|
+
to_markdown() {
|
|
550
|
+
const lines = ['# Canary Migration Report', ''];
|
|
551
|
+
if (this.dry_run) {
|
|
552
|
+
lines.push('> **Dry run** ' +
|
|
553
|
+
EMDASH +
|
|
554
|
+
' no files were written. Re-run with `--apply` to migrate.', '');
|
|
555
|
+
}
|
|
556
|
+
if (this.config_warnings.length > 0) {
|
|
557
|
+
lines.push(`## ${WARN} Config Warnings`, '');
|
|
558
|
+
for (const w of this.config_warnings)
|
|
559
|
+
lines.push(`- ${w}`);
|
|
560
|
+
lines.push('');
|
|
561
|
+
}
|
|
562
|
+
lines.push(`**Framework:** ${this.framework}`, `**Shape:** ${this.shape}`);
|
|
563
|
+
if (pyTruthy(this.detection_source) &&
|
|
564
|
+
this.detection_source !== 'none' &&
|
|
565
|
+
this.detection_source !== '') {
|
|
566
|
+
const confidenceLabel = {
|
|
567
|
+
config: `high ${EMDASH} dedicated config file`,
|
|
568
|
+
content: `medium ${EMDASH} file content / dependency scan`,
|
|
569
|
+
language: `low ${EMDASH} harness.config.json language fallback`,
|
|
570
|
+
}[this.detection_confidence] ?? this.detection_confidence;
|
|
571
|
+
lines.push(`**Detected from:** \`${this.detection_source}\``, `**Confidence:** ${confidenceLabel}`);
|
|
572
|
+
}
|
|
573
|
+
lines.push('');
|
|
574
|
+
if (this.dry_run) {
|
|
575
|
+
if (this.preserved_files.length > 0) {
|
|
576
|
+
lines.push('## Existing Tests (will be preserved)', '');
|
|
577
|
+
for (const f of this.preserved_files)
|
|
578
|
+
lines.push(`- \`${f}\``);
|
|
579
|
+
lines.push('');
|
|
580
|
+
}
|
|
581
|
+
if (this.would_create.length > 0) {
|
|
582
|
+
lines.push('## Would Create', '');
|
|
583
|
+
for (const f of this.would_create)
|
|
584
|
+
lines.push(`- \`${f}\``);
|
|
585
|
+
lines.push('');
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
lines.push('## Would Create', '', '_Nothing new ' +
|
|
589
|
+
EMDASH +
|
|
590
|
+
' project already has all Canary config files._', '');
|
|
591
|
+
}
|
|
592
|
+
if (this.skipped_configs.length > 0) {
|
|
593
|
+
lines.push('## Already Present (will not be touched)', '');
|
|
594
|
+
for (const f of this.skipped_configs)
|
|
595
|
+
lines.push(`- \`${f}\``);
|
|
596
|
+
lines.push('');
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
if (this.created_files.length > 0) {
|
|
601
|
+
lines.push('## Created Files', '');
|
|
602
|
+
for (const f of this.created_files)
|
|
603
|
+
lines.push(`- \`${f}\``);
|
|
604
|
+
lines.push('');
|
|
605
|
+
}
|
|
606
|
+
if (this.created_dirs.length > 0) {
|
|
607
|
+
lines.push('## Created Directories', '');
|
|
608
|
+
for (const d of this.created_dirs)
|
|
609
|
+
lines.push(`- \`${d}/\``);
|
|
610
|
+
lines.push('');
|
|
611
|
+
}
|
|
612
|
+
if (this.skipped_configs.length > 0) {
|
|
613
|
+
lines.push('## Skipped (already exist)', '');
|
|
614
|
+
for (const f of this.skipped_configs) {
|
|
615
|
+
lines.push(`- \`${f}\` ${EMDASH} preserved as-is`);
|
|
616
|
+
}
|
|
617
|
+
lines.push('');
|
|
618
|
+
}
|
|
619
|
+
if (this.preserved_files.length > 0) {
|
|
620
|
+
lines.push('## Existing Tests Preserved', '');
|
|
621
|
+
for (const f of this.preserved_files)
|
|
622
|
+
lines.push(`- \`${f}\``);
|
|
623
|
+
lines.push('');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (this.deployed_skills.length > 0) {
|
|
627
|
+
const copied = this.deployed_skills.filter((r) => r.status === 'copied' ||
|
|
628
|
+
r.status === 'dry_run' ||
|
|
629
|
+
r.status === 'updated');
|
|
630
|
+
const skipped = this.deployed_skills.filter((r) => r.status === 'skipped');
|
|
631
|
+
const section = this.dry_run
|
|
632
|
+
? '## Skills (would deploy)'
|
|
633
|
+
: '## Skills Deployed';
|
|
634
|
+
lines.push(section, '');
|
|
635
|
+
for (const r of copied) {
|
|
636
|
+
const prefix = r.status === 'dry_run' ? '(dry run) ' : '';
|
|
637
|
+
const verb = r.status === 'updated' ? 'refreshed in' : 'copied to';
|
|
638
|
+
lines.push(`- \`${r.skill_name}\` ${EMDASH} ${prefix}${verb} \`.canary/skills/\``);
|
|
639
|
+
}
|
|
640
|
+
for (const r of skipped) {
|
|
641
|
+
lines.push(`- \`${r.skill_name}\` ${EMDASH} skipped (${r.note})`);
|
|
642
|
+
}
|
|
643
|
+
lines.push('');
|
|
644
|
+
}
|
|
645
|
+
if (this.manual_followups.length > 0) {
|
|
646
|
+
lines.push('## Manual Follow-ups Required', '');
|
|
647
|
+
for (const item of this.manual_followups)
|
|
648
|
+
lines.push(`- ${item}`);
|
|
649
|
+
lines.push('');
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
lines.push('## Status', '', 'Migration complete. Run `canary recommend "<test description>"` to verify framework detection.', '');
|
|
653
|
+
}
|
|
654
|
+
return lines.join('\n');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/** Detects harness test-suite projects and migrates them to Canary's layout. */
|
|
658
|
+
export class HarnessMigrator {
|
|
659
|
+
home;
|
|
660
|
+
/** `home` is a TS-only test seam (Python patches `Path.home()`). */
|
|
661
|
+
constructor(home) {
|
|
662
|
+
this.home = home ?? homedir();
|
|
663
|
+
}
|
|
664
|
+
detect(projectRoot) {
|
|
665
|
+
const hasConfig = existsSync(join(projectRoot, 'harness.config.json'));
|
|
666
|
+
const hasHarnessDir = isDir(join(projectRoot, '.harness'));
|
|
667
|
+
const isHarness = hasConfig && hasHarnessDir;
|
|
668
|
+
if (!isHarness) {
|
|
669
|
+
return new MigrationContext({
|
|
670
|
+
project_root: projectRoot,
|
|
671
|
+
is_harness_project: false,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
const configWarnings = [];
|
|
675
|
+
const [rawConfig, warning] = readJsonWithWarning(join(projectRoot, 'harness.config.json'));
|
|
676
|
+
if (pyTruthy(warning))
|
|
677
|
+
configWarnings.push(warning);
|
|
678
|
+
let harnessConfig = pyTruthy(rawConfig)
|
|
679
|
+
? rawConfig
|
|
680
|
+
: {};
|
|
681
|
+
// Merge canary_shape from .canary/company.json so _detectFramework can honor
|
|
682
|
+
// an explicit shape override. A malformed company.json warns but never blocks
|
|
683
|
+
// detection.
|
|
684
|
+
const canaryCompany = join(projectRoot, '.canary', 'company.json');
|
|
685
|
+
const [overlay, warning2] = readJsonWithWarning(canaryCompany);
|
|
686
|
+
if (pyTruthy(warning2))
|
|
687
|
+
configWarnings.push(warning2);
|
|
688
|
+
if (pyTruthy(overlay) &&
|
|
689
|
+
Object.prototype.hasOwnProperty.call(overlay, 'canary_shape')) {
|
|
690
|
+
harnessConfig = {
|
|
691
|
+
...harnessConfig,
|
|
692
|
+
canary_shape: overlay['canary_shape'],
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
// #319 C: refuse a skills/docs overlay with a distinct reason.
|
|
696
|
+
const overlayReason = skillsDocsOverlayReason(harnessConfig);
|
|
697
|
+
if (overlayReason) {
|
|
698
|
+
return new MigrationContext({
|
|
699
|
+
project_root: projectRoot,
|
|
700
|
+
is_harness_project: false,
|
|
701
|
+
harness_config: harnessConfig,
|
|
702
|
+
config_warnings: configWarnings,
|
|
703
|
+
not_test_project_reason: overlayReason,
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
const [framework, shape, source, confidence] = this.detectFramework(projectRoot, harnessConfig);
|
|
707
|
+
return new MigrationContext({
|
|
708
|
+
project_root: projectRoot,
|
|
709
|
+
is_harness_project: true,
|
|
710
|
+
harness_config: harnessConfig,
|
|
711
|
+
detected_framework: framework,
|
|
712
|
+
detected_shape: shape,
|
|
713
|
+
detection_source: source,
|
|
714
|
+
detection_confidence: confidence,
|
|
715
|
+
config_warnings: configWarnings,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
migrate(projectRoot, options = {}) {
|
|
719
|
+
const dryRun = options.dryRun ?? true;
|
|
720
|
+
const framework = options.framework ?? null;
|
|
721
|
+
const overlayPath = options.overlayPath ?? null;
|
|
722
|
+
const ctx = this.detect(projectRoot);
|
|
723
|
+
if (!ctx.is_harness_project) {
|
|
724
|
+
if (ctx.not_test_project_reason)
|
|
725
|
+
throw new Error(ctx.not_test_project_reason);
|
|
726
|
+
throw new Error(`No harness project detected at ${projectRoot}. ` +
|
|
727
|
+
'Expected harness.config.json and .harness/ directory.');
|
|
728
|
+
}
|
|
729
|
+
const effectiveFramework = pyTruthy(framework)
|
|
730
|
+
? framework
|
|
731
|
+
: ctx.detected_framework;
|
|
732
|
+
const shape = ctx.detected_shape;
|
|
733
|
+
const source = pyTruthy(framework) ? 'CLI override' : ctx.detection_source;
|
|
734
|
+
const confidence = pyTruthy(framework)
|
|
735
|
+
? 'config'
|
|
736
|
+
: ctx.detection_confidence;
|
|
737
|
+
const followups = [];
|
|
738
|
+
if (effectiveFramework === null) {
|
|
739
|
+
followups.push(uncertainDetectionMessage('test framework', {
|
|
740
|
+
reason: 'no config file, dependency, or language marker matched a known framework',
|
|
741
|
+
candidates: KNOWN_FRAMEWORKS,
|
|
742
|
+
overrideHint: '`canary migrate --framework <name>`',
|
|
743
|
+
}));
|
|
744
|
+
// Issue #295 point 3: a detection miss must not block skill deployment.
|
|
745
|
+
const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
|
|
746
|
+
return new MigrationReport({
|
|
747
|
+
framework: 'unknown',
|
|
748
|
+
shape,
|
|
749
|
+
dry_run: dryRun,
|
|
750
|
+
detection_source: source,
|
|
751
|
+
detection_confidence: confidence,
|
|
752
|
+
manual_followups: followups,
|
|
753
|
+
config_warnings: ctx.config_warnings,
|
|
754
|
+
deployed_skills: deployed,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
// A framework canary knows but cannot scaffold gets no config boilerplate.
|
|
758
|
+
// Surface that loudly (which also suppresses the "Migration complete" status).
|
|
759
|
+
const reg = new FrameworkRegistry();
|
|
760
|
+
if (reg.findByName(effectiveFramework) !== null &&
|
|
761
|
+
!scaffoldableFrameworks().has(effectiveFramework)) {
|
|
762
|
+
const cmd = reg.executionInfo(effectiveFramework)?.execution_command ?? null;
|
|
763
|
+
const run = pyTruthy(cmd) ? ` Run its tests via \`${cmd}\`.` : '';
|
|
764
|
+
followups.push(`No scaffold template for '${effectiveFramework}' yet ${EMDASH} the ` +
|
|
765
|
+
'layout and skills were migrated, but test config was not ' +
|
|
766
|
+
`scaffolded; set it up manually.${run}`);
|
|
767
|
+
}
|
|
768
|
+
const preserved = this.findExistingTests(projectRoot);
|
|
769
|
+
const scaffolder = new Scaffolder();
|
|
770
|
+
const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
|
|
771
|
+
if (dryRun) {
|
|
772
|
+
const tmpl = TEMPLATES[effectiveFramework];
|
|
773
|
+
const files = tmpl?.files ?? {};
|
|
774
|
+
const dirs = tmpl?.dirs ?? [];
|
|
775
|
+
const wouldCreate = [
|
|
776
|
+
...Object.keys(files).filter((f) => !existsSync(join(projectRoot, f))),
|
|
777
|
+
...dirs.filter((d) => !existsSync(join(projectRoot, d))),
|
|
778
|
+
];
|
|
779
|
+
const alreadyPresent = Object.keys(files).filter((f) => existsSync(join(projectRoot, f)));
|
|
780
|
+
return new MigrationReport({
|
|
781
|
+
framework: effectiveFramework,
|
|
782
|
+
shape,
|
|
783
|
+
dry_run: true,
|
|
784
|
+
detection_source: source,
|
|
785
|
+
detection_confidence: confidence,
|
|
786
|
+
would_create: wouldCreate,
|
|
787
|
+
skipped_configs: alreadyPresent,
|
|
788
|
+
preserved_files: preserved,
|
|
789
|
+
manual_followups: followups,
|
|
790
|
+
deployed_skills: deployed,
|
|
791
|
+
config_warnings: ctx.config_warnings,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
const result = scaffolder.scaffold(effectiveFramework, String(projectRoot));
|
|
795
|
+
return new MigrationReport({
|
|
796
|
+
framework: effectiveFramework,
|
|
797
|
+
shape,
|
|
798
|
+
dry_run: false,
|
|
799
|
+
detection_source: source,
|
|
800
|
+
detection_confidence: confidence,
|
|
801
|
+
created_files: result['created_files'],
|
|
802
|
+
created_dirs: result['created_dirs'],
|
|
803
|
+
skipped_configs: result['skipped_files'],
|
|
804
|
+
preserved_files: preserved,
|
|
805
|
+
manual_followups: followups,
|
|
806
|
+
deployed_skills: deployed,
|
|
807
|
+
config_warnings: ctx.config_warnings,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
// -- private helpers --------------------------------------------------------
|
|
811
|
+
/**
|
|
812
|
+
* Return `[[SkillInfo, skillDir], ...]` for overlay skills whose `deploy_to`
|
|
813
|
+
* matches *shape* (or the `all` sentinel). Sources: *overlayPath* first, then
|
|
814
|
+
* `~/.canary/skills/`. The first definition of a name wins.
|
|
815
|
+
*/
|
|
816
|
+
collectOverlaySkills(shape, overlayPath) {
|
|
817
|
+
const candidateRoots = [];
|
|
818
|
+
if (overlayPath !== null)
|
|
819
|
+
candidateRoots.push(overlayPath);
|
|
820
|
+
const homeSkills = join(this.home, '.canary', 'skills');
|
|
821
|
+
if (isDir(homeSkills))
|
|
822
|
+
candidateRoots.push(this.home); // registry walks up
|
|
823
|
+
const reg = new SkillRegistry();
|
|
824
|
+
const collected = [];
|
|
825
|
+
const seenNames = new Set();
|
|
826
|
+
for (const root of candidateRoots) {
|
|
827
|
+
let overlaySkillsDir = join(root, '.canary', 'skills');
|
|
828
|
+
if (!isDir(overlaySkillsDir)) {
|
|
829
|
+
// Maybe root IS the .canary/skills dir directly.
|
|
830
|
+
if (basename(root) === 'skills' &&
|
|
831
|
+
basename(resolve(root, '..')) === '.canary') {
|
|
832
|
+
overlaySkillsDir = root;
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
let entries;
|
|
839
|
+
try {
|
|
840
|
+
entries = readdirSync(overlaySkillsDir).sort();
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
entries = [];
|
|
844
|
+
}
|
|
845
|
+
for (const name of entries) {
|
|
846
|
+
const skillDir = join(overlaySkillsDir, name);
|
|
847
|
+
if (!isDir(skillDir))
|
|
848
|
+
continue;
|
|
849
|
+
const skillMd = join(skillDir, 'SKILL.md');
|
|
850
|
+
if (!existsSync(skillMd))
|
|
851
|
+
continue;
|
|
852
|
+
// Parse frontmatter directly to avoid full registry overhead.
|
|
853
|
+
const info = reg.parseNested(skillMd, name, 'overlay');
|
|
854
|
+
if (info === null || seenNames.has(info.name))
|
|
855
|
+
continue;
|
|
856
|
+
if (!pyTruthy(info.deploy_to))
|
|
857
|
+
continue;
|
|
858
|
+
if (!info.deploy_to.includes(shape) &&
|
|
859
|
+
!info.deploy_to.includes('all')) {
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
seenNames.add(info.name);
|
|
863
|
+
collected.push([info, skillDir]);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return collected;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Copy skills from the overlay's `.canary/skills/` that match *shape*.
|
|
870
|
+
* Deployment is strictly one-way -- the overlay owns deployed files (#334).
|
|
871
|
+
*/
|
|
872
|
+
deploySkills(shape, overlayPath, targetRoot, dryRun) {
|
|
873
|
+
const results = [];
|
|
874
|
+
const skillsToDeploy = this.collectOverlaySkills(shape, overlayPath);
|
|
875
|
+
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
876
|
+
const manifest = readDeployManifest(targetSkillsDir);
|
|
877
|
+
let manifestDirty = false;
|
|
878
|
+
for (const [info, skillDir] of skillsToDeploy) {
|
|
879
|
+
const dirName = basename(skillDir);
|
|
880
|
+
const dest = join(targetSkillsDir, dirName);
|
|
881
|
+
const overlayHash = hashSkillDir(skillDir);
|
|
882
|
+
if (existsSync(dest)) {
|
|
883
|
+
const targetHash = hashSkillDir(dest);
|
|
884
|
+
if (targetHash === overlayHash) {
|
|
885
|
+
results.push(new SkillDeployResult(info.name, 'skipped', 'already current'));
|
|
886
|
+
if (manifest[dirName]?.hash !== overlayHash) {
|
|
887
|
+
manifest[dirName] = { name: info.name, hash: overlayHash };
|
|
888
|
+
manifestDirty = true;
|
|
889
|
+
}
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const recorded = manifest[dirName]?.hash;
|
|
893
|
+
if (recorded === undefined || targetHash !== recorded) {
|
|
894
|
+
// Hand-edited (or unprovenanced) -- one-way ownership refuses to clobber.
|
|
895
|
+
results.push(new SkillDeployResult(info.name, 'skipped', `local edits ${EMDASH} not overwritten`));
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
// Untouched since deploy; overlay moved on -> safe to refresh.
|
|
899
|
+
if (dryRun) {
|
|
900
|
+
results.push(new SkillDeployResult(info.name, 'dry_run', 'would update'));
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
rmSync(dest, { recursive: true, force: true });
|
|
904
|
+
cpSync(skillDir, dest, { recursive: true });
|
|
905
|
+
manifest[dirName] = { name: info.name, hash: overlayHash };
|
|
906
|
+
manifestDirty = true;
|
|
907
|
+
results.push(new SkillDeployResult(info.name, 'updated'));
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
if (dryRun) {
|
|
911
|
+
results.push(new SkillDeployResult(info.name, 'dry_run'));
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
mkdirSync(targetSkillsDir, { recursive: true });
|
|
915
|
+
cpSync(skillDir, dest, { recursive: true });
|
|
916
|
+
manifest[dirName] = { name: info.name, hash: overlayHash };
|
|
917
|
+
manifestDirty = true;
|
|
918
|
+
results.push(new SkillDeployResult(info.name, 'copied'));
|
|
919
|
+
}
|
|
920
|
+
if (manifestDirty && !dryRun)
|
|
921
|
+
writeDeployManifest(targetSkillsDir, manifest);
|
|
922
|
+
return results;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Compare the overlay's deployable skills against what *projectRoot* carries,
|
|
926
|
+
* without writing anything (#334).
|
|
927
|
+
*/
|
|
928
|
+
checkFreshness(projectRoot, options) {
|
|
929
|
+
const overlayPath = options.overlayPath;
|
|
930
|
+
const ctx = this.detect(projectRoot);
|
|
931
|
+
if (!ctx.is_harness_project) {
|
|
932
|
+
if (ctx.not_test_project_reason)
|
|
933
|
+
throw new Error(ctx.not_test_project_reason);
|
|
934
|
+
throw new Error(`No harness project detected at ${projectRoot}. ` +
|
|
935
|
+
'Expected harness.config.json and .harness/ directory.');
|
|
936
|
+
}
|
|
937
|
+
const shape = ctx.detected_shape;
|
|
938
|
+
const skills = this.collectOverlaySkills(shape, overlayPath);
|
|
939
|
+
const targetSkillsDir = join(projectRoot, '.canary', 'skills');
|
|
940
|
+
const manifest = readDeployManifest(targetSkillsDir);
|
|
941
|
+
const results = [];
|
|
942
|
+
for (const [info, skillDir] of skills) {
|
|
943
|
+
const dirName = basename(skillDir);
|
|
944
|
+
const dest = join(targetSkillsDir, dirName);
|
|
945
|
+
if (!existsSync(dest)) {
|
|
946
|
+
results.push(new SkillFreshnessResult(info.name, dirName, 'missing', 'overlay ships this skill; target does not carry it'));
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const overlayHash = hashSkillDir(skillDir);
|
|
950
|
+
const targetHash = hashSkillDir(dest);
|
|
951
|
+
if (targetHash === overlayHash) {
|
|
952
|
+
results.push(new SkillFreshnessResult(info.name, dirName, 'current'));
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
const recorded = manifest[dirName]?.hash;
|
|
956
|
+
if (recorded !== undefined && targetHash === recorded) {
|
|
957
|
+
results.push(new SkillFreshnessResult(info.name, dirName, 'stale', 'overlay has a newer version'));
|
|
958
|
+
}
|
|
959
|
+
else {
|
|
960
|
+
results.push(new SkillFreshnessResult(info.name, dirName, 'local_edit', 'deployed skill has local edits; refusing to overwrite'));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results);
|
|
964
|
+
}
|
|
965
|
+
detectFramework(root, config) {
|
|
966
|
+
// 0. Explicit override in .canary/company.json ("canary_shape" field).
|
|
967
|
+
const rawShape = config['canary_shape'];
|
|
968
|
+
const explicitShape = (rawShape == null ? '' : String(rawShape))
|
|
969
|
+
.trim()
|
|
970
|
+
.toLowerCase();
|
|
971
|
+
if (explicitShape) {
|
|
972
|
+
for (const [filename, framework, , confidence] of _CONFIG_PROBES) {
|
|
973
|
+
if (existsSync(join(root, filename))) {
|
|
974
|
+
return [framework, explicitShape, filename, confidence];
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
// Fall through to content probes.
|
|
978
|
+
}
|
|
979
|
+
// 1. Dedicated config file (highest confidence).
|
|
980
|
+
for (const [filename, framework, shape, confidence] of _CONFIG_PROBES) {
|
|
981
|
+
if (existsSync(join(root, filename))) {
|
|
982
|
+
// For playwright config files, distinguish API vs UI suites.
|
|
983
|
+
if (framework === 'playwright' && shape === 'e2e_ui') {
|
|
984
|
+
const inferred = inferPlaywrightShape(root);
|
|
985
|
+
if (inferred !== shape)
|
|
986
|
+
return [framework, inferred, filename, 'content'];
|
|
987
|
+
}
|
|
988
|
+
return [framework, shape, filename, confidence];
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// 2. pyproject.toml section markers then dependency scan.
|
|
992
|
+
const pyproject = join(root, 'pyproject.toml');
|
|
993
|
+
if (existsSync(pyproject)) {
|
|
994
|
+
const content = readTextOrNull(pyproject);
|
|
995
|
+
if (content !== null) {
|
|
996
|
+
for (const [marker, framework, shape] of _PYPROJECT_MARKERS) {
|
|
997
|
+
if (content.includes(marker)) {
|
|
998
|
+
return [framework, shape, 'pyproject.toml', 'content'];
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
|
|
1002
|
+
if (pattern.test(content)) {
|
|
1003
|
+
return [
|
|
1004
|
+
framework,
|
|
1005
|
+
shape,
|
|
1006
|
+
'pyproject.toml (dependencies)',
|
|
1007
|
+
'content',
|
|
1008
|
+
];
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// 3. requirements*.txt dependency scan.
|
|
1014
|
+
for (const reqFile of [
|
|
1015
|
+
'requirements.txt',
|
|
1016
|
+
'requirements-test.txt',
|
|
1017
|
+
'requirements-dev.txt',
|
|
1018
|
+
]) {
|
|
1019
|
+
const reqPath = join(root, reqFile);
|
|
1020
|
+
if (existsSync(reqPath)) {
|
|
1021
|
+
const content = readTextOrNull(reqPath);
|
|
1022
|
+
if (content !== null) {
|
|
1023
|
+
for (const [pattern, framework, shape] of _PYTHON_DEP_PATTERNS) {
|
|
1024
|
+
if (pattern.test(content))
|
|
1025
|
+
return [framework, shape, reqFile, 'content'];
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// 4. package.json scripts.test scan.
|
|
1031
|
+
const pkgJson = join(root, 'package.json');
|
|
1032
|
+
if (existsSync(pkgJson)) {
|
|
1033
|
+
try {
|
|
1034
|
+
const pkg = JSON.parse(readFileSync(pkgJson, 'utf-8'));
|
|
1035
|
+
const scripts = (pkg['scripts'] ?? {});
|
|
1036
|
+
const testScript = String(scripts['test'] ?? '');
|
|
1037
|
+
for (const [pattern, framework, shape] of _PACKAGE_SCRIPT_PATTERNS) {
|
|
1038
|
+
if (pattern.test(testScript)) {
|
|
1039
|
+
return [framework, shape, 'package.json (scripts.test)', 'content'];
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
// OSError / JSONDecodeError -> ignore.
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// 5. Language fallback from harness config.
|
|
1048
|
+
const language = String(config['language'] ?? '').toLowerCase();
|
|
1049
|
+
if (Object.prototype.hasOwnProperty.call(_LANGUAGE_FALLBACKS, language)) {
|
|
1050
|
+
const [fw, shape] = _LANGUAGE_FALLBACKS[language];
|
|
1051
|
+
return [
|
|
1052
|
+
fw,
|
|
1053
|
+
shape,
|
|
1054
|
+
`harness.config.json (language: ${language})`,
|
|
1055
|
+
'language',
|
|
1056
|
+
];
|
|
1057
|
+
}
|
|
1058
|
+
return [null, 'unknown', 'none', 'none'];
|
|
1059
|
+
}
|
|
1060
|
+
findExistingTests(root) {
|
|
1061
|
+
const found = [];
|
|
1062
|
+
for (const pattern of _TEST_GLOBS) {
|
|
1063
|
+
// Python sorts Path objects (component-wise); match it so preserved-files
|
|
1064
|
+
// report order agrees (see comparePathParts).
|
|
1065
|
+
for (const path of globFiles(root, pattern).sort(comparePathParts)) {
|
|
1066
|
+
const rel = relative(root, path).split(sep).join('/');
|
|
1067
|
+
if (!found.includes(rel))
|
|
1068
|
+
found.push(rel);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return found;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function readTextOrNull(path) {
|
|
1075
|
+
try {
|
|
1076
|
+
return readFileSync(path, 'utf-8');
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Return 'api' when no playwright spec file uses page/browser fixtures, else
|
|
1084
|
+
* 'e2e_ui' (the default when any UI signal is found or no spec files exist).
|
|
1085
|
+
*/
|
|
1086
|
+
function inferPlaywrightShape(root) {
|
|
1087
|
+
const specGlobs = [
|
|
1088
|
+
'tests/**/*.spec.ts',
|
|
1089
|
+
'tests/**/*.spec.js',
|
|
1090
|
+
'test/**/*.spec.ts',
|
|
1091
|
+
'test/**/*.spec.js',
|
|
1092
|
+
];
|
|
1093
|
+
let total = 0;
|
|
1094
|
+
for (const glob of specGlobs) {
|
|
1095
|
+
for (const path of globFiles(root, glob)) {
|
|
1096
|
+
// Python read_text(errors="ignore"); readFileSync substitutes U+FFFD for
|
|
1097
|
+
// invalid bytes -- immaterial for the ASCII fixture pattern below.
|
|
1098
|
+
let content;
|
|
1099
|
+
try {
|
|
1100
|
+
content = readFileSync(path, 'utf-8');
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
total += 1;
|
|
1106
|
+
if (_PW_UI_FIXTURE_RE.test(content))
|
|
1107
|
+
return 'e2e_ui';
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return total > 0 ? 'api' : 'e2e_ui';
|
|
1111
|
+
}
|
|
1112
|
+
//# sourceMappingURL=migrator.js.map
|