aeoptimize 0.5.3 → 0.6.1

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.
@@ -0,0 +1,326 @@
1
+ export type RuleFixtureKind = 'positive' | 'negative' | 'boundary';
2
+
3
+ export interface FixtureDocument {
4
+ url: string;
5
+ title: string;
6
+ html: string;
7
+ markdown: string;
8
+ headings: Array<{ level: number; text: string }>;
9
+ paragraphs: string[];
10
+ jsonLd: Array<{ '@type'?: string; '@context'?: string; [key: string]: unknown }>;
11
+ metaTags: Record<string, string>;
12
+ links: Array<{ href: string; text: string; rel?: string }>;
13
+ rawText: string;
14
+ }
15
+
16
+ export interface RuleFixtureCase {
17
+ purpose: string;
18
+ document: Partial<FixtureDocument>;
19
+ expected: {
20
+ score: number;
21
+ issues: number;
22
+ suggestions: number;
23
+ };
24
+ }
25
+
26
+ export interface RuleFixtureSet {
27
+ positive: RuleFixtureCase;
28
+ negative: RuleFixtureCase;
29
+ boundary: RuleFixtureCase;
30
+ }
31
+
32
+ const words = (count: number, prefix = 'word') =>
33
+ Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(' ');
34
+
35
+ const repeatedWords = (count: number, word = 'word') =>
36
+ Array.from({ length: count }, () => word).join(' ');
37
+
38
+ const stuffedText = (
39
+ 'Buy cheap widgets now. Cheap widgets are the best widgets. ' +
40
+ 'Our widgets are cheap widgets for sale. Get cheap widgets today. ' +
41
+ 'Cheap widgets online cheap widgets store cheap widgets deals. ' +
42
+ 'Best cheap widgets cheap widgets review cheap widgets comparison. ' +
43
+ 'Order cheap widgets cheap widgets shipping cheap widgets discount. '
44
+ ).repeat(3);
45
+
46
+ export const ruleFixtureCorpusVersion = '0.6.0';
47
+
48
+ export const ruleFixtureCorpus: Record<string, RuleFixtureSet> = {
49
+ 'heading-hierarchy': {
50
+ positive: {
51
+ purpose: 'A descriptive H1 followed by nested sections receives the full structure score.',
52
+ document: {
53
+ headings: [
54
+ { level: 1, text: 'Release guide' },
55
+ { level: 2, text: 'Verification' },
56
+ { level: 3, text: 'CLI checks' },
57
+ ],
58
+ rawText: 'A short release guide with a clear outline.',
59
+ },
60
+ expected: { score: 10, issues: 0, suggestions: 0 },
61
+ },
62
+ negative: {
63
+ purpose: 'A document with no headings triggers the deterministic missing-outline finding.',
64
+ document: { headings: [], rawText: 'Unstructured content.' },
65
+ expected: { score: 0, issues: 1, suggestions: 0 },
66
+ },
67
+ boundary: {
68
+ purpose: 'Multiple H1 elements are not treated as an automatic error when the outline does not skip levels.',
69
+ document: {
70
+ headings: [
71
+ { level: 1, text: 'Primary title' },
72
+ { level: 1, text: 'Secondary region title' },
73
+ { level: 2, text: 'Details' },
74
+ ],
75
+ rawText: 'Readable content.',
76
+ },
77
+ expected: { score: 10, issues: 0, suggestions: 0 },
78
+ },
79
+ },
80
+ 'paragraph-length': {
81
+ positive: {
82
+ purpose: 'Short focused paragraphs remain below the configured readability heuristic.',
83
+ document: { paragraphs: ['A concise paragraph.', 'Another concise paragraph.'] },
84
+ expected: { score: 8, issues: 0, suggestions: 0 },
85
+ },
86
+ negative: {
87
+ purpose: 'A majority of paragraphs above 150 words triggers the configured long-paragraph finding.',
88
+ document: { paragraphs: [repeatedWords(151), repeatedWords(151), 'A short paragraph.'] },
89
+ expected: { score: 3, issues: 1, suggestions: 1 },
90
+ },
91
+ boundary: {
92
+ purpose: 'Exactly 150 words is the non-penalized threshold boundary.',
93
+ document: { paragraphs: [repeatedWords(150)] },
94
+ expected: { score: 8, issues: 0, suggestions: 0 },
95
+ },
96
+ },
97
+ 'list-usage': {
98
+ positive: {
99
+ purpose: 'Long content containing a genuine list receives the full scannability score.',
100
+ document: { html: `<ul><li>First</li><li>Second</li></ul>${words(301)}`, rawText: words(301) },
101
+ expected: { score: 7, issues: 0, suggestions: 0 },
102
+ },
103
+ negative: {
104
+ purpose: 'Long content with no list receives a low-impact review suggestion.',
105
+ document: { html: `<p>${words(301)}</p>`, rawText: words(301) },
106
+ expected: { score: 4, issues: 0, suggestions: 1 },
107
+ },
108
+ boundary: {
109
+ purpose: 'Content at exactly 300 words is not forced into a list merely to satisfy the heuristic.',
110
+ document: { html: `<p>${words(300)}</p>`, rawText: words(300) },
111
+ expected: { score: 7, issues: 0, suggestions: 0 },
112
+ },
113
+ },
114
+ 'self-contained-statements': {
115
+ positive: {
116
+ purpose: 'Paragraphs that name their subject remain independently understandable.',
117
+ document: {
118
+ paragraphs: [
119
+ 'Aeoptimize reports deterministic content-readiness findings.',
120
+ 'The GitHub Action is advisory by default.',
121
+ 'Project owners choose whether a threshold should block CI.',
122
+ ],
123
+ },
124
+ expected: { score: 8, issues: 0, suggestions: 0 },
125
+ },
126
+ negative: {
127
+ purpose: 'A majority of dangling pronoun or transition openings triggers a review finding.',
128
+ document: {
129
+ paragraphs: [
130
+ 'This is important for the release.',
131
+ 'They require additional context.',
132
+ 'However, it varies by project.',
133
+ 'The release is versioned for users.',
134
+ 'The report is publicly available.',
135
+ ],
136
+ },
137
+ expected: { score: 3, issues: 1, suggestions: 1 },
138
+ },
139
+ boundary: {
140
+ purpose: 'Exactly twenty percent dangling openings is the non-penalized ratio boundary.',
141
+ document: {
142
+ paragraphs: [
143
+ 'This needs context for the reader.',
144
+ 'The package is explicitly versioned.',
145
+ 'The Action is advisory by default.',
146
+ 'The report remains stable for automation.',
147
+ 'The fixture is publicly reviewable.',
148
+ ],
149
+ },
150
+ expected: { score: 8, issues: 0, suggestions: 1 },
151
+ },
152
+ },
153
+ 'data-stats-presence': {
154
+ positive: {
155
+ purpose: 'A quantitative claim with explicit source language is not flagged as unsourced.',
156
+ document: { rawText: 'According to the linked release report, 20 users completed the test.' },
157
+ expected: { score: 7, issues: 0, suggestions: 0 },
158
+ },
159
+ negative: {
160
+ purpose: 'A quantitative claim without a detectable source receives an evidence warning.',
161
+ document: { rawText: 'The package serves 20 users.' },
162
+ expected: { score: 3, issues: 1, suggestions: 1 },
163
+ },
164
+ boundary: {
165
+ purpose: 'Content without quantitative claims is not penalized or encouraged to invent numbers.',
166
+ document: { rawText: 'The package exposes a deterministic local lint.' },
167
+ expected: { score: 7, issues: 0, suggestions: 0 },
168
+ },
169
+ },
170
+ 'clear-definitions': {
171
+ positive: {
172
+ purpose: 'Several explicit definitions receive the full clarity score.',
173
+ document: { rawText: 'A lint is a repeatable check. A fixture means a controlled input. A release refers to a published version.' },
174
+ expected: { score: 5, issues: 0, suggestions: 0 },
175
+ },
176
+ negative: {
177
+ purpose: 'Content without definitions receives a clarity suggestion.',
178
+ document: { rawText: 'Install the package and run the command.' },
179
+ expected: { score: 1, issues: 0, suggestions: 1 },
180
+ },
181
+ boundary: {
182
+ purpose: 'A semantic definition list is accepted without requiring a prose pattern.',
183
+ document: { html: '<dl><dt>Fixture</dt><dd>A controlled input.</dd></dl>', rawText: 'Fixture: a controlled input.' },
184
+ expected: { score: 5, issues: 0, suggestions: 0 },
185
+ },
186
+ },
187
+ attribution: {
188
+ positive: {
189
+ purpose: 'Accurate author, date, and source language receive the full attribution score.',
190
+ document: {
191
+ metaTags: { author: 'Fixture Author', date: '2026-08-22' },
192
+ rawText: 'According to the release evidence, the focused checks passed.',
193
+ },
194
+ expected: { score: 5, issues: 0, suggestions: 0 },
195
+ },
196
+ negative: {
197
+ purpose: 'Authored or time-sensitive content with no attribution signals receives a suggestion.',
198
+ document: { metaTags: {}, rawText: 'A time-sensitive release note.' },
199
+ expected: { score: 0, issues: 0, suggestions: 1 },
200
+ },
201
+ boundary: {
202
+ purpose: 'Author plus source language reaches the no-suggestion threshold without inventing a date.',
203
+ document: { metaTags: { author: 'Fixture Author' }, rawText: 'Source: local release verification.' },
204
+ expected: { score: 3, issues: 0, suggestions: 0 },
205
+ },
206
+ },
207
+ 'json-ld-presence': {
208
+ positive: {
209
+ purpose: 'Present JSON-LD is detected without awarding extra points for schema count.',
210
+ document: { jsonLd: [{ '@context': 'https://schema.org', '@type': 'SoftwareApplication' }] },
211
+ expected: { score: 8, issues: 0, suggestions: 0 },
212
+ },
213
+ negative: {
214
+ purpose: 'Missing JSON-LD produces an informational finding but no score penalty because schema is optional.',
215
+ document: { jsonLd: [] },
216
+ expected: { score: 8, issues: 1, suggestions: 0 },
217
+ },
218
+ boundary: {
219
+ purpose: 'Presence and completeness are separate rules, preventing a duplicate penalty in the presence rule.',
220
+ document: { jsonLd: [{}] },
221
+ expected: { score: 8, issues: 0, suggestions: 0 },
222
+ },
223
+ },
224
+ 'json-ld-completeness': {
225
+ positive: {
226
+ purpose: 'JSON-LD with context and type receives the full completeness score.',
227
+ document: { jsonLd: [{ '@context': 'https://schema.org', '@type': 'SoftwareApplication' }] },
228
+ expected: { score: 12, issues: 0, suggestions: 0 },
229
+ },
230
+ negative: {
231
+ purpose: 'A JSON-LD object missing both required fields receives the deterministic completeness warning.',
232
+ document: { jsonLd: [{}] },
233
+ expected: { score: 0, issues: 1, suggestions: 0 },
234
+ },
235
+ boundary: {
236
+ purpose: 'No structured data receives no completeness penalty because optional absence belongs to the presence rule.',
237
+ document: { jsonLd: [] },
238
+ expected: { score: 12, issues: 0, suggestions: 0 },
239
+ },
240
+ },
241
+ 'robots-txt-ai-config': {
242
+ positive: {
243
+ purpose: 'An indexable page receives the full page-level crawler score.',
244
+ document: { metaTags: { robots: 'index,follow' } },
245
+ expected: { score: 8, issues: 0, suggestions: 1 },
246
+ },
247
+ negative: {
248
+ purpose: 'A noindex directive triggers the deterministic critical finding.',
249
+ document: { metaTags: { robots: 'noindex,nofollow' } },
250
+ expected: { score: 0, issues: 1, suggestions: 0 },
251
+ },
252
+ boundary: {
253
+ purpose: 'A nofollow-only directive is not confused with noindex; site-level crawler access remains a separate check.',
254
+ document: { metaTags: { robots: 'nofollow,noarchive' } },
255
+ expected: { score: 8, issues: 0, suggestions: 1 },
256
+ },
257
+ },
258
+ 'meta-description-quality': {
259
+ positive: {
260
+ purpose: 'A page-specific readable summary receives the full metadata score.',
261
+ document: { metaTags: { description: 'A deterministic release guide covering package, Action, and rollback verification.' } },
262
+ expected: { score: 7, issues: 0, suggestions: 0 },
263
+ },
264
+ negative: {
265
+ purpose: 'A missing description receives a warning without claiming a ranking outcome.',
266
+ document: { metaTags: {} },
267
+ expected: { score: 0, issues: 1, suggestions: 0 },
268
+ },
269
+ boundary: {
270
+ purpose: 'A long but page-specific description is not penalized by a fabricated fixed-length limit.',
271
+ document: { metaTags: { description: `A page-specific release explanation ${repeatedWords(180, 'context')}.` } },
272
+ expected: { score: 7, issues: 0, suggestions: 0 },
273
+ },
274
+ },
275
+ 'content-boilerplate-ratio': {
276
+ positive: {
277
+ purpose: 'Paragraph content at sixty percent of extracted text receives the full heuristic score.',
278
+ document: { paragraphs: [words(60, 'content')], rawText: `${words(60, 'content')} ${words(40, 'navigation')}` },
279
+ expected: { score: 5, issues: 0, suggestions: 0 },
280
+ },
281
+ negative: {
282
+ purpose: 'Very little paragraph content relative to total text receives a review suggestion.',
283
+ document: { paragraphs: [words(20, 'content')], rawText: `${words(20, 'content')} ${words(80, 'navigation')}` },
284
+ expected: { score: 1, issues: 0, suggestions: 1 },
285
+ },
286
+ boundary: {
287
+ purpose: 'Exactly forty percent paragraph content stays at the documented middle threshold instead of the low band.',
288
+ document: { paragraphs: [words(40, 'content')], rawText: `${words(40, 'content')} ${words(60, 'navigation')}` },
289
+ expected: { score: 3, issues: 0, suggestions: 0 },
290
+ },
291
+ },
292
+ 'keyword-stuffing-detection': {
293
+ positive: {
294
+ purpose: 'Long content with diverse vocabulary receives the full repetition score.',
295
+ document: { rawText: words(80, 'term') },
296
+ expected: { score: 5, issues: 0, suggestions: 0 },
297
+ },
298
+ negative: {
299
+ purpose: 'Low-diversity language repeated across sentences triggers the stuffing heuristic.',
300
+ document: { rawText: stuffedText },
301
+ expected: { score: 0, issues: 1, suggestions: 1 },
302
+ },
303
+ boundary: {
304
+ purpose: 'A short sample below fifty words is not classified from insufficient repetition evidence.',
305
+ document: { rawText: repeatedWords(49, 'widget') },
306
+ expected: { score: 5, issues: 0, suggestions: 0 },
307
+ },
308
+ },
309
+ 'content-uniqueness-signals': {
310
+ positive: {
311
+ purpose: 'A verifiable original measurement plus a code example receives the full heuristic score.',
312
+ document: { rawText: 'Our research measured the documented fixture under controlled inputs.', html: '<pre><code>npm run check</code></pre>' },
313
+ expected: { score: 5, issues: 0, suggestions: 0 },
314
+ },
315
+ negative: {
316
+ purpose: 'Generic prose with no original evidence or example remains at the base score.',
317
+ document: { rawText: 'The package checks content.', html: '<p>The package checks content.</p>' },
318
+ expected: { score: 2, issues: 0, suggestions: 1 },
319
+ },
320
+ boundary: {
321
+ purpose: 'A code sample alone adds one point but cannot masquerade as original research.',
322
+ document: { rawText: 'Run the documented command.', html: '<pre><code>aeoptimize --version</code></pre>' },
323
+ expected: { score: 3, issues: 0, suggestions: 1 },
324
+ },
325
+ },
326
+ };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "aeoptimize",
3
- "version": "0.5.3",
4
- "description": "CLI toolkit that transforms SEO-optimized websites into AI-search-ready content",
3
+ "version": "0.6.1",
4
+ "description": "Deterministic content-readiness lint for static websites and documentation",
5
5
  "type": "module",
