canary-test-cli 6.2.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.
|
@@ -344,6 +344,7 @@ export function migrateCmd(opts, deps) {
|
|
|
344
344
|
dryRun,
|
|
345
345
|
framework: opts.framework || null,
|
|
346
346
|
overlayPath,
|
|
347
|
+
force: opts.force ?? false,
|
|
347
348
|
});
|
|
348
349
|
}
|
|
349
350
|
catch (e) {
|
|
@@ -367,6 +368,7 @@ export function migrateCmd(opts, deps) {
|
|
|
367
368
|
status: r.status,
|
|
368
369
|
note: r.note,
|
|
369
370
|
})),
|
|
371
|
+
installed_workflows: report.installed_workflows.map((r) => r.to_dict()),
|
|
370
372
|
}));
|
|
371
373
|
return;
|
|
372
374
|
}
|
package/dist/engine/cli.core.js
CHANGED
|
@@ -107,6 +107,7 @@ export function createCanaryCommand(depsInit = {}) {
|
|
|
107
107
|
.option('-o, --overlay <path>', '[deprecated: use --from] Path to an overlay repo whose .canary/skills/ are deployed.')
|
|
108
108
|
.option('--apply', 'Write files. Without this flag the command is a dry run.')
|
|
109
109
|
.option('--check', 'Freshness gate: report drift without writing.')
|
|
110
|
+
.option('--force', 'Overwrite a .github/workflows/ file that differs from the overlay template. Without this flag a difference is only reported -- your CI is never rewritten behind your back.')
|
|
110
111
|
.option('--json', 'Emit the report as JSON.')
|
|
111
112
|
.action((opts) => {
|
|
112
113
|
migrateCmd(opts, deps);
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* 2. .canary/company.json -- project-local config
|
|
14
14
|
* 3. .canary/company.<env>.json -- environment override (CANARY_ENV or explicit)
|
|
15
15
|
*
|
|
16
|
-
* List fields are unioned across sources; scalar fields
|
|
17
|
-
*
|
|
18
|
-
* sets
|
|
16
|
+
* List fields ({@link _LIST_FIELDS}) are unioned across sources; scalar fields
|
|
17
|
+
* ({@link _SCALAR_FIELDS}) are replaced by the highest-priority source that
|
|
18
|
+
* sets a non-empty value. Those two arrays are the single place a new field
|
|
19
|
+
* opts into a merge rule, and both are checked for exhaustiveness at compile
|
|
20
|
+
* time.
|
|
19
21
|
*
|
|
20
22
|
* Python->TS nuances:
|
|
21
23
|
* - Python patches `Path.home()` in its tests to isolate the home tier. There
|
|
@@ -118,6 +120,12 @@ const _KNOWN_KEYS = new Set([
|
|
|
118
120
|
// warning that it is told anyone adopting an overlay that the single field
|
|
119
121
|
// driving their adoption does nothing.
|
|
120
122
|
'canary_shape',
|
|
123
|
+
// #459: repo-relative pointers a generated workflow interpolates (the
|
|
124
|
+
// coverage report the guardian reads; the controllers dir it scopes SUT
|
|
125
|
+
// analysis to). See `validateRepoRelativePath` for why they are validated
|
|
126
|
+
// rather than stored verbatim.
|
|
127
|
+
'coverage_report_path',
|
|
128
|
+
'sut_controllers_path',
|
|
121
129
|
]);
|
|
122
130
|
const _HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
123
131
|
const _BRAND_TEXT_MAX = 200;
|
|
@@ -240,6 +248,50 @@ function validateOtelEndpoint(raw, fieldName, warnings) {
|
|
|
240
248
|
}
|
|
241
249
|
return raw;
|
|
242
250
|
}
|
|
251
|
+
// A path that is absolute in any flavour git checkouts run under: POSIX
|
|
252
|
+
// (`/x`), UNC / Windows-separator (`\x`), or drive-qualified (`C:x`, `C:/x`).
|
|
253
|
+
// Drive-relative `C:x` is included deliberately -- it is not repo-relative.
|
|
254
|
+
const _ABSOLUTE_PATH_RE = /^(?:[/\\]|[A-Za-z]:)/;
|
|
255
|
+
// Newlines would break out of the scalar these values are interpolated into
|
|
256
|
+
// when a workflow template is generated, so they are refused outright rather
|
|
257
|
+
// than escaped -- no legitimate repo path contains one.
|
|
258
|
+
const _PATH_CONTROL_RE = /[\r\n\t\0]/;
|
|
259
|
+
/**
|
|
260
|
+
* A repo-relative path pointer (#459: `coverage_report_path`,
|
|
261
|
+
* `sut_controllers_path`).
|
|
262
|
+
*
|
|
263
|
+
* These are interpolated into generated GitHub Actions YAML, so validation is
|
|
264
|
+
* a safety boundary, not tidiness: an absolute path aims the generated CI at
|
|
265
|
+
* something outside the checkout, and a `..` segment escapes the repo. Both are
|
|
266
|
+
* dropped with a warning (the module's degrade-never-throw convention); a
|
|
267
|
+
* secret-like value raises so the whole layer is refused, exactly as every
|
|
268
|
+
* other non-notes field does.
|
|
269
|
+
*
|
|
270
|
+
* `..` is rejected as a SUBSTRING, not just as a path component. A component
|
|
271
|
+
* check would have to agree with the separator handling of whatever consumes
|
|
272
|
+
* the value later (shell, Actions expression, node `path`); refusing the two
|
|
273
|
+
* characters outright cannot disagree with anything. The cost is rejecting the
|
|
274
|
+
* vanishingly rare legitimate `report..xml`.
|
|
275
|
+
*/
|
|
276
|
+
function validateRepoRelativePath(raw, fieldName, warnings) {
|
|
277
|
+
if (typeof raw !== 'string') {
|
|
278
|
+
warnings.push(`${fieldName}: expected string, got ${pyTypeName(raw)} ${EMDASH} skipped`);
|
|
279
|
+
return '';
|
|
280
|
+
}
|
|
281
|
+
const value = raw.trim();
|
|
282
|
+
if (!value)
|
|
283
|
+
return '';
|
|
284
|
+
if (looksLikeSecret(value))
|
|
285
|
+
throw new SecretDetected(fieldName, value);
|
|
286
|
+
if (_ABSOLUTE_PATH_RE.test(value) ||
|
|
287
|
+
value.includes('..') ||
|
|
288
|
+
_PATH_CONTROL_RE.test(value)) {
|
|
289
|
+
warnings.push(`${fieldName}: dropped invalid repo-relative path ${pyRepr(raw)} ` +
|
|
290
|
+
`${EMDASH} must stay inside the repo (no absolute path, no '..')`);
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
243
295
|
/** Accept #RGB / #RRGGBB (any case); drop anything else with a warning. */
|
|
244
296
|
function validateHexColor(raw, fieldName, warnings) {
|
|
245
297
|
if (typeof raw !== 'string' || !raw)
|
|
@@ -432,6 +484,14 @@ function parseLayer(data, source) {
|
|
|
432
484
|
if (Object.prototype.hasOwnProperty.call(data, 'otel_exporter_endpoint')) {
|
|
433
485
|
otel_exporter_endpoint = validateOtelEndpoint(data['otel_exporter_endpoint'], 'otel_exporter_endpoint', warns);
|
|
434
486
|
}
|
|
487
|
+
let coverage_report_path = '';
|
|
488
|
+
if (Object.prototype.hasOwnProperty.call(data, 'coverage_report_path')) {
|
|
489
|
+
coverage_report_path = validateRepoRelativePath(data['coverage_report_path'], 'coverage_report_path', warns);
|
|
490
|
+
}
|
|
491
|
+
let sut_controllers_path = '';
|
|
492
|
+
if (Object.prototype.hasOwnProperty.call(data, 'sut_controllers_path')) {
|
|
493
|
+
sut_controllers_path = validateRepoRelativePath(data['sut_controllers_path'], 'sut_controllers_path', warns);
|
|
494
|
+
}
|
|
435
495
|
let notes = '';
|
|
436
496
|
if (Object.prototype.hasOwnProperty.call(data, 'notes')) {
|
|
437
497
|
const rawNotes = data['notes'];
|
|
@@ -455,6 +515,8 @@ function parseLayer(data, source) {
|
|
|
455
515
|
dashboard_url,
|
|
456
516
|
dashboard_token_env,
|
|
457
517
|
otel_exporter_endpoint,
|
|
518
|
+
coverage_report_path,
|
|
519
|
+
sut_controllers_path,
|
|
458
520
|
notes,
|
|
459
521
|
brand,
|
|
460
522
|
warnings: warns,
|
|
@@ -509,51 +571,66 @@ function union(a, b) {
|
|
|
509
571
|
}
|
|
510
572
|
return out;
|
|
511
573
|
}
|
|
574
|
+
const _LIST_FIELDS = [
|
|
575
|
+
'confluence_spaces',
|
|
576
|
+
'jira_projects',
|
|
577
|
+
'internal_doc_urls',
|
|
578
|
+
'internal_domains',
|
|
579
|
+
'mcp_servers',
|
|
580
|
+
'claude_code_skills',
|
|
581
|
+
];
|
|
582
|
+
const _SCALAR_FIELDS = [
|
|
583
|
+
'dashboard_url',
|
|
584
|
+
'dashboard_token_env',
|
|
585
|
+
'otel_exporter_endpoint',
|
|
586
|
+
'coverage_report_path',
|
|
587
|
+
'sut_controllers_path',
|
|
588
|
+
'notes',
|
|
589
|
+
];
|
|
590
|
+
// Compile-time exhaustiveness: adding a field to `Layer`/`MergedFields` without
|
|
591
|
+
// adding it to the matching array above fails the build here (the assertion
|
|
592
|
+
// type collapses to `never`) rather than silently dropping the field at merge.
|
|
593
|
+
const _LIST_FIELDS_EXHAUSTIVE = true;
|
|
594
|
+
const _SCALAR_FIELDS_EXHAUSTIVE = true;
|
|
595
|
+
void _LIST_FIELDS_EXHAUSTIVE;
|
|
596
|
+
void _SCALAR_FIELDS_EXHAUSTIVE;
|
|
597
|
+
function mergeListFields(layers) {
|
|
598
|
+
const out = {};
|
|
599
|
+
for (const field of _LIST_FIELDS) {
|
|
600
|
+
let merged = [];
|
|
601
|
+
for (const layer of layers)
|
|
602
|
+
merged = union(merged, layer[field]);
|
|
603
|
+
out[field] = merged;
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
606
|
+
}
|
|
607
|
+
function mergeScalarFields(layers) {
|
|
608
|
+
const out = {};
|
|
609
|
+
for (const field of _SCALAR_FIELDS) {
|
|
610
|
+
let merged = '';
|
|
611
|
+
// Highest-priority non-empty wins. A layer whose value was DROPPED as
|
|
612
|
+
// invalid contributes '' and therefore leaves the lower layer's valid value
|
|
613
|
+
// standing (degrade, never blank out) -- load-bearing, and pinned by tests.
|
|
614
|
+
for (const layer of layers)
|
|
615
|
+
if (layer[field])
|
|
616
|
+
merged = layer[field];
|
|
617
|
+
out[field] = merged;
|
|
618
|
+
}
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
512
621
|
function mergeLayers(layers) {
|
|
513
|
-
|
|
514
|
-
let jira_projects = [];
|
|
515
|
-
let internal_doc_urls = [];
|
|
516
|
-
let internal_domains = [];
|
|
517
|
-
let mcp_servers = [];
|
|
518
|
-
let claude_code_skills = [];
|
|
519
|
-
let dashboard_url = '';
|
|
520
|
-
let dashboard_token_env = '';
|
|
521
|
-
let otel_exporter_endpoint = '';
|
|
522
|
-
let notes = '';
|
|
523
|
-
const warns = [];
|
|
622
|
+
const warnings = [];
|
|
524
623
|
const sources = [];
|
|
525
624
|
for (const layer of layers) {
|
|
526
|
-
|
|
527
|
-
jira_projects = union(jira_projects, layer.jira_projects);
|
|
528
|
-
internal_doc_urls = union(internal_doc_urls, layer.internal_doc_urls);
|
|
529
|
-
internal_domains = union(internal_domains, layer.internal_domains);
|
|
530
|
-
mcp_servers = union(mcp_servers, layer.mcp_servers);
|
|
531
|
-
claude_code_skills = union(claude_code_skills, layer.claude_code_skills);
|
|
532
|
-
if (layer.dashboard_url)
|
|
533
|
-
dashboard_url = layer.dashboard_url;
|
|
534
|
-
if (layer.dashboard_token_env)
|
|
535
|
-
dashboard_token_env = layer.dashboard_token_env;
|
|
536
|
-
if (layer.otel_exporter_endpoint)
|
|
537
|
-
otel_exporter_endpoint = layer.otel_exporter_endpoint;
|
|
538
|
-
if (layer.notes)
|
|
539
|
-
notes = layer.notes;
|
|
540
|
-
warns.push(...layer.warnings);
|
|
625
|
+
warnings.push(...layer.warnings);
|
|
541
626
|
if (layer.source)
|
|
542
627
|
sources.push(layer.source);
|
|
543
628
|
}
|
|
544
629
|
return {
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
internal_doc_urls,
|
|
548
|
-
internal_domains,
|
|
549
|
-
mcp_servers,
|
|
550
|
-
claude_code_skills,
|
|
551
|
-
dashboard_url,
|
|
552
|
-
dashboard_token_env,
|
|
553
|
-
otel_exporter_endpoint,
|
|
554
|
-
notes,
|
|
630
|
+
...mergeListFields(layers),
|
|
631
|
+
...mergeScalarFields(layers),
|
|
555
632
|
brand: mergeBrand(layers),
|
|
556
|
-
warnings
|
|
633
|
+
warnings,
|
|
557
634
|
sources,
|
|
558
635
|
};
|
|
559
636
|
}
|
|
@@ -585,6 +662,10 @@ export class CompanyKnowledge {
|
|
|
585
662
|
dashboard_url;
|
|
586
663
|
dashboard_token_env;
|
|
587
664
|
otel_exporter_endpoint;
|
|
665
|
+
/** Repo-relative path to the coverage report a generated workflow reads. */
|
|
666
|
+
coverage_report_path;
|
|
667
|
+
/** Repo-relative path to the SUT controllers dir analysis is scoped to. */
|
|
668
|
+
sut_controllers_path;
|
|
588
669
|
notes;
|
|
589
670
|
brand;
|
|
590
671
|
warnings;
|
|
@@ -600,6 +681,8 @@ export class CompanyKnowledge {
|
|
|
600
681
|
this.dashboard_url = init.dashboard_url ?? '';
|
|
601
682
|
this.dashboard_token_env = init.dashboard_token_env ?? '';
|
|
602
683
|
this.otel_exporter_endpoint = init.otel_exporter_endpoint ?? '';
|
|
684
|
+
this.coverage_report_path = init.coverage_report_path ?? '';
|
|
685
|
+
this.sut_controllers_path = init.sut_controllers_path ?? '';
|
|
603
686
|
this.notes = init.notes ?? '';
|
|
604
687
|
this.brand = init.brand ?? new Brand();
|
|
605
688
|
this.warnings = init.warnings ?? [];
|
|
@@ -733,6 +816,8 @@ export class CompanyKnowledge {
|
|
|
733
816
|
dashboard_url: this.dashboard_url,
|
|
734
817
|
dashboard_token_env: this.dashboard_token_env,
|
|
735
818
|
otel_exporter_endpoint: this.otel_exporter_endpoint,
|
|
819
|
+
coverage_report_path: this.coverage_report_path,
|
|
820
|
+
sut_controllers_path: this.sut_controllers_path,
|
|
736
821
|
notes: this.notes,
|
|
737
822
|
brand: this.brand.toDict(),
|
|
738
823
|
sources: this.sources,
|
|
@@ -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)) {
|
|
@@ -258,6 +258,61 @@ const AUTHORED_SENTINEL_NAME = 'canary-guardian-authored';
|
|
|
258
258
|
function authoredSentinelPath(deps, root) {
|
|
259
259
|
return join(gitDir(deps, root), AUTHORED_SENTINEL_NAME);
|
|
260
260
|
}
|
|
261
|
+
// The sentinel's FIRST line stamps the HEAD the guardian authored at:
|
|
262
|
+
// `HEAD <sha>`. Every line after it is one authored path. Anchored at the start
|
|
263
|
+
// of the body and hex-only, with a trailing `[ \t\r]*` so a CRLF-written file
|
|
264
|
+
// still parses -- anything else reads as malformed, which fails OPEN.
|
|
265
|
+
const SENTINEL_HEAD_RE = /^HEAD ([0-9a-fA-F]{7,64})[ \t\r]*(?:\n|$)/;
|
|
266
|
+
/**
|
|
267
|
+
* Parse the `HEAD <sha>` stamp off a sentinel body; `null` when malformed.
|
|
268
|
+
*
|
|
269
|
+
* Malformed covers empty, headerless (the pre-#456 paths-only format), and any
|
|
270
|
+
* unparseable first line. Callers MUST treat `null` as "cannot verify" and fail
|
|
271
|
+
* OPEN -- an unreadable sentinel must never wedge authoring off (#456).
|
|
272
|
+
*/
|
|
273
|
+
function sentinelHeadStamp(text) {
|
|
274
|
+
const match = SENTINEL_HEAD_RE.exec(text);
|
|
275
|
+
return match === null ? null : match[1].toLowerCase();
|
|
276
|
+
}
|
|
277
|
+
/** Current `HEAD` sha for `root`, or `null` when git/HEAD is unavailable. */
|
|
278
|
+
function headSha(deps, root) {
|
|
279
|
+
const res = deps.runGit(['rev-parse', 'HEAD'], root);
|
|
280
|
+
if (res === null || res.code !== 0)
|
|
281
|
+
return null; // no git / no commits
|
|
282
|
+
return res.stdout.trim().toLowerCase() || null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Is the loop guard live -- i.e. does a sentinel stamped at the CURRENT `HEAD`
|
|
286
|
+
* exist?
|
|
287
|
+
*
|
|
288
|
+
* This is the surviving half of the stage-and-block-once contract (#456). The
|
|
289
|
+
* component that CLEARED the sentinel on the next commit
|
|
290
|
+
* (`hooks/guardian_precommit.py`) was deleted as dead code in #449, which left
|
|
291
|
+
* `author-plan` fail-closed forever: author once in a clone and Tier-2 authoring
|
|
292
|
+
* never ran again. Stamping HEAD makes the guard self-expiring -- once the human
|
|
293
|
+
* reviews and commits the staged tests, `HEAD` moves, the stamp stops matching,
|
|
294
|
+
* and authoring re-enables itself with no manual step and no hook.
|
|
295
|
+
*
|
|
296
|
+
* Every unverifiable state FAILS OPEN (returns `false`, authoring allowed):
|
|
297
|
+
* missing or unreadable sentinel, a malformed/absent `HEAD` header, or a `HEAD`
|
|
298
|
+
* we cannot resolve. Fail-closed here is exactly the bug being fixed.
|
|
299
|
+
*/
|
|
300
|
+
function authoredSentinelActive(deps, root) {
|
|
301
|
+
let body;
|
|
302
|
+
try {
|
|
303
|
+
body = readFileSync(authoredSentinelPath(deps, root), 'utf-8');
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return false; // absent or unreadable -> fail open
|
|
307
|
+
}
|
|
308
|
+
const stamp = sentinelHeadStamp(body);
|
|
309
|
+
if (stamp === null)
|
|
310
|
+
return false; // malformed -> fail open
|
|
311
|
+
const head = headSha(deps, root);
|
|
312
|
+
if (head === null)
|
|
313
|
+
return false; // unverifiable -> fail open
|
|
314
|
+
return head === stamp;
|
|
315
|
+
}
|
|
261
316
|
/**
|
|
262
317
|
* Return raw unified-diff text from a source.
|
|
263
318
|
*
|
|
@@ -785,7 +840,8 @@ function authorPlanCmd(opts, deps) {
|
|
|
785
840
|
const ctx = new AuthoringContext(config.precommit_author_tests, effective, {
|
|
786
841
|
is_fork: isForkContext(deps.env),
|
|
787
842
|
repo_root: repoRoot,
|
|
788
|
-
|
|
843
|
+
// #456: HEAD-stamped, so the guard expires on the next commit by itself.
|
|
844
|
+
authored_sentinel_present: authoredSentinelActive(deps, repoRoot),
|
|
789
845
|
});
|
|
790
846
|
const results = deps.makeAgentTier().author_tests(gaps, ctx);
|
|
791
847
|
const decision = decideBlock(results);
|
|
@@ -799,11 +855,23 @@ function authorPlanCmd(opts, deps) {
|
|
|
799
855
|
};
|
|
800
856
|
deps.out(ensureAscii(JSON.stringify(payload, null, 2)));
|
|
801
857
|
}
|
|
858
|
+
/**
|
|
859
|
+
* Record the authored paths in the loop-guard sentinel, stamped with the HEAD
|
|
860
|
+
* they were authored at (#456).
|
|
861
|
+
*
|
|
862
|
+
* The `HEAD <sha>` header is what makes the guard self-expiring: `author-plan`
|
|
863
|
+
* honors it only while `HEAD` still matches, so the human's review commit clears
|
|
864
|
+
* it implicitly. When `HEAD` cannot be resolved (a repo with no commits, or no
|
|
865
|
+
* git at all) the header is omitted -- an unstamped sentinel reads as malformed
|
|
866
|
+
* and FAILS OPEN, which is the safe direction.
|
|
867
|
+
*/
|
|
802
868
|
function markAuthoredCmd(opts, deps) {
|
|
803
869
|
const root = gitToplevel(deps);
|
|
804
870
|
const sentinel = authoredSentinelPath(deps, root);
|
|
805
871
|
mkdirSync(dirname(sentinel), { recursive: true });
|
|
806
|
-
const
|
|
872
|
+
const head = headSha(deps, root);
|
|
873
|
+
const header = head === null ? '' : `HEAD ${head}\n`;
|
|
874
|
+
const body = header + opts.path.map((p) => `${p}\n`).join('');
|
|
807
875
|
writeFileSync(sentinel, body, 'utf-8');
|
|
808
876
|
deps.out(`guardian: recorded ${opts.path.length} authored path(s) ${RIGHT_ARROW} ${sentinel}`);
|
|
809
877
|
}
|