release-skill 0.6.2 → 0.7.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +48 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +137 -12
- package/README.zh-CN.md +110 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +200 -4
- package/adapters/claude/schemas/release-project.schema.json +86 -5
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +200 -4
- package/adapters/codex/schemas/release-project.schema.json +86 -5
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +200 -4
- package/adapters/kimi/schemas/release-project.schema.json +86 -5
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +187 -9
- package/bin/release-skill.bundle.mjs +50570 -42208
- package/bin/rules.json +1 -1
- package/package.json +6 -4
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +200 -4
- package/schemas/release-project.schema.json +86 -5
- package/schemas/release-run.schema.json +11 -6
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +163 -1
- package/src/commands/distribute.mjs +649 -89
- package/src/commands/postverify.mjs +808 -0
- package/src/commands/prepare.mjs +456 -45
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +171 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +175 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +241 -0
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +449 -25
- package/src/core/preset-executor.mjs +175 -0
- package/src/core/preset-gitwrite.mjs +484 -0
- package/src/core/presets.mjs +754 -0
- package/src/core/proposal-inbox.mjs +646 -0
- package/src/core/redact.mjs +16 -3
- package/src/core/run.mjs +91 -6
- package/src/producers/build-adapters.mjs +28 -14
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version-sensitive derived-artifact fast pre-gates (O1, 2026-08-18
|
|
3
|
+
* release-cycle investigation §3.2).
|
|
4
|
+
*
|
|
5
|
+
* In the 0.6.1 cycle two prepares burned the full ~80s test hook before
|
|
6
|
+
* surfacing drift that a sub-second check could have caught: adapter trees
|
|
7
|
+
* out of sync with skills-src/, and self-bootstrap fact pins still bound to
|
|
8
|
+
* the previous version. This module promotes those two exact checks to
|
|
9
|
+
* prepare's earliest stage, alongside the bundle freshness gate:
|
|
10
|
+
*
|
|
11
|
+
* - adapter gate: runs `scripts/build-adapters.mjs --check` (drift list,
|
|
12
|
+
* exit 1 on drift — the same supported check the scripts surface offers);
|
|
13
|
+
* - fact-pin gate: runs the version fact pins of
|
|
14
|
+
* `test/release-docs-self-bootstrap.test.mjs` — exactly its hermetic
|
|
15
|
+
* section 1 (`[self-bootstrap 1*]`: byte-level version assertions plus the
|
|
16
|
+
* in-process read-only planner), scoped via `--test-name-pattern`.
|
|
17
|
+
*
|
|
18
|
+
* Both promote the CANONICAL check logic — the gate and the full pipeline
|
|
19
|
+
* can never disagree about what "in sync" means. The facts gate is scoped to
|
|
20
|
+
* the suite's hermetic fact-pin section deliberately: the suite's remaining
|
|
21
|
+
* sections shell out to npm/git and drive fixture prepares, which would make
|
|
22
|
+
* a "fast pre-gate" slow, recursive, and brittle under toolchain-shimming
|
|
23
|
+
* fixtures (a prepare invoked with a shimmed `npm` would crash the gate
|
|
24
|
+
* child and false-report drift). Those sections remain the full test hooks'
|
|
25
|
+
* job — this gate never replaces them. Every failure message therefore
|
|
26
|
+
* states the boundary explicitly: this is a fast pre-gate and does NOT
|
|
27
|
+
* replace the full test hooks(快速前置,不替代全量测试).
|
|
28
|
+
*
|
|
29
|
+
* Spawn hygiene: the child environment drops NODE_TEST_CONTEXT. When prepare
|
|
30
|
+
* itself runs inside a node:test harness, the runner exports that variable,
|
|
31
|
+
* and an inheriting `node --test` child then prints "run() is being called
|
|
32
|
+
* recursively ... skipping running files" and exits 0 — a false pass this
|
|
33
|
+
* gate must never report as fresh. Production prepare runs in a plain shell
|
|
34
|
+
* where the variable is absent, so the sanitization is a pure hardening.
|
|
35
|
+
*
|
|
36
|
+
* Recursion guard: the facts gate spawns the very suite whose fixture
|
|
37
|
+
* prepares call prepareRelease — an unguarded child would re-enter the gate
|
|
38
|
+
* and recurse without bound (every level waits on its own child until the
|
|
39
|
+
* 300s timeouts cascade). Gate children therefore carry
|
|
40
|
+
* RELEASE_SKILL_FACTS_GATE_ACTIVE; a facts gate running under the marker
|
|
41
|
+
* records not-applicable (reason nested-gate-run) and spawns nothing. Only
|
|
42
|
+
* the facts gate needs the guard: the adapter child (build-adapters --check)
|
|
43
|
+
* never calls prepareRelease. The outermost prepare still enforces both
|
|
44
|
+
* gates; only the verification run itself is exempt.
|
|
45
|
+
*
|
|
46
|
+
* Applicability mirrors bundle-freshness: installed distributions ship
|
|
47
|
+
* neither the build scripts nor the test file, so the gates record
|
|
48
|
+
* not-applicable there; a source checkout is always gated.
|
|
49
|
+
*
|
|
50
|
+
* @module core/derived-artifact-gates
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { lstat } from 'node:fs/promises';
|
|
54
|
+
import { join } from 'node:path';
|
|
55
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
56
|
+
import { promisify } from 'node:util';
|
|
57
|
+
|
|
58
|
+
import { ReleaseError, DERIVED_ARTIFACT_STALE } from './errors.mjs';
|
|
59
|
+
import { boundedOutputTail } from './bounded-output.mjs';
|
|
60
|
+
|
|
61
|
+
const defaultExecFile = promisify(execFileCb);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Exact bilingual note every pre-gate failure carries: the gates are a fast
|
|
65
|
+
* front line and never a substitute for the full test hooks.
|
|
66
|
+
*/
|
|
67
|
+
export const DERIVED_ARTIFACT_PREGATE_NOTE =
|
|
68
|
+
'This is a fast pre-gate and does not replace the full test hooks(快速前置,不替代全量测试).';
|
|
69
|
+
|
|
70
|
+
/** One-click derived-artifact sync suggested by every drift remediation (O2). */
|
|
71
|
+
export const DERIVED_SYNC_COMMAND = 'node scripts/sync-derived-artifacts.mjs';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Marker every gate child carries. A facts gate running under it is part of
|
|
75
|
+
* the verification run itself and records not-applicable instead of
|
|
76
|
+
* re-spawning the suite (recursion guard, see module docs).
|
|
77
|
+
*/
|
|
78
|
+
export const FACTS_GATE_NESTED_ENV = 'RELEASE_SKILL_FACTS_GATE_ACTIVE';
|
|
79
|
+
|
|
80
|
+
/** Gate descriptors: marker file (applicability), argv, remediation text. */
|
|
81
|
+
const GATES = Object.freeze({
|
|
82
|
+
adapters: Object.freeze({
|
|
83
|
+
artifact: 'adapters',
|
|
84
|
+
marker: join('scripts', 'build-adapters.mjs'),
|
|
85
|
+
argv: (pkgRoot) => [join(pkgRoot, 'scripts', 'build-adapters.mjs'), '--check'],
|
|
86
|
+
timeoutMs: 120000,
|
|
87
|
+
remediation:
|
|
88
|
+
'Rebuild the existing adapters with: node scripts/build-adapters.mjs --apply ' +
|
|
89
|
+
`(or run the one-click derived-artifact sync from the workspace root: ${DERIVED_SYNC_COMMAND}).`,
|
|
90
|
+
}),
|
|
91
|
+
'self-bootstrap-facts': Object.freeze({
|
|
92
|
+
artifact: 'self-bootstrap-facts',
|
|
93
|
+
marker: join('test', 'release-docs-self-bootstrap.test.mjs'),
|
|
94
|
+
// Hermetic fact-pin section only (see module docs): byte-level version
|
|
95
|
+
// facts + in-process planner — no npm/git, no fixture prepares.
|
|
96
|
+
argv: (pkgRoot) => [
|
|
97
|
+
'--test',
|
|
98
|
+
'--test-name-pattern',
|
|
99
|
+
'\\[self-bootstrap 1',
|
|
100
|
+
join(pkgRoot, 'test', 'release-docs-self-bootstrap.test.mjs'),
|
|
101
|
+
],
|
|
102
|
+
timeoutMs: 120000,
|
|
103
|
+
remediation:
|
|
104
|
+
'Refresh the derived documents and version points first ' +
|
|
105
|
+
`(workspace root: ${DERIVED_SYNC_COMMAND}); if the pins still fail, update the fact pins deliberately — ` +
|
|
106
|
+
'the gate never edits sources or test pins itself.',
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function isFile(path) {
|
|
111
|
+
try {
|
|
112
|
+
return (await lstat(path)).isFile();
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Run one derived-artifact check (pure decision, never throws).
|
|
120
|
+
*
|
|
121
|
+
* @param {'adapters' | 'self-bootstrap-facts'} kind - Gate to run.
|
|
122
|
+
* @param {string} pkgRoot - Absolute package root of the running checkout.
|
|
123
|
+
* @param {object} [options]
|
|
124
|
+
* @param {Function} [options.execFileFn] - execFile seam (tests).
|
|
125
|
+
* @param {number} [options.timeoutMs] - Child timeout override.
|
|
126
|
+
* @returns {Promise<{
|
|
127
|
+
* applicable: boolean,
|
|
128
|
+
* fresh?: boolean,
|
|
129
|
+
* reason?: string,
|
|
130
|
+
* artifact?: string,
|
|
131
|
+
* exitCode?: number | null,
|
|
132
|
+
* stdoutTail?: string,
|
|
133
|
+
* stderrTail?: string,
|
|
134
|
+
* durationMs?: number,
|
|
135
|
+
* }>} stdoutTail/stderrTail are bounded and present on BOTH outcomes —
|
|
136
|
+
* on the fresh path they prove the child really executed (e.g. the facts
|
|
137
|
+
* gate carries the child suite's own `ℹ tests N` summary).
|
|
138
|
+
*/
|
|
139
|
+
export async function checkDerivedArtifactGate(kind, pkgRoot, options = {}) {
|
|
140
|
+
const gate = GATES[kind];
|
|
141
|
+
if (!gate) {
|
|
142
|
+
throw new ReleaseError(DERIVED_ARTIFACT_STALE, `unknown derived-artifact gate: ${kind}`, { kind });
|
|
143
|
+
}
|
|
144
|
+
const execFileFn = options.execFileFn ?? defaultExecFile;
|
|
145
|
+
|
|
146
|
+
if (kind === 'self-bootstrap-facts' && process.env[FACTS_GATE_NESTED_ENV]) {
|
|
147
|
+
// Recursion guard: this prepare runs inside a gate child (the suite the
|
|
148
|
+
// facts gate itself spawns). Re-spawning would recurse without bound;
|
|
149
|
+
// the outermost prepare already enforces the gate for this checkout.
|
|
150
|
+
return { applicable: false, reason: 'nested-gate-run', artifact: gate.artifact };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!(await isFile(join(pkgRoot, gate.marker)))) {
|
|
154
|
+
// Installed distributions ship neither the build scripts nor the test
|
|
155
|
+
// file; drift is a source-checkout concern only (bundle-freshness rule).
|
|
156
|
+
return { applicable: false, reason: 'installed-layout', artifact: gate.artifact };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
// Never hand the child a test-runner context: under a nested node:test
|
|
161
|
+
// harness NODE_TEST_CONTEXT makes `node --test` skip all files and exit 0,
|
|
162
|
+
// which this gate must not report as fresh (false pass).
|
|
163
|
+
const childEnv = { ...process.env };
|
|
164
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
165
|
+
childEnv[FACTS_GATE_NESTED_ENV] = '1';
|
|
166
|
+
try {
|
|
167
|
+
const { stdout, stderr } = await execFileFn(process.execPath, gate.argv(pkgRoot), {
|
|
168
|
+
cwd: pkgRoot,
|
|
169
|
+
shell: false,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
timeout: options.timeoutMs ?? gate.timeoutMs,
|
|
172
|
+
env: childEnv,
|
|
173
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
applicable: true,
|
|
177
|
+
fresh: true,
|
|
178
|
+
artifact: gate.artifact,
|
|
179
|
+
stdoutTail: boundedOutputTail(stdout ?? ''),
|
|
180
|
+
stderrTail: boundedOutputTail(stderr ?? ''),
|
|
181
|
+
durationMs: Date.now() - startedAt,
|
|
182
|
+
};
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Timeout / spawn failure fail closed too: an undecidable gate is drift.
|
|
185
|
+
const stdoutTail = boundedOutputTail(err?.stdout ?? '');
|
|
186
|
+
const stderrTail = boundedOutputTail(err?.stderr ?? err?.message ?? '');
|
|
187
|
+
return {
|
|
188
|
+
applicable: true,
|
|
189
|
+
fresh: false,
|
|
190
|
+
reason: err?.killed || err?.code === 'ETIMEDOUT' ? 'timeout' : 'drift',
|
|
191
|
+
artifact: gate.artifact,
|
|
192
|
+
exitCode: typeof err?.code === 'number' ? err.code : null,
|
|
193
|
+
stdoutTail,
|
|
194
|
+
stderrTail,
|
|
195
|
+
durationMs: Date.now() - startedAt,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Adapter freshness decision (build-adapters --check). */
|
|
201
|
+
export function checkAdapterFreshness(pkgRoot, options = {}) {
|
|
202
|
+
return checkDerivedArtifactGate('adapters', pkgRoot, options);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Self-bootstrap fact-pin decision (single-file test). */
|
|
206
|
+
export function checkSelfBootstrapFacts(pkgRoot, options = {}) {
|
|
207
|
+
return checkDerivedArtifactGate('self-bootstrap-facts', pkgRoot, options);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function assertGate(kind, pkgRoot, options = {}) {
|
|
211
|
+
const result = await checkDerivedArtifactGate(kind, pkgRoot, options);
|
|
212
|
+
if (!result.applicable || result.fresh) {
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
const gate = GATES[kind];
|
|
216
|
+
const subject = kind === 'adapters'
|
|
217
|
+
? 'adapters/ is out of sync with its sources (build-adapters --check reported drift)'
|
|
218
|
+
: 'the release-docs-self-bootstrap fact pins are stale (the hermetic fact-pin check failed)';
|
|
219
|
+
throw new ReleaseError(
|
|
220
|
+
DERIVED_ARTIFACT_STALE,
|
|
221
|
+
`${subject}. ${DERIVED_ARTIFACT_PREGATE_NOTE} ${gate.remediation}`,
|
|
222
|
+
{
|
|
223
|
+
artifact: result.artifact,
|
|
224
|
+
reason: result.reason,
|
|
225
|
+
exitCode: result.exitCode,
|
|
226
|
+
stdoutTail: result.stdoutTail,
|
|
227
|
+
stderrTail: result.stderrTail,
|
|
228
|
+
durationMs: result.durationMs,
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Fail-closed adapter pre-gate used by prepare's earliest stage.
|
|
235
|
+
* Not-applicable layouts return quietly; drift throws DERIVED_ARTIFACT_STALE.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} pkgRoot - Absolute package root.
|
|
238
|
+
* @param {object} [options] - execFileFn/timeoutMs seams (tests).
|
|
239
|
+
* @returns {Promise<object>} The gate decision.
|
|
240
|
+
* @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
|
|
241
|
+
*/
|
|
242
|
+
export function assertAdapterFreshness(pkgRoot, options = {}) {
|
|
243
|
+
return assertGate('adapters', pkgRoot, options);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Fail-closed self-bootstrap fact-pin pre-gate used by prepare's earliest
|
|
248
|
+
* stage. Not-applicable layouts return quietly; drift throws
|
|
249
|
+
* DERIVED_ARTIFACT_STALE.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} pkgRoot - Absolute package root.
|
|
252
|
+
* @param {object} [options] - execFileFn/timeoutMs seams (tests).
|
|
253
|
+
* @returns {Promise<object>} The gate decision.
|
|
254
|
+
* @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
|
|
255
|
+
*/
|
|
256
|
+
export function assertSelfBootstrapFacts(pkgRoot, options = {}) {
|
|
257
|
+
return assertGate('self-bootstrap-facts', pkgRoot, options);
|
|
258
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docs-refresh preset: refresh one or more independent docs repositories
|
|
3
|
+
* (v0.6.3 R4, design §2.5).
|
|
4
|
+
*
|
|
5
|
+
* GitHub-Pages-style docs sites live in their own repositories. One
|
|
6
|
+
* declaration refreshes ALL of them (config.repositories array) from the
|
|
7
|
+
* frozen release payload:
|
|
8
|
+
*
|
|
9
|
+
* - config.mappings copies payload files into the docs repository
|
|
10
|
+
* (from = payload-relative source, to = repository-relative destination);
|
|
11
|
+
* an optional per-mapping versionMarker placeholder is replaced with the
|
|
12
|
+
* frozen release version while writing (version-marker replacement);
|
|
13
|
+
* - config.gates (argument arrays, R1 hook runner) run inside each docs
|
|
14
|
+
* repository AFTER the write and BEFORE any commit/push — the docs build
|
|
15
|
+
* gate; a failing gate leaves zero remote side effects;
|
|
16
|
+
* - every repository is committed with the frozen bot identity and pushed
|
|
17
|
+
* (never --force); byte-identical content -> NO_CHANGE (idempotent).
|
|
18
|
+
*
|
|
19
|
+
* Payload requirement: mappings read the materialized payload, which only
|
|
20
|
+
* exists in the distribute phase (§2.3 contexts carry payloadDir there); a
|
|
21
|
+
* postVerify-phase declaration fails closed with a clear message.
|
|
22
|
+
*
|
|
23
|
+
* @module core/docs-refresh-preset
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
27
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
30
|
+
import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
|
|
31
|
+
import { redactUrlCredentialsIfPresent } from './git-url-policy.mjs';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assert `from` stays inside the materialized payload directory (declaration
|
|
35
|
+
* validation guarantees a safe relative shape; this is the execution-time
|
|
36
|
+
* re-check).
|
|
37
|
+
*/
|
|
38
|
+
function resolvePayloadSource(payloadDir, from) {
|
|
39
|
+
const sourcePath = resolve(payloadDir, from);
|
|
40
|
+
const rel = relative(payloadDir, sourcePath);
|
|
41
|
+
if (rel === '' || isAbsolute(rel) || rel === '..'
|
|
42
|
+
|| rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
43
|
+
throw new ReleaseError(
|
|
44
|
+
GATE_FAILED,
|
|
45
|
+
`docs-refresh mapping source "${from}" escapes the payload directory`,
|
|
46
|
+
{ from },
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return sourcePath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Execute one docs-refresh preset hook end-to-end: for every declared docs
|
|
54
|
+
* repository, copy the mapped payload files (version-marker replacement),
|
|
55
|
+
* run the docs build gates, and push. Shared by distribute and postVerify
|
|
56
|
+
* (payload requirement effectively binds it to the distribute phase).
|
|
57
|
+
*
|
|
58
|
+
* @param {object} params
|
|
59
|
+
* @param {object} params.hook - Declared hook entry (config bound).
|
|
60
|
+
* @param {object} params.contextProjection - The §2.3 context projection.
|
|
61
|
+
* @param {object} params.commitIdentity - Frozen commitIdentity.
|
|
62
|
+
* @param {string} params.payloadDir - Materialized payload directory
|
|
63
|
+
* (distribute phase; postVerify contexts never carry it).
|
|
64
|
+
* @param {string} params.releaseWorkspaceRoot - Release workspace root (the
|
|
65
|
+
* real project root; workspace addressing resolution + write exclusion).
|
|
66
|
+
* @param {Function} [params.exec] - Injectable git exec (tests).
|
|
67
|
+
* @param {Function} [params.hookRunner] - Injectable gate runner (tests).
|
|
68
|
+
* @returns {Promise<{ status: string, observation: object,
|
|
69
|
+
* observations: object[], mode: string }>}
|
|
70
|
+
*/
|
|
71
|
+
export async function executeDocsRefreshHook(params) {
|
|
72
|
+
const { hook, contextProjection, commitIdentity, payloadDir, releaseWorkspaceRoot, exec, hookRunner } = params ?? {};
|
|
73
|
+
const config = hook?.config;
|
|
74
|
+
const repositories = config?.repositories;
|
|
75
|
+
if (!Array.isArray(repositories) || repositories.length === 0) {
|
|
76
|
+
throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.repositories array');
|
|
77
|
+
}
|
|
78
|
+
const mappings = config?.mappings;
|
|
79
|
+
if (!Array.isArray(mappings) || mappings.length === 0) {
|
|
80
|
+
throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.mappings array');
|
|
81
|
+
}
|
|
82
|
+
if (typeof payloadDir !== 'string' || payloadDir.length === 0) {
|
|
83
|
+
throw new ReleaseError(
|
|
84
|
+
GATE_FAILED,
|
|
85
|
+
'docs-refresh copies files from the materialized payload, which only exists in the distribute phase; declare phase: distribute (the default) instead of postVerify',
|
|
86
|
+
{},
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const unitId = contextProjection?.unitId ?? 'unknown';
|
|
91
|
+
const version = contextProjection?.version ?? 'unknown';
|
|
92
|
+
const gates = config?.gates ?? [];
|
|
93
|
+
|
|
94
|
+
// Deterministic per-mapping write. Binary-safe copy; the version-marker
|
|
95
|
+
// replacement (when declared) treats the file as UTF-8 text.
|
|
96
|
+
const mutate = async (worktree) => {
|
|
97
|
+
for (const mapping of mappings) {
|
|
98
|
+
const sourcePath = resolvePayloadSource(payloadDir, mapping.from);
|
|
99
|
+
let content = await readFile(sourcePath).catch(() => null);
|
|
100
|
+
if (content === null) {
|
|
101
|
+
throw new ReleaseError(
|
|
102
|
+
GATE_FAILED,
|
|
103
|
+
`docs-refresh mapping source "${mapping.from}" is missing from the materialized payload`,
|
|
104
|
+
{ from: mapping.from },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (typeof mapping.versionMarker === 'string' && mapping.versionMarker.length > 0) {
|
|
108
|
+
const text = content.toString('utf8');
|
|
109
|
+
content = Buffer.from(text.split(mapping.versionMarker).join(version), 'utf8');
|
|
110
|
+
}
|
|
111
|
+
const destination = join(worktree, mapping.to);
|
|
112
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
113
|
+
await writeFile(destination, content);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const observations = [];
|
|
118
|
+
let anyChange = false;
|
|
119
|
+
for (const [index, target] of repositories.entries()) {
|
|
120
|
+
let result;
|
|
121
|
+
try {
|
|
122
|
+
result = await applyDownstreamGitChange({
|
|
123
|
+
target,
|
|
124
|
+
commitIdentity,
|
|
125
|
+
commitSubject: `release-skill docs-refresh ${unitId} ${version}`,
|
|
126
|
+
mutate,
|
|
127
|
+
gates,
|
|
128
|
+
contextProjection,
|
|
129
|
+
releaseWorkspaceRoot,
|
|
130
|
+
...(exec !== undefined ? { exec } : {}),
|
|
131
|
+
...(hookRunner !== undefined ? { hookRunner } : {}),
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw new ReleaseError(
|
|
135
|
+
err?.code ?? GATE_FAILED,
|
|
136
|
+
`docs-refresh repository ${index + 1} of ${repositories.length} failed: ${err?.message ?? err}`,
|
|
137
|
+
{ repositoryIndex: index, ...(err?.details ?? {}) },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
observations.push({
|
|
141
|
+
repositoryIndex: index,
|
|
142
|
+
...(typeof target.remoteUrl === 'string'
|
|
143
|
+
? { remoteUrl: redactUrlCredentialsIfPresent(target.remoteUrl) }
|
|
144
|
+
: {}),
|
|
145
|
+
...(typeof target.workspace === 'string' ? { workspace: target.workspace } : {}),
|
|
146
|
+
branch: target.branch,
|
|
147
|
+
...(result.observation ?? {}),
|
|
148
|
+
});
|
|
149
|
+
if (result.status === 'EXECUTED') anyChange = true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!anyChange) {
|
|
153
|
+
return {
|
|
154
|
+
status: 'NO_CHANGE',
|
|
155
|
+
mode: 'no-change',
|
|
156
|
+
observation: { mode: 'no-change' },
|
|
157
|
+
observations,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const firstPushed = observations.find((entry) => entry.mode === 'pushed');
|
|
161
|
+
return {
|
|
162
|
+
status: 'EXECUTED',
|
|
163
|
+
mode: 'pushed',
|
|
164
|
+
observation: {
|
|
165
|
+
mode: 'pushed',
|
|
166
|
+
...(firstPushed?.pushedCommit ? { pushedCommit: firstPushed.pushedCommit } : {}),
|
|
167
|
+
repositoryCount: repositories.length,
|
|
168
|
+
},
|
|
169
|
+
observations,
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/core/errors.mjs
CHANGED
|
@@ -95,6 +95,8 @@ const EXIT_CODE_MAP = Object.freeze({
|
|
|
95
95
|
CONTENT_MISMATCH: 52,
|
|
96
96
|
DIRTY_SOURCE_INPUT: 53,
|
|
97
97
|
BUNDLE_STALE: 54,
|
|
98
|
+
POSTPUBLISH_HOOK_INVALID: 55,
|
|
99
|
+
DERIVED_ARTIFACT_STALE: 56,
|
|
98
100
|
});
|
|
99
101
|
|
|
100
102
|
// ---- Error code constants ----
|
|
@@ -144,6 +146,8 @@ export const NOT_DEFAULT = 'NOT_DEFAULT';
|
|
|
144
146
|
export const CONTENT_MISMATCH = 'CONTENT_MISMATCH';
|
|
145
147
|
export const DIRTY_SOURCE_INPUT = 'DIRTY_SOURCE_INPUT';
|
|
146
148
|
export const BUNDLE_STALE = 'BUNDLE_STALE';
|
|
149
|
+
export const POSTPUBLISH_HOOK_INVALID = 'POSTPUBLISH_HOOK_INVALID';
|
|
150
|
+
export const DERIVED_ARTIFACT_STALE = 'DERIVED_ARTIFACT_STALE';
|
|
147
151
|
|
|
148
152
|
/**
|
|
149
153
|
* Typed error for release-skill operations.
|
package/src/core/evidence.mjs
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { open, mkdir, writeFile } from 'node:fs/promises';
|
|
11
11
|
import { basename } from 'node:path';
|
|
12
12
|
|
|
13
|
+
import { redactEmbeddedUrlCredentials } from './git-url-policy.mjs';
|
|
14
|
+
|
|
13
15
|
/** Schema version for evidence events. */
|
|
14
16
|
const SCHEMA_VERSION = 1;
|
|
15
17
|
|
|
@@ -66,11 +68,16 @@ export function redact(obj) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
if (typeof obj === 'string') {
|
|
71
|
+
// F-05: strip any embedded URL userinfo before the value reaches disk.
|
|
72
|
+
// Only credential-bearing URL spans are rewritten; ordinary strings and
|
|
73
|
+
// credential-free URLs pass through unchanged.
|
|
74
|
+
const urlRedacted = redactEmbeddedUrlCredentials(obj);
|
|
69
75
|
for (const { prefix, label } of CREDENTIAL_PREFIXES) {
|
|
70
|
-
if (
|
|
76
|
+
if (urlRedacted.startsWith(prefix)) {
|
|
71
77
|
return `[REDACTED:${label}]`;
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
return urlRedacted;
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
return obj;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release-domain Git remote URL policy (architecture gap F-05, T5).
|
|
3
|
+
*
|
|
4
|
+
* The SINGLE boundary for every postPublish remote-write URL: allowed
|
|
5
|
+
* protocols, the Git path shape, and the remote-write grading. Consumers:
|
|
6
|
+
* core/postpublish.mjs (declaration re-validation), core/presets.mjs (preset
|
|
7
|
+
* registry config), core/preset-gitwrite.mjs + core/proposal-inbox.mjs
|
|
8
|
+
* (write transports), adapters/distribute-git.mjs (legacy target mirror).
|
|
9
|
+
* The root JSON schemas are only the first gate; this runtime policy is the
|
|
10
|
+
* final authority, so plans frozen by older schema versions cannot smuggle
|
|
11
|
+
* unsafe URLs through.
|
|
12
|
+
*
|
|
13
|
+
* Boundary rules (what this module is NOT):
|
|
14
|
+
* - Parsing is delegated to the standard WHATWG URL — this module expresses
|
|
15
|
+
* release-domain POLICY over the parse result; it implements no generic
|
|
16
|
+
* URL parser.
|
|
17
|
+
* - Credential redaction is delegated to the Foundation
|
|
18
|
+
* `redactUrlCredentials` (skill-family-harness-node, FG-2) — this module
|
|
19
|
+
* implements no generic redactor; it only decides WHEN the Foundation
|
|
20
|
+
* redactor applies so non-URL strings are never collapsed into the opaque
|
|
21
|
+
* placeholder.
|
|
22
|
+
*
|
|
23
|
+
* Fail-closed vocabulary: every rejection reason is a stable token, and
|
|
24
|
+
* failure text NEVER carries the original URL — a rejected URL may hold
|
|
25
|
+
* userinfo credentials, and echoing it would defeat the boundary.
|
|
26
|
+
*
|
|
27
|
+
* @module core/git-url-policy
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
redactUrlCredentials,
|
|
32
|
+
REDACTED_URL_PLACEHOLDER,
|
|
33
|
+
} from 'skill-family-harness-node';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Protocols allowed for postPublish remote writes: http(s) for real remotes,
|
|
37
|
+
* file: strictly as the test transport.
|
|
38
|
+
*/
|
|
39
|
+
export const GIT_REMOTE_PROTOCOLS = Object.freeze(['http:', 'https:', 'file:']);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Remote-write grading (发布领域远端写分级): the protocol decides the grade —
|
|
43
|
+
* http(s) are real remote writes, file: is the test transport. Central here
|
|
44
|
+
* so no consumer re-derives grading from its own protocol regex.
|
|
45
|
+
*/
|
|
46
|
+
export const GIT_REMOTE_WRITE_GRADES = Object.freeze({
|
|
47
|
+
'http:': 'remote-write',
|
|
48
|
+
'https:': 'remote-write',
|
|
49
|
+
'file:': 'test-transport',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Rejection reason vocabulary; every entry is safe to surface (no URL). */
|
|
53
|
+
export const GIT_REMOTE_URL_REASONS = Object.freeze({
|
|
54
|
+
NOT_A_STRING: 'not-a-string',
|
|
55
|
+
CONTROL_CHARACTERS: 'control-characters',
|
|
56
|
+
UNPARSEABLE: 'unparseable',
|
|
57
|
+
PROTOCOL_NOT_ALLOWED: 'protocol-not-allowed',
|
|
58
|
+
MISSING_HOST: 'missing-host',
|
|
59
|
+
CREDENTIALS_PRESENT: 'credentials-present',
|
|
60
|
+
QUERY_OR_FRAGMENT_REJECTED: 'query-or-fragment-rejected',
|
|
61
|
+
NOT_A_GIT_PATH: 'not-a-git-path',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Human-readable failure text per rejection reason. Deliberately carries NO
|
|
66
|
+
* part of the offending URL (a credential-bearing URL must never be echoed
|
|
67
|
+
* back through an error surface).
|
|
68
|
+
*/
|
|
69
|
+
const FAILURE_TEXT = Object.freeze({
|
|
70
|
+
[GIT_REMOTE_URL_REASONS.NOT_A_STRING]: 'must be a non-empty string',
|
|
71
|
+
[GIT_REMOTE_URL_REASONS.CONTROL_CHARACTERS]: 'contains control characters',
|
|
72
|
+
[GIT_REMOTE_URL_REASONS.UNPARSEABLE]:
|
|
73
|
+
'must be an absolute URL parseable by the standard URL parser',
|
|
74
|
+
[GIT_REMOTE_URL_REASONS.PROTOCOL_NOT_ALLOWED]:
|
|
75
|
+
'protocol must be http:, https:, or file: (file: is the test transport)',
|
|
76
|
+
[GIT_REMOTE_URL_REASONS.MISSING_HOST]: 'http(s) URLs must carry a host',
|
|
77
|
+
[GIT_REMOTE_URL_REASONS.CREDENTIALS_PRESENT]:
|
|
78
|
+
'must never carry embedded credentials (username/password) — credentials belong to the host git credential helper, never to the URL',
|
|
79
|
+
[GIT_REMOTE_URL_REASONS.QUERY_OR_FRAGMENT_REJECTED]:
|
|
80
|
+
'must carry no query or fragment',
|
|
81
|
+
[GIT_REMOTE_URL_REASONS.NOT_A_GIT_PATH]: 'pathname must end in .git',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Describe one rejection reason for error messages (always URL-free).
|
|
86
|
+
*
|
|
87
|
+
* @param {string} reason - One of GIT_REMOTE_URL_REASONS.
|
|
88
|
+
* @returns {string} Safe human-readable failure text.
|
|
89
|
+
*/
|
|
90
|
+
export function describeGitRemoteUrlFailure(reason) {
|
|
91
|
+
return FAILURE_TEXT[reason] ?? 'is not an allowed Git remote URL';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The runtime contract check for one postPublish remote-write URL:
|
|
96
|
+
* 1. non-empty string, no control characters (checked on the RAW value —
|
|
97
|
+
* WHATWG parsing silently folds \t\r\n, so the raw check fails closed);
|
|
98
|
+
* 2. `new URL()` parse (the standard parser is the only parser);
|
|
99
|
+
* 3. protocol allowlist: http:, https:, file: (test transport);
|
|
100
|
+
* 4. http(s) must carry a host;
|
|
101
|
+
* 5. ALL protocols reject a non-empty username or password;
|
|
102
|
+
* 6. no query or fragment (a git remote URL is a bare location);
|
|
103
|
+
* 7. the PATHNAME ends in .git.
|
|
104
|
+
*
|
|
105
|
+
* @param {*} remoteUrl
|
|
106
|
+
* @returns {{ ok: true, protocol: string, grade: string }
|
|
107
|
+
* | { ok: false, reason: string }} The verdict never carries the input.
|
|
108
|
+
*/
|
|
109
|
+
export function checkGitRemoteUrl(remoteUrl) {
|
|
110
|
+
if (typeof remoteUrl !== 'string' || remoteUrl.length === 0) {
|
|
111
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.NOT_A_STRING };
|
|
112
|
+
}
|
|
113
|
+
if (/[\x00-\x1f\x7f]/.test(remoteUrl)) {
|
|
114
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.CONTROL_CHARACTERS };
|
|
115
|
+
}
|
|
116
|
+
let url;
|
|
117
|
+
try {
|
|
118
|
+
url = new URL(remoteUrl);
|
|
119
|
+
} catch {
|
|
120
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.UNPARSEABLE };
|
|
121
|
+
}
|
|
122
|
+
if (!GIT_REMOTE_PROTOCOLS.includes(url.protocol)) {
|
|
123
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.PROTOCOL_NOT_ALLOWED };
|
|
124
|
+
}
|
|
125
|
+
if (url.protocol !== 'file:' && url.hostname === '') {
|
|
126
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.MISSING_HOST };
|
|
127
|
+
}
|
|
128
|
+
if (url.username !== '' || url.password !== '') {
|
|
129
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.CREDENTIALS_PRESENT };
|
|
130
|
+
}
|
|
131
|
+
if (url.search !== '' || url.hash !== '') {
|
|
132
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.QUERY_OR_FRAGMENT_REJECTED };
|
|
133
|
+
}
|
|
134
|
+
if (!url.pathname.endsWith('.git')) {
|
|
135
|
+
return { ok: false, reason: GIT_REMOTE_URL_REASONS.NOT_A_GIT_PATH };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, protocol: url.protocol, grade: GIT_REMOTE_WRITE_GRADES[url.protocol] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Boolean form of the policy check (cross-check skip decisions, probes).
|
|
142
|
+
*
|
|
143
|
+
* @param {*} remoteUrl
|
|
144
|
+
* @returns {boolean} True only for policy-allowed Git remote URLs.
|
|
145
|
+
*/
|
|
146
|
+
export function isAllowedGitRemoteUrl(remoteUrl) {
|
|
147
|
+
return checkGitRemoteUrl(remoteUrl).ok === true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Remote-write grade for one policy-allowed URL; null when the URL does not
|
|
152
|
+
* pass the policy (grading never applies to rejected URLs).
|
|
153
|
+
*
|
|
154
|
+
* @param {*} remoteUrl
|
|
155
|
+
* @returns {'remote-write'|'test-transport'|null}
|
|
156
|
+
*/
|
|
157
|
+
export function resolveGitRemoteWriteGrade(remoteUrl) {
|
|
158
|
+
const verdict = checkGitRemoteUrl(remoteUrl);
|
|
159
|
+
return verdict.ok ? verdict.grade : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Guarded credential redaction for long-lived outputs (evidence, error
|
|
164
|
+
* messages/details, preset observations). Delegates the actual redaction to
|
|
165
|
+
* the Foundation `redactUrlCredentials`:
|
|
166
|
+
* - values that parse as an absolute URL carrying userinfo come back as the
|
|
167
|
+
* Foundation's credential-free serialization;
|
|
168
|
+
* - unparseable `scheme://...@...` authority shapes cannot be proven
|
|
169
|
+
* credential-free and fail closed to the opaque placeholder (the same
|
|
170
|
+
* stance as the Foundation's degraded path);
|
|
171
|
+
* - every other value returns UNCHANGED (blindly applying the Foundation
|
|
172
|
+
* redactor would collapse ordinary non-URL strings into the placeholder).
|
|
173
|
+
*
|
|
174
|
+
* @param {*} value
|
|
175
|
+
* @returns {*} The credential-free value (or the input, unchanged).
|
|
176
|
+
*/
|
|
177
|
+
export function redactUrlCredentialsIfPresent(value) {
|
|
178
|
+
if (typeof value !== 'string' || value.length === 0) return value;
|
|
179
|
+
let url;
|
|
180
|
+
try {
|
|
181
|
+
url = new URL(value);
|
|
182
|
+
} catch {
|
|
183
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*@/.test(value)) {
|
|
184
|
+
return REDACTED_URL_PLACEHOLDER;
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
if (url.username === '' && url.password === '') return value;
|
|
189
|
+
return redactUrlCredentials(url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Candidate URL-span tokenizer for prose (log lines, error messages). It only
|
|
194
|
+
* LOCATES spans shaped like `scheme://...`; the credential decision and the
|
|
195
|
+
* redaction itself are delegated to the standard URL parser + the Foundation
|
|
196
|
+
* redactor via redactUrlCredentialsIfPresent. This is what makes the error
|
|
197
|
+
* and evidence chokepoints safe "even if a validation misses" (F-05): a
|
|
198
|
+
* credential-bearing URL embedded mid-sentence cannot survive to disk.
|
|
199
|
+
*/
|
|
200
|
+
const URL_SPAN_RE = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s'"<>`]+/g;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Redact credential-bearing URL spans anywhere inside a string (whole value
|
|
204
|
+
* or embedded in prose). Credential-free spans and all non-URL text return
|
|
205
|
+
* byte-for-byte unchanged.
|
|
206
|
+
*
|
|
207
|
+
* @param {*} text
|
|
208
|
+
* @returns {*} The credential-free string (or the input, unchanged).
|
|
209
|
+
*/
|
|
210
|
+
export function redactEmbeddedUrlCredentials(text) {
|
|
211
|
+
if (typeof text !== 'string' || text.length === 0) return text;
|
|
212
|
+
if (!text.includes('://')) return text;
|
|
213
|
+
return text.replace(URL_SPAN_RE, (span) => redactUrlCredentialsIfPresent(span));
|
|
214
|
+
}
|