@deftai/directive-core 0.73.1 → 0.74.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.
Files changed (37) hide show
  1. package/dist/check/orchestrator.d.ts +3 -0
  2. package/dist/check/orchestrator.js +2 -1
  3. package/dist/codebase/map.js +2 -0
  4. package/dist/doctor/checks.d.ts +9 -0
  5. package/dist/doctor/checks.js +67 -0
  6. package/dist/doctor/main.js +2 -1
  7. package/dist/fs/projection-containment.d.ts +35 -0
  8. package/dist/fs/projection-containment.js +118 -0
  9. package/dist/init-deposit/scaffold.d.ts +21 -0
  10. package/dist/init-deposit/scaffold.js +79 -6
  11. package/dist/pr-merge-readiness/constants.d.ts +4 -0
  12. package/dist/pr-merge-readiness/constants.js +4 -0
  13. package/dist/pr-merge-readiness/evaluate.js +3 -0
  14. package/dist/pr-merge-readiness/mergeability.d.ts +8 -6
  15. package/dist/pr-merge-readiness/mergeability.js +15 -10
  16. package/dist/pr-merge-readiness/output.js +12 -6
  17. package/dist/pr-merge-readiness/parse.d.ts +1 -0
  18. package/dist/pr-merge-readiness/parse.js +9 -1
  19. package/dist/pr-merge-readiness/types.d.ts +2 -0
  20. package/dist/preflight-cache/evaluate.d.ts +2 -0
  21. package/dist/preflight-cache/evaluate.js +14 -3
  22. package/dist/preflight-cache/index.d.ts +1 -1
  23. package/dist/preflight-cache/index.js +1 -1
  24. package/dist/release/constants.d.ts +2 -0
  25. package/dist/release/constants.js +2 -0
  26. package/dist/release/preflight.d.ts +2 -0
  27. package/dist/release/preflight.js +14 -1
  28. package/dist/scope/index.d.ts +1 -0
  29. package/dist/scope/index.js +1 -0
  30. package/dist/scope/main.js +24 -1
  31. package/dist/scope/open-umbrella-warning.d.ts +12 -0
  32. package/dist/scope/open-umbrella-warning.js +403 -0
  33. package/dist/triage/bootstrap/gitignore.d.ts +1 -1
  34. package/dist/triage/bootstrap/gitignore.js +69 -58
  35. package/dist/vbrief-reconcile/umbrellas.js +12 -11
  36. package/dist/xbrief-migrate/migrate-project.js +9 -0
  37. package/package.json +3 -3
@@ -19,10 +19,13 @@ export interface CheckOrchestratorSeams {
19
19
  readonly spawnFn?: (cmd: string, args: string[], opts: {
20
20
  cwd: string;
21
21
  stdio: string;
22
+ env?: NodeJS.ProcessEnv;
22
23
  }) => {
23
24
  status: number | null;
24
25
  error?: Error;
25
26
  };
27
+ /** Child-process environment (default: process.env). */
28
+ readonly env?: NodeJS.ProcessEnv;
26
29
  }
27
30
  /**
28
31
  * True when `path` is the directive framework source checkout root (not a
@@ -77,6 +77,7 @@ export function dispatchTaskCheck(frameworkRoot, projectRoot, seams = {}) {
77
77
  const result = spawn(taskBin, [target, "--taskfile", taskfilePath], {
78
78
  cwd,
79
79
  stdio: "inherit",
80
+ env: seams.env,
80
81
  });
81
82
  if (result.error !== undefined) {
82
83
  process.stderr.write(`check: failed to invoke task: ${result.error.message}\n`);
@@ -88,7 +89,7 @@ function defaultSpawn(cmd, args, opts) {
88
89
  const result = spawnSync(cmd, args, {
89
90
  cwd: opts.cwd,
90
91
  stdio: opts.stdio,
91
- env: { ...process.env },
92
+ env: opts.env ?? process.env,
92
93
  });
93
94
  return { status: result.status, error: result.error };
94
95
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { assertProjectionContained } from "../fs/projection-containment.js";
3
4
  import { resolveProjectDefinitionPath } from "../layout/resolve.js";
4
5
  import { extractCodeStructure, loadJsonFile } from "../verify-source/code-structure-validate.js";
5
6
  import { CodeStructureConfigError, configErrorToDict, defaultCodeStructurePath, } from "./default-extractor.js";
@@ -239,6 +240,7 @@ export function writeCodebaseMap(projectRoot, options) {
239
240
  const resolvedOutput = isAbsolute(options.outputPath)
240
241
  ? options.outputPath
241
242
  : join(projectRoot, options.outputPath);
243
+ assertProjectionContained(projectRoot, resolvedOutput);
242
244
  if (!options.force && !isDeftGenerated(resolvedOutput)) {
243
245
  throw new Error(`refusing to overwrite non-generated MAP at ${resolvedOutput}; ` +
244
246
  `missing '${GENERATED_SENTINEL}' banner`);
@@ -30,6 +30,15 @@ export declare function checkCanonicalVendoredNpmSignpost(projectRoot: string, s
30
30
  * deposit is still a working install, just an unpinned one.
31
31
  */
