@codyswann/lisa 2.265.0 → 2.267.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/dist/core/lisa-plugin-selection.d.ts +11 -0
- package/dist/core/lisa-plugin-selection.d.ts.map +1 -1
- package/dist/core/lisa-plugin-selection.js +11 -1
- package/dist/core/lisa-plugin-selection.js.map +1 -1
- package/dist/core/lisa.d.ts +0 -41
- package/dist/core/lisa.d.ts.map +1 -1
- package/dist/core/lisa.js +23 -104
- package/dist/core/lisa.js.map +1 -1
- package/dist/core/template-ownership.d.ts +46 -0
- package/dist/core/template-ownership.d.ts.map +1 -0
- package/dist/core/template-ownership.js +123 -0
- package/dist/core/template-ownership.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +38 -7
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/health/deadline.d.ts +25 -0
- package/dist/health/deadline.d.ts.map +1 -0
- package/dist/health/deadline.js +50 -0
- package/dist/health/deadline.js.map +1 -0
- package/dist/health/deterministic.d.ts +34 -0
- package/dist/health/deterministic.d.ts.map +1 -0
- package/dist/health/deterministic.js +206 -0
- package/dist/health/deterministic.js.map +1 -0
- package/dist/health/finding-utils.d.ts +17 -0
- package/dist/health/finding-utils.d.ts.map +1 -0
- package/dist/health/finding-utils.js +25 -0
- package/dist/health/finding-utils.js.map +1 -0
- package/dist/health/governance-probes.d.ts +51 -0
- package/dist/health/governance-probes.d.ts.map +1 -0
- package/dist/health/governance-probes.js +94 -0
- package/dist/health/governance-probes.js.map +1 -0
- package/dist/health/hook-inspection.d.ts +35 -0
- package/dist/health/hook-inspection.d.ts.map +1 -0
- package/dist/health/hook-inspection.js +123 -0
- package/dist/health/hook-inspection.js.map +1 -0
- package/dist/health/index.d.ts +1 -0
- package/dist/health/index.d.ts.map +1 -1
- package/dist/health/index.js +1 -0
- package/dist/health/index.js.map +1 -1
- package/dist/health/plugin-inspection.d.ts +37 -0
- package/dist/health/plugin-inspection.d.ts.map +1 -0
- package/dist/health/plugin-inspection.js +128 -0
- package/dist/health/plugin-inspection.js.map +1 -0
- package/dist/health/project-probes.d.ts +34 -0
- package/dist/health/project-probes.d.ts.map +1 -0
- package/dist/health/project-probes.js +104 -0
- package/dist/health/project-probes.js.map +1 -0
- package/dist/health/read-only-fs.d.ts +54 -0
- package/dist/health/read-only-fs.d.ts.map +1 -0
- package/dist/health/read-only-fs.js +165 -0
- package/dist/health/read-only-fs.js.map +1 -0
- package/dist/health/ruleset-inspection.d.ts +57 -0
- package/dist/health/ruleset-inspection.d.ts.map +1 -0
- package/dist/health/ruleset-inspection.js +315 -0
- package/dist/health/ruleset-inspection.js.map +1 -0
- package/dist/health/template-inspection.d.ts +67 -0
- package/dist/health/template-inspection.d.ts.map +1 -0
- package/dist/health/template-inspection.js +469 -0
- package/dist/health/template-inspection.js.map +1 -0
- package/dist/strategies/copy-contents.d.ts +7 -25
- package/dist/strategies/copy-contents.d.ts.map +1 -1
- package/dist/strategies/copy-contents.js +32 -63
- package/dist/strategies/copy-contents.js.map +1 -1
- package/dist/strategies/merge.d.ts +7 -0
- package/dist/strategies/merge.d.ts.map +1 -1
- package/dist/strategies/merge.js +10 -1
- package/dist/strategies/merge.js.map +1 -1
- package/dist/strategies/package-lisa.d.ts +10 -1
- package/dist/strategies/package-lisa.d.ts.map +1 -1
- package/dist/strategies/package-lisa.js +18 -20
- package/dist/strategies/package-lisa.js.map +1 -1
- package/dist/strategies/tagged-merge.d.ts +7 -0
- package/dist/strategies/tagged-merge.d.ts.map +1 -1
- package/dist/strategies/tagged-merge.js +10 -1
- package/dist/strategies/tagged-merge.js.map +1 -1
- package/dist/sync/config-sync.d.ts +7 -0
- package/dist/sync/config-sync.d.ts.map +1 -1
- package/dist/sync/config-sync.js +23 -16
- package/dist/sync/config-sync.js.map +1 -1
- package/dist/utils/ignore-patterns.d.ts +18 -0
- package/dist/utils/ignore-patterns.d.ts.map +1 -1
- package/dist/utils/ignore-patterns.js +2 -2
- package/dist/utils/ignore-patterns.js.map +1 -1
- package/package.json +3 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +22 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-spec-conformance/SKILL.md +43 -11
- package/plugins/lisa/agents/spec-conformance-specialist.md +5 -2
- package/plugins/lisa/agents/verification-specialist.md +1 -1
- package/plugins/lisa/hooks/enforce-verification-gate.sh +83 -9
- package/plugins/lisa/rules/reference/verification.md +2 -0
- package/plugins/lisa/skills/lisa-implement/SKILL.md +22 -1
- package/plugins/lisa/skills/lisa-spec-conformance/SKILL.md +43 -11
- package/plugins/lisa-agy/agents/spec-conformance-specialist.md +5 -2
- package/plugins/lisa-agy/agents/verification-specialist.md +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +22 -1
- package/plugins/lisa-agy/skills/lisa-spec-conformance/SKILL.md +43 -11
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/agents/spec-conformance-specialist.agent.md +5 -2
- package/plugins/lisa-copilot/agents/verification-specialist.agent.md +1 -1
- package/plugins/lisa-copilot/hooks/enforce-verification-gate.sh +83 -9
- package/plugins/lisa-copilot/rules/reference/verification.md +2 -0
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +22 -1
- package/plugins/lisa-copilot/skills/lisa-spec-conformance/SKILL.md +43 -11
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/agents/spec-conformance-specialist.md +5 -2
- package/plugins/lisa-cursor/agents/verification-specialist.md +1 -1
- package/plugins/lisa-cursor/hooks/enforce-verification-gate.sh +83 -9
- package/plugins/lisa-cursor/rules/verification-reference.mdc +2 -0
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +22 -1
- package/plugins/lisa-cursor/skills/lisa-spec-conformance/SKILL.md +43 -11
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/agents/spec-conformance-specialist.md +5 -2
- package/plugins/src/base/agents/verification-specialist.md +1 -1
- package/plugins/src/base/hooks/enforce-verification-gate.sh +83 -9
- package/plugins/src/base/rules/reference/verification.md +2 -0
- package/plugins/src/base/skills/lisa-implement/SKILL.md +22 -1
- package/plugins/src/base/skills/lisa-spec-conformance/SKILL.md +43 -11
- package/scripts/detect-stale-workflow-inputs.mjs +21 -148
- package/scripts/lib/reusable-workflow-contract.d.mts +24 -0
- package/scripts/lib/reusable-workflow-contract.mjs +123 -0
- package/scripts/verify-health-deterministic-built.mjs +499 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Pure reusable-workflow caller/contract comparison shared by health and CLI. */
|
|
2
|
+
|
|
3
|
+
/** @param {string} line @returns {number} */
|
|
4
|
+
function indentOf(line) {
|
|
5
|
+
return line.length - line.trimStart().length;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {readonly string[]} lines
|
|
10
|
+
* @param {number} openerIndex
|
|
11
|
+
* @param {number} parentIndent
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
*/
|
|
14
|
+
function collectImmediateChildKeys(lines, openerIndex, parentIndent) {
|
|
15
|
+
const keys = [];
|
|
16
|
+
for (let index = openerIndex + 1; index < lines.length; index++) {
|
|
17
|
+
const line = lines[index];
|
|
18
|
+
if (line.trim() === "") continue;
|
|
19
|
+
const indentation = indentOf(line);
|
|
20
|
+
if (indentation <= parentIndent) break;
|
|
21
|
+
const match = /^\s*([A-Za-z0-9_-]+):/.exec(line);
|
|
22
|
+
if (match && indentation === parentIndent + 2) keys.push(match[1]);
|
|
23
|
+
}
|
|
24
|
+
return keys;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} usesValue
|
|
29
|
+
* @returns {{ file: string, ref: string, owner: string | null, repo: string | null } | null}
|
|
30
|
+
*/
|
|
31
|
+
export function parseReusableReference(usesValue) {
|
|
32
|
+
const value = usesValue.trim();
|
|
33
|
+
const local = /^\.\/\.github\/workflows\/([\w.-]+\.ya?ml)$/.exec(value);
|
|
34
|
+
if (local) {
|
|
35
|
+
return { file: local[1], ref: "local", owner: null, repo: null };
|
|
36
|
+
}
|
|
37
|
+
const remote =
|
|
38
|
+
/^([^/\s]+)\/([^/\s]+)\/\.github\/workflows\/([\w.-]+\.ya?ml)@(\S+)$/.exec(
|
|
39
|
+
value
|
|
40
|
+
);
|
|
41
|
+
return remote
|
|
42
|
+
? {
|
|
43
|
+
file: remote[3],
|
|
44
|
+
ref: remote[4],
|
|
45
|
+
owner: remote[1],
|
|
46
|
+
repo: remote[2],
|
|
47
|
+
}
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} content
|
|
53
|
+
* @returns {{ reusableFile: string, ref: string, owner: string | null, repo: string | null, withKeys: string[] }[]}
|
|
54
|
+
*/
|
|
55
|
+
export function extractCallerJobs(content) {
|
|
56
|
+
const lines = content.split(/\r?\n/);
|
|
57
|
+
const jobs = [];
|
|
58
|
+
for (let index = 0; index < lines.length; index++) {
|
|
59
|
+
const usesMatch = /^(\s*)uses:\s*(\S+)\s*$/.exec(lines[index]);
|
|
60
|
+
if (!usesMatch) continue;
|
|
61
|
+
const reference = parseReusableReference(usesMatch[2]);
|
|
62
|
+
if (!reference) continue;
|
|
63
|
+
const indentation = usesMatch[1].length;
|
|
64
|
+
let withKeys = [];
|
|
65
|
+
for (let child = index + 1; child < lines.length; child++) {
|
|
66
|
+
const line = lines[child];
|
|
67
|
+
if (line.trim() === "") continue;
|
|
68
|
+
const childIndentation = indentOf(line);
|
|
69
|
+
if (childIndentation < indentation) break;
|
|
70
|
+
if (childIndentation === indentation && /^\s*with:\s*$/.test(line)) {
|
|
71
|
+
withKeys = collectImmediateChildKeys(lines, child, indentation).sort(
|
|
72
|
+
(left, right) => left.localeCompare(right)
|
|
73
|
+
);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
jobs.push({
|
|
78
|
+
reusableFile: reference.file,
|
|
79
|
+
ref: reference.ref,
|
|
80
|
+
owner: reference.owner,
|
|
81
|
+
repo: reference.repo,
|
|
82
|
+
withKeys,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return jobs;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} content @returns {string[] | null} */
|
|
89
|
+
export function extractDeclaredInputs(content) {
|
|
90
|
+
const lines = content.split(/\r?\n/);
|
|
91
|
+
for (let index = 0; index < lines.length; index++) {
|
|
92
|
+
if (!/^\s*workflow_call:\s*$/.test(lines[index])) continue;
|
|
93
|
+
const indentation = indentOf(lines[index]);
|
|
94
|
+
for (let child = index + 1; child < lines.length; child++) {
|
|
95
|
+
const line = lines[child];
|
|
96
|
+
if (line.trim() === "") continue;
|
|
97
|
+
const childIndentation = indentOf(line);
|
|
98
|
+
if (childIndentation <= indentation) break;
|
|
99
|
+
if (
|
|
100
|
+
childIndentation === indentation + 2 &&
|
|
101
|
+
/^\s*inputs:\s*$/.test(line)
|
|
102
|
+
) {
|
|
103
|
+
return collectImmediateChildKeys(lines, child, childIndentation).sort(
|
|
104
|
+
(left, right) => left.localeCompare(right)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {readonly string[]} used
|
|
115
|
+
* @param {readonly string[]} declared
|
|
116
|
+
* @returns {string[]}
|
|
117
|
+
*/
|
|
118
|
+
export function diffStaleKeys(used, declared) {
|
|
119
|
+
const declaredSet = new Set(declared);
|
|
120
|
+
return used
|
|
121
|
+
.filter(key => !declaredSet.has(key))
|
|
122
|
+
.sort((left, right) => left.localeCompare(right));
|
|
123
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Empirical proof for the shipped deterministic Health fast path. */
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import {
|
|
7
|
+
chmod,
|
|
8
|
+
lstat,
|
|
9
|
+
mkdir,
|
|
10
|
+
mkdtemp,
|
|
11
|
+
readFile,
|
|
12
|
+
readdir,
|
|
13
|
+
readlink,
|
|
14
|
+
rm,
|
|
15
|
+
symlink,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
let assertions = 0;
|
|
22
|
+
const check = (condition, message) => {
|
|
23
|
+
assertions += 1;
|
|
24
|
+
assert.ok(condition, message);
|
|
25
|
+
};
|
|
26
|
+
const equal = (actual, expected, message) => {
|
|
27
|
+
assertions += 1;
|
|
28
|
+
assert.deepEqual(actual, expected, message);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const root = process.cwd();
|
|
32
|
+
const workspace = await mkdtemp(path.join(tmpdir(), "lisa-health-fast-path-"));
|
|
33
|
+
const project = path.join(workspace, "host");
|
|
34
|
+
const isolatedBin = path.join(workspace, "bin");
|
|
35
|
+
const gitExecutable = execFileSync("which", ["git"], {
|
|
36
|
+
encoding: "utf8",
|
|
37
|
+
}).trim();
|
|
38
|
+
const healthFile = path.join(project, ".lisa", "health", "latest.json");
|
|
39
|
+
const startedAt = Date.now();
|
|
40
|
+
|
|
41
|
+
const run = (executable, argv, options = {}) =>
|
|
42
|
+
execFileSync(executable, argv, {
|
|
43
|
+
cwd: project,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
...options,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const git = (...argv) => run(gitExecutable, argv);
|
|
50
|
+
|
|
51
|
+
async function snapshotTree(directory) {
|
|
52
|
+
const records = [];
|
|
53
|
+
const visit = async (absolute, relative) => {
|
|
54
|
+
const entries = await readdir(absolute, { withFileTypes: true });
|
|
55
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (relative.length === 0 && entry.name === ".git") continue;
|
|
58
|
+
const childRelative =
|
|
59
|
+
relative.length === 0 ? entry.name : `${relative}/${entry.name}`;
|
|
60
|
+
const childAbsolute = path.join(absolute, entry.name);
|
|
61
|
+
const stat = await lstat(childAbsolute);
|
|
62
|
+
const mode = stat.mode & 0o7777;
|
|
63
|
+
if (stat.isSymbolicLink()) {
|
|
64
|
+
records.push([
|
|
65
|
+
childRelative,
|
|
66
|
+
"symlink",
|
|
67
|
+
mode,
|
|
68
|
+
await readlink(childAbsolute),
|
|
69
|
+
]);
|
|
70
|
+
} else if (stat.isDirectory()) {
|
|
71
|
+
records.push([childRelative, "directory", mode]);
|
|
72
|
+
await visit(childAbsolute, childRelative);
|
|
73
|
+
} else if (stat.isFile()) {
|
|
74
|
+
const digest = createHash("sha256")
|
|
75
|
+
.update(await readFile(childAbsolute))
|
|
76
|
+
.digest("hex");
|
|
77
|
+
records.push([childRelative, "file", mode, digest]);
|
|
78
|
+
} else {
|
|
79
|
+
records.push([childRelative, "special", mode]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await visit(directory, "");
|
|
84
|
+
return records;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function rulesetDocuments() {
|
|
88
|
+
const groups = await Promise.all(
|
|
89
|
+
["all", "typescript"].map(async type => {
|
|
90
|
+
const directory = path.join(root, type, "github-rulesets");
|
|
91
|
+
return Promise.all(
|
|
92
|
+
(await readdir(directory, { withFileTypes: true }))
|
|
93
|
+
.filter(entry => entry.isFile())
|
|
94
|
+
.map(async entry => {
|
|
95
|
+
const parsed = JSON.parse(
|
|
96
|
+
await readFile(path.join(directory, entry.name), "utf8")
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
name: parsed.name,
|
|
100
|
+
target: parsed.target,
|
|
101
|
+
enforcement: parsed.enforcement,
|
|
102
|
+
conditions: parsed.conditions,
|
|
103
|
+
rules: parsed.rules,
|
|
104
|
+
};
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
return Object.values(
|
|
110
|
+
Object.fromEntries(groups.flat().map(document => [document.name, document]))
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function assertPureCollection(health, options) {
|
|
115
|
+
const beforeTree = await snapshotTree(project);
|
|
116
|
+
const beforeStatus = git("status", "--porcelain=v1", "--untracked-files=all");
|
|
117
|
+
const beforeExitCode = process.exitCode;
|
|
118
|
+
const beforeHealthFile = await lstat(healthFile)
|
|
119
|
+
.then(() => true)
|
|
120
|
+
.catch(() => false);
|
|
121
|
+
const result = await health.runDeterministicHealth(project, options);
|
|
122
|
+
equal(
|
|
123
|
+
await snapshotTree(project),
|
|
124
|
+
beforeTree,
|
|
125
|
+
"health collection preserves every working-tree inode and byte"
|
|
126
|
+
);
|
|
127
|
+
equal(
|
|
128
|
+
git("status", "--porcelain=v1", "--untracked-files=all"),
|
|
129
|
+
beforeStatus,
|
|
130
|
+
"health collection preserves git status"
|
|
131
|
+
);
|
|
132
|
+
equal(
|
|
133
|
+
process.exitCode,
|
|
134
|
+
beforeExitCode,
|
|
135
|
+
"health collection preserves process exit state"
|
|
136
|
+
);
|
|
137
|
+
equal(
|
|
138
|
+
await lstat(healthFile)
|
|
139
|
+
.then(() => true)
|
|
140
|
+
.catch(() => false),
|
|
141
|
+
beforeHealthFile,
|
|
142
|
+
"health collection never persists latest.json"
|
|
143
|
+
);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await mkdir(project, { recursive: true });
|
|
149
|
+
await mkdir(isolatedBin, { recursive: true });
|
|
150
|
+
await symlink(gitExecutable, path.join(isolatedBin, "git"));
|
|
151
|
+
await writeFile(
|
|
152
|
+
path.join(project, "package.json"),
|
|
153
|
+
`${JSON.stringify(
|
|
154
|
+
{
|
|
155
|
+
name: "deterministic-health-fixture",
|
|
156
|
+
private: true,
|
|
157
|
+
devDependencies: { harperdb: "4.6.3", typescript: "5.9.3" },
|
|
158
|
+
},
|
|
159
|
+
null,
|
|
160
|
+
2
|
|
161
|
+
)}\n`
|
|
162
|
+
);
|
|
163
|
+
await writeFile(
|
|
164
|
+
path.join(project, ".lisa.config.json"),
|
|
165
|
+
`${JSON.stringify(
|
|
166
|
+
{
|
|
167
|
+
tracker: "github",
|
|
168
|
+
github: { org: "example", repo: "health-fixture" },
|
|
169
|
+
harness: "claude",
|
|
170
|
+
},
|
|
171
|
+
null,
|
|
172
|
+
2
|
|
173
|
+
)}\n`
|
|
174
|
+
);
|
|
175
|
+
await mkdir(path.join(project, "harper-app"), { recursive: true });
|
|
176
|
+
await writeFile(
|
|
177
|
+
path.join(project, "harper-app", "config.yaml"),
|
|
178
|
+
"graphqlSchema: ./schema.graphql\njsResource: true\nstatic: ./web\n"
|
|
179
|
+
);
|
|
180
|
+
await writeFile(
|
|
181
|
+
path.join(project, "harper-app", "schema.graphql"),
|
|
182
|
+
"type Query { health: Boolean! }\n"
|
|
183
|
+
);
|
|
184
|
+
git("init", "--initial-branch=main");
|
|
185
|
+
git("config", "user.name", "Lisa Health Verifier");
|
|
186
|
+
git("config", "user.email", "health-verifier@example.invalid");
|
|
187
|
+
git("add", ".");
|
|
188
|
+
git("commit", "-m", "fixture bootstrap");
|
|
189
|
+
|
|
190
|
+
run(
|
|
191
|
+
process.execPath,
|
|
192
|
+
[
|
|
193
|
+
path.join(root, "dist/index.js"),
|
|
194
|
+
"--no-update-check",
|
|
195
|
+
"apply",
|
|
196
|
+
"--yes",
|
|
197
|
+
"--harness=claude",
|
|
198
|
+
project,
|
|
199
|
+
],
|
|
200
|
+
{
|
|
201
|
+
cwd: root,
|
|
202
|
+
env: {
|
|
203
|
+
...process.env,
|
|
204
|
+
LISA_BOOTSTRAP: "1",
|
|
205
|
+
LISA_SKIP_UPDATE_CHECK: "1",
|
|
206
|
+
PATH: isolatedBin,
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
);
|
|
210
|
+
run(
|
|
211
|
+
process.execPath,
|
|
212
|
+
[path.join(root, "dist/index.js"), "sync", project, "--json"],
|
|
213
|
+
{
|
|
214
|
+
cwd: root,
|
|
215
|
+
env: {
|
|
216
|
+
...process.env,
|
|
217
|
+
LISA_SKIP_UPDATE_CHECK: "1",
|
|
218
|
+
PATH: isolatedBin,
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
const version = JSON.parse(
|
|
223
|
+
await readFile(path.join(root, "package.json"), "utf8")
|
|
224
|
+
).version;
|
|
225
|
+
await writeFile(
|
|
226
|
+
path.join(project, ".claude", ".lisa-plugins-synced"),
|
|
227
|
+
`${version}\n`
|
|
228
|
+
);
|
|
229
|
+
git("add", ".");
|
|
230
|
+
git("commit", "-m", "apply Lisa fixture");
|
|
231
|
+
|
|
232
|
+
const health = await import("@codyswann/lisa/health");
|
|
233
|
+
check(
|
|
234
|
+
typeof health.runDeterministicHealth === "function",
|
|
235
|
+
"compiled health export resolves runner"
|
|
236
|
+
);
|
|
237
|
+
const expectedRulesets = await rulesetDocuments();
|
|
238
|
+
const settings = JSON.parse(
|
|
239
|
+
await readFile(path.join(project, ".claude", "settings.json"), "utf8")
|
|
240
|
+
);
|
|
241
|
+
const installedPlugins = Object.entries(settings.enabledPlugins)
|
|
242
|
+
.filter(([, enabled]) => enabled === true)
|
|
243
|
+
.map(([plugin]) => plugin);
|
|
244
|
+
const options = {
|
|
245
|
+
readRulesets: async () => expectedRulesets,
|
|
246
|
+
readHooksPath: async () => ".husky",
|
|
247
|
+
readInstalledPlugins: async () => installedPlugins,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const clean = await assertPureCollection(health, options);
|
|
251
|
+
equal(clean.mode, "deterministic", "runner returns deterministic mode");
|
|
252
|
+
check(
|
|
253
|
+
clean.findings.every(finding => finding.layer === "deterministic"),
|
|
254
|
+
"only deterministic findings are emitted"
|
|
255
|
+
);
|
|
256
|
+
equal(clean.summary.counts.fail, 0, "clean fixture has zero failures");
|
|
257
|
+
equal(clean.summary.verdict, "in band", "clean fixture is in band");
|
|
258
|
+
equal(
|
|
259
|
+
clean.findings.find(finding => finding.check === "templates.managed")
|
|
260
|
+
.status,
|
|
261
|
+
"pass",
|
|
262
|
+
"the real TypeScript plus Harper child template stack composes cleanly"
|
|
263
|
+
);
|
|
264
|
+
check(
|
|
265
|
+
(await readFile(path.join(project, ".prettierignore"), "utf8")).includes(
|
|
266
|
+
"AI GUARDRAILS HARPER-FABRIC"
|
|
267
|
+
),
|
|
268
|
+
"the real child copy-contents block survives its parent overwrite"
|
|
269
|
+
);
|
|
270
|
+
equal(
|
|
271
|
+
clean.findings.find(finding => finding.check === "project.wiki").status,
|
|
272
|
+
"pass",
|
|
273
|
+
"the applied fixture has a healthy project wiki"
|
|
274
|
+
);
|
|
275
|
+
equal(
|
|
276
|
+
clean.findings.find(finding => finding.check === "starters.remote").status,
|
|
277
|
+
"warn",
|
|
278
|
+
"offline starter inspection remains an explicit warning"
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const callerPath = path.join(project, ".github", "workflows", "claude.yml");
|
|
282
|
+
const callerOriginal = await readFile(callerPath, "utf8");
|
|
283
|
+
await writeFile(
|
|
284
|
+
callerPath,
|
|
285
|
+
callerOriginal.replace(
|
|
286
|
+
" with:\n",
|
|
287
|
+
" with:\n obsolete_health_input: true\n"
|
|
288
|
+
)
|
|
289
|
+
);
|
|
290
|
+
const staleCaller = await assertPureCollection(health, options);
|
|
291
|
+
const workflowFinding = staleCaller.findings.find(
|
|
292
|
+
finding => finding.check === "ci.workflows"
|
|
293
|
+
);
|
|
294
|
+
equal(workflowFinding.status, "fail", "stale create-only input is a failure");
|
|
295
|
+
check(
|
|
296
|
+
workflowFinding.reason.includes("obsolete_health_input"),
|
|
297
|
+
"stale create-only input is named"
|
|
298
|
+
);
|
|
299
|
+
await writeFile(callerPath, callerOriginal);
|
|
300
|
+
|
|
301
|
+
const prettierPath = path.join(project, ".prettierignore");
|
|
302
|
+
const prettierOriginal = await readFile(prettierPath);
|
|
303
|
+
await writeFile(path.join(project, ".lisaignore"), ".prettierignore\n");
|
|
304
|
+
await writeFile(prettierPath, "project-owned while ignored\n");
|
|
305
|
+
const ignoredTemplate = await assertPureCollection(health, options);
|
|
306
|
+
equal(
|
|
307
|
+
ignoredTemplate.findings.find(
|
|
308
|
+
finding => finding.check === "templates.managed"
|
|
309
|
+
).status,
|
|
310
|
+
"pass",
|
|
311
|
+
"ignored templates are outside Lisa ownership"
|
|
312
|
+
);
|
|
313
|
+
await rm(path.join(project, ".lisaignore"));
|
|
314
|
+
await writeFile(prettierPath, prettierOriginal);
|
|
315
|
+
|
|
316
|
+
const deletedWorkflow = path.join(
|
|
317
|
+
project,
|
|
318
|
+
".github",
|
|
319
|
+
"workflows",
|
|
320
|
+
"quality.yml"
|
|
321
|
+
);
|
|
322
|
+
await writeFile(deletedWorkflow, "name: project-owned deletion fixture\n");
|
|
323
|
+
const deletionOwned = await assertPureCollection(health, options);
|
|
324
|
+
check(
|
|
325
|
+
deletionOwned.findings.every(
|
|
326
|
+
finding => !finding.reason.includes("quality.yml")
|
|
327
|
+
),
|
|
328
|
+
"pending deletions are excluded from managed ownership"
|
|
329
|
+
);
|
|
330
|
+
await rm(deletedWorkflow);
|
|
331
|
+
|
|
332
|
+
const learningsPath = path.join(project, ".lisa", "PROJECT_LEARNINGS.md");
|
|
333
|
+
const learningsOriginal = await readFile(learningsPath);
|
|
334
|
+
const customLearnings = Buffer.from(
|
|
335
|
+
"# Project learnings\n\nHost-owned evidence.\n"
|
|
336
|
+
);
|
|
337
|
+
await writeFile(learningsPath, customLearnings);
|
|
338
|
+
const learningsOwned = await assertPureCollection(health, options);
|
|
339
|
+
equal(
|
|
340
|
+
learningsOwned.findings.find(
|
|
341
|
+
finding => finding.check === "templates.managed"
|
|
342
|
+
).status,
|
|
343
|
+
"pass",
|
|
344
|
+
"project learnings remain host-owned after their initial seed"
|
|
345
|
+
);
|
|
346
|
+
equal(
|
|
347
|
+
await readFile(learningsPath),
|
|
348
|
+
customLearnings,
|
|
349
|
+
"health preserves project-owned learning bytes"
|
|
350
|
+
);
|
|
351
|
+
await writeFile(learningsPath, learningsOriginal);
|
|
352
|
+
|
|
353
|
+
const missingPlugin = await assertPureCollection(health, {
|
|
354
|
+
...options,
|
|
355
|
+
readInstalledPlugins: async () => [],
|
|
356
|
+
});
|
|
357
|
+
equal(
|
|
358
|
+
missingPlugin.findings.find(finding => finding.check === "plugins.current")
|
|
359
|
+
.status,
|
|
360
|
+
"fail",
|
|
361
|
+
"a current marker cannot hide a missing actual plugin installation"
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const disabledRuleset = await assertPureCollection(health, {
|
|
365
|
+
...options,
|
|
366
|
+
readRulesets: async () => [
|
|
367
|
+
{ ...expectedRulesets[0], enforcement: "disabled" },
|
|
368
|
+
...expectedRulesets.slice(1),
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
equal(
|
|
372
|
+
disabledRuleset.findings.find(
|
|
373
|
+
finding => finding.check === "github.rulesets"
|
|
374
|
+
).status,
|
|
375
|
+
"fail",
|
|
376
|
+
"a present but disabled ruleset is material drift"
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const prePushPath = path.join(project, ".husky", "pre-push");
|
|
380
|
+
await chmod(prePushPath, 0o644);
|
|
381
|
+
const nonExecutableHook = await assertPureCollection(health, options);
|
|
382
|
+
equal(
|
|
383
|
+
nonExecutableHook.findings.find(
|
|
384
|
+
finding => finding.check === "hooks.managed"
|
|
385
|
+
).status,
|
|
386
|
+
"fail",
|
|
387
|
+
"a non-executable managed hook is drift"
|
|
388
|
+
);
|
|
389
|
+
await chmod(prePushPath, 0o755);
|
|
390
|
+
|
|
391
|
+
const eslintPath = path.join(project, "eslint.config.ts");
|
|
392
|
+
const eslintOriginal = await readFile(eslintPath);
|
|
393
|
+
await writeFile(
|
|
394
|
+
eslintPath,
|
|
395
|
+
Buffer.concat([eslintOriginal, Buffer.from("\n// health drift\n")])
|
|
396
|
+
);
|
|
397
|
+
const drift = await assertPureCollection(health, options);
|
|
398
|
+
const managedDrift = drift.findings.filter(
|
|
399
|
+
finding =>
|
|
400
|
+
finding.check === "templates.managed" && finding.status === "fail"
|
|
401
|
+
);
|
|
402
|
+
equal(
|
|
403
|
+
managedDrift.length,
|
|
404
|
+
1,
|
|
405
|
+
"managed drift yields exactly one managed-file failure"
|
|
406
|
+
);
|
|
407
|
+
check(
|
|
408
|
+
managedDrift[0].reason.includes("eslint.config.ts"),
|
|
409
|
+
"managed failure names the relative file"
|
|
410
|
+
);
|
|
411
|
+
equal(
|
|
412
|
+
await readFile(eslintPath),
|
|
413
|
+
Buffer.concat([eslintOriginal, Buffer.from("\n// health drift\n")]),
|
|
414
|
+
"drifted bytes remain unchanged"
|
|
415
|
+
);
|
|
416
|
+
console.log("[EVIDENCE: managed-file-drift-fail]");
|
|
417
|
+
|
|
418
|
+
await writeFile(eslintPath, eslintOriginal);
|
|
419
|
+
const restored = await assertPureCollection(health, options);
|
|
420
|
+
equal(restored.summary.counts.fail, 0, "restored fixture has zero failures");
|
|
421
|
+
equal(restored.summary.verdict, "in band", "restored fixture is in band");
|
|
422
|
+
console.log("[EVIDENCE: clean-project-in-band]");
|
|
423
|
+
console.log("[EVIDENCE: managed-file-restoration-in-band]");
|
|
424
|
+
|
|
425
|
+
const configPath = path.join(project, ".lisa.config.json");
|
|
426
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
427
|
+
delete config.github.repo;
|
|
428
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
429
|
+
const missing = await assertPureCollection(health, options);
|
|
430
|
+
const missingConfig = missing.findings.find(
|
|
431
|
+
finding => finding.check === "config.required"
|
|
432
|
+
);
|
|
433
|
+
equal(missingConfig.status, "fail", "missing required config is a failure");
|
|
434
|
+
check(
|
|
435
|
+
missingConfig.reason.includes("github.repo"),
|
|
436
|
+
"missing finding names the required key"
|
|
437
|
+
);
|
|
438
|
+
check(
|
|
439
|
+
!missingConfig.reason.includes("health-fixture"),
|
|
440
|
+
"missing finding never exposes the removed value"
|
|
441
|
+
);
|
|
442
|
+
console.log("[EVIDENCE: missing-config-key-named]");
|
|
443
|
+
|
|
444
|
+
for (const result of [clean, drift, restored, missing]) {
|
|
445
|
+
equal(
|
|
446
|
+
result.mode,
|
|
447
|
+
"deterministic",
|
|
448
|
+
"all proof runs use deterministic mode"
|
|
449
|
+
);
|
|
450
|
+
check(
|
|
451
|
+
result.findings.every(finding => finding.layer === "deterministic"),
|
|
452
|
+
"all proof findings are deterministic"
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
console.log("[EVIDENCE: deterministic-only-findings]");
|
|
456
|
+
|
|
457
|
+
if (process.platform !== "win32") {
|
|
458
|
+
const unsafeProject = path.join(workspace, "unsafe-host");
|
|
459
|
+
await mkdir(unsafeProject, { recursive: true });
|
|
460
|
+
await writeFile(
|
|
461
|
+
path.join(unsafeProject, ".lisa.config.json"),
|
|
462
|
+
'{"tracker":"github","harness":"codex"}\n'
|
|
463
|
+
);
|
|
464
|
+
execFileSync("mkfifo", [path.join(unsafeProject, "package.json")]);
|
|
465
|
+
const unsafeStarted = Date.now();
|
|
466
|
+
const unsafe = await health.runDeterministicHealth(unsafeProject, {
|
|
467
|
+
lisaRoot: root,
|
|
468
|
+
deadlineMs: 25,
|
|
469
|
+
readRulesets: async () => new Promise(() => undefined),
|
|
470
|
+
readHooksPath: async () => new Promise(() => undefined),
|
|
471
|
+
readInstalledPlugins: async () => new Promise(() => undefined),
|
|
472
|
+
});
|
|
473
|
+
check(
|
|
474
|
+
Date.now() - unsafeStarted < 1_000,
|
|
475
|
+
"FIFO and never-settling injected readers stay under the public deadline"
|
|
476
|
+
);
|
|
477
|
+
equal(
|
|
478
|
+
unsafe.findings.length,
|
|
479
|
+
12,
|
|
480
|
+
"deadline fallback preserves the fixed finding cardinality"
|
|
481
|
+
);
|
|
482
|
+
equal(
|
|
483
|
+
unsafe.findings.find(finding => finding.check === "project.state").status,
|
|
484
|
+
"fail",
|
|
485
|
+
"unsafe FIFO state is surfaced without opening it"
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const elapsedMs = Date.now() - startedAt;
|
|
490
|
+
check(elapsedMs < 120_000, "built proof completes under two minutes");
|
|
491
|
+
console.log("[EVIDENCE: under-two-minute-budget]");
|
|
492
|
+
console.log("[EVIDENCE: no-surprise-side-effects]");
|
|
493
|
+
check(assertions > 0, "proof must execute assertions");
|
|
494
|
+
console.log(
|
|
495
|
+
`health-deterministic assertions=${assertions} elapsedMs=${elapsedMs}`
|
|
496
|
+
);
|
|
497
|
+
} finally {
|
|
498
|
+
await rm(workspace, { recursive: true, force: true });
|
|
499
|
+
}
|