oss-signal 0.1.0 → 0.2.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/CHANGELOG.md +8 -0
- package/README.md +35 -10
- package/action.yml +41 -0
- package/docs/adoption-evidence.md +58 -0
- package/docs/examples/github-action-workflow.yml +22 -0
- package/docs/examples/github-url-report.md +41 -0
- package/docs/self-audit.md +2 -1
- package/package.json +4 -2
- package/src/action.js +102 -0
- package/src/cli.js +17 -5
- package/src/index.js +227 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Added direct GitHub repository audits for public repositories.
|
|
6
|
+
- Added `owner/repo` shorthand and `--ref` support.
|
|
7
|
+
- Added GitHub community profile evidence for shared maintainer files.
|
|
8
|
+
- Added a zero-dependency GitHub Action wrapper with score outputs.
|
|
9
|
+
- Updated the CLI help output and package metadata for npm 11.
|
|
10
|
+
|
|
3
11
|
## 0.1.0
|
|
4
12
|
|
|
5
13
|
- Initial CLI with Markdown and JSON output.
|
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Open-source projects often fail quietly because the maintainer workflow is undoc
|
|
|
21
21
|
- Contributors can attach a report to a cleanup issue or pull request.
|
|
22
22
|
- Teams can gate release readiness with `--fail-under`.
|
|
23
23
|
- Foundations and working groups can compare repository hygiene across many projects.
|
|
24
|
+
- CI maintainers can add it as a GitHub Action and publish the report as an artifact.
|
|
24
25
|
|
|
25
26
|
## Install
|
|
26
27
|
|
|
@@ -45,6 +46,13 @@ Audit the current directory:
|
|
|
45
46
|
oss-signal
|
|
46
47
|
```
|
|
47
48
|
|
|
49
|
+
Audit a public GitHub repository without cloning it:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
oss-signal https://github.com/SalmonPlays/oss-signal
|
|
53
|
+
oss-signal platformatic/massimo --format json
|
|
54
|
+
```
|
|
55
|
+
|
|
48
56
|
Write a Markdown report:
|
|
49
57
|
|
|
50
58
|
```bash
|
|
@@ -73,6 +81,8 @@ oss-signal . --format markdown --output docs/maintainer-readiness.md
|
|
|
73
81
|
|
|
74
82
|
See [docs/rules.md](docs/rules.md) for rule details and scoring weights.
|
|
75
83
|
|
|
84
|
+
For GitHub URL audits, `oss-signal` reads the repository file tree through the GitHub API and also uses GitHub's community profile signal when available. This lets it detect organization-level files such as a shared code of conduct.
|
|
85
|
+
|
|
76
86
|
## Real Output
|
|
77
87
|
|
|
78
88
|
This repository audits itself at **100/100 (A)**:
|
|
@@ -86,7 +96,7 @@ Summary:
|
|
|
86
96
|
- Total checks: 15
|
|
87
97
|
```
|
|
88
98
|
|
|
89
|
-
See [docs/self-audit.md](docs/self-audit.md) for the full self-audit report.
|
|
99
|
+
See [docs/self-audit.md](docs/self-audit.md) for the full local self-audit report and [docs/examples/github-url-report.md](docs/examples/github-url-report.md) for the GitHub URL audit output.
|
|
90
100
|
|
|
91
101
|
## Field Audits
|
|
92
102
|
|
|
@@ -98,6 +108,8 @@ See [docs/self-audit.md](docs/self-audit.md) for the full self-audit report.
|
|
|
98
108
|
|
|
99
109
|
See [docs/outreach](docs/outreach) for the reports and draft issue text. Drafts are not posted automatically; maintainers should only receive specific, useful, and respectful suggestions.
|
|
100
110
|
|
|
111
|
+
For a compact maintainer/adoption summary, see [docs/adoption-evidence.md](docs/adoption-evidence.md).
|
|
112
|
+
|
|
101
113
|
## Example Recommendation Output
|
|
102
114
|
|
|
103
115
|
```text
|
|
@@ -120,12 +132,17 @@ When `--fail-under <score>` is provided, it exits with `1` if the score is below
|
|
|
120
132
|
oss-signal . --fail-under 80
|
|
121
133
|
```
|
|
122
134
|
|
|
123
|
-
##
|
|
135
|
+
## GitHub Action
|
|
124
136
|
|
|
125
|
-
Add
|
|
137
|
+
Add `oss-signal` directly to a GitHub Actions workflow:
|
|
126
138
|
|
|
127
139
|
```yaml
|
|
128
|
-
-
|
|
140
|
+
- uses: SalmonPlays/oss-signal@v0.2.0
|
|
141
|
+
id: oss-signal
|
|
142
|
+
with:
|
|
143
|
+
fail-under: "80"
|
|
144
|
+
output: oss-signal-report.md
|
|
145
|
+
- run: echo "score ${{ steps.oss-signal.outputs.score }} (${{ steps.oss-signal.outputs.grade }})"
|
|
129
146
|
```
|
|
130
147
|
|
|
131
148
|
Full workflow example:
|
|
@@ -143,25 +160,33 @@ jobs:
|
|
|
143
160
|
runs-on: ubuntu-latest
|
|
144
161
|
steps:
|
|
145
162
|
- uses: actions/checkout@v4
|
|
146
|
-
- uses:
|
|
163
|
+
- uses: SalmonPlays/oss-signal@v0.2.0
|
|
164
|
+
id: oss-signal
|
|
147
165
|
with:
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
fail-under: "80"
|
|
167
|
+
output: oss-signal-report.md
|
|
150
168
|
- uses: actions/upload-artifact@v4
|
|
151
169
|
with:
|
|
152
170
|
name: oss-signal-report
|
|
153
171
|
path: oss-signal-report.md
|
|
154
172
|
```
|
|
155
173
|
|
|
174
|
+
See [docs/examples/github-action-workflow.yml](docs/examples/github-action-workflow.yml) for a copyable workflow.
|
|
175
|
+
|
|
176
|
+
You can also run the CLI directly in CI:
|
|
177
|
+
|
|
178
|
+
```yaml
|
|
179
|
+
- run: npx oss-signal . --format markdown --output oss-signal-report.md --fail-under 80
|
|
180
|
+
```
|
|
181
|
+
|
|
156
182
|
## Current Limitations
|
|
157
183
|
|
|
158
|
-
- It
|
|
159
|
-
-
|
|
184
|
+
- It checks deterministic maintenance signals, not code quality or project importance.
|
|
185
|
+
- GitHub URL mode uses unauthenticated API requests unless `GITHUB_TOKEN` is set, so very heavy usage may hit GitHub rate limits.
|
|
160
186
|
- A high score does not prove a project is important. It proves the maintainer workflow is documented and automatable.
|
|
161
187
|
|
|
162
188
|
## Roadmap
|
|
163
189
|
|
|
164
|
-
- GitHub API mode for public repository URLs
|
|
165
190
|
- Ecosystem-specific profiles for Python, Rust, Go, and JavaScript packages
|
|
166
191
|
- SARIF output for code scanning dashboards
|
|
167
192
|
- Rules for release automation and provenance metadata
|
package/action.yml
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: oss-signal
|
|
2
|
+
description: Audit open-source repository maintenance readiness and produce actionable maintainer next steps.
|
|
3
|
+
author: SalmonPlays
|
|
4
|
+
branding:
|
|
5
|
+
icon: activity
|
|
6
|
+
color: blue
|
|
7
|
+
inputs:
|
|
8
|
+
path:
|
|
9
|
+
description: Local repository path, GitHub URL, or owner/repo shorthand to audit.
|
|
10
|
+
required: false
|
|
11
|
+
default: "."
|
|
12
|
+
format:
|
|
13
|
+
description: Output format, either markdown or json.
|
|
14
|
+
required: false
|
|
15
|
+
default: markdown
|
|
16
|
+
output:
|
|
17
|
+
description: Report file path.
|
|
18
|
+
required: false
|
|
19
|
+
default: oss-signal-report.md
|
|
20
|
+
fail-under:
|
|
21
|
+
description: Fail the action when the score is below this number.
|
|
22
|
+
required: false
|
|
23
|
+
max-files:
|
|
24
|
+
description: Maximum files to inspect.
|
|
25
|
+
required: false
|
|
26
|
+
default: "20000"
|
|
27
|
+
ref:
|
|
28
|
+
description: Git ref for GitHub URL audits.
|
|
29
|
+
required: false
|
|
30
|
+
outputs:
|
|
31
|
+
score:
|
|
32
|
+
description: Numeric maintainer-readiness score.
|
|
33
|
+
grade:
|
|
34
|
+
description: Letter grade for the maintainer-readiness score.
|
|
35
|
+
failed:
|
|
36
|
+
description: Number of failed checks.
|
|
37
|
+
report-path:
|
|
38
|
+
description: Path to the generated report file, when output is enabled.
|
|
39
|
+
runs:
|
|
40
|
+
using: node20
|
|
41
|
+
main: src/action.js
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Adoption Evidence
|
|
2
|
+
|
|
3
|
+
This page collects the public evidence that `oss-signal` is built for real open-source maintainer workflows.
|
|
4
|
+
|
|
5
|
+
## Project Links
|
|
6
|
+
|
|
7
|
+
- Repository: https://github.com/SalmonPlays/oss-signal
|
|
8
|
+
- npm package: https://www.npmjs.com/package/oss-signal
|
|
9
|
+
- GitHub Action tag: https://github.com/SalmonPlays/oss-signal/tree/v0.2.0
|
|
10
|
+
- GitHub Action metadata: [action.yml](../action.yml)
|
|
11
|
+
- Self-audit report: [docs/self-audit.md](self-audit.md)
|
|
12
|
+
- GitHub URL audit report: [docs/examples/github-url-report.md](examples/github-url-report.md)
|
|
13
|
+
- GitHub Action workflow example: [docs/examples/github-action-workflow.yml](examples/github-action-workflow.yml)
|
|
14
|
+
- Rule reference: [docs/rules.md](rules.md)
|
|
15
|
+
|
|
16
|
+
## Maintainer Use Case
|
|
17
|
+
|
|
18
|
+
`oss-signal` audits repository maintenance readiness and returns a score with concrete next steps. It is aimed at work maintainers actually do: documenting contribution paths, setting support boundaries, keeping CI visible, collecting useful issue context, and making security reporting easier.
|
|
19
|
+
|
|
20
|
+
The CLI supports two practical modes:
|
|
21
|
+
|
|
22
|
+
- Local repository audit for maintainers working in a clone.
|
|
23
|
+
- Public GitHub repository audit for quick triage without cloning.
|
|
24
|
+
|
|
25
|
+
It also ships as a GitHub Action, so maintainers can gate repository hygiene in CI and upload a Markdown report as a workflow artifact.
|
|
26
|
+
|
|
27
|
+
## Public Field Audits
|
|
28
|
+
|
|
29
|
+
The tool has been used to generate maintainer-readiness reports for public repositories and convert them into respectful cleanup issues:
|
|
30
|
+
|
|
31
|
+
| Repository | Report | Posted issue |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `platformatic/massimo` | [report](outreach/platformatic-massimo-report.md) | https://github.com/platformatic/massimo/issues/159 |
|
|
34
|
+
| `supermarkt/checkjebon` | [report](outreach/supermarkt-checkjebon-report.md) | https://github.com/supermarkt/checkjebon/issues/22 |
|
|
35
|
+
| `sammorrisdesign/interactive-feed` | [report](outreach/sammorrisdesign-interactive-feed-report.md) | https://github.com/sammorrisdesign/interactive-feed/issues/14 |
|
|
36
|
+
|
|
37
|
+
These issues are evidence of the intended maintainer workflow: run a deterministic audit, explain the missing signals, and give maintainers a small set of actionable improvements.
|
|
38
|
+
|
|
39
|
+
## Verification Commands
|
|
40
|
+
|
|
41
|
+
From this repository:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm run check
|
|
45
|
+
npm run audit:github
|
|
46
|
+
node src/cli.js platformatic/massimo --format json
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The current repository self-audit score is 100/100, the GitHub community profile health score is 100, and CI verifies the local GitHub Action wrapper.
|
|
50
|
+
|
|
51
|
+
Public CI evidence:
|
|
52
|
+
|
|
53
|
+
- GitHub Action self-test job: https://github.com/SalmonPlays/oss-signal/actions/runs/26801682014/job/79009525705
|
|
54
|
+
- CodeQL run: https://github.com/SalmonPlays/oss-signal/actions/runs/26801681976
|
|
55
|
+
|
|
56
|
+
## Boundaries
|
|
57
|
+
|
|
58
|
+
`oss-signal` does not claim that a repository is high quality or widely adopted. It measures maintainability signals that are visible in repository files and GitHub community profile metadata.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Repository health
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
oss-signal:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: SalmonPlays/oss-signal@v0.2.0
|
|
14
|
+
id: oss-signal
|
|
15
|
+
with:
|
|
16
|
+
fail-under: "80"
|
|
17
|
+
output: oss-signal-report.md
|
|
18
|
+
- uses: actions/upload-artifact@v4
|
|
19
|
+
with:
|
|
20
|
+
name: oss-signal-report
|
|
21
|
+
path: oss-signal-report.md
|
|
22
|
+
- run: echo "oss-signal score ${{ steps.oss-signal.outputs.score }} (${{ steps.oss-signal.outputs.grade }})"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# OSS Signal Report
|
|
2
|
+
|
|
3
|
+
Repository: `https://github.com/SalmonPlays/oss-signal`
|
|
4
|
+
Source: GitHub (SalmonPlays/oss-signal@main)
|
|
5
|
+
Generated: 2026-06-02T06:02:52.844Z
|
|
6
|
+
|
|
7
|
+
Score: **100/100** (A)
|
|
8
|
+
|
|
9
|
+
## Summary
|
|
10
|
+
|
|
11
|
+
- Passed: 15
|
|
12
|
+
- Failed: 0
|
|
13
|
+
- Total checks: 15
|
|
14
|
+
- Default branch: main
|
|
15
|
+
- GitHub stars: 0
|
|
16
|
+
- GitHub community health: 100
|
|
17
|
+
|
|
18
|
+
## Checks
|
|
19
|
+
|
|
20
|
+
| Status | Check | Why it matters |
|
|
21
|
+
| --- | --- | --- |
|
|
22
|
+
| PASS | README | A clear README is the front door for users and contributors. |
|
|
23
|
+
| PASS | License | A license tells downstream users what they may legally do with the code. |
|
|
24
|
+
| PASS | Contributing guide | Maintainers get better issues and pull requests when expectations are documented. |
|
|
25
|
+
| PASS | Security policy | Responsible disclosure needs a private, documented path. |
|
|
26
|
+
| PASS | Code of conduct | Community norms reduce ambiguity during difficult interactions. |
|
|
27
|
+
| PASS | Changelog | Users need a durable place to understand release impact. |
|
|
28
|
+
| PASS | Support policy | Support boundaries help maintainers avoid turning every request into unpaid consulting. |
|
|
29
|
+
| PASS | Continuous integration | CI catches regressions before maintainers merge changes. |
|
|
30
|
+
| PASS | Tests | Tests make review safer and lower the cost of outside contributions. |
|
|
31
|
+
| PASS | Issue templates | Issue templates collect the facts maintainers need to reproduce and triage. |
|
|
32
|
+
| PASS | Pull request template | PR templates nudge contributors to include tests, docs, and review context. |
|
|
33
|
+
| PASS | Dependency update automation | Automated dependency updates reduce security and compatibility drift. |
|
|
34
|
+
| PASS | Static security analysis | Static analysis finds common vulnerability patterns before releases. |
|
|
35
|
+
| PASS | Node package metadata | Package metadata makes installation, testing, and release automation discoverable. |
|
|
36
|
+
| PASS | Dependency lockfile | Lockfiles make CI and contributor setup reproducible. |
|
|
37
|
+
|
|
38
|
+
## Recommended Next Steps
|
|
39
|
+
|
|
40
|
+
No missing checks. Keep the report current as the repository evolves.
|
|
41
|
+
|
package/docs/self-audit.md
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oss-signal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A dependency-light CLI that audits open-source repository maintenance readiness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"oss-signal": "
|
|
7
|
+
"oss-signal": "src/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
19
19
|
"docs",
|
|
20
|
+
"action.yml",
|
|
20
21
|
"README.md",
|
|
21
22
|
"LICENSE",
|
|
22
23
|
"CHANGELOG.md"
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"test": "node --test",
|
|
26
27
|
"lint": "node --check src/*.js test/*.test.js",
|
|
27
28
|
"audit:self": "node src/cli.js . --format markdown --output docs/self-audit.md",
|
|
29
|
+
"audit:github": "node src/cli.js SalmonPlays/oss-signal --format markdown --output docs/examples/github-url-report.md",
|
|
28
30
|
"audit:check": "node src/cli.js . --format json --fail-under 100 > /dev/null",
|
|
29
31
|
"check": "npm run lint && npm test && npm run audit:check",
|
|
30
32
|
"prepublishOnly": "npm run check"
|
package/src/action.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { auditTarget, renderMarkdown } from "./index.js";
|
|
6
|
+
|
|
7
|
+
const OUTPUT_DELIMITER = "oss_signal_output";
|
|
8
|
+
|
|
9
|
+
export async function runAction(env = process.env, stdout = process.stdout, stderr = process.stderr) {
|
|
10
|
+
const options = parseActionInputs(env);
|
|
11
|
+
const report = await auditTarget(options.path, {
|
|
12
|
+
maxFiles: options.maxFiles,
|
|
13
|
+
ref: options.ref
|
|
14
|
+
});
|
|
15
|
+
const body = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdown(report);
|
|
16
|
+
|
|
17
|
+
if (options.output) {
|
|
18
|
+
await fs.mkdir(path.dirname(path.resolve(options.output)), { recursive: true });
|
|
19
|
+
await fs.writeFile(options.output, body, "utf8");
|
|
20
|
+
} else {
|
|
21
|
+
stdout.write(body);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
await writeGitHubOutput(env.GITHUB_OUTPUT, {
|
|
25
|
+
score: report.score,
|
|
26
|
+
grade: report.grade,
|
|
27
|
+
failed: report.summary.failed,
|
|
28
|
+
"report-path": options.output ?? ""
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (typeof options.failUnder === "number" && report.score < options.failUnder) {
|
|
32
|
+
stderr.write(`oss-signal: score ${report.score} is below fail-under ${options.failUnder}\n`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return report;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseActionInputs(env = process.env) {
|
|
40
|
+
const format = getInput(env, "format") || "markdown";
|
|
41
|
+
if (!["markdown", "json"].includes(format)) {
|
|
42
|
+
throw new Error("format must be either markdown or json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
path: getInput(env, "path") || ".",
|
|
47
|
+
format,
|
|
48
|
+
output: emptyToUndefined(getInput(env, "output")) ?? "oss-signal-report.md",
|
|
49
|
+
failUnder: parseOptionalNumber(getInput(env, "fail-under"), "fail-under"),
|
|
50
|
+
maxFiles: parseOptionalNumber(getInput(env, "max-files"), "max-files") ?? 20000,
|
|
51
|
+
ref: emptyToUndefined(getInput(env, "ref"))
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function writeGitHubOutput(outputFile, values) {
|
|
56
|
+
if (!outputFile) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const body = Object.entries(values)
|
|
61
|
+
.map(([name, value]) => `${name}<<${OUTPUT_DELIMITER}\n${value}\n${OUTPUT_DELIMITER}`)
|
|
62
|
+
.join("\n");
|
|
63
|
+
await fs.appendFile(outputFile, `${body}\n`, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getInput(env, name) {
|
|
67
|
+
const directKey = `INPUT_${name.toUpperCase()}`;
|
|
68
|
+
const normalizedKey = `INPUT_${name.toUpperCase().replaceAll("-", "_")}`;
|
|
69
|
+
return env[directKey]?.trim() || env[normalizedKey]?.trim() || "";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseOptionalNumber(value, name) {
|
|
73
|
+
const normalized = emptyToUndefined(value);
|
|
74
|
+
if (normalized === undefined) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const parsed = Number(normalized);
|
|
79
|
+
if (!Number.isFinite(parsed)) {
|
|
80
|
+
throw new Error(`${name} must be a number`);
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emptyToUndefined(value) {
|
|
86
|
+
return value === undefined || value === "" ? undefined : value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeWorkflowCommand(value) {
|
|
90
|
+
return String(value).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isMainModule() {
|
|
94
|
+
return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isMainModule()) {
|
|
98
|
+
runAction().catch((error) => {
|
|
99
|
+
process.stdout.write(`::error::${escapeWorkflowCommand(error.message)}\n`);
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
});
|
|
102
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const VERSION = "0.1.0";
|
|
3
|
+
import { auditTarget, renderMarkdown, VERSION } from "./index.js";
|
|
6
4
|
|
|
7
5
|
async function main(argv) {
|
|
8
6
|
const options = parseArgs(argv);
|
|
@@ -16,7 +14,10 @@ async function main(argv) {
|
|
|
16
14
|
return;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
const report = await
|
|
17
|
+
const report = await auditTarget(options.path, {
|
|
18
|
+
maxFiles: options.maxFiles,
|
|
19
|
+
ref: options.ref
|
|
20
|
+
});
|
|
20
21
|
const body = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderMarkdown(report);
|
|
21
22
|
|
|
22
23
|
if (options.output) {
|
|
@@ -38,6 +39,7 @@ function parseArgs(argv) {
|
|
|
38
39
|
output: undefined,
|
|
39
40
|
failUnder: undefined,
|
|
40
41
|
maxFiles: 20000,
|
|
42
|
+
ref: undefined,
|
|
41
43
|
help: false,
|
|
42
44
|
version: false
|
|
43
45
|
};
|
|
@@ -66,6 +68,10 @@ function parseArgs(argv) {
|
|
|
66
68
|
options.maxFiles = parseNumber(requireValue(argv, ++index, "--max-files"), "--max-files");
|
|
67
69
|
} else if (arg.startsWith("--max-files=")) {
|
|
68
70
|
options.maxFiles = parseNumber(arg.slice("--max-files=".length), "--max-files");
|
|
71
|
+
} else if (arg === "--ref") {
|
|
72
|
+
options.ref = requireValue(argv, ++index, "--ref");
|
|
73
|
+
} else if (arg.startsWith("--ref=")) {
|
|
74
|
+
options.ref = arg.slice("--ref=".length);
|
|
69
75
|
} else if (arg.startsWith("-")) {
|
|
70
76
|
throw new Error(`Unknown option: ${arg}`);
|
|
71
77
|
} else {
|
|
@@ -105,13 +111,19 @@ function helpText() {
|
|
|
105
111
|
return `oss-signal audits open-source repository maintenance readiness.
|
|
106
112
|
|
|
107
113
|
Usage:
|
|
108
|
-
oss-signal [path] [--format markdown|json] [--output file] [--fail-under score]
|
|
114
|
+
oss-signal [path-or-github-url] [--format markdown|json] [--output file] [--fail-under score]
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
oss-signal .
|
|
118
|
+
oss-signal https://github.com/SalmonPlays/oss-signal
|
|
119
|
+
oss-signal platformatic/massimo --format json
|
|
109
120
|
|
|
110
121
|
Options:
|
|
111
122
|
--format Output format. Defaults to markdown.
|
|
112
123
|
--output, -o Write the report to a file instead of stdout.
|
|
113
124
|
--fail-under Exit with code 1 when the score is below this value.
|
|
114
125
|
--max-files Maximum files to inspect. Defaults to 20000.
|
|
126
|
+
--ref Git ref for GitHub URL audits. Defaults to the repository default branch.
|
|
115
127
|
--version, -v Show the CLI version.
|
|
116
128
|
--help, -h Show this help message.
|
|
117
129
|
`;
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
|
+
import https from "node:https";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
5
|
+
export const VERSION = "0.2.0";
|
|
6
|
+
|
|
4
7
|
const COMMUNITY_FILES = [
|
|
5
8
|
{
|
|
6
9
|
id: "readme",
|
|
@@ -146,12 +149,61 @@ const DEFAULT_IGNORE_DIRS = new Set([
|
|
|
146
149
|
export async function auditRepository(root, options = {}) {
|
|
147
150
|
const absoluteRoot = path.resolve(root ?? ".");
|
|
148
151
|
const tree = await listRepositoryFiles(absoluteRoot, options);
|
|
152
|
+
return createReportFromTree(tree, {
|
|
153
|
+
root: absoluteRoot,
|
|
154
|
+
source: {
|
|
155
|
+
type: "local",
|
|
156
|
+
location: absoluteRoot
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function auditTarget(target = ".", options = {}) {
|
|
162
|
+
const normalizedTarget = target ?? ".";
|
|
163
|
+
if (!isGitHubUrl(normalizedTarget) && await pathExists(normalizedTarget)) {
|
|
164
|
+
return auditRepository(normalizedTarget, options);
|
|
165
|
+
}
|
|
166
|
+
if (isGitHubTarget(normalizedTarget)) {
|
|
167
|
+
return auditGitHubRepository(normalizedTarget, options);
|
|
168
|
+
}
|
|
169
|
+
return auditRepository(normalizedTarget, options);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function auditGitHubRepository(target, options = {}) {
|
|
173
|
+
const parsed = parseGitHubTarget(target);
|
|
174
|
+
const fetchImpl = options.fetchImpl;
|
|
175
|
+
const headers = githubHeaders(options.githubToken ?? process.env.GITHUB_TOKEN);
|
|
176
|
+
const repo = await fetchJson(fetchImpl, `https://api.github.com/repos/${parsed.owner}/${parsed.repo}`, headers);
|
|
177
|
+
const ref = options.ref ?? repo.default_branch;
|
|
178
|
+
const tree = await listGitHubRepositoryFiles(fetchImpl, parsed.owner, parsed.repo, ref, headers, options);
|
|
179
|
+
const communityProfile = await fetchCommunityProfile(fetchImpl, parsed.owner, parsed.repo, headers);
|
|
180
|
+
|
|
181
|
+
return createReportFromTree(tree, {
|
|
182
|
+
root: repo.html_url,
|
|
183
|
+
source: {
|
|
184
|
+
type: "github",
|
|
185
|
+
location: repo.html_url,
|
|
186
|
+
owner: parsed.owner,
|
|
187
|
+
repo: parsed.repo,
|
|
188
|
+
ref,
|
|
189
|
+
defaultBranch: repo.default_branch,
|
|
190
|
+
stars: repo.stargazers_count,
|
|
191
|
+
forks: repo.forks_count,
|
|
192
|
+
openIssues: repo.open_issues_count,
|
|
193
|
+
healthPercentage: communityProfile?.health_percentage
|
|
194
|
+
},
|
|
195
|
+
communityProfile
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function createReportFromTree(tree, metadata) {
|
|
149
200
|
const fileSet = new Set(tree);
|
|
150
|
-
|
|
201
|
+
let checks = [
|
|
151
202
|
...COMMUNITY_FILES.map((rule) => checkPathRule(rule, fileSet)),
|
|
152
203
|
...AUTOMATION_FILES.map((rule) => checkMatcherRule(rule, tree)),
|
|
153
204
|
...PACKAGE_FILES.map((rule) => checkMatcherRule(rule, tree))
|
|
154
205
|
];
|
|
206
|
+
checks = applyCommunityProfileEvidence(checks, metadata.communityProfile);
|
|
155
207
|
|
|
156
208
|
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
|
|
157
209
|
const earnedWeight = checks.filter((check) => check.passed).reduce((sum, check) => sum + check.weight, 0);
|
|
@@ -159,8 +211,9 @@ export async function auditRepository(root, options = {}) {
|
|
|
159
211
|
|
|
160
212
|
return {
|
|
161
213
|
tool: "oss-signal",
|
|
162
|
-
version:
|
|
163
|
-
root:
|
|
214
|
+
version: VERSION,
|
|
215
|
+
root: metadata.root,
|
|
216
|
+
source: metadata.source,
|
|
164
217
|
generatedAt: new Date().toISOString(),
|
|
165
218
|
score,
|
|
166
219
|
grade: gradeForScore(score),
|
|
@@ -178,6 +231,7 @@ export function renderMarkdown(report) {
|
|
|
178
231
|
"# OSS Signal Report",
|
|
179
232
|
"",
|
|
180
233
|
`Repository: \`${report.root}\``,
|
|
234
|
+
`Source: ${sourceSummary(report.source)}`,
|
|
181
235
|
`Generated: ${report.generatedAt}`,
|
|
182
236
|
"",
|
|
183
237
|
`Score: **${report.score}/100** (${report.grade})`,
|
|
@@ -186,13 +240,24 @@ export function renderMarkdown(report) {
|
|
|
186
240
|
"",
|
|
187
241
|
`- Passed: ${report.summary.passed}`,
|
|
188
242
|
`- Failed: ${report.summary.failed}`,
|
|
189
|
-
`- Total checks: ${report.summary.total}
|
|
243
|
+
`- Total checks: ${report.summary.total}`
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
if (report.source?.type === "github") {
|
|
247
|
+
lines.push(
|
|
248
|
+
`- Default branch: ${report.source.defaultBranch}`,
|
|
249
|
+
`- GitHub stars: ${report.source.stars ?? 0}`,
|
|
250
|
+
`- GitHub community health: ${report.source.healthPercentage ?? "unknown"}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
lines.push(
|
|
190
255
|
"",
|
|
191
256
|
"## Checks",
|
|
192
257
|
"",
|
|
193
258
|
"| Status | Check | Why it matters |",
|
|
194
259
|
"| --- | --- | --- |"
|
|
195
|
-
|
|
260
|
+
);
|
|
196
261
|
|
|
197
262
|
for (const check of report.checks) {
|
|
198
263
|
lines.push(`| ${check.passed ? "PASS" : "FAIL"} | ${escapeTable(check.label)} | ${escapeTable(check.why)} |`);
|
|
@@ -253,6 +318,153 @@ export async function listRepositoryFiles(root, options = {}) {
|
|
|
253
318
|
return files;
|
|
254
319
|
}
|
|
255
320
|
|
|
321
|
+
export function parseGitHubTarget(target) {
|
|
322
|
+
if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(target)) {
|
|
323
|
+
const [owner, repo] = target.split("/");
|
|
324
|
+
return { owner, repo: repo.replace(/\.git$/, "") };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let url;
|
|
328
|
+
try {
|
|
329
|
+
url = new URL(target);
|
|
330
|
+
} catch {
|
|
331
|
+
throw new Error(`Invalid GitHub target: ${target}`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (url.hostname !== "github.com" && url.hostname !== "www.github.com") {
|
|
335
|
+
throw new Error(`Only github.com URLs are supported for remote audits: ${target}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const [owner, repo] = url.pathname.split("/").filter(Boolean);
|
|
339
|
+
if (!owner || !repo) {
|
|
340
|
+
throw new Error(`GitHub URL must include owner and repository: ${target}`);
|
|
341
|
+
}
|
|
342
|
+
return { owner, repo: repo.replace(/\.git$/, "") };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isGitHubTarget(target) {
|
|
346
|
+
return isGitHubUrl(target) || /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(target);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isGitHubUrl(target) {
|
|
350
|
+
return /^https?:\/\/(www\.)?github\.com\//.test(target);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function pathExists(target) {
|
|
354
|
+
try {
|
|
355
|
+
await fs.stat(path.resolve(target));
|
|
356
|
+
return true;
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error.code === "ENOENT") {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function listGitHubRepositoryFiles(fetchImpl, owner, repo, ref, headers, options = {}) {
|
|
366
|
+
const maxFiles = options.maxFiles ?? 20000;
|
|
367
|
+
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
|
|
368
|
+
const data = await fetchJson(fetchImpl, treeUrl, headers);
|
|
369
|
+
return (data.tree ?? [])
|
|
370
|
+
.filter((entry) => entry.type === "blob" && typeof entry.path === "string")
|
|
371
|
+
.map((entry) => entry.path)
|
|
372
|
+
.slice(0, maxFiles);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function fetchCommunityProfile(fetchImpl, owner, repo, headers) {
|
|
376
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/community/profile`;
|
|
377
|
+
try {
|
|
378
|
+
return await fetchJson(fetchImpl, url, headers);
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error.status === 404) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function fetchJson(fetchImpl, url, headers) {
|
|
388
|
+
if (!fetchImpl) {
|
|
389
|
+
return requestJson(url, headers);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const response = await fetchImpl(url, { headers });
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
const error = new Error(`GitHub API request failed with ${response.status}: ${url}`);
|
|
395
|
+
error.status = response.status;
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
398
|
+
return response.json();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function requestJson(url, headers) {
|
|
402
|
+
return new Promise((resolve, reject) => {
|
|
403
|
+
const request = https.get(url, { headers }, (response) => {
|
|
404
|
+
let body = "";
|
|
405
|
+
response.setEncoding("utf8");
|
|
406
|
+
response.on("data", (chunk) => {
|
|
407
|
+
body += chunk;
|
|
408
|
+
});
|
|
409
|
+
response.on("end", () => {
|
|
410
|
+
if ((response.statusCode ?? 500) < 200 || (response.statusCode ?? 500) >= 300) {
|
|
411
|
+
const error = new Error(`GitHub API request failed with ${response.statusCode}: ${url}`);
|
|
412
|
+
error.status = response.statusCode;
|
|
413
|
+
reject(error);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
resolve(JSON.parse(body));
|
|
418
|
+
} catch (error) {
|
|
419
|
+
reject(error);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
request.on("error", reject);
|
|
424
|
+
request.end();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function githubHeaders(token) {
|
|
429
|
+
const headers = {
|
|
430
|
+
Accept: "application/vnd.github+json",
|
|
431
|
+
"User-Agent": "oss-signal"
|
|
432
|
+
};
|
|
433
|
+
if (token) {
|
|
434
|
+
headers.Authorization = `Bearer ${token}`;
|
|
435
|
+
}
|
|
436
|
+
return headers;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function applyCommunityProfileEvidence(checks, profile) {
|
|
440
|
+
if (!profile?.files) {
|
|
441
|
+
return checks;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const profileEvidenceByCheck = {
|
|
445
|
+
readme: profile.files.readme,
|
|
446
|
+
license: profile.files.license,
|
|
447
|
+
contributing: profile.files.contributing,
|
|
448
|
+
security: profile.files.security_policy,
|
|
449
|
+
"code-of-conduct": profile.files.code_of_conduct_file ?? profile.files.code_of_conduct,
|
|
450
|
+
"issue-templates": profile.files.issue_template,
|
|
451
|
+
"pull-request-template": profile.files.pull_request_template
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
return checks.map((check) => {
|
|
455
|
+
const evidence = profileEvidenceByCheck[check.id];
|
|
456
|
+
if (check.passed || !evidence) {
|
|
457
|
+
return check;
|
|
458
|
+
}
|
|
459
|
+
const evidenceUrl = evidence.html_url ?? evidence.url ?? "GitHub community profile";
|
|
460
|
+
return {
|
|
461
|
+
...check,
|
|
462
|
+
passed: true,
|
|
463
|
+
evidence: [`GitHub community profile: ${evidenceUrl}`]
|
|
464
|
+
};
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
256
468
|
function checkPathRule(rule, fileSet) {
|
|
257
469
|
const matchedPath = rule.paths.find((candidate) => fileSet.has(candidate));
|
|
258
470
|
return {
|
|
@@ -324,3 +536,13 @@ function gradeForScore(score) {
|
|
|
324
536
|
function escapeTable(value) {
|
|
325
537
|
return String(value).replaceAll("|", "\\|");
|
|
326
538
|
}
|
|
539
|
+
|
|
540
|
+
function sourceSummary(source) {
|
|
541
|
+
if (!source) {
|
|
542
|
+
return "local";
|
|
543
|
+
}
|
|
544
|
+
if (source.type === "github") {
|
|
545
|
+
return `GitHub (${source.owner}/${source.repo}@${source.ref})`;
|
|
546
|
+
}
|
|
547
|
+
return "local";
|
|
548
|
+
}
|