@diffci.com/diffci 0.1.2 → 0.1.4
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/COMMERCIAL.md +35 -0
- package/LICENSE +671 -0
- package/README.md +76 -23
- package/SECURITY.md +61 -0
- package/SUPPORT.md +47 -0
- package/dist-client/src/client/observe.js +6 -5
- package/dist-client/src/client/workflow-guard.js +4 -0
- package/dist-client/src/planner/test-command.js +25 -1
- package/dist-client/src/repo/adapters/go.js +154 -0
- package/dist-client/src/repo/adapters/index.js +23 -0
- package/dist-client/src/repo/adapters/types.js +3 -0
- package/dist-client/src/repo/adapters/vue.js +63 -0
- package/dist-client/src/repo/graph.js +82 -21
- package/dist-client/src/repo/impact.js +11 -2
- package/dist-client/src/repo/test-discovery.js +6 -0
- package/docs/distribution.md +15 -4
- package/docs/language-support.md +70 -0
- package/package.json +13 -7
package/README.md
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
# DiffCI
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@diffci.com/diffci)
|
|
4
|
+
[](https://docs.npmjs.com/generating-provenance-statements)
|
|
5
|
+
[](https://github.com/DiffCI/DiffCI.com)
|
|
6
|
+
|
|
3
7
|
DiffCI is a deterministic, change-aware CI planner: given a commit or PR, it builds a real TypeScript
|
|
4
8
|
dependency graph, computes what's actually reachable from the changed files, and proposes which CI
|
|
5
9
|
tasks/tests could safely be skipped - without ever modifying production CI behavior itself. Every mode
|
|
6
10
|
this repository currently implements is observe-and-compare only; nothing here can cancel, skip, or block
|
|
7
11
|
a real CI run.
|
|
8
12
|
|
|
13
|
+
Try it in shadow mode:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @diffci.com/diffci@latest observe
|
|
17
|
+
npx @diffci.com/diffci@latest verify-workflow
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or install it as a non-blocking GitHub Action:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
- uses: DiffCI/DiffCI.com@v0.1.4
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The promise is deliberately narrow: DiffCI observes your CI and reports what it would have selected.
|
|
27
|
+
It does not skip tests, cancel jobs, change required checks, or send reports anywhere unless you
|
|
28
|
+
explicitly configure an endpoint and token.
|
|
29
|
+
|
|
9
30
|
**This repository moved out of the [DentalPresence.in](https://github.com/adityankale190895/DentalPresence.in)
|
|
10
31
|
monorepo** (previously `diffci/` there) into its own repo on 2026-08-21, once the project outgrew being a
|
|
11
32
|
subfolder. DentalPresence.in remains DiffCI's original dogfooding target - some code (the `planner`
|
|
@@ -15,6 +36,10 @@ real third-party repositories.
|
|
|
15
36
|
|
|
16
37
|
## Current state
|
|
17
38
|
|
|
39
|
+
Language expansion: initial Vue SFC and Go package-level analysis is implemented through repository
|
|
40
|
+
adapters. See [the support matrix and setup requirements](docs/language-support.md) for exact scope,
|
|
41
|
+
fallback behavior, and validation boundaries.
|
|
42
|
+
|
|
18
43
|
Three completed research stages plus an in-progress prospective-validation stage, in order:
|
|
19
44
|
|
|
20
45
|
- **Stage 0** - a 2,000-delta historical benchmark across 20 real repositories, run through a real
|
|
@@ -43,24 +68,51 @@ Every dated report behind these stages lives in [`docs/research/`](docs/research
|
|
|
43
68
|
`2026-08-21-stage2-architecture.md` for the fullest current picture of what's built vs not, or the
|
|
44
69
|
Stage 0/1A/1B reports for the historical-validation story.
|
|
45
70
|
|
|
71
|
+
For the next product milestone, see [`docs/alpha-readiness.md`](docs/alpha-readiness.md). It tracks the
|
|
72
|
+
private-alpha bar: install DiffCI, keep CI unchanged, collect real shadow observations, and render a
|
|
73
|
+
trustworthy potential-savings report.
|
|
74
|
+
|
|
46
75
|
## Architecture
|
|
47
76
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
DiffCI is now framed as an open-core product:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
DiffCI
|
|
81
|
+
|
|
|
82
|
+
├── Open-source core
|
|
83
|
+
| ├── DiffCI engine
|
|
84
|
+
| ├── CLI / npm package
|
|
85
|
+
| ├── Local analysis
|
|
86
|
+
| └── Basic GitHub Action
|
|
87
|
+
| |
|
|
88
|
+
| └── Tidelift package support
|
|
89
|
+
|
|
|
90
|
+
└── Commercial DiffCI
|
|
91
|
+
├── Hosted service / DiffCI Cloud
|
|
92
|
+
├── Organization dashboard
|
|
93
|
+
├── Historical analytics
|
|
94
|
+
├── Advanced CI/CD optimization
|
|
95
|
+
├── Enterprise policies
|
|
96
|
+
├── Managed runners
|
|
97
|
+
├── Team features
|
|
98
|
+
└── Support / enterprise services
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The open-source core is the trust and adoption surface. It runs locally or in the host repository's own
|
|
102
|
+
CI, writes a report, and changes nothing about CI execution. Commercial DiffCI adds hosted history,
|
|
103
|
+
organization views, policy, managed operations, runners, and support. Tidelift belongs to the supported
|
|
104
|
+
open-source package path, not the hosted product feature boundary. See
|
|
105
|
+
[`docs/open-core-packaging.md`](docs/open-core-packaging.md) and
|
|
106
|
+
[`docs/tidelift-package-support.md`](docs/tidelift-package-support.md).
|
|
107
|
+
|
|
108
|
+
The source tree follows that split:
|
|
109
|
+
|
|
110
|
+
- `src/git/`, `src/repo/`, `src/planner/`, and `src/client/` are the installable OSS observer path.
|
|
111
|
+
- `action.yml` wraps the observer as a basic non-blocking GitHub Action.
|
|
112
|
+
- `src/research/` and `src/shadow/` run validation, GitHub App shadow observation, and reconciliation.
|
|
113
|
+
- `src/product/`, `src/auth/`, `src/billing/`, `src/ingest/`, `src/ledger/`, `src/runner/`, and
|
|
114
|
+
`src/usage/` are the commercial/control-plane layer.
|
|
115
|
+
- `docs/oss-boundary.md` records what is allowed into the npm package.
|
|
64
116
|
|
|
65
117
|
## Commands
|
|
66
118
|
|
|
@@ -84,15 +136,16 @@ npm run research:sandbox:deploy
|
|
|
84
136
|
DiffCI is intended to be installable as infrastructure, not only as a hosted shadow experiment:
|
|
85
137
|
|
|
86
138
|
```yaml
|
|
87
|
-
- uses: DiffCI/DiffCI.com@
|
|
139
|
+
- uses: DiffCI/DiffCI.com@v0.1.4
|
|
88
140
|
```
|
|
89
141
|
|
|
90
142
|
```bash
|
|
91
|
-
npx @diffci.com/diffci observe
|
|
92
|
-
npx @diffci.com/diffci verify-workflow
|
|
143
|
+
npx @diffci.com/diffci@latest observe
|
|
144
|
+
npx @diffci.com/diffci@latest verify-workflow
|
|
93
145
|
```
|
|
94
146
|
|
|
95
|
-
The GitHub
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
147
|
+
The GitHub Action and npm CLI establish the OSS/package distribution path. The hosted GitHub App and
|
|
148
|
+
DiffCI Cloud build on that trust boundary for teams that want shared reports and history. See
|
|
149
|
+
[`docs/distribution.md`](docs/distribution.md) for the package and Action positioning,
|
|
150
|
+
[`docs/open-core-packaging.md`](docs/open-core-packaging.md) for the commercial split, and
|
|
151
|
+
[`docs/npm-adoption.md`](docs/npm-adoption.md) for copy-paste pilot material.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported project
|
|
4
|
+
|
|
5
|
+
This policy covers the DiffCI open-source core package, published as `@diffci.com/diffci`.
|
|
6
|
+
|
|
7
|
+
Covered surfaces:
|
|
8
|
+
|
|
9
|
+
- `diffci observe`
|
|
10
|
+
- `diffci verify-workflow`
|
|
11
|
+
- the basic GitHub Action in `action.yml`
|
|
12
|
+
- local report generation
|
|
13
|
+
- optional report submission to a configured endpoint and token
|
|
14
|
+
|
|
15
|
+
Commercial DiffCI, including DiffCI Cloud, hosted dashboards, managed runners, billing, organization
|
|
16
|
+
management, private report access, and enterprise policy features, is separate proprietary software and
|
|
17
|
+
is handled through commercial support channels.
|
|
18
|
+
|
|
19
|
+
## Supported versions
|
|
20
|
+
|
|
21
|
+
The supported OSS package line is the latest published npm release. Security fixes are released as a
|
|
22
|
+
new npm version and Git tag.
|
|
23
|
+
|
|
24
|
+
DiffCI Core requires Node.js `>=22.5.0`.
|
|
25
|
+
|
|
26
|
+
## Reporting a vulnerability
|
|
27
|
+
|
|
28
|
+
Do not open a public issue for a suspected vulnerability.
|
|
29
|
+
|
|
30
|
+
Report security issues by email to:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
security@diffci.com
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Include:
|
|
37
|
+
|
|
38
|
+
- affected package/version;
|
|
39
|
+
- affected command or GitHub Action path;
|
|
40
|
+
- reproduction steps;
|
|
41
|
+
- expected impact;
|
|
42
|
+
- whether any token, report, repository data, or CI behavior is exposed or modified.
|
|
43
|
+
|
|
44
|
+
## Response target
|
|
45
|
+
|
|
46
|
+
For the OSS package:
|
|
47
|
+
|
|
48
|
+
- acknowledge within 3 business days;
|
|
49
|
+
- provide an initial assessment within 7 business days;
|
|
50
|
+
- publish a fix or mitigation plan when the issue is confirmed.
|
|
51
|
+
|
|
52
|
+
## Security boundaries
|
|
53
|
+
|
|
54
|
+
DiffCI Core is observation-only. A security issue is especially important if it causes the observer to:
|
|
55
|
+
|
|
56
|
+
- modify the repository checkout without reporting it;
|
|
57
|
+
- affect another CI job's conclusion;
|
|
58
|
+
- expose tokens or report submission credentials;
|
|
59
|
+
- submit a report to the wrong repository or organization;
|
|
60
|
+
- include source file contents when the report schema says it does not;
|
|
61
|
+
- bypass the OSS package boundary and include commercial/control-plane code.
|
package/SUPPORT.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Support policy
|
|
2
|
+
|
|
3
|
+
## Open-source core
|
|
4
|
+
|
|
5
|
+
Supported package:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
@diffci.com/diffci
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Supported surfaces:
|
|
12
|
+
|
|
13
|
+
- `diffci observe`
|
|
14
|
+
- `diffci verify-workflow`
|
|
15
|
+
- `diffci version`
|
|
16
|
+
- the basic GitHub Action in `action.yml`
|
|
17
|
+
- local JSON observation reports
|
|
18
|
+
- optional report submission to a configured endpoint and token
|
|
19
|
+
|
|
20
|
+
Supported runtime:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
Node.js >=22.5.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The open-source support commitment is limited to the latest published npm release unless a security
|
|
27
|
+
advisory says otherwise.
|
|
28
|
+
|
|
29
|
+
## Commercial DiffCI
|
|
30
|
+
|
|
31
|
+
Commercial DiffCI is separate proprietary software. It includes:
|
|
32
|
+
|
|
33
|
+
- DiffCI Cloud;
|
|
34
|
+
- hosted dashboards and private report access;
|
|
35
|
+
- organization/team management;
|
|
36
|
+
- historical analytics;
|
|
37
|
+
- enterprise policies;
|
|
38
|
+
- managed runners;
|
|
39
|
+
- billing, ledger, and support operations;
|
|
40
|
+
- deployment and control-plane infrastructure.
|
|
41
|
+
|
|
42
|
+
Those features are not included in Tidelift package support for the OSS dependency.
|
|
43
|
+
|
|
44
|
+
## Tidelift boundary
|
|
45
|
+
|
|
46
|
+
Tidelift support, if accepted, should cover the open-source npm package as a dependency. It should not be
|
|
47
|
+
used to represent DiffCI Cloud, managed runners, or enterprise policy features as open source.
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Two properties are load-bearing, and both are enforced here rather than promised:
|
|
10
10
|
*
|
|
11
|
-
* READ-ONLY.
|
|
11
|
+
* READ-ONLY CHECKOUT. Analysis does not modify source; Go metadata can populate tool caches outside
|
|
12
|
+
* the checkout, with module edits and downloads disabled. This
|
|
12
13
|
* function additionally records HEAD and `git status --porcelain` before and after itself, so a run that
|
|
13
14
|
* DID dirty the tree says so in its own report instead of being discovered weeks later.
|
|
14
15
|
*
|
|
@@ -22,7 +23,7 @@ import { execFileSync } from "node:child_process";
|
|
|
22
23
|
import { createHash } from "node:crypto";
|
|
23
24
|
import { relative, resolve } from "node:path";
|
|
24
25
|
import { analyzeGitDelta } from "../git/git-diff.js";
|
|
25
|
-
import {
|
|
26
|
+
import { classifyRepositoryProject, buildDependencyGraph } from "../repo/graph.js";
|
|
26
27
|
import { ImpactAnalyzer } from "../repo/impact.js";
|
|
27
28
|
import { runPathBaseline } from "../planner/path-baseline.js";
|
|
28
29
|
import { commandSpecToString, planSelectiveTestCommands } from "../planner/test-command.js";
|
|
@@ -145,12 +146,12 @@ export async function observe(options) {
|
|
|
145
146
|
if (!resolved.ok)
|
|
146
147
|
return finish("REFUSED", "context", { reason: resolved.reason });
|
|
147
148
|
range = resolved.range;
|
|
148
|
-
// The eligibility gate is asked of the graph builder itself (
|
|
149
|
+
// The eligibility gate is asked of the graph builder itself (classifyRepositoryProject), not of a
|
|
149
150
|
// separate list of conditions that can drift away from it. Phase 01 F3 is what that drift costs.
|
|
150
|
-
const capability =
|
|
151
|
+
const capability = classifyRepositoryProject(repoPath);
|
|
151
152
|
if (!capability.capable) {
|
|
152
153
|
return finish("REFUSED", "eligibility", {
|
|
153
|
-
reason: `DiffCI
|
|
154
|
+
reason: `DiffCI supports TypeScript/JavaScript projects, Vue components, and root Go modules: ${capability.reason}`,
|
|
154
155
|
});
|
|
155
156
|
}
|
|
156
157
|
const deltaResult = await analyzeGitDelta({ baseSha: range.baseSha, headSha: range.headSha, repoPath });
|
|
@@ -101,6 +101,10 @@ function commandText(run) {
|
|
|
101
101
|
return run
|
|
102
102
|
.split("\n")
|
|
103
103
|
.map((line) => line.replace(/(^|\s)#.*$/, "$1").replace(/[a-z][a-z0-9+.-]*:\/\/\S+/gi, " "))
|
|
104
|
+
// A literal npm registry tag removal neither installs nor invokes the observer. Match the
|
|
105
|
+
// entire line, with only a literal `|| true` allowed, so appended observer commands and shell
|
|
106
|
+
// substitutions still take the conservative inspection path. Other script lines remain intact.
|
|
107
|
+
.filter((line) => !/^\s*npm(?:\.cmd)?\s+dist-tag\s+(?:rm|remove)\s+@?[\w./-]+\s+[\w.-]+(?:\s*\|\|\s*true)?\s*$/.test(line))
|
|
104
108
|
.join("\n");
|
|
105
109
|
}
|
|
106
110
|
/**
|
|
@@ -58,6 +58,29 @@ function buildCommand(framework, packageManager, configFile, paths) {
|
|
|
58
58
|
* test-discovery.ts already models is carried through to execution rather than flattened.
|
|
59
59
|
*/
|
|
60
60
|
export function planSelectiveTestCommands(profile, selectedPaths) {
|
|
61
|
+
const blockers = profile.adapterBlockers ?? profile.adapters?.flatMap((adapter) => adapter.blockers) ?? [];
|
|
62
|
+
if (blockers.length)
|
|
63
|
+
return { commands: [], groups: [], unroutedPaths: [...selectedPaths], refusalReason: blockers.join("; ") };
|
|
64
|
+
const goPaths = selectedPaths.filter((path) => path.endsWith(".go"));
|
|
65
|
+
if (goPaths.length) {
|
|
66
|
+
const packages = profile.goTestPackages ?? {};
|
|
67
|
+
const unclaimed = goPaths.filter((path) => !Object.hasOwn(packages, path));
|
|
68
|
+
if (unclaimed.length)
|
|
69
|
+
return { commands: [], groups: [], unroutedPaths: unclaimed, refusalReason: "Go test files require verified package metadata" };
|
|
70
|
+
const jsPlan = planSelectiveTestCommands(profile, selectedPaths.filter((path) => !path.endsWith(".go")));
|
|
71
|
+
if (jsPlan.refusalReason)
|
|
72
|
+
return jsPlan;
|
|
73
|
+
const targets = [...new Set(goPaths.map((path) => packages[path]))].sort();
|
|
74
|
+
if (targets.some((target) => target !== "." && (!target.startsWith("./") || target.split("/").includes("..") || /[\\\r\n]/.test(target)))) {
|
|
75
|
+
return { commands: [], groups: [], unroutedPaths: goPaths, refusalReason: "Invalid Go package target" };
|
|
76
|
+
}
|
|
77
|
+
const commandSpec = {
|
|
78
|
+
executable: "go", args: ["test", "-mod=readonly", "-json", "-count=1", ...targets],
|
|
79
|
+
env: { ...profile.goTestEnvironment, GOTOOLCHAIN: "local", GOPROXY: "off", GOSUMDB: "off", GOWORK: "off" },
|
|
80
|
+
};
|
|
81
|
+
const group = { runnerId: "go:test", label: "Go package tests", paths: [...goPaths].sort(), commandSpec };
|
|
82
|
+
return { commands: [...jsPlan.commands, commandSpec], groups: [...jsPlan.groups, group], unroutedPaths: [] };
|
|
83
|
+
}
|
|
61
84
|
const paths = [...selectedPaths].sort();
|
|
62
85
|
if (paths.length === 0)
|
|
63
86
|
return { commands: [], groups: [], unroutedPaths: [] };
|
|
@@ -120,5 +143,6 @@ function shellEscape(arg) {
|
|
|
120
143
|
return arg.replace(/([\s'"\\$|&;<>(){}\[\]*?#~`])/g, "\\$1");
|
|
121
144
|
}
|
|
122
145
|
export function commandSpecToString(spec) {
|
|
123
|
-
|
|
146
|
+
const environment = Object.entries(spec.env ?? {}).map(([key, value]) => `${shellEscape(key)}=${shellEscape(value)}`);
|
|
147
|
+
return [...environment, spec.executable, ...spec.args.map(shellEscape)].join(" ");
|
|
124
148
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { contribution } from "./types.js";
|
|
5
|
+
/** go list emits adjacent JSON objects, not a JSON array or JSONL. */
|
|
6
|
+
export function parseGoList(output) {
|
|
7
|
+
const packages = [];
|
|
8
|
+
let start = -1, depth = 0, quoted = false, escaped = false;
|
|
9
|
+
for (let i = 0; i < output.length; i++) {
|
|
10
|
+
const c = output[i];
|
|
11
|
+
if (quoted) {
|
|
12
|
+
if (escaped)
|
|
13
|
+
escaped = false;
|
|
14
|
+
else if (c === "\\")
|
|
15
|
+
escaped = true;
|
|
16
|
+
else if (c === '"')
|
|
17
|
+
quoted = false;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (c === '"')
|
|
21
|
+
quoted = true;
|
|
22
|
+
else if (c === "{") {
|
|
23
|
+
if (depth++ === 0)
|
|
24
|
+
start = i;
|
|
25
|
+
}
|
|
26
|
+
else if (c === "}") {
|
|
27
|
+
if (--depth < 0)
|
|
28
|
+
throw new Error("Invalid go list output");
|
|
29
|
+
if (depth === 0) {
|
|
30
|
+
const pkg = JSON.parse(output.slice(start, i + 1));
|
|
31
|
+
if (typeof pkg.Dir !== "string" || typeof pkg.ImportPath !== "string")
|
|
32
|
+
throw new Error("Incomplete Go package metadata");
|
|
33
|
+
packages.push(pkg);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (depth === 0 && !/\s/.test(c))
|
|
37
|
+
throw new Error("Unexpected go list output");
|
|
38
|
+
}
|
|
39
|
+
if (depth || quoted || !packages.length)
|
|
40
|
+
throw new Error("Truncated or empty go list output");
|
|
41
|
+
return packages;
|
|
42
|
+
}
|
|
43
|
+
function internalPath(root, path) {
|
|
44
|
+
const rel = relative(root, resolve(path)).replace(/\\/g, "/");
|
|
45
|
+
return rel === ".." || rel.startsWith("../") || isAbsolute(rel) ? undefined : rel;
|
|
46
|
+
}
|
|
47
|
+
export function analyzeGoMetadata(context, output) {
|
|
48
|
+
const result = contribution(goAdapter);
|
|
49
|
+
const all = parseGoList(output);
|
|
50
|
+
if (all.some((pkg) => pkg.Module?.Replace?.Dir && internalPath(context.repoPath, pkg.Module.Replace.Dir) === undefined))
|
|
51
|
+
result.blockers.push("Go local module replacement lies outside the repository");
|
|
52
|
+
if (all.some((pkg) => pkg.Error || pkg.Incomplete || pkg.DepsErrors?.length))
|
|
53
|
+
result.blockers.push("Go package metadata contains errors");
|
|
54
|
+
const packages = all.filter((pkg) => !pkg.ForTest && !pkg.ImportPath.endsWith(".test") && !pkg.Standard && internalPath(context.repoPath, pkg.Dir) !== undefined);
|
|
55
|
+
const anchors = new Map();
|
|
56
|
+
const members = new Map();
|
|
57
|
+
for (const pkg of packages) {
|
|
58
|
+
const files = [...(pkg.GoFiles ?? []), ...(pkg.CgoFiles ?? []), ...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? [])];
|
|
59
|
+
const paths = files.map((file) => internalPath(context.repoPath, resolve(pkg.Dir, file)));
|
|
60
|
+
if (paths.some((p) => p === undefined)) {
|
|
61
|
+
result.blockers.push("Go package contains files outside the repository");
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const sources = paths;
|
|
65
|
+
if (!sources.length)
|
|
66
|
+
continue;
|
|
67
|
+
anchors.set(pkg.ImportPath, sources[0]);
|
|
68
|
+
members.set(pkg.ImportPath, sources);
|
|
69
|
+
result.sourcePaths.push(...sources);
|
|
70
|
+
if (pkg.CgoFiles?.length)
|
|
71
|
+
result.blockers.push(`Go ${pkg.ImportPath}: cgo dependencies require full validation`);
|
|
72
|
+
if (pkg.SFiles?.length || pkg.SysoFiles?.length)
|
|
73
|
+
result.blockers.push(`Go ${pkg.ImportPath}: native assembly/object dependencies require full validation`);
|
|
74
|
+
if ([...(pkg.Imports ?? []), ...(pkg.TestImports ?? []), ...(pkg.XTestImports ?? [])].includes("plugin"))
|
|
75
|
+
result.blockers.push(`Go ${pkg.ImportPath}: runtime plugins require full validation`);
|
|
76
|
+
for (const file of sources) {
|
|
77
|
+
if (/^\s*\/\/go:(?:generate|linkname)\b/m.test(readFileSync(resolve(context.repoPath, file), "utf8")))
|
|
78
|
+
result.blockers.push(`Go ${file}: generated or linked dependencies require full validation`);
|
|
79
|
+
}
|
|
80
|
+
const dir = internalPath(context.repoPath, pkg.Dir);
|
|
81
|
+
for (const test of [...(pkg.TestGoFiles ?? []), ...(pkg.XTestGoFiles ?? [])]) {
|
|
82
|
+
const path = internalPath(context.repoPath, resolve(pkg.Dir, test));
|
|
83
|
+
result.testFiles.push(path);
|
|
84
|
+
result.testPackages[path] = dir ? `./${dir}` : ".";
|
|
85
|
+
}
|
|
86
|
+
for (const file of [...(pkg.EmbedFiles ?? []), ...(pkg.TestEmbedFiles ?? []), ...(pkg.XTestEmbedFiles ?? [])]) {
|
|
87
|
+
const asset = internalPath(context.repoPath, resolve(pkg.Dir, file));
|
|
88
|
+
if (asset === undefined) {
|
|
89
|
+
result.blockers.push("Go embedded file escapes repository");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
result.assetPaths.push(asset);
|
|
93
|
+
result.edges.push({ from: sources[0], to: asset, kind: "asset" });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const pkg of packages) {
|
|
97
|
+
const anchor = anchors.get(pkg.ImportPath);
|
|
98
|
+
if (!anchor)
|
|
99
|
+
continue;
|
|
100
|
+
// A package is the selection unit: every member shares package-level dependencies.
|
|
101
|
+
for (const file of members.get(pkg.ImportPath) ?? []) {
|
|
102
|
+
if (file !== anchor)
|
|
103
|
+
result.edges.push({ from: anchor, to: file, kind: "import" }, { from: file, to: anchor, kind: "import" });
|
|
104
|
+
}
|
|
105
|
+
for (const imported of [...(pkg.Imports ?? []), ...(pkg.TestImports ?? []), ...(pkg.XTestImports ?? [])]) {
|
|
106
|
+
const target = anchors.get(imported);
|
|
107
|
+
if (target && target !== anchor)
|
|
108
|
+
result.edges.push({ from: anchor, to: target, kind: "import" });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!result.sourcePaths.length)
|
|
112
|
+
result.blockers.push("Go analysis found no local packages");
|
|
113
|
+
// Ignored files/build tags, generation and testdata can change the runnable universe.
|
|
114
|
+
const modeled = new Set([...result.sourcePaths, ...result.assetPaths]);
|
|
115
|
+
if (context.files.some((file) => file.endsWith(".go") && !modeled.has(file)))
|
|
116
|
+
result.blockers.push("Go files outside the active build context require full validation");
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
export const goAdapter = {
|
|
120
|
+
id: "go", version: "1", kind: "language",
|
|
121
|
+
detect: ({ files }) => files.some((file) => file === "go.mod" || file.endsWith(".go")),
|
|
122
|
+
analyze(context) {
|
|
123
|
+
const failure = contribution(this);
|
|
124
|
+
if (!context.files.includes("go.mod") || context.files.some((f) => f === "go.work" || f.endsWith("/go.mod"))) {
|
|
125
|
+
failure.blockers.push("Go support requires one root go.mod; workspaces and nested modules require full validation");
|
|
126
|
+
return failure;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const env = { ...process.env, GOTOOLCHAIN: "local", GOPROXY: "off", GOSUMDB: "off", GOWORK: "off" };
|
|
130
|
+
const buildEnv = JSON.parse(execFileSync("go", ["env", "-json", "GOOS", "GOARCH", "CGO_ENABLED", "GOFLAGS"], {
|
|
131
|
+
cwd: context.repoPath, encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024,
|
|
132
|
+
windowsHide: true, env, stdio: ["ignore", "pipe", "pipe"],
|
|
133
|
+
}));
|
|
134
|
+
if (["GOOS", "GOARCH", "CGO_ENABLED", "GOFLAGS"].some((key) => typeof buildEnv[key] !== "string") || buildEnv.GOFLAGS.trim()) {
|
|
135
|
+
failure.blockers.push("Go custom build flags or incomplete build context require full validation");
|
|
136
|
+
return failure;
|
|
137
|
+
}
|
|
138
|
+
// No repository code is executed and module manifests must not be modified.
|
|
139
|
+
const output = execFileSync("go", ["list", "-mod=readonly", "-deps", "-test", "-json", "./..."], {
|
|
140
|
+
cwd: context.repoPath, encoding: "utf8", timeout: 60_000, maxBuffer: 64 * 1024 * 1024,
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
env,
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
});
|
|
145
|
+
const result = analyzeGoMetadata(context, output);
|
|
146
|
+
result.executionEnv = { GOOS: buildEnv.GOOS, GOARCH: buildEnv.GOARCH, CGO_ENABLED: buildEnv.CGO_ENABLED, GOFLAGS: "" };
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
failure.blockers.push("Go metadata unavailable: install Go and repository dependencies before analysis (go list must succeed offline)");
|
|
151
|
+
return failure;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { goAdapter } from "./go.js";
|
|
4
|
+
import { vueAdapter } from "./vue.js";
|
|
5
|
+
export const REPOSITORY_ADAPTERS = [vueAdapter, goAdapter];
|
|
6
|
+
/** Never follows symlinks or scans dependency/build output directories. */
|
|
7
|
+
export function adapterFiles(root, exclusions = []) {
|
|
8
|
+
const ignored = new Set([".git", "node_modules", "vendor", ".next", "dist", "build", "coverage", "tmp", "temp"]);
|
|
9
|
+
const files = [];
|
|
10
|
+
function walk(dir, prefix) {
|
|
11
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
12
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
13
|
+
if (exclusions.some((excluded) => path === excluded || path.startsWith(`${excluded}/`)))
|
|
14
|
+
continue;
|
|
15
|
+
if (entry.isDirectory() && !ignored.has(entry.name))
|
|
16
|
+
walk(join(dir, entry.name), path);
|
|
17
|
+
else if (entry.isFile())
|
|
18
|
+
files.push(path);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
walk(root, "");
|
|
22
|
+
return files.sort();
|
|
23
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
|
|
4
|
+
import { contribution } from "./types.js";
|
|
5
|
+
/** Explicit Vue SFC imports. Runtime component registries and preprocessors require full CI. */
|
|
6
|
+
export const vueAdapter = {
|
|
7
|
+
id: "vue", version: "1", kind: "framework",
|
|
8
|
+
detect: ({ files }) => files.some((file) => file.endsWith(".vue")),
|
|
9
|
+
analyze(context) {
|
|
10
|
+
const result = contribution(this);
|
|
11
|
+
const dependencies = [...context.profile.packageJson.dependencies, ...context.profile.packageJson.devDependencies];
|
|
12
|
+
if (dependencies.includes("nuxt") || context.files.some((file) => /(?:^|\/)nuxt\.config\./.test(file))) {
|
|
13
|
+
result.blockers.push("Nuxt implicit routes and auto-imports require a dedicated framework adapter");
|
|
14
|
+
}
|
|
15
|
+
for (const path of context.files.filter((file) => file.endsWith(".vue"))) {
|
|
16
|
+
result.sourcePaths.push(path);
|
|
17
|
+
const block = (reason) => result.blockers.push(`Vue ${path}: ${reason}`);
|
|
18
|
+
try {
|
|
19
|
+
const { descriptor, errors } = parse(readFileSync(join(context.repoPath, path), "utf8"), { filename: path });
|
|
20
|
+
if (errors.length)
|
|
21
|
+
block("component parse failed");
|
|
22
|
+
if (descriptor.customBlocks.length)
|
|
23
|
+
block("custom blocks require a framework plugin");
|
|
24
|
+
const blocks = [descriptor.script, descriptor.scriptSetup, descriptor.template, ...descriptor.styles].filter((b) => b !== null);
|
|
25
|
+
for (const b of blocks) {
|
|
26
|
+
if (b.src)
|
|
27
|
+
block("external SFC blocks are not yet modeled");
|
|
28
|
+
if (b.lang && !["js", "ts", "jsx", "tsx", "html", "css"].includes(b.lang))
|
|
29
|
+
block(`unsupported preprocessor ${b.lang}`);
|
|
30
|
+
}
|
|
31
|
+
const script = descriptor.script || descriptor.scriptSetup
|
|
32
|
+
? compileScript(descriptor, { id: path }) : undefined;
|
|
33
|
+
let source = script?.content ?? "";
|
|
34
|
+
if (/\bimport\.meta\.glob(?:Eager)?\s*\(/.test(source))
|
|
35
|
+
block("glob imports require bundler dependency expansion");
|
|
36
|
+
if (descriptor.template && !descriptor.template.src && !descriptor.template.lang) {
|
|
37
|
+
const template = compileTemplate({
|
|
38
|
+
source: descriptor.template.content, filename: path, id: path,
|
|
39
|
+
compilerOptions: { bindingMetadata: script?.bindings },
|
|
40
|
+
});
|
|
41
|
+
if (template.errors.length)
|
|
42
|
+
block("template compilation failed");
|
|
43
|
+
// These calls represent dependencies supplied at runtime, outside the import graph.
|
|
44
|
+
if (/\b_resolve(?:DynamicComponent|Component|Directive)\s*\(/.test(template.code))
|
|
45
|
+
block("runtime component/directive resolution requires full validation");
|
|
46
|
+
source += `\n${template.code}`;
|
|
47
|
+
}
|
|
48
|
+
else if (descriptor.template)
|
|
49
|
+
block("external or preprocessed template requires full validation");
|
|
50
|
+
for (const style of descriptor.styles) {
|
|
51
|
+
// CSS imports/URLs may be rewritten by arbitrary bundler plugins. Do not guess.
|
|
52
|
+
if (/@import\b|url\s*\(/i.test(style.content))
|
|
53
|
+
block("style imports or URLs require full validation");
|
|
54
|
+
}
|
|
55
|
+
result.virtualSources.push({ path, source });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
block("component could not be analyzed");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
},
|
|
63
|
+
};
|