6
6
  "main": "./dist/core/index.js",
7
7
  "bin": {
8
- "aeoptimize": "./dist/cli/index.js",
9
- "aeo": "./dist/cli/index.js",
10
- "aeo-cli": "./dist/cli/index.js"
8
+ "aeoptimize": "dist/cli/index.js",
9
+ "aeo": "dist/cli/index.js",
10
+ "aeo-cli": "dist/cli/index.js"
11
11
  },
12
12
  "exports": {
13
13
  ".": "./dist/core/index.js",
@@ -18,16 +18,28 @@
18
18
  "dist/",
19
19
  "skills/",
20
20
  "agents/",
21
+ "fixtures/",
22
+ "examples/github-action-sample/",
23
+ "scripts/verify-release-candidate.sh",
24
+ "scripts/verify-release-v0.6.sh",
21
25
  ".claude-plugin/",
26
+ "docs/methodology.md",
27
+ "docs/release-v0.6.md",
28
+ "CHANGELOG.md",
29
+ "CONTRIBUTING.md",
30
+ "ROADMAP.md",
31
+ "SECURITY.md",
22
32
  "README.md",
23
33
  "LICENSE"
24
34
  ],
25
35
  "scripts": {
26
36
  "build": "tsc",
37
+ "check": "npm test && npm run build",
38
+ "release:check": "bash scripts/verify-release-candidate.sh",
27
39
  "dev": "tsc --watch",
28
40
  "test": "vitest run",
29
41
  "test:watch": "vitest",
30
- "prepublishOnly": "npm test && npm run build"
42
+ "prepublishOnly": "npm run release:check"
31
43
  },
