canary-test-cli 6.1.0 → 6.3.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/dist/engine/analysis/reachability.js +305 -0
- package/dist/engine/cli-commands.js +2 -0
- package/dist/engine/cli.core.js +1 -0
- package/dist/engine/core/company-knowledge.js +131 -40
- package/dist/engine/core/migrator.js +317 -20
- package/dist/engine/guardian/agent-tier.js +7 -2
- package/dist/engine/guardian/cli.js +232 -9
- package/dist/engine/guardian/coverage.js +90 -0
- package/dist/engine/guardian/pr-check.js +194 -20
- package/package.json +1 -1
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
import { createHash } from 'node:crypto';
|
|
40
40
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
41
41
|
import { homedir } from 'node:os';
|
|
42
|
-
import { basename, join, relative, resolve, sep } from 'node:path';
|
|
42
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
43
43
|
import { readJsonWithWarning } from './config-validation.js';
|
|
44
44
|
import { uncertainDetectionMessage } from './detection.js';
|
|
45
45
|
import { FrameworkRegistry } from './framework-registry.js';
|
|
@@ -147,34 +147,127 @@ function collectRelFiles(dir) {
|
|
|
147
147
|
walk(dir, '');
|
|
148
148
|
return out;
|
|
149
149
|
}
|
|
150
|
+
/** An object-valued manifest section, or `{}` when absent/malformed. */
|
|
151
|
+
function manifestSection(data, key) {
|
|
152
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
const section = data[key];
|
|
156
|
+
if (section === null ||
|
|
157
|
+
typeof section !== 'object' ||
|
|
158
|
+
Array.isArray(section)) {
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
return section;
|
|
162
|
+
}
|
|
150
163
|
/**
|
|
151
|
-
*
|
|
152
|
-
* unreadable
|
|
164
|
+
* Read the whole manifest document (skills + workflows). Both sections default
|
|
165
|
+
* to `{}` when the file is absent, unreadable, or malformed -- provenance is
|
|
166
|
+
* best-effort and never blocks a deploy.
|
|
153
167
|
*/
|
|
154
|
-
function
|
|
168
|
+
function readManifestDoc(targetSkillsDir) {
|
|
155
169
|
const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
|
|
156
170
|
let data;
|
|
157
171
|
try {
|
|
158
172
|
data = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
159
173
|
}
|
|
160
174
|
catch {
|
|
161
|
-
return {};
|
|
175
|
+
return { skills: {}, workflows: {} };
|
|
162
176
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return {};
|
|
168
|
-
}
|
|
169
|
-
return skills;
|
|
177
|
+
return {
|
|
178
|
+
skills: manifestSection(data, 'skills'),
|
|
179
|
+
workflows: manifestSection(data, 'workflows'),
|
|
180
|
+
};
|
|
170
181
|
}
|
|
171
|
-
|
|
182
|
+
/**
|
|
183
|
+
* Write the manifest. Read-modify-write of the whole document, because the
|
|
184
|
+
* skill-deploy phase and the workflow-install phase each own one section and
|
|
185
|
+
* both write this one file. `workflows` is omitted entirely when empty so a
|
|
186
|
+
* skills-only manifest keeps its historical bytes.
|
|
187
|
+
*/
|
|
188
|
+
function writeManifestDoc(targetSkillsDir, doc) {
|
|
172
189
|
const manifestPath = join(targetSkillsDir, DEPLOY_MANIFEST_NAME);
|
|
173
190
|
mkdirSync(targetSkillsDir, { recursive: true });
|
|
174
|
-
const
|
|
191
|
+
const payload = {
|
|
192
|
+
schemaVersion: 1,
|
|
193
|
+
skills: doc.skills,
|
|
194
|
+
};
|
|
195
|
+
if (Object.keys(doc.workflows).length > 0)
|
|
196
|
+
payload['workflows'] = doc.workflows;
|
|
197
|
+
const body = ensureAscii(JSON.stringify(payload, null, 2)) + '\n';
|
|
175
198
|
writeFileSync(manifestPath, body, 'utf-8');
|
|
176
199
|
}
|
|
177
200
|
// ---------------------------------------------------------------------------
|
|
201
|
+
// Workflow templates (#459)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
/** Frontmatter key: templates this skill installs into `.github/workflows/`. */
|
|
204
|
+
const WORKFLOW_DECL_KEY = 'install_workflows';
|
|
205
|
+
/** Frontmatter key: the version stamped into the deploy manifest. */
|
|
206
|
+
const WORKFLOW_VERSION_KEY = 'workflow_template_version';
|
|
207
|
+
/** Default when a skill declares templates but no explicit version. */
|
|
208
|
+
const WORKFLOW_DEFAULT_VERSION = '1';
|
|
209
|
+
/**
|
|
210
|
+
* An optional `<shape>:` prefix on a declared entry, e.g.
|
|
211
|
+
* `api:templates/guardian-api.yml`. Two or more leading characters are required
|
|
212
|
+
* so a Windows drive letter (`C:\...`) can never be mistaken for a shape -- such
|
|
213
|
+
* a path is refused by {@link resolveTemplatePath} instead.
|
|
214
|
+
*/
|
|
215
|
+
const _WORKFLOW_ENTRY_RE = /^([A-Za-z][A-Za-z0-9_-]+):(.+)$/;
|
|
216
|
+
function sha256(bytes) {
|
|
217
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
218
|
+
}
|
|
219
|
+
/** File bytes, or null when unreadable (e.g. the path is a directory). */
|
|
220
|
+
function readBytesOrNull(path) {
|
|
221
|
+
try {
|
|
222
|
+
return readFileSync(path);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The workflow declaration in a skill's SKILL.md frontmatter.
|
|
230
|
+
*
|
|
231
|
+
* Parsed here rather than on {@link SkillInfo} because these fields are
|
|
232
|
+
* migrator-specific: the registry's job is discovery, and every other consumer
|
|
233
|
+
* of `SkillInfo` would carry two fields it never reads.
|
|
234
|
+
*/
|
|
235
|
+
function readWorkflowDeclaration(skillMd) {
|
|
236
|
+
const text = readTextOrNull(skillMd);
|
|
237
|
+
if (text === null)
|
|
238
|
+
return { entries: [], version: WORKFLOW_DEFAULT_VERSION };
|
|
239
|
+
const fm = SkillRegistry.parseFrontmatter(text);
|
|
240
|
+
const entries = SkillRegistry.parseStrList(fm, WORKFLOW_DECL_KEY);
|
|
241
|
+
const rawVersion = fm[WORKFLOW_VERSION_KEY];
|
|
242
|
+
const version = typeof rawVersion === 'string' && rawVersion.trim()
|
|
243
|
+
? rawVersion.trim()
|
|
244
|
+
: WORKFLOW_DEFAULT_VERSION;
|
|
245
|
+
return { entries, version };
|
|
246
|
+
}
|
|
247
|
+
/** Split `[<shape>:]<relative-path>` into its shape filter and path. */
|
|
248
|
+
function parseWorkflowEntry(entry) {
|
|
249
|
+
const m = _WORKFLOW_ENTRY_RE.exec(entry.trim());
|
|
250
|
+
if (m === null)
|
|
251
|
+
return [null, entry.trim()];
|
|
252
|
+
return [m[1].toLowerCase(), m[2].trim()];
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Resolve a declared template path inside *skillDir*, or null when it escapes.
|
|
256
|
+
*
|
|
257
|
+
* An overlay is third-party content, so a declared path is untrusted input: an
|
|
258
|
+
* absolute path or a `..` climb would let an overlay copy an arbitrary file
|
|
259
|
+
* from the machine running `migrate` into the consumer's CI directory.
|
|
260
|
+
*/
|
|
261
|
+
function resolveTemplatePath(skillDir, rel) {
|
|
262
|
+
if (!rel || isAbsolute(rel) || /^[A-Za-z]:/.test(rel))
|
|
263
|
+
return null;
|
|
264
|
+
const base = resolve(skillDir);
|
|
265
|
+
const full = resolve(base, rel);
|
|
266
|
+
if (full !== base && !full.startsWith(base + sep))
|
|
267
|
+
return null;
|
|
268
|
+
return full;
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
178
271
|
// Constants / probes
|
|
179
272
|
// ---------------------------------------------------------------------------
|
|
180
273
|
/**
|
|
@@ -416,6 +509,43 @@ export class SkillDeployResult {
|
|
|
416
509
|
this.note = note;
|
|
417
510
|
}
|
|
418
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* The outcome of installing one declared workflow template (#459).
|
|
514
|
+
*
|
|
515
|
+
* `status` is one of:
|
|
516
|
+
* - `installed` -- the target had no such workflow; it was written
|
|
517
|
+
* - `skipped` -- byte-identical to the template; nothing to do
|
|
518
|
+
* - `outdated` -- differs, but is unmodified since canary installed it, so
|
|
519
|
+
* the overlay simply moved on (a template fix is waiting)
|
|
520
|
+
* - `conflict` -- differs and was edited locally / has unknown provenance
|
|
521
|
+
* - `updated` -- differed and `--force` replaced it
|
|
522
|
+
* - `dry_run` -- what an `--apply` run would have done
|
|
523
|
+
* - `missing` -- the overlay declares a template it does not ship
|
|
524
|
+
* - `invalid` -- the declared path escapes the skill directory (refused)
|
|
525
|
+
*
|
|
526
|
+
* `outdated` and `conflict` are REPORTS. Neither ever writes.
|
|
527
|
+
*/
|
|
528
|
+
export class WorkflowInstallResult {
|
|
529
|
+
/** File name under `.github/workflows/`. */
|
|
530
|
+
workflow;
|
|
531
|
+
skill_name;
|
|
532
|
+
status;
|
|
533
|
+
detail;
|
|
534
|
+
constructor(workflow, skill_name, status, detail = '') {
|
|
535
|
+
this.workflow = workflow;
|
|
536
|
+
this.skill_name = skill_name;
|
|
537
|
+
this.status = status;
|
|
538
|
+
this.detail = detail;
|
|
539
|
+
}
|
|
540
|
+
to_dict() {
|
|
541
|
+
return {
|
|
542
|
+
workflow: this.workflow,
|
|
543
|
+
skill_name: this.skill_name,
|
|
544
|
+
status: this.status,
|
|
545
|
+
detail: this.detail,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
419
549
|
export class SkillFreshnessResult {
|
|
420
550
|
skill_name;
|
|
421
551
|
dir_name;
|
|
@@ -433,10 +563,21 @@ export class FreshnessReport {
|
|
|
433
563
|
shape;
|
|
434
564
|
overlay_path;
|
|
435
565
|
results;
|
|
436
|
-
|
|
566
|
+
/**
|
|
567
|
+
* What a workflow install WOULD do (#459) -- informational only.
|
|
568
|
+
*
|
|
569
|
+
* Deliberately excluded from `has_drift` / `has_local_edits` / `exit_code`:
|
|
570
|
+
* the freshness gate speaks for overlay-owned skills, and a consumer's
|
|
571
|
+
* `.github/workflows/` is not overlay-owned. Failing CI because someone
|
|
572
|
+
* hand-tuned their own workflow, or has not adopted one at all, would be
|
|
573
|
+
* nagging about something canary has no claim over.
|
|
574
|
+
*/
|
|
575
|
+
workflows;
|
|
576
|
+
constructor(shape, overlay_path = null, results = [], workflows = []) {
|
|
437
577
|
this.shape = shape;
|
|
438
578
|
this.overlay_path = overlay_path;
|
|
439
579
|
this.results = results;
|
|
580
|
+
this.workflows = workflows;
|
|
440
581
|
}
|
|
441
582
|
get stale() {
|
|
442
583
|
return this.results.filter((r) => r.status === 'stale' || r.status === 'missing');
|
|
@@ -475,6 +616,7 @@ export class FreshnessReport {
|
|
|
475
616
|
status: r.status,
|
|
476
617
|
detail: r.detail,
|
|
477
618
|
})),
|
|
619
|
+
workflows: this.workflows.map((r) => r.to_dict()),
|
|
478
620
|
};
|
|
479
621
|
}
|
|
480
622
|
to_markdown() {
|
|
@@ -486,6 +628,7 @@ export class FreshnessReport {
|
|
|
486
628
|
];
|
|
487
629
|
if (this.results.length === 0) {
|
|
488
630
|
lines.push("_No overlay skills match this project's shape._", '');
|
|
631
|
+
lines.push(...workflowMarkdown(this.workflows, false));
|
|
489
632
|
return lines.join('\n');
|
|
490
633
|
}
|
|
491
634
|
if (this.in_sync) {
|
|
@@ -514,9 +657,37 @@ export class FreshnessReport {
|
|
|
514
657
|
'edits above (revert them, or upstream them into the overlay) ' +
|
|
515
658
|
'before the freshness gate can pass.', '');
|
|
516
659
|
}
|
|
660
|
+
lines.push(...workflowMarkdown(this.workflows, false));
|
|
517
661
|
return lines.join('\n');
|
|
518
662
|
}
|
|
519
663
|
}
|
|
664
|
+
/**
|
|
665
|
+
* Render the workflow-install section shared by both reports.
|
|
666
|
+
*
|
|
667
|
+
* The closing note is not decoration: it is the only place a consumer is told
|
|
668
|
+
* that a reported difference will never be applied behind their back, and how
|
|
669
|
+
* to opt in when they do want the overlay's version.
|
|
670
|
+
*/
|
|
671
|
+
function workflowMarkdown(results, dryRun) {
|
|
672
|
+
if (results.length === 0)
|
|
673
|
+
return [];
|
|
674
|
+
const heading = dryRun
|
|
675
|
+
? '## Workflows (would install into `.github/workflows/`)'
|
|
676
|
+
: '## Workflows (`.github/workflows/`)';
|
|
677
|
+
const lines = [heading, ''];
|
|
678
|
+
// Every result carries a detail; the status alone would not tell a consumer
|
|
679
|
+
// which file was touched or what to do next.
|
|
680
|
+
for (const r of results) {
|
|
681
|
+
lines.push(`- \`${r.workflow}\` ${EMDASH} ${r.detail}`);
|
|
682
|
+
}
|
|
683
|
+
lines.push('');
|
|
684
|
+
if (results.some((r) => r.status === 'conflict' || r.status === 'outdated')) {
|
|
685
|
+
lines.push(`${WARN} Your CI is yours: canary never overwrites a workflow that ` +
|
|
686
|
+
'differs from the template. Re-run with `--force` to take the ' +
|
|
687
|
+
"overlay's version.", '');
|
|
688
|
+
}
|
|
689
|
+
return lines;
|
|
690
|
+
}
|
|
520
691
|
export class MigrationReport {
|
|
521
692
|
framework;
|
|
522
693
|
shape;
|
|
@@ -530,6 +701,7 @@ export class MigrationReport {
|
|
|
530
701
|
would_create;
|
|
531
702
|
manual_followups;
|
|
532
703
|
deployed_skills;
|
|
704
|
+
installed_workflows;
|
|
533
705
|
config_warnings;
|
|
534
706
|
constructor(init) {
|
|
535
707
|
this.framework = init.framework;
|
|
@@ -544,6 +716,7 @@ export class MigrationReport {
|
|
|
544
716
|
this.would_create = init.would_create ?? [];
|
|
545
717
|
this.manual_followups = init.manual_followups ?? [];
|
|
546
718
|
this.deployed_skills = init.deployed_skills ?? [];
|
|
719
|
+
this.installed_workflows = init.installed_workflows ?? [];
|
|
547
720
|
this.config_warnings = init.config_warnings ?? [];
|
|
548
721
|
}
|
|
549
722
|
to_markdown() {
|
|
@@ -642,6 +815,7 @@ export class MigrationReport {
|
|
|
642
815
|
}
|
|
643
816
|
lines.push('');
|
|
644
817
|
}
|
|
818
|
+
lines.push(...workflowMarkdown(this.installed_workflows, this.dry_run));
|
|
645
819
|
if (this.manual_followups.length > 0) {
|
|
646
820
|
lines.push('## Manual Follow-ups Required', '');
|
|
647
821
|
for (const item of this.manual_followups)
|
|
@@ -719,6 +893,7 @@ export class HarnessMigrator {
|
|
|
719
893
|
const dryRun = options.dryRun ?? true;
|
|
720
894
|
const framework = options.framework ?? null;
|
|
721
895
|
const overlayPath = options.overlayPath ?? null;
|
|
896
|
+
const force = options.force ?? false;
|
|
722
897
|
const ctx = this.detect(projectRoot);
|
|
723
898
|
if (!ctx.is_harness_project) {
|
|
724
899
|
if (ctx.not_test_project_reason)
|
|
@@ -741,7 +916,9 @@ export class HarnessMigrator {
|
|
|
741
916
|
candidates: KNOWN_FRAMEWORKS,
|
|
742
917
|
overrideHint: '`canary migrate --framework <name>`',
|
|
743
918
|
}));
|
|
744
|
-
// Issue #295 point 3: a detection miss must not block skill deployment
|
|
919
|
+
// Issue #295 point 3: a detection miss must not block skill deployment --
|
|
920
|
+
// nor, for the same reason, the workflow install (#459). The guardian
|
|
921
|
+
// workflow is exactly what an unrecognised repo most needs.
|
|
745
922
|
const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
|
|
746
923
|
return new MigrationReport({
|
|
747
924
|
framework: 'unknown',
|
|
@@ -752,6 +929,7 @@ export class HarnessMigrator {
|
|
|
752
929
|
manual_followups: followups,
|
|
753
930
|
config_warnings: ctx.config_warnings,
|
|
754
931
|
deployed_skills: deployed,
|
|
932
|
+
installed_workflows: this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force),
|
|
755
933
|
});
|
|
756
934
|
}
|
|
757
935
|
// A framework canary knows but cannot scaffold gets no config boilerplate.
|
|
@@ -768,6 +946,9 @@ export class HarnessMigrator {
|
|
|
768
946
|
const preserved = this.findExistingTests(projectRoot);
|
|
769
947
|
const scaffolder = new Scaffolder();
|
|
770
948
|
const deployed = this.deploySkills(shape, overlayPath, projectRoot, dryRun);
|
|
949
|
+
// Post-copy install phase: the template bytes already landed under
|
|
950
|
+
// .canary/skills/ with the skill; this puts them where Actions looks.
|
|
951
|
+
const installedWorkflows = this.installWorkflows(shape, overlayPath, projectRoot, dryRun, force);
|
|
771
952
|
if (dryRun) {
|
|
772
953
|
const tmpl = TEMPLATES[effectiveFramework];
|
|
773
954
|
const files = tmpl?.files ?? {};
|
|
@@ -788,6 +969,7 @@ export class HarnessMigrator {
|
|
|
788
969
|
preserved_files: preserved,
|
|
789
970
|
manual_followups: followups,
|
|
790
971
|
deployed_skills: deployed,
|
|
972
|
+
installed_workflows: installedWorkflows,
|
|
791
973
|
config_warnings: ctx.config_warnings,
|
|
792
974
|
});
|
|
793
975
|
}
|
|
@@ -804,6 +986,7 @@ export class HarnessMigrator {
|
|
|
804
986
|
preserved_files: preserved,
|
|
805
987
|
manual_followups: followups,
|
|
806
988
|
deployed_skills: deployed,
|
|
989
|
+
installed_workflows: installedWorkflows,
|
|
807
990
|
config_warnings: ctx.config_warnings,
|
|
808
991
|
});
|
|
809
992
|
}
|
|
@@ -873,7 +1056,8 @@ export class HarnessMigrator {
|
|
|
873
1056
|
const results = [];
|
|
874
1057
|
const skillsToDeploy = this.collectOverlaySkills(shape, overlayPath);
|
|
875
1058
|
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
876
|
-
const
|
|
1059
|
+
const doc = readManifestDoc(targetSkillsDir);
|
|
1060
|
+
const manifest = doc.skills;
|
|
877
1061
|
let manifestDirty = false;
|
|
878
1062
|
for (const [info, skillDir] of skillsToDeploy) {
|
|
879
1063
|
const dirName = basename(skillDir);
|
|
@@ -918,7 +1102,117 @@ export class HarnessMigrator {
|
|
|
918
1102
|
results.push(new SkillDeployResult(info.name, 'copied'));
|
|
919
1103
|
}
|
|
920
1104
|
if (manifestDirty && !dryRun)
|
|
921
|
-
|
|
1105
|
+
writeManifestDoc(targetSkillsDir, doc);
|
|
1106
|
+
return results;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Install the workflow templates the shape-matching overlay skills declare
|
|
1110
|
+
* into the target's `.github/workflows/` (#459).
|
|
1111
|
+
*
|
|
1112
|
+
* This runs AFTER the skill copy and is a distinct phase, not an extension of
|
|
1113
|
+
* it: the bytes already arrive (whole skill dirs are copied, templates
|
|
1114
|
+
* included) -- what was missing is putting them where GitHub Actions looks.
|
|
1115
|
+
*
|
|
1116
|
+
* **Ownership deliberately differs from `deploySkills`.** Deployed skills are
|
|
1117
|
+
* owned one-way by the overlay (#334); a consumer's CI is NOT. Absent ->
|
|
1118
|
+
* write. Byte-identical -> no-op. Different -> report and leave alone, always,
|
|
1119
|
+
* whatever the provenance. `force` is the deliberate escape hatch. Clobbering
|
|
1120
|
+
* a hand-tuned workflow -- or nagging that it is "stale" via an exit code --
|
|
1121
|
+
* would be a worse failure than the partial adoption this fixes.
|
|
1122
|
+
*
|
|
1123
|
+
* Shape selection reuses the same resolved `canary_shape` that drives
|
|
1124
|
+
* `deploy_to` matching: skills are gated by {@link collectOverlaySkills}, and
|
|
1125
|
+
* an entry may additionally carry a `<shape>:` prefix to pick a variant.
|
|
1126
|
+
*/
|
|
1127
|
+
installWorkflows(shape, overlayPath, targetRoot, dryRun, force = false) {
|
|
1128
|
+
const results = [];
|
|
1129
|
+
const skills = this.collectOverlaySkills(shape, overlayPath);
|
|
1130
|
+
const targetSkillsDir = join(targetRoot, '.canary', 'skills');
|
|
1131
|
+
const doc = readManifestDoc(targetSkillsDir);
|
|
1132
|
+
const workflowsDir = join(targetRoot, '.github', 'workflows');
|
|
1133
|
+
let manifestDirty = false;
|
|
1134
|
+
for (const [info, skillDir] of skills) {
|
|
1135
|
+
const { entries, version } = readWorkflowDeclaration(info.path);
|
|
1136
|
+
for (const entry of entries) {
|
|
1137
|
+
const [wantShape, rel] = parseWorkflowEntry(entry);
|
|
1138
|
+
if (wantShape !== null && wantShape !== shape && wantShape !== 'all') {
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const src = resolveTemplatePath(skillDir, rel);
|
|
1142
|
+
if (src === null) {
|
|
1143
|
+
results.push(new WorkflowInstallResult(basename(rel), info.name, 'invalid', `declared template '${rel}' resolves outside the skill directory ` +
|
|
1144
|
+
`${EMDASH} refused`));
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (!isFile(src)) {
|
|
1148
|
+
results.push(new WorkflowInstallResult(basename(rel), info.name, 'missing', `the overlay declares '${rel}' but does not ship it`));
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
const name = basename(src);
|
|
1152
|
+
const dest = join(workflowsDir, name);
|
|
1153
|
+
const templateBytes = readFileSync(src);
|
|
1154
|
+
const templateHash = sha256(templateBytes);
|
|
1155
|
+
const record = () => {
|
|
1156
|
+
doc.workflows[name] = {
|
|
1157
|
+
skill: info.name,
|
|
1158
|
+
template: rel,
|
|
1159
|
+
version,
|
|
1160
|
+
hash: templateHash,
|
|
1161
|
+
};
|
|
1162
|
+
manifestDirty = true;
|
|
1163
|
+
};
|
|
1164
|
+
const install = () => {
|
|
1165
|
+
mkdirSync(workflowsDir, { recursive: true });
|
|
1166
|
+
writeFileSync(dest, templateBytes);
|
|
1167
|
+
record();
|
|
1168
|
+
};
|
|
1169
|
+
const push = (status, detail) => {
|
|
1170
|
+
results.push(new WorkflowInstallResult(name, info.name, status, detail));
|
|
1171
|
+
};
|
|
1172
|
+
if (!existsSync(dest)) {
|
|
1173
|
+
if (dryRun) {
|
|
1174
|
+
push('dry_run', `would install .github/workflows/${name} (v${version})`);
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
install();
|
|
1178
|
+
push('installed', `installed .github/workflows/${name} (v${version})`);
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const installedBytes = readBytesOrNull(dest);
|
|
1182
|
+
if (installedBytes !== null && installedBytes.equals(templateBytes)) {
|
|
1183
|
+
// Back-fill provenance for a hand-placed but identical file so a later
|
|
1184
|
+
// template fix can be reported as `outdated` rather than `conflict`.
|
|
1185
|
+
if (doc.workflows[name]?.hash !== templateHash)
|
|
1186
|
+
record();
|
|
1187
|
+
push('skipped', `.github/workflows/${name} already current`);
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (force) {
|
|
1191
|
+
if (dryRun) {
|
|
1192
|
+
push('dry_run', `would overwrite .github/workflows/${name} with v${version} (--force)`);
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
install();
|
|
1196
|
+
push('updated', `overwrote .github/workflows/${name} with v${version} (--force)`);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
const recorded = doc.workflows[name];
|
|
1200
|
+
const untouched = installedBytes !== null &&
|
|
1201
|
+
recorded !== undefined &&
|
|
1202
|
+
recorded.hash === sha256(installedBytes);
|
|
1203
|
+
if (untouched) {
|
|
1204
|
+
push('outdated', `.github/workflows/${name} is at v${recorded.version}, the overlay ` +
|
|
1205
|
+
`ships v${version} ${EMDASH} unmodified since install, so re-run ` +
|
|
1206
|
+
'with --force to take the update');
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
push('conflict', `.github/workflows/${name} differs from the overlay template and was ` +
|
|
1210
|
+
`left untouched ${EMDASH} your CI is yours; re-run with --force to ` +
|
|
1211
|
+
'replace it');
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if (manifestDirty && !dryRun)
|
|
1215
|
+
writeManifestDoc(targetSkillsDir, doc);
|
|
922
1216
|
return results;
|
|
923
1217
|
}
|
|
924
1218
|
/**
|
|
@@ -937,7 +1231,7 @@ export class HarnessMigrator {
|
|
|
937
1231
|
const shape = ctx.detected_shape;
|
|
938
1232
|
const skills = this.collectOverlaySkills(shape, overlayPath);
|
|
939
1233
|
const targetSkillsDir = join(projectRoot, '.canary', 'skills');
|
|
940
|
-
const manifest =
|
|
1234
|
+
const manifest = readManifestDoc(targetSkillsDir).skills;
|
|
941
1235
|
const results = [];
|
|
942
1236
|
for (const [info, skillDir] of skills) {
|
|
943
1237
|
const dirName = basename(skillDir);
|
|
@@ -960,7 +1254,10 @@ export class HarnessMigrator {
|
|
|
960
1254
|
results.push(new SkillFreshnessResult(info.name, dirName, 'local_edit', 'deployed skill has local edits; refusing to overwrite'));
|
|
961
1255
|
}
|
|
962
1256
|
}
|
|
963
|
-
return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results
|
|
1257
|
+
return new FreshnessReport(shape, overlayPath !== null ? String(overlayPath) : null, results,
|
|
1258
|
+
// dryRun = true: `--check` reports what an install WOULD do and never
|
|
1259
|
+
// writes. Informational only -- see FreshnessReport.workflows.
|
|
1260
|
+
this.installWorkflows(shape, overlayPath, projectRoot, true, false));
|
|
964
1261
|
}
|
|
965
1262
|
detectFramework(root, config) {
|
|
966
1263
|
// 0. Explicit override in .canary/company.json ("canary_shape" field).
|
|
@@ -181,7 +181,11 @@ export class AuthoringContext {
|
|
|
181
181
|
effective_tier; // from resolveTier (2 == can author)
|
|
182
182
|
is_fork; // reuse Phase-2 fork/403 detection -- (b)
|
|
183
183
|
repo_root; // collision + sentinel base
|
|
184
|
-
|
|
184
|
+
// loop-guard -- (a). True only when a sentinel stamped at the CURRENT HEAD
|
|
185
|
+
// exists (#456): the caller resolves the stamp, and every unverifiable state
|
|
186
|
+
// (missing/unreadable/malformed sentinel, unresolvable HEAD) passes `false`
|
|
187
|
+
// so authoring fails OPEN.
|
|
188
|
+
authored_sentinel_present;
|
|
185
189
|
constructor(author_tests_optin, effective_tier, init = {}) {
|
|
186
190
|
this.author_tests_optin = author_tests_optin;
|
|
187
191
|
this.effective_tier = effective_tier;
|
|
@@ -256,7 +260,8 @@ function authoringSkipReason(gap, ctx) {
|
|
|
256
260
|
return `fork: read-only ${EM_DASH} guardian never writes on a fork PR`;
|
|
257
261
|
}
|
|
258
262
|
if (ctx.authored_sentinel_present) {
|
|
259
|
-
return `loop-guard: guardian tests already authored this
|
|
263
|
+
return (`loop-guard: guardian tests already authored at this HEAD ${EM_DASH} ` +
|
|
264
|
+
`review and commit them to re-enable authoring`);
|
|
260
265
|
}
|
|
261
266
|
const target = joinPosix(ctx.repo_root, targetTestPath(gap));
|
|
262
267
|
if (existsSync(target)) {
|