devflow-kit 2.4.0 → 3.0.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/CHANGELOG.md +229 -0
- package/README.md +111 -18
- package/dist/agents/git.md +822 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/ambient.js +160 -145
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/capture.js +29 -55
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +48 -55
- package/dist/cli/commands/context.js +17 -32
- package/dist/cli/commands/debug.js +65 -26
- package/dist/cli/commands/flags.js +3 -3
- package/dist/cli/commands/hud.js +34 -10
- package/dist/cli/commands/init-seed.js +61 -27
- package/dist/cli/commands/init.js +649 -240
- package/dist/cli/commands/install-report.js +200 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +35 -37
- package/dist/cli/commands/learning.js +79 -57
- package/dist/cli/commands/legacy-hooks.js +11 -14
- package/dist/cli/commands/memory.js +134 -135
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/proxy.js +23 -41
- package/dist/cli/commands/security.js +81 -29
- package/dist/cli/commands/skills.js +71 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +277 -0
- package/dist/cli/commands/uninstall.js +520 -169
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +58 -14
- package/dist/commands/code-review.md +110 -32
- package/dist/commands/debug.md +55 -11
- package/dist/commands/dynamic-build.md +344 -73
- package/dist/commands/dynamic-plan.md +77 -27
- package/dist/commands/dynamic-profile.md +25 -11
- package/dist/commands/dynamic-tickets.md +76 -15
- package/dist/commands/explore.md +37 -7
- package/dist/commands/implement.md +314 -62
- package/dist/commands/plan.md +146 -32
- package/dist/commands/release.md +64 -17
- package/dist/commands/research.md +34 -8
- package/dist/commands/resolve.md +196 -68
- package/dist/commands/self-review.md +45 -9
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/compliance-compose.js +27 -27
- package/dist/core/evidence-policy.js +363 -0
- package/dist/core/feature-config.js +200 -65
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +34 -6
- package/dist/core/fs-atomic.js +27 -0
- package/dist/core/hook-log-dirs.js +104 -0
- package/dist/core/learning-tuning-config.js +5 -3
- package/dist/core/ledger-root.js +102 -0
- package/dist/core/manifest.js +38 -10
- package/dist/core/mds-variants.js +798 -0
- package/dist/core/migrations.js +49 -23
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +361 -12
- package/dist/core/project-paths.js +1 -18
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/same-location.js +25 -0
- package/dist/core/tracker.js +494 -0
- package/dist/hud/components/config-counts.js +15 -4
- package/dist/hud/components/learning-counts.js +14 -0
- package/dist/hud/config.js +2 -1
- package/dist/hud/cost-history.js +2 -4
- package/dist/hud/git.js +52 -7
- package/dist/hud/index.js +7 -9
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/claude-paths.js +59 -57
- package/dist/targets/claude-code/compliance-install.js +49 -65
- package/dist/targets/claude-code/hooks.js +108 -3
- package/dist/targets/claude-code/installer.js +1187 -32
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +366 -151
- package/dist/targets/claude-code/tracker-install.js +134 -0
- package/package.json +8 -6
- package/src/assets/agents/code.md +45 -6
- package/src/assets/agents/design.md +2 -1
- package/src/assets/agents/git.mds +825 -0
- package/src/assets/agents/knowledge.md +3 -3
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/review.md +3 -1
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +474 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_compliance.mds +19 -1
- package/src/assets/commands/_partials/_decisions.mds +15 -3
- package/src/assets/commands/_partials/_docs_root.mds +35 -0
- package/src/assets/commands/_partials/_engine.mds +13 -11
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_factory.mds +1 -1
- package/src/assets/commands/_partials/_knowledge.mds +27 -9
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +2 -2
- package/src/assets/commands/_partials/_publication.mds +8 -2
- package/src/assets/commands/_partials/_settings.mds +28 -0
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +31 -19
- package/src/assets/commands/code-review.mds +67 -41
- package/src/assets/commands/debug.mds +13 -7
- package/src/assets/commands/dynamic-build.mds +274 -66
- package/src/assets/commands/dynamic-plan.mds +50 -23
- package/src/assets/commands/dynamic-profile.mds +24 -11
- package/src/assets/commands/dynamic-tickets.mds +63 -16
- package/src/assets/commands/explore.mds +4 -5
- package/src/assets/commands/implement.mds +234 -67
- package/src/assets/commands/plan.mds +91 -33
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/research.mds +11 -9
- package/src/assets/commands/resolve.mds +150 -78
- package/src/assets/commands/self-review.mds +24 -25
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +305 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +40 -19
- package/src/assets/scripts/hooks/capture-prompt +18 -8
- package/src/assets/scripts/hooks/capture-question +18 -8
- package/src/assets/scripts/hooks/capture-turn +27 -13
- package/src/assets/scripts/hooks/debug-trace +11 -6
- package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
- package/src/assets/scripts/hooks/ensure-proxy +9 -8
- package/src/assets/scripts/hooks/ensure-root-gitignore +236 -60
- package/src/assets/scripts/hooks/git-marker +48 -0
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +228 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -20
- package/src/assets/scripts/hooks/log-paths +80 -0
- package/src/assets/scripts/hooks/memory-worker +22 -13
- package/src/assets/scripts/hooks/pre-compact-memory +44 -15
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +146 -28
- package/src/assets/scripts/hooks/resolve-project-root +101 -7
- package/src/assets/scripts/hooks/session-start-context +534 -20
- package/src/assets/scripts/hooks/session-start-memory +38 -15
- package/src/assets/scripts/lib/project-config.cjs +633 -0
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1145 -0
- package/src/assets/scripts/resolve-settings.cjs +1054 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +4 -2
- package/src/assets/skills/docs-framework/SKILL.md +11 -10
- package/src/assets/skills/docs-framework/references/patterns.md +10 -17
- package/src/assets/skills/gap-analysis/SKILL.md +2 -2
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- package/src/assets/skills/worktree-support/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +29 -0
- package/src/targets/claude-code/templates/managed-settings.json +25 -9
- package/src/assets/agents/git.md +0 -938
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/assets/scripts/resolve-evidence-policy.cjs
|
|
3
|
+
//
|
|
4
|
+
// Resolves EVIDENCE_POLICY for a repository and prints it on ONE line together
|
|
5
|
+
// with the three mechanism inputs that operations receive. Installed as a
|
|
6
|
+
// top-level sibling of hud.sh and redact-secrets.cjs under ~/.devflow/scripts/,
|
|
7
|
+
// with its parser beside it in lib/project-config.cjs.
|
|
8
|
+
//
|
|
9
|
+
// Usage: node resolve-evidence-policy.cjs [<dir>] (<dir> defaults to cwd)
|
|
10
|
+
//
|
|
11
|
+
// The policy is plumbing, decided once by the caller: this script prints it plus
|
|
12
|
+
// the mechanism inputs, and operations only ever see the inputs. It WRITES NOTHING
|
|
13
|
+
// — no file, no git ref, no remote state — so `.devflow/project.json` stays a
|
|
14
|
+
// team-owned file that only the team commits (applies ADR-024). A committed
|
|
15
|
+
// `.devflow/policy.json` is detected by presence and never parsed
|
|
16
|
+
// (D-POLICY-JSON-RETIRED).
|
|
17
|
+
//
|
|
18
|
+
// stdout is exactly one line plus "\n", or empty (D-POLICY-LINE):
|
|
19
|
+
// EVIDENCE_POLICY=<required|standard> SOURCE=<file|worktree|default|invalid|error>
|
|
20
|
+
// REF=<branch|none>[ WARN=<w>[,<w>…]] ISSUE_REQUIRED=<bool> APPLY_CONVENTIONS=<bool>
|
|
21
|
+
// REQUIRE_NON_AUTHOR_APPROVAL=<bool>
|
|
22
|
+
// (one line on stdout; wrapped here for reading). Every value is a token from a
|
|
23
|
+
// closed vocabulary or a SAFE_REF_RE-checked branch name, so no byte of a policy
|
|
24
|
+
// file, a gh answer or a git answer can reach stdout.
|
|
25
|
+
//
|
|
26
|
+
// Exit codes (a caller treats EVERY non-zero code as `required`):
|
|
27
|
+
// 0 resolved — the line above
|
|
28
|
+
// 1 usage error — stdout entirely empty, usage on stderr
|
|
29
|
+
// 2 input unusable — <dir> missing or not a directory; prints FAIL_CLOSED_LINE
|
|
30
|
+
// 3 never emitted — this script writes no file, so the write-failure code of
|
|
31
|
+
// redact-secrets.cjs has no arm here and is absent from EXIT_CODES
|
|
32
|
+
// 4 internal error — or git could not say whether <dir> is in a repository
|
|
33
|
+
// (missing, timed out, killed); prints FAIL_CLOSED_LINE
|
|
34
|
+
// 5 output gate refused — the composed line failed the grammar or was not
|
|
35
|
+
// consistent with the resolved policy; prints FAIL_CLOSED_LINE. Final: a
|
|
36
|
+
// re-run resolves the same inputs to the same refusal
|
|
37
|
+
//
|
|
38
|
+
// Design constraints (binding):
|
|
39
|
+
// - main() returns {code, line} and never calls process.exit; the single
|
|
40
|
+
// `require.main === module` boundary is the only stdout write and the only
|
|
41
|
+
// exitCode assignment, so nothing is truncated and no cleanup is skipped
|
|
42
|
+
// - every subprocess is spawned with an argv array (never a shell), stdin
|
|
43
|
+
// ignored, a timeout and a maxBuffer; every loop has a fixed bound
|
|
44
|
+
// - a project.json that is not a regular file is never opened, and one over
|
|
45
|
+
// MAX_CONFIG_BYTES is never read; a working-tree policy.json is never opened
|
|
46
|
+
// - no index-refreshing git command runs (no `status`, no `diff`), so a
|
|
47
|
+
// repository's configured fsmonitor hook never fires; no fetch, no set-head
|
|
48
|
+
|
|
49
|
+
'use strict';
|
|
50
|
+
|
|
51
|
+
const fs = require('fs');
|
|
52
|
+
const path = require('path');
|
|
53
|
+
const childProcess = require('child_process');
|
|
54
|
+
const projectConfig = require('./lib/project-config.cjs');
|
|
55
|
+
|
|
56
|
+
/** Largest project.json read, in bytes — the shared parser's bound. */
|
|
57
|
+
const { MAX_CONFIG_BYTES } = projectConfig;
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Closed vocabularies
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** @typedef {'required' | 'standard'} Policy */
|
|
64
|
+
/** @typedef {'file' | 'worktree' | 'default' | 'invalid' | 'error'} Source */
|
|
65
|
+
/** @typedef {'remote-unavailable' | 'invalid-file' | 'raised-by-compliance' | 'pr-changes-policy'} Warning */
|
|
66
|
+
|
|
67
|
+
/** Every policy value, strictest first. */
|
|
68
|
+
const POLICIES = Object.freeze(/** @type {Policy[]} */ (['required', 'standard']));
|
|
69
|
+
|
|
70
|
+
/** Every SOURCE value — the governing input, or `error` for a fail-closed exit. */
|
|
71
|
+
const SOURCES = Object.freeze(/** @type {Source[]} */ (['file', 'worktree', 'default', 'invalid', 'error']));
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every WARN token, in the order the line prints them.
|
|
75
|
+
* remote-unavailable the default branch's file could not be consulted
|
|
76
|
+
* invalid-file a folded source (remote, worktree or tracking copy) was invalid —
|
|
77
|
+
* its project.json evidence, or the retired policy.json's presence
|
|
78
|
+
* raised-by-compliance compliance raised a `standard` file or worktree policy
|
|
79
|
+
* pr-changes-policy HEAD or the worktree differs from the default branch (advisory)
|
|
80
|
+
*/
|
|
81
|
+
const WARNINGS = Object.freeze(/** @type {Warning[]} */ ([
|
|
82
|
+
'remote-unavailable',
|
|
83
|
+
'invalid-file',
|
|
84
|
+
'raised-by-compliance',
|
|
85
|
+
'pr-changes-policy',
|
|
86
|
+
]));
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Exit codes by meaning. 3 is deliberately absent: no arm of this script can
|
|
90
|
+
* produce it, and a value in a closed vocabulary that no arm reaches reads as a
|
|
91
|
+
* live outcome to anyone auditing the set.
|
|
92
|
+
*/
|
|
93
|
+
const EXIT_CODES = Object.freeze({
|
|
94
|
+
RESOLVED: 0,
|
|
95
|
+
USAGE: 1,
|
|
96
|
+
INPUT_UNUSABLE: 2,
|
|
97
|
+
INTERNAL_ERROR: 4,
|
|
98
|
+
OUTPUT_GATE_REFUSED: 5,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @typedef {{ ISSUE_REQUIRED: boolean, APPLY_CONVENTIONS: boolean, REQUIRE_NON_AUTHOR_APPROVAL: boolean }} MechanismInputs
|
|
103
|
+
*/
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* D-POLICY-PLUMBING: the mechanism inputs are a pure function of EVIDENCE_POLICY,
|
|
107
|
+
* and this table is their single authority. Operations receive these three
|
|
108
|
+
* booleans and never the policy itself; a prompt that restated the mapping would
|
|
109
|
+
* be a second authority free to drift from this one.
|
|
110
|
+
* ISSUE_REQUIRED a tracked issue is mandatory for the task
|
|
111
|
+
* APPLY_CONVENTIONS learned naming conventions are applied (branch, PR title)
|
|
112
|
+
* REQUIRE_NON_AUTHOR_APPROVAL merge-readiness demands a non-author approval
|
|
113
|
+
* Per-PR content (a PR's test-plan block, its exceptions) is rendered by callers
|
|
114
|
+
* and is not a function of the policy, so it is not emitted here.
|
|
115
|
+
*
|
|
116
|
+
* @type {Readonly<Record<Policy, Readonly<MechanismInputs>>>}
|
|
117
|
+
*/
|
|
118
|
+
const MECHANISM_INPUTS = Object.freeze({
|
|
119
|
+
required: Object.freeze({ ISSUE_REQUIRED: true, APPLY_CONVENTIONS: true, REQUIRE_NON_AUTHOR_APPROVAL: true }),
|
|
120
|
+
standard: Object.freeze({ ISSUE_REQUIRED: false, APPLY_CONVENTIONS: false, REQUIRE_NON_AUTHOR_APPROVAL: false }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** The mechanism-input keys, in line order. */
|
|
124
|
+
const INPUT_KEYS = Object.freeze(['ISSUE_REQUIRED', 'APPLY_CONVENTIONS', 'REQUIRE_NON_AUTHOR_APPROVAL']);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A branch name this script will put into argv or onto stdout — the shared gate
|
|
128
|
+
* (lib/project-config.cjs), so the settings resolver reads the same branch names.
|
|
129
|
+
*/
|
|
130
|
+
const { SAFE_REF_RE } = projectConfig;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* D-POLICY-LINE: the exported output grammar — anchored, closed alternations
|
|
134
|
+
* only, named groups for consumers. Consumers' parses are pinned to this
|
|
135
|
+
* expression, so it is the one definition of a well-formed line. REF admits
|
|
136
|
+
* `none` or a SAFE_REF_RE branch (a branch literally named `none` is ambiguous;
|
|
137
|
+
* REF is display-only and no consumer branches on it).
|
|
138
|
+
*/
|
|
139
|
+
const OUTPUT_LINE_RE = /^EVIDENCE_POLICY=(?<policy>required|standard) SOURCE=(?<source>file|worktree|default|invalid|error) REF=(?<ref>none|(?![A-Za-z0-9._/-]{0,254}\.\.)[A-Za-z0-9][A-Za-z0-9._/-]{0,254})(?: WARN=(?<warn>(?:remote-unavailable|invalid-file|raised-by-compliance|pr-changes-policy)(?:,(?:remote-unavailable|invalid-file|raised-by-compliance|pr-changes-policy)){0,3}))? ISSUE_REQUIRED=(?<issue>true|false) APPLY_CONVENTIONS=(?<conventions>true|false) REQUIRE_NON_AUTHOR_APPROVAL=(?<approval>true|false)$/;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The line every refusal prints (exits 2, 4 and 5). A constant, so the boundary
|
|
143
|
+
* can never fail to compose it, and `required` in every field a consumer reads.
|
|
144
|
+
*/
|
|
145
|
+
const FAIL_CLOSED_LINE =
|
|
146
|
+
'EVIDENCE_POLICY=required SOURCE=error REF=none ISSUE_REQUIRED=true APPLY_CONVENTIONS=true REQUIRE_NON_AUTHOR_APPROVAL=true';
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Subprocess bounds (every spawn carries a timeout and a maxBuffer)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
const GH_TIMEOUT_MS = 10000;
|
|
153
|
+
const GIT_REMOTE_TIMEOUT_MS = 10000;
|
|
154
|
+
const GIT_LOCAL_TIMEOUT_MS = 5000;
|
|
155
|
+
/** gh answers and blob reads: generous for a 4 KiB project.json, and an overflow is ENOBUFS ⇒ invalid. */
|
|
156
|
+
const BLOB_MAX_BUFFER = 65536;
|
|
157
|
+
/** rev-parse and ls-remote print one short line each. */
|
|
158
|
+
const LINE_MAX_BUFFER = 4096;
|
|
159
|
+
|
|
160
|
+
/** The repository-relative path of the retired policy file, in git and API spelling (D-POLICY-JSON-RETIRED). */
|
|
161
|
+
const POLICY_REL = '.devflow/policy.json';
|
|
162
|
+
|
|
163
|
+
/** The repository-relative path of the team config file, in git and API spelling. */
|
|
164
|
+
const PROJECT_REL = '.devflow/project.json';
|
|
165
|
+
|
|
166
|
+
/** `git ls-remote --symref origin HEAD`, first line. Bounded class, no backtracking. */
|
|
167
|
+
const LS_REMOTE_SYMREF_RE = /^ref: refs\/heads\/([^\t\n]{1,255})\tHEAD$/;
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Types shared by the helpers
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @typedef {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'valid', policy: Policy }} ParsedPolicy
|
|
175
|
+
*
|
|
176
|
+
* @typedef {{ policy: Policy, source: Source, ref: string, warnings: Warning[], inputs: MechanismInputs }} Resolution
|
|
177
|
+
* `ref` is the default branch as resolved, else `none`. Frozen, arrays included.
|
|
178
|
+
*
|
|
179
|
+
* @typedef {{ status: number | null, stdout?: Buffer | string, stderr?: Buffer | string, error?: { code?: string } }} ExecResult
|
|
180
|
+
* The spawnSync subset this script reads.
|
|
181
|
+
*
|
|
182
|
+
* @typedef {(file: string, args: string[], opts: object) => ExecResult} ExecFn
|
|
183
|
+
* Called exactly like child_process.spawnSync(file, args, opts).
|
|
184
|
+
*
|
|
185
|
+
* @typedef {{ dir: string, compliance?: unknown }} ResolveOptions
|
|
186
|
+
* `compliance` is the caller's already-read `manifest.features.compliance` value
|
|
187
|
+
* (raw or normalized — complianceDefault accepts either). Omitted (undefined), the
|
|
188
|
+
* script reads the manifest itself; pass `null` for "no compliance state".
|
|
189
|
+
*
|
|
190
|
+
* @typedef {{ exec?: ExecFn }} ResolveDeps
|
|
191
|
+
* `exec` is the only injected I/O; it defaults to child_process.spawnSync.
|
|
192
|
+
*
|
|
193
|
+
* @typedef {{ kind: 'resolve', dir: string } | { kind: 'usage', usage: string }} ParsedArgs
|
|
194
|
+
*
|
|
195
|
+
* @typedef {{ exec?: ExecFn, formatLine?: (r: Resolution) => unknown }} MainDeps
|
|
196
|
+
* Injected by tests only, to reach the output gate.
|
|
197
|
+
*
|
|
198
|
+
* @typedef {{ code: number, line: string }} MainOutcome
|
|
199
|
+
* `line` is '' only for code 1, FAIL_CLOSED_LINE for codes 2, 4 and 5.
|
|
200
|
+
*/
|
|
201
|
+
|
|
202
|
+
/** @type {ParsedPolicy} */
|
|
203
|
+
const ABSENT = Object.freeze({ kind: 'absent' });
|
|
204
|
+
/** @type {ParsedPolicy} */
|
|
205
|
+
const INVALID = Object.freeze({ kind: 'invalid' });
|
|
206
|
+
|
|
207
|
+
/** @type {Resolution} */
|
|
208
|
+
const ERROR_RESOLUTION = Object.freeze({
|
|
209
|
+
policy: 'required',
|
|
210
|
+
source: 'error',
|
|
211
|
+
ref: 'none',
|
|
212
|
+
warnings: Object.freeze([]),
|
|
213
|
+
inputs: MECHANISM_INPUTS.required,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// parseArgs
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Parse argv into a directory or a usage error. `kind` is the discriminant, and
|
|
222
|
+
* main() dispatches on it alone. Any `-`-prefixed argument (including `-` and
|
|
223
|
+
* `--`) or a second positional is a usage error, never an ignored argument.
|
|
224
|
+
*
|
|
225
|
+
* @param {readonly string[]} argv process.argv
|
|
226
|
+
* @returns {ParsedArgs}
|
|
227
|
+
*/
|
|
228
|
+
function parseArgs(argv) {
|
|
229
|
+
const USAGE = 'Usage: node resolve-evidence-policy.cjs [<dir>]';
|
|
230
|
+
const rest = Array.isArray(argv) ? argv.slice(2) : [];
|
|
231
|
+
/** @type {string[]} */
|
|
232
|
+
const positionals = [];
|
|
233
|
+
for (const arg of rest) {
|
|
234
|
+
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
|
235
|
+
return {
|
|
236
|
+
kind: 'usage',
|
|
237
|
+
usage: 'resolve-evidence-policy: unrecognised argument ' + JSON.stringify(String(arg)).slice(0, 80) + '\n' + USAGE,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
positionals.push(arg);
|
|
241
|
+
}
|
|
242
|
+
if (positionals.length > 1) return { kind: 'usage', usage: USAGE };
|
|
243
|
+
return { kind: 'resolve', dir: positionals.length === 1 ? positionals[0] : process.cwd() };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Compliance default
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The compliance default C. Mirrors the CLI's `normalizeComplianceFeature`
|
|
252
|
+
* (src/core/compliance.ts) exactly — absent or malformed is disabled — behind a
|
|
253
|
+
* parity test. Enabled is `required` WHATEVER the framework count: an install
|
|
254
|
+
* with zero frameworks ("generic controls only") still installs the compliance
|
|
255
|
+
* skill and is gated today, and no compliance-on user may lose that. Which
|
|
256
|
+
* frameworks are listed therefore never matters, only that the value is
|
|
257
|
+
* well-formed.
|
|
258
|
+
*
|
|
259
|
+
* @param {unknown} rawFeatureValue `manifest.features.compliance`, raw or normalized
|
|
260
|
+
* @returns {Policy}
|
|
261
|
+
*/
|
|
262
|
+
function complianceDefault(rawFeatureValue) {
|
|
263
|
+
const raw = /** @type {any} */ (rawFeatureValue);
|
|
264
|
+
if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return 'standard';
|
|
265
|
+
if (typeof raw.enabled !== 'boolean') return 'standard';
|
|
266
|
+
if (!Array.isArray(raw.frameworks)) return 'standard';
|
|
267
|
+
if (!raw.frameworks.every((/** @type {unknown} */ f) => typeof f === 'string')) return 'standard';
|
|
268
|
+
return raw.enabled ? 'required' : 'standard';
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Bounded file reads
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** Bounded, never-writing file read — the shared one (lib/project-config.cjs). */
|
|
276
|
+
const readBoundedRegularFile = projectConfig.readBoundedRegularFile;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* D-POLICY-JSON-RETIRED: `.devflow/policy.json` is never parsed. At a source whose
|
|
280
|
+
* project.json does not decide the evidence (absent, or present without an
|
|
281
|
+
* `evidence` key), an existing policy.json makes that source `invalid` — whatever
|
|
282
|
+
* its bytes say, `standard` and unreadable alike — so the repository resolves
|
|
283
|
+
* `required` with `invalid-file` until the team states its value as `evidence` in
|
|
284
|
+
* project.json. With neither file the source is absent. Presence is the whole
|
|
285
|
+
* signal, and each source probes it with the call it already makes:
|
|
286
|
+
* R the default branch's contents call — exit 0 or an overflow is present, a
|
|
287
|
+
* 404 absent, anything else the remote unavailable (remoteSource)
|
|
288
|
+
* T/H `git cat-file blob` — exit 0 present, an answered non-zero exit absent,
|
|
289
|
+
* an unanswered call invalid (catFilePolicyPresence)
|
|
290
|
+
* W an lstat that never opens the file — ENOENT or ENOTDIR absent, anything
|
|
291
|
+
* else present, a symlink or FIFO included (worktreePolicyPresence)
|
|
292
|
+
* The bytes a call returns are discarded unread, so no value in the file can
|
|
293
|
+
* lower the policy or reach stdout.
|
|
294
|
+
*
|
|
295
|
+
* @param {boolean} present
|
|
296
|
+
* @returns {ParsedPolicy}
|
|
297
|
+
*/
|
|
298
|
+
function retiredPolicyReading(present) {
|
|
299
|
+
return present ? INVALID : ABSENT;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* W — whether the working tree holds a policy.json. lstat only: the file is never
|
|
304
|
+
* opened, so a FIFO cannot block and a symlink is never followed. An lstat that
|
|
305
|
+
* fails for any reason but "no such path" cannot prove absence, so it reads as
|
|
306
|
+
* present (fail closed).
|
|
307
|
+
*
|
|
308
|
+
* @param {string} root
|
|
309
|
+
* @returns {ParsedPolicy}
|
|
310
|
+
*/
|
|
311
|
+
function worktreePolicyPresence(root) {
|
|
312
|
+
try {
|
|
313
|
+
fs.lstatSync(path.join(root, '.devflow', 'policy.json'));
|
|
314
|
+
return retiredPolicyReading(true);
|
|
315
|
+
} catch (/** @type {any} */ err) {
|
|
316
|
+
return retiredPolicyReading(!(err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// Source readings (D-POLICY-SOURCE-PRECEDENCE, D-COMPLIANCE-REPO-FLOOR)
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @typedef {{ evidence: ParsedPolicy, compliance: boolean }} SourceReading
|
|
326
|
+
* What one source (R, T, H or W) says: its evidence policy, and whether its
|
|
327
|
+
* project.json declares repository compliance.
|
|
328
|
+
*
|
|
329
|
+
* @typedef {{ evidence: ParsedPolicy | null, compliance: boolean }} ProjectReading
|
|
330
|
+
* `evidence` null ⇒ project.json does not decide it, so the presence of the
|
|
331
|
+
* retired policy file at the same source does (D-POLICY-JSON-RETIRED).
|
|
332
|
+
*
|
|
333
|
+
* @typedef {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'parsed', evidence: { kind: string, value?: unknown }, compliance: { kind: string } }} ProjectConfigView
|
|
334
|
+
* The part of lib/project-config.cjs ProjectConfig this script reads.
|
|
335
|
+
*/
|
|
336
|
+
|
|
337
|
+
/** @type {ProjectConfigView} */
|
|
338
|
+
const PROJECT_ABSENT = Object.freeze({ kind: 'absent' });
|
|
339
|
+
/** @type {ProjectConfigView} */
|
|
340
|
+
const PROJECT_INVALID = Object.freeze({ kind: 'invalid' });
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* D-POLICY-SOURCE-PRECEDENCE: at every source, the `evidence` key of
|
|
344
|
+
* `project.json` is the one authority on the evidence policy. A valid value
|
|
345
|
+
* decides it, and a present-but-malformed or duplicated `evidence` decides
|
|
346
|
+
* `invalid` (⇒ required); a project.json that is not a JSON object at all is
|
|
347
|
+
* invalid outright. Only where project.json is absent, or present without an
|
|
348
|
+
* `evidence` key, does the same source's retired `policy.json` count — by
|
|
349
|
+
* presence alone (D-POLICY-JSON-RETIRED). The precedence is per source, so each
|
|
350
|
+
* source says ONE thing and foldPolicy folds them unchanged.
|
|
351
|
+
*
|
|
352
|
+
* D-COMPLIANCE-REPO-FLOOR: a `compliance` key in project.json at R, T or W — any
|
|
353
|
+
* value, an empty list and a malformed one included — makes the repository's
|
|
354
|
+
* compliance default `required`, folded with the machine's as
|
|
355
|
+
* stricter(C_machine, C_repo). A machine with compliance on at zero frameworks is
|
|
356
|
+
* already `required`, and a repository that declares compliance is held to the
|
|
357
|
+
* same floor. A project.json that cannot be read counts as declaring it (fail
|
|
358
|
+
* closed).
|
|
359
|
+
*
|
|
360
|
+
* @param {ProjectConfigView} project
|
|
361
|
+
* @returns {ProjectReading}
|
|
362
|
+
*/
|
|
363
|
+
function projectReading(project) {
|
|
364
|
+
if (project.kind === 'absent') return { evidence: null, compliance: false };
|
|
365
|
+
if (project.kind === 'invalid') return { evidence: INVALID, compliance: true };
|
|
366
|
+
const compliance = project.compliance.kind !== 'absent';
|
|
367
|
+
const evidence = project.evidence;
|
|
368
|
+
if (evidence.kind === 'valid' && POLICIES.includes(/** @type {Policy} */ (evidence.value))) {
|
|
369
|
+
return { evidence: Object.freeze({ kind: 'valid', policy: /** @type {Policy} */ (evidence.value) }), compliance };
|
|
370
|
+
}
|
|
371
|
+
if (evidence.kind === 'absent') return { evidence: null, compliance };
|
|
372
|
+
return { evidence: INVALID, compliance };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Complete a project reading with the retired policy file's presence when
|
|
377
|
+
* project.json did not decide the evidence (D-POLICY-JSON-RETIRED).
|
|
378
|
+
*
|
|
379
|
+
* @param {ProjectReading} reading
|
|
380
|
+
* @param {() => ParsedPolicy} probePolicy called only when needed
|
|
381
|
+
* @returns {SourceReading}
|
|
382
|
+
*/
|
|
383
|
+
function withRetiredPolicy(reading, probePolicy) {
|
|
384
|
+
return { evidence: reading.evidence === null ? probePolicy() : reading.evidence, compliance: reading.compliance };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* W — the working tree's project.json, then (only when it has no `evidence`)
|
|
389
|
+
* whether a policy.json is there. project.json is lstat-refused, never followed:
|
|
390
|
+
* a symlink, directory, FIFO or device is invalid unopened, and an oversize file
|
|
391
|
+
* invalid unread. policy.json is never opened at all.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} root
|
|
394
|
+
* @returns {SourceReading}
|
|
395
|
+
*/
|
|
396
|
+
function readWorktreeSource(root) {
|
|
397
|
+
const read = readBoundedRegularFile(path.join(root, '.devflow', 'project.json'), MAX_CONFIG_BYTES, false);
|
|
398
|
+
const project = read.kind === 'ok' ? projectConfig.parseProjectBytes(read.bytes)
|
|
399
|
+
: read.kind === 'absent' ? PROJECT_ABSENT : PROJECT_INVALID;
|
|
400
|
+
return withRetiredPolicy(projectReading(project), () => worktreePolicyPresence(root));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The raw `features.compliance` value from the machine manifest, or undefined.
|
|
405
|
+
* The manifest is read by the shared reader (lib/project-config.cjs
|
|
406
|
+
* readMachineManifest) — never through the CLI's manifest reader, which
|
|
407
|
+
* heal-writes. Anything unreadable or malformed is "no state", which
|
|
408
|
+
* complianceDefault maps to disabled; since C only ever raises, a missing C can
|
|
409
|
+
* never lower the result below the governing file.
|
|
410
|
+
*
|
|
411
|
+
* @returns {unknown}
|
|
412
|
+
*/
|
|
413
|
+
function readManifestCompliance() {
|
|
414
|
+
const parsed = projectConfig.readMachineManifest();
|
|
415
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
|
|
416
|
+
const features = /** @type {any} */ (parsed).features;
|
|
417
|
+
if (features === null || typeof features !== 'object' || Array.isArray(features)) return undefined;
|
|
418
|
+
return Object.prototype.hasOwnProperty.call(features, 'compliance') ? features.compliance : undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Subprocess calls (D-POLICY-PROBE)
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @typedef {{ ok: boolean, status: number | null, errorCode: string | null, stdout: Buffer, stderr: string }} CallResult
|
|
427
|
+
* @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv }} CallContext
|
|
428
|
+
*/
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether a call ran to completion and exited on its own — as opposed to never
|
|
432
|
+
* starting (ENOENT), timing out, overflowing its buffer or being killed. Only an
|
|
433
|
+
* answered non-zero exit is a real "no" ("not a repository", "no such path",
|
|
434
|
+
* "no such ref"); an unanswered call is NOT KNOWING, and a local-git step that
|
|
435
|
+
* does not know must not read as the permissive answer (avoids PF-075).
|
|
436
|
+
*
|
|
437
|
+
* @param {CallResult} r
|
|
438
|
+
* @returns {boolean}
|
|
439
|
+
*/
|
|
440
|
+
function answered(r) {
|
|
441
|
+
return r.errorCode === null && r.status !== null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** @param {unknown} value @returns {Buffer} */
|
|
445
|
+
function asBuffer(value) {
|
|
446
|
+
if (Buffer.isBuffer(value)) return value;
|
|
447
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
448
|
+
return Buffer.alloc(0);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* One bounded subprocess call, normalized. `ok` is exit 0 with no spawn error.
|
|
453
|
+
* The argv array is a fresh copy, so an exec cannot mutate a caller's constant.
|
|
454
|
+
*
|
|
455
|
+
* @param {CallContext} ctx
|
|
456
|
+
* @param {string} file
|
|
457
|
+
* @param {readonly string[]} args
|
|
458
|
+
* @param {string} cwd
|
|
459
|
+
* @param {number} timeout
|
|
460
|
+
* @param {number} maxBuffer
|
|
461
|
+
* @returns {CallResult}
|
|
462
|
+
*/
|
|
463
|
+
function runCall(ctx, file, args, cwd, timeout, maxBuffer) {
|
|
464
|
+
const res = ctx.exec(file, [...args], {
|
|
465
|
+
cwd,
|
|
466
|
+
env: ctx.env,
|
|
467
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
468
|
+
timeout,
|
|
469
|
+
maxBuffer,
|
|
470
|
+
windowsHide: true,
|
|
471
|
+
shell: false,
|
|
472
|
+
});
|
|
473
|
+
const errorCode = res && res.error ? String(res.error.code || 'EUNKNOWN') : null;
|
|
474
|
+
const status = res && typeof res.status === 'number' ? res.status : null;
|
|
475
|
+
return {
|
|
476
|
+
ok: errorCode === null && status === 0,
|
|
477
|
+
status,
|
|
478
|
+
errorCode,
|
|
479
|
+
stdout: asBuffer(res && res.stdout),
|
|
480
|
+
stderr: asBuffer(res && res.stderr).toString('utf8'),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @typedef {{ kind: 'root', root: string } | { kind: 'none' } | { kind: 'unknown' }} Toplevel
|
|
486
|
+
*/
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Step 1: the repository root.
|
|
490
|
+
* root exit 0 with exactly one absolute path plus its newline
|
|
491
|
+
* none git ANSWERED non-zero — not a repository (or not a work tree); the
|
|
492
|
+
* resolver then treats the remote as unavailable and the worktree file
|
|
493
|
+
* as absent
|
|
494
|
+
* unknown git did not answer (missing, timed out, overflowed, killed), or
|
|
495
|
+
* answered exit 0 — a repository — with an unusable path. Either way a
|
|
496
|
+
* repository's files may exist and cannot be read, so resolve() fails
|
|
497
|
+
* closed rather than resolving from compliance alone
|
|
498
|
+
*
|
|
499
|
+
* @param {CallContext} ctx
|
|
500
|
+
* @param {string} dir
|
|
501
|
+
* @returns {Toplevel}
|
|
502
|
+
*/
|
|
503
|
+
function gitToplevel(ctx, dir) {
|
|
504
|
+
const r = runCall(ctx, 'git', ['rev-parse', '--show-toplevel'], dir, GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
|
|
505
|
+
if (!answered(r)) return { kind: 'unknown' };
|
|
506
|
+
if (r.status !== 0) return { kind: 'none' };
|
|
507
|
+
const text = r.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
508
|
+
if (text === '' || /[\r\n\0]/.test(text) || !path.isAbsolute(text)) return { kind: 'unknown' };
|
|
509
|
+
return { kind: 'root', root: text };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Step 2 — D-POLICY-PROBE: ONE gh call is both the reachability probe and the
|
|
514
|
+
* default-branch lookup. `{owner}/{repo}` are gh's own placeholders, filled from
|
|
515
|
+
* the repository's remotes (host inference is gh's). Reachable iff exit 0 with a
|
|
516
|
+
* SAFE_REF_RE value that is not jq's `null`; anything else — no gh, no auth, no
|
|
517
|
+
* GitHub remote, a hostile answer — is unavailable.
|
|
518
|
+
*
|
|
519
|
+
* @param {CallContext} ctx
|
|
520
|
+
* @param {string} root
|
|
521
|
+
* @returns {string | null} the default branch D, or null (unavailable)
|
|
522
|
+
*/
|
|
523
|
+
function probeDefaultBranch(ctx, root) {
|
|
524
|
+
const r = runCall(ctx, 'gh', ['api', 'repos/{owner}/{repo}', '--jq', '.default_branch'], root, GH_TIMEOUT_MS, BLOB_MAX_BUFFER);
|
|
525
|
+
if (!r.ok) return null;
|
|
526
|
+
const value = r.stdout.toString('utf8').replace(/\n$/, '');
|
|
527
|
+
return value !== 'null' && SAFE_REF_RE.test(value) ? value : null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @typedef {{ kind: 'bytes', bytes: Buffer } | { kind: 'absent' } | { kind: 'overflow' }} RemoteFile
|
|
532
|
+
*/
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Step 3: one file on the default branch — or null when the remote turned out to
|
|
536
|
+
* be unavailable after all.
|
|
537
|
+
*
|
|
538
|
+
* `--method GET` is mandatory: gh sends POST whenever a field is added. stdout is
|
|
539
|
+
* file content ONLY on exit 0 — a 404 prints gh's JSON error body on stdout, so
|
|
540
|
+
* it is classified from stderr and the exit code and its stdout is never parsed.
|
|
541
|
+
* ENOBUFS ⇒ overflow — invalid, never unavailable,
|
|
542
|
+
* or a huge file would hand control to
|
|
543
|
+
* the worktree
|
|
544
|
+
* exit 0 ⇒ the bytes
|
|
545
|
+
* exit ≠ 0 and stderr has `(HTTP 404)` ⇒ absent — the probe already succeeded,
|
|
546
|
+
* so 404 means "no file", not "no access"
|
|
547
|
+
* anything else (403/429, 5xx, timeout, spawn error) ⇒ unavailable
|
|
548
|
+
*
|
|
549
|
+
* @param {CallContext} ctx
|
|
550
|
+
* @param {string} root
|
|
551
|
+
* @param {string} ref
|
|
552
|
+
* @param {string} rel PROJECT_REL or POLICY_REL — a constant, never input
|
|
553
|
+
* @returns {RemoteFile | null}
|
|
554
|
+
*/
|
|
555
|
+
function fetchRemoteFile(ctx, root, ref, rel) {
|
|
556
|
+
const r = runCall(ctx, 'gh', [
|
|
557
|
+
'api', '--method', 'GET', 'repos/{owner}/{repo}/contents/' + rel,
|
|
558
|
+
'-f', 'ref=' + ref, '-H', 'Accept: application/vnd.github.raw+json',
|
|
559
|
+
], root, GH_TIMEOUT_MS, BLOB_MAX_BUFFER);
|
|
560
|
+
if (r.errorCode === 'ENOBUFS') return { kind: 'overflow' };
|
|
561
|
+
if (r.ok) return { kind: 'bytes', bytes: r.stdout };
|
|
562
|
+
if (r.errorCode === null && r.status !== null && r.status !== 0 && r.stderr.includes('(HTTP 404)')) return { kind: 'absent' };
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* R — the default branch's project.json, then (only when it is a 404 or has no
|
|
568
|
+
* `evidence`) whether its policy.json exists — the contents call's bytes are
|
|
569
|
+
* discarded unread (D-POLICY-JSON-RETIRED). Null when either call finds the
|
|
570
|
+
* remote unavailable: a half-read default branch is not a default branch.
|
|
571
|
+
*
|
|
572
|
+
* @param {CallContext} ctx
|
|
573
|
+
* @param {string} root
|
|
574
|
+
* @param {string} ref
|
|
575
|
+
* @returns {SourceReading | null}
|
|
576
|
+
*/
|
|
577
|
+
function remoteSource(ctx, root, ref) {
|
|
578
|
+
const projectFile = fetchRemoteFile(ctx, root, ref, PROJECT_REL);
|
|
579
|
+
if (projectFile === null) return null;
|
|
580
|
+
const reading = projectReading(projectFile.kind === 'bytes' ? projectConfig.parseProjectBytes(projectFile.bytes)
|
|
581
|
+
: projectFile.kind === 'absent' ? PROJECT_ABSENT : PROJECT_INVALID);
|
|
582
|
+
if (reading.evidence !== null) return { evidence: reading.evidence, compliance: reading.compliance };
|
|
583
|
+
const policyFile = fetchRemoteFile(ctx, root, ref, POLICY_REL);
|
|
584
|
+
if (policyFile === null) return null;
|
|
585
|
+
return withRetiredPolicy(reading, () => retiredPolicyReading(policyFile.kind !== 'absent'));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Step 5 (offline, D unknown): the default branch from `ls-remote --symref`.
|
|
590
|
+
* Only line 1 is read, and its capture must still pass SAFE_REF_RE.
|
|
591
|
+
*
|
|
592
|
+
* @param {CallContext} ctx
|
|
593
|
+
* @param {string} root
|
|
594
|
+
* @returns {string | null}
|
|
595
|
+
*/
|
|
596
|
+
function lsRemoteDefaultBranch(ctx, root) {
|
|
597
|
+
const r = runCall(ctx, 'git', ['ls-remote', '--symref', 'origin', 'HEAD'], root, GIT_REMOTE_TIMEOUT_MS, LINE_MAX_BUFFER);
|
|
598
|
+
if (!r.ok) return null;
|
|
599
|
+
const text = r.stdout.toString('utf8');
|
|
600
|
+
const newline = text.indexOf('\n');
|
|
601
|
+
const m = LS_REMOTE_SYMREF_RE.exec(newline === -1 ? text : text.slice(0, newline));
|
|
602
|
+
return m !== null && SAFE_REF_RE.test(m[1]) ? m[1] : null;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* @typedef {{ kind: 'ref', ref: string } | { kind: 'none' } | { kind: 'unreadable' }} OriginHead
|
|
607
|
+
*/
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Step 5b (offline, D still unknown after ls-remote) — D-OFFLINE-ORIGIN-HEAD:
|
|
611
|
+
* the default branch this clone last recorded for origin, read LOCALLY with
|
|
612
|
+
* `git symbolic-ref --quiet refs/remotes/origin/HEAD` (no network, no index
|
|
613
|
+
* refresh). With D unnamed an unreachable origin leaves the worktree's own file
|
|
614
|
+
* governing, so a branch that sets `evidence:"standard"` would resolve standard
|
|
615
|
+
* while main says required; naming D here lets trackingSource fold main's
|
|
616
|
+
* tracking copy in, so the branch cannot lower it.
|
|
617
|
+
* ref exit 0 with one `refs/remotes/origin/<SAFE_REF_RE>` line
|
|
618
|
+
* none an answered non-zero exit: origin/HEAD is not recorded — the
|
|
619
|
+
* residual case, where the worktree file governs with
|
|
620
|
+
* WARN=remote-unavailable
|
|
621
|
+
* unreadable git did not answer, or origin/HEAD exists but its answer names
|
|
622
|
+
* no safe branch (another remote, a hostile or unparseable name).
|
|
623
|
+
* Either is a failure, never the residual case: gatherFacts reads
|
|
624
|
+
* it as an invalid base, which resolves required (avoids PF-075).
|
|
625
|
+
* resolve-settings' defaultBranchCompliance fails the same answer
|
|
626
|
+
* closed, to `generic`.
|
|
627
|
+
*
|
|
628
|
+
* @param {CallContext} ctx
|
|
629
|
+
* @param {string} root
|
|
630
|
+
* @returns {OriginHead}
|
|
631
|
+
*/
|
|
632
|
+
function localOriginHead(ctx, root) {
|
|
633
|
+
const r = runCall(ctx, 'git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], root,
|
|
634
|
+
GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
|
|
635
|
+
if (!answered(r)) return { kind: 'unreadable' };
|
|
636
|
+
if (!r.ok) return { kind: 'none' };
|
|
637
|
+
const ref = projectConfig.parseOriginHeadRef(r.stdout.toString('utf8'));
|
|
638
|
+
return ref === null ? { kind: 'unreadable' } : { kind: 'ref', ref };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Steps 4 and 7: one blob at a revision (HEAD, or the tracking ref).
|
|
643
|
+
* Exit 0 ⇒ the bytes; an answered non-zero exit (the path does not exist there,
|
|
644
|
+
* an unborn HEAD) ⇒ absent; an unanswered call (ENOBUFS included) ⇒ unanswered,
|
|
645
|
+
* which every caller reads as invalid — never absent, or a git that timed out
|
|
646
|
+
* would silently drop the default branch's copy from the fold.
|
|
647
|
+
*
|
|
648
|
+
* @param {CallContext} ctx
|
|
649
|
+
* @param {string} root
|
|
650
|
+
* @param {string} revision
|
|
651
|
+
* @param {string} rel PROJECT_REL or POLICY_REL — a constant, never input
|
|
652
|
+
* @returns {{ kind: 'bytes', bytes: Buffer } | { kind: 'absent' } | { kind: 'unanswered' }}
|
|
653
|
+
*/
|
|
654
|
+
function catFileBlob(ctx, root, revision, rel) {
|
|
655
|
+
const r = runCall(ctx, 'git', ['cat-file', 'blob', revision + ':' + rel], root,
|
|
656
|
+
GIT_LOCAL_TIMEOUT_MS, BLOB_MAX_BUFFER);
|
|
657
|
+
if (r.ok) return { kind: 'bytes', bytes: r.stdout };
|
|
658
|
+
return answered(r) ? { kind: 'absent' } : { kind: 'unanswered' };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Whether a revision holds the retired policy blob (D-POLICY-JSON-RETIRED): its
|
|
663
|
+
* bytes are discarded unread, and an unanswered read cannot prove absence, so it
|
|
664
|
+
* reads as present.
|
|
665
|
+
*
|
|
666
|
+
* @param {CallContext} ctx
|
|
667
|
+
* @param {string} root
|
|
668
|
+
* @param {string} revision
|
|
669
|
+
* @returns {ParsedPolicy}
|
|
670
|
+
*/
|
|
671
|
+
function catFilePolicyPresence(ctx, root, revision) {
|
|
672
|
+
return retiredPolicyReading(catFileBlob(ctx, root, revision, POLICY_REL).kind !== 'absent');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* T or H: a revision's project.json, then (only when it is absent or has no
|
|
677
|
+
* `evidence`) whether its policy.json exists.
|
|
678
|
+
*
|
|
679
|
+
* @param {CallContext} ctx
|
|
680
|
+
* @param {string} root
|
|
681
|
+
* @param {string} revision
|
|
682
|
+
* @returns {SourceReading}
|
|
683
|
+
*/
|
|
684
|
+
function revisionSource(ctx, root, revision) {
|
|
685
|
+
const blob = catFileBlob(ctx, root, revision, PROJECT_REL);
|
|
686
|
+
const project = blob.kind === 'bytes' ? projectConfig.parseProjectBytes(blob.bytes)
|
|
687
|
+
: blob.kind === 'absent' ? PROJECT_ABSENT : PROJECT_INVALID;
|
|
688
|
+
return withRetiredPolicy(projectReading(project), () => catFilePolicyPresence(ctx, root, revision));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Steps 6 and 7 (offline, D known): T, the local tracking copy of the default
|
|
693
|
+
* branch's files. The ref check decides whether T can stand for the default branch
|
|
694
|
+
* at all — a ref git ANSWERS is missing is "B unknown" (null), which a failed blob
|
|
695
|
+
* read could not tell apart from "file absent". A ref check git does not answer is
|
|
696
|
+
* not knowing, so T is invalid (it raises) rather than unknown (it would not).
|
|
697
|
+
*
|
|
698
|
+
* @param {CallContext} ctx
|
|
699
|
+
* @param {string} root
|
|
700
|
+
* @param {string} ref
|
|
701
|
+
* @returns {SourceReading | null}
|
|
702
|
+
*/
|
|
703
|
+
function trackingSource(ctx, root, ref) {
|
|
704
|
+
const trackingRef = 'refs/remotes/origin/' + ref;
|
|
705
|
+
const r = runCall(ctx, 'git', ['rev-parse', '--verify', '--quiet', trackingRef], root,
|
|
706
|
+
GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
|
|
707
|
+
if (r.ok) return revisionSource(ctx, root, trackingRef);
|
|
708
|
+
return answered(r) ? null : { evidence: INVALID, compliance: false };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
// Gathering the facts (the imperative shell)
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* @typedef {{
|
|
717
|
+
* reachable: boolean,
|
|
718
|
+
* ref: string | null,
|
|
719
|
+
* remote: ParsedPolicy | null,
|
|
720
|
+
* worktree: ParsedPolicy,
|
|
721
|
+
* tracking: ParsedPolicy | null,
|
|
722
|
+
* head: ParsedPolicy | null,
|
|
723
|
+
* compliance: Policy,
|
|
724
|
+
* }} Facts
|
|
725
|
+
* remote R — set iff reachable
|
|
726
|
+
* tracking T — set iff offline and refs/remotes/origin/<D> exists (or git
|
|
727
|
+
* could not answer whether it does, or origin/HEAD is unreadable —
|
|
728
|
+
* then invalid)
|
|
729
|
+
* head H — set iff B (R online, T offline) is known
|
|
730
|
+
* compliance stricter(C_machine, C_repo) — D-COMPLIANCE-REPO-FLOOR
|
|
731
|
+
*/
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Run the fixed call sequence (D-POLICY-PROBE). The only calls ever made, in order:
|
|
735
|
+
* git rev-parse --show-toplevel (cwd = <dir>)
|
|
736
|
+
* gh api repos/{owner}/{repo} --jq .default_branch
|
|
737
|
+
* gh api --method GET …/contents/.devflow/project.json (the probe named D)
|
|
738
|
+
* gh api --method GET …/contents/.devflow/policy.json (… and project.json is
|
|
739
|
+
* a 404 or has no evidence;
|
|
740
|
+
* presence only)
|
|
741
|
+
* git ls-remote --symref origin HEAD (offline, D unknown)
|
|
742
|
+
* git symbolic-ref --quiet refs/remotes/origin/HEAD (… and ls-remote named none)
|
|
743
|
+
* git rev-parse --verify --quiet refs/remotes/origin/D (offline, D known)
|
|
744
|
+
* git cat-file blob refs/remotes/origin/D:…/project.json (that ref exists)
|
|
745
|
+
* git cat-file blob refs/remotes/origin/D:…/policy.json (… no evidence there; presence)
|
|
746
|
+
* git cat-file blob HEAD:…/project.json (B known)
|
|
747
|
+
* git cat-file blob HEAD:…/policy.json (… no evidence there; presence)
|
|
748
|
+
*
|
|
749
|
+
* Returns null when git cannot say whether <dir> is in a repository at all (see
|
|
750
|
+
* gitToplevel): nothing below can be trusted then, and resolve() fails closed.
|
|
751
|
+
*
|
|
752
|
+
* @param {string} dir
|
|
753
|
+
* @param {Policy} compliance C_machine
|
|
754
|
+
* @param {ExecFn} exec
|
|
755
|
+
* @returns {Facts | null}
|
|
756
|
+
*/
|
|
757
|
+
function gatherFacts(dir, compliance, exec) {
|
|
758
|
+
/** @type {CallContext} */
|
|
759
|
+
const ctx = {
|
|
760
|
+
exec,
|
|
761
|
+
env: Object.assign({}, process.env, { GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0' }),
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const toplevel = gitToplevel(ctx, dir);
|
|
765
|
+
if (toplevel.kind === 'unknown') return null;
|
|
766
|
+
if (toplevel.kind === 'none') {
|
|
767
|
+
return { reachable: false, ref: null, remote: null, worktree: ABSENT, tracking: null, head: null, compliance };
|
|
768
|
+
}
|
|
769
|
+
const root = toplevel.root;
|
|
770
|
+
|
|
771
|
+
const worktree = readWorktreeSource(root);
|
|
772
|
+
let ref = probeDefaultBranch(ctx, root);
|
|
773
|
+
const remote = ref === null ? null : remoteSource(ctx, root, ref);
|
|
774
|
+
const reachable = remote !== null;
|
|
775
|
+
|
|
776
|
+
/** @type {SourceReading | null} */
|
|
777
|
+
let tracking = null;
|
|
778
|
+
if (!reachable) {
|
|
779
|
+
if (ref === null) ref = lsRemoteDefaultBranch(ctx, root);
|
|
780
|
+
if (ref === null) {
|
|
781
|
+
const originHead = localOriginHead(ctx, root);
|
|
782
|
+
if (originHead.kind === 'ref') ref = originHead.ref;
|
|
783
|
+
if (originHead.kind === 'unreadable') tracking = { evidence: INVALID, compliance: false };
|
|
784
|
+
}
|
|
785
|
+
if (ref !== null) tracking = trackingSource(ctx, root, ref);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const baseKnown = reachable || tracking !== null;
|
|
789
|
+
const head = baseKnown ? revisionSource(ctx, root, 'HEAD') : null;
|
|
790
|
+
const repoCompliance = worktree.compliance
|
|
791
|
+
|| (remote !== null && remote.compliance)
|
|
792
|
+
|| (tracking !== null && tracking.compliance);
|
|
793
|
+
return {
|
|
794
|
+
reachable,
|
|
795
|
+
ref,
|
|
796
|
+
remote: remote === null ? null : remote.evidence,
|
|
797
|
+
worktree: worktree.evidence,
|
|
798
|
+
tracking: tracking === null ? null : tracking.evidence,
|
|
799
|
+
head: head === null ? null : head.evidence,
|
|
800
|
+
compliance: stricter(compliance, repoCompliance ? 'required' : 'standard'),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// The fold (the functional core)
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* @param {Policy} a
|
|
810
|
+
* @param {Policy} b
|
|
811
|
+
* @returns {Policy}
|
|
812
|
+
*/
|
|
813
|
+
function stricter(a, b) {
|
|
814
|
+
return a === 'required' || b === 'required' ? 'required' : 'standard';
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The comparable state of a parsed file: absent, invalid, or its policy.
|
|
819
|
+
*
|
|
820
|
+
* @param {ParsedPolicy} p
|
|
821
|
+
* @returns {string}
|
|
822
|
+
*/
|
|
823
|
+
function stateOf(p) {
|
|
824
|
+
return p.kind === 'valid' ? p.policy : p.kind;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* D-POLICY-FOLD: the stricter value always wins, and local sources only raise.
|
|
829
|
+
*
|
|
830
|
+
* The governing file decides SOURCE and the base — R when the remote is
|
|
831
|
+
* reachable, W when it is not:
|
|
832
|
+
* valid v ⇒ file|worktree, base v invalid ⇒ invalid, base required
|
|
833
|
+
* absent ⇒ default, base C
|
|
834
|
+
* The classifier is total by construction: the terminal arm is the conservative
|
|
835
|
+
* one (invalid, required), so an unforeseen state can only tighten.
|
|
836
|
+
*
|
|
837
|
+
* Then, raise-only: online W folds in (a valid v contributes v, invalid
|
|
838
|
+
* contributes required); offline T folds in when pr-changes-policy fires; C
|
|
839
|
+
* always folds in. `raised-by-compliance` fires only for SOURCE file|worktree —
|
|
840
|
+
* under `default`, C IS the default rather than a raise.
|
|
841
|
+
*
|
|
842
|
+
* D-POLICY-CHANGE-DETECT: `pr-changes-policy` fires iff B (the default branch's
|
|
843
|
+
* state — R online, T offline) is known and state(H) ≠ state(B) or state(W) ≠
|
|
844
|
+
* state(B). The comparison is SEMANTIC (absent/invalid/required/standard), so a
|
|
845
|
+
* CRLF checkout or reformatted JSON does not fire it. It means "the branch or
|
|
846
|
+
* working tree differs from the default branch": a branch cut before a later
|
|
847
|
+
* default-branch edit fires it too, and rebasing clears it — consumers must
|
|
848
|
+
* treat it as advisory. It never lowers: online B is already the base and W can
|
|
849
|
+
* only raise; offline T is folded in, so a branch that edits or deletes the file
|
|
850
|
+
* cannot drop below the default-branch copy.
|
|
851
|
+
*
|
|
852
|
+
* @param {Facts} facts
|
|
853
|
+
* @returns {Resolution}
|
|
854
|
+
*/
|
|
855
|
+
function foldPolicy(facts) {
|
|
856
|
+
/** @type {Set<Warning>} */
|
|
857
|
+
const warnings = new Set();
|
|
858
|
+
if (!facts.reachable) warnings.add('remote-unavailable');
|
|
859
|
+
|
|
860
|
+
const governing = facts.reachable && facts.remote !== null ? facts.remote : facts.worktree;
|
|
861
|
+
/** @type {Source} */
|
|
862
|
+
let source;
|
|
863
|
+
/** @type {Policy} */
|
|
864
|
+
let policy;
|
|
865
|
+
if (governing.kind === 'valid') {
|
|
866
|
+
source = facts.reachable ? 'file' : 'worktree';
|
|
867
|
+
policy = governing.policy;
|
|
868
|
+
} else if (governing.kind === 'absent') {
|
|
869
|
+
source = 'default';
|
|
870
|
+
policy = facts.compliance;
|
|
871
|
+
} else {
|
|
872
|
+
source = 'invalid';
|
|
873
|
+
policy = 'required';
|
|
874
|
+
warnings.add('invalid-file');
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const base = facts.reachable ? facts.remote : facts.tracking;
|
|
878
|
+
if (base !== null) {
|
|
879
|
+
const baseState = stateOf(base);
|
|
880
|
+
const headDiffers = facts.head !== null && stateOf(facts.head) !== baseState;
|
|
881
|
+
if (headDiffers || stateOf(facts.worktree) !== baseState) warnings.add('pr-changes-policy');
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** @type {ParsedPolicy[]} */
|
|
885
|
+
const folded = [];
|
|
886
|
+
if (facts.reachable) folded.push(facts.worktree);
|
|
887
|
+
if (!facts.reachable && facts.tracking !== null && warnings.has('pr-changes-policy')) folded.push(facts.tracking);
|
|
888
|
+
for (const file of folded) {
|
|
889
|
+
if (file.kind === 'valid') {
|
|
890
|
+
policy = stricter(policy, file.policy);
|
|
891
|
+
} else if (file.kind === 'invalid') {
|
|
892
|
+
policy = 'required';
|
|
893
|
+
warnings.add('invalid-file');
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const withoutCompliance = policy;
|
|
898
|
+
policy = stricter(policy, facts.compliance);
|
|
899
|
+
if (facts.compliance === 'required' && withoutCompliance === 'standard'
|
|
900
|
+
&& (source === 'file' || source === 'worktree')) {
|
|
901
|
+
warnings.add('raised-by-compliance');
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return Object.freeze({
|
|
905
|
+
policy,
|
|
906
|
+
source,
|
|
907
|
+
ref: facts.ref === null ? 'none' : facts.ref,
|
|
908
|
+
warnings: Object.freeze(WARNINGS.filter(w => warnings.has(w))),
|
|
909
|
+
inputs: MECHANISM_INPUTS[policy],
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// ---------------------------------------------------------------------------
|
|
914
|
+
// resolve
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The production exec: spawnSync, read off the module object at call time.
|
|
919
|
+
*
|
|
920
|
+
* @type {ExecFn}
|
|
921
|
+
*/
|
|
922
|
+
function defaultExec(file, args, opts) {
|
|
923
|
+
return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Resolve the policy for `opts.dir`. Never throws: an unexpected internal
|
|
928
|
+
* failure (the exec itself throwing, say), or a git that cannot say whether
|
|
929
|
+
* `opts.dir` is in a repository, is the fail-closed resolution — `required`,
|
|
930
|
+
* SOURCE `error`, REF `none` — which main() maps to exit 4.
|
|
931
|
+
*
|
|
932
|
+
* @param {ResolveOptions} opts
|
|
933
|
+
* @param {ResolveDeps} [deps]
|
|
934
|
+
* @returns {Resolution}
|
|
935
|
+
*/
|
|
936
|
+
function resolve(opts, deps) {
|
|
937
|
+
try {
|
|
938
|
+
const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
|
|
939
|
+
const compliance = complianceDefault(opts.compliance !== undefined ? opts.compliance : readManifestCompliance());
|
|
940
|
+
const facts = gatherFacts(opts.dir, compliance, exec);
|
|
941
|
+
return facts === null ? ERROR_RESOLUTION : foldPolicy(facts);
|
|
942
|
+
} catch (_) {
|
|
943
|
+
return ERROR_RESOLUTION;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
// The line, and the gate on it
|
|
949
|
+
// ---------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Compose the stdout line for a resolution (D-POLICY-LINE). WARN is omitted when
|
|
953
|
+
* empty; the mechanism inputs follow in their fixed order.
|
|
954
|
+
*
|
|
955
|
+
* @param {Resolution} r
|
|
956
|
+
* @returns {string}
|
|
957
|
+
*/
|
|
958
|
+
function formatLine(r) {
|
|
959
|
+
const warn = r.warnings.length > 0 ? ' WARN=' + r.warnings.join(',') : '';
|
|
960
|
+
const inputs = INPUT_KEYS.map(k => k + '=' + String(r.inputs[/** @type {keyof MechanismInputs} */ (k)])).join(' ');
|
|
961
|
+
return 'EVIDENCE_POLICY=' + r.policy + ' SOURCE=' + r.source + ' REF=' + r.ref + warn + ' ' + inputs;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Whether a line is well-formed AND internally coherent: it matches
|
|
966
|
+
* OUTPUT_LINE_RE, its inputs are exactly MECHANISM_INPUTS for its policy, its
|
|
967
|
+
* WARN tokens are unique and in registry order, and SOURCE invalid|error carries
|
|
968
|
+
* `required`.
|
|
969
|
+
*
|
|
970
|
+
* @param {unknown} line
|
|
971
|
+
* @returns {boolean}
|
|
972
|
+
*/
|
|
973
|
+
function isCoherentLine(line) {
|
|
974
|
+
if (typeof line !== 'string') return false;
|
|
975
|
+
const m = OUTPUT_LINE_RE.exec(line);
|
|
976
|
+
if (m === null || m.groups === undefined) return false;
|
|
977
|
+
const g = m.groups;
|
|
978
|
+
const expected = MECHANISM_INPUTS[/** @type {Policy} */ (g.policy)];
|
|
979
|
+
if (g.issue !== String(expected.ISSUE_REQUIRED)
|
|
980
|
+
|| g.conventions !== String(expected.APPLY_CONVENTIONS)
|
|
981
|
+
|| g.approval !== String(expected.REQUIRE_NON_AUTHOR_APPROVAL)) {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
if ((g.source === 'invalid' || g.source === 'error') && g.policy !== 'required') return false;
|
|
985
|
+
if (g.warn !== undefined) {
|
|
986
|
+
const order = g.warn.split(',').map(t => WARNINGS.indexOf(/** @type {Warning} */ (t)));
|
|
987
|
+
for (let i = 1; i < order.length; i++) {
|
|
988
|
+
if (order[i] <= order[i - 1]) return false;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return true;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* THE GATE main() applies before returning a line: coherent, and carrying
|
|
996
|
+
* exactly the resolved policy — a line that would LOWER the policy is refused
|
|
997
|
+
* even when it is otherwise perfect.
|
|
998
|
+
*
|
|
999
|
+
* @param {unknown} line
|
|
1000
|
+
* @param {Resolution} resolution
|
|
1001
|
+
* @returns {boolean}
|
|
1002
|
+
*/
|
|
1003
|
+
function passesOutputGate(line, resolution) {
|
|
1004
|
+
if (!isCoherentLine(line)) return false;
|
|
1005
|
+
const m = OUTPUT_LINE_RE.exec(/** @type {string} */ (line));
|
|
1006
|
+
return m !== null && m.groups !== undefined && m.groups.policy === resolution.policy;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Settle whatever main() returned into what the boundary may print. The code
|
|
1011
|
+
* must be a known exit code and the line must fit it: '' for usage, a coherent
|
|
1012
|
+
* line for 0, FAIL_CLOSED_LINE for the refusals. Anything else is refused.
|
|
1013
|
+
*
|
|
1014
|
+
* @param {unknown} outcome
|
|
1015
|
+
* @returns {MainOutcome}
|
|
1016
|
+
*/
|
|
1017
|
+
function settleOutcome(outcome) {
|
|
1018
|
+
const o = /** @type {any} */ (outcome);
|
|
1019
|
+
if (o === null || typeof o !== 'object' || typeof o.line !== 'string') {
|
|
1020
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
|
|
1021
|
+
}
|
|
1022
|
+
if (o.code === EXIT_CODES.USAGE) return { code: EXIT_CODES.USAGE, line: '' };
|
|
1023
|
+
if (o.code === EXIT_CODES.RESOLVED) {
|
|
1024
|
+
return isCoherentLine(o.line) ? { code: o.code, line: o.line } : { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: FAIL_CLOSED_LINE };
|
|
1025
|
+
}
|
|
1026
|
+
if (o.code === EXIT_CODES.INPUT_UNUSABLE || o.code === EXIT_CODES.OUTPUT_GATE_REFUSED) {
|
|
1027
|
+
return { code: o.code, line: FAIL_CLOSED_LINE };
|
|
1028
|
+
}
|
|
1029
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* A short, safe label for a thrown value — its class name and system error code,
|
|
1034
|
+
* never its message (a message can quote input bytes).
|
|
1035
|
+
*
|
|
1036
|
+
* @param {unknown} err
|
|
1037
|
+
* @returns {string}
|
|
1038
|
+
*/
|
|
1039
|
+
function errorLabel(err) {
|
|
1040
|
+
const e = /** @type {any} */ (err);
|
|
1041
|
+
const name = e && typeof e.name === 'string' ? e.name : 'unknown';
|
|
1042
|
+
const code = e && typeof e.code === 'string' ? '/' + e.code : '';
|
|
1043
|
+
return (name + code).replace(/[^A-Za-z0-9_/]/g, '').slice(0, 60);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
// main — returns {code, line}; never calls process.exit
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* @param {string} dir
|
|
1052
|
+
* @returns {boolean}
|
|
1053
|
+
*/
|
|
1054
|
+
function isUsableDirectory(dir) {
|
|
1055
|
+
try {
|
|
1056
|
+
return fs.statSync(dir).isDirectory();
|
|
1057
|
+
} catch (_) {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* @param {readonly string[]} argv process.argv
|
|
1064
|
+
* @param {MainDeps} [deps]
|
|
1065
|
+
* @returns {MainOutcome}
|
|
1066
|
+
*/
|
|
1067
|
+
function main(argv, deps) {
|
|
1068
|
+
const d = deps || {};
|
|
1069
|
+
const args = parseArgs(argv);
|
|
1070
|
+
if (args.kind === 'usage') {
|
|
1071
|
+
process.stderr.write(args.usage + '\n');
|
|
1072
|
+
return { code: EXIT_CODES.USAGE, line: '' };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (!isUsableDirectory(args.dir)) {
|
|
1076
|
+
process.stderr.write('resolve-evidence-policy: not a usable directory: '
|
|
1077
|
+
+ JSON.stringify(args.dir).slice(0, 300) + ' — failing closed to required\n');
|
|
1078
|
+
return { code: EXIT_CODES.INPUT_UNUSABLE, line: FAIL_CLOSED_LINE };
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const resolution = resolve({ dir: args.dir }, { exec: d.exec });
|
|
1082
|
+
if (resolution.source === 'error') {
|
|
1083
|
+
process.stderr.write('resolve-evidence-policy: could not resolve (git did not answer, or an internal error)'
|
|
1084
|
+
+ ' — failing closed to required\n');
|
|
1085
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const format = typeof d.formatLine === 'function' ? d.formatLine : formatLine;
|
|
1089
|
+
let line;
|
|
1090
|
+
try {
|
|
1091
|
+
line = format(resolution);
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
process.stderr.write('resolve-evidence-policy: internal error (' + errorLabel(err) + ') — failing closed to required\n');
|
|
1094
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (!passesOutputGate(line, resolution)) {
|
|
1098
|
+
process.stderr.write('resolve-evidence-policy: output gate refused the composed line — failing closed to required\n');
|
|
1099
|
+
return { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: FAIL_CLOSED_LINE };
|
|
1100
|
+
}
|
|
1101
|
+
return { code: EXIT_CODES.RESOLVED, line: /** @type {string} */ (line) };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ---------------------------------------------------------------------------
|
|
1105
|
+
// Top-level boundary
|
|
1106
|
+
//
|
|
1107
|
+
// This is the ONLY place that writes to stdout and sets process.exitCode.
|
|
1108
|
+
// It re-checks what main() returned (settleOutcome) before writing, so even a
|
|
1109
|
+
// main() that returned something malformed prints either nothing (usage) or a
|
|
1110
|
+
// line that passes the gate. Its catch is the fail-closed arm for anything
|
|
1111
|
+
// main() did not anticipate.
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
|
|
1114
|
+
if (require.main === module) {
|
|
1115
|
+
let outcome;
|
|
1116
|
+
try {
|
|
1117
|
+
outcome = main(process.argv);
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
process.stderr.write('resolve-evidence-policy: internal error (' + errorLabel(err) + ') — failing closed to required\n');
|
|
1120
|
+
outcome = { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
|
|
1121
|
+
}
|
|
1122
|
+
const settled = settleOutcome(outcome);
|
|
1123
|
+
if (settled.line !== '') process.stdout.write(settled.line + '\n');
|
|
1124
|
+
process.exitCode = settled.code;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ---------------------------------------------------------------------------
|
|
1128
|
+
// Exports — the CLI's `compliance --status` seam and the unit tests
|
|
1129
|
+
// ---------------------------------------------------------------------------
|
|
1130
|
+
|
|
1131
|
+
module.exports = Object.freeze({
|
|
1132
|
+
POLICIES,
|
|
1133
|
+
SOURCES,
|
|
1134
|
+
WARNINGS,
|
|
1135
|
+
EXIT_CODES,
|
|
1136
|
+
MECHANISM_INPUTS,
|
|
1137
|
+
SAFE_REF_RE,
|
|
1138
|
+
OUTPUT_LINE_RE,
|
|
1139
|
+
FAIL_CLOSED_LINE,
|
|
1140
|
+
parseArgs,
|
|
1141
|
+
complianceDefault,
|
|
1142
|
+
resolve,
|
|
1143
|
+
formatLine,
|
|
1144
|
+
main,
|
|
1145
|
+
});
|