32
44
  "keywords": [
33
45
  "aeo",
@@ -41,25 +53,28 @@
41
53
  "vite-plugin",
42
54
  "nextjs-plugin"
43
55
  ],
44
- "homepage": "https://github.com/dexuwang627-cloud/aeoptimize",
56
+ "homepage": "https://github.com/cucuwang/aeoptimize",
45
57
  "repository": {
46
58
  "type": "git",
47
- "url": "https://github.com/dexuwang627-cloud/aeoptimize.git"
59
+ "url": "git+https://github.com/cucuwang/aeoptimize.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/cucuwang/aeoptimize/issues"
48
63
  },
49
64
  "license": "MIT",
50
65
  "engines": {
51
- "node": ">=18.0.0"
66
+ "node": ">=22.12.0"
52
67
  },
53
68
  "dependencies": {
54
69
  "chalk": "^5.3.0",
55
- "puppeteer-core": "^24.0.0",
56
- "cheerio": "^1.0.0",
70
+ "puppeteer-core": "^25.7.0",
71
+ "cheerio": "^1.2.0",
57
72
  "commander": "^12.0.0",
58
73
  "gray-matter": "^4.0.3"
59
74
  },
60
75
  "devDependencies": {
61
- "@types/node": "^20.0.0",
76
+ "@types/node": "^24.0.0",
62
77
  "typescript": "^5.4.0",
63
- "vitest": "^2.0.0"
78
+ "vitest": "^4.1.10"
64
79
  }
