@opengsd/gsd-core 1.4.0-rc.1 → 1.4.0-rc.2
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/plugin.json +23 -0
- package/GEMINI.md +53 -0
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.md +1 -1
- package/agents/gsd-domain-researcher.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-eval-planner.md +1 -1
- package/agents/gsd-framework-selector.md +1 -1
- package/agents/gsd-nyquist-auditor.md +1 -1
- package/agents/gsd-pattern-mapper.md +1 -1
- package/agents/gsd-security-auditor.md +1 -1
- package/agents/gsd-ui-auditor.md +1 -1
- package/agents/gsd-ui-checker.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-user-profiler.md +1 -1
- package/bin/install.js +1911 -354
- package/commands/gsd/autonomous.md +2 -0
- package/commands/gsd/execute-phase.md +2 -0
- package/commands/gsd/plan-phase.md +2 -0
- package/commands/gsd/progress.md +1 -0
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/update.md +3 -2
- package/gemini-extension.json +6 -0
- package/gsd-core/bin/check-latest-version.cjs +58 -4
- package/gsd-core/bin/lib/install-profiles.cjs +58 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
- package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
- package/gsd-core/bin/lib/shell-command-projection.cjs +6 -0
- package/gsd-core/bin/lib/surface.cjs +54 -11
- package/gsd-core/workflows/help/modes/full.md +2 -1
- package/gsd-core/workflows/review.md +2 -2
- package/gsd-core/workflows/update.md +32 -5
- package/hooks/dist/gsd-config-reload.js +133 -0
- package/hooks/dist/gsd-cursor-post-tool.js +75 -0
- package/hooks/dist/gsd-cursor-session-start.js +52 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-config-reload.js +133 -0
- package/hooks/gsd-cursor-post-tool.js +75 -0
- package/hooks/gsd-cursor-session-start.js +52 -0
- package/hooks/hooks.json +69 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +5 -1
- package/scripts/build-hooks.js +7 -0
- package/scripts/changeset/cli.cjs +53 -10
- package/scripts/ci-test-scope.cjs +120 -18
- package/scripts/issue-dedupe.cjs +278 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-notes/discord-release-summary.cjs +373 -0
- package/scripts/research-profiles.cjs +3 -3
- package/scripts/sync-manifest-versions.cjs +119 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// gsd-cursor-post-tool.js — Cursor postToolUse hook (issue #777)
|
|
4
|
+
//
|
|
5
|
+
// Cursor invokes this script after each tool call completes.
|
|
6
|
+
// Protocol: JSON from Cursor on stdin; JSON response on stdout.
|
|
7
|
+
//
|
|
8
|
+
// Input schema (cursor postToolUse):
|
|
9
|
+
// { tool_name, tool_input, tool_output, duration,
|
|
10
|
+
// conversation_id, generation_id, model, hook_event_name,
|
|
11
|
+
// cursor_version, workspace_roots, user_email, transcript_path }
|
|
12
|
+
//
|
|
13
|
+
// Output schema (cursor postToolUse):
|
|
14
|
+
// { additional_context?: string } ← injected as context after the tool use
|
|
15
|
+
//
|
|
16
|
+
// Behaviour:
|
|
17
|
+
// - After a write-class tool that targets .planning/, reminds the agent
|
|
18
|
+
// to keep STATE.md current.
|
|
19
|
+
// - Fails open: any error silently exits 0.
|
|
20
|
+
//
|
|
21
|
+
// Cursor docs: https://cursor.com/docs/hooks
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
|
|
26
|
+
const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
|
27
|
+
const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/;
|
|
28
|
+
|
|
29
|
+
let raw = '';
|
|
30
|
+
const stdinTimeout = setTimeout(() => {
|
|
31
|
+
// Timeout guard: exit silently rather than hanging.
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}, 10000);
|
|
34
|
+
|
|
35
|
+
process.stdin.setEncoding('utf8');
|
|
36
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
37
|
+
process.stdin.on('end', () => {
|
|
38
|
+
clearTimeout(stdinTimeout);
|
|
39
|
+
try {
|
|
40
|
+
let input;
|
|
41
|
+
try { input = JSON.parse(raw || '{}'); } catch { process.stdout.write(JSON.stringify({})); return; }
|
|
42
|
+
|
|
43
|
+
const toolName = String(
|
|
44
|
+
input.tool_name || input.toolName || ''
|
|
45
|
+
).toLowerCase();
|
|
46
|
+
|
|
47
|
+
const isWrite = WRITE_TOOL_RE.test(toolName);
|
|
48
|
+
if (!isWrite) { process.stdout.write(JSON.stringify({})); return; }
|
|
49
|
+
|
|
50
|
+
// Collect only PATH-bearing field values (not free-form content).
|
|
51
|
+
const paths = [];
|
|
52
|
+
const walk = (v, depth) => {
|
|
53
|
+
if (depth > 5 || paths.length > 64) return;
|
|
54
|
+
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
|
55
|
+
if (v && typeof v === 'object') {
|
|
56
|
+
for (const k of Object.keys(v)) {
|
|
57
|
+
const val = v[k];
|
|
58
|
+
if (typeof val === 'string' && PATH_KEY_RE.test(k)) paths.push(val);
|
|
59
|
+
else walk(val, depth + 1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(input.tool_input || input.toolInput || {}, 0);
|
|
64
|
+
|
|
65
|
+
if (paths.some((p) => PLANNING_PATH_RE.test(p))) {
|
|
66
|
+
process.stdout.write(JSON.stringify({
|
|
67
|
+
additional_context:
|
|
68
|
+
'GSD: .planning/ artifact updated — ensure STATE.md reflects the latest phase and progress.',
|
|
69
|
+
}));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
} catch { /* fall through to empty response */ }
|
|
73
|
+
|
|
74
|
+
process.stdout.write(JSON.stringify({}));
|
|
75
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gsd-hook-version: {{GSD_VERSION}}
|
|
3
|
+
// gsd-cursor-session-start.js — Cursor sessionStart hook (issue #777)
|
|
4
|
+
//
|
|
5
|
+
// Cursor invokes this script at the start of each agent session.
|
|
6
|
+
// Protocol: JSON from Cursor on stdin; JSON response on stdout.
|
|
7
|
+
//
|
|
8
|
+
// Input schema (cursor sessionStart):
|
|
9
|
+
// { session_id, is_background_agent, composer_mode, conversation_id,
|
|
10
|
+
// generation_id, model, hook_event_name, cursor_version,
|
|
11
|
+
// workspace_roots, user_email, transcript_path }
|
|
12
|
+
//
|
|
13
|
+
// Output schema (cursor sessionStart):
|
|
14
|
+
// { additional_context?: string } ← injected into the session as context
|
|
15
|
+
//
|
|
16
|
+
// Behaviour:
|
|
17
|
+
// - If .planning/STATE.md is present, injects a brief state reminder.
|
|
18
|
+
// - If absent, nudges the user toward /gsd:new-project.
|
|
19
|
+
// - Fails open: any error silently exits 0 so a hook bug never wedges Cursor.
|
|
20
|
+
//
|
|
21
|
+
// Cursor docs: https://cursor.com/docs/hooks
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const MSG_PRESENT =
|
|
29
|
+
'GSD: .planning/STATE.md is present — review the current phase and any blockers before acting.';
|
|
30
|
+
const MSG_ABSENT =
|
|
31
|
+
'GSD: no .planning/ workflow found — run /gsd:new-project to start a tracked workflow.';
|
|
32
|
+
|
|
33
|
+
let raw = '';
|
|
34
|
+
const stdinTimeout = setTimeout(() => {
|
|
35
|
+
// Timeout guard: exit silently rather than hanging.
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}, 10000);
|
|
38
|
+
|
|
39
|
+
process.stdin.setEncoding('utf8');
|
|
40
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
41
|
+
process.stdin.on('end', () => {
|
|
42
|
+
clearTimeout(stdinTimeout);
|
|
43
|
+
try {
|
|
44
|
+
const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
|
|
45
|
+
const statePresent = fs.existsSync(statePath);
|
|
46
|
+
const msg = statePresent ? MSG_PRESENT : MSG_ABSENT;
|
|
47
|
+
process.stdout.write(JSON.stringify({ additional_context: msg }));
|
|
48
|
+
} catch {
|
|
49
|
+
// Fail open — never block a Cursor session because of a GSD hook error.
|
|
50
|
+
process.stdout.write(JSON.stringify({}));
|
|
51
|
+
}
|
|
52
|
+
});
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"SessionStart": [
|
|
4
|
+
{
|
|
5
|
+
"hooks": [
|
|
6
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
|
|
7
|
+
]
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"PreToolUse": [
|
|
11
|
+
{
|
|
12
|
+
"matcher": "Write|Edit",
|
|
13
|
+
"hooks": [
|
|
14
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-prompt-guard.js\"", "timeout": 5 },
|
|
15
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-guard.js\"", "timeout": 5 }
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
20
|
+
"hooks": [
|
|
21
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-worktree-path-guard.js\"", "timeout": 5 }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"PostToolUse": [
|
|
26
|
+
{
|
|
27
|
+
"matcher": "Bash|Edit|Write|MultiEdit|Agent|Task",
|
|
28
|
+
"hooks": [
|
|
29
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 }
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"matcher": "Read",
|
|
34
|
+
"hooks": [
|
|
35
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-read-injection-scanner.js\"", "timeout": 5 }
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
"SubagentStop": [
|
|
40
|
+
{
|
|
41
|
+
"hooks": [
|
|
42
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 }
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"Stop": [
|
|
47
|
+
{
|
|
48
|
+
"hooks": [
|
|
49
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"PreCompact": [
|
|
54
|
+
{
|
|
55
|
+
"hooks": [
|
|
56
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-context-monitor.js\"", "timeout": 10 }
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"FileChanged": [
|
|
61
|
+
{
|
|
62
|
+
"matcher": "config.json",
|
|
63
|
+
"hooks": [
|
|
64
|
+
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-config-reload.js\"", "timeout": 8 }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
const MANAGED_HOOKS = [
|
|
19
19
|
'gsd-check-update-worker.js',
|
|
20
20
|
'gsd-check-update.js',
|
|
21
|
+
'gsd-config-reload.js',
|
|
21
22
|
'gsd-context-monitor.js',
|
|
23
|
+
'gsd-cursor-post-tool.js',
|
|
24
|
+
'gsd-cursor-session-start.js',
|
|
22
25
|
'gsd-graphify-update.sh',
|
|
23
26
|
'gsd-phase-boundary.sh',
|
|
24
27
|
'gsd-prompt-guard.js',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengsd/gsd-core",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
3
|
+
"version": "1.4.0-rc.2",
|
|
4
4
|
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gsd-core": "bin/install.js",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"gsd-core",
|
|
13
13
|
"assets",
|
|
14
14
|
"agents",
|
|
15
|
+
".claude-plugin",
|
|
16
|
+
"gemini-extension.json",
|
|
17
|
+
"GEMINI.md",
|
|
15
18
|
"hooks",
|
|
16
19
|
"scripts"
|
|
17
20
|
],
|
|
@@ -80,6 +83,7 @@
|
|
|
80
83
|
"generate:identity": "node scripts/generate-package-identity.cjs",
|
|
81
84
|
"prepack": "npm run build:lib",
|
|
82
85
|
"prepare": "npm run build:lib",
|
|
86
|
+
"version": "node scripts/sync-manifest-versions.cjs --stage",
|
|
83
87
|
"prepublishOnly": "npm run build:lib && npm run build:hooks",
|
|
84
88
|
"pretest": "npm run build:lib && npm run lint:skill-deps",
|
|
85
89
|
"pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
|
package/scripts/build-hooks.js
CHANGED
|
@@ -30,6 +30,13 @@ const HOOKS_TO_COPY = [
|
|
|
30
30
|
// so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir.
|
|
31
31
|
'managed-hooks-registry.cjs',
|
|
32
32
|
'gsd-context-monitor.js',
|
|
33
|
+
// Cursor lifecycle hooks (issue #777): sessionStart context injection + postToolUse monitor
|
|
34
|
+
'gsd-cursor-session-start.js',
|
|
35
|
+
'gsd-cursor-post-tool.js',
|
|
36
|
+
// Claude Code FileChanged hook (#770) — hot-reloads gsd config when
|
|
37
|
+
// .planning/config.json changes mid-session. Must ship to dist so the
|
|
38
|
+
// installer can copy it to the target hooks/ dir and register FileChanged.
|
|
39
|
+
'gsd-config-reload.js',
|
|
33
40
|
'gsd-prompt-guard.js',
|
|
34
41
|
'gsd-read-guard.js',
|
|
35
42
|
'gsd-read-injection-scanner.js',
|
|
@@ -42,6 +42,7 @@ function parseArgs(argv) {
|
|
|
42
42
|
installCommand: `npx ${packageName}@latest`,
|
|
43
43
|
json: false,
|
|
44
44
|
allowEmpty: false,
|
|
45
|
+
preview: false,
|
|
45
46
|
};
|
|
46
47
|
if (argv.length === 0) return { ok: true, opts };
|
|
47
48
|
opts.cmd = argv[0];
|
|
@@ -62,6 +63,7 @@ function parseArgs(argv) {
|
|
|
62
63
|
const a = argv[i];
|
|
63
64
|
if (a === '--json') { opts.json = true; continue; }
|
|
64
65
|
if (a === '--allow-empty') { opts.allowEmpty = true; continue; }
|
|
66
|
+
if (a === '--preview') { opts.preview = true; continue; }
|
|
65
67
|
if (
|
|
66
68
|
a === '--repo' ||
|
|
67
69
|
a === '--version' ||
|
|
@@ -133,6 +135,19 @@ function assembleChangelog(lead, releaseBlock) {
|
|
|
133
135
|
].join('\n');
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
// Insert a "_No notable changes._" placeholder after the dated release heading
|
|
139
|
+
// of an otherwise-empty release block. serializeChangelog with no sections
|
|
140
|
+
// yields just "## [v] - d\n"; we expand the trailing newline into a blank line
|
|
141
|
+
// + placeholder + blank line so parseChangelog still sees the dated heading
|
|
142
|
+
// first and the output is human-readable. Shared by the --allow-empty and
|
|
143
|
+
// --preview zero-fragment paths so they can never drift.
|
|
144
|
+
function injectEmptyPlaceholder(headerOnlyBlock) {
|
|
145
|
+
return headerOnlyBlock.replace(
|
|
146
|
+
/^(##\s+\[[^\]]+\][^\n]*)\n+/,
|
|
147
|
+
'$1\n\n_No notable changes._\n\n',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
function cmdRender(opts) {
|
|
137
152
|
const repo = path.resolve(opts.repo);
|
|
138
153
|
const changesetDir = path.join(repo, '.changeset');
|
|
@@ -156,6 +171,38 @@ function cmdRender(opts) {
|
|
|
156
171
|
// 2. Read priorText once; reuse in all subsequent branches.
|
|
157
172
|
const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
|
|
158
173
|
|
|
174
|
+
// Preview mode (#759): render the dated release section WITHOUT writing
|
|
175
|
+
// CHANGELOG.md and WITHOUT consuming .changeset fragments. Used by the rc
|
|
176
|
+
// release job to surface the curated notes for the version under test while
|
|
177
|
+
// leaving the fragment set intact for the eventual finalize render.
|
|
178
|
+
if (opts.preview) {
|
|
179
|
+
// priorChangelog is intentionally null: a preview shows ONLY the new dated
|
|
180
|
+
// section for the version under test, not the full file history.
|
|
181
|
+
// serializeChangelog appends priorChangelog verbatim, so passing the prior
|
|
182
|
+
// text here would dump every past release into the rc job summary.
|
|
183
|
+
const ir = renderChangelog({
|
|
184
|
+
fragments,
|
|
185
|
+
version: opts.version,
|
|
186
|
+
date: opts.date,
|
|
187
|
+
priorChangelog: null,
|
|
188
|
+
});
|
|
189
|
+
let releaseBlock = serializeChangelog(ir);
|
|
190
|
+
if (fragments.length === 0) {
|
|
191
|
+
// Mirror --allow-empty: a no-fragment release still shows a dated heading
|
|
192
|
+
// with a placeholder rather than an empty block.
|
|
193
|
+
releaseBlock = injectEmptyPlaceholder(releaseBlock);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
exitCode: 0,
|
|
197
|
+
report: {
|
|
198
|
+
consumed: 0,
|
|
199
|
+
failures: [],
|
|
200
|
+
preview: releaseBlock,
|
|
201
|
+
fragmentCount: fragments.length,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
159
206
|
// 3. FIX 1: idempotency guard — if the version is already promoted (a dated
|
|
160
207
|
// release heading for this version already exists in CHANGELOG), split on
|
|
161
208
|
// whether fragments are still present:
|
|
@@ -201,15 +248,7 @@ function cmdRender(opts) {
|
|
|
201
248
|
priorChangelog: prior || null,
|
|
202
249
|
});
|
|
203
250
|
const headerOnlyBlock = serializeChangelog(ir);
|
|
204
|
-
|
|
205
|
-
// serializeChangelog with no sections yields just "## [v] - d\n" (single
|
|
206
|
-
// trailing newline, no blank line). We replace that trailing newline with
|
|
207
|
-
// a blank line + placeholder + blank line so parseChangelog still sees the
|
|
208
|
-
// dated heading first and the file is human-readable.
|
|
209
|
-
const releaseBlock = headerOnlyBlock.replace(
|
|
210
|
-
/^(##\s+\[[^\]]+\][^\n]*)\n+/,
|
|
211
|
-
'$1\n\n_No notable changes._\n\n',
|
|
212
|
-
);
|
|
251
|
+
const releaseBlock = injectEmptyPlaceholder(headerOnlyBlock);
|
|
213
252
|
// FIX 2: use shared assembleChangelog helper.
|
|
214
253
|
const out = assembleChangelog(lead, releaseBlock);
|
|
215
254
|
fs.writeFileSync(changelogPath, out);
|
|
@@ -460,7 +499,8 @@ function cmdGithubReleaseNotes(opts) {
|
|
|
460
499
|
function usage() {
|
|
461
500
|
return [
|
|
462
501
|
'usage:',
|
|
463
|
-
' changeset/cli.cjs render --repo <dir> --version V --date D [--allow-empty] [--json]',
|
|
502
|
+
' changeset/cli.cjs render --repo <dir> --version V --date D [--allow-empty] [--preview] [--json]',
|
|
503
|
+
' --preview renders the dated section to stdout without writing CHANGELOG.md or consuming fragments.',
|
|
464
504
|
' changeset/cli.cjs github-release-notes --repo <dir> --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]',
|
|
465
505
|
' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo <dir>] [--json]',
|
|
466
506
|
' Extracts changelog entries strictly after --from (exclusive) and up to',
|
|
@@ -525,6 +565,9 @@ function main() {
|
|
|
525
565
|
const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
|
|
526
566
|
if (opts.json) {
|
|
527
567
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
568
|
+
} else if (opts.cmd === 'render' && opts.preview) {
|
|
569
|
+
// render --preview: emit the rendered section verbatim (no mutation occurred).
|
|
570
|
+
process.stdout.write(report.preview);
|
|
528
571
|
} else if (opts.cmd === 'github-release-notes' && report.body) {
|
|
529
572
|
process.stdout.write(report.body);
|
|
530
573
|
} else {
|
|
@@ -6,16 +6,84 @@ const { existsSync, readdirSync, appendFileSync } = require('fs');
|
|
|
6
6
|
|
|
7
7
|
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
|
8
8
|
|
|
9
|
+
// Workflow files that are purely administrative / policy bots. Changes to these
|
|
10
|
+
// files do NOT require the cross-platform test matrix — only a lightweight
|
|
11
|
+
// ubuntu lane running workflow-lint tests is needed.
|
|
12
|
+
// FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a
|
|
13
|
+
// pipeline workflow and gets the full matrix. New workflow files default to full.
|
|
14
|
+
const INERT_WORKFLOWS = new Set([
|
|
15
|
+
'stale.yml',
|
|
16
|
+
'branch-cleanup.yml',
|
|
17
|
+
'branch-naming.yml',
|
|
18
|
+
'auto-label-issues.yml',
|
|
19
|
+
'auto-branch.yml',
|
|
20
|
+
'auto-backmerge.yml',
|
|
21
|
+
'close-draft-prs.yml',
|
|
22
|
+
'dismiss-unauthorized-pr-approvals.yml',
|
|
23
|
+
'pr-gate.yml',
|
|
24
|
+
'pr-target-validator.yml',
|
|
25
|
+
'pr-template-format.yml',
|
|
26
|
+
'require-issue-link.yml',
|
|
27
|
+
'changeset-required.yml',
|
|
28
|
+
'docs-required.yml',
|
|
29
|
+
'discord-changelog.yml',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Workflows that gate merges, ship the product, or run security/cross-platform
|
|
33
|
+
// suites — these must ALWAYS get the full pipeline treatment and can never be
|
|
34
|
+
// added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken
|
|
35
|
+
// or malicious addition fails CI loudly in the `changes` job on every PR.
|
|
36
|
+
const PROTECTED_WORKFLOWS = new Set([
|
|
37
|
+
'test.yml',
|
|
38
|
+
'install-smoke.yml',
|
|
39
|
+
'mutation.yml',
|
|
40
|
+
'security-scan.yml',
|
|
41
|
+
'release.yml',
|
|
42
|
+
]);
|
|
43
|
+
for (const wf of PROTECTED_WORKFLOWS) {
|
|
44
|
+
if (INERT_WORKFLOWS.has(wf)) {
|
|
45
|
+
throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns true if the path is an inert (non-pipeline) workflow file.
|
|
51
|
+
* Only `.github/workflows/<name>` where <name> is in INERT_WORKFLOWS qualifies.
|
|
52
|
+
*/
|
|
53
|
+
function isInertCi(filePath) {
|
|
54
|
+
if (!filePath.startsWith('.github/workflows/')) return false;
|
|
55
|
+
const name = filePath.slice('.github/workflows/'.length);
|
|
56
|
+
// Must be a direct child (no further slashes) and in the allowlist.
|
|
57
|
+
return !name.includes('/') && INERT_WORKFLOWS.has(name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Tests shared by both the 'workflow automation' and 'inert CI' rules.
|
|
61
|
+
const WORKFLOW_LINT_TESTS = [
|
|
62
|
+
'tests/workflow-shell-pinning.test.cjs',
|
|
63
|
+
'tests/pr-template-policy.test.cjs',
|
|
64
|
+
'tests/lint-pr-check-project-dir.test.cjs',
|
|
65
|
+
];
|
|
66
|
+
|
|
9
67
|
const RULES = [
|
|
10
68
|
{
|
|
11
69
|
name: 'workflow automation',
|
|
12
|
-
|
|
70
|
+
// Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix.
|
|
71
|
+
// FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline.
|
|
72
|
+
match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) ||
|
|
73
|
+
filePath.startsWith('.github/rulesets/'),
|
|
13
74
|
fullMatrix: true,
|
|
14
75
|
tests: [
|
|
15
|
-
|
|
76
|
+
...WORKFLOW_LINT_TESTS,
|
|
16
77
|
'tests/release-tarball-smoke-workflow.test.cjs',
|
|
17
|
-
|
|
18
|
-
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'inert CI',
|
|
82
|
+
match: filePath => isInertCi(filePath),
|
|
83
|
+
fullMatrix: false,
|
|
84
|
+
tests: [
|
|
85
|
+
...WORKFLOW_LINT_TESTS,
|
|
86
|
+
'tests/policy-lint-shallow-checkout.test.cjs',
|
|
19
87
|
],
|
|
20
88
|
},
|
|
21
89
|
{
|
|
@@ -144,14 +212,6 @@ const RULES = [
|
|
|
144
212
|
'tests/docs-parity-live-registry.test.cjs',
|
|
145
213
|
],
|
|
146
214
|
},
|
|
147
|
-
{
|
|
148
|
-
name: 'docs content',
|
|
149
|
-
match: path => path.startsWith('docs/'),
|
|
150
|
-
fullMatrix: false,
|
|
151
|
-
tests: [
|
|
152
|
-
'tests/docs-parity-live-registry.test.cjs',
|
|
153
|
-
],
|
|
154
|
-
},
|
|
155
215
|
{
|
|
156
216
|
name: 'configuration',
|
|
157
217
|
match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
|
|
@@ -229,7 +289,12 @@ function changedFiles(args) {
|
|
|
229
289
|
if (!args.base || !args.head) {
|
|
230
290
|
throw new Error('--base/--head or --files is required');
|
|
231
291
|
}
|
|
232
|
-
|
|
292
|
+
// Three-dot diff (merge-base...head) matches GitHub's PR "Files changed" semantics.
|
|
293
|
+
// A two-dot `git diff base head` would surface every file `next` gained after this
|
|
294
|
+
// branch's merge-base, mis-flagging product_changed/full_matrix on docs-only PRs cut
|
|
295
|
+
// from a slightly stale base (#837). The `changes` job checks out with fetch-depth: 0,
|
|
296
|
+
// so the merge-base is always available.
|
|
297
|
+
const stdout = execFileSync('git', ['diff', '--name-only', `${args.base}...${args.head}`], {
|
|
233
298
|
encoding: 'utf8',
|
|
234
299
|
});
|
|
235
300
|
return splitFiles(stdout);
|
|
@@ -251,16 +316,30 @@ function classify(files) {
|
|
|
251
316
|
const targeted = new Set();
|
|
252
317
|
const windows = new Set();
|
|
253
318
|
const reasons = [];
|
|
254
|
-
let
|
|
319
|
+
let productOrPipelineChanged = false; // product/pipeline code (excludes docs)
|
|
320
|
+
let inertCiChanged = false; // inert workflow files
|
|
255
321
|
let fullMatrix = false;
|
|
256
322
|
|
|
257
323
|
for (const file of files) {
|
|
258
|
-
|
|
324
|
+
// Determine if this file is product/pipeline code.
|
|
325
|
+
// docs/ and root-level .md files are intentionally excluded.
|
|
326
|
+
if (
|
|
327
|
+
['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
|
|
259
328
|
file === 'package.json' || file === 'package-lock.json' ||
|
|
260
329
|
(file.startsWith('tsconfig') && file.endsWith('.json')) ||
|
|
261
|
-
file.startsWith('.github/
|
|
262
|
-
|
|
263
|
-
|
|
330
|
+
file.startsWith('.github/rulesets/')
|
|
331
|
+
) {
|
|
332
|
+
productOrPipelineChanged = true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Non-inert .github/workflows/* are pipeline code → full matrix.
|
|
336
|
+
if (file.startsWith('.github/workflows/') && !isInertCi(file)) {
|
|
337
|
+
productOrPipelineChanged = true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Inert workflow files set a lightweight signal.
|
|
341
|
+
if (isInertCi(file)) {
|
|
342
|
+
inertCiChanged = true;
|
|
264
343
|
}
|
|
265
344
|
|
|
266
345
|
if (file.startsWith('tests/') && file.endsWith('.test.cjs')) {
|
|
@@ -280,6 +359,10 @@ function classify(files) {
|
|
|
280
359
|
}
|
|
281
360
|
}
|
|
282
361
|
|
|
362
|
+
// code_changed: true when product/pipeline OR inert CI changed.
|
|
363
|
+
// Docs-only PRs (neither flag set) get code_changed=false → full matrix skip.
|
|
364
|
+
const codeChanged = productOrPipelineChanged || inertCiChanged;
|
|
365
|
+
|
|
283
366
|
const targetedTests = existingTests([...targeted].sort());
|
|
284
367
|
|
|
285
368
|
// When code changed but no rule matched any changed file, fall back to the
|
|
@@ -290,8 +373,25 @@ function classify(files) {
|
|
|
290
373
|
|
|
291
374
|
const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort());
|
|
292
375
|
|
|
376
|
+
// Inert-CI-only: full_matrix must be false (override any RULES that fired).
|
|
377
|
+
if (inertCiChanged && !productOrPipelineChanged) {
|
|
378
|
+
fullMatrix = false;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Normalize: when code_changed is false, the output must be self-consistent.
|
|
382
|
+
// A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md
|
|
383
|
+
// matches the installer rule via path.includes('install')), leaving full_matrix=true and
|
|
384
|
+
// non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed)
|
|
385
|
+
// but the output object would be self-contradictory. Force a clean "nothing to run" result.
|
|
386
|
+
if (!codeChanged) {
|
|
387
|
+
fullMatrix = false;
|
|
388
|
+
targetedTests.length = 0;
|
|
389
|
+
windowsTests.length = 0;
|
|
390
|
+
}
|
|
391
|
+
|
|
293
392
|
return {
|
|
294
393
|
code_changed: codeChanged,
|
|
394
|
+
product_changed: productOrPipelineChanged,
|
|
295
395
|
full_matrix: fullMatrix,
|
|
296
396
|
targeted_tests: targetedTests,
|
|
297
397
|
windows_tests: windowsTests,
|
|
@@ -303,6 +403,7 @@ function writeOutputs(result) {
|
|
|
303
403
|
if (!process.env.GITHUB_OUTPUT) return;
|
|
304
404
|
const lines = [
|
|
305
405
|
`code_changed=${result.code_changed}`,
|
|
406
|
+
`product_changed=${result.product_changed}`,
|
|
306
407
|
`full_matrix=${result.full_matrix}`,
|
|
307
408
|
`targeted_tests=${result.targeted_tests.join(' ')}`,
|
|
308
409
|
`windows_tests=${result.windows_tests.join(' ')}`,
|
|
@@ -313,6 +414,7 @@ function writeOutputs(result) {
|
|
|
313
414
|
function main() {
|
|
314
415
|
try {
|
|
315
416
|
const args = parseArgs(process.argv.slice(2));
|
|
417
|
+
|
|
316
418
|
const files = changedFiles(args);
|
|
317
419
|
const result = classify(files);
|
|
318
420
|
result.changed_files = files;
|