32
32
  export declare function checkManifestVersionReportable(projectRoot: string, installRoot: string | null, seams?: CheckSeams): CheckResult;
33
+ /** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
34
+ export declare const STALE_VBRIEF_CORE_SCHEMA_REL: string;
35
+ /**
36
+ * #2368: on an already-migrated xbrief/ layout, a stale deposited
37
+ * `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
38
+ * operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
39
+ * `directive update` instead. Advisory only (exit-exempt).
40
+ */
41
+ export declare function checkStaleXbriefSchemaDeposit(projectRoot: string, seams?: CheckSeams): CheckResult;
33
42
  /**
34
43
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
35
44
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -5,6 +5,8 @@ import { detectCanonicalVendoredManifest, isNpmManaged, NPM_MANAGED_SENTINEL_KEY
5
5
  import { resolveLifecycleRoot } from "../layout/resolve.js";
6
6
  import { findSkillPathsInText } from "../text/redos-safe.js";
7
7
  import { stripGitignoreInlineComment } from "../triage/bootstrap/gitignore.js";
8
+ import { LEGACY_INFO_ROOT_KEY, MIGRATED_ARTIFACT_DIR } from "../xbrief-migrate/constants.js";
9
+ import { detectXbriefConvergence } from "../xbrief-migrate/detect.js";
8
10
  import { CANONICAL_UPGRADE_COMMAND, GO_BRIDGE_RELEASES_URL, UPGRADING_DOC_URL, } from "./constants.js";
9
11
  import { isDeprecationRedirectStub, locateManifest, manifestCandidatePaths, manifestReportableVersion, manifestTagToVersion, parseInstallManifest, parseInstallRootFromAgentsMd, parseManifest, } from "./manifest.js";
10
12
  import { readTextSafe } from "./paths.js";
@@ -459,6 +461,68 @@ function collectGitignorePresent(text) {
459
461
  function gitignoreLineIsCovered(present, line) {
460
462
  return present.has(line);
461
463
  }
464
+ /** Relative path to the deposited v0.6 schema file superseded by xbrief-core-0.8.schema.json. */
465
+ export const STALE_VBRIEF_CORE_SCHEMA_REL = join(MIGRATED_ARTIFACT_DIR, "schemas", "vbrief-core.schema.json");
466
+ const MIGRATED_XBRIEF_LAYOUT_STATES = new Set([
467
+ "xbrief-only",
468
+ "xbrief-marker",
469
+ ]);
470
+ /**
471
+ * #2368: on an already-migrated xbrief/ layout, a stale deposited
472
+ * `xbrief/schemas/vbrief-core.schema.json` (vBRIEFInfo v0.6) must NOT route
473
+ * operators to `migrate:xbrief` — that verb requires a vbrief/ tree. Point at
474
+ * `directive update` instead. Advisory only (exit-exempt).
475
+ */
476
+ export function checkStaleXbriefSchemaDeposit(projectRoot, seams = {}) {
477
+ const checkName = "stale-xbrief-schema-deposit";
478
+ const convergence = detectXbriefConvergence(projectRoot);
479
+ if (!MIGRATED_XBRIEF_LAYOUT_STATES.has(convergence.state)) {
480
+ return {
481
+ name: checkName,
482
+ status: "skip",
483
+ detail: "Project is not on a fully migrated xbrief/ layout; stale schema deposit check does not apply.",
484
+ data: { convergence_state: convergence.state },
485
+ };
486
+ }
487
+ const schemaPath = join(projectRoot, STALE_VBRIEF_CORE_SCHEMA_REL);
488
+ const isFile = seams.isFile ?? ((p) => readText(p, seams) !== null);
489
+ if (!isFile(schemaPath)) {
490
+ return {
491
+ name: checkName,
492
+ status: "skip",
493
+ detail: `No deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL}.`,
494
+ data: { schema_path: schemaPath },
495
+ };
496
+ }
497
+ const text = readText(schemaPath, seams);
498
+ if (text === null) {
499
+ return {
500
+ name: checkName,
501
+ status: "skip",
502
+ detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is unreadable.`,
503
+ data: { schema_path: schemaPath },
504
+ };
505
+ }
506
+ if (!text.includes(`"${LEGACY_INFO_ROOT_KEY}"`)) {
507
+ return {
508
+ name: checkName,
509
+ status: "pass",
510
+ detail: `Deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} is current (no ${LEGACY_INFO_ROOT_KEY} root key).`,
511
+ data: { schema_path: schemaPath },
512
+ };
513
+ }
514
+ return {
515
+ name: checkName,
516
+ status: "fail",
517
+ detail: `Stale deposited schema at ${STALE_VBRIEF_CORE_SCHEMA_REL} still carries the legacy ${LEGACY_INFO_ROOT_KEY} root key on an already-migrated xbrief/ layout. ` +
518
+ "Run `directive update` to refresh deposited schema files — not `deft migrate:xbrief` (no vbrief/ tree remains to convert).",
519
+ data: {
520
+ schema_path: schemaPath,
521
+ convergence_state: convergence.state,
522
+ suggested_fix: "directive update",
523
+ },
524
+ };
525
+ }
462
526
  /**
463
527
  * #2206: check that the consumer `.gitignore` carries the canonical Deft baseline
464
528
  * entries. Advisory (exit-exempt) because missing entries are an adoption risk, not
@@ -515,6 +579,7 @@ export function deriveExitCode(checks, errors) {
515
579
  "canonical-vendored-npm-signpost",
516
580
  "manifest-version-reportable",
517
581
  "gitignore-coverage",
582
+ "stale-xbrief-schema-deposit",
518
583
  ]);
519
584
  if (errors.length > 0 || checks.some((c) => c.status === "error")) {
520
585
  return 2;
@@ -554,6 +619,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
554
619
  checks.push(checkManifestVersionReportable(projectRoot, null, seams));
555
620
  checks.push(checkLegacyLayout(projectRoot, seams));
556
621
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
622
+ checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
557
623
  checks.push(checkGitignoreCoverage(projectRoot, seams));
558
624
  return {
559
625
  projectRoot,
@@ -570,6 +636,7 @@ export function runChecksImpl(projectRoot, seams = {}) {
570
636
  checks.push(checkInstallPathConsistency(projectRoot, installRoot, seams));
571
637
  checks.push(checkLegacyLayout(projectRoot, seams));
572
638
  checks.push(checkCanonicalVendoredNpmSignpost(projectRoot, seams));
639
+ checks.push(checkStaleXbriefSchemaDeposit(projectRoot, seams));
573
640
  checks.push(checkGitignoreCoverage(projectRoot, seams));
574
641
  return {
575
642
  projectRoot,
@@ -374,7 +374,8 @@ function runInstallIntegrityChecks(projectRoot, sink, addFinding, seams) {
374
374
  if ((name === "legacy-layout" ||
375
375
  name === "canonical-vendored-npm-signpost" ||
376
376
  name === "manifest-version-reportable" ||
377
- name === "gitignore-coverage") &&
377
+ name === "gitignore-coverage" ||
378
+ name === "stale-xbrief-schema-deposit") &&
378
379
  status === "fail") {
379
380
  sink.warn(`${name}: ${detail}`);
380
381
  addFinding({
@@ -0,0 +1,35 @@
1
+ /**
2
+ * projection-containment.ts — repo-controlled projection symlink guard (#2413).
3
+ *
4
+ * Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
5
+ * paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
6
+ * If a malicious repo author commits one of those paths (or a parent) as a
7
+ * symlink escaping the project tree, routine operator commands would follow the
8
+ * link and create/overwrite files outside the checkout.
9
+ *
10
+ * `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
11
+ * existing component down to the write target, rejects symlinks that escape the
12
+ * tree, and asserts the deepest existing ancestor stays under the project root.
13
+ *
14
+ * Refs #2413.
15
+ */
16
+ /** Non-zero exit code for a projection-containment refusal (needs-action). */
17
+ export declare const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
18
+ /** Thrown when a projection write target escapes the project tree. */
19
+ export declare class ProjectionContainmentError extends Error {
20
+ readonly projectDir: string;
21
+ readonly targetPath: string;
22
+ readonly offendingPath: string;
23
+ constructor(message: string, details: {
24
+ projectDir: string;
25
+ targetPath: string;
26
+ offendingPath: string;
27
+ });
28
+ }
29
+ /**
30
+ * Refuse projection writes when the target (or a parent) is a symlink that
31
+ * escapes the resolved project tree. Throws {@link ProjectionContainmentError};
32
+ * MUST be called BEFORE any projection read/write/mkdir.
33
+ */
34
+ export declare function assertProjectionContained(projectDir: string, targetPath: string): void;
35
+ //# sourceMappingURL=projection-containment.d.ts.map
@@ -0,0 +1,118 @@
1
+ /**
2
+ * projection-containment.ts — repo-controlled projection symlink guard (#2413).
3
+ *
4
+ * Projection writers (`codebase:map`, `triage:bootstrap`) write to well-known
5
+ * paths such as `.planning/codebase/MAP.md`, `.gitignore`, and `.gitattributes`.
6
+ * If a malicious repo author commits one of those paths (or a parent) as a
7
+ * symlink escaping the project tree, routine operator commands would follow the
8
+ * link and create/overwrite files outside the checkout.
9
+ *
10
+ * `assertProjectionContained` anchors on `realpath(projectDir)`, walks each
11
+ * existing component down to the write target, rejects symlinks that escape the
12
+ * tree, and asserts the deepest existing ancestor stays under the project root.
13
+ *
14
+ * Refs #2413.
15
+ */
16
+ import { lstatSync, realpathSync } from "node:fs";
17
+ import { isAbsolute, join, relative, resolve } from "node:path";
18
+ /** Non-zero exit code for a projection-containment refusal (needs-action). */
19
+ export const PROJECTION_CONTAINMENT_REFUSED_EXIT_CODE = 2;
20
+ /** Thrown when a projection write target escapes the project tree. */
21
+ export class ProjectionContainmentError extends Error {
22
+ projectDir;
23
+ targetPath;
24
+ offendingPath;
25
+ constructor(message, details) {
26
+ super(message);
27
+ this.name = "ProjectionContainmentError";
28
+ this.projectDir = details.projectDir;
29
+ this.targetPath = details.targetPath;
30
+ this.offendingPath = details.offendingPath;
31
+ }
32
+ }
33
+ /**
34
+ * Path-SEGMENT containment: is `child` equal to `parent` or nested under it?
35
+ * Uses `path.relative` so `/foo` is NOT treated as containing `/foobar`.
36
+ */
37
+ function isContained(parent, child) {
38
+ if (parent === child) {
39
+ return true;
40
+ }
41
+ const rel = relative(parent, child);
42
+ return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel);
43
+ }
44
+ /**
45
+ * Refuse projection writes when the target (or a parent) is a symlink that
46
+ * escapes the resolved project tree. Throws {@link ProjectionContainmentError};
47
+ * MUST be called BEFORE any projection read/write/mkdir.
48
+ */
49
+ export function assertProjectionContained(projectDir, targetPath) {
50
+ const projectAbs = resolve(projectDir);
51
+ let projectReal;
52
+ try {
53
+ projectReal = realpathSync(projectAbs);
54
+ }
55
+ catch {
56
+ throw new ProjectionContainmentError(`projection write refused: project directory ${projectAbs} does not exist`, { projectDir: projectAbs, targetPath: resolve(targetPath), offendingPath: projectAbs });
57
+ }
58
+ const targetAbs = resolve(targetPath);
59
+ const rel = relative(projectAbs, targetAbs);
60
+ if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) {
61
+ throw new ProjectionContainmentError(`projection write refused: target ${targetAbs} is not nested under the project tree ${projectAbs}`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: targetAbs });
62
+ }
63
+ const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0);
64
+ let current = projectAbs;
65
+ let deepestExistingReal = projectReal;
66
+ for (const segment of segments) {
67
+ current = join(current, segment);
68
+ let info;
69
+ try {
70
+ info = lstatSync(current);
71
+ }
72
+ catch {
73
+ break;
74
+ }
75
+ if (info.isSymbolicLink()) {
76
+ let linkReal;
77
+ try {
78
+ linkReal = realpathSync(current);
79
+ }
80
+ catch {
81
+ throw new ProjectionContainmentError(`projection write refused: ${current} is a broken/dangling symlink on the projection path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
82
+ }
83
+ if (!isContained(projectReal, linkReal)) {
84
+ throw new ProjectionContainmentError(`projection write refused: ${current} is a symlink escaping the project tree ` +
85
+ `(resolves to ${linkReal}, outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
86
+ }
87
+ deepestExistingReal = linkReal;
88
+ current = linkReal;
89
+ }
90
+ else {
91
+ try {
92
+ deepestExistingReal = realpathSync(current);
93
+ }
94
+ catch (err) {
95
+ throw new ProjectionContainmentError(`projection write refused: could not resolve ${current} on the projection path`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: current });
96
+ }
97
+ }
98
+ }
99
+ try {
100
+ const targetReal = realpathSync(targetAbs);
101
+ if (!isContained(projectReal, targetReal)) {
102
+ throw new ProjectionContainmentError(`projection write refused: projection target resolves outside the project tree ` +
103
+ `(${targetReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: targetReal });
104
+ }
105
+ deepestExistingReal = targetReal;
106
+ }
107
+ catch (err) {
108
+ if (err instanceof ProjectionContainmentError) {
109
+ throw err;
110
+ }
111
+ // Target does not exist yet; parent walk above is sufficient.
112
+ }
113
+ if (!isContained(projectReal, deepestExistingReal)) {
114
+ throw new ProjectionContainmentError(`projection write refused: projection path escapes the project tree ` +
115
+ `(${deepestExistingReal} is outside ${projectReal})`, { projectDir: projectAbs, targetPath: targetAbs, offendingPath: deepestExistingReal });
116
+ }
117
+ }
118
+ //# sourceMappingURL=projection-containment.js.map
@@ -61,6 +61,27 @@ export interface GitHooksSeams {
61
61
  setHooksPath?: (projectDir: string, value: string) => boolean;
62
62
  }
63
63
  export declare function writeConsumerGitHooks(projectDir: string, deftDir: string, io: InitDepositIo, seams?: GitHooksSeams): boolean;
64
+ /** Commit SHA for `actions/checkout` deposited in deft-core-guard.yml (#1672 / #1072). */
65
+ export declare const CORE_GUARD_CHECKOUT_SHA = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0";
66
+ /** Tag comment paired with {@link CORE_GUARD_CHECKOUT_SHA}. */
67
+ export declare const CORE_GUARD_CHECKOUT_TAG = "v7.0.0";
68
+ /**
69
+ * Extract the `uses: actions/checkout@…` step line from a guard workflow, if present.
70
+ * Uses linear string scanning (no regex) to avoid CodeQL js/polynomial-redos (#1672).
71
+ */
72
+ export declare function extractCoreGuardCheckoutUsesLine(content: string): string | null;
73
+ /**
74
+ * Whether refresh should keep the existing checkout pin instead of the template pin.
75
+ * Legacy framework-deposited `@v4` tags migrate forward; consumer/Dependabot bumps do not.
76
+ */
77
+ export declare function shouldPreserveCoreGuardCheckoutPin(existingUsesLine: string, desiredUsesLine: string): boolean;
78
+ /** Canonical SHA-pinned checkout step for the deposited deft-core-guard workflow. */
79
+ export declare function coreGuardCheckoutUsesLine(sha?: string, tag?: string): string;
80
+ /**
81
+ * On refresh, preserve an existing consumer `actions/checkout@…` pin while updating
82
+ * the managed guard script / allowlist body (#1672 option 1).
83
+ */
84
+ export declare function mergeCoreGuardWorkflowRefresh(existing: string, desired: string): string;
64
85
  export declare function ensureGitattributes(projectDir: string, io: InitDepositIo): boolean;
65
86
  export declare function ensureGreptileIgnore(projectDir: string, io: InitDepositIo): boolean;
66
87
  export declare function ensureCodeqlPathsIgnore(projectDir: string, io: InitDepositIo): boolean;
@@ -538,6 +538,78 @@ export function writeConsumerGitHooks(projectDir, deftDir, io, seams = {}) {
538
538
  function githubActionsExpr(expression) {
539
539
  return ["$", "{{ ", expression, " }}"].join("");
540
540
  }
541
+ /** Commit SHA for `actions/checkout` deposited in deft-core-guard.yml (#1672 / #1072). */
542
+ export const CORE_GUARD_CHECKOUT_SHA = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0";
543
+ /** Tag comment paired with {@link CORE_GUARD_CHECKOUT_SHA}. */
544
+ export const CORE_GUARD_CHECKOUT_TAG = "v7.0.0";
545
+ const CHECKOUT_USES_PREFIX = "- uses: actions/checkout@";
546
+ /**
547
+ * Extract the `uses: actions/checkout@…` step line from a guard workflow, if present.
548
+ * Uses linear string scanning (no regex) to avoid CodeQL js/polynomial-redos (#1672).
549
+ */
550
+ export function extractCoreGuardCheckoutUsesLine(content) {
551
+ for (const line of content.split("\n")) {
552
+ const trimmed = line.trimStart();
553
+ if (!trimmed.startsWith(CHECKOUT_USES_PREFIX))
554
+ continue;
555
+ // YAML step lines are indented; a bare top-level match is not a step.
556
+ if (trimmed.length === line.length)
557
+ continue;
558
+ return line;
559
+ }
560
+ return null;
561
+ }
562
+ /** Ref after `actions/checkout@` (tag, SHA, or major), stopping at whitespace. */
563
+ function checkoutActionRef(usesLine) {
564
+ const idx = usesLine.indexOf(CHECKOUT_USES_PREFIX);
565
+ if (idx < 0)
566
+ return null;
567
+ const after = usesLine.slice(idx + CHECKOUT_USES_PREFIX.length);
568
+ let end = after.length;
569
+ for (let i = 0; i < after.length; i += 1) {
570
+ const c = after[i];
571
+ if (c === " " || c === "\t" || c === "\r") {
572
+ end = i;
573
+ break;
574
+ }
575
+ }
576
+ const ref = after.slice(0, end);
577
+ return ref.length > 0 ? ref : null;
578
+ }
579
+ /**
580
+ * Whether refresh should keep the existing checkout pin instead of the template pin.
581
+ * Legacy framework-deposited `@v4` tags migrate forward; consumer/Dependabot bumps do not.
582
+ */
583
+ export function shouldPreserveCoreGuardCheckoutPin(existingUsesLine, desiredUsesLine) {
584
+ if (existingUsesLine === desiredUsesLine)
585
+ return false;
586
+ const existingRef = checkoutActionRef(existingUsesLine);
587
+ if (!existingRef)
588
+ return false;
589
+ // Stale framework-deposited floating major tag — migrate to SHA template (#1672).
590
+ return existingRef !== "v4";
591
+ }
592
+ /** Canonical SHA-pinned checkout step for the deposited deft-core-guard workflow. */
593
+ export function coreGuardCheckoutUsesLine(sha = CORE_GUARD_CHECKOUT_SHA, tag = CORE_GUARD_CHECKOUT_TAG) {
594
+ return ` - uses: actions/checkout@${sha} # ${tag}`;
595
+ }
596
+ /**
597
+ * On refresh, preserve an existing consumer `actions/checkout@…` pin while updating
598
+ * the managed guard script / allowlist body (#1672 option 1).
599
+ */
600
+ export function mergeCoreGuardWorkflowRefresh(existing, desired) {
601
+ const existingCheckout = extractCoreGuardCheckoutUsesLine(existing);
602
+ if (!existingCheckout)
603
+ return desired;
604
+ const desiredCheckout = extractCoreGuardCheckoutUsesLine(desired);
605
+ if (!desiredCheckout || !shouldPreserveCoreGuardCheckoutPin(existingCheckout, desiredCheckout)) {
606
+ return desired;
607
+ }
608
+ const idx = desired.indexOf(desiredCheckout);
609
+ if (idx < 0)
610
+ return desired;
611
+ return desired.slice(0, idx) + existingCheckout + desired.slice(idx + desiredCheckout.length);
612
+ }
541
613
  function coreGuardWorkflowContent() {
542
614
  const baseSha = githubActionsExpr("github.event.pull_request.base.sha");
543
615
  const headSha = githubActionsExpr("github.event.pull_request.head.sha");
@@ -555,7 +627,7 @@ function coreGuardWorkflowContent() {
555
627
  " no-mixed-core-and-app:\n" +
556
628
  " runs-on: ubuntu-latest\n" +
557
629
  " steps:\n" +
558
- " - uses: actions/checkout@v4\n" +
630
+ `${coreGuardCheckoutUsesLine()}\n` +
559
631
  " with:\n" +
560
632
  " fetch-depth: 0\n" +
561
633
  " - name: Refuse PRs that mix .deft/core/** with non-framework paths\n" +
@@ -715,15 +787,16 @@ export function ensureCoreGuardWorkflow(projectDir, io) {
715
787
  const desired = coreGuardWorkflowContent();
716
788
  if (existsSync(path)) {
717
789
  const existing = readFileSync(path, "utf8");
718
- if (existing === desired) {
719
- io.printf(`${CORE_GUARD_WORKFLOW_REL} already current — skipping.\n`);
720
- return false;
721
- }
722
790
  if (!existing.includes("name: deft-core-guard")) {
723
791
  io.printf(`${CORE_GUARD_WORKFLOW_REL} present but not deft-managed — leaving unchanged.\n`);
724
792
  return false;
725
793
  }
726
- writeFileSync(path, desired, "utf8");
794
+ const refreshed = mergeCoreGuardWorkflowRefresh(existing, desired);
795
+ if (existing === refreshed) {
796
+ io.printf(`${CORE_GUARD_WORKFLOW_REL} already current — skipping.\n`);
797
+ return false;
798
+ }
799
+ writeFileSync(path, refreshed, "utf8");
727
800
  io.printf(`${CORE_GUARD_WORKFLOW_REL} refreshed: deft-core-guard allowlist updated (#1478).\n`);
728
801
  return true;
729
802
  }
@@ -3,6 +3,10 @@ export declare const EXIT_MERGE_BLOCKED = 1;
3
3
  export declare const EXIT_EXTERNAL_ERROR = 2;
4
4
  export declare const GREPTILE_LOGIN = "greptile-apps[bot]";
5
5
  export declare const GREPTILE_ERRORED_SENTINEL = "Greptile encountered an error while reviewing this PR";
6
+ /** Greptile status marker on excluded-author skip comments (#2375). */
7
+ export declare const GREPTILE_STATUS_MARKER = "<!-- greptile-status -->";
8
+ /** Substring Greptile posts when the PR author is on the excluded-authors list (#2375). */
9
+ export declare const GREPTILE_EXCLUDED_AUTHOR_PHRASE = "excluded authors list";
6
10
  export declare const LAST_REVIEWED_RE: RegExp;
7
11
  export declare const CONFIDENCE_RE: RegExp;
8
12
  export declare const P0_BADGE = "<img alt=\"P0\"";
@@ -3,6 +3,10 @@ export const EXIT_MERGE_BLOCKED = 1;
3
3
  export const EXIT_EXTERNAL_ERROR = 2;
4
4
  export const GREPTILE_LOGIN = "greptile-apps[bot]";
5
5
  export const GREPTILE_ERRORED_SENTINEL = "Greptile encountered an error while reviewing this PR";
6
+ /** Greptile status marker on excluded-author skip comments (#2375). */
7
+ export const GREPTILE_STATUS_MARKER = "<!-- greptile-status -->";
8
+ /** Substring Greptile posts when the PR author is on the excluded-authors list (#2375). */
9
+ export const GREPTILE_EXCLUDED_AUTHOR_PHRASE = "excluded authors list";
6
10
  export const LAST_REVIEWED_RE = /Last reviewed commit:\s*\[[^\]]*\]\(https?:\/\/github\.com\/[^/]+\/[^/]+\/commit\/(?<sha>[0-9a-f]{7,40})/g;
7
11
  export const CONFIDENCE_RE = /Confidence Score:\s*(?<score>\d+)\s*\/\s*5/i;
8
12
  export const P0_BADGE = '<img alt="P0"';
@@ -8,6 +8,9 @@ export function evaluateGates(_prNumber, headSha, verdict) {
8
8
  "Wait for the review to land before merging (see #796 late-bot-review re-check).");
9
9
  return failures;
10
10
  }
11
+ if (verdict.excludedAuthor) {
12
+ return failures;
13
+ }
11
14
  if (verdict.errored) {
12
15
  failures.push("Greptile review is in the ERRORED state on the current HEAD (#526). " +
13
16
  "Retry via @greptileai or escalate per " +
@@ -31,13 +31,15 @@ export declare function verdictShaIsStale(verdict: GreptileVerdict, headSha: str
31
31
  * Classify whether the verdict-based merge block is ONLY "soft" (#2260).
32
32
  *
33
33
  * A soft block means the review verdict is absent or pinned to a prior head
34
- * SHA (rebased staleness) -- i.e. the review has not spoken about the CURRENT
35
- * head. It is safe to reconcile a soft block against GitHub mergeability.
34
+ * SHA without carrying real blocker-class findings (rebased staleness with no
35
+ * P0/P1/errored/low-confidence) -- i.e. the review has not spoken about the
36
+ * CURRENT head in a blocking way. It is safe to reconcile a soft block against
37
+ * GitHub mergeability.
36
38
  *
37
- * A HARD block -- a genuine P0/P1 finding, an ERRORED review, or a low
38
- * confidence score on the current head -- is NEVER soft; those must keep
39
- * blocking regardless of GitHub mergeability (guardrail: do not merge a PR
40
- * with a real P0/P1 review finding).
39
+ * A HARD block -- a genuine P0/P1 finding (even on a stale SHA), an ERRORED
40
+ * review, or a low confidence score -- is NEVER soft; those must keep blocking
41
+ * regardless of GitHub mergeability (guardrail: do not merge a PR with a real
42
+ * P0/P1 review finding).
41
43
  */
42
44
  export declare function verdictBlockIsSoftOnly(verdict: GreptileVerdict, headSha: string | null): boolean;
43
45
  //# sourceMappingURL=mergeability.d.ts.map
@@ -65,13 +65,15 @@ export function verdictShaIsStale(verdict, headSha) {
65
65
  * Classify whether the verdict-based merge block is ONLY "soft" (#2260).
66
66
  *
67
67
  * A soft block means the review verdict is absent or pinned to a prior head
68
- * SHA (rebased staleness) -- i.e. the review has not spoken about the CURRENT
69
- * head. It is safe to reconcile a soft block against GitHub mergeability.
68
+ * SHA without carrying real blocker-class findings (rebased staleness with no
69
+ * P0/P1/errored/low-confidence) -- i.e. the review has not spoken about the
70
+ * CURRENT head in a blocking way. It is safe to reconcile a soft block against
71
+ * GitHub mergeability.
70
72
  *
71
- * A HARD block -- a genuine P0/P1 finding, an ERRORED review, or a low
72
- * confidence score on the current head -- is NEVER soft; those must keep
73
- * blocking regardless of GitHub mergeability (guardrail: do not merge a PR
74
- * with a real P0/P1 review finding).
73
+ * A HARD block -- a genuine P0/P1 finding (even on a stale SHA), an ERRORED
74
+ * review, or a low confidence score -- is NEVER soft; those must keep blocking
75
+ * regardless of GitHub mergeability (guardrail: do not merge a PR with a real
76
+ * P0/P1 review finding).
75
77
  */
76
78
  export function verdictBlockIsSoftOnly(verdict, headSha) {
77
79
  // Absent: no Greptile rolling-summary comment at all.
@@ -83,12 +85,11 @@ export function verdictBlockIsSoftOnly(verdict, headSha) {
83
85
  if (verdict.informalClean) {
84
86
  return false;
85
87
  }
86
- // Stale: verdict pinned to a prior head SHA -> its findings pertain to old code.
87
- if (verdictShaIsStale(verdict, headSha)) {
88
+ // Excluded-author skip is an intentional N/A reviewer state (#2375).
89
+ if (verdict.excludedAuthor) {
88
90
  return true;
89
91
  }
90
- // Verdict is current (or its SHA is unparseable). A genuine current-head
91
- // finding is a hard block and must not be overridden.
92
+ // Blocker-class signals apply even when the verdict SHA is stale (#2382).
92
93
  if (verdict.errored) {
93
94
  return false;
94
95
  }
@@ -98,6 +99,10 @@ export function verdictBlockIsSoftOnly(verdict, headSha) {
98
99
  if (verdict.p0Count > 0 || verdict.p1Count > 0) {
99
100
  return false;
100
101
  }
102
+ // Stale without blocker findings: review has not spoken about the current head.
103
+ if (verdictShaIsStale(verdict, headSha)) {
104
+ return true;
105
+ }
101
106
  // No genuine finding: the block is a missing-canonical-field / not-yet-posted
102
107
  // wait, which GitHub mergeability may resolve.
103
108
  return true;
@@ -10,6 +10,7 @@ function verdictToDict(verdict) {
10
10
  p1_count: verdict.p1Count,
11
11
  p2_count: verdict.p2Count,
12
12
  informal_clean: verdict.informalClean,
13
+ excluded_author: verdict.excludedAuthor,
13
14
  raw_body_excerpt: verdict.rawBodyExcerpt,
14
15
  };
15
16
  }
@@ -47,12 +48,17 @@ export function printHuman(result) {
47
48
  const lines = [];
48
49
  lines.push(`PR #${result.prNumber} merge-readiness check (via=${result.via})`);
49
50
  lines.push(` HEAD SHA: ${result.headSha ?? "<unknown>"}`);
50
- lines.push(` Greptile reviewed: ${result.verdict.lastReviewedSha ?? "<not parsed>"}`);
51
- const confidenceStr = result.verdict.confidence !== null ? String(result.verdict.confidence) : "<not parsed>";
52
- lines.push(` Confidence: ${confidenceStr}/5`);
53
- lines.push(` Findings: P0=${result.verdict.p0Count} ` +
54
- `P1=${result.verdict.p1Count} P2=${result.verdict.p2Count}`);
55
- lines.push(` Errored sentinel: ${result.verdict.errored ? "True" : "False"}`);
51
+ if (result.verdict.excludedAuthor) {
52
+ lines.push(" Greptile review: skipped (author excluded)");
53
+ }
54
+ else {
55
+ lines.push(` Greptile reviewed: ${result.verdict.lastReviewedSha ?? "<not parsed>"}`);
56
+ const confidenceStr = result.verdict.confidence !== null ? String(result.verdict.confidence) : "<not parsed>";
57
+ lines.push(` Confidence: ${confidenceStr}/5`);
58
+ lines.push(` Findings: P0=${result.verdict.p0Count} ` +
59
+ `P1=${result.verdict.p1Count} P2=${result.verdict.p2Count}`);
60
+ lines.push(` Errored sentinel: ${result.verdict.errored ? "True" : "False"}`);
61
+ }
56
62
  const ciBlock = result.partialData.ci;
57
63
  if (ciBlock !== null && typeof ciBlock === "object" && !Array.isArray(ciBlock)) {
58
64
  const ci = ciBlock;
@@ -1,5 +1,6 @@
1
1
  import type { GreptileVerdict } from "./types.js";
2
2
  export declare function emptyVerdict(): GreptileVerdict;
3
+ export declare function isGreptileExcludedAuthor(body: string): boolean;
3
4
  export declare function isInformalCleanMissingCanonicalFields(verdict: GreptileVerdict, body: string): boolean;
4
5
  /** Parse a Greptile rolling-summary comment body into a structured verdict. */
5
6
  export declare function parseGreptileBody(body: string): GreptileVerdict;