65
80
  }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd)
5
+ cd "$REPO_ROOT"
6
+
7
+ for command_name in git jq mktemp node npm; do
8
+ if ! command -v "$command_name" >/dev/null 2>&1; then
9
+ echo "missing required command: $command_name" >&2
10
+ exit 2
11
+ fi
12
+ done
13
+
14
+ if [ "${ALLOW_DIRTY_RELEASE_CHECK:-0}" != "1" ] && [ -n "$(git status --porcelain)" ]; then
15
+ echo "release candidate must be built from a clean worktree" >&2
16
+ exit 1
17
+ fi
18
+
19
+ VERIFY_BASE=${TMPDIR:-/tmp}
20
+ VERIFY_BASE=${VERIFY_BASE%/}
21
+ VERIFY_ROOT=$(mktemp -d "$VERIFY_BASE/aeoptimize-release-candidate.XXXXXX")
22
+ PACK_ROOT="$VERIFY_ROOT/pack"
23
+ CONSUMER_ROOT="$VERIFY_ROOT/consumer"
24
+ PACK_JSON="$VERIFY_ROOT/pack.json"
25
+
26
+ cleanup() {
27
+ case "$VERIFY_ROOT" in
28
+ "$VERIFY_BASE"/aeoptimize-release-candidate.*)
29
+ rm -rf -- "$VERIFY_ROOT"
30
+ ;;
31
+ *)
32
+ echo "Refusing to remove unexpected verification path: $VERIFY_ROOT" >&2
33
+ ;;
34
+ esac
35
+ }
36
+
37
+ trap cleanup EXIT
38
+ mkdir -p "$PACK_ROOT"
39
+
40
+ npm run check
41
+ bash action/test-contract.sh
42
+ npm --cache "$VERIFY_ROOT/npm-cache" audit --audit-level=high
43
+ npm_config_dry_run=false npm --cache "$VERIFY_ROOT/npm-cache" \
44
+ pack --json --pack-destination "$PACK_ROOT" > "$PACK_JSON"
45
+
46
+ PACKAGE_FILENAME=$(jq -er '.[0].filename' "$PACK_JSON")
47
+ PACKAGE_VERSION=$(jq -er '.[0].version' "$PACK_JSON")
48
+ PACKAGE_FILE_COUNT=$(jq -er '.[0].files | length' "$PACK_JSON")
49
+ PACKAGE_UNPACKED_SIZE=$(jq -er '.[0].unpackedSize' "$PACK_JSON")
50
+ PACKAGE_TARBALL="$PACK_ROOT/$PACKAGE_FILENAME"
51
+ PACKAGE_SHA256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');console.log(crypto.createHash('sha256').update(fs.readFileSync(process.argv[1])).digest('hex'))" "$PACKAGE_TARBALL")
52
+
53
+ jq -e '
54
+ (.[0].files | map(.path) | index("dist/cli/index.js")) != null and
55
+ (.[0].files | map(.path) | index("fixtures/v0.6/rule-corpus.ts")) != null and
56
+ (.[0].files | map(.path) | index("examples/github-action-sample/.github/workflows/aeoptimize.yml")) != null and
57
+ (.[0].files | map(.path) | index("scripts/verify-release-candidate.sh")) != null and
58
+ (.[0].files | map(.path) | index("scripts/verify-release-v0.6.sh")) != null
59
+ ' "$PACK_JSON" >/dev/null
60
+
61
+ npm_config_dry_run=false npm --cache "$VERIFY_ROOT/npm-cache" install \
62
+ --ignore-scripts --no-audit --no-fund \
63
+ --prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null
64
+
65
+ for binary in aeoptimize aeo aeo-cli; do
66
+ BINARY_VERSION=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version)
67
+ if [ "$BINARY_VERSION" != "$PACKAGE_VERSION" ]; then
68
+ echo "$binary returned $BINARY_VERSION; expected $PACKAGE_VERSION" >&2
69
+ exit 1
70
+ fi
71
+ done
72
+
73
+ MANIFEST=$(jq -n \
74
+ --arg version "$PACKAGE_VERSION" \
75
+ --arg filename "$PACKAGE_FILENAME" \
76
+ --arg sha256 "$PACKAGE_SHA256" \
77
+ --argjson fileCount "$PACKAGE_FILE_COUNT" \
78
+ --argjson unpackedSize "$PACKAGE_UNPACKED_SIZE" \
79
+ '{version: $version, filename: $filename, sha256: $sha256, fileCount: $fileCount, unpackedSize: $unpackedSize}')
80
+
81
+ if [ -n "${RELEASE_MANIFEST_OUT:-}" ]; then
82
+ printf '%s\n' "$MANIFEST" > "$RELEASE_MANIFEST_OUT"
83
+ fi
84
+
85
+ printf '%s\n' "$MANIFEST"
86
+ echo "Release candidate checks passed."
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bash
2
+ set -u
3
+
4
+ PACKAGE_NAME=aeoptimize
5
+ REPOSITORY=cucuwang/aeoptimize
6
+ EXPECTED_COMMIT=${1:-}
7
+ EXPECTED_PACKAGE_SHA256=${2:-}
8
+ EXPECTED_REPOSITORY_URL=git+https://github.com/cucuwang/aeoptimize.git
9
+ EXPECTED_HOMEPAGE=https://github.com/cucuwang/aeoptimize
10
+ EXPECTED_BUGS_URL=https://github.com/cucuwang/aeoptimize/issues
11
+ SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
12
+ PACKAGE_JSON="$SCRIPT_DIR/../package.json"
13
+
14
+ if ! command -v node >/dev/null 2>&1; then
15
+ echo "missing required command: node" >&2
16
+ exit 2
17
+ fi
18
+
19
+ EXPECTED_VERSION=$(node -e "const fs=require('node:fs');const packageJson=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));process.stdout.write(packageJson.version)" "$PACKAGE_JSON")
20
+ EXPECTED_TAG="v$EXPECTED_VERSION"
21
+
22
+ if [ -z "$EXPECTED_COMMIT" ] || [ -z "$EXPECTED_PACKAGE_SHA256" ]; then
23
+ echo "usage: $0 <expected-release-commit> <expected-package-sha256>" >&2
24
+ exit 2
25
+ fi
26
+
27
+ if ! [[ "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
28
+ echo "expected-release-commit must be a lowercase 40-character Git SHA" >&2
29
+ exit 2
30
+ fi
31
+
32
+ if ! [[ "$EXPECTED_PACKAGE_SHA256" =~ ^[0-9a-f]{64}$ ]]; then
33
+ echo "expected-package-sha256 must be a lowercase 64-character SHA-256" >&2
34
+ exit 2
35
+ fi
36
+
37
+ for command_name in awk curl jq npm git mktemp; do
38
+ if ! command -v "$command_name" >/dev/null 2>&1; then
39
+ echo "missing required command: $command_name" >&2
40
+ exit 2
41
+ fi
42
+ done
43
+
44
+ VERIFY_BASE=${TMPDIR:-/tmp}
45
+ VERIFY_BASE=${VERIFY_BASE%/}
46
+ VERIFY_ROOT=$(mktemp -d "$VERIFY_BASE/aeoptimize-release-verify.XXXXXX")
47
+ REGISTRY_JSON="$VERIFY_ROOT/registry.json"
48
+ RELEASE_JSON="$VERIFY_ROOT/release.json"
49
+ PACKAGE_TARBALL="$VERIFY_ROOT/$PACKAGE_NAME-$EXPECTED_VERSION.tgz"
50
+ CONSUMER_ROOT="$VERIFY_ROOT/consumer"
51
+ FAILURES=0
52
+
53
+ cleanup() {
54
+ if [ "${KEEP_VERIFY_ROOT:-0}" = "1" ]; then
55
+ echo "Verification workspace preserved: $VERIFY_ROOT"
56
+ return
57
+ fi
58
+
59
+ case "$VERIFY_ROOT" in
60
+ "$VERIFY_BASE"/aeoptimize-release-verify.*)
61
+ rm -rf -- "$VERIFY_ROOT"
62
+ ;;
63
+ *)
64
+ echo "Refusing to remove unexpected verification path: $VERIFY_ROOT" >&2
65
+ ;;
66
+ esac
67
+ }
68
+
69
+ trap cleanup EXIT
70
+
71
+ pass() {
72
+ echo "PASS: $1"
73
+ }
74
+
75
+ note() {
76
+ echo "INFO: $1"
77
+ }
78
+
79
+ fail() {
80
+ echo "FAIL: $1" >&2
81
+ FAILURES=$((FAILURES + 1))
82
+ }
83
+
84
+ if curl -fsS "https://registry.npmjs.org/$PACKAGE_NAME" > "$REGISTRY_JSON"; then
85
+ latest=$(jq -r '."dist-tags".latest // empty' "$REGISTRY_JSON")
86
+ if [ "$latest" = "$EXPECTED_VERSION" ]; then
87
+ pass "npm latest is $EXPECTED_VERSION"
88
+ else
89
+ fail "npm latest is ${latest:-missing}; expected $EXPECTED_VERSION"
90
+ fi
91
+
92
+ if jq -e --arg version "$EXPECTED_VERSION" '.versions[$version] != null' "$REGISTRY_JSON" >/dev/null; then
93
+ pass "npm contains exact version $EXPECTED_VERSION"
94
+
95
+ published_git_head=$(jq -r --arg version "$EXPECTED_VERSION" '.versions[$version].gitHead // empty' "$REGISTRY_JSON")
96
+ if [ -z "$published_git_head" ]; then
97
+ note "npm does not expose gitHead; tarball SHA-256 remains the artifact identity gate"
98
+ elif [ "$published_git_head" = "$EXPECTED_COMMIT" ]; then
99
+ pass "npm gitHead matches $EXPECTED_COMMIT"
100
+ else
101
+ fail "npm gitHead is $published_git_head; expected $EXPECTED_COMMIT"
102
+ fi
103
+
104
+ published_repository=$(jq -r --arg version "$EXPECTED_VERSION" '(.versions[$version].repository | if type == "object" then .url else . end) // empty' "$REGISTRY_JSON")
105
+ published_homepage=$(jq -r --arg version "$EXPECTED_VERSION" '.versions[$version].homepage // empty' "$REGISTRY_JSON")
106
+ published_bugs=$(jq -r --arg version "$EXPECTED_VERSION" '.versions[$version].bugs.url // empty' "$REGISTRY_JSON")
107
+
108
+ if [ "$published_repository" = "$EXPECTED_REPOSITORY_URL" ] && \
109
+ [ "$published_homepage" = "$EXPECTED_HOMEPAGE" ] && \
110
+ [ "$published_bugs" = "$EXPECTED_BUGS_URL" ]; then
111
+ pass "npm repository identity matches $REPOSITORY"
112
+ else
113
+ fail "npm repository identity does not match $REPOSITORY"
114
+ fi
115
+
116
+ tarball_url=$(jq -r --arg version "$EXPECTED_VERSION" '.versions[$version].dist.tarball // empty' "$REGISTRY_JSON")
117
+ if [ -n "$tarball_url" ] && curl -fLsS "$tarball_url" -o "$PACKAGE_TARBALL"; then
118
+ package_sha256=$(node -e "const crypto=require('node:crypto');const fs=require('node:fs');const path=process.argv[1];console.log(crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'))" "$PACKAGE_TARBALL")
119
+ if [ "$package_sha256" = "$EXPECTED_PACKAGE_SHA256" ]; then
120
+ pass "npm tarball SHA-256 matches the verified candidate"
121
+ else
122
+ fail "npm tarball SHA-256 is ${package_sha256:-missing}; expected $EXPECTED_PACKAGE_SHA256"
123
+ fi
124
+ else
125
+ fail "npm tarball could not be downloaded for SHA-256 verification"
126
+ fi
127
+
128
+ if npm --cache "$VERIFY_ROOT/npm-cache" install \
129
+ --ignore-scripts --no-audit --no-fund \
130
+ --prefix "$CONSUMER_ROOT" "$PACKAGE_TARBALL" >/dev/null; then
131
+ for binary in aeoptimize aeo aeo-cli; do
132
+ binary_version=$("$CONSUMER_ROOT/node_modules/.bin/$binary" --version 2>/dev/null || true)
133
+ if [ "$binary_version" = "$EXPECTED_VERSION" ]; then
134
+ pass "$binary resolves to $EXPECTED_VERSION from the public package"
135
+ else
136
+ fail "$binary returned ${binary_version:-no version}; expected $EXPECTED_VERSION"
137
+ fi
138
+ done
139
+ else
140
+ fail "clean consumer installation failed for the verified package tarball"
141
+ fi
142
+ else
143
+ fail "npm does not contain exact version $EXPECTED_VERSION"
144
+ fi
145
+ else
146
+ fail "npm registry metadata could not be fetched"
147
+ fi
148
+
149
+ tag_lines=$(git ls-remote --tags "https://github.com/$REPOSITORY.git" \
150
+ "refs/tags/$EXPECTED_TAG" "refs/tags/$EXPECTED_TAG^{}" 2>/dev/null || true)
151
+ tag_commit=$(printf '%s\n' "$tag_lines" | awk -v peeled="refs/tags/$EXPECTED_TAG^{}" '$2 == peeled { print $1 }')
152
+ if [ -z "$tag_commit" ]; then
153
+ tag_commit=$(printf '%s\n' "$tag_lines" | awk -v direct="refs/tags/$EXPECTED_TAG" '$2 == direct { print $1 }')
154
+ fi
155
+
156
+ if [ "$tag_commit" = "$EXPECTED_COMMIT" ]; then
157
+ pass "$EXPECTED_TAG points to $EXPECTED_COMMIT"
158
+ else
159
+ fail "$EXPECTED_TAG points to ${tag_commit:-missing}; expected $EXPECTED_COMMIT"
160
+ fi
161
+
162
+ release_status=$(curl -sS -o "$RELEASE_JSON" -w '%{http_code}' \
163
+ "https://api.github.com/repos/$REPOSITORY/releases/tags/$EXPECTED_TAG" || true)
164
+ if [ "$release_status" = "200" ]; then
165
+ release_state=$(jq -r '[.tag_name, (.draft | tostring), (.prerelease | tostring)] | @tsv' "$RELEASE_JSON")
166
+ if [ "$release_state" = "$EXPECTED_TAG"$'\tfalse\tfalse' ]; then
167
+ pass "GitHub Release is published for $EXPECTED_TAG"
168
+ else
169
+ fail "GitHub Release is not a published non-prerelease for $EXPECTED_TAG"
170
+ fi
171
+ else
172
+ fail "GitHub Release lookup returned HTTP ${release_status:-error}"
173
+ fi
174
+
175
+ if [ "$FAILURES" -ne 0 ]; then
176
+ echo "$FAILURES release verification check(s) failed." >&2
177
+ exit 1
178
+ fi
179
+
180
+ echo "All public release checks passed."