@opengsd/gsd-core 1.19.0 → 1.20.1
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/bin/install.js +36 -40
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/commands/gsd/update.md +5 -2
- package/gsd-core/bin/gsd-tools.cjs +177 -93
- 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 +46 -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/gsd-tool-dir.cjs +134 -0
- 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 +181 -68
- package/gsd-core/bin/lib/install-engine.cjs +9 -9
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- 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 +627 -36
- 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 +3039 -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 +759 -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/runtime-artifact-conversion.cjs +310 -130
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- 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 +51 -15
- package/gsd-core/bin/lib/state.cjs +499 -180
- 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 +27 -21
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- 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-agent-isolation-guard.js +1 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
- 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/dist/lib/isolation-sentinel.js +77 -2
- package/hooks/gsd-agent-isolation-guard.js +1 -1
- package/hooks/gsd-cursor-subagent-start.js +1 -1
- 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/hooks/lib/isolation-sentinel.js +77 -2
- 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-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/pi/package-skills/gsd-update/SKILL.md +4 -1
- package/scripts/changeset/lint.cjs +28 -3
- 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 +10 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
- package/scripts/lint-phase-enumeration-drift.cjs +8 -4
- package/scripts/lint-roadmap-progress-drift.cjs +421 -0
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/mutation-matrix.cjs +23 -1
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +48 -7
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/skills/gsd-update/SKILL.md +5 -2
- package/vscode/package.json +1 -1
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Necessity Review
|
|
2
|
+
|
|
3
|
+
Rubric for category 4 (Necessity) of `gsd-code-reviewer`. Read at `standard` and `deep` depth only, before classifying findings. It asks a question the other three categories do not: **does the problem this code addresses actually occur in this stack?** Correct, clean, well-tested code can still guard against nothing.
|
|
4
|
+
|
|
5
|
+
Taxonomy adapted from the `functionality-complexity-tradeoff` skill in [l-gevity/l-gevity-skills](https://github.com/l-gevity/l-gevity-skills) (MIT), #4100.
|
|
6
|
+
|
|
7
|
+
## Taxonomy
|
|
8
|
+
|
|
9
|
+
| Subtype | Signal | Example |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| **Impossible-state guard** | Defends against a state ruled out by the type system, the deployment topology, or a runtime invariant | Null-check on a value whose type is non-nullable end to end; client/server version-skew check in a single-artifact deploy |
|
|
12
|
+
| **Already-defended-elsewhere** | A concern fully owned by another layer, duplicated here | Manual HTML escaping on top of an auto-escaping template engine; manual rollback inside an outer transaction that already rolls back |
|
|
13
|
+
| **Cargo-culted pattern** | A pattern whose prerequisites do not hold in this context | Connection pool in a CLI that runs for 200 ms; singleton cache in a stateless lambda |
|
|
14
|
+
| **Phantom requirement** | Solves a requirement that was never real or has provably lapsed | Feature flag for a launch that completed and is on for every environment; a migration shim for data the repo proves was migrated everywhere |
|
|
15
|
+
| **Generality without instantiation** | An abstraction with exactly one instantiation for its whole lifetime | Strategy pattern with one strategy; plugin registry with one plugin; config key holding the same value in every environment |
|
|
16
|
+
|
|
17
|
+
## Evidence rule
|
|
18
|
+
|
|
19
|
+
Verify the premise before reviewing the implementation. A necessity finding must state **why the guarded state cannot occur** (or why the requirement lapsed) and cite where that is proven: the type signature, the build/deploy configuration, the call sites, the flag's values. The pattern name alone is not a finding — drop it.
|
|
20
|
+
|
|
21
|
+
## Severity
|
|
22
|
+
|
|
23
|
+
- **Warning** (`WR-`) — the impossibility or lapse is provable from the repository.
|
|
24
|
+
- **Info** (`IN-`) — it rests on judgment (likely, but not shown).
|
|
25
|
+
- **Never Critical.** Removal is a scope decision for a human, not a defect fix.
|
|
26
|
+
|
|
27
|
+
## Negative space — NOT necessity findings
|
|
28
|
+
|
|
29
|
+
- **Trust boundary.** A guard on data crossing a trust boundary (user input, network, files, environment, deserialized data, another process) is never an impossible-state guard, even when internal types claim the value is safe — types do not hold across a trust boundary.
|
|
30
|
+
- **Defense in depth.** A deliberate second security layer is not "already-defended-elsewhere".
|
|
31
|
+
- **Test seams.** A single-implementation interface that exists as a dependency-injection or test seam is not "generality without instantiation".
|
|
32
|
+
- **Unreachable code** is category 3 (Code Quality, dead code). Necessity is code that runs, but defends against nothing.
|
|
33
|
+
- **Unproven lapse.** A flag or migration whose lapse cannot be shown from the repository is Info at most.
|
|
34
|
+
|
|
35
|
+
## REVIEW.md format
|
|
36
|
+
|
|
37
|
+
A necessity finding keeps its normal ID and section (`WR-` under `## Warnings`, `IN-` under `## Info`), names its subtype in the title, and carries the category marker line. The marker is what keeps `gsd-code-fixer` from removing the code under the default fix scope:
|
|
38
|
+
|
|
39
|
+
```markdown
|
|
40
|
+
### WR-04: Impossible-state guard — client version check in single-artifact SPA
|
|
41
|
+
|
|
42
|
+
**File:** `src/api/client.ts:42`
|
|
43
|
+
**Category:** necessity
|
|
44
|
+
**Issue:** The guarded state (client/server version skew) cannot occur: both are built and deployed as one artifact (`vite.config.ts:12`, `deploy/Dockerfile:8`).
|
|
45
|
+
**Fix:** Remove the check; record the invariant in a comment or ADR rather than a runtime branch.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Count every necessity finding in the frontmatter `necessity:` key. It is a subset of `warning` + `info`, never added to `total`.
|