@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +20 -0
- package/agents/gsd-doc-classifier.md +105 -0
- package/agents/gsd-doc-synthesizer.md +61 -0
- package/agents/gsd-ui-checker.md +28 -0
- package/bin/install.js +624 -456
- package/gsd-core/bin/gsd-tools.cjs +40 -1
- package/gsd-core/bin/lib/api-coverage.cjs +466 -0
- package/gsd-core/bin/lib/audit.cjs +6 -3
- package/gsd-core/bin/lib/capability-loader.cjs +11 -9
- package/gsd-core/bin/lib/capability-registry.cjs +325 -64
- package/gsd-core/bin/lib/capability-validator.cjs +1 -1
- package/gsd-core/bin/lib/capability-writer.cjs +10 -1
- package/gsd-core/bin/lib/check-command-router.cjs +242 -3
- package/gsd-core/bin/lib/commands.cjs +7 -5
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +96 -0
- package/gsd-core/bin/lib/core-utils.cjs +4 -1
- package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
- package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
- package/gsd-core/bin/lib/host-integration.cjs +16 -0
- package/gsd-core/bin/lib/init.cjs +74 -30
- package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
- package/gsd-core/bin/lib/install-engine.cjs +151 -11
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
- package/gsd-core/bin/lib/milestone.cjs +3 -3
- package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
- package/gsd-core/bin/lib/phase-id.cjs +132 -3
- package/gsd-core/bin/lib/phase.cjs +78 -16
- package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
- package/gsd-core/bin/lib/roadmap.cjs +42 -56
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
- package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
- package/gsd-core/bin/lib/spec-section.cjs +111 -0
- package/gsd-core/bin/lib/state-transition.cjs +1 -1
- package/gsd-core/bin/lib/state.cjs +24 -24
- package/gsd-core/bin/lib/surface.cjs +30 -0
- package/gsd-core/bin/lib/uat.cjs +4 -1
- package/gsd-core/bin/lib/validate.cjs +15 -6
- package/gsd-core/bin/lib/verify.cjs +33 -37
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/bin/shared/model-catalog.json +6 -6
- package/gsd-core/references/api-coverage.md +104 -0
- package/gsd-core/references/model-profiles.md +2 -2
- package/gsd-core/references/planning-config.md +2 -0
- package/gsd-core/references/specless-probe-fallback.md +172 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/workflows/audit-fix.md +9 -1
- package/gsd-core/workflows/code-review-fix.md +7 -3
- package/gsd-core/workflows/code-review.md +4 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
- package/gsd-core/workflows/execute-phase.md +1 -25
- package/gsd-core/workflows/plan-phase.md +31 -2
- package/gsd-core/workflows/quick.md +2 -2
- package/gsd-core/workflows/review.md +59 -13
- package/gsd-core/workflows/settings-advanced.md +5 -5
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/verify-phase.md +3 -2
- package/gsd-core/workflows/verify-work.md +38 -0
- package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
- package/hooks/dist/gsd-cursor-stop.js +48 -0
- package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
- package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
- package/hooks/dist/managed-hooks-registry.cjs +4 -0
- package/hooks/gsd-cursor-pre-tool.js +76 -0
- package/hooks/gsd-cursor-stop.js +48 -0
- package/hooks/gsd-cursor-subagent-start.js +50 -0
- package/hooks/gsd-cursor-subagent-stop.js +40 -0
- package/hooks/managed-hooks-registry.cjs +4 -0
- package/package.json +2 -1
- package/scripts/build-hooks.js +5 -1
- package/scripts/lint-phase-id-drift.cjs +150 -0
- package/scripts/run-tests.cjs +21 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* install-effort-resolver — install-time effort resolution (#443), extracted from
|
|
7
|
+
* the package-root `bin/install.js` (#2071).
|
|
8
|
+
*
|
|
9
|
+
* `gsd-tools effort sync` (src/commands.cts `cmdEffortSync`) must mirror what the
|
|
10
|
+
* installer writes — home `~/.gsd/defaults.json` merged with the project's
|
|
11
|
+
* `.planning/config.json` — which the runtime resolver (`resolveEffortInternal`
|
|
12
|
+
* via `loadConfig`) does NOT do. It previously reached those two functions via
|
|
13
|
+
* `require('../../../bin/install.js')`, but the installer never copies the
|
|
14
|
+
* package-root `bin/install.js` into a runtime home, so `effort sync` crashed with
|
|
15
|
+
* MODULE_NOT_FOUND in every installed runtime (#2071). Moving the logic here — a
|
|
16
|
+
* `src/*.cts` module compiled into the shipped `gsd-core/bin/lib/` tree — lets both
|
|
17
|
+
* the installer AND `effort sync` require it from a location that is always present,
|
|
18
|
+
* keeping a single source of truth (no duplication / drift).
|
|
19
|
+
*
|
|
20
|
+
* Pure with respect to config: `readGsdEffectiveEffortConfig` performs the config
|
|
21
|
+
* reads; `resolveInstallTimeEffort` is pure given a pre-merged effort object.
|
|
22
|
+
*/
|
|
23
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
24
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
25
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
|
|
27
|
+
const modelResolver = require("./model-resolver.cjs");
|
|
28
|
+
const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver;
|
|
29
|
+
/**
|
|
30
|
+
* #2517 — Read a single GSD config file (defaults.json or per-project
|
|
31
|
+
* config.json) into a plain object, returning null on missing/empty files
|
|
32
|
+
* and warning to stderr on JSON parse failures so silent corruption can't
|
|
33
|
+
* mask broken configs (review finding #5).
|
|
34
|
+
*/
|
|
35
|
+
function _readGsdConfigFile(absPath, label) {
|
|
36
|
+
if (!node_fs_1.default.existsSync(absPath))
|
|
37
|
+
return null;
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = node_fs_1.default.readFileSync(absPath, 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// #443 — model-catalog and config-defaults.manifest.json exports needed only
|
|
55
|
+
// by effort-resolution code paths (resolveInstallTimeEffort /
|
|
56
|
+
// generateCodexAgentToml / Claude .md effort injection). Loaded lazily the
|
|
57
|
+
// first time they are needed so that requiring this module in test contexts that
|
|
58
|
+
// never trigger an install does NOT produce module-load-time side effects (the
|
|
59
|
+
// manifest read + hard throw) that could alter subprocess exit codes or stderr.
|
|
60
|
+
let _gsdEffortCatalogCache = null;
|
|
61
|
+
function _getGsdEffortCatalog() {
|
|
62
|
+
if (_gsdEffortCatalogCache)
|
|
63
|
+
return _gsdEffortCatalogCache;
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module
|
|
65
|
+
const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require('./model-catalog.cjs');
|
|
66
|
+
// This module lives in gsd-core/bin/lib/, so the shared manifest is one level
|
|
67
|
+
// up in gsd-core/bin/shared/ (bin/lib → bin → shared). (In bin/install.js this
|
|
68
|
+
// path was `.., gsd-core, bin, shared` relative to the package-root bin/.)
|
|
69
|
+
const manifestPath = node_path_1.default.join(__dirname, '..', 'shared', 'config-defaults.manifest.json');
|
|
70
|
+
let manifestData;
|
|
71
|
+
try {
|
|
72
|
+
manifestData = JSON.parse(node_fs_1.default.readFileSync(manifestPath, 'utf-8'));
|
|
73
|
+
}
|
|
74
|
+
catch (_err) {
|
|
75
|
+
// Fail loudly — a missing manifest is a broken install, not a soft degradation.
|
|
76
|
+
throw new Error(`gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}`);
|
|
77
|
+
}
|
|
78
|
+
const tierDefaults = (manifestData.effort &&
|
|
79
|
+
manifestData.effort.routing_tier_defaults &&
|
|
80
|
+
typeof manifestData.effort.routing_tier_defaults === 'object' &&
|
|
81
|
+
!Array.isArray(manifestData.effort.routing_tier_defaults))
|
|
82
|
+
? manifestData.effort.routing_tier_defaults
|
|
83
|
+
: { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid
|
|
84
|
+
const effortDefault = (manifestData.effort && typeof manifestData.effort.default === 'string')
|
|
85
|
+
? manifestData.effort.default
|
|
86
|
+
: 'high'; // guard: unreachable if manifest is valid
|
|
87
|
+
_gsdEffortCatalogCache = {
|
|
88
|
+
AGENT_DEFAULT_TIERS,
|
|
89
|
+
renderEffortForRuntime,
|
|
90
|
+
EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults,
|
|
91
|
+
EFFORT_MANIFEST_DEFAULT: effortDefault,
|
|
92
|
+
};
|
|
93
|
+
return _gsdEffortCatalogCache;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* #443 — Read the merged `effort` config block for install-time effort resolution.
|
|
97
|
+
*
|
|
98
|
+
* Probes the same config sources as readGsdRuntimeProfileResolver (per-project
|
|
99
|
+
* `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the
|
|
100
|
+
* `effort` object instead of the model-profile fields.
|
|
101
|
+
*
|
|
102
|
+
* Returns the merged `effort` object or null when neither source defines one.
|
|
103
|
+
* The caller can pass this to resolveInstallTimeEffort() which is pure and
|
|
104
|
+
* requires no filesystem access beyond what this helper already performs.
|
|
105
|
+
*
|
|
106
|
+
* @param targetDir Runtime install root (walks up to find .planning/).
|
|
107
|
+
*/
|
|
108
|
+
function readGsdEffectiveEffortConfig(targetDir = null) {
|
|
109
|
+
const homeDefaults = _readGsdConfigFile(node_path_1.default.join(node_os_1.default.homedir(), '.gsd', 'defaults.json'), '~/.gsd/defaults.json');
|
|
110
|
+
let projectConfig = null;
|
|
111
|
+
if (targetDir) {
|
|
112
|
+
let probeDir = node_path_1.default.resolve(targetDir);
|
|
113
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
114
|
+
const candidate = node_path_1.default.join(probeDir, '.planning', 'config.json');
|
|
115
|
+
if (node_fs_1.default.existsSync(candidate)) {
|
|
116
|
+
projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
const parent = node_path_1.default.dirname(probeDir);
|
|
120
|
+
if (parent === probeDir)
|
|
121
|
+
break;
|
|
122
|
+
probeDir = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort))
|
|
126
|
+
? homeDefaults.effort
|
|
127
|
+
: null;
|
|
128
|
+
const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort))
|
|
129
|
+
? projectConfig.effort
|
|
130
|
+
: null;
|
|
131
|
+
if (!homeEffort && !projectEffort)
|
|
132
|
+
return null;
|
|
133
|
+
// Per-project wins on conflict within each sub-field. Merge field-by-field so
|
|
134
|
+
// a project config that only sets agent_overrides still inherits global
|
|
135
|
+
// routing_tier_defaults and default.
|
|
136
|
+
return {
|
|
137
|
+
...(homeEffort || {}),
|
|
138
|
+
...(projectEffort || {}),
|
|
139
|
+
// Deep-merge agent_overrides (project wins per-key)
|
|
140
|
+
agent_overrides: {
|
|
141
|
+
...((homeEffort && homeEffort.agent_overrides) || {}),
|
|
142
|
+
...((projectEffort && projectEffort.agent_overrides) || {}),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* #443 — Resolve install-time effort for a given agent, using the same
|
|
148
|
+
* precedence chain as resolveEffortInternal() in core.cjs, but operating
|
|
149
|
+
* on a pre-loaded effortCfg object (no loadConfig side-effects at install).
|
|
150
|
+
*
|
|
151
|
+
* Precedence (mirrors resolveEffortInternal):
|
|
152
|
+
* 1. effortCfg.agent_overrides[agentName]
|
|
153
|
+
* 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present)
|
|
154
|
+
* — OR manifest tier defaults when effortCfg is null
|
|
155
|
+
* 3. effortCfg.default
|
|
156
|
+
* 4. 'high' (hardcoded fallback)
|
|
157
|
+
*
|
|
158
|
+
* @param effortCfg Result of readGsdEffectiveEffortConfig().
|
|
159
|
+
* @param agentName e.g. 'gsd-planner'
|
|
160
|
+
* @returns Universal effort string (low/medium/high/xhigh/max/minimal)
|
|
161
|
+
*/
|
|
162
|
+
function resolveInstallTimeEffort(effortCfg, agentName) {
|
|
163
|
+
// Validates each candidate against the canonical EFFORT_SET (sourced once
|
|
164
|
+
// from model-resolver.cjs) before accepting it, mirroring resolveEffortInternal
|
|
165
|
+
// exactly. Invalid values fall through to the next precedence layer; final
|
|
166
|
+
// fallback 'high'.
|
|
167
|
+
// Step 1: agent_overrides
|
|
168
|
+
if (effortCfg) {
|
|
169
|
+
const ao = effortCfg.agent_overrides;
|
|
170
|
+
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
|
171
|
+
const v = ao[agentName];
|
|
172
|
+
if (typeof v === 'string' && GSD_EFFORT_SET.has(v))
|
|
173
|
+
return v;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Step 2: routing_tier_defaults keyed by the agent's catalog tier
|
|
177
|
+
const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog();
|
|
178
|
+
const agentTier = AGENT_DEFAULT_TIERS[agentName];
|
|
179
|
+
if (agentTier) {
|
|
180
|
+
if (effortCfg && effortCfg.routing_tier_defaults &&
|
|
181
|
+
typeof effortCfg.routing_tier_defaults === 'object' &&
|
|
182
|
+
!Array.isArray(effortCfg.routing_tier_defaults)) {
|
|
183
|
+
const v = effortCfg.routing_tier_defaults[agentTier];
|
|
184
|
+
if (typeof v === 'string' && GSD_EFFORT_SET.has(v))
|
|
185
|
+
return v;
|
|
186
|
+
}
|
|
187
|
+
else if (!effortCfg) {
|
|
188
|
+
// No effort config — use manifest tier defaults
|
|
189
|
+
const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier];
|
|
190
|
+
if (typeof v === 'string' && GSD_EFFORT_SET.has(v))
|
|
191
|
+
return v;
|
|
192
|
+
}
|
|
193
|
+
// effortCfg exists but has no routing_tier_defaults — fall through
|
|
194
|
+
}
|
|
195
|
+
// Step 3: effort.default
|
|
196
|
+
if (effortCfg) {
|
|
197
|
+
const d = effortCfg.default;
|
|
198
|
+
if (typeof d === 'string' && GSD_EFFORT_SET.has(d))
|
|
199
|
+
return d;
|
|
200
|
+
}
|
|
201
|
+
// Step 4: manifest default (sourced from config-defaults.manifest.json effort.default)
|
|
202
|
+
// If even the manifest default is invalid, fall back to 'high'.
|
|
203
|
+
if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) {
|
|
204
|
+
return EFFORT_MANIFEST_DEFAULT;
|
|
205
|
+
}
|
|
206
|
+
return 'high';
|
|
207
|
+
}
|
|
208
|
+
module.exports = {
|
|
209
|
+
readGsdEffectiveEffortConfig,
|
|
210
|
+
resolveInstallTimeEffort,
|
|
211
|
+
_getGsdEffortCatalog,
|
|
212
|
+
_readGsdConfigFile,
|
|
213
|
+
};
|
|
@@ -26,6 +26,7 @@ const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
|
|
|
26
26
|
const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
|
|
27
27
|
const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
|
|
28
28
|
const runtimeNamePolicy = require("./runtime-name-policy.cjs");
|
|
29
|
+
const installProfiles = require("./install-profiles.cjs");
|
|
29
30
|
const { processAttribution } = runtimeArtifactConversion;
|
|
30
31
|
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
|
31
32
|
// test stubs that monkeypatch the module's exports are seen at call time.
|
|
@@ -51,6 +52,25 @@ const { getDirName } = runtimeNamePolicy;
|
|
|
51
52
|
*/
|
|
52
53
|
const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
|
|
53
54
|
// ---------------------------------------------------------------------------
|
|
55
|
+
// Host-behavior helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Host-specific install behaviors declared on the runtime descriptor
|
|
59
|
+
* (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
|
|
60
|
+
* Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
|
|
61
|
+
* {} for runtimes that declare none or if the registry fails to load, so
|
|
62
|
+
* every behavior branch degrades to the generic path by default.
|
|
63
|
+
*/
|
|
64
|
+
function _hostBehaviors(runtime) {
|
|
65
|
+
try {
|
|
66
|
+
const reg = require('./capability-registry.cjs');
|
|
67
|
+
return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
54
74
|
// Conversion helpers
|
|
55
75
|
// ---------------------------------------------------------------------------
|
|
56
76
|
/**
|
|
@@ -231,14 +251,20 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
|
|
|
231
251
|
if (configDir === undefined) {
|
|
232
252
|
throw new Error('_copyStaged: configDir (install root) is required to confine writes — refusing to write');
|
|
233
253
|
}
|
|
254
|
+
// The install root is normally configDir, but a kind may declare an alternate
|
|
255
|
+
// `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in
|
|
256
|
+
// that case this defense-in-depth check must confine against the resolved
|
|
257
|
+
// alternate root instead, matching the upstream gate's own root selection in
|
|
258
|
+
// createRuntimeArtifactInstallPlan.
|
|
259
|
+
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
|
234
260
|
// Strict-subpath + NUL containment via the canonical gate (shared with the
|
|
235
261
|
// layout-driven install plan); throws if destDir escapes the install root.
|
|
236
|
-
// destDir here is an absolute path; path.resolve(
|
|
237
|
-
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(
|
|
238
|
-
// Symlink-escape guard: reject if any path component between
|
|
239
|
-
// destDir is a symlink that would redirect writes outside
|
|
240
|
-
if (hasExistingSymlinkBetween(node_path_1.default.resolve(
|
|
241
|
-
throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${
|
|
262
|
+
// destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
|
|
263
|
+
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
|
|
264
|
+
// Symlink-escape guard: reject if any path component between the install root and
|
|
265
|
+
// destDir is a symlink that would redirect writes outside the install root.
|
|
266
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest)) {
|
|
267
|
+
throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`);
|
|
242
268
|
}
|
|
243
269
|
// Use the validated absolute path for the actual writes below.
|
|
244
270
|
destDir = resolvedDest;
|
|
@@ -519,6 +545,15 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
|
|
|
519
545
|
* @param resolveAttribution injection: (runtime) => attribution string | undefined
|
|
520
546
|
*/
|
|
521
547
|
function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
|
|
548
|
+
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
|
|
549
|
+
// the dedicated combined commands+skills+plugin orchestrator instead of the
|
|
550
|
+
// generic layout-driven loop below, mirroring the bespoke install path that
|
|
551
|
+
// previously lived inline in bin/install.js.
|
|
552
|
+
const behaviors = _hostBehaviors(runtime);
|
|
553
|
+
if (behaviors.combinedFamilyInstall) {
|
|
554
|
+
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
522
557
|
// Legacy cleanup before layout-driven writes
|
|
523
558
|
_runLegacyInstallMigrations(runtime, configDir, scope);
|
|
524
559
|
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
|
|
@@ -543,10 +578,16 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
|
|
|
543
578
|
throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
|
|
544
579
|
const dest = item.destDir;
|
|
545
580
|
// Symlink-escape guard: reject before mkdir if dest (or any component
|
|
546
|
-
// between
|
|
547
|
-
// mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
|
548
|
-
|
|
549
|
-
|
|
581
|
+
// between the install root and dest) is a symlink pointing outside that
|
|
582
|
+
// root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
|
|
583
|
+
// call. The install root is normally configDir, but a kind may declare
|
|
584
|
+
// an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
|
|
585
|
+
// $HOME/.agents) — in that case the guard must check against the
|
|
586
|
+
// resolved alternate root instead, matching assertDestWithinConfigHome's
|
|
587
|
+
// own root selection in createRuntimeArtifactInstallPlan.
|
|
588
|
+
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
|
|
589
|
+
if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest)) {
|
|
590
|
+
throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`);
|
|
550
591
|
}
|
|
551
592
|
node_fs_1.default.mkdirSync(dest, { recursive: true });
|
|
552
593
|
if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
|
|
@@ -638,7 +679,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
638
679
|
const rawDir = rawCommandsDir;
|
|
639
680
|
if (!rawDir || !node_fs_1.default.existsSync(rawDir))
|
|
640
681
|
return 0;
|
|
641
|
-
const converter = runtime === 'kilo'
|
|
682
|
+
const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
|
642
683
|
? convertClaudeCommandToKiloSkill
|
|
643
684
|
: convertClaudeCommandToOpencodeSkill;
|
|
644
685
|
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
|
|
@@ -686,6 +727,102 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
|
|
|
686
727
|
return count;
|
|
687
728
|
}
|
|
688
729
|
// ---------------------------------------------------------------------------
|
|
730
|
+
// installOpencodeFamilyCommands
|
|
731
|
+
// ---------------------------------------------------------------------------
|
|
732
|
+
/**
|
|
733
|
+
* Install the flattened commands surface for an OpenCode-family runtime
|
|
734
|
+
* (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
|
|
735
|
+
* per-runtime frontmatter conversion and path-prefix/attribution rewrites.
|
|
736
|
+
*
|
|
737
|
+
* Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
|
|
738
|
+
* #2087), except attribution is resolved via the injected
|
|
739
|
+
* `resolveAttribution` callback instead of a module-level getCommitAttribution.
|
|
740
|
+
*
|
|
741
|
+
* @param runtime - 'opencode' or 'kilo'
|
|
742
|
+
* @param destDir - destination directory for flattened commands (recurses with the same destDir)
|
|
743
|
+
* @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
|
|
744
|
+
* @param pathPrefix - computed config-path prefix for body rewrites
|
|
745
|
+
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
746
|
+
* @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
|
|
747
|
+
*/
|
|
748
|
+
function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, resolveAttribution = () => undefined, prefix = 'gsd') {
|
|
749
|
+
if (!node_fs_1.default.existsSync(srcDir))
|
|
750
|
+
return;
|
|
751
|
+
// Remove old gsd-*.md files before copying new ones
|
|
752
|
+
if (node_fs_1.default.existsSync(destDir)) {
|
|
753
|
+
for (const file of node_fs_1.default.readdirSync(destDir)) {
|
|
754
|
+
if (file.startsWith(`${prefix}-`) && file.endsWith('.md'))
|
|
755
|
+
node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
node_fs_1.default.mkdirSync(destDir, { recursive: true });
|
|
760
|
+
}
|
|
761
|
+
for (const entry of node_fs_1.default.readdirSync(srcDir, { withFileTypes: true })) {
|
|
762
|
+
const srcPath = node_path_1.default.join(srcDir, entry.name);
|
|
763
|
+
if (entry.isDirectory()) {
|
|
764
|
+
installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
|
|
765
|
+
}
|
|
766
|
+
else if (entry.name.endsWith('.md')) {
|
|
767
|
+
const baseName = entry.name.replace('.md', '');
|
|
768
|
+
const destName = `${prefix}-${baseName}.md`;
|
|
769
|
+
let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
|
|
770
|
+
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
|
771
|
+
content = processAttribution(content, resolveAttribution(runtime));
|
|
772
|
+
content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
|
|
773
|
+
? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
|
|
774
|
+
: runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
|
|
775
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, destName), content);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
// installOpencodeFamilyArtifacts
|
|
781
|
+
// ---------------------------------------------------------------------------
|
|
782
|
+
/**
|
|
783
|
+
* Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
|
|
784
|
+
* Stages the flattened commands surface + skills surface + (OpenCode only)
|
|
785
|
+
* native plugin adapter, mirroring the bespoke `else if (isOpencode ||
|
|
786
|
+
* isKilo)` block previously inlined in bin/install.js.
|
|
787
|
+
*
|
|
788
|
+
* @param runtime - 'opencode' or 'kilo'
|
|
789
|
+
* @param configDir - resolved runtime config directory
|
|
790
|
+
* @param scope - install scope ('global' | 'local')
|
|
791
|
+
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
|
|
792
|
+
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
|
793
|
+
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
|
|
794
|
+
*/
|
|
795
|
+
function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}) {
|
|
796
|
+
const isGlobal = scope === 'global';
|
|
797
|
+
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
|
|
798
|
+
// (via the .gsd-source marker or a walk-up from __dirname) — every other
|
|
799
|
+
// call site in runtime-artifact-layout.cts feeds its return value straight
|
|
800
|
+
// into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
|
|
801
|
+
// root (needed below for the native plugin source) is two levels up.
|
|
802
|
+
const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
|
|
803
|
+
const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
|
|
804
|
+
const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
|
|
805
|
+
const pathPrefix = runtimeArtifactConversion._computePathPrefix({
|
|
806
|
+
isGlobal,
|
|
807
|
+
isOpencode: behaviors.skipHomePrefixSubstitution === true,
|
|
808
|
+
isWindowsHost: process.platform === 'win32',
|
|
809
|
+
resolvedTarget: node_path_1.default.resolve(configDir).replace(/\\/g, '/'),
|
|
810
|
+
homeDir: node_os_1.default.homedir().replace(/\\/g, '/'),
|
|
811
|
+
});
|
|
812
|
+
const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
|
|
813
|
+
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
|
814
|
+
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
|
815
|
+
const np = behaviors.nativePlugin;
|
|
816
|
+
if (np && np.source) {
|
|
817
|
+
const pluginSrc = node_path_1.default.join(src, np.source);
|
|
818
|
+
if (node_fs_1.default.existsSync(pluginSrc)) {
|
|
819
|
+
const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
|
|
820
|
+
node_fs_1.default.mkdirSync(destDir, { recursive: true });
|
|
821
|
+
node_fs_1.default.copyFileSync(pluginSrc, node_path_1.default.join(destDir, np.file));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
689
826
|
// uninstallRuntimeArtifacts
|
|
690
827
|
// ---------------------------------------------------------------------------
|
|
691
828
|
/**
|
|
@@ -740,6 +877,9 @@ module.exports = {
|
|
|
740
877
|
installRuntimeArtifacts,
|
|
741
878
|
uninstallRuntimeArtifacts,
|
|
742
879
|
installOpencodeFamilySkills,
|
|
880
|
+
installOpencodeFamilyCommands,
|
|
881
|
+
installOpencodeFamilyArtifacts,
|
|
882
|
+
_hostBehaviors,
|
|
743
883
|
_copyStaged,
|
|
744
884
|
hasExistingSymlinkBetween,
|
|
745
885
|
preserveUserArtifacts,
|
|
@@ -37,7 +37,11 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
|
|
|
37
37
|
'hooks/gsd-config-reload.js',
|
|
38
38
|
'hooks/gsd-context-monitor.js',
|
|
39
39
|
'hooks/gsd-cursor-post-tool.js',
|
|
40
|
+
'hooks/gsd-cursor-pre-tool.js',
|
|
40
41
|
'hooks/gsd-cursor-session-start.js',
|
|
42
|
+
'hooks/gsd-cursor-stop.js',
|
|
43
|
+
'hooks/gsd-cursor-subagent-start.js',
|
|
44
|
+
'hooks/gsd-cursor-subagent-stop.js',
|
|
41
45
|
'hooks/gsd-ensure-canonical-path.js',
|
|
42
46
|
'hooks/gsd-graphify-update.sh',
|
|
43
47
|
'hooks/gsd-phase-boundary.sh',
|
|
@@ -401,6 +401,25 @@ function renderLoopHooks(resolved) {
|
|
|
401
401
|
* Missing <capId> value → coreError + non-zero exit.
|
|
402
402
|
* Unknown/inactive capId → `false` (not an error).
|
|
403
403
|
*/
|
|
404
|
+
// #2009: a capability id surfaced inside the runnable `gsd capability remove <id>`
|
|
405
|
+
// remediation must match the canonical kebab-case id shape (identical to
|
|
406
|
+
// capability-consent.cts / capability-ledger.cts) before it is embedded — a raw
|
|
407
|
+
// overlay directory name is attacker-controlled and can carry shell/markdown
|
|
408
|
+
// metacharacters (backticks, ';', '|', '$()'). An id that fails this check is
|
|
409
|
+
// withheld and no runnable command is rendered for it.
|
|
410
|
+
const LOAD_FAIL_CAP_ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
411
|
+
// #2009: neutralize control chars, newlines, and backticks from a third-party
|
|
412
|
+
// load-failure reason so a malicious manifest cannot break out of the warning
|
|
413
|
+
// line or inject markdown / prompt content into the surfaced message.
|
|
414
|
+
function sanitizeLoadFailReason(reason) {
|
|
415
|
+
const cleaned = String(reason)
|
|
416
|
+
// Strip C0 control chars, DEL, and backticks; collapse remaining whitespace.
|
|
417
|
+
.replace(/[\x00-\x1F\x7F`]/g, ' ')
|
|
418
|
+
.replace(/\s+/g, " ")
|
|
419
|
+
.trim()
|
|
420
|
+
.slice(0, 300);
|
|
421
|
+
return cleaned || '(no reason given)';
|
|
422
|
+
}
|
|
404
423
|
function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
|
|
405
424
|
if (!point) {
|
|
406
425
|
coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
|
|
@@ -456,26 +475,54 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
|
|
|
456
475
|
coreError(msg);
|
|
457
476
|
return;
|
|
458
477
|
}
|
|
459
|
-
// ── ADR-1244 D2
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
478
|
+
// ── ADR-1244 D2: load-failed capability gates FAIL OPEN with a loud warning ────
|
|
479
|
+
// Decision (#2009): a capability that failed to LOAD must not block the loop.
|
|
480
|
+
// The prior behavior injected a BLOCKING synthetic gate (blocking:true,
|
|
481
|
+
// onError:'halt') at every point where the skipped cap declared a gate, so a
|
|
482
|
+
// single incompatible capability halted every ship:pre / verify:post
|
|
483
|
+
// project-wide for a load error unrelated to what the gate checked — with no
|
|
484
|
+
// remediation surfaced. We now fail OPEN: no gate is injected (the loop proceeds
|
|
485
|
+
// and `--active-cap <failed-cap>` correctly reports it inactive), and a loud
|
|
486
|
+
// warning is emitted instead — to STDERR (which the operator, or the agent
|
|
487
|
+
// running the command, actually sees regardless of how the host workflow
|
|
488
|
+
// consumes stdout) AND in the envelope's `warnings` channel for structured
|
|
489
|
+
// consumers. The warning names the load reason and the exact
|
|
490
|
+
// `gsd capability remove <id>` remediation so the operator can clear the broken
|
|
491
|
+
// capability. blockedGates is still recorded by the loader; only the consequence
|
|
492
|
+
// changes from block to warn. step/contribution overlays were already skip-open.
|
|
493
|
+
//
|
|
494
|
+
// The gate injection was dropped rather than made non-blocking because no host
|
|
495
|
+
// workflow generically surfaces an arbitrary gate's message at ship:pre /
|
|
496
|
+
// verify:post (consumers dispatch on specific capIds / ref.skills), and the
|
|
497
|
+
// generic gate consumers expect an object-shaped `check`, not a prose string —
|
|
498
|
+
// so an injected advisory gate would be silently dropped or mis-dispatched. A
|
|
499
|
+
// stderr warning is the channel that is actually surfaced. (See #2009 review.)
|
|
464
500
|
const overlayMeta = registry['_overlay'];
|
|
501
|
+
const loadFailWarnings = [];
|
|
465
502
|
if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) {
|
|
466
503
|
for (const blocked of overlayMeta.blockedGates) {
|
|
467
|
-
if (blocked.point
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
504
|
+
if (blocked.point !== point)
|
|
505
|
+
continue;
|
|
506
|
+
// Security (#2009 review): capId/reason come from a third-party manifest or
|
|
507
|
+
// directory name. Validate capId before embedding it in the runnable
|
|
508
|
+
// remediation command; withhold it (no runnable command) if it is not a
|
|
509
|
+
// canonical id. Strip control chars/backticks from reason.
|
|
510
|
+
const idValid = LOAD_FAIL_CAP_ID_RE.test(String(blocked.capId));
|
|
511
|
+
const capLabel = idValid
|
|
512
|
+
? `"${blocked.capId}"`
|
|
513
|
+
: 'with an invalid id (withheld) under .gsd/capabilities/';
|
|
514
|
+
const remediation = idValid
|
|
515
|
+
? `Run \`gsd capability remove ${blocked.capId}\` to remove it, or fix the load error.`
|
|
516
|
+
: 'Remove the offending capability directory under .gsd/capabilities/, or fix the load error.';
|
|
517
|
+
loadFailWarnings.push(`capability ${capLabel} failed to load (${sanitizeLoadFailReason(blocked.reason)}); ` +
|
|
518
|
+
`its gate at ${point} is SKIPPED and NOT enforced (failing open). ${remediation}`);
|
|
477
519
|
}
|
|
478
520
|
}
|
|
521
|
+
// Emit loudly to stderr in EVERY output mode (including --active-cap), so a
|
|
522
|
+
// skipped gate is never silently invisible to the operator/agent.
|
|
523
|
+
for (const w of loadFailWarnings) {
|
|
524
|
+
process.stderr.write(`gsd: warning — ${w}\n`);
|
|
525
|
+
}
|
|
479
526
|
// --active-cap mode: print exactly 'true' or 'false' with no envelope
|
|
480
527
|
if (activeCapId !== undefined) {
|
|
481
528
|
const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
|
|
@@ -488,8 +535,12 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
|
|
|
488
535
|
activeHooks: resolved.activeHooks,
|
|
489
536
|
rendered,
|
|
490
537
|
};
|
|
491
|
-
|
|
492
|
-
|
|
538
|
+
// Surface capability-state warnings and the #2009 load-failure fail-open
|
|
539
|
+
// warnings together in the structured `warnings` channel (in addition to the
|
|
540
|
+
// stderr emission above, which is the channel host workflows actually see).
|
|
541
|
+
const combinedWarnings = [...(state.warnings || []), ...loadFailWarnings];
|
|
542
|
+
if (combinedWarnings.length > 0) {
|
|
543
|
+
envelope.warnings = combinedWarnings;
|
|
493
544
|
}
|
|
494
545
|
coreOutput(envelope, raw);
|
|
495
546
|
}
|
|
@@ -17,6 +17,7 @@ exports.collectSections = collectSections;
|
|
|
17
17
|
exports.collectSection = collectSection;
|
|
18
18
|
exports.iterateBullets = iterateBullets;
|
|
19
19
|
exports.extractTaggedBlocks = extractTaggedBlocks;
|
|
20
|
+
exports.stripTaggedBlocks = stripTaggedBlocks;
|
|
20
21
|
exports.replaceSection = replaceSection;
|
|
21
22
|
// ─── stripFencedCode ──────────────────────────────────────────────────────────
|
|
22
23
|
/**
|
|
@@ -407,25 +408,26 @@ function iterateBullets(sectionText) {
|
|
|
407
408
|
* fenced code blocks itself. If a `<tagName>` block appears inside a fenced code
|
|
408
409
|
* block and should be excluded, the caller should apply `stripFencedCode` first.
|
|
409
410
|
*
|
|
410
|
-
* **Nested tags are NOT supported.** The
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
411
|
+
* **Nested tags are NOT supported.** The body scan terminates at the NEXT
|
|
412
|
+
* opening of the same tag (the ReDoS-safe boundary, #2128). Given
|
|
413
|
+
* `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns `['inner']`
|
|
414
|
+
* — the well-formed inner block; the unterminated outer `<x>` is skipped.
|
|
415
|
+
* Callers that need true nesting must use a proper XML/HTML parser.
|
|
416
|
+
*
|
|
417
|
+
* `allowAttributes` (default `false`): when `true`, the opening tag may carry
|
|
418
|
+
* bounded attributes (`<tag foo="x">`) — needed for `<task type="…">` blocks.
|
|
419
|
+
* Leave `false` for tags that must match exactly (e.g. `<decisions>`), and never
|
|
420
|
+
* enable it for a tag where an attributed form is semantically distinct.
|
|
417
421
|
*
|
|
418
422
|
* Generalises `decisions.cts`'s bespoke `matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)`
|
|
419
423
|
* so tier T1 can drop its own copy (tracked duplication until T1 lands).
|
|
420
424
|
*/
|
|
421
|
-
function extractTaggedBlocks(content, tagName) {
|
|
425
|
+
function extractTaggedBlocks(content, tagName, allowAttributes = false) {
|
|
422
426
|
if (typeof content !== 'string' || content.length === 0)
|
|
423
427
|
return [];
|
|
424
428
|
if (typeof tagName !== 'string' || tagName.length === 0)
|
|
425
429
|
return [];
|
|
426
|
-
|
|
427
|
-
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
428
|
-
const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g');
|
|
430
|
+
const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
|
|
429
431
|
const results = [];
|
|
430
432
|
let match;
|
|
431
433
|
while ((match = pattern.exec(content)) !== null) {
|
|
@@ -433,6 +435,43 @@ function extractTaggedBlocks(content, tagName) {
|
|
|
433
435
|
}
|
|
434
436
|
return results;
|
|
435
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
|
|
440
|
+
* `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
|
|
441
|
+
*
|
|
442
|
+
* Safety: the body terminates at the NEXT opening of this tag (stop-at-next-open)
|
|
443
|
+
* instead of lazily rescanning the whole remaining document for a `</tag>` that
|
|
444
|
+
* may never appear — so a document full of unclosed `<tag>` openings scans
|
|
445
|
+
* LINEARLY, not quadratically (#2128). Group 1 is the block body.
|
|
446
|
+
*
|
|
447
|
+
* `allowAttributes`: when `true`, the opener accepts bounded attributes
|
|
448
|
+
* (`<tag foo="x">`) and the body boundary is `<tag` followed by a space or `>`.
|
|
449
|
+
* When `false`, the opener is the EXACT `<tag>` and the boundary is exact `<tag>`,
|
|
450
|
+
* so an attributed `<tag foo>` is neither an opener nor a boundary — it is body
|
|
451
|
+
* content. That exact form is load-bearing for `<details>` stripping: `<details
|
|
452
|
+
* open>` marks the ACTIVE milestone and must be preserved, not stripped (#557).
|
|
453
|
+
*/
|
|
454
|
+
function taggedBlockPattern(tagName, flags, allowAttributes) {
|
|
455
|
+
const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
456
|
+
const open = allowAttributes ? `<${esc}(?:\\s[^>]{0,1000})?>` : `<${esc}>`;
|
|
457
|
+
const boundary = allowAttributes ? `<${esc}[\\s>]` : `<${esc}>`;
|
|
458
|
+
return new RegExp(`${open}((?:(?!${boundary})[\\s\\S])*?)</${esc}>`, flags);
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Remove every `<tagName>…</tagName>` block (opening tag, body, and closing tag)
|
|
462
|
+
* from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same
|
|
463
|
+
* hardened pattern, `.replace(…, '')` instead of body extraction. `allowAttributes`
|
|
464
|
+
* defaults to `false` so `<details open>` (active milestone) is preserved (#557);
|
|
465
|
+
* case-insensitive by default (matching the `<details>` strip call sites), pass
|
|
466
|
+
* `caseSensitive` to force exact-case matching.
|
|
467
|
+
*/
|
|
468
|
+
function stripTaggedBlocks(content, tagName, allowAttributes = false, caseSensitive = false) {
|
|
469
|
+
if (typeof content !== 'string' || content.length === 0)
|
|
470
|
+
return '';
|
|
471
|
+
if (typeof tagName !== 'string' || tagName.length === 0)
|
|
472
|
+
return content;
|
|
473
|
+
return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi', allowAttributes), '');
|
|
474
|
+
}
|
|
436
475
|
// ─── replaceSection ───────────────────────────────────────────────────────────
|
|
437
476
|
/**
|
|
438
477
|
* Splice `newBody` in place of a section's body and return the resulting
|