@opengsd/gsd-core 1.19.0 → 1.20.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.compact.md +5 -5
- package/agents/gsd-code-fixer.md +9 -4
- package/agents/gsd-code-reviewer.compact.md +49 -8
- package/agents/gsd-code-reviewer.md +53 -14
- package/agents/gsd-executor.md +8 -4
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +73 -23
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
- package/gsd-core/bin/lib/capability-registry.cjs +284 -93
- package/gsd-core/bin/lib/capability-validator.cjs +15 -0
- package/gsd-core/bin/lib/commands.cjs +27 -17
- package/gsd-core/bin/lib/config-loader.cjs +3 -3
- package/gsd-core/bin/lib/core-utils.cjs +11 -0
- package/gsd-core/bin/lib/frontmatter.cjs +21 -3
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
- package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
- package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
- package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
- package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
- package/gsd-core/bin/lib/init.cjs +180 -67
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
- package/gsd-core/bin/lib/markdown-table.cjs +23 -0
- package/gsd-core/bin/lib/mcp-server.cjs +8 -5
- package/gsd-core/bin/lib/milestone.cjs +18 -50
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
- package/gsd-core/bin/lib/phase-id.cjs +609 -35
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
- package/gsd-core/bin/lib/phase-locator.cjs +328 -19
- package/gsd-core/bin/lib/phase.cjs +2990 -666
- package/gsd-core/bin/lib/planning-document.cjs +26 -5
- package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
- package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
- package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
- package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +730 -144
- package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
- package/gsd-core/bin/lib/roadmap.cjs +358 -106
- package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
- package/gsd-core/bin/lib/smart-entry.cjs +22 -11
- package/gsd-core/bin/lib/state-contract.cjs +11 -7
- package/gsd-core/bin/lib/state-document.cjs +332 -23
- package/gsd-core/bin/lib/state-transition.cjs +39 -10
- package/gsd-core/bin/lib/state.cjs +498 -179
- package/gsd-core/bin/lib/template.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +3 -2
- package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
- package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
- package/gsd-core/bin/lib/verify.cjs +2 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
- package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
- package/gsd-core/references/cicd-review-patterns.md +45 -0
- package/gsd-core/references/necessity-review.md +48 -0
- package/gsd-core/workflows/code-review-fix.md +46 -5
- package/gsd-core/workflows/code-review.md +3 -0
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/pr-branch.md +2 -0
- package/gsd-core/workflows/reapply-patches.md +104 -0
- package/gsd-core/workflows/review.md +54 -141
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +57 -9
- package/gsd-core/workflows/ship.md +12 -0
- package/hooks/dist/gsd-graphify-update.sh +6 -0
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/gsd-worktree-path-guard.js +4 -4
- package/hooks/dist/lib/git-probe.js +16 -0
- package/hooks/gsd-graphify-update.sh +6 -0
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-windsurf-pre-write.js +4 -4
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/gsd-worktree-path-guard.js +4 -4
- package/hooks/lib/git-probe.js +16 -0
- package/package.json +3 -3
- package/pi/package-agents/gsd-code-fixer.md +9 -4
- package/pi/package-agents/gsd-code-reviewer.md +53 -14
- package/pi/package-agents/gsd-executor.md +8 -4
- package/pi/package-agents/manifest.json +3 -3
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/ci-next-health.cjs +246 -23
- package/scripts/docs-guard-registry.cjs +2 -0
- package/scripts/gen-features.cjs +14 -2
- package/scripts/lib/drift-scan.cjs +27 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +6 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/sync-next-version.cjs +38 -6
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -30,7 +30,7 @@ const state_document_cjs_1 = require("./state-document.cjs");
|
|
|
30
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
31
31
|
const frontmatterMod = require("./frontmatter.cjs");
|
|
32
32
|
const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
|
|
33
|
-
const
|
|
33
|
+
const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
|
|
34
34
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
|
35
35
|
const verificationMod = require("./verification.cjs");
|
|
36
36
|
const { isPhaseComplete, isVerificationStatus, VERIFICATION_STATUS } = verificationMod;
|
|
@@ -41,6 +41,9 @@ const { phaseKeyFromDir, phaseKeyFromProse, parentPhaseKey } = phaseIdMod;
|
|
|
41
41
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
42
42
|
const { getMilestonePhaseFilter, isMilestoneShippedInRoadmap } = roadmapParserMod;
|
|
43
43
|
const workstream_inventory_builder_cjs_1 = require("./workstream-inventory-builder.cjs");
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
45
|
+
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
46
|
+
const { phaseDirectoryOptions } = phaseLocatorMod;
|
|
44
47
|
// ─── Implementation ───────────────────────────────────────────────────────────
|
|
45
48
|
function workstreamsRoot(cwd) {
|
|
46
49
|
return node_path_1.default.join(planningRoot(cwd), 'workstreams');
|
|
@@ -75,7 +78,7 @@ function countRoadmapPhases(roadmapPath, fallbackCount, cwd, ws, versionOverride
|
|
|
75
78
|
// `null` -> 0 (fallback), `undefined` -> 3. A non-bracket workstream
|
|
76
79
|
// resolves to a value that compiles the same base grammar `null` did, so
|
|
77
80
|
// its count is unchanged (verified against a legacy control).
|
|
78
|
-
const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, undefined, ws ?? null);
|
|
81
|
+
const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, undefined, ws ?? null, phaseDirectoryOptions(cwd, undefined, undefined, ws ?? null));
|
|
79
82
|
// A pass-all degrade (phaseCount 0) means the window declared no phases —
|
|
80
83
|
// fall back rather than reporting a confident zero.
|
|
81
84
|
return filter.phaseCount > 0 ? filter.phaseCount : fallbackCount;
|
|
@@ -98,25 +101,22 @@ function countRoadmapPhases(roadmapPath, fallbackCount, cwd, ws, versionOverride
|
|
|
98
101
|
* `phaseKeyFromDir` uses for directories, so a `| 01. … |` row and a `1-slug`
|
|
99
102
|
* directory cannot land in different key spaces (the padding-asymmetry defect).
|
|
100
103
|
*/
|
|
101
|
-
function parseRoadmapProgressRows(
|
|
102
|
-
let content;
|
|
103
|
-
try {
|
|
104
|
-
content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
return []; /* no roadmap */
|
|
108
|
-
}
|
|
104
|
+
function parseRoadmapProgressRows(content, directoryContext) {
|
|
109
105
|
// Milestone-ATTRIBUTING shape first. Both shapes carry `Plans Complete`, so
|
|
110
106
|
// probing that column first would pick a flat table appearing earlier in the
|
|
111
107
|
// document over a milestone-grouped one later — every row would come back
|
|
112
108
|
// unattributed and be treated as current-milestone, silently over-including.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
// #5761: the roadmap-progress owner picks the table (heading-scoped, so a
|
|
110
|
+
// decoy table elsewhere cannot be mistaken for it); this reader only supplies
|
|
111
|
+
// the probe order. A table outside a table-less `## Progress` section has
|
|
112
|
+
// always been read here (#5692: no behavior change), hence `sectionMiss`.
|
|
113
|
+
const { rows: tableRows } = (0, roadmap_progress_cjs_1.locateRoadmapProgress)(content, {
|
|
114
|
+
columns: [['Phase', 'Milestone'], ['Phase', 'Plans Complete']],
|
|
115
|
+
sectionMiss: 'document',
|
|
116
|
+
});
|
|
117
117
|
const rows = [];
|
|
118
|
-
for (const row of
|
|
119
|
-
const key = phaseKeyFromProse(row['Phase']);
|
|
118
|
+
for (const row of tableRows) {
|
|
119
|
+
const key = phaseKeyFromProse(row['Phase'], directoryContext);
|
|
120
120
|
if (key === null)
|
|
121
121
|
continue;
|
|
122
122
|
const cell = (row['Milestone'] ?? '').trim();
|
|
@@ -428,12 +428,18 @@ function inspectWorkstream(cwd, name, options = {}) {
|
|
|
428
428
|
const writeDiagnostic = options.writeDiagnostic ?? ((message) => process.stderr.write(message));
|
|
429
429
|
const p = planningPaths(cwd, name);
|
|
430
430
|
const phaseDirNames = readSubdirectories(p.phases);
|
|
431
|
+
let roadmap = '';
|
|
432
|
+
try {
|
|
433
|
+
roadmap = node_fs_1.default.readFileSync(p.roadmap, 'utf8');
|
|
434
|
+
}
|
|
435
|
+
catch { /* no roadmap */ }
|
|
436
|
+
const directoryContext = phaseDirectoryOptions(cwd, undefined, roadmap, name);
|
|
431
437
|
// #2562: scope progress to the CURRENT milestone. Membership and the
|
|
432
438
|
// denominator are derived in ONE key space (`phaseKeyFromDir` /
|
|
433
439
|
// `phaseKeyFromProse`, both from the phase-id owner module) so the two sides
|
|
434
440
|
// of the rollup cannot disagree.
|
|
435
441
|
const currentVersion = readCurrentMilestoneVersion(p.state, p.roadmap);
|
|
436
|
-
const progressRows = parseRoadmapProgressRows(
|
|
442
|
+
const progressRows = parseRoadmapProgressRows(roadmap, directoryContext);
|
|
437
443
|
// Phase keys the ROADMAP attributes to the current milestone. A row whose
|
|
438
444
|
// Milestone cell is blank or malformed is INCLUDED rather than dropped: a
|
|
439
445
|
// phase we cannot attribute must still be visible to the rollup. Dropping it
|
|
@@ -458,7 +464,7 @@ function inspectWorkstream(cwd, name, options = {}) {
|
|
|
458
464
|
// it is exactly the caller that cannot set `GSD_WORKSTREAM`; spelling `null`
|
|
459
465
|
// pinned every workstream to the legacy grammar and made `headingScoped`
|
|
460
466
|
// unreachable (`phaseCount > 0` never held) on a bracket workstream.
|
|
461
|
-
const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, undefined, name);
|
|
467
|
+
const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, undefined, name, directoryContext);
|
|
462
468
|
const headingScoped = headingFilter.versionScoped && headingFilter.phaseCount > 0;
|
|
463
469
|
// Phase keys the ROADMAP attributes to some OTHER milestone. A row carrying an
|
|
464
470
|
// explicit version that is not the current one is a positive claim by a prior
|
|
@@ -507,7 +513,7 @@ function inspectWorkstream(cwd, name, options = {}) {
|
|
|
507
513
|
const isDirInCurrentMilestone = (dir) => {
|
|
508
514
|
if (!scoped)
|
|
509
515
|
return true;
|
|
510
|
-
const key = phaseKeyFromDir(dir);
|
|
516
|
+
const key = phaseKeyFromDir(dir, undefined, directoryContext);
|
|
511
517
|
if (currentMilestoneKeys.has(key))
|
|
512
518
|
return true;
|
|
513
519
|
const parent = parentPhaseKey(key);
|
|
@@ -563,7 +569,7 @@ function inspectWorkstream(cwd, name, options = {}) {
|
|
|
563
569
|
}
|
|
564
570
|
return {
|
|
565
571
|
directory: dir,
|
|
566
|
-
phaseKey: phaseKeyFromDir(dir),
|
|
572
|
+
phaseKey: phaseKeyFromDir(dir, undefined, directoryContext),
|
|
567
573
|
mtimeMs: phaseDirMtime(phaseDir),
|
|
568
574
|
planCount: counts.planCount,
|
|
569
575
|
summaryCount: counts.summaryCount,
|
|
@@ -29,6 +29,14 @@
|
|
|
29
29
|
* # documented #934 advisory posture, but
|
|
30
30
|
* # its coverage is ALWAYS headline-reported.
|
|
31
31
|
*
|
|
32
|
+
* [--hunks] # pre-merge mode (#4435): per-hunk stock vs
|
|
33
|
+
* # user text, content hash, recommendation
|
|
34
|
+
* # and exception reasons; always exits 0
|
|
35
|
+
* [--decisions <path>] # gate mode only (#4435): JSON file of human
|
|
36
|
+
* # keep/retire/rewrite decisions bound to
|
|
37
|
+
* # hunk content hashes; retire/rewrite drop
|
|
38
|
+
* # that hunk's required lines from the gate
|
|
39
|
+
*
|
|
32
40
|
* Exit codes (default gate mode):
|
|
33
41
|
* 0 — every user-added line is present in the merged file (gate passes)
|
|
34
42
|
* 1 — at least one missing line in at least one file (gate fails); outranks
|
|
@@ -60,12 +68,15 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
|
60
68
|
// release's writer dropped). Same module the installer's preserve-check uses,
|
|
61
69
|
// so the two readers cannot drift apart again.
|
|
62
70
|
const { findPristineByHash, findPristineInGit } = require('./lib/pristine-baseline.cjs');
|
|
71
|
+
// #4435: line matcher (Myers, edit-distance capped) used to split a backup into
|
|
72
|
+
// per-hunk user/stock regions and to locate upstream changes over the baseline.
|
|
73
|
+
const { matchLines } = require('./lib/markdown-line-diff.cjs');
|
|
63
74
|
|
|
64
75
|
const SIGNIFICANT_MIN_CHARS = 12;
|
|
65
76
|
const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
|
|
66
77
|
|
|
67
78
|
function parseArgs(argv) {
|
|
68
|
-
const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null };
|
|
79
|
+
const opts = { patchesDir: null, configDir: null, pristineDir: null, json: false, classify: false, minBaselineCoverage: null, hunks: false, decisionsPath: null };
|
|
69
80
|
for (let i = 0; i < argv.length; i++) {
|
|
70
81
|
const arg = argv[i];
|
|
71
82
|
if (arg === '--patches-dir') opts.patchesDir = argv[++i];
|
|
@@ -73,7 +84,12 @@ function parseArgs(argv) {
|
|
|
73
84
|
else if (arg === '--pristine-dir') opts.pristineDir = argv[++i];
|
|
74
85
|
else if (arg === '--json') opts.json = true;
|
|
75
86
|
else if (arg === '--classify') opts.classify = true;
|
|
76
|
-
else if (arg === '--
|
|
87
|
+
else if (arg === '--hunks') opts.hunks = true;
|
|
88
|
+
else if (arg === '--decisions') {
|
|
89
|
+
const raw = argv[++i];
|
|
90
|
+
if (raw === undefined) throw new ExitError(2, '--decisions requires a path');
|
|
91
|
+
opts.decisionsPath = raw;
|
|
92
|
+
} else if (arg === '--min-baseline-coverage') {
|
|
77
93
|
const raw = argv[++i];
|
|
78
94
|
if (raw === undefined) {
|
|
79
95
|
throw new ExitError(2, '--min-baseline-coverage requires a value between 0 and 1');
|
|
@@ -85,7 +101,7 @@ function parseArgs(argv) {
|
|
|
85
101
|
opts.minBaselineCoverage = value;
|
|
86
102
|
} else if (arg === '--help' || arg === '-h') {
|
|
87
103
|
process.stdout.write(
|
|
88
|
-
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json] [--classify] [--min-baseline-coverage <0..1>]\n',
|
|
104
|
+
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json] [--classify] [--hunks] [--decisions <path>] [--min-baseline-coverage <0..1>]\n',
|
|
89
105
|
);
|
|
90
106
|
throw new ExitError(0);
|
|
91
107
|
} else {
|
|
@@ -469,7 +485,279 @@ function coverageHeadline(covered, total) {
|
|
|
469
485
|
return unverified > 0 ? `${base} (${unverified} unverified)` : base;
|
|
470
486
|
}
|
|
471
487
|
|
|
472
|
-
|
|
488
|
+
// ── #4435: per-hunk model ────────────────────────────────────────────────
|
|
489
|
+
const HUNK_REASON = Object.freeze({
|
|
490
|
+
BASELINE_UNCONFIRMED: 'baseline_unconfirmed',
|
|
491
|
+
DIFF_TOO_LARGE: 'diff_too_large',
|
|
492
|
+
ALREADY_UPSTREAM: 'already_upstream',
|
|
493
|
+
PARTIALLY_UPSTREAM: 'partially_upstream',
|
|
494
|
+
ANCHOR_ORPHANED: 'anchor_orphaned',
|
|
495
|
+
REGION_CHANGED_UPSTREAM: 'region_changed_upstream',
|
|
496
|
+
});
|
|
497
|
+
const HUNK_RECOMMENDATION = Object.freeze({ KEEP: 'keep', RETIRE: 'retire', REVIEW: 'review' });
|
|
498
|
+
const DISPOSITION = Object.freeze({ KEEP: 'keep', RETIRE: 'retire', REWRITE: 'rewrite' });
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* #4435: split a backup into hunks against the pristine baseline. Pure. The
|
|
502
|
+
* union of every hunk's required_lines equals computeUserAddedLines(backup,
|
|
503
|
+
* pristine) — same significance + normalization predicate.
|
|
504
|
+
*/
|
|
505
|
+
function computeHunks(fileKey, backupContent, pristineContent) {
|
|
506
|
+
const B = backupContent.split(/\r?\n/);
|
|
507
|
+
if (!pristineContent) {
|
|
508
|
+
const required = computeUserAddedLines(backupContent, null);
|
|
509
|
+
if (required.length === 0) return [];
|
|
510
|
+
return numberHunks(fileKey, [{
|
|
511
|
+
kind: 'file',
|
|
512
|
+
user_lines: B.filter(isSignificantLine),
|
|
513
|
+
stock_lines: [],
|
|
514
|
+
required_lines: required,
|
|
515
|
+
anchor_before: null,
|
|
516
|
+
anchor_after: null,
|
|
517
|
+
stock_range: null,
|
|
518
|
+
diff_too_large: false,
|
|
519
|
+
}]);
|
|
520
|
+
}
|
|
521
|
+
const P = pristineContent.split(/\r?\n/);
|
|
522
|
+
const nP = P.map(normalizeUpstreamOwnedLine);
|
|
523
|
+
const nB = B.map(normalizeUpstreamOwnedLine);
|
|
524
|
+
const pristineSet = new Set(nP);
|
|
525
|
+
const isRequired = (line) =>
|
|
526
|
+
isSignificantLine(line) && !pristineSet.has(normalizeUpstreamOwnedLine(line));
|
|
527
|
+
const m = matchLines(nP, nB);
|
|
528
|
+
if (m === null) {
|
|
529
|
+
const required = B.filter(isRequired);
|
|
530
|
+
if (required.length === 0) return [];
|
|
531
|
+
return numberHunks(fileKey, [{
|
|
532
|
+
kind: 'replacement',
|
|
533
|
+
user_lines: required,
|
|
534
|
+
stock_lines: [],
|
|
535
|
+
required_lines: required.slice(),
|
|
536
|
+
anchor_before: null,
|
|
537
|
+
anchor_after: null,
|
|
538
|
+
stock_range: null,
|
|
539
|
+
diff_too_large: true,
|
|
540
|
+
}]);
|
|
541
|
+
}
|
|
542
|
+
const hunks = [];
|
|
543
|
+
let prevP = -1;
|
|
544
|
+
let run = [];
|
|
545
|
+
const flush = (s, e) => {
|
|
546
|
+
const userLines = run;
|
|
547
|
+
run = [];
|
|
548
|
+
const stockLines = P.slice(s, e);
|
|
549
|
+
if (!userLines.some(isSignificantLine) && !stockLines.some(isSignificantLine)) return;
|
|
550
|
+
const kind = userLines.length && stockLines.length ? 'replacement' : userLines.length ? 'addition' : 'deletion';
|
|
551
|
+
let before = null;
|
|
552
|
+
for (let i = s - 1; i >= 0; i--) {
|
|
553
|
+
if (isSignificantLine(P[i])) { before = P[i]; break; }
|
|
554
|
+
}
|
|
555
|
+
let after = null;
|
|
556
|
+
for (let i = e; i < P.length; i++) {
|
|
557
|
+
if (isSignificantLine(P[i])) { after = P[i]; break; }
|
|
558
|
+
}
|
|
559
|
+
hunks.push({
|
|
560
|
+
kind,
|
|
561
|
+
user_lines: userLines,
|
|
562
|
+
stock_lines: stockLines,
|
|
563
|
+
required_lines: userLines.filter(isRequired),
|
|
564
|
+
anchor_before: before,
|
|
565
|
+
anchor_after: after,
|
|
566
|
+
stock_range: [s, e],
|
|
567
|
+
diff_too_large: false,
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
for (let j = 0; j < B.length; j++) {
|
|
571
|
+
if (m[j] === -1) {
|
|
572
|
+
run.push(B[j]);
|
|
573
|
+
} else {
|
|
574
|
+
flush(prevP + 1, m[j]);
|
|
575
|
+
prevP = m[j];
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
flush(prevP + 1, P.length);
|
|
579
|
+
return numberHunks(fileKey, hunks);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function numberHunks(fileKey, hunks) {
|
|
583
|
+
return hunks.map((h, i) => ({
|
|
584
|
+
hunk_id: i + 1,
|
|
585
|
+
hunk_hash: sha256(JSON.stringify([fileKey, h.kind, h.user_lines, h.stock_lines, h.anchor_before, h.anchor_after, h.stock_range])),
|
|
586
|
+
kind: h.kind,
|
|
587
|
+
user_lines: h.user_lines,
|
|
588
|
+
stock_lines: h.stock_lines,
|
|
589
|
+
required_lines: h.required_lines,
|
|
590
|
+
anchor_before: h.anchor_before,
|
|
591
|
+
anchor_after: h.anchor_after,
|
|
592
|
+
stock_range: h.stock_range,
|
|
593
|
+
diff_too_large: h.diff_too_large,
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* #4435: annotate hunks with presence in the fresh install, closed-vocabulary
|
|
599
|
+
* reasons and a recommendation. Pure; strips stock_range / diff_too_large.
|
|
600
|
+
*/
|
|
601
|
+
function annotateHunks(hunks, { pristineContent, installedContent, baselineConfirmed }) {
|
|
602
|
+
const installed = installedContent == null ? '' : installedContent;
|
|
603
|
+
const iLines = new Set(installed.split(/\r?\n/).map((l) => normalizeUpstreamOwnedLine(l).trim()));
|
|
604
|
+
let upstreamUnknown = false;
|
|
605
|
+
const intervals = [];
|
|
606
|
+
if (pristineContent && hunks.some((h) => h.stock_range)) {
|
|
607
|
+
const P = pristineContent.split(/\r?\n/);
|
|
608
|
+
const nP = P.map(normalizeUpstreamOwnedLine);
|
|
609
|
+
const nI = installed.split(/\r?\n/).map(normalizeUpstreamOwnedLine);
|
|
610
|
+
const u = matchLines(nP, nI);
|
|
611
|
+
if (u === null) {
|
|
612
|
+
upstreamUnknown = true;
|
|
613
|
+
} else {
|
|
614
|
+
let prevP = -1;
|
|
615
|
+
let pendingInsert = false;
|
|
616
|
+
for (let k = 0; k < nI.length; k++) {
|
|
617
|
+
if (u[k] === -1) {
|
|
618
|
+
pendingInsert = true;
|
|
619
|
+
} else {
|
|
620
|
+
if (pendingInsert || u[k] > prevP + 1) intervals.push([prevP + 1, u[k]]);
|
|
621
|
+
pendingInsert = false;
|
|
622
|
+
prevP = u[k];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (pendingInsert || P.length > prevP + 1) intervals.push([prevP + 1, P.length]);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const touches = ([s, e]) => intervals.some(([a, b]) => a <= e && s <= b);
|
|
629
|
+
const inInstall = (l) => iLines.has(normalizeUpstreamOwnedLine(l).trim());
|
|
630
|
+
|
|
631
|
+
return hunks.map((h) => {
|
|
632
|
+
const required = h.required_lines;
|
|
633
|
+
const n = required.filter((l) => installed.includes(l)).length;
|
|
634
|
+
const present = required.length === 0 ? 'n/a' : n === required.length ? 'all' : n === 0 ? 'none' : 'some';
|
|
635
|
+
const reasons = [];
|
|
636
|
+
if (!baselineConfirmed) reasons.push(HUNK_REASON.BASELINE_UNCONFIRMED);
|
|
637
|
+
if (h.diff_too_large || (upstreamUnknown && h.stock_range)) reasons.push(HUNK_REASON.DIFF_TOO_LARGE);
|
|
638
|
+
const stockSig = h.stock_lines.filter(isSignificantLine);
|
|
639
|
+
if (
|
|
640
|
+
(h.kind !== 'deletion' && present === 'all') ||
|
|
641
|
+
(h.kind === 'deletion' && stockSig.length > 0 && stockSig.every((l) => !inInstall(l)))
|
|
642
|
+
) reasons.push(HUNK_REASON.ALREADY_UPSTREAM);
|
|
643
|
+
if (present === 'some') reasons.push(HUNK_REASON.PARTIALLY_UPSTREAM);
|
|
644
|
+
const anchors = [h.anchor_before, h.anchor_after].filter(Boolean);
|
|
645
|
+
if (anchors.length > 0 && anchors.every((a) => !inInstall(a))) reasons.push(HUNK_REASON.ANCHOR_ORPHANED);
|
|
646
|
+
if (h.stock_range && !upstreamUnknown && touches(h.stock_range)) reasons.push(HUNK_REASON.REGION_CHANGED_UPSTREAM);
|
|
647
|
+
let recommendation;
|
|
648
|
+
if (reasons.includes(HUNK_REASON.BASELINE_UNCONFIRMED) || reasons.includes(HUNK_REASON.DIFF_TOO_LARGE)) {
|
|
649
|
+
recommendation = HUNK_RECOMMENDATION.REVIEW;
|
|
650
|
+
} else if (reasons.includes(HUNK_REASON.ALREADY_UPSTREAM)) {
|
|
651
|
+
recommendation = HUNK_RECOMMENDATION.RETIRE;
|
|
652
|
+
} else if (reasons.length > 0) {
|
|
653
|
+
recommendation = HUNK_RECOMMENDATION.REVIEW;
|
|
654
|
+
} else {
|
|
655
|
+
recommendation = HUNK_RECOMMENDATION.KEEP;
|
|
656
|
+
}
|
|
657
|
+
const { stock_range: _sr, diff_too_large: _dtl, ...rest } = h;
|
|
658
|
+
return { ...rest, present_in_install: present, reasons, recommendation, exception: recommendation !== HUNK_RECOMMENDATION.KEEP };
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* #4435: per-file hunk record shared by --hunks and --decisions validation.
|
|
664
|
+
*/
|
|
665
|
+
function buildFileHunks({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect }) {
|
|
666
|
+
const out = { file: relPath, baseline: 'none', reason: null, hunks: [], installedContent: null, pristineContent: null };
|
|
667
|
+
const backupPath = path.join(patchesDir, relPath);
|
|
668
|
+
if (!fs.existsSync(backupPath) || !fs.statSync(backupPath).isFile()) return out;
|
|
669
|
+
let backupContent;
|
|
670
|
+
try {
|
|
671
|
+
backupContent = fs.readFileSync(backupPath, 'utf8');
|
|
672
|
+
} catch {
|
|
673
|
+
out.reason = REASON.FAIL_READ_ERROR;
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect);
|
|
677
|
+
try {
|
|
678
|
+
const st = fs.statSync(installedPath);
|
|
679
|
+
if (!st.isFile()) out.reason = REASON.FAIL_INSTALLED_NOT_REGULAR_FILE;
|
|
680
|
+
else out.installedContent = fs.readFileSync(installedPath, 'utf8');
|
|
681
|
+
} catch (err) {
|
|
682
|
+
out.reason = err && err.code === 'ENOENT' ? REASON.FAIL_INSTALLED_MISSING : REASON.FAIL_READ_ERROR;
|
|
683
|
+
}
|
|
684
|
+
const { resolution, content } = resolvePristineBaseline({ relPath, configDir, pristineDir, pristineHashes });
|
|
685
|
+
let pristine = null;
|
|
686
|
+
if (resolution === PRISTINE_RESOLUTION.VALIDATED) {
|
|
687
|
+
out.baseline = 'validated';
|
|
688
|
+
pristine = content;
|
|
689
|
+
} else if (resolution === PRISTINE_RESOLUTION.UNVALIDATED) {
|
|
690
|
+
out.baseline = 'unvalidated';
|
|
691
|
+
pristine = content;
|
|
692
|
+
} else if (resolution === PRISTINE_RESOLUTION.DRIFTED) {
|
|
693
|
+
out.reason = REASON.OK_PRISTINE_DRIFT_DETECTED;
|
|
694
|
+
} else if (resolution === PRISTINE_RESOLUTION.ABSENT_RECORDED) {
|
|
695
|
+
out.reason = REASON.OK_NO_BASELINE;
|
|
696
|
+
}
|
|
697
|
+
out.pristineContent = pristine;
|
|
698
|
+
out.hunks = computeHunks(relPath.replace(/\\/g, '/'), backupContent, pristine);
|
|
699
|
+
return out;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* #4435: strict loader for the human decisions file. Every malformed shape is
|
|
704
|
+
* a usage error (exit 2) — a decision file is a trust boundary.
|
|
705
|
+
*/
|
|
706
|
+
function loadDecisions(decisionsPath) {
|
|
707
|
+
let raw;
|
|
708
|
+
try {
|
|
709
|
+
raw = fs.readFileSync(decisionsPath, 'utf8');
|
|
710
|
+
} catch (err) {
|
|
711
|
+
throw new ExitError(2, `cannot read decisions file: ${decisionsPath}${err && err.code ? ` (${err.code})` : ''}`);
|
|
712
|
+
}
|
|
713
|
+
let parsed;
|
|
714
|
+
try {
|
|
715
|
+
parsed = JSON.parse(raw);
|
|
716
|
+
} catch {
|
|
717
|
+
throw new ExitError(2, `decisions file is not valid JSON: ${decisionsPath}`);
|
|
718
|
+
}
|
|
719
|
+
const isPlain = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
720
|
+
if (!isPlain(parsed) || !Array.isArray(parsed.decisions)) {
|
|
721
|
+
throw new ExitError(2, 'decisions file must be an object with a "decisions" array');
|
|
722
|
+
}
|
|
723
|
+
const dispositions = new Set(Object.values(DISPOSITION));
|
|
724
|
+
const byKey = new Map();
|
|
725
|
+
parsed.decisions.forEach((d, i) => {
|
|
726
|
+
if (!isPlain(d)) throw new ExitError(2, `invalid decision at index ${i}: not an object`);
|
|
727
|
+
if (typeof d.file !== 'string' || d.file === '') throw new ExitError(2, `invalid decision at index ${i}: file must be a non-empty string`);
|
|
728
|
+
if (typeof d.hunk_hash !== 'string' || !/^[0-9a-f]{64}$/.test(d.hunk_hash)) {
|
|
729
|
+
throw new ExitError(2, `invalid decision at index ${i}: hunk_hash must be 64 lowercase hex characters`);
|
|
730
|
+
}
|
|
731
|
+
if (typeof d.disposition !== 'string' || !dispositions.has(d.disposition)) {
|
|
732
|
+
throw new ExitError(2, `invalid decision at index ${i}: disposition must be one of ${[...dispositions].join(', ')}`);
|
|
733
|
+
}
|
|
734
|
+
// #4435: a rewrite must carry the user's replacement text so the gate can
|
|
735
|
+
// verify it landed; keep/retire must not carry one.
|
|
736
|
+
let replacement = null;
|
|
737
|
+
if (d.disposition === DISPOSITION.REWRITE) {
|
|
738
|
+
if (!Array.isArray(d.replacement_lines) || d.replacement_lines.length === 0 || !d.replacement_lines.every((x) => typeof x === 'string')) {
|
|
739
|
+
throw new ExitError(2, `invalid decision at index ${i}: rewrite requires a non-empty replacement_lines array of strings`);
|
|
740
|
+
}
|
|
741
|
+
replacement = d.replacement_lines.slice();
|
|
742
|
+
} else if (Object.prototype.hasOwnProperty.call(d, 'replacement_lines')) {
|
|
743
|
+
throw new ExitError(2, `invalid decision at index ${i}: replacement_lines is allowed only for rewrite`);
|
|
744
|
+
}
|
|
745
|
+
const file = d.file.replace(/\\/g, '/');
|
|
746
|
+
const key = `${file}\0${d.hunk_hash}`;
|
|
747
|
+
const prior = byKey.get(key);
|
|
748
|
+
if (prior) {
|
|
749
|
+
if (prior.disposition !== d.disposition
|
|
750
|
+
|| JSON.stringify(prior.replacement_lines) !== JSON.stringify(replacement)) {
|
|
751
|
+
throw new ExitError(2, `conflicting decisions for ${file} ${d.hunk_hash}`);
|
|
752
|
+
}
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
byKey.set(key, { file, hunk_hash: d.hunk_hash, disposition: d.disposition, replacement_lines: replacement });
|
|
756
|
+
});
|
|
757
|
+
return [...byKey.values()];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashes, skillsRedirect, retiredHashes, extraRequired }) {
|
|
473
761
|
const backupPath = path.join(patchesDir, relPath);
|
|
474
762
|
const installedPath = resolveInstalledPath(configDir, relPath, skillsRedirect);
|
|
475
763
|
const result = { file: relPath, status: 'ok', missing: [], reason: null };
|
|
@@ -535,7 +823,16 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
|
|
|
535
823
|
// content (null for OVERBROAD) feeds computeUserAddedLines, whose no-pristine
|
|
536
824
|
// branch is the over-broad fallback.
|
|
537
825
|
|
|
538
|
-
|
|
826
|
+
let userAdded = computeUserAddedLines(backupContent, pristineContent);
|
|
827
|
+
// #4435: a hunk retired/rewritten by a recorded human decision (bound to its
|
|
828
|
+
// content hash) stops being required; every other hunk's lines still are.
|
|
829
|
+
if (retiredHashes && retiredHashes.size > 0) {
|
|
830
|
+
userAdded = computeHunks(relPath.replace(/\\/g, '/'), backupContent, pristineContent)
|
|
831
|
+
.filter((h) => !retiredHashes.has(h.hunk_hash))
|
|
832
|
+
.flatMap((h) => h.required_lines);
|
|
833
|
+
}
|
|
834
|
+
// #4435: a rewrite's user-supplied replacement must be present in the merge.
|
|
835
|
+
if (extraRequired && extraRequired.length > 0) userAdded = userAdded.concat(extraRequired);
|
|
539
836
|
if (userAdded.length === 0) {
|
|
540
837
|
// Backup and pristine match exactly (or no significant content) — nothing
|
|
541
838
|
// to verify but also nothing to lose. Report as ok with diagnostic code.
|
|
@@ -654,6 +951,12 @@ function classifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHas
|
|
|
654
951
|
|
|
655
952
|
function main() {
|
|
656
953
|
const opts = parseArgs(process.argv.slice(2));
|
|
954
|
+
if (opts.hunks && opts.classify) {
|
|
955
|
+
throw new ExitError(2, '--hunks and --classify are mutually exclusive');
|
|
956
|
+
}
|
|
957
|
+
if (opts.decisionsPath !== null && (opts.hunks || opts.classify)) {
|
|
958
|
+
throw new ExitError(2, '--decisions applies only to the post-merge gate');
|
|
959
|
+
}
|
|
657
960
|
if (!opts.patchesDir || !opts.configDir) {
|
|
658
961
|
throw new ExitError(2, '--patches-dir and --config-dir are required');
|
|
659
962
|
}
|
|
@@ -672,6 +975,50 @@ function main() {
|
|
|
672
975
|
// `home` override outside configDir (codex global → $HOME/.agents/skills).
|
|
673
976
|
const skillsRedirect = resolveSkillsRedirect(opts.configDir);
|
|
674
977
|
|
|
978
|
+
// #4435: --hunks is the pre-merge per-hunk presentation mode. It recommends;
|
|
979
|
+
// it never decides and never gates (always exit 0).
|
|
980
|
+
if (opts.hunks) {
|
|
981
|
+
const fileRecords = files.map((relPath) => {
|
|
982
|
+
const rec = buildFileHunks({
|
|
983
|
+
relPath,
|
|
984
|
+
patchesDir: opts.patchesDir,
|
|
985
|
+
configDir: opts.configDir,
|
|
986
|
+
pristineDir: opts.pristineDir,
|
|
987
|
+
pristineHashes,
|
|
988
|
+
skillsRedirect,
|
|
989
|
+
});
|
|
990
|
+
return {
|
|
991
|
+
file: rec.file,
|
|
992
|
+
baseline: rec.baseline,
|
|
993
|
+
reason: rec.reason,
|
|
994
|
+
hunks: annotateHunks(rec.hunks, {
|
|
995
|
+
pristineContent: rec.pristineContent,
|
|
996
|
+
installedContent: rec.installedContent,
|
|
997
|
+
baselineConfirmed: rec.baseline === 'validated',
|
|
998
|
+
}),
|
|
999
|
+
};
|
|
1000
|
+
});
|
|
1001
|
+
const hunksTotal = fileRecords.reduce((n, f) => n + f.hunks.length, 0);
|
|
1002
|
+
const exceptionsTotal = fileRecords.reduce((n, f) => n + f.hunks.filter((h) => h.exception).length, 0);
|
|
1003
|
+
if (opts.json) {
|
|
1004
|
+
process.stdout.write(
|
|
1005
|
+
JSON.stringify({ checked: fileRecords.length, hunks_total: hunksTotal, exceptions_total: exceptionsTotal, files: fileRecords }, null, 2) + '\n',
|
|
1006
|
+
);
|
|
1007
|
+
} else {
|
|
1008
|
+
process.stdout.write(`# Reapply Patch Hunks (#4435)\n\n`);
|
|
1009
|
+
process.stdout.write(`Checked: ${fileRecords.length} file(s)\n`);
|
|
1010
|
+
process.stdout.write(`Hunks: ${hunksTotal} (${exceptionsTotal} exception(s))\n`);
|
|
1011
|
+
for (const f of fileRecords) {
|
|
1012
|
+
for (const h of f.hunks) {
|
|
1013
|
+
if (h.exception) {
|
|
1014
|
+
process.stdout.write(`- ${f.file} hunk ${h.hunk_id} [${h.recommendation}] ${h.reasons.join(', ')}\n`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return 0;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
675
1022
|
// Bug #4136: --classify is the pre-merge mode the workflow runs in Step 4
|
|
676
1023
|
// to decide which files NOT to merge. It is informational — always exit 0 —
|
|
677
1024
|
// because the binding enforcement stays with the post-merge gate below;
|
|
@@ -720,6 +1067,47 @@ function main() {
|
|
|
720
1067
|
return 0;
|
|
721
1068
|
}
|
|
722
1069
|
|
|
1070
|
+
// #4435: validate every decision against the current hunks BEFORE verifying.
|
|
1071
|
+
// A decision whose hash no longer matches a hunk is stale and aborts (exit 2).
|
|
1072
|
+
let decisions = null;
|
|
1073
|
+
const retiredByFile = new Map();
|
|
1074
|
+
const retiredList = [];
|
|
1075
|
+
const rewriteLinesByFile = new Map();
|
|
1076
|
+
if (opts.decisionsPath !== null) {
|
|
1077
|
+
decisions = loadDecisions(opts.decisionsPath);
|
|
1078
|
+
const fileSet = new Set(files.map((f) => f.replace(/\\/g, '/')));
|
|
1079
|
+
const relByKey = new Map(files.map((f) => [f.replace(/\\/g, '/'), f]));
|
|
1080
|
+
const hunkCache = new Map();
|
|
1081
|
+
for (const d of decisions) {
|
|
1082
|
+
if (!fileSet.has(d.file)) {
|
|
1083
|
+
throw new ExitError(2, `decision names a file not in the patches dir: ${d.file}`);
|
|
1084
|
+
}
|
|
1085
|
+
if (!hunkCache.has(d.file)) {
|
|
1086
|
+
hunkCache.set(d.file, buildFileHunks({
|
|
1087
|
+
relPath: relByKey.get(d.file),
|
|
1088
|
+
patchesDir: opts.patchesDir,
|
|
1089
|
+
configDir: opts.configDir,
|
|
1090
|
+
pristineDir: opts.pristineDir,
|
|
1091
|
+
pristineHashes,
|
|
1092
|
+
skillsRedirect,
|
|
1093
|
+
}).hunks);
|
|
1094
|
+
}
|
|
1095
|
+
const hunk = hunkCache.get(d.file).find((h) => h.hunk_hash === d.hunk_hash);
|
|
1096
|
+
if (!hunk) {
|
|
1097
|
+
throw new ExitError(2, `stale decision: hunk ${d.hunk_hash} no longer matches ${d.file} (the backup or baseline changed since it was shown)`);
|
|
1098
|
+
}
|
|
1099
|
+
if (d.disposition === DISPOSITION.RETIRE || d.disposition === DISPOSITION.REWRITE) {
|
|
1100
|
+
if (!retiredByFile.has(d.file)) retiredByFile.set(d.file, new Set());
|
|
1101
|
+
retiredByFile.get(d.file).add(d.hunk_hash);
|
|
1102
|
+
retiredList.push({ file: d.file, hunk_id: hunk.hunk_id, hunk_hash: d.hunk_hash, disposition: d.disposition });
|
|
1103
|
+
if (d.disposition === DISPOSITION.REWRITE) {
|
|
1104
|
+
if (!rewriteLinesByFile.has(d.file)) rewriteLinesByFile.set(d.file, []);
|
|
1105
|
+
rewriteLinesByFile.get(d.file).push(...d.replacement_lines.filter(isSignificantLine));
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
723
1111
|
const results = files.map((relPath) =>
|
|
724
1112
|
verifyFile({
|
|
725
1113
|
relPath,
|
|
@@ -728,6 +1116,8 @@ function main() {
|
|
|
728
1116
|
pristineDir: opts.pristineDir,
|
|
729
1117
|
pristineHashes,
|
|
730
1118
|
skillsRedirect,
|
|
1119
|
+
retiredHashes: retiredByFile.get(relPath.replace(/\\/g, '/')),
|
|
1120
|
+
extraRequired: rewriteLinesByFile.get(relPath.replace(/\\/g, '/')),
|
|
731
1121
|
}),
|
|
732
1122
|
);
|
|
733
1123
|
|
|
@@ -770,9 +1160,9 @@ function main() {
|
|
|
770
1160
|
}
|
|
771
1161
|
|
|
772
1162
|
if (opts.json) {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
);
|
|
1163
|
+
const report = { checked: results.length, failures: failures.length, drifted, drifted_files, no_baseline, no_baseline_files, baseline_covered, results };
|
|
1164
|
+
if (decisions) report.decisions = { applied: decisions.length, retired: retiredList };
|
|
1165
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
776
1166
|
} else {
|
|
777
1167
|
process.stdout.write(`# Hunk Verification Gate (#2969)\n\n`);
|
|
778
1168
|
process.stdout.write(`Checked: ${results.length} file(s)\n`);
|
|
@@ -780,7 +1170,18 @@ function main() {
|
|
|
780
1170
|
// #4135: the coverage headline is printed on EVERY run, before any
|
|
781
1171
|
// per-file detail, so a near-zero-coverage green run can never render
|
|
782
1172
|
// identically to a fully-verified one.
|
|
783
|
-
process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n
|
|
1173
|
+
process.stdout.write(`${coverageHeadline(baseline_covered, results.length)}\n`);
|
|
1174
|
+
if (decisions) {
|
|
1175
|
+
process.stdout.write(`Decisions: ${decisions.length} applied, ${retiredList.length} hunk(s) retired by user decision\n`);
|
|
1176
|
+
}
|
|
1177
|
+
process.stdout.write('\n');
|
|
1178
|
+
if (decisions && retiredList.length > 0) {
|
|
1179
|
+
process.stdout.write(`## Hunks retired or rewritten by user decision\n\n`);
|
|
1180
|
+
for (const r of retiredList) {
|
|
1181
|
+
process.stdout.write(`- ${r.file} hunk ${r.hunk_id} (${r.disposition})\n`);
|
|
1182
|
+
}
|
|
1183
|
+
process.stdout.write('\n');
|
|
1184
|
+
}
|
|
784
1185
|
if (baseline_covered < results.length) {
|
|
785
1186
|
if (!opts.pristineDir) {
|
|
786
1187
|
process.stdout.write(`No --pristine-dir: over-broad fallback — nothing was verified against a pristine baseline (#2998).\n\n`);
|
|
@@ -821,5 +1222,5 @@ if (require.main === module) {
|
|
|
821
1222
|
runMain(main);
|
|
822
1223
|
}
|
|
823
1224
|
|
|
824
|
-
module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage };
|
|
1225
|
+
module.exports = { computeHunks, annotateHunks, buildFileHunks, HUNK_REASON, HUNK_RECOMMENDATION, DISPOSITION, computeUserAddedLines, isSignificantLine, verifyFile, classifyFile, walk, REASON, CLASSIFICATION, PRISTINE_RESOLUTION, resolvePristineBaseline, readPristineHashes, sha256, resolveInstalledPath, resolveSkillsRedirect, coverageHeadline, classifyBaselineCoverage };
|
|
825
1226
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# CI/CD Pipeline Review Patterns
|
|
2
|
+
|
|
3
|
+
Rubric for the **CI/CD pipelines** entry of `gsd-code-reviewer`'s language-aware checks. Read at `standard` and `deep` depth only, when a file in scope is a pipeline file. These defects rarely fail a test. They show up at release or deploy time, as a broken release or a bad production deploy.
|
|
4
|
+
|
|
5
|
+
Pipeline files are recognized by path, not extension: `.github/workflows/*.yml` / `*.yaml`, `Dockerfile*` / `Containerfile`, `docker-compose*.yml` / `compose*.yaml`, and deploy scripts (`deploy*.sh` and equivalents). A deploy script is also a Shell file: apply both sets of checks.
|
|
6
|
+
|
|
7
|
+
Adapted from the `ci-cd-reliability-architecture` skill in [l-gevity/l-gevity-skills](https://github.com/l-gevity/l-gevity-skills) (MIT), reduced to what is checkable from file contents at review time (#4101).
|
|
8
|
+
|
|
9
|
+
## Checks
|
|
10
|
+
|
|
11
|
+
| Check | Signal | Why it matters | Severity |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| **Non-idempotent step** | `npm install` where a `package-lock.json` exists (should be `npm ci`); `pip install` with no lock or pinned requirements; `mkdir` without `-p`; an unguarded `rm`; delete-then-create of a live resource | A re-run produces a different result, or fails part-way and leaves the target broken | Warning |
|
|
14
|
+
| **Missing timeout** | A job with `runs-on:` and no job-level `timeout-minutes:` | A stuck job holds its runner until the 6-hour default | Warning |
|
|
15
|
+
| **Missing concurrency guard** | A deploy workflow with no `concurrency` group and `cancel-in-progress: true` | Two pushes run in parallel; the older run can finish last and deploy over the newer one | Warning |
|
|
16
|
+
| **Per-environment rebuild** | The same commit is built separately for each environment instead of build-once/promote | The artifact that reaches production is not the artifact that passed CI | Warning |
|
|
17
|
+
| **Deploy without health check or rollback** | A deploy step with no post-deploy health check and no rollback path | A bad deploy stays live until a human notices | Warning |
|
|
18
|
+
| **Load-bearing `continue-on-error`** | `continue-on-error: true` on a step whose success later steps assume | The failure is masked; later steps run on a broken state and the job reports green | Warning |
|
|
19
|
+
| **Static cloud credentials** | Long-lived cloud keys stored as secrets for a provider that supports OIDC (`permissions: id-token: write`) | A leaked key stays valid until someone rotates it; OIDC tokens expire per run | Warning |
|
|
20
|
+
| **Unpinned third-party action** | `uses: owner/action@main`, `@master` or `@latest` | An unpinned ref lets the action's code change between two runs of the same commit | Warning |
|
|
21
|
+
|
|
22
|
+
A hardcoded secret in a pipeline file stays Critical under the existing secrets rule. Style-level pipeline findings (step naming, step ordering, redundant checkout options) are Info.
|
|
23
|
+
|
|
24
|
+
## Evidence rule
|
|
25
|
+
|
|
26
|
+
Cite the line. For the concurrency, rebuild and rollback checks, also name what makes the workflow a deploy workflow (the trigger, the environment, the deploy command): those checks are about deploys, not every workflow.
|
|
27
|
+
|
|
28
|
+
## Negative space: NOT findings
|
|
29
|
+
|
|
30
|
+
- **Global tool installs.** `npm install -g <tool>` installs a tool, not the project's dependency tree. A repository with no `package-lock.json` has nothing for `npm ci` to read.
|
|
31
|
+
- **Reusable workflow calls.** A job that calls a reusable workflow (`uses:` at the job level) cannot set `timeout-minutes`; the called workflow owns its timeouts.
|
|
32
|
+
- **Pinned refs.** A version tag (`@v4`) or a full commit SHA is a pin. Local actions (`./path`) and `docker://` references are not third-party action refs.
|
|
33
|
+
- **CI-only workflows.** `cancel-in-progress` is required on deploy workflows. On a workflow that only runs tests it is an optimization, not a Warning.
|
|
34
|
+
- **Informational steps.** `continue-on-error` on a step nothing depends on (a coverage upload, a notification) masks nothing.
|
|
35
|
+
- **Providers without OIDC.** A secret for a provider that does not support OIDC is not a static-credential finding.
|
|
36
|
+
|
|
37
|
+
## Example
|
|
38
|
+
|
|
39
|
+
```markdown
|
|
40
|
+
### WR-02: Deploy workflow lacks concurrency guard — stale commit can deploy last
|
|
41
|
+
|
|
42
|
+
**File:** `.github/workflows/deploy.yml:8`
|
|
43
|
+
**Issue:** Two pushes in quick succession run in parallel; the older run can finish after the newer one and overwrite production.
|
|
44
|
+
**Fix:** Add a `concurrency` group keyed on the ref, with `cancel-in-progress: true`.
|
|
45
|
+
```
|