harness-everything 0.25.0 → 0.26.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/ci/mechanism-35-verification-contract.test.js +416 -0
- package/ci/mutation-check.js +2 -0
- package/ci/mutation-loader.js +4 -0
- package/docs/audit.md +2 -0
- package/harness-everything/SKILL.md +2 -2
- package/harness-everything/scripts/verify-gate.js +223 -75
- package/hooks/scripts/lib/workflow-isolation.js +3 -4
- package/hooks/scripts/state-persist.js +1 -1
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/hooks/scripts/lib/workflow-isolation.js +3 -4
- package/plugins/harness-everything/hooks/scripts/state-persist.js +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/scripts/lib/verification-contract.js +357 -0
- package/plugins/harness-everything/skills/harness-everything/SKILL.md +2 -2
- package/plugins/harness-everything/skills/harness-everything/scripts/verify-gate.js +223 -75
- package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +14 -13
- package/plugins/harness-everything/skills/using-git-worktrees/references/submodules-in-worktrees.md +135 -0
- package/plugins/harness-everything/skills/verification-loop/SKILL.md +15 -23
- package/plugins/harness-everything/skills/verification-loop/references/project-verification-contract.md +112 -0
- package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md +4 -3
- package/plugins/harness-everything/skills/verification-loop/references/verify-contract.schema.json +32 -0
- package/plugins/harness-everything/skills/verification-loop/references/verify-result.schema.json +65 -0
- package/sbom.cdx.json +7 -7
- package/scripts/lib/verification-contract.js +357 -0
- package/using-git-worktrees/SKILL.md +14 -13
- package/using-git-worktrees/references/submodules-in-worktrees.md +135 -0
- package/verification-loop/SKILL.md +15 -23
- package/verification-loop/references/project-verification-contract.md +112 -0
- package/verification-loop/references/verification-phases.md +4 -3
- package/verification-loop/references/verify-contract.schema.json +32 -0
- package/verification-loop/references/verify-result.schema.json +65 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Project Verification Contract
|
|
2
|
+
|
|
3
|
+
Harness project verification has two layers:
|
|
4
|
+
|
|
5
|
+
1. **Declared contract** — `.harness/verify.json` is authoritative and executable.
|
|
6
|
+
2. **Read-only discovery** — ecosystem signals are reported as candidates when no contract exists. Detected candidates are never auto-run except the legacy `package.json` `lint` / `test` scripts.
|
|
7
|
+
|
|
8
|
+
This keeps verification explicit without turning discovery into an accidental command runner.
|
|
9
|
+
|
|
10
|
+
## Contract
|
|
11
|
+
|
|
12
|
+
Create `.harness/verify.json` at the enclosing Git repository root. Its documented JSON Schema is `<this-skill-dir>/references/verify-contract.schema.json`:
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"version": 1,
|
|
17
|
+
"roots": [".", "sub/a", "sub/b"],
|
|
18
|
+
"checks": [
|
|
19
|
+
{
|
|
20
|
+
"id": "precommit",
|
|
21
|
+
"run": "pre-commit run --all-files",
|
|
22
|
+
"cwd": ".",
|
|
23
|
+
"timeoutSec": 600
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "sub-a-e2e",
|
|
27
|
+
"run": "bash scripts/e2e.sh",
|
|
28
|
+
"cwd": "sub/a",
|
|
29
|
+
"timeoutSec": 600
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
|
|
37
|
+
- `version` MUST be `1`.
|
|
38
|
+
- `roots` is optional; default is `["."]`. Every root MUST stay inside the enclosing Git repository and exist.
|
|
39
|
+
- `checks` MUST be a non-empty array.
|
|
40
|
+
- Each check MUST have a unique non-empty `id`, non-empty `run`, repository-contained `cwd` (default `.`), and optional integer `timeoutSec` from 1 through 3600.
|
|
41
|
+
- Unknown fields, invalid JSON, duplicate IDs, escaping paths, or invalid values make verification `FAILED`.
|
|
42
|
+
- Contract checks run with `HARNESS_SKIP_PROJECT_CHECKS=1` so a project's checks cannot recursively invoke the same project gate.
|
|
43
|
+
- When a contract exists, ecosystem detection and legacy npm auto-run are not used.
|
|
44
|
+
|
|
45
|
+
A `git commit` is accepted as successful verification evidence only when:
|
|
46
|
+
- the declared contract contains a `pre-commit ...` check,
|
|
47
|
+
- a runnable pre-commit hook is configured for that repository (on POSIX, the hook file must have an executable bit), and
|
|
48
|
+
- the commit command does not use `--no-verify` or `-n`.
|
|
49
|
+
|
|
50
|
+
This prevents a generic commit from being mistaken for proof that the declared verification contract ran.
|
|
51
|
+
|
|
52
|
+
## Status taxonomy
|
|
53
|
+
|
|
54
|
+
| Status | Meaning | Exit |
|
|
55
|
+
|---|---|---:|
|
|
56
|
+
| `PASSED` | Every executed authoritative check exited 0. | 0 |
|
|
57
|
+
| `FAILED` | A check failed/timed out, the injected failure marker exists, or declared/project metadata is invalid. | 1 |
|
|
58
|
+
| `UNCHECKED_NO_CHECKS` | No contract, no runnable legacy npm checks, and no verification candidates were detected. | 0 |
|
|
59
|
+
| `UNCHECKED_DISCOVERED` | Verification candidates exist but at least one was not executed. | 0 |
|
|
60
|
+
| `SKIPPED` | `HARNESS_SKIP_PROJECT_CHECKS=1` recursion guard. | 0 |
|
|
61
|
+
|
|
62
|
+
Every `UNCHECKED_*` human report MUST include:
|
|
63
|
+
|
|
64
|
+
> PROHIBITED: Do NOT cite this run as proof that tests pass.
|
|
65
|
+
|
|
66
|
+
An exit code of 0 from `UNCHECKED_*` is non-blocking control flow, not passing-test evidence.
|
|
67
|
+
|
|
68
|
+
## Discovery when no contract exists
|
|
69
|
+
|
|
70
|
+
Discovery is read-only. Harness may report:
|
|
71
|
+
|
|
72
|
+
- `package.json`: `lint` / `test` remain legacy auto-run; `check`, `typecheck`, `verify` are candidates.
|
|
73
|
+
- `.pre-commit-config.yaml`: `pre-commit run --all-files`.
|
|
74
|
+
- `Makefile`: `test`, `check`, `lint`, `ci` targets.
|
|
75
|
+
- `justfile`: `test`, `check`, `ci` recipes.
|
|
76
|
+
- Python: pytest/ruff configuration in `pyproject.toml`, `tox.ini`, `noxfile.py`.
|
|
77
|
+
- `Cargo.toml`: `cargo test`.
|
|
78
|
+
- `go.mod`: `go test ./...`.
|
|
79
|
+
- `core.hooksPath`, `.husky/`, or `.git/hooks/pre-commit`: commit-time gate presence.
|
|
80
|
+
|
|
81
|
+
Detected commands are suggestions only. Their existence does not become pass evidence until they are explicitly run or declared in the contract.
|
|
82
|
+
|
|
83
|
+
## Multi-root behavior
|
|
84
|
+
|
|
85
|
+
With a contract, `roots` defines the verification scope. Each check is attributed to the most-specific declared root containing its `cwd`.
|
|
86
|
+
|
|
87
|
+
Without a contract, Harness reports the main Git root plus initialized submodule roots from `git submodule status --recursive`.
|
|
88
|
+
|
|
89
|
+
Overall status uses the most conservative root result:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
FAILED > UNCHECKED_DISCOVERED > UNCHECKED_NO_CHECKS > PASSED
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A submodule with no checks remains visible as its own `UNCHECKED_NO_CHECKS` root; it is never silently merged into the parent result.
|
|
96
|
+
|
|
97
|
+
## Structured output
|
|
98
|
+
|
|
99
|
+
Run:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
node harness-everything/scripts/verify-gate.js --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The output contains:
|
|
106
|
+
|
|
107
|
+
- `status`, `reason`, and `repoRoot`
|
|
108
|
+
- `roots[]` with per-root status/reason
|
|
109
|
+
- `checks[]` with `id`, `run`, `cwd`, `source`, `exitCode`, and `durationMs`
|
|
110
|
+
- `candidates[]` with `source`, `run`, and `cwd`
|
|
111
|
+
|
|
112
|
+
The machine-readable result conforms to `<this-skill-dir>/references/verify-result.schema.json`. It is evidence metadata; only `PASSED` means the gate produced passing verification evidence.
|
package/plugins/harness-everything/skills/verification-loop/references/verification-phases.md
CHANGED
|
@@ -25,11 +25,12 @@ flowchart TD
|
|
|
25
25
|
RunScript --> ReportResult{All Verification Gates Passed?}
|
|
26
26
|
DirectReport --> ReportResult
|
|
27
27
|
|
|
28
|
-
ReportResult --
|
|
29
|
-
ReportResult --
|
|
28
|
+
ReportResult -- PASSED --> Deliver[Task Ready for Delivery / PR]
|
|
29
|
+
ReportResult -- FAILED --> Fix[Fix Issues in Code & Re-verify] --> Build
|
|
30
|
+
ReportResult -- UNCHECKED_* --> Manual[Run declared/listed verification; exit 0 alone is not evidence] --> Build
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
Adapt commands according to the project's ecosystem and active environment (`environment-detection`). Run commands directly without non-portable POSIX pipe assumptions (avoid raw `head`, `tail`, `grep`, `2>/dev/null` piping on Windows).
|
|
33
|
+
Adapt commands according to the project's ecosystem and active environment (`environment-detection`). Run commands directly without non-portable POSIX pipe assumptions (avoid raw `head`, `tail`, `grep`, `2>/dev/null` piping on Windows). For repository-specific mechanical gates and structured `PASSED` / `FAILED` / `UNCHECKED_*` semantics, follow `<this-skill-dir>/references/project-verification-contract.md`.
|
|
33
34
|
|
|
34
35
|
## Phase 1: Build Verification
|
|
35
36
|
|
package/plugins/harness-everything/skills/verification-loop/references/verify-contract.schema.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/dyphn1/Harness-everything/verification-loop/references/verify-contract.schema.json",
|
|
4
|
+
"title": "Harness project verification contract",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["version", "checks"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"version": { "const": 1 },
|
|
10
|
+
"roots": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"minItems": 1,
|
|
13
|
+
"uniqueItems": true,
|
|
14
|
+
"items": { "type": "string", "minLength": 1 }
|
|
15
|
+
},
|
|
16
|
+
"checks": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"minItems": 1,
|
|
19
|
+
"items": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"required": ["id", "run"],
|
|
23
|
+
"properties": {
|
|
24
|
+
"id": { "type": "string", "minLength": 1 },
|
|
25
|
+
"run": { "type": "string", "minLength": 1 },
|
|
26
|
+
"cwd": { "type": "string", "minLength": 1, "default": "." },
|
|
27
|
+
"timeoutSec": { "type": "integer", "minimum": 1, "maximum": 3600, "default": 600 }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/plugins/harness-everything/skills/verification-loop/references/verify-result.schema.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/dyphn1/Harness-everything/verification-loop/references/verify-result.schema.json",
|
|
4
|
+
"title": "Harness verification result",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["status", "reason", "repoRoot", "roots", "checks", "candidates"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"status": {
|
|
10
|
+
"enum": ["PASSED", "FAILED", "UNCHECKED_NO_CHECKS", "UNCHECKED_DISCOVERED", "SKIPPED"]
|
|
11
|
+
},
|
|
12
|
+
"reason": { "type": "string" },
|
|
13
|
+
"repoRoot": { "type": "string" },
|
|
14
|
+
"roots": {
|
|
15
|
+
"type": "array",
|
|
16
|
+
"items": {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"additionalProperties": false,
|
|
19
|
+
"required": ["path", "status", "reason", "checks", "candidates"],
|
|
20
|
+
"properties": {
|
|
21
|
+
"path": { "type": "string" },
|
|
22
|
+
"status": {
|
|
23
|
+
"enum": ["PASSED", "FAILED", "UNCHECKED_NO_CHECKS", "UNCHECKED_DISCOVERED"]
|
|
24
|
+
},
|
|
25
|
+
"reason": { "type": "string" },
|
|
26
|
+
"checks": { "$ref": "#/$defs/checkArray" },
|
|
27
|
+
"candidates": { "$ref": "#/$defs/candidateArray" }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"checks": { "$ref": "#/$defs/checkArray" },
|
|
32
|
+
"candidates": { "$ref": "#/$defs/candidateArray" }
|
|
33
|
+
},
|
|
34
|
+
"$defs": {
|
|
35
|
+
"checkArray": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"additionalProperties": true,
|
|
40
|
+
"required": ["id", "run", "cwd", "source", "exitCode", "durationMs"],
|
|
41
|
+
"properties": {
|
|
42
|
+
"id": { "type": "string" },
|
|
43
|
+
"run": { "type": "string" },
|
|
44
|
+
"cwd": { "type": "string" },
|
|
45
|
+
"source": { "type": "string" },
|
|
46
|
+
"exitCode": { "type": ["integer", "null"] },
|
|
47
|
+
"durationMs": { "type": "integer", "minimum": 0 }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"candidateArray": {
|
|
52
|
+
"type": "array",
|
|
53
|
+
"items": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"required": ["source", "run", "cwd"],
|
|
57
|
+
"properties": {
|
|
58
|
+
"source": { "type": "string" },
|
|
59
|
+
"run": { "type": "string" },
|
|
60
|
+
"cwd": { "type": "string" }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/sbom.cdx.json
CHANGED
|
@@ -2,23 +2,23 @@
|
|
|
2
2
|
"$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.7",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:d5232bfe-e479-5e87-a012-d5dafb7acdc2",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-24T14:53:53.083Z",
|
|
9
9
|
"tools": [
|
|
10
10
|
{
|
|
11
11
|
"vendor": "Harness Everything",
|
|
12
12
|
"name": "generate-sbom.js",
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.26.0"
|
|
14
14
|
}
|
|
15
15
|
],
|
|
16
16
|
"component": {
|
|
17
17
|
"type": "application",
|
|
18
|
-
"bom-ref": "pkg:npm/harness-everything@0.
|
|
18
|
+
"bom-ref": "pkg:npm/harness-everything@0.26.0",
|
|
19
19
|
"name": "harness-everything",
|
|
20
|
-
"version": "0.
|
|
21
|
-
"purl": "pkg:npm/harness-everything@0.
|
|
20
|
+
"version": "0.26.0",
|
|
21
|
+
"purl": "pkg:npm/harness-everything@0.26.0"
|
|
22
22
|
}
|
|
23
23
|
},
|
|
24
24
|
"components": [
|
|
@@ -1999,7 +1999,7 @@
|
|
|
1999
1999
|
]
|
|
2000
2000
|
},
|
|
2001
2001
|
{
|
|
2002
|
-
"ref": "pkg:npm/harness-everything@0.
|
|
2002
|
+
"ref": "pkg:npm/harness-everything@0.26.0",
|
|
2003
2003
|
"dependsOn": [
|
|
2004
2004
|
"pkg:npm/%40commitlint/cli@19.8.1",
|
|
2005
2005
|
"pkg:npm/%40commitlint/config-conventional@19.8.1",
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const CONTRACT_RELATIVE = path.join('.harness', 'verify.json');
|
|
8
|
+
const CONTRACT_VERSION = 1;
|
|
9
|
+
const MAX_TIMEOUT_SEC = 3600;
|
|
10
|
+
|
|
11
|
+
function runGit(cwd, args) {
|
|
12
|
+
return spawnSync('git', args, {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
timeout: 5000,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findGitRoot(cwd = process.cwd()) {
|
|
21
|
+
const result = runGit(cwd, ['rev-parse', '--show-toplevel']);
|
|
22
|
+
return result.status === 0 ? path.resolve(result.stdout.trim()) : path.resolve(cwd);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function within(root, target) {
|
|
26
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
27
|
+
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolveContained(root, value, label, { mustExist = true } = {}) {
|
|
31
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be a non-empty relative path`);
|
|
32
|
+
if (path.isAbsolute(value)) throw new Error(`${label} must stay inside the repository`);
|
|
33
|
+
const resolved = path.resolve(root, value);
|
|
34
|
+
if (!within(root, resolved)) throw new Error(`${label} escapes the repository`);
|
|
35
|
+
if (mustExist && !fs.existsSync(resolved)) throw new Error(`${label} does not exist: ${value}`);
|
|
36
|
+
if (mustExist) {
|
|
37
|
+
const physicalRoot = fs.realpathSync.native(path.resolve(root));
|
|
38
|
+
const physicalTarget = fs.realpathSync.native(resolved);
|
|
39
|
+
if (!within(physicalRoot, physicalTarget)) throw new Error(`${label} escapes the repository through a symbolic link`);
|
|
40
|
+
return physicalTarget;
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertKnownFields(value, allowed, label) {
|
|
46
|
+
for (const key of Object.keys(value)) {
|
|
47
|
+
if (!allowed.has(key)) throw new Error(`${label} contains unknown field: ${key}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readContract(repoRoot) {
|
|
52
|
+
const contractPath = path.join(repoRoot, CONTRACT_RELATIVE);
|
|
53
|
+
if (!fs.existsSync(contractPath)) return null;
|
|
54
|
+
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`verify.json is invalid JSON: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('verify.json must contain a JSON object');
|
|
62
|
+
assertKnownFields(raw, new Set(['version', 'roots', 'checks']), 'verify.json');
|
|
63
|
+
if (raw.version !== CONTRACT_VERSION) throw new Error(`verify.json version must be ${CONTRACT_VERSION}`);
|
|
64
|
+
if (!Array.isArray(raw.checks) || raw.checks.length === 0) throw new Error('verify.json checks must be a non-empty array');
|
|
65
|
+
|
|
66
|
+
const rootValues = raw.roots === undefined ? ['.'] : raw.roots;
|
|
67
|
+
if (!Array.isArray(rootValues) || rootValues.length === 0) throw new Error('verify.json roots must be a non-empty array');
|
|
68
|
+
const seenRoots = new Set();
|
|
69
|
+
const roots = rootValues.map((value, index) => {
|
|
70
|
+
const resolved = resolveContained(repoRoot, value, `verify.json roots[${index}]`);
|
|
71
|
+
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
72
|
+
if (seenRoots.has(key)) throw new Error(`verify.json has duplicate root: ${value}`);
|
|
73
|
+
seenRoots.add(key);
|
|
74
|
+
return { declared: value, path: resolved };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const ids = new Set();
|
|
78
|
+
const checks = raw.checks.map((check, index) => {
|
|
79
|
+
if (!check || typeof check !== 'object' || Array.isArray(check)) throw new Error(`verify.json checks[${index}] must be an object`);
|
|
80
|
+
assertKnownFields(check, new Set(['id', 'run', 'cwd', 'timeoutSec']), `verify.json checks[${index}]`);
|
|
81
|
+
if (typeof check.id !== 'string' || !check.id.trim()) throw new Error(`verify.json checks[${index}].id must be non-empty`);
|
|
82
|
+
if (ids.has(check.id)) throw new Error(`verify.json check id is duplicated: ${check.id}`);
|
|
83
|
+
ids.add(check.id);
|
|
84
|
+
if (typeof check.run !== 'string' || !check.run.trim()) throw new Error(`verify.json checks[${index}].run must be non-empty`);
|
|
85
|
+
const cwdValue = check.cwd === undefined ? '.' : check.cwd;
|
|
86
|
+
const cwd = resolveContained(repoRoot, cwdValue, `verify.json checks[${index}].cwd`);
|
|
87
|
+
const timeoutSec = check.timeoutSec === undefined ? 600 : check.timeoutSec;
|
|
88
|
+
if (!Number.isInteger(timeoutSec) || timeoutSec < 1 || timeoutSec > MAX_TIMEOUT_SEC) {
|
|
89
|
+
throw new Error(`verify.json checks[${index}].timeoutSec must be an integer from 1 to ${MAX_TIMEOUT_SEC}`);
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
id: check.id,
|
|
93
|
+
run: check.run.trim(),
|
|
94
|
+
cwd,
|
|
95
|
+
cwdDeclared: cwdValue,
|
|
96
|
+
timeoutSec,
|
|
97
|
+
source: 'contract',
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
for (const check of checks) {
|
|
102
|
+
const owners = roots.filter(root => within(root.path, check.cwd))
|
|
103
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
104
|
+
if (!owners.length) throw new Error(`verify.json check ${check.id} cwd is outside declared roots`);
|
|
105
|
+
check.root = owners[0].path;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { path: contractPath, roots, checks };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function detectRunner(dir) {
|
|
112
|
+
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
113
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
|
|
114
|
+
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
|
|
115
|
+
return 'npm';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function packageCommand(runner, name) {
|
|
119
|
+
return `${runner} run ${name}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function addCandidate(list, seen, candidate) {
|
|
123
|
+
const key = [candidate.source, candidate.run, candidate.cwd].join('\0');
|
|
124
|
+
if (seen.has(key)) return;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
list.push(candidate);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readTextIfExists(file) {
|
|
130
|
+
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function detectRoot(root) {
|
|
134
|
+
const checks = [];
|
|
135
|
+
const candidates = [];
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const pkgPath = path.join(root, 'package.json');
|
|
138
|
+
|
|
139
|
+
if (fs.existsSync(pkgPath)) {
|
|
140
|
+
let pkg;
|
|
141
|
+
try {
|
|
142
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(`package.json at ${root} is unreadable/invalid JSON (${error.message})`);
|
|
145
|
+
}
|
|
146
|
+
const scripts = pkg && typeof pkg === 'object' && pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
147
|
+
const runner = detectRunner(root);
|
|
148
|
+
for (const name of ['lint', 'test']) {
|
|
149
|
+
const script = scripts[name];
|
|
150
|
+
if (typeof script === 'string' && script.trim() && !/no test specified/i.test(script)) {
|
|
151
|
+
checks.push({
|
|
152
|
+
id: `package-${name}`,
|
|
153
|
+
run: packageCommand(runner, name),
|
|
154
|
+
cwd: root,
|
|
155
|
+
timeoutSec: 600,
|
|
156
|
+
source: 'package-auto',
|
|
157
|
+
root,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const name of ['check', 'typecheck', 'verify', 'build']) {
|
|
162
|
+
const script = scripts[name];
|
|
163
|
+
if (typeof script === 'string' && script.trim()) {
|
|
164
|
+
addCandidate(candidates, seen, { source: `package:${name}`, run: packageCommand(runner, name), cwd: root });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (fs.existsSync(path.join(root, '.pre-commit-config.yaml')) || fs.existsSync(path.join(root, '.pre-commit-config.yml'))) {
|
|
170
|
+
addCandidate(candidates, seen, { source: 'pre-commit', run: 'pre-commit run --all-files', cwd: root });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const makeText = readTextIfExists(path.join(root, 'Makefile'));
|
|
174
|
+
if (makeText) {
|
|
175
|
+
const targets = new Set([...makeText.matchAll(/^([A-Za-z0-9_.-]+)\s*:(?![=])/gm)].map(match => match[1]));
|
|
176
|
+
for (const target of ['test', 'check', 'lint', 'ci']) {
|
|
177
|
+
if (targets.has(target)) addCandidate(candidates, seen, { source: 'make', run: `make ${target}`, cwd: root });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const justText = readTextIfExists(path.join(root, 'justfile')) || readTextIfExists(path.join(root, 'Justfile'));
|
|
182
|
+
if (justText) {
|
|
183
|
+
const recipes = new Set([...justText.matchAll(/^([A-Za-z0-9_-]+)\s*(?:[^:=\r\n]*)?:\s*$/gm)].map(match => match[1]));
|
|
184
|
+
for (const recipe of ['test', 'check', 'ci']) {
|
|
185
|
+
if (recipes.has(recipe)) addCandidate(candidates, seen, { source: 'just', run: `just ${recipe}`, cwd: root });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pyproject = readTextIfExists(path.join(root, 'pyproject.toml'));
|
|
190
|
+
if (/\[tool\.pytest(?:\.|\])/m.test(pyproject)) {
|
|
191
|
+
addCandidate(candidates, seen, { source: 'pyproject:pytest', run: 'pytest', cwd: root });
|
|
192
|
+
addCandidate(candidates, seen, { source: 'pyproject:pytest', run: 'uv run pytest', cwd: root });
|
|
193
|
+
}
|
|
194
|
+
if (/\[tool\.ruff(?:\.|\])/m.test(pyproject)) {
|
|
195
|
+
addCandidate(candidates, seen, { source: 'pyproject:ruff', run: 'ruff check .', cwd: root });
|
|
196
|
+
addCandidate(candidates, seen, { source: 'pyproject:ruff', run: 'uv run ruff check .', cwd: root });
|
|
197
|
+
}
|
|
198
|
+
if (fs.existsSync(path.join(root, 'tsconfig.json'))) addCandidate(candidates, seen, { source: 'typescript', run: 'npx tsc --noEmit', cwd: root });
|
|
199
|
+
if (['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', '.eslintrc', '.eslintrc.json', '.eslintrc.js', '.eslintrc.cjs'].some(name => fs.existsSync(path.join(root, name)))) {
|
|
200
|
+
addCandidate(candidates, seen, { source: 'eslint', run: 'npx eslint .', cwd: root });
|
|
201
|
+
}
|
|
202
|
+
if (fs.existsSync(path.join(root, 'tox.ini'))) addCandidate(candidates, seen, { source: 'tox', run: 'tox', cwd: root });
|
|
203
|
+
if (fs.existsSync(path.join(root, 'noxfile.py'))) addCandidate(candidates, seen, { source: 'nox', run: 'nox', cwd: root });
|
|
204
|
+
if (fs.existsSync(path.join(root, 'Cargo.toml'))) addCandidate(candidates, seen, { source: 'cargo', run: 'cargo test', cwd: root });
|
|
205
|
+
if (fs.existsSync(path.join(root, 'go.mod'))) addCandidate(candidates, seen, { source: 'go', run: 'go test ./...', cwd: root });
|
|
206
|
+
|
|
207
|
+
if (hasCommitHook(root)) {
|
|
208
|
+
addCandidate(candidates, seen, { source: 'git-hook', run: 'git commit (pre-commit hook configured)', cwd: root });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { checks, candidates };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function enumerateSubmoduleRoots(repoRoot) {
|
|
215
|
+
const roots = [path.resolve(repoRoot)];
|
|
216
|
+
const result = runGit(repoRoot, ['submodule', 'status', '--recursive']);
|
|
217
|
+
if (result.status !== 0 || !result.stdout.trim()) return roots;
|
|
218
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
219
|
+
if (!line.trim()) continue;
|
|
220
|
+
const match = line.match(/^[ +-U]?([0-9a-fA-F]{40,64})\s+(.+?)(?:\s+\(.+\))?$/);
|
|
221
|
+
if (!match) continue;
|
|
222
|
+
const candidate = path.resolve(repoRoot, match[2]);
|
|
223
|
+
if (within(repoRoot, candidate) && fs.existsSync(candidate)) roots.push(candidate);
|
|
224
|
+
}
|
|
225
|
+
return [...new Set(roots)];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hookIsRunnable(stat, platform = process.platform) {
|
|
229
|
+
return !!stat && stat.isFile() && (platform === 'win32' || (stat.mode & 0o111) !== 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function hasCommitHook(repoRoot, platform = process.platform) {
|
|
233
|
+
const result = runGit(repoRoot, ['rev-parse', '--git-path', 'hooks/pre-commit']);
|
|
234
|
+
if (result.status !== 0) return false;
|
|
235
|
+
const value = result.stdout.trim();
|
|
236
|
+
if (!value) return false;
|
|
237
|
+
const hook = path.isAbsolute(value) ? value : path.resolve(repoRoot, value);
|
|
238
|
+
let stat;
|
|
239
|
+
try { stat = fs.statSync(hook); }
|
|
240
|
+
catch (_) { return false; }
|
|
241
|
+
return hookIsRunnable(stat, platform);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeCommand(command) {
|
|
245
|
+
return String(command || '').trim().replace(/\s+/g, ' ');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function hasControlOperators(command) {
|
|
249
|
+
return /(?:&&|\|\||[;|\r\n])/.test(command);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function commandMatches(command, expected) {
|
|
253
|
+
const actual = normalizeCommand(command);
|
|
254
|
+
const target = normalizeCommand(expected);
|
|
255
|
+
if (!actual || !target || hasControlOperators(actual)) return false;
|
|
256
|
+
return actual === target || actual.startsWith(target + ' ');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function isGitCommit(command) {
|
|
260
|
+
const actual = normalizeCommand(command);
|
|
261
|
+
return /^git\s+commit(?:\s|$)/i.test(actual);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function bypassesHooks(command) {
|
|
265
|
+
const actual = normalizeCommand(command);
|
|
266
|
+
if (/(?:^|\s)--no-verify(?:\s|$)/i.test(actual) || /(?:^|\s)-n(?:\s|$)/.test(actual)) return true;
|
|
267
|
+
|
|
268
|
+
// Git accepts boolean short options in clusters, e.g. `git commit -an`.
|
|
269
|
+
// Treat a cluster containing -n as hook bypass only when every clustered
|
|
270
|
+
// option is a no-argument commit flag; this avoids mistaking attached
|
|
271
|
+
// arguments such as `-S<key>` or `-m<message>` for --no-verify.
|
|
272
|
+
return actual.split(/\s+/).some(token =>
|
|
273
|
+
/^-[avqseipon]+$/i.test(token) && token.length > 2 && token.slice(1).toLowerCase().includes('n')
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function legacyVerificationCommand(command) {
|
|
278
|
+
const value = normalizeCommand(command);
|
|
279
|
+
return /^(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|lint|build|typecheck|check|verify)\b|npx\s+(?:jest|vitest|mocha|tsc|eslint)\b|(?:jest|vitest|mocha|pytest|rspec|phpunit)\b|uv\s+run\s+(?:pytest|ruff)\b|(?:make|just)\s+(?:test|check|lint|ci)\b|cargo\s+(?:test|check)\b|go\s+test\b|dotnet\s+(?:test|build)\b)/i.test(value);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function verificationCommandSet(cwd, options = {}) {
|
|
283
|
+
const repoRoot = path.resolve(options.root || findGitRoot(cwd));
|
|
284
|
+
let contract = null;
|
|
285
|
+
try { contract = readContract(repoRoot); }
|
|
286
|
+
catch (error) {
|
|
287
|
+
// The verify gate treats an invalid authoritative contract as FAILED.
|
|
288
|
+
// Preserve that fail-closed state here instead of falling through to
|
|
289
|
+
// legacy recognition and manufacturing contradictory verification evidence.
|
|
290
|
+
return {
|
|
291
|
+
repoRoot,
|
|
292
|
+
contract: null,
|
|
293
|
+
contractInvalid: true,
|
|
294
|
+
contractError: error && error.message ? error.message : String(error),
|
|
295
|
+
commands: [],
|
|
296
|
+
commitHookDeclared: false,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (contract) {
|
|
301
|
+
return {
|
|
302
|
+
repoRoot,
|
|
303
|
+
contract,
|
|
304
|
+
contractInvalid: false,
|
|
305
|
+
commands: contract.checks.map(check => check.run),
|
|
306
|
+
commitHookDeclared: contract.checks.some(check => /^pre-commit\s+/i.test(normalizeCommand(check.run))),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const root = findGitRoot(cwd);
|
|
311
|
+
let detected;
|
|
312
|
+
try { detected = detectRoot(root); }
|
|
313
|
+
catch (_) { detected = { checks: [], candidates: [] }; }
|
|
314
|
+
return {
|
|
315
|
+
repoRoot,
|
|
316
|
+
contract: null,
|
|
317
|
+
contractInvalid: false,
|
|
318
|
+
commands: [
|
|
319
|
+
...detected.checks.map(check => check.run),
|
|
320
|
+
...detected.candidates.filter(candidate => candidate.source !== 'git-hook').map(candidate => candidate.run),
|
|
321
|
+
],
|
|
322
|
+
commitHookDeclared: false,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isVerificationCommand(command, options = {}) {
|
|
327
|
+
const cwd = path.resolve(options.cwd || process.cwd());
|
|
328
|
+
const actual = normalizeCommand(command);
|
|
329
|
+
if (!actual || hasControlOperators(actual)) return false;
|
|
330
|
+
const set = verificationCommandSet(cwd, options);
|
|
331
|
+
if (set.contractInvalid) return false;
|
|
332
|
+
|
|
333
|
+
if (isGitCommit(actual)) {
|
|
334
|
+
return !!set.commitHookDeclared && !bypassesHooks(actual) && hasCommitHook(set.repoRoot);
|
|
335
|
+
}
|
|
336
|
+
if (set.commands.some(expected => commandMatches(actual, expected))) return true;
|
|
337
|
+
return !set.contract && legacyVerificationCommand(actual);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = {
|
|
341
|
+
CONTRACT_RELATIVE,
|
|
342
|
+
MAX_TIMEOUT_SEC,
|
|
343
|
+
addCandidate,
|
|
344
|
+
commandMatches,
|
|
345
|
+
detectRoot,
|
|
346
|
+
enumerateSubmoduleRoots,
|
|
347
|
+
findGitRoot,
|
|
348
|
+
hasCommitHook,
|
|
349
|
+
hookIsRunnable,
|
|
350
|
+
isVerificationCommand,
|
|
351
|
+
legacyVerificationCommand,
|
|
352
|
+
normalizeCommand,
|
|
353
|
+
readContract,
|
|
354
|
+
resolveContained,
|
|
355
|
+
verificationCommandSet,
|
|
356
|
+
within,
|
|
357
|
+
};
|