@uchibeke/gallivant 0.2.0 → 0.3.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/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
|
@@ -0,0 +1,1875 @@
|
|
|
1
|
+
import { hostDefinition, completionAdapter } from "./host_definitions.mjs";
|
|
2
|
+
// Typed delivery profiles for natural-language intake. Portable: no Node
|
|
3
|
+
// imports, no configuration reads, no provider calls. Agent Passport vendors
|
|
4
|
+
// this file and its portable host-policy dependencies; the intake contract pins their bytes.
|
|
5
|
+
//
|
|
6
|
+
// A profile is the only source of delivery authority for a new task. Request
|
|
7
|
+
// text and model output can describe implementation and can raise clarification
|
|
8
|
+
// questions, but neither can add, widen or remove an external action.
|
|
9
|
+
//
|
|
10
|
+
// The pinned dependency set loads without Node or transport imports. The completion conditions
|
|
11
|
+
// below must match task_completion.mjs; a test asserts that agreement.
|
|
12
|
+
|
|
13
|
+
export const PROFILE_VERSION = 1;
|
|
14
|
+
// Missing pins mean the historical line-based source interpretation.
|
|
15
|
+
export const FRAGMENT_VERSION = 2;
|
|
16
|
+
export function sourceFragmentVersion(task) {
|
|
17
|
+
const version = task.fragmentVersion === undefined ? 1 : task.fragmentVersion;
|
|
18
|
+
if (version !== 1 && version !== FRAGMENT_VERSION) throw profileError("TASK_PROFILE_UNSUPPORTED", "unsupported source fragment version; inspect the saved request with a compatible package or prepare a new task from it");
|
|
19
|
+
return version;
|
|
20
|
+
}
|
|
21
|
+
export function sourceFragments(text, { prefix = "r", source = prefix === "r" ? "original" : `answer:${prefix.replace(/^a-/, "")}`, version = FRAGMENT_VERSION } = {}) {
|
|
22
|
+
sourceFragmentVersion({ fragmentVersion: version });
|
|
23
|
+
const out = [];
|
|
24
|
+
const add = (start, end) => {
|
|
25
|
+
const value = text.slice(start, end), trimmed = value.trim();
|
|
26
|
+
if (trimmed) { start += value.indexOf(trimmed); out.push({ id: `${prefix}-${out.length + 1}`, text: trimmed, source: { of: source, start, end: start + trimmed.length } }); }
|
|
27
|
+
};
|
|
28
|
+
if (version === 1) {
|
|
29
|
+
let index = 0;
|
|
30
|
+
for (const piece of text.split(/(\r?\n)/)) { if (!/^\r?\n$/.test(piece)) add(index, index + piece.length); index += piece.length; }
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
// Delimit outside quoted literals/code only. Whitespace and punctuation keep
|
|
34
|
+
// their original UTF-16 positions; nothing is normalized or discarded.
|
|
35
|
+
const visible = text.replace(LITERAL_SPAN, span => " ".repeat(span.length));
|
|
36
|
+
let start = 0;
|
|
37
|
+
for (const match of visible.matchAll(/\r?\n|[.!?;](?=\s|$)/gu)) {
|
|
38
|
+
if (match[0] === "." && (/[\s.]/u.test(text[match.index - 1] || " ") || /\b(?:e\.g|i\.e|mr|mrs|ms|dr|prof|vs|etc)\.$/iu.test(visible.slice(Math.max(start, match.index - 8), match.index + 1)))) continue;
|
|
39
|
+
const end = match.index + match[0].length;
|
|
40
|
+
add(start, end); start = end;
|
|
41
|
+
}
|
|
42
|
+
add(start, text.length);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
export const DELIVERY_PROFILE_SECTION = "Delivery profile";
|
|
46
|
+
export const DELIVERY_QUESTION_PREFIX = "q-delivery-";
|
|
47
|
+
export const COMPLETION_CONDITIONS = Object.freeze(["delivery_ready", "integrated"]);
|
|
48
|
+
|
|
49
|
+
const profileError = (code, message) => Object.assign(new Error(message), { code, version: PROFILE_VERSION });
|
|
50
|
+
|
|
51
|
+
// Every action Gallivant can name. Only `pull_request` has a supported adapter
|
|
52
|
+
// in this release; merge and deployment are recognised so a request for them
|
|
53
|
+
// fails closed with a remedy instead of being read as a product feature.
|
|
54
|
+
export const DELIVERY_ACTIONS = Object.freeze([
|
|
55
|
+
Object.freeze({ id: "pull_request", label: "Open one review pull request", supported: true, adapter: completionAdapter(),
|
|
56
|
+
effects: Object.freeze(["branch", "commit", "push", "pull_request"]),
|
|
57
|
+
description: "Implement on a task branch and open exactly one pull request for human review. Grants no merge, release or deployment authority." }),
|
|
58
|
+
Object.freeze({ id: "merge", label: "Merge the pull request", supported: false, adapter: null, effects: Object.freeze(["merge"]),
|
|
59
|
+
requires: "an explicit supported merge control and authorization; this release has none, so merges stay with the repository's own controls" }),
|
|
60
|
+
Object.freeze({ id: "deploy", label: "Deploy the change", supported: false, adapter: null, effects: Object.freeze(["deploy"]),
|
|
61
|
+
requires: "an explicit supported deployment control and authorization; this release has none" }),
|
|
62
|
+
]);
|
|
63
|
+
export const DELIVERY_PROFILE_SCHEMA = Object.freeze({ type: "object", properties: {
|
|
64
|
+
action: { type: "string", enum: DELIVERY_ACTIONS.map(action => action.id) },
|
|
65
|
+
completion: { type: "string", enum: COMPLETION_CONDITIONS },
|
|
66
|
+
}, required: ["action"], additionalProperties: false });
|
|
67
|
+
export const DELIVERY_CAPABILITIES = Object.freeze({ version: PROFILE_VERSION, defaultAction: "pull_request", defaultCompletion: "delivery_ready",
|
|
68
|
+
actions: DELIVERY_ACTIONS.map(({ id, label, supported, adapter, description, requires }) => ({ id, label, supported, adapter, ...(description ? { description } : {}), ...(requires ? { requires } : {}) })),
|
|
69
|
+
completions: COMPLETION_CONDITIONS, selectedBy: "owner", expandableBy: [] });
|
|
70
|
+
|
|
71
|
+
const actionFor = id => DELIVERY_ACTIONS.find(action => action.id === id) || null;
|
|
72
|
+
const supportedActions = DELIVERY_ACTIONS.filter(action => action.supported).map(action => action.id);
|
|
73
|
+
|
|
74
|
+
// Build a new task's profile from typed owner input. The legacy `completion`
|
|
75
|
+
// selection remains accepted; it must agree with any explicit profile value.
|
|
76
|
+
export function deliveryProfile(input, { completion, repository, base } = {}) {
|
|
77
|
+
if (input !== undefined && (!input || typeof input !== "object" || Array.isArray(input))) throw profileError("TASK_INPUT", "delivery must be an object selecting a typed action and optional completion condition");
|
|
78
|
+
const extra = input ? Object.keys(input).filter(key => !["action", "completion"].includes(key)) : [];
|
|
79
|
+
if (extra.length) throw profileError("TASK_INPUT", `delivery has unsupported keys: ${extra.join(", ")}; only action and completion are typed profile inputs`);
|
|
80
|
+
const action = input?.action === undefined ? "pull_request" : input.action;
|
|
81
|
+
const descriptor = actionFor(action);
|
|
82
|
+
if (!descriptor) throw profileError("TASK_INPUT", `delivery action ${JSON.stringify(action)} is unknown; supported actions: ${supportedActions.join(", ")}`);
|
|
83
|
+
if (!descriptor.supported) throw profileError("TASK_INPUT", `delivery action ${action} requires ${descriptor.requires}; select ${supportedActions.join(" or ")} and perform ${action} through the repository's own authorized controls`);
|
|
84
|
+
if (input?.completion !== undefined && !COMPLETION_CONDITIONS.includes(input.completion)) throw profileError("TASK_INPUT", `delivery completion must be one of ${COMPLETION_CONDITIONS.join(", ")}`);
|
|
85
|
+
if (completion !== undefined && input?.completion !== undefined && completion !== input.completion) throw profileError("TASK_INPUT", "delivery.completion and the completion target disagree; select one condition");
|
|
86
|
+
const condition = input?.completion ?? completion ?? "delivery_ready";
|
|
87
|
+
if (!COMPLETION_CONDITIONS.includes(condition)) throw profileError("TASK_INPUT", `completion condition must be one of ${COMPLETION_CONDITIONS.join(", ")}`);
|
|
88
|
+
if (!repositoryTarget(repository)) throw profileError("TASK_INPUT", "delivery target requires a resolved owner/name repository");
|
|
89
|
+
if (typeof base !== "string" || !base.trim() || /[\s\0]/.test(base)) throw profileError("TASK_INPUT", "delivery target requires a resolved base branch");
|
|
90
|
+
return { version: PROFILE_VERSION, action, target: { kind: repositoryTarget(repository).adapter.id, repository: repositoryTarget(repository).repository.key, base }, completion: { condition } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Saved profiles are validated on every read that can lead to an effect. An
|
|
94
|
+
// unknown version fails closed with a recovery path; it never degrades to the
|
|
95
|
+
// legacy free-text contract.
|
|
96
|
+
export function validateDeliveryProfile(value) {
|
|
97
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw profileError("TASK_PROFILE_UNSUPPORTED", "the saved delivery profile is missing or malformed; inspect the task read-only and prepare a new task with a typed profile");
|
|
98
|
+
if (value.version !== PROFILE_VERSION) throw profileError("TASK_PROFILE_UNSUPPORTED", `delivery profile version ${JSON.stringify(value.version)} is not supported by this package (supported: ${PROFILE_VERSION}); inspect or export the task read-only, then prepare a new task; this record does not pin the namespace`);
|
|
99
|
+
const descriptor = actionFor(value.action);
|
|
100
|
+
if (!descriptor || !descriptor.supported) throw profileError("TASK_PROFILE_UNSUPPORTED", `delivery action ${JSON.stringify(value.action)} is not supported; prepare a new task with ${supportedActions.join(" or ")}`);
|
|
101
|
+
const target = value.target;
|
|
102
|
+
if (!target || typeof target !== "object" || target.kind !== repositoryTarget(target.repository)?.adapter.id || !repositoryTarget(target.repository) || typeof target.base !== "string" || !target.base.trim()) throw profileError("TASK_PROFILE_UNSUPPORTED", "the saved delivery target is malformed; prepare a new task with a resolved repository and base");
|
|
103
|
+
if (!value.completion || typeof value.completion !== "object" || !COMPLETION_CONDITIONS.includes(value.completion.condition)) throw profileError("TASK_PROFILE_UNSUPPORTED", "the saved completion condition is not supported; prepare a new task");
|
|
104
|
+
const keys = Object.keys(value).sort().join(","), targetKeys = Object.keys(target).sort().join(","), completionKeys = Object.keys(value.completion).join(",");
|
|
105
|
+
if (keys !== "action,completion,target,version" || targetKeys !== "base,kind,repository" || completionKeys !== "condition") throw profileError("TASK_PROFILE_UNSUPPORTED", "the saved delivery profile has unsupported fields; prepare a new task");
|
|
106
|
+
return { version: value.version, action: value.action, target: { kind: target.kind, repository: target.repository, base: target.base }, completion: { condition: value.completion.condition } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Records created since typed profiles exist carry a planner route, a
|
|
110
|
+
// repository snapshot or a pinned model route. One of those without a saved
|
|
111
|
+
// profile is damaged typed state, not a historical task: it fails closed like
|
|
112
|
+
// an unsupported version instead of inheriting legacy free-text semantics.
|
|
113
|
+
export const typedEraTask = task => Boolean(task) && (task.route !== undefined || task.repositoryContext !== undefined || (task.modelRoute !== undefined && task.modelRoute !== null));
|
|
114
|
+
export function savedProfile(task) {
|
|
115
|
+
if (task?.deliveryProfile) return validateDeliveryProfile(task.deliveryProfile);
|
|
116
|
+
if (typedEraTask(task)) throw profileError("TASK_PROFILE_UNSUPPORTED", "the task was created with a typed delivery profile but none is saved; inspect or export the task read-only, then prepare a new task; this record does not pin the namespace");
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Canonical JSON for hashing and for the brief's typed section.
|
|
121
|
+
export const stableProfile = value => {
|
|
122
|
+
const profile = validateDeliveryProfile(value);
|
|
123
|
+
return JSON.stringify({ version: profile.version, action: profile.action, target: { kind: profile.target.kind, repository: profile.target.repository, base: profile.target.base }, completion: { condition: profile.completion.condition } });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export function deliveryProfileFromPrd(prd) {
|
|
127
|
+
const section = prd?.sections?.[DELIVERY_PROFILE_SECTION];
|
|
128
|
+
if (section === undefined) return null;
|
|
129
|
+
if ((String(prd.body || "").match(/^## Delivery profile\s*$/gm) || []).length !== 1) throw profileError("TASK_PROFILE_UNSUPPORTED", "the delivery profile must have exactly one section");
|
|
130
|
+
const match = section.trim().match(/^```json\r?\n([\s\S]+)\r?\n```$/);
|
|
131
|
+
if (!match) throw profileError("TASK_PROFILE_UNSUPPORTED", "the delivery profile must be one complete JSON block");
|
|
132
|
+
let value; try { value = JSON.parse(match[1]); } catch { throw profileError("TASK_PROFILE_UNSUPPORTED", "the delivery profile is invalid JSON"); }
|
|
133
|
+
const profile = validateDeliveryProfile(value);
|
|
134
|
+
if (stableProfile(profile) !== JSON.stringify(value)) throw profileError("TASK_PROFILE_UNSUPPORTED", "the delivery profile block is not in canonical form; regenerate the brief");
|
|
135
|
+
return profile;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const describeProfile = profile => `${profile.action} (${profile.completion.condition})`;
|
|
139
|
+
|
|
140
|
+
// ---- Deterministic delivery screen -----------------------------------------
|
|
141
|
+
//
|
|
142
|
+
// This is not a grammar and grants nothing. It finds delivery vocabulary in an
|
|
143
|
+
// immutable clause so the owner sees it in review. A hit can add a visible
|
|
144
|
+
// assumption or a clarification question; it can never add an external action.
|
|
145
|
+
// Share the vocabulary between initial detection and per-statement gate
|
|
146
|
+
// checks; a covered PR tag cannot hide an owner decision expressed differently.
|
|
147
|
+
const APPROVAL_TERM = /\b(?:approv(?:e|es|ed|ing|al|als)\b|sign[\s-]?off\b|consent(?:s|ed|ing)?\b|authori[sz](?:e|es|ed|ing|ation|ations)\b|permissions?\b|confirmations?\b|(?:check(?:ing)?|confirm(?:ing)?|clear(?:ing)?)\s+with\s+(?:me|us)\b|(?:ask(?:ing)?|consult(?:ing)?)\s+(?:me|us)\b|(?:(?:get|obtain|seek|await)(?:\s+(?:my|our|your|their|the|a))?|my|our|your|their|the|a)\s+go[\s-]?ahead\b)/iu;
|
|
148
|
+
// Registry removals, administration and index changes have the same unsupported external
|
|
149
|
+
// effect as publication. Local dependency removal is not a registry mutation.
|
|
150
|
+
// Share registry destinations between term detection, escalation and removal
|
|
151
|
+
// checks. Hosts may carry a scheme, port or artifact path; they are destinations
|
|
152
|
+
// only when a mutation verb points at them, never grants of publication access.
|
|
153
|
+
const REGISTRY_HOST = String.raw`(?:(?:https?|oci)://)?(?:ghcr\.io|(?:[\w-]+\.)?gcr\.io|quay\.io|(?:registry-1\.)?docker\.io|public\.ecr\.aws|[\w-]+\.dkr\.ecr(?:-fips)?\.[\w-]+\.amazonaws\.com(?:\.cn)?|[\w-]+\.dkr-ecr(?:-fips)?\.[\w-]+\.on\.aws|[\w-]+\.azurecr\.io|[\w-]+\.pkg\.dev|[\w-]+\.pkg\.github\.com|[\w-]+\.jfrog\.io|(?:[\w-]+\.)*(?:registry|packages|artifacts|artifactory|harbor|nexus)\.[\w-]+(?:\.[\w-]+)*)(?![\w-]|\.[\w-])`;
|
|
154
|
+
const REGISTRY_DESTINATION = String.raw`(?:${REGISTRY_HOST}|github\s+(?:packages|(?:container\s+)?registry)|gitlab\s+(?:(?:container|package)\s+)?registry|(?:google(?:\s+cloud)?\s+)?artifact\s+registry|(?:amazon|aws)\s+(?:ecr|(?:elastic\s+)?container\s+registry)|azure\s+(?:artifacts|(?:container\s+)?registry)|(?:jfrog\s+)?artifactory|npm|npmjs|pypi|rubygems(?:\.org)?|crates\.io|crates|maven(?:\s+central)?|nuget|packagist|docker\s+hub|homebrew|hex\.pm|cocoapods|(?:package\s+|artifact\s+|container\s+|image\s+)?registry)`;
|
|
155
|
+
const PUBLICATION_DESTINATION = String.raw`(?:${REGISTRY_DESTINATION}|(?:app|play|chrome\s+web|extension|snap)\s+store|marketplace|testflight)`;
|
|
156
|
+
const SUBMISSION_VERB = String.raw`(?:submit|upload|post|send|put|push|release)`;
|
|
157
|
+
const SUBMISSION_TARGET = String.raw`\s+(?:\S+\s+){0,4}?(?:to|onto|on|into)\s+(?:the\s+|a\s+|our\s+)?`;
|
|
158
|
+
const PUBLISH_START = new RegExp(String.raw`\b(?:publish(?:es|ed|ing)?|publication|${SUBMISSION_VERB}(?:s|ted|ed|ting|ing)?|gem|nuget|helm|docker|oras|twine)\b`, "iu");
|
|
159
|
+
const REGISTRY_REMOVAL_VERB = String.raw`(?:remov(?:e|es|ed|ing)|delet(?:e|es|ed|ing)|deprecat(?:e|es|ed|ing)|yank(?:s|ed|ing)?)`;
|
|
160
|
+
// The prefilter contains every possible first token of a registry effect.
|
|
161
|
+
const REGISTRY_START = new RegExp(String.raw`\b(?:unpublish|npm|cargo|gem|${REGISTRY_REMOVAL_VERB})\b`, "iu");
|
|
162
|
+
// Unique npm command prefixes, checked against npm's cmd-list deref resolver.
|
|
163
|
+
// Normalize token values only; immutable source text is never rewritten.
|
|
164
|
+
const NPM_EFFECT_PREFIXES = [
|
|
165
|
+
["publish", "pu"], ["unpublish", "unp"], ["deprecate", "dep"],
|
|
166
|
+
["access", "ac"], ["owner", "ow"], ["author", "aut", "owner"],
|
|
167
|
+
["dist-tag", "dist-tag"], ["dist-tags", "dist-tags", "dist-tag"], ["token", "to"],
|
|
168
|
+
["exec", "exe"], ["x", "x", "exec"],
|
|
169
|
+
["org", "or"],
|
|
170
|
+
];
|
|
171
|
+
const npmEffectCommand = value => {
|
|
172
|
+
const match = NPM_EFFECT_PREFIXES.find(([name, minimum]) => value.length >= minimum.length && name.startsWith(value));
|
|
173
|
+
return match ? match[2] || match[0] : value;
|
|
174
|
+
};
|
|
175
|
+
const NPM_REMOVAL_COMMANDS = NPM_EFFECT_PREFIXES.filter(([name]) => ["unpublish", "deprecate"].includes(name))
|
|
176
|
+
.flatMap(([name, minimum]) => Array.from({length:name.length - minimum.length + 1}, (_, i) => name.slice(0, minimum.length + i))).join("|");
|
|
177
|
+
const REGISTRY_MUTATION = new RegExp(String.raw`\bunpublish\b|\bnpm(?:\s+(?:--(?:registry|userconfig|prefix|loglevel)(?:=|\s+)[^\s;|&]+|--(?:force|silent|quiet|yes)|-[fsyq]))*\s+(?:${NPM_REMOVAL_COMMANDS})\b|\b(?:cargo|gem)\s+yank\b|\b${REGISTRY_REMOVAL_VERB}\s+(?:\S+\s+){0,6}?(?:from|on|in|at)\s+(?:the\s+|our\s+)?${REGISTRY_DESTINATION}\b`, "giu");
|
|
178
|
+
// Parse registry administration with the same bounded, quote-aware argument
|
|
179
|
+
// reader as PR creation. Option values and later commands cannot supply writes.
|
|
180
|
+
const NPM_COMMAND = /\bnpm\b/giu;
|
|
181
|
+
const NPM_VALUE_OPTIONS = new Set(["--registry", "--userconfig", "--globalconfig", "--prefix", "--loglevel", "--scope", "--otp", "--workspace", "-w", "--location", "--cache", "--access", "--tag"]);
|
|
182
|
+
const NPM_ADMIN_VALUE_OPTIONS = new Set([...NPM_VALUE_OPTIONS, "--cidr", "--name", "--token-description", "--expires", "--packages", "--scopes", "--orgs", "--packages-and-scopes-permission", "--orgs-permission", "--password"]);
|
|
183
|
+
const NPM_ADMIN_CONTINUATION = /^[ \t]*(?:--?[a-zA-Z]|(?:access|owner|dist-tag|token|or|org|create|revoke|list|ls|set|grant|add|rm|remove|delete|public|restricted|2fa-required|2fa-not-required)\b)/u;
|
|
184
|
+
const REGISTRY_ADMIN_WRITES = {
|
|
185
|
+
access: new Set(["set", "grant", "revoke", "public", "restricted", "2fa-required", "2fa-not-required"]),
|
|
186
|
+
owner: new Set(["add", "rm", "remove"]),
|
|
187
|
+
"dist-tag": new Set(["add", "rm", "remove"]),
|
|
188
|
+
token: new Set(["create", "revoke", "rm", "delete", "remove"]),
|
|
189
|
+
org: new Set(["set", "add", "rm"]),
|
|
190
|
+
};
|
|
191
|
+
// npm Boolean configuration accepts a separate true/false token. A later
|
|
192
|
+
// occurrence replaces an earlier value; unresolved values cannot prove help.
|
|
193
|
+
function npmBooleanArgument(args, index) {
|
|
194
|
+
const arg = args[index], equal = arg.value.indexOf("=");
|
|
195
|
+
let value = equal < 0 ? "true" : arg.value.slice(equal + 1), dynamic = arg.dynamic;
|
|
196
|
+
if (equal < 0 && /^(?:true|false)$/u.test(args[index + 1]?.value || "")) {
|
|
197
|
+
const next = args[++index]; value = next.value; dynamic ||= next.dynamic;
|
|
198
|
+
} else if (equal < 0 && args[index + 1]?.dynamic) dynamic = true;
|
|
199
|
+
return { index, known: !dynamic && /^(?:true|false)$/u.test(value), value: value === "true" };
|
|
200
|
+
}
|
|
201
|
+
function* commandReadings(source, text, pattern, options) {
|
|
202
|
+
const commands = [...text.matchAll(pattern)];
|
|
203
|
+
if (!commands.length) return;
|
|
204
|
+
const spans = [...source.matchAll(LITERAL_SPAN)];
|
|
205
|
+
// An independently introduced command cannot be consumed as argv of its
|
|
206
|
+
// predecessor, even when a URL/path legitimately retains a trailing dot.
|
|
207
|
+
let introductionSpan = 0;
|
|
208
|
+
const introduced = commands.flatMap(match => {
|
|
209
|
+
while (introductionSpan < spans.length && spans[introductionSpan].index + spans[introductionSpan][0].length <= match.index) introductionSpan++;
|
|
210
|
+
if (spans[introductionSpan]?.index <= match.index) return []; // executable operand, not a new invocation
|
|
211
|
+
const prefix = deliveryPrefix(text, match.index), invocation = prefix.match(COMMAND_SPAN_PREFIX);
|
|
212
|
+
if (!invocation) return [];
|
|
213
|
+
const start = match.index - prefix.length + invocation.index;
|
|
214
|
+
// --call/--run and path components are words inside the current argv.
|
|
215
|
+
return start && !/[\s;.!?:]/u.test(text[start - 1]) ? [] : [{ at: match.index, start }];
|
|
216
|
+
});
|
|
217
|
+
let consumed = 0, spanIndex = 0, introduction = 0;
|
|
218
|
+
for (const match of commands) {
|
|
219
|
+
if (match.index < consumed) continue;
|
|
220
|
+
const settings = typeof options === "function" ? options(match) : options;
|
|
221
|
+
const prefix = deliveryPrefix(text, match.index);
|
|
222
|
+
const feature = !settings?.includeFeatures && (featureDeliveryPrefix(prefix) || settings?.shellList && PROGRAM_DATA_PREFIX.test(prefix));
|
|
223
|
+
while (spanIndex < spans.length && spans[spanIndex].index + spans[spanIndex][0].length <= match.index) spanIndex++;
|
|
224
|
+
while (introduction < introduced.length && introduced[introduction].at <= match.index) introduction++;
|
|
225
|
+
const span = spans[spanIndex];
|
|
226
|
+
const end = Math.min(span && span.index <= match.index ? span.index + span[0].length - 1 : source.length,
|
|
227
|
+
settings?.shellList ? source.length : introduced[introduction]?.start ?? source.length);
|
|
228
|
+
// A documentation match must not consume later executable invocations.
|
|
229
|
+
const binary = (match.groups?.enclosure || "") + (match.groups?.binary || "");
|
|
230
|
+
const enclosed = Boolean(span && span.index <= match.index);
|
|
231
|
+
const reading = settings?.shellList ? { ...settings, shellList: !feature, enclosed,
|
|
232
|
+
initialDepth: Math.max(0, (binary.match(/[({]/gu)?.length || 0) - (binary.match(/[)}]/gu)?.length || 0)) } : enclosed ? { ...settings, enclosed: true } : settings;
|
|
233
|
+
const parsed = commandArguments(source, match.index + match[0].length, end, reading);
|
|
234
|
+
consumed = parsed.end;
|
|
235
|
+
if (feature) continue;
|
|
236
|
+
yield { match, args: parsed.args, end: parsed.end, expansions: parsed.expansions };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Publication namespaces share the existing quote-aware reader. A command's
|
|
240
|
+
// flags are not prose, and only a fully understood simulation/help mode can
|
|
241
|
+
// remove its delivery vocabulary. Unknown arity or dynamic/overridden modes
|
|
242
|
+
// retain the conflict. Wrapped commands can reveal writes, never prove a dry run.
|
|
243
|
+
const PUBLICATION_COMMAND = /\b(?:docker\s+(?:buildx\s+(?:imagetools\s+create|build|bake|b|f)|(?:image|manifest|compose)\s+push|compose\s+build|(?:builder|image)\s+build|build|push)|(?:npm|cargo)\s+publish|docker|npm|cargo)\b/giu;
|
|
244
|
+
const PUBLICATION_PRESENT = /\b(?:docker|npm|cargo)\b/iu;
|
|
245
|
+
const PUBLICATION_PATHS = {
|
|
246
|
+
npm: { publish: "npm" }, cargo: { publish: "cargo" },
|
|
247
|
+
docker: { push: "write", "image push": "write", "manifest push": "write", "compose push": "dry", "buildx imagetools create": "dry",
|
|
248
|
+
build: "build", "builder build": "build", "image build": "build", "buildx build": "build", "buildx b": "build", "buildx bake": "bake", "buildx f": "bake", "compose build": "build" },
|
|
249
|
+
};
|
|
250
|
+
const PUBLICATION_VALUES = {
|
|
251
|
+
npm: new Set([...NPM_VALUE_OPTIONS, "--provenance-file", "--cafile", "--cert", "--key", "--logs-dir", "-w"]),
|
|
252
|
+
cargo: new Set(["--registry", "--index", "--token", "--manifest-path", "--target", "--target-dir", "--package", "--exclude", "--features", "--jobs", "--config", "--color", "--message-format", "--lockfile-path", "-p", "-F", "-j", "-Z", "-C"]),
|
|
253
|
+
docker: new Set(["--set", "--var", "--allow", "--jobs", "-j", "--memory", "-m", "--config", "--context", "--host", "--log-level", "--builder", "--file", "--project-name", "--project-directory", "--env-file", "--profile", "--parallel", "--progress", "--ansi", "--tag", "--label", "--annotation", "--metadata-file", "--platform", "--build-arg", "--build-context", "--target", "--secret", "--ssh", "--network", "--cache-from", "--cache-to", "--output", "--attest", "--provenance", "--sbom", "--call", "-c", "-H", "-l", "-f", "-p", "-t", "-o"]),
|
|
254
|
+
};
|
|
255
|
+
const PUBLICATION_SWITCHES = {
|
|
256
|
+
npm: new Set(["--json", "--ignore-scripts", "--foreground-scripts", "--force", "--silent", "--quiet", "--workspaces", "--include-workspace-root", "--provenance", "-f", "-s", "-q"]),
|
|
257
|
+
cargo: new Set(["--no-verify", "--allow-dirty", "--workspace", "--all", "--all-features", "--no-default-features", "--locked", "--offline", "--frozen", "--keep-going", "--verbose", "--quiet", "-v", "-vv", "-q"]),
|
|
258
|
+
docker: new Set(["--all-tags", "--quiet", "--insecure", "--purge", "--include-deps", "--ignore-push-failures", "--append", "--prefer-index", "--load", "--pull", "--no-cache", "--check", "--debug", "-a", "-q", "-D"]),
|
|
259
|
+
};
|
|
260
|
+
function publicationCommands(source, text) {
|
|
261
|
+
if (!PUBLICATION_PRESENT.test(text)) return { text, writes: [] };
|
|
262
|
+
const parts = [], writes = []; let last = 0;
|
|
263
|
+
for (const { match, args: tail, end } of commandReadings(source, text, PUBLICATION_COMMAND, match => ({ newlines: true, includeFeatures: true, valueOptions: PUBLICATION_VALUES[match[0].toLowerCase().split(/\s+/u)[0]] }))) {
|
|
264
|
+
const [tool, ...prefix] = match[0].toLowerCase().split(/\s+/u), paths = PUBLICATION_PATHS[tool], path = [];
|
|
265
|
+
const args = [...prefix.map(value => ({ value, dynamic: false })), ...tail];
|
|
266
|
+
let kind = null, unknown = false, dry = false, drySeen = false, help = false, print = false, push = false, output = false, positional = 0, i = 0;
|
|
267
|
+
const boolean = (arg, equal) => {
|
|
268
|
+
// npm accepts a separate Boolean configuration value; Cobra-style
|
|
269
|
+
// Docker flags do not use that npm-specific consumption rule.
|
|
270
|
+
if (tool === "npm") {
|
|
271
|
+
const parsed = npmBooleanArgument(args, i); i = parsed.index;
|
|
272
|
+
if (!parsed.known) { unknown = true; return null; }
|
|
273
|
+
return parsed.value;
|
|
274
|
+
}
|
|
275
|
+
if (arg.dynamic || (equal >= 0 && !/^(?:true|false)$/u.test(arg.value.slice(equal + 1)))) { unknown = true; return null; }
|
|
276
|
+
return equal < 0 || arg.value.slice(equal + 1) === "true";
|
|
277
|
+
};
|
|
278
|
+
for (; i < args.length; i++) {
|
|
279
|
+
const arg = args[i], equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
280
|
+
if (arg.value === "--") {
|
|
281
|
+
// npm selects its command from remaining positional argv too. Flags
|
|
282
|
+
// after this boundary cannot establish a proven simulation here.
|
|
283
|
+
if (tool === "npm" && !kind && npmEffectCommand(args[i + 1]?.value || "") === "publish") { kind = "npm"; unknown = true; }
|
|
284
|
+
positional += args.length - i - 1;
|
|
285
|
+
if (args.slice(i + 1).some(value => value.dynamic)) unknown = true;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (option === "--help" || option === "-h") { if (tool === "cargo" && equal >= 0) unknown = true; help = boolean(arg, equal) === true; continue; }
|
|
289
|
+
if (option === "--dry-run") {
|
|
290
|
+
if (tool === "cargo" && (equal >= 0 || drySeen)) unknown = true;
|
|
291
|
+
drySeen = true;
|
|
292
|
+
dry = boolean(arg, equal) === true; continue;
|
|
293
|
+
}
|
|
294
|
+
if (tool === "npm" && option === "--no-dry-run") { dry = false; if (equal >= 0 || arg.dynamic) unknown = true; continue; }
|
|
295
|
+
if (tool === "docker" && option === "--push") { push = boolean(arg, equal) !== false; continue; }
|
|
296
|
+
if (tool === "docker" && option === "--print") { print = boolean(arg, equal) === true; continue; }
|
|
297
|
+
let valueOption = option, inline = equal >= 0, attached = false;
|
|
298
|
+
if (!PUBLICATION_VALUES[tool].has(valueOption) && /^-[^-].+/u.test(arg.value) && PUBLICATION_VALUES[tool].has(arg.value.slice(0, 2))) { valueOption = arg.value.slice(0, 2); inline = true; attached = true; }
|
|
299
|
+
if (PUBLICATION_VALUES[tool].has(valueOption)) {
|
|
300
|
+
const value = inline ? { value: arg.value.slice(attached ? 2 : equal + 1), dynamic: arg.dynamic } : args[++i];
|
|
301
|
+
if (!value?.value || value.dynamic || value.value.startsWith("-")) unknown = true;
|
|
302
|
+
if (tool === "docker" && ["--output", "-o", "--cache-to"].includes(valueOption)) {
|
|
303
|
+
// Registry/image exporters publish just like --push. Local and inline
|
|
304
|
+
// cache exports do not; unresolved exporter values cannot prove that.
|
|
305
|
+
const spec = value?.value || "";
|
|
306
|
+
if (value?.dynamic || /(?:^|,)type=registry(?:,|$)|(?:^|,)push=(?!false(?:,|$))/u.test(spec)
|
|
307
|
+
|| (valueOption === "--cache-to" && !/^(?:type=(?:local|inline))(?:,|$)/u.test(spec))) output = true;
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (arg.value.startsWith("-")) { if (!PUBLICATION_SWITCHES[tool].has(option) || arg.dynamic || equal >= 0) unknown = true; continue; }
|
|
312
|
+
if (!kind) {
|
|
313
|
+
path.push(tool === "npm" ? npmEffectCommand(arg.value) : arg.value); const name = path.join(" ");
|
|
314
|
+
if (arg.dynamic || (!Object.hasOwn(paths, name) && !Object.keys(paths).some(candidate => candidate.startsWith(name + " ")))) break;
|
|
315
|
+
kind = paths[name] || null;
|
|
316
|
+
} else { positional++; if (arg.dynamic) unknown = true; }
|
|
317
|
+
}
|
|
318
|
+
if (!kind) {
|
|
319
|
+
if (tool === "docker" && path.length && Object.keys(paths).some(candidate => candidate.startsWith(path.join(" ") + " ")) && /[\r\n]/u.test(source[end] || "")) writes.push({ start: match.index, end });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if ((kind === "npm" && positional > 1) || (kind === "cargo" && positional)) unknown = true;
|
|
323
|
+
const simulation = dry && (["npm", "cargo", "dry"].includes(kind) || path[0] === "compose");
|
|
324
|
+
const feature = featureDeliveryPrefix(deliveryPrefix(text, match.index));
|
|
325
|
+
// Bake can publish via its file even without --push; only proven
|
|
326
|
+
// print-only evaluation bypasses publication clarification here.
|
|
327
|
+
const printOnly = print && (kind === "bake" || path.join(" ") === "compose build");
|
|
328
|
+
const safe = feature || (!unknown && !/[\r\n]/u.test(source.slice(match.index, end)) && (help || simulation || printOnly));
|
|
329
|
+
if (!safe && (kind !== "build" || push || output || (unknown && args.some(arg => arg.dynamic)))) writes.push({ start: match.index, end });
|
|
330
|
+
else if (safe || kind === "build") {
|
|
331
|
+
parts.push(text.slice(last, match.index), " ".repeat(end - match.index)); last = end;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { text: parts.join("") + text.slice(last), writes };
|
|
335
|
+
}
|
|
336
|
+
function registryAdministration(source, text = source) {
|
|
337
|
+
for (const { args } of commandReadings(source, text, NPM_COMMAND, { newlines: NPM_ADMIN_CONTINUATION, valueOptions: NPM_ADMIN_VALUE_OPTIONS })) {
|
|
338
|
+
let family = null, action = null, help = false, unknown = false, literal = false;
|
|
339
|
+
for (let i = 0; i < args.length; i++) {
|
|
340
|
+
const arg = args[i], option = arg.value.split("=", 1)[0];
|
|
341
|
+
if (!literal && arg.value === "--") { literal = true; continue; }
|
|
342
|
+
if (!literal && ["--help", "-h", "--no-help"].includes(option)) {
|
|
343
|
+
const parsed = npmBooleanArgument(args, i); i = parsed.index;
|
|
344
|
+
unknown ||= !parsed.known;
|
|
345
|
+
help = parsed.known && (option === "--no-help" ? !parsed.value : parsed.value);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!literal && NPM_ADMIN_VALUE_OPTIONS.has(option)) { if (!arg.value.includes("=")) i++; continue; }
|
|
349
|
+
if (!literal && arg.value.startsWith("-")) {
|
|
350
|
+
// Unknown option arity cannot establish help. Consume only npm's
|
|
351
|
+
// documented Boolean literals, never a possible command operand.
|
|
352
|
+
unknown = true;
|
|
353
|
+
if (!arg.value.includes("=") && /^(?:true|false)$/u.test(args[i + 1]?.value || "")) i++;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (!family) { const name = npmEffectCommand(arg.value); if (!Object.hasOwn(REGISTRY_ADMIN_WRITES, name) && !["unpublish", "deprecate"].includes(name)) break; family = name; }
|
|
357
|
+
else if (!action) action = arg;
|
|
358
|
+
}
|
|
359
|
+
if ((!help || unknown) && (["unpublish", "deprecate"].includes(family) || action && (action.dynamic || REGISTRY_ADMIN_WRITES[family].has(action.value)))) return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
function registryMutation(text, source = text) {
|
|
364
|
+
if (!REGISTRY_START.test(text)) return false;
|
|
365
|
+
if (registryAdministration(source, text)) return true;
|
|
366
|
+
for (const match of text.matchAll(REGISTRY_MUTATION)) if (!featureDeliveryPrefix(deliveryPrefix(text, match.index))) return true;
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
// Object-storage writes are not task-branch delivery. Read operands with the
|
|
370
|
+
// shared command parser: remote sources alone mean downloads, while a move
|
|
371
|
+
// also deletes its remote source. Unknown option arity/dynamic operands cannot
|
|
372
|
+
// prove a download or simulation. No command is executed during screening.
|
|
373
|
+
const STORAGE_DESTINATION = String.raw`(?:s3\b|(?:amazon|aws)\s+s3\b|google\s+cloud\s+storage\b|gcs\b|azure\s+blob\s+storage\b|object[\s-]+storage\b|(?:s3|gs|az)://)`;
|
|
374
|
+
const STORAGE_TRANSFER = new RegExp(String.raw`\b(?:upload|send|push|copy|sync)(?:s|ed|ing)?${SUBMISSION_TARGET}${STORAGE_DESTINATION}`, "giu");
|
|
375
|
+
const STORAGE_COMMAND = /\b(?:aws|gsutil)\b/giu;
|
|
376
|
+
const STORAGE_REMOTE = /^(?:s3|gs|az):\/\//iu;
|
|
377
|
+
const STORAGE_VALUE_OPTIONS = new Set(["--profile", "--region", "--endpoint-url", "--output", "--query", "--ca-bundle", "--cli-read-timeout", "--cli-connect-timeout", "--include", "--exclude", "--acl", "--sse", "--sse-c", "--sse-c-key", "--sse-kms-key-id", "--sse-c-copy-source", "--sse-c-copy-source-key", "--storage-class", "--website-redirect", "--content-type", "--cache-control", "--content-disposition", "--content-encoding", "--content-language", "--expires", "--source-region", "--request-payer", "--checksum-mode", "--checksum-algorithm", "--metadata", "--copy-props", "--metadata-directive", "--expected-size", "--page-size", "--progress-frequency", "--case-conflict"]);
|
|
378
|
+
const STORAGE_SWITCH_OPTIONS = new Set(["--quiet", "--recursive", "--delete", "--debug", "--no-verify-ssl", "--no-sign-request", "--no-paginate", "--no-cli-pager", "--follow-symlinks", "--no-follow-symlinks", "--no-guess-mime-type", "--only-show-errors", "--no-progress", "--progress-multiline", "--ignore-glacier-warnings", "--force-glacier-transfer", "--size-only", "--exact-timestamps", "--no-overwrite"]);
|
|
379
|
+
function objectStorageMutation(source, text = source) {
|
|
380
|
+
for (const match of text.matchAll(STORAGE_TRANSFER)) {
|
|
381
|
+
const before = deliveryPrefix(text, match.index), after = text.slice(match.index + match[0].length);
|
|
382
|
+
if (!PUBLISH_MODIFIER_HEAD.test(before) && !featureDeliveryPrefix(before) && !PUBLISH_MODIFIER_TAIL.test(after)) return true;
|
|
383
|
+
}
|
|
384
|
+
for (const { match, args } of commandReadings(source, text, STORAGE_COMMAND, { valueOptions: STORAGE_VALUE_OPTIONS })) {
|
|
385
|
+
const aws = match[0].toLowerCase() === "aws", paths = [];
|
|
386
|
+
let namespace = !aws, action = null, unknown = false, simulation = false, help = false;
|
|
387
|
+
for (let i = 0; i < args.length; i++) {
|
|
388
|
+
const arg = args[i], equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
389
|
+
if (arg.value === "--help" || (aws && arg.value === "help")) { help = true; break; }
|
|
390
|
+
if (aws && STORAGE_VALUE_OPTIONS.has(option)) {
|
|
391
|
+
const value = equal < 0 ? args[++i] : arg;
|
|
392
|
+
if (!value || value.dynamic || value.value.startsWith("-")) unknown = true;
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!aws && (option === "-o" || option === "-h" || (action === "rsync" && ["-a", "-x", "-j"].includes(option)) || (action === "cp" && ["-a", "-L", "-j", "-s"].includes(option)))) {
|
|
396
|
+
const value = args[++i]; if (!value || value.dynamic || value.value.startsWith("-")) unknown = true;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (aws && option === "--dryrun") { simulation = !arg.dynamic && (equal < 0 || /^(?:true|1)$/iu.test(arg.value.slice(equal + 1))); if (!simulation) unknown = true; continue; }
|
|
400
|
+
if (!aws && action === "rsync" && arg.value === "-n") { simulation = true; continue; }
|
|
401
|
+
if (arg.value.startsWith("-") && arg.value !== "-") {
|
|
402
|
+
if (arg.dynamic || (aws ? !STORAGE_SWITCH_OPTIONS.has(arg.value) : !/^-[mqrRnNcCdDeJpPuvzZ]+$/u.test(arg.value))) unknown = true;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!namespace) { if (arg.value !== "s3" || arg.dynamic) break; namespace = true; }
|
|
406
|
+
else if (!action) { if (!["cp", "sync", "rsync", "mv"].includes(arg.value) || arg.dynamic) break; action = arg.value; }
|
|
407
|
+
else paths.push(arg);
|
|
408
|
+
}
|
|
409
|
+
if (!action || ((help || simulation) && !unknown)) continue;
|
|
410
|
+
const destination = paths.at(-1);
|
|
411
|
+
if (unknown || paths.length < 2 || (aws && paths.length !== 2) || paths.some(path => path.dynamic) || STORAGE_REMOTE.test(destination.value) || (action === "mv" && paths.slice(0, -1).some(path => path.dynamic || STORAGE_REMOTE.test(path.value)))) return true;
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
// Messages and HTTP writes are external effects, never PR delivery authority.
|
|
416
|
+
// Use the same feature/literal boundaries and argument reader as other effects.
|
|
417
|
+
const COMMUNICATION_DESTINATION = String.raw`(?:(?:slack|(?:microsoft\s+)?teams|discord|telegram|email|e-mail|webhooks?)\b|https?://)`;
|
|
418
|
+
const COMMUNICATION_ACTION = new RegExp(String.raw`\b(?:send|sending|post|posting|deliver|delivering)\s+(?:(?:a|an|the|this|that|our|my)\s+){0,2}(?:e-?mails?|messages?|notifications?)\b|\b(?:send|sending|post|posting|share|sharing|report|reporting|upload|uploading)\s+(?:\S+\s+){0,6}?(?:to|on|in|via|through)\s+(?:(?:a|the|our|my)\s+)?${COMMUNICATION_DESTINATION}|\b(?:e-?mail|notify|message|text)\s+(?:(?:the|our|my|all)\s+)?(?:me|us|them|team|owner|users?|customers?)\b|\bsendmail\b`, "giu");
|
|
419
|
+
const COMMUNICATION_START = /\b(?:send|sending|post|posting|deliver|delivering|share|sharing|report|reporting|upload|uploading|e-?mail|notify|message|text|sendmail)\b/iu;
|
|
420
|
+
const LOCAL_MESSAGE_TARGET = /^\s+(?:to|on|in)\s+(?:the\s+)?(?:stdout|stderr|console|local\s+(?:logs?|files?))\b/iu;
|
|
421
|
+
function outboundCommunication(text) {
|
|
422
|
+
if (!COMMUNICATION_START.test(text)) return false;
|
|
423
|
+
for (const match of text.matchAll(COMMUNICATION_ACTION)) {
|
|
424
|
+
if (featureDeliveryPrefix(deliveryPrefix(text, match.index))) continue;
|
|
425
|
+
if (LOCAL_MESSAGE_TARGET.test(text.slice(match.index + match[0].length))) continue;
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
const CURL_COMMAND = /\bcurl\b/giu;
|
|
431
|
+
const READ_ONLY_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
|
|
432
|
+
const CURL_BODY_OPTIONS = new Set(["--data", "--data-ascii", "--data-binary", "--data-raw", "--data-urlencode", "--json", "--form", "--form-string"]);
|
|
433
|
+
const CURL_VALUE_OPTIONS = new Set([...CURL_BODY_OPTIONS, "--request", "--upload-file", "--config", "--header", "--proxy-header", "--url", "--user", "--proxy-user", "--proxy", "--output", "--write-out", "--user-agent", "--referer", "--cookie", "--cookie-jar", "--connect-timeout", "--max-time", "--retry", "--cacert", "--cert", "--key", "--resolve", "--connect-to", "--request-target", "--url-query"]);
|
|
434
|
+
const CURL_READ_SWITCHES = new Set(["--silent", "--show-error", "--fail", "--fail-with-body", "--location", "--verbose", "--insecure", "--no-buffer", "--disable", "--compressed", "--globoff", "--remote-name", "--remote-header-name", "--http1.0", "--http1.1", "--http2", "--http2-prior-knowledge", "--ipv4", "--ipv6", "--no-progress-meter"]);
|
|
435
|
+
const CURL_SHORT_OPTIONS = { s: "--silent", S: "--show-error", f: "--fail", L: "--location", v: "--verbose", k: "--insecure", N: "--no-buffer", q: "--disable", g: "--globoff", O: "--remote-name", J: "--remote-header-name", "4": "--ipv4", "6": "--ipv6", X: "--request", d: "--data", F: "--form", T: "--upload-file", K: "--config", H: "--header", u: "--user", U: "--proxy-user", x: "--proxy", o: "--output", w: "--write-out", A: "--user-agent", e: "--referer", b: "--cookie", c: "--cookie-jar", m: "--max-time", G: "--get", I: "--head", h: "--help", V: "--version", ":": "--next" };
|
|
436
|
+
function curlMutation(source, text = source) {
|
|
437
|
+
for (const { args } of commandReadings(source, text, CURL_COMMAND, { valueOptions: CURL_VALUE_OPTIONS, shortOptions: CURL_SHORT_OPTIONS })) {
|
|
438
|
+
let method = null, data = false, upload = false, get = false, head = false, unknown = false, mutation = false, help = false;
|
|
439
|
+
const writes = () => unknown || upload || (method ? !READ_ONLY_HTTP_METHODS.has(method) : data && !get && !head);
|
|
440
|
+
const read = (option, arg) => {
|
|
441
|
+
if (option === "--next") { mutation ||= writes(); method = null; data = upload = get = head = unknown = false; }
|
|
442
|
+
else if (option === "--help" || option === "--version") help = true;
|
|
443
|
+
else if (option === "--request") method = !arg?.value || arg.dynamic ? "UNKNOWN" : arg.value.toUpperCase();
|
|
444
|
+
else if (CURL_BODY_OPTIONS.has(option)) data = true;
|
|
445
|
+
else if (option === "--upload-file") upload = true;
|
|
446
|
+
else if (option === "--config") unknown = true;
|
|
447
|
+
else if (option === "--get") get = true;
|
|
448
|
+
else if (option === "--no-get") get = false;
|
|
449
|
+
else if (option === "--head") head = true;
|
|
450
|
+
else if (option === "--no-head") head = false;
|
|
451
|
+
else if (!CURL_VALUE_OPTIONS.has(option) && !CURL_READ_SWITCHES.has(option)) unknown = true;
|
|
452
|
+
};
|
|
453
|
+
for (let i = 0; i < args.length; i++) {
|
|
454
|
+
const arg = args[i];
|
|
455
|
+
if (arg.value === "--") break;
|
|
456
|
+
if (arg.value.startsWith("--")) {
|
|
457
|
+
const equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
458
|
+
read(option, CURL_VALUE_OPTIONS.has(option) ? (equal < 0 ? args[++i] : { value: arg.value.slice(equal + 1), dynamic: arg.dynamic }) : null);
|
|
459
|
+
} else if (arg.value.startsWith("-")) {
|
|
460
|
+
for (let j = 1; j < arg.value.length; j++) {
|
|
461
|
+
const option = CURL_SHORT_OPTIONS[arg.value[j]];
|
|
462
|
+
if (CURL_VALUE_OPTIONS.has(option)) { read(option, j + 1 < arg.value.length ? { value: arg.value.slice(j + 1), dynamic: arg.dynamic } : args[++i]); break; }
|
|
463
|
+
read(option, null);
|
|
464
|
+
}
|
|
465
|
+
} else if (arg.dynamic) unknown = true;
|
|
466
|
+
}
|
|
467
|
+
if ((mutation || writes()) && (!help || unknown)) return true;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
// Authenticated API calls share the HTTP restriction, not PR creation
|
|
472
|
+
// authority. Field/input flags select POST unless a method is explicit. Keep
|
|
473
|
+
// option values atomic and never execute requests while screening a clause.
|
|
474
|
+
const GH_API_COMMAND = /\bgh\s+api\b/giu;
|
|
475
|
+
const GH_API_BODY_OPTIONS = new Set(["--field", "--raw-field", "--input"]);
|
|
476
|
+
const GH_API_VALUE_OPTIONS = new Set([...GH_API_BODY_OPTIONS, "--method", "--header", "--hostname", "--cache", "--jq", "--template", "--preview"]);
|
|
477
|
+
const GH_API_SWITCH_OPTIONS = new Set(["--include", "--paginate", "--slurp", "--silent", "--verbose", "--allow-escape-sequences"]);
|
|
478
|
+
const GH_API_SHORT_OPTIONS = { X: "--method", f: "--raw-field", F: "--field", H: "--header", p: "--preview", q: "--jq", t: "--template", i: "--include", h: "--help" };
|
|
479
|
+
function githubApiMutation(source, text = source) {
|
|
480
|
+
for (const { args } of commandReadings(source, text, GH_API_COMMAND, { valueOptions: GH_API_VALUE_OPTIONS, shortOptions: GH_API_SHORT_OPTIONS })) {
|
|
481
|
+
let method = null, body = false, unknown = false, help = false;
|
|
482
|
+
const read = (option, value) => {
|
|
483
|
+
if (option === "--help") { help = !value || (!value.dynamic && /^(?:true|1|t)$/iu.test(value.value)); return; }
|
|
484
|
+
if (GH_API_VALUE_OPTIONS.has(option) && (!value?.value || (value.value.startsWith("-") && !(option === "--input" && value.value === "-")))) unknown = true;
|
|
485
|
+
if (option === "--method") method = !value?.value || value.dynamic ? "UNKNOWN" : value.value.toUpperCase();
|
|
486
|
+
else if (GH_API_BODY_OPTIONS.has(option)) body = true;
|
|
487
|
+
};
|
|
488
|
+
for (let i = 0; i < args.length; i++) {
|
|
489
|
+
const arg = args[i];
|
|
490
|
+
if (arg.value === "--") break;
|
|
491
|
+
if (arg.value.startsWith("--")) {
|
|
492
|
+
const equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
493
|
+
const value = equal < 0 ? null : { value: arg.value.slice(equal + 1), dynamic: arg.dynamic };
|
|
494
|
+
if (GH_API_VALUE_OPTIONS.has(option)) read(option, equal < 0 ? args[++i] : value);
|
|
495
|
+
else if (option === "--help") read(option, value);
|
|
496
|
+
else if (!GH_API_SWITCH_OPTIONS.has(option) || arg.dynamic) unknown = true;
|
|
497
|
+
} else if (arg.value.startsWith("-")) {
|
|
498
|
+
for (let j = 1; j < arg.value.length; j++) {
|
|
499
|
+
const option = GH_API_SHORT_OPTIONS[arg.value[j]];
|
|
500
|
+
if (!option) { unknown = true; break; }
|
|
501
|
+
if (GH_API_VALUE_OPTIONS.has(option)) { read(option, j + 1 < arg.value.length ? { value: arg.value.slice(j + 1).replace(/^=/u, ""), dynamic: arg.dynamic } : args[++i]); break; }
|
|
502
|
+
read(option, null);
|
|
503
|
+
}
|
|
504
|
+
} else if (arg.dynamic) unknown = true;
|
|
505
|
+
}
|
|
506
|
+
if (!(help && !unknown) && (unknown || (method ? !READ_ONLY_HTTP_METHODS.has(method) : body))) return true;
|
|
507
|
+
}
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
const TERMS = [
|
|
511
|
+
["pull_request", /\bpull[\s-]+requests?\b|\bprs?\b/iu],
|
|
512
|
+
// Existing-PR mutations are not the profile's creation of one review PR.
|
|
513
|
+
["pull_request_lifecycle", null],
|
|
514
|
+
// Issue writes are outside task-branch and review-PR delivery authority.
|
|
515
|
+
["issue_mutation", null],
|
|
516
|
+
["workflow_mutation", null],
|
|
517
|
+
["label_mutation", null],
|
|
518
|
+
["project_mutation", null],
|
|
519
|
+
["codespace_mutation", null],
|
|
520
|
+
["gist_mutation", null],
|
|
521
|
+
["discussion_mutation", null],
|
|
522
|
+
["account_key_mutation", null],
|
|
523
|
+
["authentication_mutation", null],
|
|
524
|
+
["configuration_mutation", null],
|
|
525
|
+
["repository_mutation", null],
|
|
526
|
+
["external_communication", null],
|
|
527
|
+
["http_mutation", null],
|
|
528
|
+
["storage_mutation", null],
|
|
529
|
+
["merge", /\bmerg(?:e|es|ed|ing)\b/iu],
|
|
530
|
+
// Shipping, rollout, go-live, cut-over and promotion wording, and any
|
|
531
|
+
// "to production" destination, are deployment instructions too.
|
|
532
|
+
["deploy", /\b(?:deploy(?:s|ed|ing|ment|ments)?\b|rollouts?\b|roll(?:s|ed|ing)?[\s-]+(?:(?:it|this|that|them|the\s+\w+)\s+)?out\b|go(?:es|ing)?[\s-]+live\b|cut[\s-]*over\b|ship(?:s|ped|ping)?\s+(?:it|this|that|them|the\s+\w+)\b|promot(?:e|es|ed|ing)\s+\S+(?:\s+\S+)?\s+to\s+(?:production|prod|staging|live)\b|(?:to|into)\s+(?:production|prod)\b)/iu],
|
|
533
|
+
// Product release names/metadata are not requests to mutate a release.
|
|
534
|
+
["release", null],
|
|
535
|
+
// Submitting, uploading or pushing a package or build to a registry or
|
|
536
|
+
// store, and registry tool commands, are publication wording too.
|
|
537
|
+
["publish", new RegExp(String.raw`\bpublish(?:es|ed|ing)?\b|\bpublication\b|\b${SUBMISSION_VERB}(?:s|ted|ed|ting|ing)?${SUBMISSION_TARGET}${PUBLICATION_DESTINATION}\b|\b(?:gem|nuget|helm|docker|oras)\s+push\b|\btwine\s+upload\b`, "iu")],
|
|
538
|
+
["push", /\bpush(?:es|ed|ing)?\b/iu],
|
|
539
|
+
// A write aimed at the base branch itself, or at skipping the task branch
|
|
540
|
+
// and pull request, is a delivery the profile never performs.
|
|
541
|
+
// An explicitly direct or straight write names any branch ("push directly to
|
|
542
|
+
// staging"); the selected base itself is matched by name through the
|
|
543
|
+
// screen's base option, since a profile's base is arbitrary.
|
|
544
|
+
["direct_push", /\b(?:push|commit|land|write)(?:es|ed|ing|s)?\s+(?:\S+\s+){0,4}?(?:directly\s+|straight\s+)?(?:to|into|on|onto)\s+(?:the\s+)?(?:main|master|trunk|develop(?:ment)?|base(?:\s+branch)?|default\s+branch|release\s+branch|production\s+branch)\b|\b(?:push|commit|land|write)(?:es|ed|ing|s)?\s+(?:\S+\s+){0,4}?(?:directly|straight)\s+(?:to|into|on|onto)\s+(?:the\s+)?[\p{L}\p{N}_./-]+|\bdirectly\s+(?:to|into|on|onto)\s+(?:the\s+)?(?:main|master|trunk|develop|base(?:\s+branch)?|default\s+branch|[\p{L}\p{N}_./-]+\s+branch)\b|\bwithout\s+(?:a\s+|any\s+)?(?:pull\s+request|pr|task\s+branch|feature\s+branch|branch)\b/iu],
|
|
545
|
+
["commit", /\bcommit(?:s|ted|ting)?\b/iu],
|
|
546
|
+
["branch", /\bbranch(?:es)?\b/iu],
|
|
547
|
+
["approval", APPROVAL_TERM],
|
|
548
|
+
["review", /\breview(?:s|ed|ing|er|ers)?\b/iu],
|
|
549
|
+
["wait", /\bwait(?:s|ed|ing)?\b|\bhold\s+(?:off|it|this|the)\b/iu],
|
|
550
|
+
["local", /\b(?:local-only\b|keep\s+(?:it|this|that|the\s+changes?|changes)\s+local\b|patch\s+only\b|diff\s+only\b|do\s+not\s+push\b|don['’]t\s+push\b|un(?:committed|staged|pushed|published|merged|deployed|released|submitted)\b|(?:in|within)\s+(?:the\s+|my\s+|your\s+)?(?:working\s+(?:tree|copy|directory)|worktree|checkout)\s+only\b|(?:keep|leave)\s+(?:\S+\s+){0,4}?(?:in|within)\s+(?:the\s+|my\s+|your\s+)?(?:working\s+(?:tree|copy|directory)|worktree|checkout)\b|working[\s-]tree[\s-]only\b|do\s+not\s+(?:stage|commit)\b|don['’]t\s+(?:stage|commit)\b)/iu],
|
|
551
|
+
];
|
|
552
|
+
// The selected base is matched by name: with base "staging", "push the
|
|
553
|
+
// changes to staging" or "land it on the staging branch" is a direct write to
|
|
554
|
+
// the base. The name is matched whole, so "staging2" is not "staging".
|
|
555
|
+
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
556
|
+
const baseWritePattern = base => {
|
|
557
|
+
const selected = typeof base === "string" && base.trim();
|
|
558
|
+
const name = selected ? escapeRegExp(base.trim()) : "(?:main|master|trunk|develop(?:ment)?)";
|
|
559
|
+
// Imperative code can use Git refspec syntax instead of "push to main".
|
|
560
|
+
const gitPush = String.raw`\bgit\s+push\s+(?:--?[^\s]+\s+)*(?:[^\s]+\s+)?(?:--?[^\s]+\s+)*(?:[^\s:]+:)?["']?(?:refs/heads/)?${name}["']?(?![\p{L}\p{N}_-]|[./][\p{L}\p{N}])`;
|
|
561
|
+
if (!selected) return new RegExp(gitPush, "iu");
|
|
562
|
+
return new RegExp(String.raw`\b(?:push|commit|land|write)(?:es|ed|ing|s)?\s+(?:\S+\s+){0,4}?(?:directly\s+|straight\s+)?(?:to|into|on|onto)\s+(?:the\s+)?${name}(?:\s+branch)?(?![\p{L}\p{N}_-]|[./][\p{L}\p{N}])|\bdirectly\s+(?:to|into|on|onto)\s+(?:the\s+)?${name}(?:\s+branch)?(?![\p{L}\p{N}_-]|[./][\p{L}\p{N}])|${gitPush}`, "iu");
|
|
563
|
+
};
|
|
564
|
+
// Every base-write alternative starts with one of these roots; the prefix
|
|
565
|
+
// guard is deliberately broader than its grammar, including inflected words.
|
|
566
|
+
// It avoids full ref-write matching for large requests containing only PRs.
|
|
567
|
+
const BASE_WRITE_PRESENT = /\b(?:push|commit|land|write|directly|without)/iu;
|
|
568
|
+
const termMatches = (term, pattern, text, baseWrite, source = text, base, registryMutates = registryMutation) => (term !== "direct_push" || BASE_WRITE_PRESENT.test(text)) && ((term !== "publish" || PUBLISH_START.test(text)) && pattern?.test(text) || (term === "pull_request_lifecycle" && pullRequestLifecycle(text, source)) || (term === "issue_mutation" && issueMutation(text)) || (term === "workflow_mutation" && workflowMutation(text)) || (term === "label_mutation" && labelMutation(source, text)) || (term === "project_mutation" && projectMutation(source, text)) || (term === "codespace_mutation" && codespaceMutation(source, text)) || (term === "gist_mutation" && gistMutation(source, text)) || (term === "discussion_mutation" && discussionMutation(source, text)) || (term === "account_key_mutation" && accountKeyMutation(source, text)) || (term === "authentication_mutation" && authenticationMutation(source, text)) || (term === "configuration_mutation" && configurationMutation(source, text)) || (term === "repository_mutation" && repositoryMutation(source, text)) || (term === "external_communication" && outboundCommunication(text)) || (term === "http_mutation" && (curlMutation(source, text) || githubApiMutation(source, text))) || (term === "storage_mutation" && objectStorageMutation(source, text)) || (term === "merge" && landingInstruction(text)) || (term === "deploy" && infrastructureMutation(text, source)) || (term === "release" && releaseInstruction(text, source)) || (term === "publish" && registryMutates(text, source)) || (term === "direct_push" && ((baseWrite !== null && baseWrite.test(text)) || nonTaskRefPush(text, base, source))) || (term === "local" && stopAfterCommit(text)));
|
|
569
|
+
// Publication and approval escalate per statement (see publishInstruction and
|
|
570
|
+
// approvalInstruction) so product nouns such as "a publish button" or "an
|
|
571
|
+
// approval workflow" stay assumptions.
|
|
572
|
+
const ESCALATING = new Set(["pull_request_lifecycle", "issue_mutation", "workflow_mutation", "label_mutation", "project_mutation", "codespace_mutation", "gist_mutation", "discussion_mutation", "account_key_mutation", "authentication_mutation", "configuration_mutation", "repository_mutation", "external_communication", "http_mutation", "storage_mutation", "merge", "deploy", "release", "wait", "local", "direct_push"]);
|
|
573
|
+
// A bare "publish" verb anywhere in a statement is an instruction to publish
|
|
574
|
+
// ("and publish the package to npm", "could you publish it") unless it is a
|
|
575
|
+
// noun modifier ("a publish button") or the infinitive of a feature phrase
|
|
576
|
+
// ("a command to publish docs"). Inflected descriptive forms ("the button
|
|
577
|
+
// publishes", "published articles") describe product behaviour. A bare
|
|
578
|
+
// submission verb aimed at a registry or store ("submit the package to npm",
|
|
579
|
+
// "upload the build to the App Store") and a registry tool command ("gem
|
|
580
|
+
// push", "twine upload") are publication instructions under the same rules.
|
|
581
|
+
const PUBLISH_MODIFIER_HEAD = /\b(?:a|an|the|this|that|our|its|new|auto|manual|one-click)\s*$/iu;
|
|
582
|
+
const PUBLISH_FEATURE_INFINITIVE = /\b(?:command|button|option|ability|way|tool|script|action|feature|endpoint|page|link|menu|job|task|method|function|flag|setting|permission|right|role|form|screen|dialog|modal|workflow|service|handler|hook|api|ui|cli)s?\s+to\s*$/iu;
|
|
583
|
+
const EXAMPLE_INFINITIVE = /\b(?:how|code|tests?|examples?|instructions?|documentation)\s+to\s*$/iu;
|
|
584
|
+
const FEATURE_DELIVERY_CONTEXT = /\b(?:support|code|tests?|examples?|documentation)\s+for(?:\s+(?:running|executing|invoking|issuing))?\s*$/iu;
|
|
585
|
+
// Landing a PR, patch or change means merging it. Keep the existing merge
|
|
586
|
+
// effect and polarity checks; landing pages and literal product examples do
|
|
587
|
+
// not request that effect. Both active and passive instructions are screened.
|
|
588
|
+
const LANDING_WORDS = /\bland(?:s|ed|ing)?\b/giu;
|
|
589
|
+
const LANDING_OBJECT = /^\s+(?:(?:a|an|the|this|that|these|those|my|our|your)\s+)?(?:pull[\s-]+requests?|prs?|changes?|patch(?:es)?|commits?|it|them|this|that)\b/iu;
|
|
590
|
+
const LANDED_SUBJECT = /\b(?:pull[\s-]+requests?|prs?|changes?|patch(?:es)?|commits?|it|they|this|that)\s+(?:(?:must|should|shall|will|would|could|can|has|have|had|needs?\s+to)\s+)?(?:be|been|is|are|was|were|gets?|got)\s*$/iu;
|
|
591
|
+
// Filter with the existing object/subject grammar before building context.
|
|
592
|
+
// Repeated bare inflections then require no per-word slices or prefix scans.
|
|
593
|
+
const LANDING_CANDIDATE = new RegExp(`${LANDING_WORDS.source}(?=${LANDING_OBJECT.source.slice(1)})|${LANDED_SUBJECT.source.slice(0, -1)}(landed)\\b`, "giu");
|
|
594
|
+
function deliveryPrefix(text, end) {
|
|
595
|
+
// Scan sixteen tokens once, including nested tool infinitives, instead of rechecking
|
|
596
|
+
// the whole growing prefix for every landing word in a large request.
|
|
597
|
+
let start = end;
|
|
598
|
+
for (let words = 0; words < 16 && start > 0; words++) {
|
|
599
|
+
while (start > 0 && /\s/u.test(text[start - 1])) start--;
|
|
600
|
+
// A completed sentence cannot supply this action's feature context. Stop
|
|
601
|
+
// before its punctuation instead of re-reading sixteen words per command.
|
|
602
|
+
if (start > 0 && /[.!?;]/u.test(text[start - 1])) break;
|
|
603
|
+
while (start > 0 && !/\s/u.test(text[start - 1])) start--;
|
|
604
|
+
}
|
|
605
|
+
return text.slice(start, end);
|
|
606
|
+
}
|
|
607
|
+
function featureDeliveryPrefix(before) {
|
|
608
|
+
const command = before.match(COMMAND_SPAN_PREFIX);
|
|
609
|
+
return [before, ...(command ? [before.slice(0, command.index)] : [])].some(prefix => {
|
|
610
|
+
if (!(PUBLISH_FEATURE_INFINITIVE.test(prefix) || EXAMPLE_INFINITIVE.test(prefix) || FEATURE_DELIVERY_CONTEXT.test(prefix))) return false;
|
|
611
|
+
const scope = prefix.split(/[,.:!?;\n]|\b(?:and|then|but|yet|however)\b/iu).at(-1);
|
|
612
|
+
const executes = /\b(?:run(?:ning)?|execut(?:e|ing)|invok(?:e|ing)|call(?:ing)?|us(?:e|es|ing)|trigger(?:ing)?|launch(?:ing)?|start(?:ing)?|perform(?:ing)?|issu(?:e|ing))\b/iu.exec(scope);
|
|
613
|
+
const implementsFeature = /\b(?:add|build|implement|create|write|document|describe|design|develop|fix|improve|extend|provide|support|test|update|refactor|change|modify|adjust|enhance|redesign|rewrite)\b/iu.exec(scope);
|
|
614
|
+
if (!executes) return true;
|
|
615
|
+
if (!implementsFeature || implementsFeature.index >= executes.index) return false;
|
|
616
|
+
// The implementation must govern this tool through its infinitive, not
|
|
617
|
+
// merely occur earlier in the request ("fix the parser, use the CLI ...").
|
|
618
|
+
const bridge = scope.slice(implementsFeature.index + implementsFeature[0].length, executes.index);
|
|
619
|
+
return (/\b(?:to|for)\s*$/iu.test(bridge) || /\b(?:code|tests?|examples?|instructions?|documentation|support)\s+of\s*$/iu.test(bridge)) && !/\b(?:while|before|after|by|once|when|until|unless|because)\b/iu.test(bridge);
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
// Tag publication, force modes, broad/deleting pushes and explicit non-task
|
|
623
|
+
// refs exceed a normal task-branch push. Reuse the direct_push restriction; this detector
|
|
624
|
+
// only adds conflicts and never chooses a ref or executes a command.
|
|
625
|
+
const PUSH_WORDS = /\bpush(?:es|ed|ing)?\b/giu;
|
|
626
|
+
const PUSH_PRESENT = /\bpush(?:es|ed|ing)?\b/iu;
|
|
627
|
+
const NON_TASK_REF_OBJECT = /^\s+(?:(?:the|a|an|all|our|these|those)\s+){0,3}(?:(?:git|release|remote)\s+)?(?:tags?|refs?|notes)\b|^\s+(?:all|every|other|unrelated)\s+branches?\b|^\s+--(?:tags|follow-tags|all|mirror|delete|prune)(?=\s|=|$|[.;])/iu;
|
|
628
|
+
const NAMED_BRANCH_OBJECT = /^\s+(?:(?:the|a)\s+)?branch\s+(?!for\b|to\b|after\b|when\b|once\b|and\b)([\p{L}\p{N}_./-]+)/iu;
|
|
629
|
+
const GIT_PUSH_COMMAND = /\bgit(?:\s+(?:(?:-C|-c|--git-dir|--work-tree)\s+[^\s;|&]+|--(?:git-dir|work-tree)=[^\s;|&]+)){0,4}\s+push\b/giu;
|
|
630
|
+
const REF_PUSH_FLAG = /^--(?:tags|follow-tags|all|mirror|delete|prune|force(?:-with-lease)?)(?:=|$)/iu;
|
|
631
|
+
const PUSH_VALUE_OPTIONS = new Set(["--push-option", "--receive-pack", "--exec"]);
|
|
632
|
+
const PUSH_MODE_CONTINUATION = /\r?\n(?=[ \t]*(?:--force(?:-with-lease)?(?:[=\s]|$)|-[a-zA-Z]*f[a-zA-Z]*(?=\s|$)))/gu;
|
|
633
|
+
const FORCE_PUSH_PROSE = /\bforce[\s-]+push(?:es|ed|ing)?\b/giu;
|
|
634
|
+
const FORCE_PUSH_FEATURE_TAIL = /^\s+(?:support|options?|flags?|buttons?|settings?|warnings?|status|documentation)\b/iu;
|
|
635
|
+
// A source ref before the remote still names a base/non-task write. Match
|
|
636
|
+
// selected base names literally; descriptive task/feature branches and feature
|
|
637
|
+
// infinitives do not choose another ref. Reuse the existing push conflict.
|
|
638
|
+
const REVERSED_BRANCH_OBJECT = /^\s+(?:(?:the|our|a)\s+)?(?!task\s|feature\s|current\s|this\s)([\p{L}\p{N}_./+-]+)\s+branch\s+(?:to|onto)\s+\S/iu;
|
|
639
|
+
function sourceRefPushPattern(base) {
|
|
640
|
+
const selected = typeof base === "string" && base.trim();
|
|
641
|
+
const name = selected ? `${escapeRegExp(base.trim())}|main|master|trunk|develop(?:ment)?` : "main|master|trunk|develop(?:ment)?";
|
|
642
|
+
return new RegExp(String.raw`^\s+(?:(?:the|our)\s+)?(?:refs/heads/)?(?:${name})(?:\s+branch)?\s+(?:to|onto)\s+(?:(?:the|a|our)\s+)?(?:remote\s+)?[\p{L}\p{N}_./:@-]+`, "iu");
|
|
643
|
+
}
|
|
644
|
+
function nonTaskRefPush(text, base, source = text) {
|
|
645
|
+
if (!PUSH_PRESENT.test(text)) return false;
|
|
646
|
+
const sourceRef = sourceRefPushPattern(base);
|
|
647
|
+
for (const match of text.matchAll(PUSH_WORDS)) {
|
|
648
|
+
const after = text.slice(match.index + match[0].length);
|
|
649
|
+
// Check the anchored object first: --push-option data cannot name a ref.
|
|
650
|
+
// Avoid scanning feature context for every flag in a maximum-size command.
|
|
651
|
+
if (!(NON_TASK_REF_OBJECT.test(after) || NAMED_BRANCH_OBJECT.test(after) || REVERSED_BRANCH_OBJECT.test(after) || sourceRef.test(after))) continue;
|
|
652
|
+
if (!featureDeliveryPrefix(deliveryPrefix(text, match.index))) return true;
|
|
653
|
+
}
|
|
654
|
+
// Share the quote-aware command reader: option data cannot supply a mode,
|
|
655
|
+
// and feature examples cannot request a write. Force modes conflict even
|
|
656
|
+
// when the destination is HEAD; the worker never force-pushes its branch.
|
|
657
|
+
const proseParts = []; let last = 0;
|
|
658
|
+
// A mode may wrap onto the next request line. Same-length spaces preserve
|
|
659
|
+
// the immutable source offsets and the reader's quoted/value boundaries.
|
|
660
|
+
const joinModes = value => value.replace(PUSH_MODE_CONTINUATION, space => " ".repeat(space.length));
|
|
661
|
+
for (const { match, args, end } of commandReadings(joinModes(source), joinModes(text), GIT_PUSH_COMMAND, { valueOptions: new Set([...PUSH_VALUE_OPTIONS, "--repo"]) })) {
|
|
662
|
+
proseParts.push(text.slice(last, match.index), " ".repeat(end - match.index)); last = end;
|
|
663
|
+
const positional = [];
|
|
664
|
+
let remoteOption = false, options = true;
|
|
665
|
+
for (let i = 0; i < args.length; i++) {
|
|
666
|
+
const arg = args[i].value;
|
|
667
|
+
if (options && arg === "--") { options = false; continue; }
|
|
668
|
+
if (options) {
|
|
669
|
+
if (REF_PUSH_FLAG.test(arg)) return true;
|
|
670
|
+
if (arg === "--repo") { remoteOption = true; if (!args[++i] || args[i].value.startsWith("-")) return true; continue; }
|
|
671
|
+
if (arg.startsWith("--repo=")) { remoteOption = true; continue; }
|
|
672
|
+
if (PUSH_VALUE_OPTIONS.has(arg)) { i++; continue; }
|
|
673
|
+
if (arg.startsWith("-") && !arg.startsWith("--")) {
|
|
674
|
+
for (let j = 1; j < arg.length; j++) {
|
|
675
|
+
if (arg[j] === "o") { if (j + 1 === arg.length) i++; break; }
|
|
676
|
+
if (arg[j] === "f" || arg[j] === "d") return true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (!arg || arg.startsWith("-")) continue;
|
|
680
|
+
}
|
|
681
|
+
positional.push(arg);
|
|
682
|
+
}
|
|
683
|
+
// Without --repo, the first positional word is the remote. A destination must
|
|
684
|
+
// identify only the current task branch, not another named ref/refspec.
|
|
685
|
+
if (positional.slice(remoteOption ? 0 : 1).some(ref => ref !== "HEAD")) return true;
|
|
686
|
+
}
|
|
687
|
+
return lifecycleInstruction(proseParts.join("") + text.slice(last), [FORCE_PUSH_PROSE], FORCE_PUSH_FEATURE_TAIL);
|
|
688
|
+
}
|
|
689
|
+
const WRITTEN_FEEDBACK_ACTION = /(?:comment(?:ing)?(?:\s+on)?|reply(?:ing)?\s+to|(?:leav(?:e|ing)|post(?:ing)?|add(?:ing)?|send(?:ing)?|writ(?:e|ing))\s+(?:(?:a|the)\s+)?(?:review|comments?|feedback)\s+(?:on|to|for))/.source;
|
|
690
|
+
const PR_LIFECYCLE_ACTION = String.raw`(?:clos(?:e|ing)|reopen(?:ing)?|delet(?:e|ing)|edit(?:ing)?|lock(?:ing)?|unlock(?:ing)?|retarget(?:ing)?|convert(?:ing)?|mark(?:ing)?|review(?:ing)?|${WRITTEN_FEEDBACK_ACTION})`;
|
|
691
|
+
const PR_LIFECYCLE_VERB = new RegExp(String.raw`\b${PR_LIFECYCLE_ACTION}\s+(?:(?:the|a|an|this|that|existing|current|old)\s+){0,3}(?:pull[\s-]+requests?|prs?)\b`, "giu");
|
|
692
|
+
const PR_LIFECYCLE_PASSIVE = /\b(?:pull[\s-]+requests?|prs?)(?:\s*#\d+|\s+\d+)?\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:closed|reopened|deleted|edited|locked|unlocked|retargeted|converted|marked|reviewed|commented\s+on)\b/giu;
|
|
693
|
+
const cliRepositoryOptions = actions => String.raw`(?:\s+(?:(?:--repo|-R)(?:=[^\s;|&]*|\s+(?!-|${actions}(?:\s|[.;]|$))[^\s;|&]+)?|-R[^\s;|&]+))*`;
|
|
694
|
+
const PR_LIFECYCLE_CLI_ACTION = String.raw`(?:close|reopen|edit|lock|unlock|ready|comment|review)`;
|
|
695
|
+
const PR_LIFECYCLE_COMMAND = new RegExp(String.raw`\bgh\s+pr${cliRepositoryOptions(PR_LIFECYCLE_CLI_ACTION)}\s+${PR_LIFECYCLE_CLI_ACTION}\b`, "giu");
|
|
696
|
+
function lifecycleInstruction(text, patterns, featureTail = null) {
|
|
697
|
+
for (const pattern of patterns) for (const match of text.matchAll(pattern)) {
|
|
698
|
+
if (featureDeliveryPrefix(deliveryPrefix(text, match.index))) continue;
|
|
699
|
+
if (featureTail?.test(text.slice(match.index + match[0].length))) continue;
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
function pullRequestLifecycle(text, source = text) {
|
|
705
|
+
return lifecycleInstruction(text, [PR_LIFECYCLE_VERB, PR_LIFECYCLE_PASSIVE, PR_LIFECYCLE_COMMAND])
|
|
706
|
+
|| (/\bupdate-branch\b/iu.test(text) && contentMutation(source, text, PR_BRANCH_UPDATE));
|
|
707
|
+
}
|
|
708
|
+
// Issue writes never become a supported action or prohibition tag. Read-only
|
|
709
|
+
// commands and issue-related product features keep their ordinary meaning.
|
|
710
|
+
const ISSUE_CREATE_ACTION = String.raw`(?:open(?:ing)?|creat(?:e|ing)|fil(?:e|ing)|rais(?:e|ing)|submit(?:ting)?)`;
|
|
711
|
+
const ISSUE_WRITE_ACTION = String.raw`(?:clos(?:e|ing)|reopen(?:ing)?|delet(?:e|ing)|edit(?:ing)?|updat(?:e|ing)|modif(?:y|ying)|lock(?:ing)?|unlock(?:ing)?|assign(?:ing)?|label(?:ling|ing)?|transfer(?:ring)?|pin(?:ning)?|unpin(?:ning)?|${WRITTEN_FEEDBACK_ACTION})`;
|
|
712
|
+
const ISSUE_NOUN = String.raw`(?:github\s+)?issues?`;
|
|
713
|
+
const ISSUE_URL = String.raw`https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+`;
|
|
714
|
+
const ISSUE_REFERENCE = String.raw`(?:${ISSUE_NOUN}(?:\s*#\d+|\s+\d+)?|${ISSUE_URL})`;
|
|
715
|
+
const ISSUE_DETERMINERS = String.raw`(?:(?:the|a|an|this|that|new|existing|current|old)\s+){0,3}`;
|
|
716
|
+
const ISSUE_VERB = new RegExp(String.raw`\b(?:${ISSUE_CREATE_ACTION}\s+${ISSUE_DETERMINERS}${ISSUE_NOUN}|${ISSUE_WRITE_ACTION}\s+${ISSUE_DETERMINERS}(?:${ISSUE_NOUN}|${ISSUE_URL}))\b`, "giu");
|
|
717
|
+
const ISSUE_PASSIVE_AFTER = /^\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|opened|filed|raised|submitted|edited|updated|modified|commented\s+on|closed|reopened|deleted|locked|unlocked|assigned|labelled|labeled|transferred|pinned|unpinned)\b/iu;
|
|
718
|
+
const ISSUE_PASSIVE = new RegExp(String.raw`\b${ISSUE_REFERENCE}${ISSUE_PASSIVE_AFTER.source.slice(1)}`, "giu");
|
|
719
|
+
const ISSUE_QUOTED_REFERENCE = new RegExp(String.raw`^${ISSUE_DETERMINERS}${ISSUE_REFERENCE}$`, "iu");
|
|
720
|
+
const ISSUE_SPAN_PREFIX = new RegExp(String.raw`\b${ISSUE_WRITE_ACTION}\s+${ISSUE_DETERMINERS}$`, "iu");
|
|
721
|
+
const ISSUE_FEATURE_TAIL = /^\s*(?:templates?|dialogs?|modals?|editors?|forms?|buttons?|panels?|pages?|views?|screens?|components?|tabs?|menus?|badges?|fields?|lists?|tables?|models?|endpoints?|api)\b/iu;
|
|
722
|
+
const ISSUE_CLI_ACTION = String.raw`(?:create|edit|comment|close|reopen|delete|develop|lock|unlock|pin|unpin|transfer)`;
|
|
723
|
+
// gh accepts the repository selector before the subcommand as well. A quoted
|
|
724
|
+
// value may be blanked by literal masking; never consume the action as a value.
|
|
725
|
+
const ISSUE_CLI_REPO = cliRepositoryOptions(ISSUE_CLI_ACTION);
|
|
726
|
+
const ISSUE_COMMAND = new RegExp(String.raw`\bgh\s+issue${ISSUE_CLI_REPO}\s+${ISSUE_CLI_ACTION}\b`, "giu");
|
|
727
|
+
function issueMutation(text) {
|
|
728
|
+
if (!/\bissues?\b/iu.test(text)) return false;
|
|
729
|
+
return lifecycleInstruction(text, [ISSUE_VERB], ISSUE_FEATURE_TAIL)
|
|
730
|
+
|| lifecycleInstruction(text, [ISSUE_PASSIVE, ISSUE_COMMAND]);
|
|
731
|
+
}
|
|
732
|
+
// Workflow dispatch and state writes are outside PR delivery authority, as
|
|
733
|
+
// are rerunning, cancelling or deleting an existing Actions run. No tag grants
|
|
734
|
+
// these effects. Share CLI selector and product-context boundaries with issues.
|
|
735
|
+
const WORKFLOW_CLI_ACTION = String.raw`(?:run|enable|disable)`;
|
|
736
|
+
const RUN_CLI_ACTION = String.raw`(?:rerun|cancel|delete)`;
|
|
737
|
+
const WORKFLOW_COMMAND = new RegExp(String.raw`\b(?:gh\s+workflow${cliRepositoryOptions(WORKFLOW_CLI_ACTION)}\s+${WORKFLOW_CLI_ACTION}|gh\s+run${cliRepositoryOptions(RUN_CLI_ACTION)}\s+${RUN_CLI_ACTION})\b`, "giu");
|
|
738
|
+
const WORKFLOW_VERB = /\b(?:trigger(?:ing)?|dispatch(?:ing)?|run(?:ning)?|rerun(?:ning)?|re-run(?:ning)?|enabl(?:e|ing)|disabl(?:e|ing)|cancel(?:ling|ing)?)\s+(?:(?:the|a|this|that)\s+)?(?:github\s+(?:actions\s+)?)?workflows?\b/giu;
|
|
739
|
+
const RUN_MUTATION_ACTION = String.raw`(?:cancel(?:ling|ing)?|rerun(?:ning)?|re-run(?:ning)?|delet(?:e|ing))`;
|
|
740
|
+
const RUN_DETERMINERS = String.raw`(?:(?:the|a|an|this|that|these|those|all|failed|running|queued|completed|latest|current|existing|old)\s+){0,4}`;
|
|
741
|
+
const RUN_NOUN = String.raw`(?:(?:github\s+)?actions|(?:github\s+(?:actions\s+)?)?workflow)\s+runs?`;
|
|
742
|
+
const RUN_REFERENCE = String.raw`${RUN_DETERMINERS}${RUN_NOUN}(?:\s*#\d+|\s+\d+)?`;
|
|
743
|
+
const RUN_VERB = new RegExp(String.raw`\b${RUN_MUTATION_ACTION}\s+${RUN_REFERENCE}\b`, "giu");
|
|
744
|
+
const RUN_PASSIVE_AFTER = /^\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:cancelled|canceled|rerun|re-run|deleted)\b/iu;
|
|
745
|
+
const RUN_PASSIVE = new RegExp(String.raw`\b${RUN_REFERENCE}${RUN_PASSIVE_AFTER.source.slice(1)}`, "giu");
|
|
746
|
+
const RUN_QUOTED_REFERENCE = new RegExp(String.raw`^${RUN_REFERENCE}$`, "iu");
|
|
747
|
+
const RUN_SPAN_PREFIX = new RegExp(String.raw`\b${RUN_MUTATION_ACTION}\s+${RUN_DETERMINERS}$`, "iu");
|
|
748
|
+
function workflowMutation(text) {
|
|
749
|
+
return /\b(?:workflows?|runs?)\b/iu.test(text) && (lifecycleInstruction(text, [WORKFLOW_COMMAND, WORKFLOW_VERB, RUN_PASSIVE])
|
|
750
|
+
|| lifecycleInstruction(text, [RUN_VERB], ISSUE_FEATURE_TAIL));
|
|
751
|
+
}
|
|
752
|
+
// Repository label writes are independent of creating the task's review PR.
|
|
753
|
+
// Parse the family once: names/option values cannot supply an action or help,
|
|
754
|
+
// while clone writes destination labels just like create/edit/delete.
|
|
755
|
+
const LABEL_COMMAND = /\bgh\s+label\b(?:[ \t]*\r?\n[ \t]*(?=(?:create|edit|delete|clone|list|ls)\b))?/giu;
|
|
756
|
+
const LABEL_WRITES = new Set(["create", "edit", "delete", "clone"]);
|
|
757
|
+
const LABEL_VALUE_OPTIONS = new Set(["--repo", "--color", "--description", "--name", "--limit", "--search", "--json", "--jq", "--template", "--order", "--sort"]);
|
|
758
|
+
const LABEL_SHORT_OPTIONS = { R: "--repo", c: "--color", d: "--description", n: "--name", L: "--limit", S: "--search", q: "--jq", t: "--template", h: "--help" };
|
|
759
|
+
const LABEL_NOUN = String.raw`(?:github|repository|repo)\s+labels?`;
|
|
760
|
+
const LABEL_VERB = new RegExp(String.raw`\b(?:creat(?:e|ing)|edit(?:ing)?|updat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing))\s+${ISSUE_DETERMINERS}${LABEL_NOUN}\b`, "giu");
|
|
761
|
+
const LABEL_PASSIVE = new RegExp(String.raw`\b${LABEL_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|edited|updated|deleted|removed)\b`, "giu");
|
|
762
|
+
const CONTENT_FEATURE_TAIL = /^\s*(?:selectors?|fields?|templates?|dialogs?|editors?|forms?|buttons?|panels?|pages?|view(?:er)?s?|screens?|components?|tabs?|menus?|badges?|lists?|tables?|models?|endpoints?|api)\b/iu;
|
|
763
|
+
const LABEL_MUTATION = { command: LABEL_COMMAND, writes: LABEL_WRITES, valueOptions: LABEL_VALUE_OPTIONS, shortOptions: LABEL_SHORT_OPTIONS,
|
|
764
|
+
verbs: [LABEL_VERB], passives: [LABEL_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
765
|
+
// Gists publish or change account-owned content even when secret. In contrast,
|
|
766
|
+
// gh gist clone only downloads locally; it does not copy labels to GitHub.
|
|
767
|
+
const GIST_COMMAND = /\bgh\s+gist\b(?:[ \t]*\r?\n[ \t]*(?=(?:create|new|edit|delete|rename|list|ls|view|clone)\b))?/giu;
|
|
768
|
+
const GIST_WRITES = new Set(["create", "new", "edit", "delete", "rename"]);
|
|
769
|
+
const GIST_VALUE_OPTIONS = new Set(["--desc", "--filename", "--add", "--remove", "--limit", "--filter"]);
|
|
770
|
+
const GIST_SHORT_OPTIONS = { d: "--desc", f: "--filename", a: "--add", r: "--remove", L: "--limit", h: "--help" };
|
|
771
|
+
const GIST_NOUN = String.raw`(?:github\s+)?gists?`;
|
|
772
|
+
const GIST_VERB = new RegExp(String.raw`\b(?:creat(?:e|ing)|edit(?:ing)?|updat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing)|renam(?:e|ing))\s+${ISSUE_DETERMINERS}${GIST_NOUN}\b`, "giu");
|
|
773
|
+
const GIST_PASSIVE = new RegExp(String.raw`\b${GIST_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|edited|updated|deleted|removed|renamed)\b`, "giu");
|
|
774
|
+
const GIST_MUTATION = { command: GIST_COMMAND, writes: GIST_WRITES, valueOptions: GIST_VALUE_OPTIONS, shortOptions: GIST_SHORT_OPTIONS,
|
|
775
|
+
verbs: [GIST_VERB], passives: [GIST_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
776
|
+
// Discussion posts, edits and comment/reply writes are external content;
|
|
777
|
+
// list/view, help and quoted title/body data share the existing family reader.
|
|
778
|
+
const DISCUSSION_PRESENT = /\bdiscussions?\b/iu;
|
|
779
|
+
const DISCUSSION_COMMAND = /\bgh\s+discussion\b(?:[ \t]*\r?\n[ \t]*(?=(?:create|edit|comment|list|view)\b))?/giu;
|
|
780
|
+
const DISCUSSION_NOUN = String.raw`(?:github|repository|repo)\s+discussions?`;
|
|
781
|
+
const DISCUSSION_VERB = new RegExp(String.raw`\b(?:creat(?:e|ing)|edit(?:ing)?|updat(?:e|ing)|comment(?:ing)?\s+on|reply(?:ing)?\s+to)\s+${ISSUE_DETERMINERS}${DISCUSSION_NOUN}\b`, "giu");
|
|
782
|
+
const DISCUSSION_PASSIVE = new RegExp(String.raw`\b${DISCUSSION_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|edited|updated)\b`, "giu");
|
|
783
|
+
const DISCUSSION_MUTATION = { command: DISCUSSION_COMMAND, writes: new Set(["create", "edit", "comment"]),
|
|
784
|
+
valueOptions: new Set(["--repo", "--title", "--body", "--body-file", "--category", "--label", "--add-label", "--remove-label", "--limit", "--search", "--json", "--jq", "--template"]),
|
|
785
|
+
shortOptions: { R: "--repo", t: "--title", b: "--body", F: "--body-file", c: "--category", l: "--label", L: "--limit", S: "--search", q: "--jq", h: "--help" },
|
|
786
|
+
verbs: [DISCUSSION_VERB], passives: [DISCUSSION_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
787
|
+
function discussionMutation(source, text = source) { return DISCUSSION_PRESENT.test(text) && contentMutation(source, text, DISCUSSION_MUTATION); }
|
|
788
|
+
// Secret/variable writes change remote configuration beyond task-branch/PR
|
|
789
|
+
// scope. Share command data/help boundaries; secret set --no-store is the
|
|
790
|
+
// documented local encryption mode, so only a proven true flag excludes it.
|
|
791
|
+
const CONFIGURATION_PRESENT = /\b(?:secrets?|variables?)\b/iu;
|
|
792
|
+
const CONFIGURATION_COMMAND = /\bgh\s+(?:secret|variable)\b(?:[ \t]*\r?\n[ \t]*(?=(?:set|delete|list|get)\b))?/giu;
|
|
793
|
+
const CONFIGURATION_NOUN = String.raw`github\s+(?:(?:repository|repo|environment|organization|org|user|account)\s+)?(?:secrets?|variables?)`;
|
|
794
|
+
const CONFIGURATION_VERB = new RegExp(String.raw`\b(?:set(?:ting)?|creat(?:e|ing)|updat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing))\s+${ISSUE_DETERMINERS}${CONFIGURATION_NOUN}\b`, "giu");
|
|
795
|
+
const CONFIGURATION_PASSIVE = new RegExp(String.raw`\b${CONFIGURATION_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:set|created|updated|deleted|removed)\b`, "giu");
|
|
796
|
+
const CONFIGURATION_MUTATION = { command: CONFIGURATION_COMMAND, localOnly: { action: "set", option: "--no-store", command: /\bgh\s+secret\b/iu }, writes: new Set(["set", "delete"]),
|
|
797
|
+
valueOptions: new Set(["--repo", "--app", "--body", "--env", "--env-file", "--org", "--repos", "--visibility", "--json", "--jq", "--template", "--limit"]),
|
|
798
|
+
shortOptions: { R: "--repo", a: "--app", b: "--body", e: "--env", f: "--env-file", o: "--org", r: "--repos", v: "--visibility", q: "--jq", t: "--template", L: "--limit", h: "--help" },
|
|
799
|
+
verbs: [CONFIGURATION_VERB], passives: [CONFIGURATION_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
800
|
+
function configurationMutation(source, text = source) {
|
|
801
|
+
return CONFIGURATION_PRESENT.test(text) && contentMutation(source, text, CONFIGURATION_MUTATION);
|
|
802
|
+
}
|
|
803
|
+
// Projects, their fields/items and repository/team links are account-owned
|
|
804
|
+
// external state. Share only parsing; each namespace supplies its write set.
|
|
805
|
+
const PROJECT_COMMAND = /\bgh\s+project\b(?:[ \t]*\r?\n[ \t]*(?=(?:create|edit|delete|close|copy|field-create|field-delete|item-add|item-archive|item-create|item-delete|item-edit|link|unlink|mark-template|list|view|field-list|item-list)\b))?/giu;
|
|
806
|
+
const PROJECT_WRITES = new Set(["create", "edit", "delete", "close", "copy", "field-create", "field-delete", "item-add", "item-archive", "item-create", "item-delete", "item-edit", "link", "unlink", "mark-template"]);
|
|
807
|
+
const PROJECT_VALUE_OPTIONS = new Set(["--owner", "--title", "--description", "--readme", "--visibility", "--format", "--jq", "--template", "--source-owner", "--target-owner", "--data-type", "--name", "--single-select-options", "--id", "--url", "--body", "--date", "--field", "--field-id", "--iteration-id", "--number", "--project-id", "--single-select-option-id", "--text", "--value", "--repo", "--team", "--limit", "--query"]);
|
|
808
|
+
const PROJECT_SHORT_OPTIONS = { d: "--description", q: "--jq", t: "--template", R: "--repo", T: "--team", L: "--limit", h: "--help" };
|
|
809
|
+
const PROJECT_NOUN = String.raw`github\s+projects?`;
|
|
810
|
+
const PROJECT_VERB = new RegExp(String.raw`\b(?:creat(?:e|ing)|edit(?:ing)?|updat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing)|clos(?:e|ing)|reopen(?:ing)?|copy(?:ing)?)\s+${ISSUE_DETERMINERS}${PROJECT_NOUN}\b`, "giu");
|
|
811
|
+
const PROJECT_PASSIVE = new RegExp(String.raw`\b${PROJECT_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|edited|updated|deleted|removed|closed|reopened|copied)\b`, "giu");
|
|
812
|
+
const PROJECT_MUTATION = { command: PROJECT_COMMAND, writes: PROJECT_WRITES, valueOptions: PROJECT_VALUE_OPTIONS, shortOptions: PROJECT_SHORT_OPTIONS,
|
|
813
|
+
verbs: [PROJECT_VERB], passives: [PROJECT_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
814
|
+
// Codespace lifecycle writes allocate, change or discard remote workspaces.
|
|
815
|
+
// The CLI alias and quoted option values share the content-family reader;
|
|
816
|
+
// status/list/view/logs and help cannot supply a mutating subcommand as data.
|
|
817
|
+
const CODESPACE_COMMAND = /\bgh\s+(?:codespace|cs)\b(?:[ \t]*\r?\n[ \t]*(?=(?:create|delete|stop|rebuild|edit|list|ls|status|view|logs)\b))?/giu;
|
|
818
|
+
const CODESPACE_WRITES = new Set(["create", "delete", "stop", "rebuild", "edit"]);
|
|
819
|
+
const CODESPACE_VALUE_OPTIONS = new Set(["--codespace", "--repo", "--repo-owner", "--org", "--user", "--branch", "--display-name", "--location", "--machine", "--idle-timeout", "--retention-period", "--devcontainer-path", "--days", "--json", "--jq", "--template", "--limit"]);
|
|
820
|
+
const CODESPACE_SHORT_OPTIONS = { c: "--codespace", R: "--repo", o: "--org", u: "--user", b: "--branch", d: "--display-name", l: "--location", m: "--machine", q: "--jq", t: "--template", L: "--limit", h: "--help" };
|
|
821
|
+
const CODESPACE_NOUN = String.raw`(?:github\s+)?codespaces?`;
|
|
822
|
+
const CODESPACE_DETERMINERS = String.raw`(?:(?:the|a|an|this|that|these|those|all|my|our|your|new|existing|current|old)\s+){0,3}`;
|
|
823
|
+
const CODESPACE_ACTION = String.raw`(?:creat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing)|stop(?:ping)?|rebuild(?:ing)?|edit(?:ing)?|resiz(?:e|ing))`;
|
|
824
|
+
const CODESPACE_VERB = new RegExp(String.raw`\b${CODESPACE_ACTION}\s+${CODESPACE_DETERMINERS}${CODESPACE_NOUN}\b`, "giu");
|
|
825
|
+
const CODESPACE_PASSIVE_AFTER = /^\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|deleted|removed|stopped|rebuilt|edited|resized)\b/iu;
|
|
826
|
+
const CODESPACE_PASSIVE = new RegExp(String.raw`\b${CODESPACE_NOUN}${CODESPACE_PASSIVE_AFTER.source.slice(1)}`, "giu");
|
|
827
|
+
const CODESPACE_QUOTED_REFERENCE = new RegExp(String.raw`^${CODESPACE_DETERMINERS}${CODESPACE_NOUN}$`, "iu");
|
|
828
|
+
const CODESPACE_SPAN_PREFIX = new RegExp(String.raw`\b${CODESPACE_ACTION}\s+${CODESPACE_DETERMINERS}$`, "iu");
|
|
829
|
+
const CODESPACE_MUTATION = { command: CODESPACE_COMMAND, writes: CODESPACE_WRITES, valueOptions: CODESPACE_VALUE_OPTIONS, shortOptions: CODESPACE_SHORT_OPTIONS,
|
|
830
|
+
verbs: [CODESPACE_VERB], passives: [CODESPACE_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
831
|
+
function codespaceMutation(source, text = source) { return contentMutation(source, text, CODESPACE_MUTATION); }
|
|
832
|
+
function projectMutation(source, text = source) { return contentMutation(source, text, PROJECT_MUTATION); }
|
|
833
|
+
function labelMutation(source, text = source) { return contentMutation(source, text, LABEL_MUTATION); }
|
|
834
|
+
function gistMutation(source, text = source) { return contentMutation(source, text, GIST_MUTATION); }
|
|
835
|
+
// Authentication configuration is persistent account authority, not task-PR
|
|
836
|
+
// delivery. Reuse content-family boundaries; -h is a hostname value here,
|
|
837
|
+
// whereas only --help is help. Status/token merely read existing credentials.
|
|
838
|
+
const AUTHENTICATION_PRESENT = /\bgh\s+auth\b/iu;
|
|
839
|
+
const AUTHENTICATION_COMMAND = /\bgh\s+auth\b(?:[ \t]*\r?\n[ \t]*(?=(?:login|logout|switch|refresh|setup-git|status|token)\b))?/giu;
|
|
840
|
+
const AUTHENTICATION_MUTATION = { command: AUTHENTICATION_COMMAND, writes: new Set(["login", "logout", "switch", "refresh", "setup-git"]),
|
|
841
|
+
valueOptions: new Set(["--hostname", "--user", "--scopes", "--remove-scopes", "--git-protocol", "--json", "--jq", "--template"]),
|
|
842
|
+
shortOptions: { h: "--hostname", u: "--user", s: "--scopes", r: "--remove-scopes", p: "--git-protocol", q: "--jq", t: "--template" },
|
|
843
|
+
verbs: [], passives: [] };
|
|
844
|
+
function authenticationMutation(source, text = source) { return AUTHENTICATION_PRESENT.test(text) && contentMutation(source, text, AUTHENTICATION_MUTATION); }
|
|
845
|
+
// Account keys are outside branch/PR authority. The shared command reader
|
|
846
|
+
// keeps list/help and key title/type values separate from add/delete actions.
|
|
847
|
+
const ACCOUNT_KEY_PRESENT = /\b(?:ssh|gpg)[\s-]+keys?\b/iu;
|
|
848
|
+
const ACCOUNT_KEY_COMMAND = /\bgh\s+(?:ssh|gpg)-key\b(?:[ \t]*\r?\n[ \t]*(?=(?:add|delete|list|ls)\b))?/giu;
|
|
849
|
+
const ACCOUNT_KEY_KIND = String.raw`(?:ssh|gpg)[\s-]+keys?`;
|
|
850
|
+
const ACCOUNT_KEY_NOUN = String.raw`github\s+(?:account\s+)?${ACCOUNT_KEY_KIND}`;
|
|
851
|
+
const ACCOUNT_KEY_ACTION = String.raw`(?:add(?:ing)?|delet(?:e|ing)|remov(?:e|ing)|revok(?:e|ing))`;
|
|
852
|
+
const ACCOUNT_KEY_VERB = new RegExp(String.raw`\b${ACCOUNT_KEY_ACTION}\s+${ISSUE_DETERMINERS}${ACCOUNT_KEY_NOUN}\b`, "giu");
|
|
853
|
+
const ACCOUNT_KEY_DESTINATION = new RegExp(String.raw`\b${ACCOUNT_KEY_ACTION}\s+${ISSUE_DETERMINERS}${ACCOUNT_KEY_KIND}\s+(?:to|from|on|in)\s+(?:(?:the|my|our|your)\s+)?github\s+account\b`, "giu");
|
|
854
|
+
const ACCOUNT_KEY_PASSIVE = new RegExp(String.raw`\b${ACCOUNT_KEY_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:added|deleted|removed|revoked)\b`, "giu");
|
|
855
|
+
const ACCOUNT_KEY_MUTATION = { command: ACCOUNT_KEY_COMMAND, writes: new Set(["add", "delete"]),
|
|
856
|
+
valueOptions: new Set(["--title", "--type"]), shortOptions: { t: "--title", h: "--help" },
|
|
857
|
+
verbs: [ACCOUNT_KEY_VERB, ACCOUNT_KEY_DESTINATION], passives: [ACCOUNT_KEY_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
858
|
+
function accountKeyMutation(source, text = source) { return ACCOUNT_KEY_PRESENT.test(text) && contentMutation(source, text, ACCOUNT_KEY_MUTATION); }
|
|
859
|
+
// Repository lifecycle/settings writes are not task-branch or PR delivery.
|
|
860
|
+
// Share command/data/help boundaries with the other unsupported mutations.
|
|
861
|
+
const REPOSITORY_PRESENT = /\b(?:gh\s+repo|github\s+(?:repos?|repositor(?:y|ies)))\b/iu;
|
|
862
|
+
const REPOSITORY_COMMAND = /\bgh\s+repo\b(?:[ \t]*\r?\n[ \t]*(?=(?:delete|archive|unarchive|rename|edit|create|fork|sync|autolink|view|list|clone)\b))?/giu;
|
|
863
|
+
const REPOSITORY_NOUN = String.raw`github\s+(?:repos?|repositor(?:y|ies))`;
|
|
864
|
+
const REPOSITORY_ACTION = String.raw`(?:delet(?:e|ing)|remov(?:e|ing)|archiv(?:e|ing)|unarchiv(?:e|ing)|renam(?:e|ing))`;
|
|
865
|
+
const REPOSITORY_VERB = new RegExp(String.raw`\b${REPOSITORY_ACTION}\s+${ISSUE_DETERMINERS}${REPOSITORY_NOUN}\b`, "giu");
|
|
866
|
+
const REPOSITORY_PASSIVE = new RegExp(String.raw`\b${REPOSITORY_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:deleted|removed|archived|unarchived|renamed)\b`, "giu");
|
|
867
|
+
const REPOSITORY_MUTATION = { command: REPOSITORY_COMMAND, writes: new Set(["delete", "archive", "unarchive", "rename", "edit", "create", "fork", "sync"]),
|
|
868
|
+
valueOptions: new Set(["--repo", "--description", "--homepage", "--gitignore", "--license", "--remote", "--source", "--team", "--template", "--fork-name", "--org", "--remote-name", "--branch", "--visibility", "--default-branch", "--add-topic", "--remove-topic", "--squash-merge-commit-message", "--json", "--jq", "--limit", "--language", "--topic"]),
|
|
869
|
+
shortOptions: { R: "--repo", d: "--description", h: "--homepage", g: "--gitignore", l: "--license", r: "--remote", s: "--source", t: "--team", p: "--template", b: "--branch", q: "--jq", L: "--limit" },
|
|
870
|
+
subcommands: new Map([["autolink", { writes: new Set(["create", "new", "delete"]), shortOptions: { R: "--repo", q: "--jq", t: "--template", h: "--help" } }]]),
|
|
871
|
+
verbs: [REPOSITORY_VERB], passives: [REPOSITORY_PASSIVE], featureTail: CONTENT_FEATURE_TAIL };
|
|
872
|
+
function repositoryMutation(source, text = source) { return REPOSITORY_PRESENT.test(text) && contentMutation(source, text, REPOSITORY_MUTATION); }
|
|
873
|
+
// Keep command/help parsing and quoted-data boundaries identical across the
|
|
874
|
+
// content families; their own command tables decide which actions write.
|
|
875
|
+
function contentMutation(source, text, { command, writes, valueOptions, shortOptions, verbs, passives, featureTail, proseCheck = null, subcommands = null, localOnly = null }) {
|
|
876
|
+
const proseParts = []; let last = 0;
|
|
877
|
+
for (const { match, args, end } of commandReadings(source, text, command, { valueOptions, shortOptions })) {
|
|
878
|
+
// A preserved code span can contain quoted arguments. They are data, even
|
|
879
|
+
// when their contents look like qualified natural-language writes.
|
|
880
|
+
proseParts.push(text.slice(last, match.index), " ".repeat(end - match.index)); last = end;
|
|
881
|
+
let action = null, help = false, nested = null, local = false;
|
|
882
|
+
const readHelp = value => { help = !value || (!value.dynamic && /^(?:true|1|t)$/iu.test(value.value)); };
|
|
883
|
+
for (let i = 0; i < args.length; i++) {
|
|
884
|
+
const arg = args[i], equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
885
|
+
if (arg.value === "--") break;
|
|
886
|
+
if (option === localOnly?.option && (!localOnly.command || localOnly.command.test(match[0]))) { local = equal < 0 || (!arg.dynamic && /^(?:true|1|t)$/iu.test(arg.value.slice(equal + 1))); continue; }
|
|
887
|
+
if (option === "--help") { readHelp(equal < 0 ? null : { value: arg.value.slice(equal + 1), dynamic: arg.dynamic }); continue; }
|
|
888
|
+
if (valueOptions.has(option)) { if (equal < 0) i++; continue; }
|
|
889
|
+
if (arg.value.startsWith("-") && !arg.value.startsWith("--")) {
|
|
890
|
+
for (let j = 1; j < arg.value.length; j++) {
|
|
891
|
+
const short = (nested?.shortOptions ?? shortOptions)[arg.value[j]];
|
|
892
|
+
if (valueOptions.has(short)) { if (j + 1 === arg.value.length) i++; break; }
|
|
893
|
+
if (short === "--help") {
|
|
894
|
+
const assigned = arg.value[j + 1] === "=";
|
|
895
|
+
readHelp(assigned ? { value: arg.value.slice(j + 2), dynamic: arg.dynamic } : null);
|
|
896
|
+
if (assigned) break;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} else if (!arg.value.startsWith("-") && !action) {
|
|
900
|
+
// Consume only a configured namespace, never an option value. Dynamic
|
|
901
|
+
// namespace/action words still fail closed through the normal rule.
|
|
902
|
+
const child = !nested && !arg.dynamic && subcommands?.get(arg.value);
|
|
903
|
+
if (child) nested = child;
|
|
904
|
+
else action = arg;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (action && (action.dynamic || (!help && (nested?.writes ?? writes).has(action.value) && !(local && action.value === localOnly?.action)))) return true;
|
|
908
|
+
}
|
|
909
|
+
const prose = proseParts.join("") + text.slice(last);
|
|
910
|
+
return lifecycleInstruction(prose, verbs, featureTail) || lifecycleInstruction(prose, passives) || Boolean(proseCheck?.(prose));
|
|
911
|
+
}
|
|
912
|
+
// Canonical infrastructure writes request deployment even without a deploy
|
|
913
|
+
// verb. Reuse the existing effect, feature/literal context and statement bounds.
|
|
914
|
+
const INFRA_OPTIONS = String.raw`(?:\s+(?:-(?:n|f)\s+[^\s;|&]+|--(?:namespace|kube-context|kubeconfig|context|values)(?:=|\s+)[^\s;|&]+|--dry-run(?:=[^\s;|&]+)?|--(?:debug|wait|atomic|create-namespace)))*`;
|
|
915
|
+
const NODE_MAINTENANCE = String.raw`(?:drain|cordon|uncordon|taint)`;
|
|
916
|
+
const NODE_MAINTENANCE_COMMAND = new RegExp(String.raw`^kubectl${INFRA_OPTIONS}\s+${NODE_MAINTENANCE}\b`, "iu");
|
|
917
|
+
const INFRA_MUTATION = new RegExp(String.raw`\b(?:(?:terraform|tofu)(?:\s+-chdir(?:=[^\s;|&]+|\s+[^\s;|&]+))?\s+(?:apply|destroy)|helm${INFRA_OPTIONS}\s+(?:install|upgrade|rollback|uninstall|delete)|kubectl${INFRA_OPTIONS}\s+(?:apply|create|replace|patch|delete|scale|run|${NODE_MAINTENANCE}|rollout\s+(?:restart|undo)|set\s+image))\b`, "giu");
|
|
918
|
+
const INFRA_VALUE_OPTIONS = new Set(["-f", "--filename", "-k", "--kustomize", "-n", "--namespace", "--context", "--kube-context", "--kubeconfig", "--values", "--set", "--set-string", "--set-json", "--set-file", "--set-literal", "-o", "--output"]);
|
|
919
|
+
const NODE_VALUE_OPTIONS = new Set([...INFRA_VALUE_OPTIONS, "-l", "--selector", "--pod-selector", "--grace-period", "--timeout", "--chunk-size", "--skip-wait-for-delete-timeout", "--field-manager", "--template", "--validate"]);
|
|
920
|
+
const NODE_SWITCH_OPTIONS = new Set(["--force", "--ignore-daemonsets", "--delete-emptydir-data", "--disable-eviction", "--all", "--overwrite", "--show-managed-fields"]);
|
|
921
|
+
function infrastructureDryRun(command, nodeMaintenance = false) {
|
|
922
|
+
// Only unambiguous simulation flags exempt a command: flag-shaped values,
|
|
923
|
+
// quoted arguments, unknown option arity and later overrides ask instead.
|
|
924
|
+
if (/["'`\\]/u.test(command) || (nodeMaintenance && /\$/u.test(command))) return false;
|
|
925
|
+
const args = command.trim().split(/\s+/u);
|
|
926
|
+
let dryRun = false;
|
|
927
|
+
for (let i = 0; i < args.length; i++) {
|
|
928
|
+
const arg = args[i];
|
|
929
|
+
if (nodeMaintenance && arg === "--") break;
|
|
930
|
+
if (arg === "--dry-run" || arg.startsWith("--dry-run=")) {
|
|
931
|
+
if (nodeMaintenance ? arg !== "--dry-run=client" : arg !== "--dry-run" && !/^--dry-run=(?:client|server)$/u.test(arg)) return false;
|
|
932
|
+
dryRun = true;
|
|
933
|
+
} else if (nodeMaintenance && NODE_VALUE_OPTIONS.has(arg.split("=", 1)[0])) {
|
|
934
|
+
const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++i];
|
|
935
|
+
if (!value || value.startsWith("-")) return false;
|
|
936
|
+
} else if (nodeMaintenance && arg.startsWith("-")) {
|
|
937
|
+
const option = arg.split("=", 1)[0];
|
|
938
|
+
if (!NODE_SWITCH_OPTIONS.has(option) || (arg.includes("=") && !/=(?:true|false)$/u.test(arg))) return false;
|
|
939
|
+
} else if (INFRA_VALUE_OPTIONS.has(arg)) {
|
|
940
|
+
if (!args[++i]) return false;
|
|
941
|
+
} else if (arg.startsWith("-") && !arg.includes("=")) return false;
|
|
942
|
+
}
|
|
943
|
+
return dryRun;
|
|
944
|
+
}
|
|
945
|
+
// Pulumi preview/history/output are not writes. Resource updates, deletion,
|
|
946
|
+
// state refresh/import and cancelling an update retain the deployment conflict.
|
|
947
|
+
// Parse global option values atomically so --cwd up cannot become an update.
|
|
948
|
+
const PULUMI_COMMAND = /\bpulumi\b/giu;
|
|
949
|
+
const PULUMI_VALUE_OPTIONS = new Set(["--cwd", "-C", "--stack", "-s", "--color", "--profiling", "--tracing", "--verbose", "-v", "--message", "-m", "--config", "-c", "--config-file", "--parallel", "-p", "--plan", "--policy-pack", "--policy-pack-config", "--target", "--replace", "--target-replace", "--exec-agent", "--exec-kind", "--import-file", "--out", "--parent", "--provider", "--properties"]);
|
|
950
|
+
const PULUMI_SWITCH_OPTIONS = new Set(["--yes", "-y", "--skip-preview", "-f", "--expect-no-changes", "--non-interactive", "--disable-integrity-checking", "--logflow", "--logtostderr", "--debug", "-d", "--json", "-j"]);
|
|
951
|
+
const PULUMI_WRITES = new Set(["up", "update", "destroy", "refresh", "import", "cancel"]);
|
|
952
|
+
function pulumiMutation(source, text = source) {
|
|
953
|
+
for (const { args } of commandReadings(source, text, PULUMI_COMMAND, { valueOptions: PULUMI_VALUE_OPTIONS })) {
|
|
954
|
+
let action = null, help = false, unknown = false, preview = false;
|
|
955
|
+
for (let i = 0; i < args.length; i++) {
|
|
956
|
+
const arg = args[i], option = arg.value.split("=", 1)[0];
|
|
957
|
+
if (arg.value === "--help" || arg.value === "-h") { help = true; continue; }
|
|
958
|
+
if (PULUMI_VALUE_OPTIONS.has(option)) { const value = arg.value.includes("=") ? arg : args[++i]; if (!value || value.dynamic) unknown = true; continue; }
|
|
959
|
+
if (option === "--preview-only") { preview = !arg.dynamic && (!arg.value.includes("=") || /^(?:true|1)$/iu.test(arg.value.slice(arg.value.indexOf("=") + 1))); continue; }
|
|
960
|
+
if (arg.value.startsWith("-")) { if (!PULUMI_SWITCH_OPTIONS.has(option) || arg.dynamic) unknown = true; continue; }
|
|
961
|
+
if (!action) action = arg;
|
|
962
|
+
}
|
|
963
|
+
if (action && (action.dynamic || PULUMI_WRITES.has(action.value)) && !(help && !unknown) && !(action.value === "refresh" && preview && !unknown)) return true;
|
|
964
|
+
}
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
function infrastructureMutation(text, source = text) {
|
|
968
|
+
if (pulumiMutation(source, text)) return true;
|
|
969
|
+
for (const match of text.matchAll(INFRA_MUTATION)) {
|
|
970
|
+
if (featureDeliveryPrefix(deliveryPrefix(text, match.index))) continue;
|
|
971
|
+
const after = text.slice(match.index + match[0].length), stop = after.search(INFRA_COMMAND_BREAK);
|
|
972
|
+
const args = stop < 0 ? after : after.slice(0, stop);
|
|
973
|
+
if (/^(?:helm|kubectl)\b/iu.test(match[0]) && infrastructureDryRun(match[0] + args, NODE_MAINTENANCE_COMMAND.test(match[0]))) continue;
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
function landingInstruction(text) {
|
|
979
|
+
for (const match of text.matchAll(LANDING_CANDIDATE)) {
|
|
980
|
+
// Passive candidates include their subject. Feature context still ends
|
|
981
|
+
// at the landing word, just as it does for an active object candidate.
|
|
982
|
+
const word = match.index + (match[1] ? match[0].length - match[1].length : 0);
|
|
983
|
+
if (featureDeliveryPrefix(deliveryPrefix(text, word))) continue;
|
|
984
|
+
return true;
|
|
985
|
+
}
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
// A terminal stop after a Git commit withholds the push and review PR that the
|
|
989
|
+
// selected profile requires. Read both clause orders, within statement bounds;
|
|
990
|
+
// feature infinitives and database transactions retain their product meaning.
|
|
991
|
+
const STOP_AFTER_COMMIT = /\b(?:stop|pause|halt)\s+(?:right\s+|immediately\s+)?(?:after|once|when)\s+(?:[^\s;.!?]+\s+){0,6}?commit(?:ted|ting)?\b|\b(?:after|once|when)\s+(?:[^\s;.!?]+\s+){0,6}?commit(?:ted|ting)?\b(?:\s+[^\s;.!?,]+){0,6}\s*,?\s*(?:stop|pause|halt)\b/giu;
|
|
992
|
+
function stopAfterCommit(text) {
|
|
993
|
+
if (!/\bcommit(?:ted|ting)?\b/iu.test(text)) return false;
|
|
994
|
+
for (const statement of statements(text)) for (const match of statement.matchAll(STOP_AFTER_COMMIT)) {
|
|
995
|
+
const prefix = statement.slice(0, match.index);
|
|
996
|
+
if (PUBLISH_FEATURE_INFINITIVE.test(prefix) && !/\b(?:task|job)s?\s+to\s*$/iu.test(prefix)) continue;
|
|
997
|
+
const commit = match[0].match(/\bcommit(?:ted|ting)?\b/iu);
|
|
998
|
+
const object = statement.slice(match.index + commit.index + commit[0].length);
|
|
999
|
+
if (/^\s+(?:(?:the|a|this|that|our)\s+)?(?:database\s+|sql\s+)?transaction\b/iu.test(object)) continue;
|
|
1000
|
+
return true;
|
|
1001
|
+
}
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
const PUBLISH_MODIFIER_TAIL = /^\s*(?:button|command|action|feature|flag|option|toggle|status|state|date|time|event|events|endpoint|api|page|menu|dialog|workflow|pipeline|step|hook|permission|job|tab|screen|form)\b/iu;
|
|
1005
|
+
// Mandatory passive delivery is an instruction too, not descriptive metadata.
|
|
1006
|
+
const PASSIVE_PUBLICATION = new RegExp(String.raw`\b(?:(?:must|should|shall|will)\s+(?:not\s+)?be|needs?\s+to\s+be|(?:is|are)\s+required\s+to\s+be)\s+(?:published\b|(?:uploaded|submitted|pushed|posted|sent|put|released)${SUBMISSION_TARGET}${PUBLICATION_DESTINATION}\b)`, "giu");
|
|
1007
|
+
const PUBLISH_VERBS = new RegExp(String.raw`\bpublish\b|\b${SUBMISSION_VERB}${SUBMISSION_TARGET}${PUBLICATION_DESTINATION}\b|\b(?:gem|nuget|helm|docker|oras)\s+push\b|\btwine\s+upload\b`, "giu");
|
|
1008
|
+
function publishInstruction(statement, source = statement, registryMutates = registryMutation) {
|
|
1009
|
+
if (registryMutates(statement, source)) return true;
|
|
1010
|
+
for (const match of statement.matchAll(PASSIVE_PUBLICATION))
|
|
1011
|
+
if (!featureDeliveryPrefix(deliveryPrefix(statement, match.index))) return true;
|
|
1012
|
+
for (const match of statement.matchAll(PUBLISH_VERBS)) {
|
|
1013
|
+
const before = deliveryPrefix(statement, match.index), after = statement.slice(match.index + match[0].length);
|
|
1014
|
+
if (PUBLISH_MODIFIER_HEAD.test(before) || featureDeliveryPrefix(before) || PUBLISH_MODIFIER_TAIL.test(after)) continue;
|
|
1015
|
+
return true;
|
|
1016
|
+
}
|
|
1017
|
+
return false;
|
|
1018
|
+
}
|
|
1019
|
+
// Release actions keep their existing unsupported effect. Product nouns,
|
|
1020
|
+
// local resource release and read-only CLI data never supply that action.
|
|
1021
|
+
// Reuse command masking so a read-only template cannot become release prose.
|
|
1022
|
+
const RELEASE_PRESENT = /\breleas(?:e|es|ed|ing)\b/iu;
|
|
1023
|
+
const RELEASE_WORDS = /\breleas(?:e|es|ed|ing)\b/giu;
|
|
1024
|
+
const RELEASE_COMMAND = /\bgh\s+release\b(?:[ \t]*\r?\n[ \t]*(?=(?:create|edit|delete|delete-asset|upload|list|view|download|verify|verify-asset)\b))?/giu;
|
|
1025
|
+
const RELEASE_WRITES = new Set(["create", "edit", "delete", "delete-asset", "upload"]);
|
|
1026
|
+
const RELEASE_VALUE_OPTIONS = new Set(["--repo", "--notes", "--notes-file", "--title", "--target", "--tag", "--discussion-category", "--notes-start-tag", "--json", "--jq", "--template", "--limit", "--archive", "--pattern", "--dir", "--output"]);
|
|
1027
|
+
const RELEASE_SHORT_OPTIONS = { R: "--repo", n: "--notes", F: "--notes-file", t: "--title", q: "--jq", L: "--limit", h: "--help" };
|
|
1028
|
+
const RELEASE_FEATURE_TAIL = /^\s*(?:notes?|metadata|management|changelogs?|history|dashboards?|viewers?|browsers?|panels?|dialogs?|buttons?|forms?|schemas?|fields?|checklists?|(?:version|status)\s+(?:fields?|columns?|badges?|selectors?))\b/iu;
|
|
1029
|
+
const RELEASE_DETERMINERS = String.raw`(?:(?:a|an|the|this|that|new|latest|current|previous|old)\s+){0,3}`;
|
|
1030
|
+
const RELEASE_NOUN = String.raw`(?:github\s+)?releases?`;
|
|
1031
|
+
const RELEASE_LIFECYCLE = new RegExp(String.raw`\b(?:creat(?:e|ing)|cut(?:ting)?|publish(?:ing)?|draft(?:ing)?|delet(?:e|ing)|remov(?:e|ing)|edit(?:ing)?|updat(?:e|ing))\s+${RELEASE_DETERMINERS}${RELEASE_NOUN}\b`, "giu");
|
|
1032
|
+
const RELEASE_DESTINATION = new RegExp(String.raw`\b(?:upload(?:ing)?|attach(?:ing)?|submit(?:ting)?|post(?:ing)?|send(?:ing)?|push(?:ing)?)${SUBMISSION_TARGET}${RELEASE_NOUN}\b`, "giu");
|
|
1033
|
+
const RELEASE_PASSIVE = new RegExp(String.raw`\b${RELEASE_NOUN}\s+(?:(?:must|should|shall|will|needs?\s+to)\s+)?(?:be|is|are|get|gets)\s+(?:created|cut|published|drafted|deleted|removed|edited|updated)\b`, "giu");
|
|
1034
|
+
const RELEASE_REMOTE_METADATA = new RegExp(String.raw`\b(?:creat(?:e|ing)|edit(?:ing)?|updat(?:e|ing)|delet(?:e|ing)|remov(?:e|ing))\s+${RELEASE_DETERMINERS}github\s+releases?\s+(?:notes?|metadata)\b`, "giu");
|
|
1035
|
+
const RELEASE_METADATA_FEATURE = /^\s*(?:pages?|parsers?|viewers?|browsers?|panels?|dialogs?|forms?|schemas?|fields?|models?|serializers?|validators?|formatters?)\b/iu;
|
|
1036
|
+
const RELEASE_READ_HEAD = /\b(?:display|show|list|parse|read|fetch|query|inspect|render|describe|document|track|check|verify|cache|index|filter|sort|count|compare|load|serialize|validate)\s+(?:(?:the|a|an|latest|current|previous|next|new|old|stable|beta|github)\s+){0,4}$/iu;
|
|
1037
|
+
const RELEASE_FEATURE_HEAD = /\b(?:add|build|implement|fix|improve|refactor|redesign|enhance|adjust|test|update|edit|create|extend|change|alter|remove|delete|support)\s+(?:(?:the|a|an|latest|current|previous|new|old|github)\s+){0,3}$/iu;
|
|
1038
|
+
const RELEASE_NOUN_HEAD = /\b(?:the|a|an|latest|current|previous|next|new|old|stable|beta|of|for|against|on|in|github)\s*$|(?:[/:]|--)\s*$/iu;
|
|
1039
|
+
const RELEASE_RESOURCE = /^\s+(?:(?:the|a|an|this|our|your)\s+)?(?:memory|mutex(?:es)?|locks?|semaphores?|file\s+handles?|file\s+descriptors?)\b/iu;
|
|
1040
|
+
const RELEASE_PASSIVE_HEAD = /\b(?:be|been|being|is|are|was|were|gets?|got)\s*$|\b(?:have|get)\s+(?:(?:the|a|this|that|our)\s+)?(?:it|package|build|version|changes?|artifact|app)\s*$/iu;
|
|
1041
|
+
function releaseProse(text) {
|
|
1042
|
+
if (!RELEASE_PRESENT.test(text)) return false;
|
|
1043
|
+
if (lifecycleInstruction(text, [RELEASE_REMOTE_METADATA], RELEASE_METADATA_FEATURE)) return true;
|
|
1044
|
+
for (const statement of statements(text)) for (const match of statement.matchAll(RELEASE_WORDS)) {
|
|
1045
|
+
const before = deliveryPrefix(statement, match.index), after = statement.slice(match.index + match[0].length);
|
|
1046
|
+
if (/\bgh\s*$/iu.test(before) || featureDeliveryPrefix(before) || RELEASE_READ_HEAD.test(before) || RELEASE_FEATURE_HEAD.test(before) || RELEASE_RESOURCE.test(after) || /^[./_-]|^:(?=\S)/u.test(after)) continue;
|
|
1047
|
+
if (/^\s*(?:notes?|metadata)\b/iu.test(after) && !/^\s*(?:notes?|metadata)(?:\s+\S+){0,2}?\s+(?:to|onto)\b/iu.test(after)) continue;
|
|
1048
|
+
if (match[0].toLowerCase() === "released" && !RELEASE_PASSIVE_HEAD.test(before)) continue;
|
|
1049
|
+
const control = after.match(CONTROL_IN_STATEMENT);
|
|
1050
|
+
const controlledAfter = control && /^\s*(?:(?:is|are|remains?|stays?|must|should|shall|will)(?:\s+be)?\s*)?$/iu.test(after.slice(0, control.index));
|
|
1051
|
+
if (RELEASE_NOUN_HEAD.test(before) && !CONTROL_IN_STATEMENT.test(before) && !controlledAfter) continue;
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
const RELEASE_MUTATION = { command: RELEASE_COMMAND, writes: RELEASE_WRITES, valueOptions: RELEASE_VALUE_OPTIONS, shortOptions: RELEASE_SHORT_OPTIONS,
|
|
1057
|
+
verbs: [RELEASE_LIFECYCLE, RELEASE_DESTINATION], passives: [RELEASE_PASSIVE], featureTail: RELEASE_FEATURE_TAIL, proseCheck: releaseProse };
|
|
1058
|
+
function releaseInstruction(text, source = text) { return RELEASE_PRESENT.test(text) && contentMutation(source, text, RELEASE_MUTATION); }
|
|
1059
|
+
// Approval wording is an instruction to wait for someone's decision ("wait
|
|
1060
|
+
// for my approval", "get my consent", "get my authorization", "once approved")
|
|
1061
|
+
// unless it names product behaviour: an approval noun followed by a feature
|
|
1062
|
+
// noun ("an approval workflow", "the approval status column"), an approval
|
|
1063
|
+
// verb as a feature infinitive ("a button to approve comments") or "approved"
|
|
1064
|
+
// as an adjective on a product noun ("list approved expenses").
|
|
1065
|
+
const APPROVAL_WORDS = new RegExp(APPROVAL_TERM.source, "giu");
|
|
1066
|
+
const APPROVAL_FEATURE_TAIL = /^\s*(?:workflow|workflows|flow|flows|process|processes|step|steps|stage|stages|status|statuses|state|states|button|buttons|screen|screens|page|pages|form|forms|dialog|modal|queue|queues|request|requests|rule|rules|policy|policies|chain|chains|matrix|gate|gates|history|log|logs|email|emails|notification|notifications|badge|badges|column|columns|field|fields|table|tables|model|models|endpoint|endpoints|api|ui|feature|features|logic|level|levels|limit|limits|threshold|thresholds|role|roles|permission|permissions|type|types|record|records|comment|comments|action|actions|checkbox|toggle|setting|settings|icon|icons|list|lists|view|views|tab|tabs|timestamp|timestamps|date|dates|count|counts|rate|rates|flag|flags|event|events|hook|hooks|routing|system|module|service|handler|controller|component|widget|panel|dashboard|report|reports|requirement|requirements|mechanism|pipeline|tracking|audit|reminder|reminders|deadline|deadlines|sla|engine|manager|managers|reviewer|reviewers|approver|approvers|chain|template|templates|message|messages|reason|reasons|note|notes|metadata|data|entity|entities|object|objects|flow|banners?|middleware|checks?)\b/iu;
|
|
1067
|
+
const APPROVAL_PRODUCT_NOUN = /^\s*(?:expenses?|invoices?|requests?|claims?|orders?|refunds?|applications?|documents?|comments?|posts?|articles?|submissions?|items?|records?|users?|accounts?|vendors?|suppliers?|timesheets?|budgets?|purchases?|leave|transactions?|payments?|listings?|reviews?|entries|entry|drafts?|changes?\s+list|content|uploads?|images?|products?|templates?|designs?|proposals?)\b/iu;
|
|
1068
|
+
function approvalInstruction(statement) {
|
|
1069
|
+
for (const match of statement.matchAll(APPROVAL_WORDS)) {
|
|
1070
|
+
const word = match[0].toLowerCase(), after = statement.slice(match.index + match[0].length);
|
|
1071
|
+
if ((/^(?:approvals?|consents?|authori[sz]ations?|permissions?|confirmations?|sign[\s-]?off)$/u.test(word) || /\bgo[\s-]?ahead$/u.test(word)) && APPROVAL_FEATURE_TAIL.test(after)) continue;
|
|
1072
|
+
if (/^(?:approved|authori[sz]ed|consented)$/u.test(word) && APPROVAL_PRODUCT_NOUN.test(after)) continue;
|
|
1073
|
+
if (featureDeliveryPrefix(deliveryPrefix(statement, match.index))) continue;
|
|
1074
|
+
return true;
|
|
1075
|
+
}
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
const CONTROLS = /\b(?:do\s+not|don['’]t|never|not|no|without|avoid|refrain|forbid(?:den|s)?|prohibit(?:ed|s)?|only\s+after|only\s+once|until|unless|instead\s+of|skip|cannot|can['’]t|must\s+not|mustn['’]t|should\s+not|shouldn['’]t|won['’]t|will\s+not|is\s+(?:not\s+)?(?:required|mandatory|optional)|required|mandatory|out\s+of\s+scope|outside\s+(?:of\s+)?(?:the\s+|this\s+|our\s+)?scope|exclud(?:e|es|ed|ing)|except(?:\s+for)?|other\s+than|apart\s+from|aside\s+from|but\s+not|minus|leave\s+out|leaving\s+out|(?:I|we)(?:['’]ll|\s+will|\s+shall|\s+plan\s+to|\s+intend\s+to|\s+would\s+(?:rather|prefer\s+to))\s+(?:\w+\s+){0,2}?(?:open|create|raise|submit|make|file|review|comment|close|reopen|edit|lock|unlock|retarget|convert|mark|merge|land|deploy|release|publish|push|ship|handle|take\s+care\s+of|do)|let\s+(?:me|us)\s+(?:\w+\s+){0,2}?(?:open|create|raise|submit|make|file|review|comment|close|reopen|edit|lock|unlock|retarget|convert|mark|merge|land|deploy|release|publish|push|ship|handle|take\s+care\s+of|do|approve|sign\s+off|decide)|myself|ourselves|leave\s+(?:\S+\s+){0,4}?to\s+(?:me|us)|is\s+(?:mine|ours)\s+to|on\s+my\s+own|isn['’]t|aren['’]t|wasn['’]t|weren['’]t|disallow(?:ed|s)?|not\s+permitted|unauthori[sz]ed|off[\s-]limits|stop(?:s|ped|ping)?\s+(?:short\s+of|before|at|prior\s+to|right\s+before|just\s+before)|paus(?:e|es|ed|ing)\s+(?:before|at|prior\s+to)|halt(?:s|ed|ing)?\s+(?:before|at|prior\s+to)|short\s+of)\b/giu;
|
|
1079
|
+
|
|
1080
|
+
// Text that ends with a control phrase, optionally followed by an introductory
|
|
1081
|
+
// colon, comma or dash: whatever comes next is the target of that control.
|
|
1082
|
+
const CONTROL_TAIL = new RegExp(`${CONTROLS.source}[\\s:,\\-–—]*$`, "iu");
|
|
1083
|
+
// Affirmative controls state a requirement rather than a prohibition. A
|
|
1084
|
+
// negated form ("is not required") keeps its "not" and reads as negative.
|
|
1085
|
+
const AFFIRMATIVE_CONTROLS = /\b(?:(?:is|are|be|remains?|stays?)\s+(?:required|mandatory|compulsory|optional)|required|mandatory|compulsory)\b/giu;
|
|
1086
|
+
const AFFIRMATIVE_PRESENT = new RegExp(AFFIRMATIVE_CONTROLS.source, "iu");
|
|
1087
|
+
const AFFIRMATIVE_WHOLE = new RegExp(`^(?:${AFFIRMATIVE_CONTROLS.source})$`, "iu");
|
|
1088
|
+
// Dots inside registry, repository and branch names are not sentence ends.
|
|
1089
|
+
// Contrast starts a new control scope; conjunctions such as "and" retain it.
|
|
1090
|
+
const SENTENCE_BREAK = /[;!?]+|\.(?![\p{L}\p{N}_/-])|\r?\n[ \t]*\r?\n|\r?\n(?=[ \t]*(?:[-*+]|\d+[.)])\s)|\b(?:but|however|yet|nevertheless)\b/iu;
|
|
1091
|
+
const COMMAND_BREAK = new RegExp(`${SENTENCE_BREAK.source}|[\\n&|]`, "iu");
|
|
1092
|
+
const INFRA_COMMAND_BREAK = new RegExp(`${COMMAND_BREAK.source}|\\b(?:and|then|after|once|when|before)\\b`, "iu");
|
|
1093
|
+
|
|
1094
|
+
// Literal examples do not instruct. Paired quotes and code spans are masked;
|
|
1095
|
+
// unmatched openers stay visible so a quote cannot hide an instruction. A span
|
|
1096
|
+
// inside a statement that a control phrase already governs (`Do not ever
|
|
1097
|
+
// "open a pull request"`) is emphasis on the control's target, not an example,
|
|
1098
|
+
// and stays visible; over-reading such a span only costs a clarification.
|
|
1099
|
+
const CONTROL_IN_STATEMENT = new RegExp(CONTROLS.source, "iu");
|
|
1100
|
+
// Apostrophes inside words are not quote openers or closers. A quoted label
|
|
1101
|
+
// may itself contain a contraction, such as 'Don't deploy'.
|
|
1102
|
+
const LITERAL_SPAN = /`[^`]*`|"[^"]*"|[“][^”]*[”]|(?<![\p{L}\p{N}_])'(?:[^']|(?<=[\p{L}\p{N}])'(?=[\p{L}\p{N}]))*'(?![\p{L}\p{N}_])|(?<![\p{L}\p{N}_])‘(?:[^’]|(?<=[\p{L}\p{N}])’(?=[\p{L}\p{N}]))*’(?![\p{L}\p{N}_])/gu;
|
|
1103
|
+
const COMMAND_PROGRAM_VERB = String.raw`(?:run|execute|invoke)`;
|
|
1104
|
+
const COMMAND_INVOCATION_VERB = String.raw`(?:${COMMAND_PROGRAM_VERB}|issue|use|call|trigger|launch|start|perform)`;
|
|
1105
|
+
const COMMAND_INVOCATION_MODIFIERS = String.raw`(?:\s+(?:the|this|that|following|next|exact|specific|given|provided|listed|above|below|shell|terminal|cli)){0,6}`;
|
|
1106
|
+
const COMMAND_INVOCATION_LOCATION = String.raw`(?:\s+(?:below|above))?(?:\s+(?:in|from|using)\s+(?:(?:the|a|an)\s+)?(?:shell|terminal|cli))?`;
|
|
1107
|
+
const COMMAND_SPAN_PREFIX = new RegExp(String.raw`\b${COMMAND_INVOCATION_VERB}\b${COMMAND_INVOCATION_MODIFIERS}(?:\s+commands?)?${COMMAND_INVOCATION_LOCATION}\s*:?\s*(?:(?:,|and|then)\s*)*$`, "iu");
|
|
1108
|
+
// An interpreter's program argument is executable source, not a quoted
|
|
1109
|
+
// example. Preserve it for the same mutation screen as a direct command.
|
|
1110
|
+
const PROGRAM_SPAN_PREFIX = /\b(?:(?:run|execute|invoke|issue|use|call|trigger|launch|start|perform)\s+)?(?:bash|sh|dash|zsh|ksh|fish|pwsh|powershell|node|nodejs|python(?:[\d.]*)?|ruby|perl|php|deno|bun)(?:\s+--?[\w-]+(?:=[^\s'"\x60]+)?){0,8}\s+(?:-[a-zA-Z]*[cer][a-zA-Z]*|--(?:command|eval|execute))=?\s*$/iu;
|
|
1111
|
+
// env's split-string operand is another executable program argument. Keep
|
|
1112
|
+
// the same documentation-context guard as interpreters and shell programs.
|
|
1113
|
+
const ENV_SPLIT_SPAN_PREFIX = /\benv(?:\s+(?:-[i0v]|--ignore-environment|--null|--debug|(?:-u|--unset|-C|--chdir)(?:=|\s+)[^\s'"\x60]+|[a-z_][a-z0-9_]*=[^\s'"\x60]+)){0,8}\s+(?:-S|--split-string)=?\s*$/iu;
|
|
1114
|
+
const PROGRAM_DATA_ACTION = String.raw`(?:document|describe|explain)`;
|
|
1115
|
+
const PROGRAM_DATA_CREATION = String.raw`(?:add|write|create|show|provide|include|update|refactor)`;
|
|
1116
|
+
const PROGRAM_DATA_CONTENT = String.raw`(?:examples?|samples?|documentation|docs|instructions?|commands?|scripts?)`;
|
|
1117
|
+
const PROGRAM_DATA_PREFIX = new RegExp(String.raw`\b${PROGRAM_DATA_ACTION}\s*$|\b${PROGRAM_DATA_CREATION}\s+(?:(?:an?|the)\s+)?${PROGRAM_DATA_CONTENT}(?:\s+(?:of|for|using|with|about))?\s*$`, "iu");
|
|
1118
|
+
const PROGRAM_DATA_HEAD = String.raw`(?:${PROGRAM_DATA_ACTION}\b|${PROGRAM_DATA_CREATION}\s+(?:(?:an?|the)\s+)?${PROGRAM_DATA_CONTENT}\b)`;
|
|
1119
|
+
const PROGRAM_DATA_PARAGRAPH = new RegExp(String.raw`^(?:[ \t]*\r?\n)+[ \t]*${PROGRAM_DATA_HEAD}`, "iu");
|
|
1120
|
+
const PROGRAM_DATA_SENTENCE = new RegExp(String.raw`^\s+${PROGRAM_DATA_HEAD}`, "iu");
|
|
1121
|
+
const DELIVERY_SPAN_PREFIX = new RegExp(String.raw`\b(?:open|create|raise|submit|make|file|send|${PR_LIFECYCLE_ACTION}|merge|land(?:s|ed|ing)?|deploy|publish|push|commit)(?:\s+(?:a|an|the|one|two|three|four|five|single|separate|multiple|several|another|\d+)){0,3}\s*$`, "iu");
|
|
1122
|
+
const PUBLICATION_TARGET_PREFIX = new RegExp(String.raw`\b(?:${SUBMISSION_VERB}${SUBMISSION_TARGET}|${REGISTRY_REMOVAL_VERB}\s+(?:\S+\s+){0,6}?(?:from|on|in|at)\s+(?:the\s+|our\s+)?)$`, "iu");
|
|
1123
|
+
const COMMUNICATION_SPAN_PREFIX = /\b(?:post|notify)\s*$/iu;
|
|
1124
|
+
const APPROVAL_SPAN_PREFIX = /\b(?:get|obtain|seek|request|await)(?:\s+(?:my|our|your|their|the|a))?\s*$|\b(?:check|confirm|clear)\s+with\s*$|\b(?:ask|consult)\s*$/iu;
|
|
1125
|
+
const STOP_SPAN_PREFIX = /\b(?:stop|pause|halt)\s+(?:after|once|when)\s*$/iu;
|
|
1126
|
+
function quotedPrTargetSpans(source, blank, spans) {
|
|
1127
|
+
const kept = new Set();
|
|
1128
|
+
if (!PR_INSTRUCTION_PRESENT.test(blank)) return kept;
|
|
1129
|
+
const parts = []; let last = 0;
|
|
1130
|
+
for (const span of spans) { parts.push(source.slice(last, span.index), " " + span[0].slice(1, -1) + " "); last = span.index + span[0].length; }
|
|
1131
|
+
const values = parts.join("") + source.slice(last);
|
|
1132
|
+
const keep = (start, end) => {
|
|
1133
|
+
let lo = 0, hi = spans.length;
|
|
1134
|
+
while (lo < hi) { const mid = (lo + hi) >>> 1; if (spans[mid].index < start) lo = mid + 1; else hi = mid; }
|
|
1135
|
+
for (let i = lo; i < spans.length && spans[i].index < end; i++) if (spans[i].index + spans[i][0].length - 1 <= end) kept.add(spans[i].index);
|
|
1136
|
+
};
|
|
1137
|
+
for (const { start, end } of statementRanges(blank)) {
|
|
1138
|
+
let first = true;
|
|
1139
|
+
for (const match of blank.slice(start, end).matchAll(PR_INSTRUCTION)) {
|
|
1140
|
+
const at = start + match.index, after = at + match[0].length;
|
|
1141
|
+
if (featureDeliveryPrefix(deliveryPrefix(blank, at))) continue;
|
|
1142
|
+
if (first) {
|
|
1143
|
+
const leading = pullRequestQualifiers(values.slice(start, at), source.slice(start, at));
|
|
1144
|
+
if (/^[\s,]*(?:please\s*)?$/iu.test(leading.rest)) for (const range of leading.ranges) keep(start + range.start, start + range.end);
|
|
1145
|
+
first = false;
|
|
1146
|
+
}
|
|
1147
|
+
const following = pullRequestQualifiers(values.slice(after, end), source.slice(after, end));
|
|
1148
|
+
for (const range of following.ranges) keep(after + range.start, after + range.end);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
return kept;
|
|
1152
|
+
}
|
|
1153
|
+
function imperativeSpan(before, after, span, atStart) {
|
|
1154
|
+
const issueReference = ISSUE_QUOTED_REFERENCE.test(span.slice(1, -1).trim());
|
|
1155
|
+
const runReference = RUN_QUOTED_REFERENCE.test(span.slice(1, -1).trim());
|
|
1156
|
+
const codespaceReference = CODESPACE_QUOTED_REFERENCE.test(span.slice(1, -1).trim());
|
|
1157
|
+
const message = before.match(COMMUNICATION_SPAN_PREFIX);
|
|
1158
|
+
const messageCommand = message && !PUBLISH_MODIFIER_HEAD.test(before.slice(0, message.index)) && outboundCommunication(message[0] + span.slice(1, -1)) ? message : null;
|
|
1159
|
+
const program = before.match(PROGRAM_SPAN_PREFIX) || before.match(ENV_SPLIT_SPAN_PREFIX);
|
|
1160
|
+
const command = messageCommand || (codespaceReference && before.match(CODESPACE_SPAN_PREFIX)) || (runReference && before.match(RUN_SPAN_PREFIX)) || (issueReference && before.match(ISSUE_SPAN_PREFIX)) || before.match(COMMAND_SPAN_PREFIX) || program || before.match(DELIVERY_SPAN_PREFIX) || before.match(PUBLICATION_TARGET_PREFIX) || before.match(APPROVAL_SPAN_PREFIX) || before.match(STOP_SPAN_PREFIX);
|
|
1161
|
+
if (command) {
|
|
1162
|
+
const prefix = before.slice(0, command.index);
|
|
1163
|
+
if (!PUBLISH_FEATURE_INFINITIVE.test(prefix) && !EXAMPLE_INFINITIVE.test(prefix) && !(command === program && PROGRAM_DATA_PREFIX.test(prefix))) return true;
|
|
1164
|
+
}
|
|
1165
|
+
return (codespaceReference && CODESPACE_PASSIVE_AFTER.test(after)) || (runReference && RUN_PASSIVE_AFTER.test(after)) || (issueReference && ISSUE_PASSIVE_AFTER.test(after))
|
|
1166
|
+
|| /^\s*(?:must|should|shall|will)\s+be\s+(?:run|executed|invoked)\b/iu.test(after)
|
|
1167
|
+
|| (span.startsWith("`") && atStart && /^\s*(?:$|(?:after|once|when|then)\b)/iu.test(after));
|
|
1168
|
+
}
|
|
1169
|
+
export function maskLiterals(text, { syntax = false } = {}) {
|
|
1170
|
+
// Judge each span against its whole statement (before and after it) with
|
|
1171
|
+
// every span blanked, so a control word inside an example never counts and
|
|
1172
|
+
// a trailing control ("... is forbidden") still governs the span before it.
|
|
1173
|
+
const whole = String(text);
|
|
1174
|
+
const spans = [...whole.matchAll(LITERAL_SPAN)];
|
|
1175
|
+
if (!spans.length) return whole;
|
|
1176
|
+
// Repeated literal instructions have identical pure classification inputs.
|
|
1177
|
+
// Retain one exact tuple only, scoped to this masking call; control state and
|
|
1178
|
+
// later requests never share it. This preserves quote/context provenance.
|
|
1179
|
+
let lastImperative = null;
|
|
1180
|
+
const cachedImperativeSpan = (before, after, span, atStart) => {
|
|
1181
|
+
if (lastImperative && before === lastImperative.before && after === lastImperative.after && span === lastImperative.span && atStart === lastImperative.atStart) return lastImperative.result;
|
|
1182
|
+
const result = imperativeSpan(before, after, span, atStart);
|
|
1183
|
+
lastImperative = { before, after, span, atStart, result };
|
|
1184
|
+
return result;
|
|
1185
|
+
};
|
|
1186
|
+
const parts = [];
|
|
1187
|
+
let blankEnd = 0;
|
|
1188
|
+
for (const m of spans) {
|
|
1189
|
+
parts.push(whole.slice(blankEnd, m.index), " ".repeat(m[0].length));
|
|
1190
|
+
blankEnd = m.index + m[0].length;
|
|
1191
|
+
}
|
|
1192
|
+
const blank = parts.join("") + whole.slice(blankEnd);
|
|
1193
|
+
const prTargets = quotedPrTargetSpans(whole, blank, spans);
|
|
1194
|
+
const breaks = [...blank.matchAll(new RegExp(SENTENCE_BREAK.source, "giu"))];
|
|
1195
|
+
let nextBreak = 0;
|
|
1196
|
+
let out = "", last = 0, statementStart = -1;
|
|
1197
|
+
let controlled = false, command = false, prefixWhitespace = true, bareList = false, trailingCommand = false;
|
|
1198
|
+
for (let i = 0; i < spans.length; i++) {
|
|
1199
|
+
const m = spans[i];
|
|
1200
|
+
while (nextBreak < breaks.length && breaks[nextBreak].index < m.index) nextBreak++;
|
|
1201
|
+
const previous = breaks[nextBreak - 1], next = breaks[nextBreak];
|
|
1202
|
+
const start = previous ? previous.index + previous[0].length : 0, end = next?.index ?? blank.length;
|
|
1203
|
+
if (start !== statementStart) {
|
|
1204
|
+
statementStart = start;
|
|
1205
|
+
const statement = blank.slice(start, end);
|
|
1206
|
+
controlled = CONTROL_IN_STATEMENT.test(statement);
|
|
1207
|
+
bareList = !blank.slice(start, m.index).trim() && /^(?:\s|,|and\b|then\b)*(?:$|(?:after|once|when)\b)/iu.test(statement);
|
|
1208
|
+
trailingCommand = /^(?:\s|,|and\b|then\b)*(?:must|should|shall|will)\s+be\s+(?:run|executed|invoked)\b/iu.test(statement);
|
|
1209
|
+
command = false; prefixWhitespace = true;
|
|
1210
|
+
}
|
|
1211
|
+
// Adjacent outside segments are disjoint: long lists of quoted examples
|
|
1212
|
+
// do not repeatedly scan their whole statement or all preceding spans.
|
|
1213
|
+
const before = blank.slice(Math.max(start, last), m.index);
|
|
1214
|
+
const after = blank.slice(m.index + m[0].length, Math.min(end, spans[i + 1]?.index ?? end));
|
|
1215
|
+
prefixWhitespace &&= !before.trim();
|
|
1216
|
+
command = prTargets.has(m.index) || trailingCommand || (bareList && m[0].startsWith("`")) || (command && /^(?:\s|,|and\b|then\b)*$/iu.test(before)) || cachedImperativeSpan(before, after, m[0], prefixWhitespace);
|
|
1217
|
+
out += whole.slice(last, m.index);
|
|
1218
|
+
out += controlled || command ? (syntax ? " " + m[0].slice(1, -1) + " " : m[0]) : " ".repeat(m[0].length);
|
|
1219
|
+
last = m.index + m[0].length;
|
|
1220
|
+
}
|
|
1221
|
+
return out + whole.slice(last);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Control words bind within a statement. Sentence/clause punctuation and
|
|
1225
|
+
// contrast end one; additive conjunctions do not, so "do not push and open a PR" keeps both
|
|
1226
|
+
// terms under the control, and a colon that introduces a control's target
|
|
1227
|
+
// ("Do not: open a pull request") does not end the statement either.
|
|
1228
|
+
function statementRanges(text) {
|
|
1229
|
+
const ranges = [];
|
|
1230
|
+
const sentence = (start, end) => {
|
|
1231
|
+
let current = start;
|
|
1232
|
+
for (const colon of text.slice(start, end).matchAll(/:(?!\/\/)/gu)) {
|
|
1233
|
+
const at = start + colon.index;
|
|
1234
|
+
if (!CONTROL_TAIL.test(text.slice(current, at))) { ranges.push({ start: current, end: at }); current = at + 1; }
|
|
1235
|
+
}
|
|
1236
|
+
ranges.push({ start: current, end });
|
|
1237
|
+
};
|
|
1238
|
+
let start = 0;
|
|
1239
|
+
for (const boundary of text.matchAll(new RegExp(SENTENCE_BREAK.source, "giu"))) {
|
|
1240
|
+
sentence(start, boundary.index);
|
|
1241
|
+
start = boundary.index + boundary[0].length;
|
|
1242
|
+
}
|
|
1243
|
+
sentence(start, text.length);
|
|
1244
|
+
return ranges;
|
|
1245
|
+
}
|
|
1246
|
+
export function statements(text) {
|
|
1247
|
+
const source = String(text);
|
|
1248
|
+
return statementRanges(source).map(({ start, end }) => source.slice(start, end));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Keep immutable line IDs while screening the complete statements they touch.
|
|
1252
|
+
// Quoted spans cannot manufacture context boundaries. Cache shared windows so
|
|
1253
|
+
// many lines of one wrapped instruction are screened once, within input limits.
|
|
1254
|
+
export function deliveryScreens(text, requirements, target = {}) {
|
|
1255
|
+
const source = String(text);
|
|
1256
|
+
const ranges = statementRanges(source.replace(LITERAL_SPAN, span => " ".repeat(span.length)));
|
|
1257
|
+
const cache = new Map(), screens = new Map();
|
|
1258
|
+
const syntax = PRE_EXECUTION_PRESENT.test(source) ? maskLiterals(source, { syntax: true }) : undefined;
|
|
1259
|
+
const expansions = [...preExecutionExpansions(source, syntax)];
|
|
1260
|
+
// The same quote-aware argv reader owns context for every command family.
|
|
1261
|
+
// Immutable clause punctuation cannot sever a URL/path from later options,
|
|
1262
|
+
// and full-command help/read modes must not be lost to a partial clause.
|
|
1263
|
+
const commands = syntax === undefined ? [] : [...commandReadings(source, syntax, COMMAND_CONTEXT,
|
|
1264
|
+
{ newlines: true, valueOptions: PRE_EXECUTION_VALUES, shortOptions: PRE_EXECUTION_SHORT_OPTIONS })]
|
|
1265
|
+
.filter(({ match }) => !PROGRAM_DATA_PREFIX.test(deliveryPrefix(source, match.index)))
|
|
1266
|
+
.map(({ match, end }) => ({ start: match.index, end }));
|
|
1267
|
+
for (const requirement of requirements) {
|
|
1268
|
+
const span = requirement.source;
|
|
1269
|
+
const touching = span && Number.isInteger(span.start) && Number.isInteger(span.end)
|
|
1270
|
+
&& source.slice(span.start, span.end) === requirement.text
|
|
1271
|
+
? ranges.filter(range => range.end > span.start && range.start < span.end) : [];
|
|
1272
|
+
if (!touching.length) { screens.set(requirement.id, deliveryScreen(requirement.text, target)); continue; }
|
|
1273
|
+
const start = touching[0].start, end = touching.at(-1).end;
|
|
1274
|
+
let contextStart = start, contextEnd = end;
|
|
1275
|
+
for (const command of commands) if (command.end > span.start && command.start < span.end) {
|
|
1276
|
+
contextStart = Math.min(contextStart, command.start); contextEnd = Math.max(contextEnd, command.end);
|
|
1277
|
+
}
|
|
1278
|
+
// A nounless "then another for the UI" needs its prior PR reference.
|
|
1279
|
+
// Extend only across adjacent follow-ons, and only to a real creation
|
|
1280
|
+
// reading; feature requests and unrelated clauses cannot supply it.
|
|
1281
|
+
if (PR_LEADING_ELLIPSIS.test(source.slice(start, end))) {
|
|
1282
|
+
for (let i = ranges.indexOf(touching[0]) - 1; i >= 0; i--) {
|
|
1283
|
+
const previous = maskLiterals(source.slice(ranges[i].start, ranges[i].end), { syntax: true });
|
|
1284
|
+
if (!previous.trim()) continue;
|
|
1285
|
+
const { instructions, requests } = pullRequestReadings(previous);
|
|
1286
|
+
if (instructions.length || requests.length || lifecycleInstruction(previous, [PR_LEADING_REFERENCE], CONTENT_FEATURE_TAIL)) { contextStart = Math.min(contextStart, ranges[i].start); break; }
|
|
1287
|
+
if (!lifecycleInstruction(previous, [PR_LEADING_FOLLOW_ON], CONTENT_FEATURE_TAIL)) break;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const key = `${contextStart}:${contextEnd}`;
|
|
1291
|
+
if (!cache.has(key)) cache.set(key, deliveryScreen(source.slice(contextStart, contextEnd), target));
|
|
1292
|
+
const screen = cache.get(key);
|
|
1293
|
+
screens.set(requirement.id, expansions.some(effect => effect.end > span.start && effect.start < span.end) ? combineDeliveryScreens(screen, opaqueExecutionScreen()) : screen);
|
|
1294
|
+
}
|
|
1295
|
+
return screens;
|
|
1296
|
+
}
|
|
1297
|
+
// A review prohibited outright ("do not send this for review", "no code
|
|
1298
|
+
// review", "reviews are forbidden") conflicts with pull-request delivery, which
|
|
1299
|
+
// exists to request review, so it escalates and no tag can speak for it. A
|
|
1300
|
+
// review named as a condition ("until someone has reviewed it", "do not merge
|
|
1301
|
+
// until reviewed") or as optional ("a review is not required") does not. The
|
|
1302
|
+
// nearest control word in the statement decides, a preceding one first.
|
|
1303
|
+
const REVIEW_TERM = /\breview(?:s|ed|ing|er|ers)?\b/giu;
|
|
1304
|
+
const NON_PROHIBITIVE_CONTROL = /^(?:until|unless|only after|only once|is (?:not )?(?:required|mandatory|optional))$/u;
|
|
1305
|
+
function reviewProhibited(statement, matches) {
|
|
1306
|
+
const controls = matches.map(match => ({ index: match.index, text: match[0].toLowerCase().replace(/\s+/g, " ") }));
|
|
1307
|
+
if (!controls.length) return false;
|
|
1308
|
+
for (const match of statement.matchAll(REVIEW_TERM)) {
|
|
1309
|
+
const nearest = controls.filter(control => control.index < match.index).at(-1) || controls.find(control => control.index > match.index);
|
|
1310
|
+
if (nearest && !NON_PROHIBITIVE_CONTROL.test(nearest.text)) return true;
|
|
1311
|
+
}
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Retain explicit PR destinations independently of the planner's action tag.
|
|
1316
|
+
// These are comparisons with the selected target, never target selection.
|
|
1317
|
+
// Ambiguous destinations ask rather than guessing which repository or branch
|
|
1318
|
+
// the owner meant. Feature infinitives and quoted examples remain product text.
|
|
1319
|
+
const PR_INSTRUCTION = /\b(?:open(?:ing)?|creat(?:e|ing)|rais(?:e|ing)|submit(?:ting)?|mak(?:e|ing)|fil(?:e|ing)|send(?:ing)?)\s+(?:\S+\s+){0,4}?(?:pull[\s-]+requests?|prs?)\b/giu;
|
|
1320
|
+
const PR_COUNT_REQUEST = /\b(?:use|return|deliver|provide|want|need)\s+(?:\S+\s+){0,4}?(?:pull[\s-]+requests?|prs?)\b/giu;
|
|
1321
|
+
const PR_CREATE_COMMAND = /\bgh\s+pr\s+create\b/giu;
|
|
1322
|
+
const PR_INSTRUCTION_PRESENT = new RegExp(PR_INSTRUCTION.source, "iu");
|
|
1323
|
+
// Elliptical follow-ons still request another external PR, including when
|
|
1324
|
+
// punctuation moves them into their own source clause. Product noun tails
|
|
1325
|
+
// and feature context keep the same shared lifecycle boundaries.
|
|
1326
|
+
const PR_ADDITIONAL_REFERENCE = String.raw`(?:another|an?\s+(?:additional|separate|second)|one\s+more)\s+(?:pull[\s-]+requests?|prs?)\b`;
|
|
1327
|
+
const PR_ADDITIONAL = String.raw`(?:another\s+for\b|${PR_ADDITIONAL_REFERENCE})`;
|
|
1328
|
+
const PR_TEMPORAL = String.raw`(?:then|next|afterwards?|later|subsequently|after\s+that)`;
|
|
1329
|
+
const PR_CONNECTOR = String.raw`(?:and(?:\s*,?\s+${PR_TEMPORAL})?|${PR_TEMPORAL})`;
|
|
1330
|
+
const PR_FOLLOW_ON = new RegExp(String.raw`(?:\b${PR_CONNECTOR}(?:\s+|\s*,\s*)|,\s*)${PR_ADDITIONAL}`, "giu");
|
|
1331
|
+
const PR_LEADING_FOLLOW_ON = new RegExp(String.raw`^\s*(?:${PR_CONNECTOR}(?:\s+|\s*,\s*))?${PR_ADDITIONAL}`, "giu");
|
|
1332
|
+
const PR_LEADING_REFERENCE = new RegExp(String.raw`^\s*(?:${PR_CONNECTOR}(?:\s+|\s*,\s*))?${PR_ADDITIONAL_REFERENCE}`, "giu");
|
|
1333
|
+
const PR_LEADING_ELLIPSIS = new RegExp(String.raw`^\s*(?:${PR_CONNECTOR}(?:\s+|\s*,\s*))?another\s+for\b`, "iu");
|
|
1334
|
+
const PR_REQUEST_PRESENT = new RegExp(`(?:${PR_INSTRUCTION.source}|${PR_COUNT_REQUEST.source}|${PR_CREATE_COMMAND.source}|${PR_ADDITIONAL}|\\b(?:separate|multiple|several|individual|distinct)\\s+(?:pull[\\s-]+requests|prs)\\b)`, "iu");
|
|
1335
|
+
// The supported profile opens exactly one PR. Quantity and per-component
|
|
1336
|
+
// requests remain conflicts independently of an optional planner action tag.
|
|
1337
|
+
function pullRequestReadings(statement) {
|
|
1338
|
+
const instructions = [...statement.matchAll(PR_INSTRUCTION), ...statement.matchAll(PR_CREATE_COMMAND)].filter(m => !PUBLISH_FEATURE_INFINITIVE.test(statement.slice(0, m.index)));
|
|
1339
|
+
const requests = [...statement.matchAll(PR_COUNT_REQUEST)].filter(m => !PUBLISH_FEATURE_INFINITIVE.test(statement.slice(0, m.index)));
|
|
1340
|
+
return { instructions, requests };
|
|
1341
|
+
}
|
|
1342
|
+
function multiplePullRequests(text) {
|
|
1343
|
+
if (!PR_REQUEST_PRESENT.test(text)) return false;
|
|
1344
|
+
let count = 0;
|
|
1345
|
+
const featureStatements = new Set();
|
|
1346
|
+
for (const statement of statements(text)) {
|
|
1347
|
+
if (featureStatements.has(statement)) continue;
|
|
1348
|
+
if (lifecycleInstruction(statement, [count ? PR_LEADING_FOLLOW_ON : PR_LEADING_REFERENCE], CONTENT_FEATURE_TAIL)) return true;
|
|
1349
|
+
const { instructions, requests } = pullRequestReadings(statement);
|
|
1350
|
+
count += instructions.length + requests.filter(m => !instructions.some(i => i.index < m.index + m[0].length && m.index < i.index + i[0].length)).length;
|
|
1351
|
+
if (count > 1 || /^\s*(?:please\s+)?(?:separate|multiple|several|individual|distinct)\s+(?:pull[\s-]+requests|prs)\b/iu.test(statement)) return true;
|
|
1352
|
+
// Repeated feature statements contribute no creation request. Cache only
|
|
1353
|
+
// zero-count readings; repeated actual requests must still count twice.
|
|
1354
|
+
if (!instructions.length && !requests.length) featureStatements.add(statement);
|
|
1355
|
+
for (const match of [...instructions, ...requests]) {
|
|
1356
|
+
const after = statement.slice(match.index + match[0].length);
|
|
1357
|
+
if (/\b(?:two|three|four|five|six|seven|eight|nine|ten|multiple|several|another|additional|second|third|[2-9]\d*|1\d+)\b/iu.test(match[0])
|
|
1358
|
+
|| /\b(?:pull[\s-]+requests|prs)\b/iu.test(match[0])
|
|
1359
|
+
|| /^[`'"”’]*\s*(?:twice|for\s+(?:each|every)|per)\b/iu.test(after)
|
|
1360
|
+
|| /^\s*for\s+(?:(?:code|team)\s+)?review\s+(?:per|for\s+(?:each|every))\b/iu.test(after)
|
|
1361
|
+
|| lifecycleInstruction(after, [PR_FOLLOW_ON], CONTENT_FEATURE_TAIL)) return true;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
const PR_DRAFT_QUALIFIER = /^(?:as\s+(?:a\s+)?draft\b|in\s+draft\s+mode\b)/iu;
|
|
1367
|
+
const PR_BASE = /^(?:against|into|to|in|on|targeting|targets?|(?:base|target)(?:\s+branch)?\s*(?:is|to|:))\s+(?:the\s+)?(?:branch\s+)?([^\s,;!?]+)/iu;
|
|
1368
|
+
const PR_REPOSITORY = /^(?:in|to|for|on)\s+(?:the\s+)?(?:repo(?:sitory)?\s+)?(https?:\/\/[^\s,;!?]+|(?:github\.com\/)?[\w.-]+\/[\w.-]+)/iu;
|
|
1369
|
+
const PR_OTHER_REPOSITORY = /^(?:in|to|for|on)\s+(?:another|a\s+different|the\s+other)\s+repo(?:sitory)?\b/iu;
|
|
1370
|
+
const PR_MISSING_TARGET = /^(?:against|into|targeting|in|on|to)\s*(?=,|$)/iu;
|
|
1371
|
+
// Read only a contiguous sequence attached to the PR, with an optional review
|
|
1372
|
+
// purpose. Stop at the first independent action instead of borrowing its
|
|
1373
|
+
// prepositions ("run tests on Linux", "document it in docs/README.md").
|
|
1374
|
+
// Descriptive values must be one complete quoted span. Read them from source,
|
|
1375
|
+
// skip them atomically, and expose ranges only for actual target qualifiers.
|
|
1376
|
+
const PR_DESCRIPTION_HEAD = /^(?:titled|named|called|(?:with\s+(?:(?:the|a)\s+)?)?(?:title|body|description))\b(?:\s+(?:of|is)\b)?\s*/iu;
|
|
1377
|
+
const PR_DESCRIPTION_VALUE = new RegExp(`^(?:${LITERAL_SPAN.source})`, "u");
|
|
1378
|
+
function pullRequestQualifiers(text, source = text) {
|
|
1379
|
+
const targets = [], ranges = [];
|
|
1380
|
+
let rest = text;
|
|
1381
|
+
for (;;) {
|
|
1382
|
+
const tail = rest.replace(/^(?:\s+|,|and\b|for\s+(?:(?:code|team)\s+)?review\b)*/iu, "");
|
|
1383
|
+
const at = text.length - tail.length, original = source.slice(at);
|
|
1384
|
+
if (PR_DRAFT_QUALIFIER.test(tail)) return { targets, rest, ranges };
|
|
1385
|
+
const description = original.match(PR_DESCRIPTION_HEAD);
|
|
1386
|
+
if (description) {
|
|
1387
|
+
const value = original.slice(description[0].length).match(PR_DESCRIPTION_VALUE);
|
|
1388
|
+
if (!value || value[0].includes("\\")) { targets.push({ kind: "description" }); return { targets, rest, ranges }; }
|
|
1389
|
+
rest = tail.slice(description[0].length + value[0].length);
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
const other = tail.match(PR_OTHER_REPOSITORY), repo = tail.match(PR_REPOSITORY);
|
|
1393
|
+
const base = tail.match(PR_BASE), missing = tail.match(PR_MISSING_TARGET);
|
|
1394
|
+
const match = other || repo || base || missing;
|
|
1395
|
+
if (!match) return { targets, rest, ranges };
|
|
1396
|
+
targets.push(other ? { kind: "other" } : repo ? { kind: "repository", value: repo[1] } : base ? { kind: "base", value: base[1] } : { kind: "missing" });
|
|
1397
|
+
ranges.push({ start: at, end: at + match[0].length });
|
|
1398
|
+
rest = tail.slice(match[0].length);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function pullRequestTargetConflicts(statement, profile, source = statement) {
|
|
1402
|
+
const instructions = [...statement.matchAll(PR_INSTRUCTION)].filter(m => !PUBLISH_FEATURE_INFINITIVE.test(statement.slice(0, m.index)));
|
|
1403
|
+
if (!instructions.length) return [];
|
|
1404
|
+
const targets = [];
|
|
1405
|
+
for (const instruction of instructions) {
|
|
1406
|
+
const leading = pullRequestQualifiers(statement.slice(0, instruction.index), source.slice(0, instruction.index));
|
|
1407
|
+
targets.push(...(/^[\s,]*(?:please\s*)?$/iu.test(leading.rest) ? leading.targets : []), ...pullRequestQualifiers(statement.slice(instruction.index + instruction[0].length), source.slice(instruction.index + instruction[0].length)).targets);
|
|
1408
|
+
}
|
|
1409
|
+
return requestedTargetConflicts(targets, profile);
|
|
1410
|
+
}
|
|
1411
|
+
function requestedTargetConflicts(targets, { base, repository }) {
|
|
1412
|
+
const conflicts = [];
|
|
1413
|
+
for (const target of targets) {
|
|
1414
|
+
if (target.kind === "repository") {
|
|
1415
|
+
const requested = target.value.replace(/^(?:https?:\/\/)?github\.com\//iu, "").replace(/\.git$/iu, "").toLowerCase();
|
|
1416
|
+
if (requested !== repository?.toLowerCase()) conflicts.push(`repository ${JSON.stringify(requested)} differs from selected repository ${JSON.stringify(repository ?? null)}`);
|
|
1417
|
+
} else if (target.kind === "base") {
|
|
1418
|
+
if (target.value !== base) conflicts.push(`base ${JSON.stringify(target.value)} differs from selected base ${JSON.stringify(base ?? null)}`);
|
|
1419
|
+
} else if (target.kind === "head") conflicts.push(`requested PR head ${JSON.stringify(target.value)} cannot be verified as the managed task branch during preparation`);
|
|
1420
|
+
else if (target.kind === "description") conflicts.push("the PR title/body qualifier needs one complete unambiguous quoted value before its target can be checked");
|
|
1421
|
+
else if (target.kind === "other") conflicts.push("the clause names a different repository");
|
|
1422
|
+
else conflicts.push("the requested PR target is not an unambiguous repository or base");
|
|
1423
|
+
}
|
|
1424
|
+
return conflicts;
|
|
1425
|
+
}
|
|
1426
|
+
// Read command arguments without running a shell. Source and screened text
|
|
1427
|
+
// have identical offsets: only commands still visible after literal masking
|
|
1428
|
+
// contribute, while their original quotes keep title/body values atomic.
|
|
1429
|
+
const PR_CREATE_PRESENT = new RegExp(PR_CREATE_COMMAND.source, "iu");
|
|
1430
|
+
const PR_VALUE_OPTIONS = new Set(["--assignee", "--attach", "--base", "--body", "--body-file", "--head", "--label", "--milestone", "--project", "--recover", "--repo", "--reviewer", "--template", "--title"]);
|
|
1431
|
+
// The adapter creates a regular review PR. Draft and simulation
|
|
1432
|
+
// modes cannot be covered by its action tag; explicit false flags are harmless.
|
|
1433
|
+
const PR_MODE_OPTIONS = new Set(["--draft", "--dry-run"]);
|
|
1434
|
+
const PR_SWITCH_OPTIONS = new Set(["--draft", "--dry-run", "--editor", "--fill", "--fill-first", "--fill-verbose", "--help", "--no-maintainer-edit", "--web"]);
|
|
1435
|
+
const PR_SHORT_OPTIONS = { a: "--assignee", B: "--base", b: "--body", F: "--body-file", H: "--head", l: "--label", m: "--milestone", p: "--project", R: "--repo", r: "--reviewer", T: "--template", t: "--title", d: "--draft", e: "--editor", f: "--fill", h: "--help", w: "--web" };
|
|
1436
|
+
// Only update-branch extends this screen: parse the whole PR command so read-only
|
|
1437
|
+
// template values cannot supply that action, while preserving legacy lifecycle rules.
|
|
1438
|
+
const PR_BRANCH_UPDATE = { command: /\bgh\s+pr\b/giu, writes: new Set(["update-branch"]),
|
|
1439
|
+
valueOptions: new Set([...PR_VALUE_OPTIONS, "--json", "--jq", "--limit", "--state", "--search"]),
|
|
1440
|
+
shortOptions: { ...PR_SHORT_OPTIONS, q: "--jq", L: "--limit", S: "--search" }, verbs: [], passives: [] };
|
|
1441
|
+
|
|
1442
|
+
function commandArguments(source, start, end, { newlines = false, valueOptions = new Set(), shortOptions = {}, shellList = false, initialDepth = 0, enclosed = false } = {}) {
|
|
1443
|
+
const args = [], expansions = [];
|
|
1444
|
+
let value = "", quote = null, quoted = false, dynamic = false, executableExpansion = false, stopped = end, pendingValue = false;
|
|
1445
|
+
let groupDepth = initialDepth;
|
|
1446
|
+
const expansion = at => { executableExpansion = true; if (shellList) expansions.push({ start: at, end: at + 1 }); };
|
|
1447
|
+
const continuation = () => groupDepth || !value && args.at(-1)?.operator && /^(?:&&|\|\|?|\|&|[<>].*)$/u.test(args.at(-1).value);
|
|
1448
|
+
// Reuse each command's option schema before recognizing prose boundaries.
|
|
1449
|
+
// An unquoted argument such as "then" is still data after --user-agent/-A.
|
|
1450
|
+
const expectsValue = arg => {
|
|
1451
|
+
if (arg.includes("=")) return false;
|
|
1452
|
+
if (valueOptions.has(arg)) return true;
|
|
1453
|
+
if (/^-[^-]/u.test(arg)) for (let i = 1; i < arg.length; i++) {
|
|
1454
|
+
if (valueOptions.has(shortOptions[arg[i]])) return i === arg.length - 1;
|
|
1455
|
+
}
|
|
1456
|
+
return false;
|
|
1457
|
+
};
|
|
1458
|
+
const emit = () => {
|
|
1459
|
+
if (!value && !quoted) return true;
|
|
1460
|
+
if (!shellList && !enclosed && !quoted && !pendingValue && (/^(?:and|then|after|once|when|before)$/iu.test(value) || (newlines && /^(?:but|however|yet|nevertheless)$/iu.test(value)))) return false;
|
|
1461
|
+
args.push({ value, dynamic, executableExpansion }); pendingValue = !pendingValue && expectsValue(value);
|
|
1462
|
+
value = ""; quoted = false; dynamic = false; executableExpansion = false;
|
|
1463
|
+
return true;
|
|
1464
|
+
};
|
|
1465
|
+
for (let i = start; i < end; i++) {
|
|
1466
|
+
const c = source[i];
|
|
1467
|
+
// Ordinary unquoted identifier characters cannot delimit arguments or
|
|
1468
|
+
// expand shell code. Keep their hot path out of per-character regexes;
|
|
1469
|
+
// quoting, punctuation, escapes and emission still use the shared reader.
|
|
1470
|
+
if (!quote) {
|
|
1471
|
+
const code = source.charCodeAt(i);
|
|
1472
|
+
if (code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || code === 45 || code === 95) { value += c; continue; }
|
|
1473
|
+
}
|
|
1474
|
+
if (quote) {
|
|
1475
|
+
if (c === quote) quote = null;
|
|
1476
|
+
else {
|
|
1477
|
+
value += c;
|
|
1478
|
+
if (quote !== "'" && /[$`\\]/u.test(c)) dynamic = true;
|
|
1479
|
+
if (quote !== "'" && (c === "`" || c === "$" && source[i + 1] === "(")) expansion(i);
|
|
1480
|
+
if (c === "\\" && quote !== "'" && i + 1 < end) value += source[++i];
|
|
1481
|
+
}
|
|
1482
|
+
} else if (c === '"' || c === "'") { quote = c; quoted = true; }
|
|
1483
|
+
// A shell line continuation contributes no word characters. Keeping the
|
|
1484
|
+
// backslash as data would hide a subsequent real comment and its newline.
|
|
1485
|
+
else if (shellList && c === "\\" && source[i + 1] === "\n") i++;
|
|
1486
|
+
else if (c === "\\") { dynamic = true; value += c; if (i + 1 < end) value += source[++i]; }
|
|
1487
|
+
else if (shellList && /[;|&<>(){}]/u.test(c)) {
|
|
1488
|
+
// Operators start a new lexical word, even without surrounding spaces.
|
|
1489
|
+
// Comment text must never open a quote that survives into a later line.
|
|
1490
|
+
emit(); pendingValue = false;
|
|
1491
|
+
if (/[<>]/u.test(c) && source[i + 1] === "(") expansion(i);
|
|
1492
|
+
if (c === "(" || c === "{") groupDepth++;
|
|
1493
|
+
if (c === ")" || c === "}") groupDepth = Math.max(0, groupDepth - 1);
|
|
1494
|
+
const paired = /[&|<>]/u.test(c) && (source[i + 1] === c || c === "|" && source[i + 1] === "&");
|
|
1495
|
+
const operator = paired ? c + source[++i] : c;
|
|
1496
|
+
args.push({ value: operator, dynamic: false, executableExpansion: false, operator: true });
|
|
1497
|
+
}
|
|
1498
|
+
else if (shellList && c === "#" && !value && !quoted) {
|
|
1499
|
+
// A shell comment ends at the newline, not the executable command list.
|
|
1500
|
+
const newline = source.indexOf("\n", i + 1);
|
|
1501
|
+
if (newline < 0 || newline >= end) { stopped = i; break; }
|
|
1502
|
+
i = newline - 1;
|
|
1503
|
+
}
|
|
1504
|
+
// Periods are valid shell operands and filename characters. Only the
|
|
1505
|
+
// command-specific reader treats them as prose punctuation. An independent
|
|
1506
|
+
// documentation paragraph ends lexical list ownership, unless an operator
|
|
1507
|
+
// still requires continuation; later explicit invocations are read anew.
|
|
1508
|
+
else if ((!enclosed && c === "." && value && !groupDepth && !continuation() && PROGRAM_DATA_SENTENCE.test(source.slice(i + 1, end))) || (!shellList && (/[;|&!]/u.test(c) || (c === "?" && !value) || (newlines && c === "," && /\s/u.test(source[i + 1] || "")) || (c === "\n" && (!newlines || !(newlines instanceof RegExp ? newlines : /^[ \t]*--?[a-zA-Z]/u).test(source.slice(i + 1)))) || (!enclosed && c === "." && !/[\p{L}\p{N}_/-]/u.test(source[i + 1] || "") && !(/\S/u.test(source.slice(i + 1, end)) && (!value || quoted || pendingValue || /[./\\]/u.test(value) || /^\s+--?[^\s]/u.test(source.slice(i + 1, end))))))) || (shellList && !enclosed && c === "\n" && PROGRAM_DATA_PARAGRAPH.test(source.slice(i + 1)) && !continuation()) || (c === "#" && !value && !quoted)) { stopped = i; break; }
|
|
1509
|
+
else if (/\s/u.test(c)) { if (!emit()) return { args, end: i, expansions }; }
|
|
1510
|
+
else { value += c; if (/[$`\\]/u.test(c)) dynamic = true; if (c === "`" || /[$<>=]/u.test(c) && source[i + 1] === "(") expansion(i); }
|
|
1511
|
+
}
|
|
1512
|
+
if (quote) dynamic = true;
|
|
1513
|
+
emit();
|
|
1514
|
+
return { args, end: stopped, expansions };
|
|
1515
|
+
}
|
|
1516
|
+
function pullRequestCommandQualifiers(source, visible) {
|
|
1517
|
+
if (!PR_CREATE_PRESENT.test(visible)) return [];
|
|
1518
|
+
const spans = [...source.matchAll(LITERAL_SPAN)], commands = [...visible.matchAll(PR_CREATE_COMMAND)], targets = [];
|
|
1519
|
+
let spanIndex = 0;
|
|
1520
|
+
for (let commandIndex = 0; commandIndex < commands.length; commandIndex++) {
|
|
1521
|
+
const command = commands[commandIndex];
|
|
1522
|
+
if (featureDeliveryPrefix(deliveryPrefix(visible, command.index))) continue;
|
|
1523
|
+
while (spanIndex < spans.length && spans[spanIndex].index + spans[spanIndex][0].length <= command.index) spanIndex++;
|
|
1524
|
+
const span = spans[spanIndex];
|
|
1525
|
+
const end = Math.min(commands[commandIndex + 1]?.index ?? source.length, span && span.index <= command.index ? span.index + span[0].length - 1 : source.length);
|
|
1526
|
+
const { args } = commandArguments(source, command.index + command[0].length, end, { valueOptions: PR_VALUE_OPTIONS, shortOptions: PR_SHORT_OPTIONS });
|
|
1527
|
+
const read = (option, argument) => {
|
|
1528
|
+
if (PR_MODE_OPTIONS.has(option) && (!argument || argument.dynamic || !/^(?:false|0|f)$/iu.test(argument.value))) targets.push({ kind: "mode", value: option });
|
|
1529
|
+
// The managed head is allocated after approval; preparation cannot prove
|
|
1530
|
+
// any explicit head selector belongs to it, including HEAD or a fork ref.
|
|
1531
|
+
if (option === "--head") targets.push({ kind: "head", value: argument?.value ?? null });
|
|
1532
|
+
if (option === "--base" || option === "--repo") targets.push(!argument?.value || argument.dynamic || argument.value.startsWith("-") ? { kind: "missing" } : { kind: option === "--base" ? "base" : "repository", value: argument.value });
|
|
1533
|
+
};
|
|
1534
|
+
for (let i = 0; i < args.length; i++) {
|
|
1535
|
+
const arg = args[i];
|
|
1536
|
+
if (arg.value === "--") break;
|
|
1537
|
+
if (arg.dynamic) { const option = arg.value.split("=", 1)[0]; if (PR_MODE_OPTIONS.has(option)) read(option, arg); else targets.push({ kind: "missing" }); continue; }
|
|
1538
|
+
if (arg.value.startsWith("--")) {
|
|
1539
|
+
const equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
1540
|
+
if (PR_VALUE_OPTIONS.has(option)) read(option, equal < 0 ? args[++i] : { value: arg.value.slice(equal + 1), dynamic: false });
|
|
1541
|
+
else if (PR_MODE_OPTIONS.has(option)) read(option, equal < 0 ? null : { value: arg.value.slice(equal + 1), dynamic: false });
|
|
1542
|
+
else if (!PR_SWITCH_OPTIONS.has(option)) targets.push({ kind: "missing" });
|
|
1543
|
+
} else if (arg.value.startsWith("-")) {
|
|
1544
|
+
for (let j = 1; j < arg.value.length; j++) {
|
|
1545
|
+
const option = PR_SHORT_OPTIONS[arg.value[j]];
|
|
1546
|
+
if (!option) { targets.push({ kind: "missing" }); break; }
|
|
1547
|
+
if (PR_MODE_OPTIONS.has(option)) {
|
|
1548
|
+
const assigned = arg.value[j + 1] === "=";
|
|
1549
|
+
read(option, assigned ? { value: arg.value.slice(j + 2), dynamic: false } : null);
|
|
1550
|
+
if (assigned) break;
|
|
1551
|
+
}
|
|
1552
|
+
if (PR_VALUE_OPTIONS.has(option)) { read(option, j + 1 < arg.value.length ? { value: arg.value.slice(j + 1).replace(/^=/u, ""), dynamic: false } : args[++i]); break; }
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return targets;
|
|
1558
|
+
}
|
|
1559
|
+
function deliveryScreenDirect(text, { base, repository } = {}, syntax) {
|
|
1560
|
+
const source = typeof text === "string" ? text : "";
|
|
1561
|
+
const publication = publicationCommands(source, syntax ?? maskLiterals(source, { syntax: true }));
|
|
1562
|
+
const visible = publication.text;
|
|
1563
|
+
// Term discovery and statement polarity inspect the same registry command.
|
|
1564
|
+
// Reuse that work only within this synchronous screen and exact raw/visible
|
|
1565
|
+
// source pair; quoted operands and other requests never share a result.
|
|
1566
|
+
const registryResults = new Map();
|
|
1567
|
+
const registryMutates = (value, raw = value) => {
|
|
1568
|
+
let views = registryResults.get(raw);
|
|
1569
|
+
if (!views) { views = new Map(); registryResults.set(raw, views); }
|
|
1570
|
+
if (!views.has(value)) views.set(value, registryMutation(value, raw));
|
|
1571
|
+
return views.get(value);
|
|
1572
|
+
};
|
|
1573
|
+
const baseWrite = baseWritePattern(base);
|
|
1574
|
+
const passivePublication = visible.match(PASSIVE_PUBLICATION);
|
|
1575
|
+
// An existing publication write already requires the same global decision.
|
|
1576
|
+
// Keep registry-only scans for requests whose publication reader found none.
|
|
1577
|
+
const registryWrites = !publication.writes.length && registryMutates(visible, source);
|
|
1578
|
+
const activeTerms = TERMS.filter(([term, pattern]) => (term === "publish" && (publication.writes.length || passivePublication || registryWrites)) || termMatches(term, pattern, visible, baseWrite, source, base, registryMutates));
|
|
1579
|
+
const terms = activeTerms.map(([term]) => term);
|
|
1580
|
+
if (!terms.length) return { terms: [], controlled: [], affirmed: [], escalated: [], controls: [], tier: "none" };
|
|
1581
|
+
const controls = [...new Set([...visible.matchAll(CONTROLS)].map(match => match[0].toLowerCase().replace(/\s+/g, " ")))];
|
|
1582
|
+
const hasAffirmative = AFFIRMATIVE_PRESENT.test(visible);
|
|
1583
|
+
const hasTargetInstruction = terms.includes("pull_request") && PR_INSTRUCTION_PRESENT.test(visible);
|
|
1584
|
+
// A term is controlled when it shares a statement with a control word. A
|
|
1585
|
+
// publication is escalated when the statement instructs one ("Publish this
|
|
1586
|
+
// package to npm", "Submit the package to npm"), not when it names product
|
|
1587
|
+
// behaviour ("Add a publish button"); the profile never publishes anywhere.
|
|
1588
|
+
// Approval is escalated when the statement waits on someone's decision, not
|
|
1589
|
+
// when it describes an approval feature ("Add an approval workflow").
|
|
1590
|
+
// Control polarity: a statement with any negative control ("do not",
|
|
1591
|
+
// "is not required", "except") puts its terms under a prohibition; one with
|
|
1592
|
+
// only affirmative controls ("is required", "mandatory") states a
|
|
1593
|
+
// requirement, which a requested-action tag can speak for.
|
|
1594
|
+
const qualifiers = pullRequestCommandQualifiers(source, visible);
|
|
1595
|
+
const modeConflicts = qualifiers.filter(q => q.kind === "mode").map(q => `${q.value} does not create a regular review PR`);
|
|
1596
|
+
for (const instruction of visible.matchAll(PR_INSTRUCTION)) {
|
|
1597
|
+
// Regular PR wording needs no feature-context allocation for this rule.
|
|
1598
|
+
// Keep the same context guard when a draft mode is actually present.
|
|
1599
|
+
if (!/\bdraft\b/iu.test(instruction[0]) && !PR_DRAFT_QUALIFIER.test(visible.slice(instruction.index + instruction[0].length).trimStart())) continue;
|
|
1600
|
+
if (featureDeliveryPrefix(deliveryPrefix(visible, instruction.index))) continue;
|
|
1601
|
+
modeConflicts.push("the clause requests a draft PR instead of a regular review PR");
|
|
1602
|
+
}
|
|
1603
|
+
const controlled = new Set(), affirmed = new Set(), uncontrolled = new Set(), escalated = new Set(terms.filter(term => ESCALATING.has(term))), targetConflicts = requestedTargetConflicts(qualifiers.filter(q => q.kind !== "mode"), { base, repository });
|
|
1604
|
+
// A complete registry command can cross prose clause punctuation; retain
|
|
1605
|
+
// its shared reader's write verdict even when partial statements show help.
|
|
1606
|
+
if (registryWrites) escalated.add("publish");
|
|
1607
|
+
// Repeated statements contribute the same sets. Cache within this screen
|
|
1608
|
+
// only; target-conflict occurrences and whole-request PR counts stay intact.
|
|
1609
|
+
const statementTargets = new Map(); let publicationIndex = 0;
|
|
1610
|
+
for (const { start, end } of statementRanges(visible)) {
|
|
1611
|
+
const statement = visible.slice(start, end), original = source.slice(start, end);
|
|
1612
|
+
while (publication.writes[publicationIndex]?.end <= start) publicationIndex++;
|
|
1613
|
+
const publishes = publication.writes[publicationIndex]?.start < end;
|
|
1614
|
+
// Original quotes matter to command argument boundaries even when screening
|
|
1615
|
+
// exposes their content. Distinct quoted arguments cannot share a cache hit.
|
|
1616
|
+
const key = statement === original ? statement : JSON.stringify([statement, original]);
|
|
1617
|
+
if (statementTargets.has(key)) { targetConflicts.push(...statementTargets.get(key)); continue; }
|
|
1618
|
+
const targets = hasTargetInstruction ? pullRequestTargetConflicts(statement, { base, repository }, original) : [];
|
|
1619
|
+
statementTargets.set(key, targets);
|
|
1620
|
+
targetConflicts.push(...targets);
|
|
1621
|
+
if (publishes || (terms.includes("publish") && publishInstruction(statement, original, registryMutates))) escalated.add("publish");
|
|
1622
|
+
if (terms.includes("approval") && approvalInstruction(statement)) escalated.add("approval");
|
|
1623
|
+
const statementControls = [...statement.matchAll(CONTROLS)];
|
|
1624
|
+
const negative = statementControls.some(match => !AFFIRMATIVE_WHOLE.test(match[0]));
|
|
1625
|
+
const affirmative = !negative && hasAffirmative && AFFIRMATIVE_PRESENT.test(statement);
|
|
1626
|
+
if (negative && reviewProhibited(statement, statementControls)) escalated.add("review");
|
|
1627
|
+
for (const [term, pattern] of activeTerms) if ((term === "publish" && publishes) || termMatches(term, pattern, statement, baseWrite, original, base, registryMutates)) (negative ? controlled : affirmative ? affirmed : uncontrolled).add(term);
|
|
1628
|
+
}
|
|
1629
|
+
if (modeConflicts.length) { terms.push("pull_request_mode"); escalated.add("pull_request_mode"); }
|
|
1630
|
+
if (targetConflicts.length) { terms.push("pull_request_target"); escalated.add("pull_request_target"); }
|
|
1631
|
+
if (terms.includes("pull_request") && multiplePullRequests(visible)) { terms.push("pull_request_count"); escalated.add("pull_request_count"); }
|
|
1632
|
+
// Only a control that shares a statement with a delivery term asks; a
|
|
1633
|
+
// control elsewhere on the line ("Authentication is required.") does not.
|
|
1634
|
+
const tier = escalated.size || controlled.size || affirmed.size ? "question" : "assumption";
|
|
1635
|
+
const externalConflicts = [...(terms.includes("storage_mutation") ? ["outbound or unresolved object-storage transfers are not authorized by the selected delivery profile"] : []), ...(terms.includes("external_communication") ? ["outbound messages are not authorized by the selected delivery profile"] : []), ...(terms.includes("http_mutation") ? ["mutating or unresolved network requests are not authorized by the selected delivery profile"] : [])];
|
|
1636
|
+
return { terms, controlled: terms.filter(term => controlled.has(term)), affirmed: terms.filter(term => affirmed.has(term)), uncontrolled: terms.filter(term => uncontrolled.has(term)), escalated: terms.filter(term => escalated.has(term)), controls, tier, ...(targetConflicts.length ? { targetConflicts } : {}), ...(modeConflicts.length ? { modeConflicts } : {}), ...(externalConflicts.length ? { externalConflicts } : {}) };
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// Execution wrappers expose command operands, not inert option data. Reuse
|
|
1640
|
+
// the existing command reader and delivery screen for the extracted commands;
|
|
1641
|
+
// never execute them. Both traversal depth and aggregate work are bounded.
|
|
1642
|
+
const NPM_EXEC_NAMES = NPM_EFFECT_PREFIXES.filter(([name, _minimum, canonical]) => (canonical || name) === "exec")
|
|
1643
|
+
.flatMap(([name, minimum]) => Array.from({length:name.length - minimum.length + 1}, (_, i) => name.slice(0, minimum.length + i)));
|
|
1644
|
+
const NPM_EXEC_PRESENT = new RegExp(String.raw`\b(?:${NPM_EXEC_NAMES.join("|")}|npx)\b`, "iu");
|
|
1645
|
+
const NPM_EXEC_COMMAND = /\b(?:npm|npx)\b/giu;
|
|
1646
|
+
const NPM_EXEC_VALUES = new Set([...NPM_VALUE_OPTIONS, "--package", "-p", "--call", "-c", "--script-shell"]);
|
|
1647
|
+
function* npmExecutionCommands(source) {
|
|
1648
|
+
if (!NPM_EXEC_PRESENT.test(source)) return;
|
|
1649
|
+
const visible = maskLiterals(source, { syntax: true });
|
|
1650
|
+
for (const { match, args } of commandReadings(source, visible, NPM_EXEC_COMMAND, { newlines: true, valueOptions: NPM_EXEC_VALUES })) {
|
|
1651
|
+
if (PROGRAM_DATA_PREFIX.test(deliveryPrefix(source, match.index))) continue;
|
|
1652
|
+
let family = match[0].toLowerCase() === "npx" ? "exec" : null, literal = false, start = -1, call = null, help = false, unknown = args.some(arg => arg.dynamic);
|
|
1653
|
+
for (let i = 0; i < args.length; i++) {
|
|
1654
|
+
const arg = args[i], equal = arg.value.indexOf("="), option = equal < 0 ? arg.value : arg.value.slice(0, equal);
|
|
1655
|
+
if (!literal && arg.value === "--") { literal = true; continue; }
|
|
1656
|
+
if (!literal && ["--help", "-h", "--no-help"].includes(option)) {
|
|
1657
|
+
const parsed = npmBooleanArgument(args, i); i = parsed.index; unknown ||= !parsed.known;
|
|
1658
|
+
help = parsed.known && (option === "--no-help" ? !parsed.value : parsed.value); continue;
|
|
1659
|
+
}
|
|
1660
|
+
const attachedCall = !literal && arg.value.startsWith("-c") && arg.value.length > 2;
|
|
1661
|
+
if (!literal && (NPM_EXEC_VALUES.has(option) || attachedCall)) {
|
|
1662
|
+
const value = attachedCall ? { value: arg.value.slice(2), dynamic: arg.dynamic } : equal < 0 ? args[++i] : { value: arg.value.slice(equal + 1), dynamic: arg.dynamic };
|
|
1663
|
+
if (["--call", "-c"].includes(option) || attachedCall) call = value || { value: "", dynamic: true };
|
|
1664
|
+
else unknown ||= !value || value.dynamic;
|
|
1665
|
+
continue;
|
|
1666
|
+
}
|
|
1667
|
+
if (!literal && arg.value.startsWith("-")) {
|
|
1668
|
+
unknown ||= arg.dynamic;
|
|
1669
|
+
if (equal < 0 && /^(?:true|false)$/u.test(args[i + 1]?.value || "")) i++;
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
if (!family) { family = npmEffectCommand(arg.value); if (family !== "exec") break; }
|
|
1673
|
+
else if (start < 0) start = i;
|
|
1674
|
+
}
|
|
1675
|
+
if (family !== "exec") continue;
|
|
1676
|
+
// Shell expansion of any operand precedes the npm process, including
|
|
1677
|
+
// help handling. A safe child command cannot erase that prior effect.
|
|
1678
|
+
if (args.some(arg => arg.dynamic)) { yield null; continue; }
|
|
1679
|
+
if (help && !unknown) continue;
|
|
1680
|
+
const prefix = deliveryPrefix(source, match.index);
|
|
1681
|
+
if (call) {
|
|
1682
|
+
yield !call.value || call.dynamic || /[$`\\]/u.test(call.value) ? null : prefix + call.value;
|
|
1683
|
+
} else if (start >= 0) {
|
|
1684
|
+
const operands = args.slice(start);
|
|
1685
|
+
if (operands.some(arg => arg.dynamic || /[$`\\]/u.test(arg.value))) { yield null; continue; }
|
|
1686
|
+
const quoted = operands.map(arg => /^[a-zA-Z0-9_./:@%=+,-]+$/u.test(arg.value) ? arg.value : JSON.stringify(arg.value)).join(" ");
|
|
1687
|
+
yield prefix + quoted;
|
|
1688
|
+
} else yield null;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
const opaqueExecutionScreen = () => ({ terms: ["publish"], controlled: [], affirmed: [], escalated: ["publish"], controls: [], tier: "question", externalConflicts: ["The executable wrapper could not be safely inspected; clarify its exact command and external effects."] });
|
|
1692
|
+
function combineDeliveryScreens(left, right) {
|
|
1693
|
+
if (right.tier === "none") return left;
|
|
1694
|
+
if (left.tier === "none") return right;
|
|
1695
|
+
const result = { ...left, tier: left.tier === "question" || right.tier === "question" ? "question" : "assumption" };
|
|
1696
|
+
for (const key of ["terms", "controlled", "affirmed", "uncontrolled", "escalated", "controls", "targetConflicts", "modeConflicts", "externalConflicts"])
|
|
1697
|
+
if (left[key] || right[key]) result[key] = [...new Set([...(left[key] || []), ...(right[key] || [])])];
|
|
1698
|
+
return result;
|
|
1699
|
+
}
|
|
1700
|
+
// Shell expansions happen before any tool can honor help or read-only mode.
|
|
1701
|
+
// Check them once using the same quote-aware argument reader, independently of
|
|
1702
|
+
// individual command exemptions. Inspect the full command list: a separator
|
|
1703
|
+
// or shell control word cannot hide expansion in a later command. This mode
|
|
1704
|
+
// only collects lexical effects; command-specific option parsing keeps its
|
|
1705
|
+
// original boundaries. Single-quoted data and documented examples
|
|
1706
|
+
// retain their existing ownership. This check never executes owner text.
|
|
1707
|
+
const PRE_EXECUTION_COMMAND = /\b(?:npm|npx|curl|gh|git|docker|cargo|aws|gsutil|kubectl|helm|terraform|pulumi|bash|sh|zsh|node|python|ruby|perl|php|deno|bun|env|echo|printf)\b/giu;
|
|
1708
|
+
const PRE_EXECUTION_RUN = new RegExp(String.raw`\b(?:${COMMAND_INVOCATION_VERB}\b${COMMAND_INVOCATION_MODIFIERS}\s+commands?${COMMAND_INVOCATION_LOCATION}\s*:?\s*|${COMMAND_INVOCATION_VERB}\b${COMMAND_INVOCATION_MODIFIERS}\s*:\s*|${COMMAND_PROGRAM_VERB}\b\s+)(?<enclosure>(?:[({]\s*)*)(?<binary>[^\s'"\x60;|&<>(){}]+)`, "giu");
|
|
1709
|
+
const PRE_EXECUTION_PRESENT = new RegExp(String.raw`(?:${PRE_EXECUTION_COMMAND.source})|\b${COMMAND_INVOCATION_VERB}\b`, "iu");
|
|
1710
|
+
const COMMAND_CONTEXT = new RegExp(`(?:${PRE_EXECUTION_RUN.source})|(?:${PRE_EXECUTION_COMMAND.source})`, "giu");
|
|
1711
|
+
const PRE_EXECUTION_VALUES = new Set([
|
|
1712
|
+
...NPM_ADMIN_VALUE_OPTIONS, ...NPM_EXEC_VALUES, ...CURL_VALUE_OPTIONS, ...GH_API_VALUE_OPTIONS,
|
|
1713
|
+
...PR_VALUE_OPTIONS, ...STORAGE_VALUE_OPTIONS, ...LABEL_VALUE_OPTIONS, ...GIST_VALUE_OPTIONS,
|
|
1714
|
+
...PROJECT_VALUE_OPTIONS, ...CODESPACE_VALUE_OPTIONS, ...NODE_VALUE_OPTIONS, ...PULUMI_VALUE_OPTIONS,
|
|
1715
|
+
...RELEASE_VALUE_OPTIONS, ...PUSH_VALUE_OPTIONS, ...Object.values(PUBLICATION_VALUES).flatMap(values => [...values]),
|
|
1716
|
+
]);
|
|
1717
|
+
const PRE_EXECUTION_SHORT_OPTIONS = { ...PR_SHORT_OPTIONS, ...GH_API_SHORT_OPTIONS, ...CURL_SHORT_OPTIONS };
|
|
1718
|
+
function* preExecutionExpansions(source, syntax) {
|
|
1719
|
+
if (!/[$`\\]|[<>=]\(/u.test(source) || !PRE_EXECUTION_PRESENT.test(source)) return;
|
|
1720
|
+
const visible = syntax ?? maskLiterals(source, { syntax: true });
|
|
1721
|
+
for (const [pattern, text] of [[PRE_EXECUTION_COMMAND, visible], [PRE_EXECUTION_RUN, source]])
|
|
1722
|
+
for (const { match, expansions } of commandReadings(source, text, pattern, { newlines: true, valueOptions: PRE_EXECUTION_VALUES, shortOptions: PRE_EXECUTION_SHORT_OPTIONS, shellList: true })) {
|
|
1723
|
+
// A generic invocation must start in executable text. Read raw command
|
|
1724
|
+
// delimiters here so Markdown code fences cannot become shell expansion.
|
|
1725
|
+
if (!visible.slice(match.index, match.index + match[0].length).trim()) continue;
|
|
1726
|
+
if (PROGRAM_DATA_PREFIX.test(deliveryPrefix(source, match.index))) continue;
|
|
1727
|
+
const generic = pattern === PRE_EXECUTION_RUN, binary = generic ? match.groups.binary : null;
|
|
1728
|
+
if (generic && /^(?:the|a|an|this|that|these|those|our|my|all|some)$/iu.test(binary)) continue;
|
|
1729
|
+
if (/[$`\\]/u.test(match[0])) yield { start: match.index, end: match.index + match[0].length };
|
|
1730
|
+
yield* expansions;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
export function deliveryScreen(text, target = {}) {
|
|
1734
|
+
const source = typeof text === "string" ? text : "";
|
|
1735
|
+
// Reuse the exact original syntax view across direct and expansion checks.
|
|
1736
|
+
// Publication normalization has its own view and cannot change this input.
|
|
1737
|
+
const syntax = maskLiterals(source, { syntax: true });
|
|
1738
|
+
let result = deliveryScreenDirect(source, target, syntax), count = 0, bytes = 0;
|
|
1739
|
+
if (!preExecutionExpansions(source, syntax).next().done) return combineDeliveryScreens(result, opaqueExecutionScreen());
|
|
1740
|
+
const queue = [{ text: source, depth: 0 }];
|
|
1741
|
+
for (let index = 0; index < queue.length; index++) {
|
|
1742
|
+
const parent = queue[index];
|
|
1743
|
+
for (const command of npmExecutionCommands(parent.text)) {
|
|
1744
|
+
if (++count > 32 || parent.depth >= 8 || command === null || (bytes += command.length) > 128 * 1024)
|
|
1745
|
+
return combineDeliveryScreens(result, opaqueExecutionScreen());
|
|
1746
|
+
result = combineDeliveryScreens(result, deliveryScreenDirect(command, target));
|
|
1747
|
+
queue.push({ text: command, depth: parent.depth + 1 });
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
return result;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// A planner may annotate a clause with a typed delivery reading. The reading is
|
|
1754
|
+
// checked against the owner's profile; it is evidence for a question, never a
|
|
1755
|
+
// grant. Shapes outside this set are malformed output.
|
|
1756
|
+
// `restrict` reads a clause that only forbids the listed effects; `local_only`
|
|
1757
|
+
// reads a clause that forbids every external action.
|
|
1758
|
+
const TAG_ACTIONS = new Set(["pull_request", "merge", "deploy", "release", "publish", "restrict", "local_only", "wait_for_approval"]);
|
|
1759
|
+
const TAG_EFFECTS = new Set(["branch", "commit", "push", "pull_request", "merge", "deploy", "release", "publish", "direct_push"]);
|
|
1760
|
+
export function validateDeliveryTag(tag) {
|
|
1761
|
+
if (!tag || typeof tag !== "object" || Array.isArray(tag)) throw new Error("delivery tag must be an object with action and optional prohibits/completion/target");
|
|
1762
|
+
const extra = Object.keys(tag).filter(key => !["action", "prohibits", "completion", "target"].includes(key));
|
|
1763
|
+
if (extra.length) throw new Error(`delivery tag has unsupported keys: ${extra.join(", ")}`);
|
|
1764
|
+
if (!TAG_ACTIONS.has(tag.action)) throw new Error(`delivery tag action must be one of ${[...TAG_ACTIONS].join(", ")}`);
|
|
1765
|
+
if (tag.action === "restrict" && !Array.isArray(tag.prohibits)) throw new Error("a restrict delivery tag must list the prohibited effects");
|
|
1766
|
+
if (tag.prohibits !== undefined && (!Array.isArray(tag.prohibits) || tag.prohibits.length > 8 || tag.prohibits.some(effect => !TAG_EFFECTS.has(effect)) || new Set(tag.prohibits).size !== tag.prohibits.length)) throw new Error(`delivery tag prohibits must list distinct effects from ${[...TAG_EFFECTS].join(", ")}`);
|
|
1767
|
+
if (tag.completion !== undefined && !COMPLETION_CONDITIONS.includes(tag.completion)) throw new Error(`delivery tag completion must be one of ${COMPLETION_CONDITIONS.join(", ")}`);
|
|
1768
|
+
if (tag.target !== undefined) {
|
|
1769
|
+
const target = tag.target;
|
|
1770
|
+
if (!target || typeof target !== "object" || Array.isArray(target) || !Object.keys(target).length || Object.keys(target).some(key => !["repository", "base"].includes(key))) throw new Error("delivery tag target must name repository and/or base only");
|
|
1771
|
+
if (target.repository !== undefined && (typeof target.repository !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(target.repository))) throw new Error("delivery tag target repository must be owner/name");
|
|
1772
|
+
if (target.base !== undefined && (typeof target.base !== "string" || !target.base.trim() || /[\s\0]/.test(target.base) || target.base.length > 255)) throw new Error("delivery tag target base must be a nonempty branch name under 256 characters");
|
|
1773
|
+
}
|
|
1774
|
+
return { action: tag.action, ...(tag.prohibits ? { prohibits: [...tag.prohibits] } : {}), ...(tag.completion ? { completion: tag.completion } : {}), ...(tag.target ? { target: { ...tag.target } } : {}) };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// The screened terms a planner tag speaks for. A tag that names one action
|
|
1778
|
+
// cannot vouch for another action the same clause mentions.
|
|
1779
|
+
const ACTION_TERMS = { pull_request: ["pull_request"], merge: ["merge"], deploy: ["deploy"], release: ["release"], publish: ["publish"], local_only: ["local"], wait_for_approval: ["wait", "approval", "review"], restrict: [] };
|
|
1780
|
+
const EFFECT_TERMS = new Set(["branch", "commit", "push", "pull_request", "merge", "deploy", "release", "publish", "direct_push"]);
|
|
1781
|
+
export function tagTerms(tag) {
|
|
1782
|
+
return { requested: new Set(ACTION_TERMS[tag.action] || []), prohibited: new Set(tag.prohibits || []) };
|
|
1783
|
+
}
|
|
1784
|
+
// A clarification reply must itself state its typed reading. An empty
|
|
1785
|
+
// restriction or an invented action/prohibition cannot resolve an anaphoric
|
|
1786
|
+
// "do it anyway" merely because the selected profile covers that tag.
|
|
1787
|
+
export function deliveryReplyGrounded(screen, tag) {
|
|
1788
|
+
const { requested, prohibited } = tagTerms(tag);
|
|
1789
|
+
const explicitAction = [...requested].some(term => (screen.uncontrolled || []).includes(term) || (screen.affirmed || []).includes(term));
|
|
1790
|
+
const explicitRestrictions = prohibited.size > 0 && [...prohibited].every(term => (screen.controlled || []).includes(term));
|
|
1791
|
+
return tag.action === "restrict" ? explicitRestrictions : explicitAction && (prohibited.size === 0 || explicitRestrictions);
|
|
1792
|
+
}
|
|
1793
|
+
// Screened terms the tag leaves unaccounted for. A term under a control word
|
|
1794
|
+
// ("do not open a PR") is spoken for only by a prohibition; a term without one
|
|
1795
|
+
// ("merge the PR") only by the requested action. Every escalating term must be
|
|
1796
|
+
// accounted for, and every effect term that sits under a control word.
|
|
1797
|
+
export function unaccountedTerms(screen, tag) {
|
|
1798
|
+
const { requested, prohibited } = tagTerms(tag);
|
|
1799
|
+
const controlled = new Set(screen.controlled || []), affirmed = new Set(screen.affirmed || []), escalated = new Set([...(screen.escalated || []), ...screen.terms.filter(term => ESCALATING.has(term))]);
|
|
1800
|
+
return screen.terms.filter(term => {
|
|
1801
|
+
const required = escalated.has(term) || ((controlled.has(term) || affirmed.has(term)) && EFFECT_TERMS.has(term));
|
|
1802
|
+
const positive = affirmed.has(term) || (screen.uncontrolled || []).includes(term) || !controlled.has(term);
|
|
1803
|
+
const spoken = (!controlled.has(term) || prohibited.has(term)) && (!positive || requested.has(term));
|
|
1804
|
+
return required && !spoken;
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Does the typed profile already do exactly what the clause reads as asking?
|
|
1809
|
+
export function profileCovers(profile, tag) {
|
|
1810
|
+
const descriptor = actionFor(profile.action);
|
|
1811
|
+
if (!descriptor) return { covered: false, reason: "unsupported profile action" };
|
|
1812
|
+
if (tag.action !== profile.action && tag.action !== "restrict") return { covered: false, reason: `the clause reads as ${tag.action}; the profile performs ${profile.action}` };
|
|
1813
|
+
const conflicting = (tag.prohibits || []).filter(effect => descriptor.effects.includes(effect));
|
|
1814
|
+
if (conflicting.length) return { covered: false, reason: `the clause prohibits ${conflicting.join(", ")}, which ${profile.action} requires` };
|
|
1815
|
+
if (tag.completion && tag.completion !== profile.completion.condition) return { covered: false, reason: `the clause expects completion ${tag.completion}; the profile selects ${profile.completion.condition}` };
|
|
1816
|
+
if (tag.target?.repository !== undefined && tag.target.repository.toLowerCase() !== profile.target.repository.toLowerCase()) return { covered: false, reason: `the clause targets repository ${JSON.stringify(tag.target.repository)}; the profile selects ${JSON.stringify(profile.target.repository)}` };
|
|
1817
|
+
if (tag.target?.base !== undefined && tag.target.base !== profile.target.base) return { covered: false, reason: `the clause targets base ${JSON.stringify(tag.target.base)}; the profile selects ${JSON.stringify(profile.target.base)}` };
|
|
1818
|
+
return { covered: true, reason: null };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
// New IDs fit both the planner and public answer schemas. Preserve a saved,
|
|
1822
|
+
// question-owned legacy ID while it is answerable, and retain answered IDs for
|
|
1823
|
+
// provenance. The portable FNV-1a-128 suffix identifies a clause, never grants
|
|
1824
|
+
// authority; exact requirement ownership is still checked independently.
|
|
1825
|
+
export function deliveryQuestionId(requirementId, task) {
|
|
1826
|
+
const legacy = `${DELIVERY_QUESTION_PREFIX}${requirementId}`;
|
|
1827
|
+
const saved = task?.questionHistory?.[legacy], owned = task?.questionMappings?.[legacy];
|
|
1828
|
+
if (saved?.id === legacy && saved.requirements?.length === 1 && saved.requirements[0] === requirementId && owned?.length === 1 && owned[0] === requirementId && (legacy.length <= 128 || Object.hasOwn(task.answers || {}, legacy))) return legacy;
|
|
1829
|
+
if (legacy.length <= 64) return legacy;
|
|
1830
|
+
let digest = 0x6c62272e07bb014262b821756295c58dn;
|
|
1831
|
+
for (const byte of new TextEncoder().encode(requirementId)) digest = BigInt.asUintN(128, (digest ^ BigInt(byte)) * 0x1000000000000000000013bn);
|
|
1832
|
+
return `${DELIVERY_QUESTION_PREFIX}h-${digest.toString(16).padStart(32, "0")}`;
|
|
1833
|
+
}
|
|
1834
|
+
export const isDeliveryQuestion = id => typeof id === "string" && id.startsWith(DELIVERY_QUESTION_PREFIX);
|
|
1835
|
+
// Question text depends only on the immutable clause and the owner's profile,
|
|
1836
|
+
// so repeated planner turns reproduce it exactly and resumption can match it.
|
|
1837
|
+
export function deliveryQuestion(profile, requirement, task) {
|
|
1838
|
+
return { id: deliveryQuestionId(requirement.id, task), requirements: [requirement.id],
|
|
1839
|
+
text: `Clause ${requirement.id} reads as a delivery instruction. The selected delivery profile is ${describeProfile(profile)} targeting ${profile.target.repository} against ${profile.target.base} and a request cannot change it. Explicitly restate the supported delivery action or prohibited effects and the implementation scope this clause keeps, or prepare a new task with a different typed profile.` };
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
export function profileAssumption(profile, requirement, screen) {
|
|
1843
|
+
return `Clause ${requirement.id} mentions ${screen.terms.map(term => term.replace("_", " ")).join(", ")}; it is treated as product behaviour. The typed delivery profile ${describeProfile(profile)} governs every external action and is not changed by this clause.`;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// The review projection for a saved task. Legacy tasks without a typed profile
|
|
1847
|
+
// keep their original interpretation and are labelled instead of upgraded.
|
|
1848
|
+
// A saved profile is authority only for the task it was reviewed on: its target
|
|
1849
|
+
// and completion must match the task's own repository, base and completion
|
|
1850
|
+
// target exactly, so a profile copied or restored from another task fails closed.
|
|
1851
|
+
export function assertProfileBound(profile, task) {
|
|
1852
|
+
const repository = typeof task?.repo === "string" ? task.repo.toLowerCase() : null;
|
|
1853
|
+
const mismatches = [];
|
|
1854
|
+
if (profile.target.repository !== repository) mismatches.push(`repository ${JSON.stringify(profile.target.repository)} is not the task's ${JSON.stringify(repository)}`);
|
|
1855
|
+
if (profile.target.base !== task?.base) mismatches.push(`base ${JSON.stringify(profile.target.base)} is not the task's ${JSON.stringify(task?.base ?? null)}`);
|
|
1856
|
+
if (profile.completion.condition !== task?.completion?.target) mismatches.push(`completion ${JSON.stringify(profile.completion.condition)} is not the task's ${JSON.stringify(task?.completion?.target ?? null)}`);
|
|
1857
|
+
if (mismatches.length) throw profileError("TASK_PROFILE_UNSUPPORTED", `the saved delivery profile is not bound to this task (${mismatches.join("; ")}); inspect the task read-only and prepare a new task`);
|
|
1858
|
+
return profile;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
export function profileView(task) {
|
|
1862
|
+
if (!task?.deliveryProfile && !typedEraTask(task)) {
|
|
1863
|
+
const condition = task?.completion?.target || null;
|
|
1864
|
+
return { legacy: true, version: null, action: task?.ownerDelivery ? "pull_request" : null, target: null, completion: condition ? { condition } : null, supported: true, source: task?.ownerDelivery ? "owner-delivery-contract" : task?.completion ? "completion-target" : "workflow-default", label: "Historical task; its original interpretation is retained" };
|
|
1865
|
+
}
|
|
1866
|
+
try {
|
|
1867
|
+
const profile = assertProfileBound(savedProfile(task), task);
|
|
1868
|
+
const descriptor = actionFor(profile.action);
|
|
1869
|
+
return { ...profile, legacy: false, supported: true, source: "typed-profile", label: descriptor.label, mergeAuthorized: false, deployAuthorized: false };
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
return { legacy: false, version: task.deliveryProfile?.version ?? null, action: null, target: null, completion: null, supported: false, source: "typed-profile", error: { code: error.code, message: error.message } };
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function repositoryTarget(value) { if (typeof value !== "string" || !value) return null; try { return hostDefinition(value); } catch { return null; } }
|