@lnilluv/pi-ralph-loop 0.2.1 → 1.0.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/.github/workflows/ci.yml +5 -2
- package/.github/workflows/release.yml +15 -43
- package/README.md +51 -113
- package/package.json +13 -5
- package/scripts/version-helper.ts +210 -0
- package/src/index.ts +1360 -275
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +297 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +1457 -0
- package/src/runner-rpc.ts +434 -0
- package/src/runner-state.ts +822 -0
- package/src/runner.ts +957 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +0 -3
- package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/migrate/RALPH.md +27 -0
- package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
- package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
- package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
- package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
- package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
- package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/research/RALPH.md +45 -0
- package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
- package/tests/fixtures/parity/research/expected-outputs.md +22 -0
- package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
- package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
- package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
- package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
- package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
- package/tests/fixtures/parity/research/source-manifest.md +20 -0
- package/tests/index.test.ts +3529 -0
- package/tests/parity/README.md +9 -0
- package/tests/parity/harness.py +526 -0
- package/tests/parity-harness.test.ts +42 -0
- package/tests/parity-research-fixture.test.ts +34 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +434 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +1840 -0
- package/tests/runner-event-contract.test.ts +235 -0
- package/tests/runner-rpc.test.ts +358 -0
- package/tests/runner-state.test.ts +553 -0
- package/tests/runner.test.ts +1347 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tests/version-helper.test.ts +75 -0
- package/tsconfig.json +3 -2
package/.github/workflows/ci.yml
CHANGED
|
@@ -17,13 +17,16 @@ jobs:
|
|
|
17
17
|
- name: Checkout
|
|
18
18
|
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
|
|
19
19
|
|
|
20
|
-
- name: Setup Node.js 22
|
|
20
|
+
- name: Setup Node.js 22.22.1
|
|
21
21
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
|
22
22
|
with:
|
|
23
|
-
node-version: 22
|
|
23
|
+
node-version: 22.22.1
|
|
24
24
|
|
|
25
25
|
- name: Install dependencies
|
|
26
26
|
run: npm ci --ignore-scripts
|
|
27
27
|
|
|
28
|
+
- name: Test
|
|
29
|
+
run: npm test
|
|
30
|
+
|
|
28
31
|
- name: Typecheck
|
|
29
32
|
run: npm run typecheck
|
|
@@ -24,15 +24,18 @@ jobs:
|
|
|
24
24
|
with:
|
|
25
25
|
fetch-depth: 0
|
|
26
26
|
|
|
27
|
-
- name: Setup Node.js 24
|
|
27
|
+
- name: Setup Node.js 24.14.1
|
|
28
28
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
|
29
29
|
with:
|
|
30
|
-
node-version: 24
|
|
30
|
+
node-version: 24.14.1
|
|
31
31
|
registry-url: https://registry.npmjs.org
|
|
32
32
|
|
|
33
33
|
- name: Install dependencies
|
|
34
34
|
run: npm ci --ignore-scripts
|
|
35
35
|
|
|
36
|
+
- name: Test
|
|
37
|
+
run: npm test
|
|
38
|
+
|
|
36
39
|
- name: Typecheck
|
|
37
40
|
run: npm run typecheck
|
|
38
41
|
|
|
@@ -68,69 +71,38 @@ jobs:
|
|
|
68
71
|
fi
|
|
69
72
|
|
|
70
73
|
branch="${GITHUB_REF_NAME}"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const match = current.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
75
|
-
if (!match) throw new Error(`Unsupported version: ${current}`);
|
|
76
|
-
|
|
77
|
-
let major = Number(match[1]);
|
|
78
|
-
let minor = Number(match[2]);
|
|
79
|
-
let patch = Number(match[3]);
|
|
80
|
-
const prerelease = match[4];
|
|
81
|
-
|
|
82
|
-
if (branch === 'dev') {
|
|
83
|
-
if (prerelease && prerelease.startsWith('dev.')) {
|
|
84
|
-
const currentN = Number(prerelease.split('.')[1] ?? '0');
|
|
85
|
-
process.stdout.write(`${major}.${minor}.${patch}-dev.${currentN + 1}`);
|
|
86
|
-
} else {
|
|
87
|
-
if (bump === 'major') { major += 1; minor = 0; patch = 0; }
|
|
88
|
-
else if (bump === 'minor') { minor += 1; patch = 0; }
|
|
89
|
-
else { patch += 1; }
|
|
90
|
-
process.stdout.write(`${major}.${minor}.${patch}-dev.0`);
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
if (bump === 'major') { major += 1; minor = 0; patch = 0; }
|
|
94
|
-
else if (bump === 'minor') { minor += 1; patch = 0; }
|
|
95
|
-
else { patch += 1; }
|
|
96
|
-
process.stdout.write(`${major}.${minor}.${patch}`);
|
|
97
|
-
}
|
|
98
|
-
NODE
|
|
99
|
-
)"
|
|
74
|
+
npm_versions="$(npm view @lnilluv/pi-ralph-loop versions --json)"
|
|
75
|
+
git_tags="$(git tag --list)"
|
|
76
|
+
new_version="$(node --experimental-strip-types ./scripts/version-helper.ts "$branch" "$bump" "$npm_versions" "$git_tags")"
|
|
100
77
|
|
|
101
78
|
echo "should_release=true" >> "$GITHUB_OUTPUT"
|
|
102
79
|
echo "bump=$bump" >> "$GITHUB_OUTPUT"
|
|
103
80
|
echo "new_version=$new_version" >> "$GITHUB_OUTPUT"
|
|
104
81
|
|
|
105
|
-
- name: Bump version
|
|
106
|
-
if: steps.bump.outputs.should_release == 'true'
|
|
107
|
-
run: npm version ${{ steps.bump.outputs.
|
|
108
|
-
|
|
109
|
-
- name: Bump version (dev)
|
|
110
|
-
if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
|
|
111
|
-
run: npm version prerelease --preid dev --no-git-tag-version
|
|
82
|
+
- name: Bump version
|
|
83
|
+
if: steps.bump.outputs.should_release == 'true'
|
|
84
|
+
run: npm version ${{ steps.bump.outputs.new_version }} --no-git-tag-version
|
|
112
85
|
|
|
113
86
|
- name: Capture version tag
|
|
114
87
|
if: steps.bump.outputs.should_release == 'true'
|
|
115
88
|
id: version
|
|
116
89
|
run: |
|
|
117
|
-
|
|
118
|
-
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
|
90
|
+
echo "tag=v${{ steps.bump.outputs.new_version }}" >> "$GITHUB_OUTPUT"
|
|
119
91
|
|
|
120
92
|
- name: Publish (main)
|
|
121
93
|
if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'main'
|
|
122
|
-
run: npx npm@11 publish --access public --provenance
|
|
94
|
+
run: npx -y npm@11.12.1 publish --access public --provenance
|
|
123
95
|
|
|
124
96
|
- name: Publish (dev prerelease)
|
|
125
97
|
if: steps.bump.outputs.should_release == 'true' && github.ref_name == 'dev'
|
|
126
|
-
run: npx npm@11 publish --access public --provenance --tag dev
|
|
98
|
+
run: npx -y npm@11.12.1 publish --access public --provenance --tag dev
|
|
127
99
|
|
|
128
100
|
- name: Commit version bump and tag
|
|
129
101
|
if: steps.bump.outputs.should_release == 'true'
|
|
130
102
|
run: |
|
|
131
103
|
git config user.name "github-actions[bot]"
|
|
132
104
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
133
|
-
VERSION
|
|
105
|
+
VERSION="${{ steps.bump.outputs.new_version }}"
|
|
134
106
|
git add package.json package-lock.json
|
|
135
107
|
git commit -m "chore(release): v${VERSION}"
|
|
136
108
|
git tag "v${VERSION}"
|
package/README.md
CHANGED
|
@@ -1,140 +1,78 @@
|
|
|
1
1
|
# pi-ralph
|
|
2
|
+
Autonomous coding loops for pi with task folders, editable drafts, durable state, and per-iteration supervision.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
## Why use it
|
|
5
|
+
- Keep work in a task folder instead of a single chat turn.
|
|
6
|
+
- Re-run commands each iteration and feed the output back into the prompt.
|
|
7
|
+
- Keep short rolling memory in `RALPH_PROGRESS.md`.
|
|
8
|
+
- Store durable loop state in `.ralph-runner/`.
|
|
9
|
+
- Draft from plain language, then review before starting.
|
|
4
10
|
|
|
5
11
|
## Install
|
|
6
|
-
|
|
7
12
|
```bash
|
|
8
13
|
pi install npm:@lnilluv/pi-ralph-loop
|
|
9
14
|
```
|
|
10
15
|
|
|
11
16
|
## Quick start
|
|
17
|
+
1. Create `work/RALPH.md`.
|
|
18
|
+
2. Run `/ralph --path work --arg owner="Ada Lovelace"`.
|
|
19
|
+
3. If you want a draft first, use `/ralph-draft fix flaky auth tests`.
|
|
12
20
|
|
|
21
|
+
## Concise `RALPH.md`
|
|
13
22
|
```md
|
|
14
|
-
# my-task/RALPH.md
|
|
15
23
|
---
|
|
24
|
+
args:
|
|
25
|
+
- owner
|
|
16
26
|
commands:
|
|
17
27
|
- name: tests
|
|
18
|
-
run: npm test
|
|
28
|
+
run: npm test
|
|
19
29
|
timeout: 60
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{{ commands.tests }}
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Run `/ralph my-task` in pi.
|
|
27
|
-
|
|
28
|
-
## How it works
|
|
29
|
-
|
|
30
|
-
On each iteration, pi-ralph reads `RALPH.md`, runs the configured commands, injects their output into the prompt through `{{ commands.<name> }}` placeholders, starts a fresh session, sends the prompt, and waits for completion. Failed test output appears in the next iteration, which creates a self-healing loop.
|
|
31
|
-
|
|
32
|
-
## RALPH.md format
|
|
33
|
-
|
|
34
|
-
```md
|
|
35
|
-
---
|
|
36
|
-
commands:
|
|
37
|
-
- name: tests
|
|
38
|
-
run: npm test -- --runInBand
|
|
39
|
-
timeout: 90
|
|
40
|
-
- name: lint
|
|
41
|
-
run: npm run lint
|
|
30
|
+
- name: verify
|
|
31
|
+
run: ./scripts/verify.sh
|
|
42
32
|
timeout: 60
|
|
43
33
|
max_iterations: 25
|
|
44
34
|
timeout: 300
|
|
45
|
-
completion_promise:
|
|
46
|
-
guardrails:
|
|
47
|
-
block_commands:
|
|
48
|
-
- "rm\\s+-rf\\s+/"
|
|
49
|
-
- "git\\s+push"
|
|
50
|
-
protected_files:
|
|
51
|
-
- ".env*"
|
|
52
|
-
- "**/secrets/**"
|
|
35
|
+
completion_promise: DONE
|
|
53
36
|
---
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<!-- This comment is stripped before sending to the agent -->
|
|
37
|
+
Fix the failing auth tests for {{ args.owner }}.
|
|
57
38
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
Latest lint output:
|
|
62
|
-
{{ commands.lint }}
|
|
63
|
-
|
|
64
|
-
Iteration {{ ralph.iteration }} of {{ ralph.name }}.
|
|
65
|
-
Apply the smallest safe fix and explain why it works.
|
|
39
|
+
Use {{ commands.tests }} and {{ commands.verify }} as evidence.
|
|
40
|
+
Stop with <promise>DONE</promise> only when the gate passes.
|
|
66
41
|
```
|
|
67
42
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
| `guardrails.protected_files` | string[] | `[]` | Glob patterns to block writes |
|
|
79
|
-
|
|
80
|
-
### Placeholders
|
|
81
|
-
|
|
82
|
-
| Placeholder | Description |
|
|
83
|
-
|-------------|-------------|
|
|
84
|
-
| `{{ commands.<name> }}` | Output from the named command |
|
|
85
|
-
| `{{ ralph.iteration }}` | Current 1-based iteration number |
|
|
86
|
-
| `{{ ralph.name }}` | Directory name containing the RALPH.md |
|
|
87
|
-
|
|
88
|
-
HTML comments (`<!-- ... -->`) are stripped from the prompt body after placeholder resolution, so you can annotate your RALPH.md freely.
|
|
43
|
+
## Key features
|
|
44
|
+
- `/ralph` runs an existing task folder or `RALPH.md`, or drafts a new loop from plain language.
|
|
45
|
+
- `/ralph-draft` saves the draft without starting the loop.
|
|
46
|
+
- `/ralph-stop` writes a stop flag under `.ralph-runner/` so the loop exits after the current iteration.
|
|
47
|
+
- Frontmatter can declare `args` and `{{ args.name }}` placeholders; `--arg name=value` fills them when you run an existing task folder with `/ralph --path`.
|
|
48
|
+
- Commands that start with `./` run from the task directory, so checked-in helper scripts work.
|
|
49
|
+
- `RALPH_PROGRESS.md` is injected as short rolling memory and excluded from progress snapshots.
|
|
50
|
+
- The runner stores status, iteration records, events, transcripts, and stop signals in `.ralph-runner/`.
|
|
51
|
+
- Completion gating only stops early when the promise is seen and the readiness checks pass; a clear no-progress result will not trigger early stop.
|
|
52
|
+
- The loop can use a selected model and thinking level; if interactive draft strengthening has no authenticated model, it falls back to the deterministic draft path.
|
|
89
53
|
|
|
90
54
|
## Commands
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
### Iteration timeout
|
|
114
|
-
|
|
115
|
-
Each iteration has a configurable timeout (default 300 seconds). If the agent is stuck and doesn't become idle within the timeout, the loop stops with a warning. This prevents runaway iterations from running forever.
|
|
116
|
-
|
|
117
|
-
### Input validation
|
|
118
|
-
|
|
119
|
-
The extension validates `RALPH.md` frontmatter before starting and on each re-parse: `max_iterations` must be a positive integer, `timeout` must be positive, `block_commands` regexes must compile, and commands must have non-empty names and run strings with positive timeouts.
|
|
120
|
-
|
|
121
|
-
## Comparison table
|
|
122
|
-
|
|
123
|
-
| Feature | **@lnilluv/pi-ralph-loop** | pi-ralph | pi-ralph-wiggum | ralphi | ralphify |
|
|
124
|
-
|---------|------------------------|----------------------|-----------------|--------|----------|
|
|
125
|
-
| Command output injection | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
126
|
-
| Fresh-context sessions | ✓ | ✓ | ✗ | ✓ | ✓ |
|
|
127
|
-
| Mid-turn guardrails | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
128
|
-
| Cross-iteration memory | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
129
|
-
| Mid-turn steering | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
130
|
-
| Live prompt editing | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
131
|
-
| Completion promise | ✓ | ✗ | ✗ | ✗ | ✓ |
|
|
132
|
-
| Iteration timeout | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
133
|
-
| Session-scoped hooks | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
134
|
-
| Input validation | ✓ | ✗ | ✗ | ✗ | ✗ |
|
|
135
|
-
| Setup required | RALPH.md | config | RALPH.md | PRD pipeline | RALPH.md |
|
|
55
|
+
| Command | Use |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `/ralph [path-or-task]` | Run an existing task folder or `RALPH.md`, or draft a new loop from a task description. |
|
|
58
|
+
| `/ralph-draft [path-or-task]` | Draft or edit a loop without starting it. |
|
|
59
|
+
| `/ralph-stop [path-or-task]` | Request a graceful stop after the current iteration. |
|
|
60
|
+
|
|
61
|
+
## Config reference
|
|
62
|
+
| Field | Purpose |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `commands` | Shell commands to run each iteration. |
|
|
65
|
+
| `args` | Declared runtime parameters for `--arg`. |
|
|
66
|
+
| `max_iterations` | Maximum iterations, from 1 to 50. |
|
|
67
|
+
| `inter_iteration_delay` | Delay between iterations, in seconds. |
|
|
68
|
+
| `timeout` | Per-iteration timeout, up to 300 seconds. |
|
|
69
|
+
| `completion_promise` | Early-stop marker such as `DONE`. |
|
|
70
|
+
| `required_outputs` | Files that must exist before early stop. |
|
|
71
|
+
| `guardrails.block_commands` | Regexes blocked in bash commands. |
|
|
72
|
+
| `guardrails.protected_files` | File globs protected from `write` and `edit`. |
|
|
73
|
+
| Model selection | Use a selected model and optional thinking level; the runner applies it before the prompt. |
|
|
74
|
+
|
|
75
|
+
Advanced behavior, validation, and edge cases live in `src/runner.ts`, `src/runner-state.ts`, `src/runner-rpc.ts`, `src/ralph.ts`, and `tests/`.
|
|
136
76
|
|
|
137
77
|
## License
|
|
138
|
-
|
|
139
78
|
MIT
|
|
140
|
-
# CI provenance test
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lnilluv/pi-ralph-loop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Pi-native ralph loop — autonomous coding iterations with mid-turn supervision",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"pi": {
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
"./src/index.ts"
|
|
9
9
|
]
|
|
10
10
|
},
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": "22.22.1 || 24.14.1"
|
|
13
|
+
},
|
|
11
14
|
"scripts": {
|
|
15
|
+
"test": "node --test --experimental-strip-types $(find tests -type f -name '*.ts' | sort)",
|
|
12
16
|
"typecheck": "tsc --noEmit"
|
|
13
17
|
},
|
|
14
18
|
"repository": {
|
|
@@ -16,18 +20,22 @@
|
|
|
16
20
|
"url": "git+https://github.com/lnilluv/pi-ralph-loop.git"
|
|
17
21
|
},
|
|
18
22
|
"dependencies": {
|
|
19
|
-
"
|
|
20
|
-
"minimatch": "10.2.3"
|
|
23
|
+
"@mariozechner/pi-ai": "0.66.1",
|
|
24
|
+
"minimatch": "10.2.3",
|
|
25
|
+
"yaml": "2.8.3"
|
|
21
26
|
},
|
|
22
27
|
"peerDependencies": {
|
|
23
28
|
"@mariozechner/pi-coding-agent": "*"
|
|
24
29
|
},
|
|
25
30
|
"keywords": [
|
|
26
31
|
"pi-extension",
|
|
27
|
-
"pi-package",
|
|
28
32
|
"ralph",
|
|
29
33
|
"autonomous",
|
|
30
34
|
"loop"
|
|
31
35
|
],
|
|
32
|
-
"license": "MIT"
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "25.6.0",
|
|
39
|
+
"typescript": "6.0.2"
|
|
40
|
+
}
|
|
33
41
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export type VersionBump = "major" | "minor" | "patch";
|
|
5
|
+
export type ReleaseBranch = "main" | "dev";
|
|
6
|
+
|
|
7
|
+
export interface ReleaseVersionRequest {
|
|
8
|
+
branch: ReleaseBranch;
|
|
9
|
+
bump: VersionBump;
|
|
10
|
+
npmVersions: readonly string[] | string;
|
|
11
|
+
gitTags: readonly string[] | string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ParsedVersion = {
|
|
15
|
+
major: number;
|
|
16
|
+
minor: number;
|
|
17
|
+
patch: number;
|
|
18
|
+
prerelease?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const STABLE_VERSION = /^\d+\.\d+\.\d+$/;
|
|
22
|
+
const SEMVER_VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
23
|
+
const DEV_PRERELEASE = /^dev\.(\d+)$/;
|
|
24
|
+
|
|
25
|
+
function normalizeVersionList(input: readonly string[] | string): string[] {
|
|
26
|
+
if (Array.isArray(input)) {
|
|
27
|
+
return input.map((version) => version.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const trimmed = input.trim();
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
37
|
+
if (Array.isArray(parsed)) {
|
|
38
|
+
return parsed.map((value) => String(value).trim()).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (typeof parsed === "string") {
|
|
42
|
+
return parsed.trim() ? [parsed.trim()] : [];
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fall through to line-based parsing.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return trimmed
|
|
49
|
+
.split(/[\r\n,]+/)
|
|
50
|
+
.map((version) => version.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripGitTagPrefix(version: string): string {
|
|
55
|
+
return version.startsWith("v") ? version.slice(1) : version;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseVersion(version: string): ParsedVersion | null {
|
|
59
|
+
const match = version.match(SEMVER_VERSION);
|
|
60
|
+
if (!match) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
major: Number(match[1]),
|
|
66
|
+
minor: Number(match[2]),
|
|
67
|
+
patch: Number(match[3]),
|
|
68
|
+
prerelease: match[4],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isStableVersion(version: string): boolean {
|
|
73
|
+
return STABLE_VERSION.test(version);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compareVersions(left: string, right: string): number {
|
|
77
|
+
const leftParsed = parseVersion(left);
|
|
78
|
+
const rightParsed = parseVersion(right);
|
|
79
|
+
|
|
80
|
+
if (!leftParsed || !rightParsed) {
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (leftParsed.major !== rightParsed.major) {
|
|
85
|
+
return leftParsed.major - rightParsed.major;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (leftParsed.minor !== rightParsed.minor) {
|
|
89
|
+
return leftParsed.minor - rightParsed.minor;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return leftParsed.patch - rightParsed.patch;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function maxVersion(versions: string[]): string | null {
|
|
96
|
+
return versions.reduce<string | null>((currentMax, version) => {
|
|
97
|
+
if (!currentMax) {
|
|
98
|
+
return version;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return compareVersions(version, currentMax) > 0 ? version : currentMax;
|
|
102
|
+
}, null);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function incStable(version: string, bump: VersionBump): string {
|
|
106
|
+
const parsed = parseVersion(version);
|
|
107
|
+
if (!parsed) {
|
|
108
|
+
throw new Error(`Unsupported version: ${version}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (bump === "major") {
|
|
112
|
+
return `${parsed.major + 1}.0.0`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (bump === "minor") {
|
|
116
|
+
return `${parsed.major}.${parsed.minor + 1}.0`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectStableVersions(input: readonly string[] | string): string[] {
|
|
123
|
+
return normalizeVersionList(input)
|
|
124
|
+
.map(stripGitTagPrefix)
|
|
125
|
+
.filter(isStableVersion);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function highestStableVersion(npmVersions: readonly string[] | string, gitTags: readonly string[] | string): string {
|
|
129
|
+
const stableNpm = collectStableVersions(npmVersions);
|
|
130
|
+
const stableTags = collectStableVersions(gitTags);
|
|
131
|
+
const highest = maxVersion([...stableNpm, ...stableTags]);
|
|
132
|
+
return highest ?? "0.0.0";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function stableReleaseExistsAtOrAboveOne(npmVersions: readonly string[] | string, gitTags: readonly string[] | string): boolean {
|
|
136
|
+
return [...collectStableVersions(npmVersions), ...collectStableVersions(gitTags)].some(
|
|
137
|
+
(version) => compareVersions(version, "1.0.0") >= 0,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function nextPrereleaseNumber(targetStable: string, npmVersions: readonly string[] | string, gitTags: readonly string[] | string): number {
|
|
142
|
+
const used = new Set<number>();
|
|
143
|
+
|
|
144
|
+
for (const rawVersion of [...normalizeVersionList(npmVersions), ...normalizeVersionList(gitTags)]) {
|
|
145
|
+
const version = stripGitTagPrefix(rawVersion);
|
|
146
|
+
const parsed = parseVersion(version);
|
|
147
|
+
if (!parsed || `${parsed.major}.${parsed.minor}.${parsed.patch}` !== targetStable) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const prerelease = parsed.prerelease;
|
|
152
|
+
if (!prerelease) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const match = prerelease.match(DEV_PRERELEASE);
|
|
157
|
+
if (!match) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
used.add(Number(match[1]));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let next = 0;
|
|
165
|
+
while (used.has(next)) {
|
|
166
|
+
next += 1;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return next;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function computeReleaseVersion({ branch, bump, npmVersions, gitTags }: ReleaseVersionRequest): string {
|
|
173
|
+
const baseStable = highestStableVersion(npmVersions, gitTags);
|
|
174
|
+
let targetStable = incStable(baseStable, bump);
|
|
175
|
+
|
|
176
|
+
if (!stableReleaseExistsAtOrAboveOne(npmVersions, gitTags)) {
|
|
177
|
+
targetStable = compareVersions(targetStable, "1.0.0") < 0 ? "1.0.0" : targetStable;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (branch === "main") {
|
|
181
|
+
return targetStable;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const prereleaseNumber = nextPrereleaseNumber(targetStable, npmVersions, gitTags);
|
|
185
|
+
return `${targetStable}-dev.${prereleaseNumber}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const nextReleaseVersion = computeReleaseVersion;
|
|
189
|
+
|
|
190
|
+
function isReleaseBranch(value: string): value is ReleaseBranch {
|
|
191
|
+
return value === "main" || value === "dev";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isVersionBump(value: string): value is VersionBump {
|
|
195
|
+
return value === "major" || value === "minor" || value === "patch";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function main(argv: string[]): void {
|
|
199
|
+
const [branch, bump, npmVersions, gitTags] = argv;
|
|
200
|
+
|
|
201
|
+
if (!branch || !bump || !npmVersions || !gitTags || !isReleaseBranch(branch) || !isVersionBump(bump)) {
|
|
202
|
+
throw new Error("Usage: version-helper <main|dev> <major|minor|patch> <npm-versions> <git-tags>");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
process.stdout.write(computeReleaseVersion({ branch, bump, npmVersions, gitTags }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
209
|
+
main(process.argv.slice(2));
|
|
210
|
+
}
|