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