@tryinget/pi-agent-vent 0.1.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/LICENSE +78 -0
- package/README.md +188 -0
- package/biome.jsonc +43 -0
- package/docs/adr/2026-05-22-agent-vent-retention-delete-policy.md +65 -0
- package/docs/engineering.local.md +57 -0
- package/docs/project/2026-05-21-agent-vent-design.md +182 -0
- package/docs/project/2026-05-21-agent-vent-implementation-plan.md +66 -0
- package/docs/project/extension-sop.md +52 -0
- package/docs/project/product-posture.md +191 -0
- package/docs/project/trusted-publishing.md +53 -0
- package/docs/project/vision.md +70 -0
- package/examples/.gitkeep +0 -0
- package/extensions/agent-vent.ts +1384 -0
- package/package.json +95 -0
- package/policy/engineering-lane.json +22 -0
- package/policy/security-policy.json +10 -0
- package/prompts/implementation-planning.md +20 -0
- package/prompts/security-review.md +20 -0
- package/scripts/docs-list.sh +50 -0
- package/scripts/quality-gate.sh +168 -0
- package/scripts/release-artifact-check.mjs +221 -0
- package/scripts/release-check.sh +196 -0
- package/scripts/release-smoke-check.mjs +367 -0
- package/scripts/release-smoke.sh +90 -0
- package/scripts/validate-structure.mjs +248 -0
- package/scripts/validate-structure.sh +145 -0
- package/src/vent-store.js +2839 -0
- package/tests/.gitkeep +0 -0
- package/tests/agent-vent-extension.test.js +762 -0
- package/tests/release-artifact-check.test.js +168 -0
- package/tests/release-smoke-check.test.js +235 -0
- package/tests/vent-store.test.js +1972 -0
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tryinget/pi-agent-vent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local pi tool for agents to record recurring frustrations, bugs, and workflow friction without creating incidents or external telemetry",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tryingET/pi-extensions.git",
|
|
10
|
+
"directory": "packages/pi-agent-vent"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tryingET/pi-extensions/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/tryingET/pi-extensions/tree/main/packages/pi-agent-vent",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"agent-vent",
|
|
20
|
+
"monorepo",
|
|
21
|
+
"agent-feedback",
|
|
22
|
+
"frustration-capture",
|
|
23
|
+
"local-diagnostics",
|
|
24
|
+
"incident-candidate"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"registry": "https://registry.npmjs.org/",
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"fix": "bash ./scripts/quality-gate.sh fix",
|
|
35
|
+
"lint": "bash ./scripts/quality-gate.sh lint",
|
|
36
|
+
"typecheck": "bash ./scripts/quality-gate.sh typecheck",
|
|
37
|
+
"quality:pre-commit": "bash ./scripts/quality-gate.sh pre-commit",
|
|
38
|
+
"quality:pre-push": "bash ./scripts/quality-gate.sh pre-push",
|
|
39
|
+
"quality:ci": "bash ./scripts/quality-gate.sh ci",
|
|
40
|
+
"check": "npm run quality:ci",
|
|
41
|
+
"test": "npm run quality:ci",
|
|
42
|
+
"docs:list": "bash ./scripts/docs-list.sh",
|
|
43
|
+
"docs:list:workspace": "bash ./scripts/docs-list.sh --workspace --discover",
|
|
44
|
+
"docs:list:json": "bash ./scripts/docs-list.sh --json",
|
|
45
|
+
"release:check": "bash ./scripts/release-check.sh",
|
|
46
|
+
"release:check:quick": "SKIP_PI_SMOKE=1 bash ./scripts/release-check.sh"
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"extensions/agent-vent.ts",
|
|
50
|
+
"src/vent-store.js",
|
|
51
|
+
"scripts",
|
|
52
|
+
"tests",
|
|
53
|
+
"biome.jsonc",
|
|
54
|
+
"prompts",
|
|
55
|
+
"examples",
|
|
56
|
+
"policy/security-policy.json",
|
|
57
|
+
"policy/engineering-lane.json",
|
|
58
|
+
"docs/engineering.local.md",
|
|
59
|
+
"docs/project/vision.md",
|
|
60
|
+
"docs/project/product-posture.md",
|
|
61
|
+
"docs/project/2026-05-21-agent-vent-design.md",
|
|
62
|
+
"docs/project/2026-05-21-agent-vent-implementation-plan.md",
|
|
63
|
+
"docs/project/extension-sop.md",
|
|
64
|
+
"docs/project/trusted-publishing.md",
|
|
65
|
+
"docs/adr/2026-05-22-agent-vent-retention-delete-policy.md"
|
|
66
|
+
],
|
|
67
|
+
"pi": {
|
|
68
|
+
"extensions": [
|
|
69
|
+
"./extensions/agent-vent.ts"
|
|
70
|
+
],
|
|
71
|
+
"prompts": [
|
|
72
|
+
"./prompts"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"x-pi-template": {
|
|
76
|
+
"scaffoldMode": "simple-package",
|
|
77
|
+
"workspacePath": "packages/pi-agent-vent",
|
|
78
|
+
"releaseComponent": "pi-agent-vent",
|
|
79
|
+
"releaseConfigMode": "component"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@biomejs/biome": "2.3.14"
|
|
83
|
+
},
|
|
84
|
+
"overrides": {
|
|
85
|
+
"fast-xml-parser": "5.3.6"
|
|
86
|
+
},
|
|
87
|
+
"peerDependencies": {
|
|
88
|
+
"@mariozechner/pi-ai": "*",
|
|
89
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
90
|
+
"typebox": "*"
|
|
91
|
+
},
|
|
92
|
+
"dependencies": {
|
|
93
|
+
"typebox": "^1.0.0"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lane": "ts",
|
|
3
|
+
"engineering_core": {
|
|
4
|
+
"tool": "engineering-core",
|
|
5
|
+
"lane": "pi-ts",
|
|
6
|
+
"repository": "https://github.com/tryingET/core_engineering-core",
|
|
7
|
+
"ref": "workspace-local-unpinned",
|
|
8
|
+
"command": "uv tool -n run --from ~/ai-society/core/engineering-core engineering-core show pi-ts",
|
|
9
|
+
"catalog_command": "uv tool -n run --from ~/ai-society/core/engineering-core engineering-core catalog --pretty",
|
|
10
|
+
"list_disciplines_command": "uv tool -n run --from ~/ai-society/core/engineering-core engineering-core list-disciplines",
|
|
11
|
+
"list_templates_command": "uv tool -n run --from ~/ai-society/core/engineering-core engineering-core list-templates",
|
|
12
|
+
"disciplines": [
|
|
13
|
+
"validation",
|
|
14
|
+
"testing",
|
|
15
|
+
"security-privacy",
|
|
16
|
+
"documentation",
|
|
17
|
+
"dependency-governance",
|
|
18
|
+
"specification-and-dsls",
|
|
19
|
+
"engineering-reasoning"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Monorepo package implementation planning prompt template."
|
|
3
|
+
read_when:
|
|
4
|
+
- "Using or updating the monorepo package implementation-planning prompt template."
|
|
5
|
+
description: Draft an implementation plan for a requested change
|
|
6
|
+
system4d:
|
|
7
|
+
container: "Prompt template for implementation planning."
|
|
8
|
+
compass: "Turn requests into actionable, risk-aware plans."
|
|
9
|
+
engine: "Scope -> tasks -> validation -> rollout."
|
|
10
|
+
fog: "Hidden constraints unless assumptions are surfaced."
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Create an implementation plan for this request: $@
|
|
14
|
+
|
|
15
|
+
Include:
|
|
16
|
+
- Scope and non-goals
|
|
17
|
+
- Key risks and mitigations
|
|
18
|
+
- Step-by-step implementation tasks
|
|
19
|
+
- Validation commands and expected outcomes
|
|
20
|
+
- Rollout and rollback notes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
summary: "Monorepo package security review prompt template."
|
|
3
|
+
read_when:
|
|
4
|
+
- "Using or updating the monorepo package security-review prompt template."
|
|
5
|
+
description: Review a change for security risks and mitigations
|
|
6
|
+
system4d:
|
|
7
|
+
container: "Prompt template for security-focused review."
|
|
8
|
+
compass: "Identify practical vulnerabilities before release."
|
|
9
|
+
engine: "Threats -> impact -> mitigations -> verification."
|
|
10
|
+
fog: "Partial context can hide exploit paths."
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Review this change for security concerns: $@
|
|
14
|
+
|
|
15
|
+
Focus on:
|
|
16
|
+
- Input validation and injection risk
|
|
17
|
+
- Privilege boundaries and secret handling
|
|
18
|
+
- Dependency and supply-chain risk
|
|
19
|
+
- Safe failure modes and logging
|
|
20
|
+
- Concrete remediations with priority
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
DEFAULT_SCRIPT="$HOME/ai-society/core/agent-scripts/scripts/docs-list.mjs"
|
|
6
|
+
LOCAL_FALLBACK_SCRIPT="$ROOT_DIR/scripts/docs-list.mjs"
|
|
7
|
+
|
|
8
|
+
usage() {
|
|
9
|
+
cat <<'USAGE'
|
|
10
|
+
Usage: ./scripts/docs-list.sh [docs-list args]
|
|
11
|
+
|
|
12
|
+
Resolves docs-list script in this order:
|
|
13
|
+
1) DOCS_LIST_SCRIPT env var (absolute/relative path)
|
|
14
|
+
2) Local fallback: ./scripts/docs-list.mjs
|
|
15
|
+
3) Default global path: ~/ai-society/core/agent-scripts/scripts/docs-list.mjs
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
./scripts/docs-list.sh
|
|
19
|
+
./scripts/docs-list.sh --workspace --discover
|
|
20
|
+
./scripts/docs-list.sh --json
|
|
21
|
+
USAGE
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
|
25
|
+
usage
|
|
26
|
+
exit 0
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
30
|
+
echo "Error: node is required to run docs-list." >&2
|
|
31
|
+
exit 1
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
SCRIPT_PATH=""
|
|
35
|
+
|
|
36
|
+
if [[ -n "${DOCS_LIST_SCRIPT:-}" ]]; then
|
|
37
|
+
SCRIPT_PATH="${DOCS_LIST_SCRIPT}"
|
|
38
|
+
elif [[ -f "$LOCAL_FALLBACK_SCRIPT" ]]; then
|
|
39
|
+
SCRIPT_PATH="$LOCAL_FALLBACK_SCRIPT"
|
|
40
|
+
else
|
|
41
|
+
SCRIPT_PATH="$DEFAULT_SCRIPT"
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
if [[ ! -f "$SCRIPT_PATH" ]]; then
|
|
45
|
+
echo "Error: docs-list script not found: $SCRIPT_PATH" >&2
|
|
46
|
+
echo "Set DOCS_LIST_SCRIPT to your docs-list.mjs path, or place docs-list script at: $DEFAULT_SCRIPT" >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
node "$SCRIPT_PATH" "$@"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
5
|
+
SEARCH_DIR="$ROOT_DIR"
|
|
6
|
+
OVERRIDE_GATE="${PACKAGE_QUALITY_GATE_SCRIPT:-}"
|
|
7
|
+
|
|
8
|
+
if [[ -n "$OVERRIDE_GATE" ]]; then
|
|
9
|
+
if [[ -f "$OVERRIDE_GATE" ]]; then
|
|
10
|
+
exec bash "$OVERRIDE_GATE" "${1:-}" "$ROOT_DIR"
|
|
11
|
+
fi
|
|
12
|
+
echo "error: PACKAGE_QUALITY_GATE_SCRIPT does not exist: $OVERRIDE_GATE" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
while [[ "$SEARCH_DIR" != "/" ]]; do
|
|
17
|
+
if [[ -x "$SEARCH_DIR/scripts/package-quality-gate.sh" ]]; then
|
|
18
|
+
exec bash "$SEARCH_DIR/scripts/package-quality-gate.sh" "${1:-}" "$ROOT_DIR"
|
|
19
|
+
fi
|
|
20
|
+
SEARCH_DIR="$(dirname "$SEARCH_DIR")"
|
|
21
|
+
done
|
|
22
|
+
|
|
23
|
+
stage="${1:-}"
|
|
24
|
+
case "$stage" in
|
|
25
|
+
lint|fix|typecheck|test|pre-commit|pre-push|ci) ;;
|
|
26
|
+
*)
|
|
27
|
+
echo "Usage: bash ./scripts/quality-gate.sh <lint|fix|typecheck|test|pre-commit|pre-push|ci>" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
relative_root() {
|
|
33
|
+
basename "$ROOT_DIR"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
has_biome_config() {
|
|
37
|
+
[[ -f "$ROOT_DIR/biome.json" ]] || [[ -f "$ROOT_DIR/biome.jsonc" ]]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
run_biome() {
|
|
41
|
+
if ! has_biome_config; then
|
|
42
|
+
echo "lint: skipped ($(relative_root), no biome config found)"
|
|
43
|
+
return 0
|
|
44
|
+
fi
|
|
45
|
+
if [[ ! -x "$ROOT_DIR/node_modules/.bin/biome" ]]; then
|
|
46
|
+
echo "biome: skipped ($(relative_root), local biome binary unavailable; run npm install to enable lint)."
|
|
47
|
+
return 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Runtime tarballs do not necessarily include a VCS ignore file. The package biome config
|
|
51
|
+
# already excludes node_modules/dist/coverage, so disable VCS-ignore-file loading there.
|
|
52
|
+
local -a vcs_args=()
|
|
53
|
+
if [[ ! -f "$ROOT_DIR/.gitignore" ]]; then
|
|
54
|
+
vcs_args+=(--vcs-use-ignore-file=false)
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
(cd "$ROOT_DIR" && ./node_modules/.bin/biome "$@" "${vcs_args[@]}")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
run_lint() {
|
|
61
|
+
run_biome check --no-errors-on-unmatched .
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
run_fix() {
|
|
65
|
+
run_biome check --write --no-errors-on-unmatched .
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
run_typecheck() {
|
|
69
|
+
if [[ ! -f "$ROOT_DIR/tsconfig.json" ]]; then
|
|
70
|
+
echo "typecheck: skipped ($(relative_root), no tsconfig.json found)"
|
|
71
|
+
return 0
|
|
72
|
+
fi
|
|
73
|
+
if [[ -x "$ROOT_DIR/node_modules/.bin/tsgo" ]]; then
|
|
74
|
+
(cd "$ROOT_DIR" && ./node_modules/.bin/tsgo --noEmit)
|
|
75
|
+
return 0
|
|
76
|
+
fi
|
|
77
|
+
if [[ -x "$ROOT_DIR/node_modules/.bin/tsc" ]]; then
|
|
78
|
+
(cd "$ROOT_DIR" && ./node_modules/.bin/tsc --noEmit)
|
|
79
|
+
return 0
|
|
80
|
+
fi
|
|
81
|
+
echo "typecheck: skipped ($(relative_root), tsconfig found but no local tsgo/tsc binary)."
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
run_tests() {
|
|
85
|
+
if [[ ! -d "$ROOT_DIR/tests" ]]; then
|
|
86
|
+
echo "tests: skipped ($(relative_root), no tests directory found)"
|
|
87
|
+
return 0
|
|
88
|
+
fi
|
|
89
|
+
mapfile -t test_files < <(
|
|
90
|
+
cd "$ROOT_DIR"
|
|
91
|
+
find tests -type f \( \
|
|
92
|
+
-name '*.test.js' -o \
|
|
93
|
+
-name '*.test.mjs' -o \
|
|
94
|
+
-name '*.test.cjs' -o \
|
|
95
|
+
-name '*.test.ts' -o \
|
|
96
|
+
-name '*.test.tsx' -o \
|
|
97
|
+
-name '*.test.mts' -o \
|
|
98
|
+
-name '*.test.cts' \
|
|
99
|
+
\) | LC_ALL=C sort
|
|
100
|
+
)
|
|
101
|
+
if [[ "${#test_files[@]}" -eq 0 ]]; then
|
|
102
|
+
echo "tests: skipped ($(relative_root), no test files found)"
|
|
103
|
+
return 0
|
|
104
|
+
fi
|
|
105
|
+
local needs_tsx="false"
|
|
106
|
+
local test_file
|
|
107
|
+
for test_file in "${test_files[@]}"; do
|
|
108
|
+
case "$test_file" in
|
|
109
|
+
*.test.ts|*.test.tsx|*.test.mts|*.test.cts)
|
|
110
|
+
needs_tsx="true"
|
|
111
|
+
break
|
|
112
|
+
;;
|
|
113
|
+
esac
|
|
114
|
+
done
|
|
115
|
+
|
|
116
|
+
if [[ "$needs_tsx" == "true" ]]; then
|
|
117
|
+
if [[ ! -x "$ROOT_DIR/node_modules/.bin/tsx" ]]; then
|
|
118
|
+
echo "tests: TypeScript test files detected but local tsx binary is unavailable." >&2
|
|
119
|
+
exit 1
|
|
120
|
+
fi
|
|
121
|
+
(cd "$ROOT_DIR" && node --import tsx --test "${test_files[@]}")
|
|
122
|
+
return 0
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
(cd "$ROOT_DIR" && node --test "${test_files[@]}")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
run_structure_if_source_checkout() {
|
|
129
|
+
if [[ -f "$ROOT_DIR/.copier-answers.yml" && -f "$ROOT_DIR/scripts/validate-structure.sh" ]]; then
|
|
130
|
+
(cd "$ROOT_DIR" && bash ./scripts/validate-structure.sh)
|
|
131
|
+
else
|
|
132
|
+
echo "structure: skipped ($(relative_root), source-checkout structure contract not present)"
|
|
133
|
+
fi
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
echo "==> package local quality gate: $(relative_root) [$stage]"
|
|
137
|
+
echo "note: monorepo root gate not found; running self-contained package fallback."
|
|
138
|
+
|
|
139
|
+
case "$stage" in
|
|
140
|
+
lint)
|
|
141
|
+
run_lint
|
|
142
|
+
;;
|
|
143
|
+
fix)
|
|
144
|
+
run_fix
|
|
145
|
+
;;
|
|
146
|
+
typecheck)
|
|
147
|
+
run_typecheck
|
|
148
|
+
;;
|
|
149
|
+
test)
|
|
150
|
+
run_tests
|
|
151
|
+
;;
|
|
152
|
+
pre-commit)
|
|
153
|
+
run_structure_if_source_checkout
|
|
154
|
+
run_lint
|
|
155
|
+
;;
|
|
156
|
+
pre-push)
|
|
157
|
+
run_structure_if_source_checkout
|
|
158
|
+
run_lint
|
|
159
|
+
run_typecheck
|
|
160
|
+
run_tests
|
|
161
|
+
;;
|
|
162
|
+
ci)
|
|
163
|
+
run_structure_if_source_checkout
|
|
164
|
+
run_lint
|
|
165
|
+
run_typecheck
|
|
166
|
+
run_tests
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const normalizePackPath = (value) => String(value).replace(/^\.\//, "").replace(/\\/g, "/");
|
|
5
|
+
|
|
6
|
+
export const assertSafePackPath = (filePath) => {
|
|
7
|
+
if (path.posix.isAbsolute(filePath) || filePath === ".." || filePath.split("/").includes("..")) {
|
|
8
|
+
throw new Error(`npm pack output contains unsafe package path: ${filePath}`);
|
|
9
|
+
}
|
|
10
|
+
return filePath;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const parsePackJson = (packJsonText) => {
|
|
14
|
+
let pack;
|
|
15
|
+
try {
|
|
16
|
+
pack = JSON.parse(packJsonText || "[]");
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Could not parse npm pack --dry-run --json output: ${error.message}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!Array.isArray(pack) || !pack[0] || !Array.isArray(pack[0].files)) {
|
|
22
|
+
throw new Error("Could not parse npm pack --dry-run --json output.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return pack[0].files
|
|
26
|
+
.map((file) => normalizePackPath(String(file.path || "")))
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map(assertSafePackPath)
|
|
29
|
+
.sort();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const readPackageFilesContract = (packageJsonPath = "package.json") => {
|
|
33
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
34
|
+
const filesEntries = Array.isArray(pkg.files)
|
|
35
|
+
? pkg.files.map((entry) => normalizePackPath(String(entry).trim())).filter(Boolean)
|
|
36
|
+
: [];
|
|
37
|
+
|
|
38
|
+
if (filesEntries.length === 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"package.json must define a non-empty files array for deterministic publish artifacts.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return filesEntries;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isAlwaysIncluded = (filePath) =>
|
|
48
|
+
/^README(?:\.[^/]+)?$/i.test(filePath) ||
|
|
49
|
+
/^LICENSE(?:\.[^/]+)?$/i.test(filePath) ||
|
|
50
|
+
/^NOTICE(?:\.[^/]+)?$/i.test(filePath);
|
|
51
|
+
|
|
52
|
+
export const validatePackageFilesWhitelist = ({
|
|
53
|
+
actualFiles,
|
|
54
|
+
filesEntries,
|
|
55
|
+
cwd = process.cwd(),
|
|
56
|
+
}) => {
|
|
57
|
+
const actualSet = new Set(actualFiles);
|
|
58
|
+
const expectedExact = new Set(["package.json"]);
|
|
59
|
+
const expectedDirPrefixes = [];
|
|
60
|
+
|
|
61
|
+
for (const entry of filesEntries) {
|
|
62
|
+
if (/[*?[]/.test(entry)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Unsupported files[] wildcard entry; use exact files or directories: ${entry}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const fullPath = path.resolve(cwd, entry);
|
|
69
|
+
if (!fs.existsSync(fullPath)) {
|
|
70
|
+
throw new Error(`files[] entry does not exist: ${entry}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const stat = fs.statSync(fullPath);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
expectedDirPrefixes.push(entry.endsWith("/") ? entry : `${entry}/`);
|
|
76
|
+
} else {
|
|
77
|
+
expectedExact.add(entry);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const missing = [];
|
|
82
|
+
for (const filePath of expectedExact) {
|
|
83
|
+
if (!actualSet.has(filePath)) {
|
|
84
|
+
missing.push(filePath);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const prefix of expectedDirPrefixes) {
|
|
88
|
+
if (!actualFiles.some((filePath) => filePath.startsWith(prefix))) {
|
|
89
|
+
missing.push(`${prefix}*`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const extra = actualFiles.filter((filePath) => {
|
|
94
|
+
if (expectedExact.has(filePath)) return false;
|
|
95
|
+
if (expectedDirPrefixes.some((prefix) => filePath.startsWith(prefix))) return false;
|
|
96
|
+
if (isAlwaysIncluded(filePath)) return false;
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return { missing, extra };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const isExternalMarkdownDestination = (target) =>
|
|
104
|
+
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target) || target.startsWith("//");
|
|
105
|
+
|
|
106
|
+
export const markdownDestination = (rawTarget) => {
|
|
107
|
+
let target = String(rawTarget || "").trim();
|
|
108
|
+
if (!target) return "";
|
|
109
|
+
if (target.startsWith("<")) {
|
|
110
|
+
const closingAngle = target.indexOf(">");
|
|
111
|
+
target = closingAngle >= 0 ? target.slice(1, closingAngle).trim() : target;
|
|
112
|
+
} else {
|
|
113
|
+
target = target.split(/\s+/, 1)[0] || "";
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return decodeURI(target);
|
|
117
|
+
} catch {
|
|
118
|
+
return target;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const markdownLinkPattern = /!?\[[^\]\n]*\]\(([^)\n]+)\)/g;
|
|
123
|
+
const referenceLinkDefinitionPattern = /^[ \t]{0,3}\[[^\]\n]+\]:[ \t]*(\S.*)$/gm;
|
|
124
|
+
|
|
125
|
+
const collectMarkdownDestinations = (markdown) => [
|
|
126
|
+
...[...markdown.matchAll(markdownLinkPattern)].map((match) => match[1]),
|
|
127
|
+
...[...markdown.matchAll(referenceLinkDefinitionPattern)].map((match) => match[1]),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
export const collectPackagedMarkdownLinkFailures = ({ actualFiles, readFile }) => {
|
|
131
|
+
const actualSet = new Set(actualFiles);
|
|
132
|
+
const failures = [];
|
|
133
|
+
const packagedMarkdownFiles = actualFiles.filter((filePath) =>
|
|
134
|
+
filePath.toLowerCase().endsWith(".md"),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
for (const markdownPath of packagedMarkdownFiles) {
|
|
138
|
+
const markdown = readFile(markdownPath);
|
|
139
|
+
for (const rawTarget of collectMarkdownDestinations(markdown)) {
|
|
140
|
+
const target = markdownDestination(rawTarget);
|
|
141
|
+
if (!target || target.startsWith("#") || isExternalMarkdownDestination(target)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const targetWithoutFragment = target.split("#", 1)[0].split("?", 1)[0];
|
|
146
|
+
if (!targetWithoutFragment) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const normalizedTarget = normalizePackPath(
|
|
151
|
+
path.posix.normalize(
|
|
152
|
+
path.posix.join(path.posix.dirname(markdownPath), targetWithoutFragment),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
if (
|
|
156
|
+
path.posix.isAbsolute(targetWithoutFragment) ||
|
|
157
|
+
normalizedTarget === ".." ||
|
|
158
|
+
normalizedTarget.startsWith("../")
|
|
159
|
+
) {
|
|
160
|
+
failures.push(`${markdownPath}: local link escapes package root: ${target}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const directoryPrefix = normalizedTarget.endsWith("/")
|
|
165
|
+
? normalizedTarget
|
|
166
|
+
: `${normalizedTarget}/`;
|
|
167
|
+
const targetIsPackaged =
|
|
168
|
+
actualSet.has(normalizedTarget) ||
|
|
169
|
+
actualSet.has(`${normalizedTarget}.md`) ||
|
|
170
|
+
actualFiles.some((filePath) => filePath.startsWith(directoryPrefix));
|
|
171
|
+
if (!targetIsPackaged) {
|
|
172
|
+
failures.push(`${markdownPath}: local link missing from npm artifact: ${target}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { failures, packagedMarkdownCount: packagedMarkdownFiles.length };
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export const runReleaseArtifactCheck = ({
|
|
181
|
+
packJsonText,
|
|
182
|
+
cwd = process.cwd(),
|
|
183
|
+
log = console.log,
|
|
184
|
+
}) => {
|
|
185
|
+
const actualFiles = parsePackJson(packJsonText);
|
|
186
|
+
const filesEntries = readPackageFilesContract(path.join(cwd, "package.json"));
|
|
187
|
+
const whitelist = validatePackageFilesWhitelist({ actualFiles, filesEntries, cwd });
|
|
188
|
+
|
|
189
|
+
if (whitelist.missing.length || whitelist.extra.length) {
|
|
190
|
+
const lines = ["Publish file whitelist mismatch."];
|
|
191
|
+
if (whitelist.missing.length) lines.push(`Missing: ${whitelist.missing.join(", ")}`);
|
|
192
|
+
if (whitelist.extra.length) lines.push(`Extra: ${whitelist.extra.join(", ")}`);
|
|
193
|
+
throw new Error(lines.join("\n"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const markdown = collectPackagedMarkdownLinkFailures({
|
|
197
|
+
actualFiles,
|
|
198
|
+
readFile: (filePath) => fs.readFileSync(path.join(cwd, filePath), "utf8"),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (markdown.failures.length) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
[
|
|
204
|
+
"Packaged Markdown link check failed.",
|
|
205
|
+
...markdown.failures.map((failure) => `- ${failure}`),
|
|
206
|
+
].join("\n"),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
log(`File whitelist OK (${actualFiles.length} files).`);
|
|
211
|
+
log(`Packaged Markdown links OK (${markdown.packagedMarkdownCount} files).`);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
215
|
+
try {
|
|
216
|
+
runReleaseArtifactCheck({ packJsonText: process.env.PACK_JSON || "[]" });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error(error.message);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
}
|