testguard-cli 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +152 -13
- package/package.json +1 -1
- package/spec/GATE-SEMANTICS.md +295 -0
- package/spec/README.md +27 -2
- package/spec/lib/authoring-input.mjs +40 -0
- package/spec/lib/origins.mjs +56 -0
- package/spec/lib/validate.mjs +120 -1
- package/spec/schemas/annotations.schema.json +65 -0
- package/spec/schemas/authoring-input.schema.json +67 -0
- package/spec/schemas/brief.schema.json +2 -0
- package/spec/schemas/claims.schema.json +2 -8
- package/spec/schemas/common.schema.json +73 -0
- package/spec/schemas/evidence.schema.json +37 -8
- package/spec/schemas/status.schema.json +6 -2
- package/src/brief/brief.mjs +11 -4
- package/src/claims/annotation-placement.mjs +357 -0
- package/src/claims/annotations.mjs +61 -2
- package/src/cli.mjs +40 -5
- package/src/commands/admit.mjs +13 -0
- package/src/commands/annotations.mjs +67 -0
- package/src/commands/claims.mjs +8 -4
- package/src/commands/probe.mjs +50 -7
- package/src/commands/replay.mjs +7 -0
- package/src/commands/scaffold.mjs +49 -2
- package/src/commands/sweep.mjs +7 -0
- package/src/evidence/writer.mjs +12 -4
- package/src/git.mjs +4 -4
- package/src/init/templates/SKILL.md +35 -0
- package/src/probe/attribution.mjs +10 -0
- package/src/probe/contention.mjs +20 -2
- package/src/probe/cost.mjs +6 -1
- package/src/probe/inject.mjs +2 -6
- package/src/probe/origin-policy.mjs +69 -0
- package/src/probe/policy-freshness.mjs +74 -0
- package/src/probe/probe.mjs +48 -23
- package/src/probe/runners/discovery.mjs +2 -0
- package/src/probe/runners/jest.mjs +2 -2
- package/src/probe/runners/lifecycle.mjs +58 -0
- package/src/probe/runners/playwright.mjs +6 -2
- package/src/probe/runners/python.mjs +10 -4
- package/src/probe/runners/shared.mjs +12 -6
- package/src/probe/runners/vitest.mjs +3 -3
- package/src/probe/universe.mjs +47 -0
- package/src/probe/worktree.mjs +3 -1
- package/src/render.mjs +17 -1
- package/src/replay/replay.mjs +8 -4
- package/src/scaffold/admission.mjs +117 -0
- package/src/scaffold/append.mjs +41 -0
- package/src/scaffold/apply-append.mjs +71 -0
- package/src/scaffold/fix-input.mjs +119 -0
- package/src/scaffold/fix-paths.mjs +90 -0
- package/src/scaffold/intent.mjs +2 -0
- package/src/scaffold/open-append.mjs +44 -0
- package/src/scaffold/plan-append.mjs +37 -0
- package/src/scaffold/plan-intent-input.mjs +38 -0
- package/src/scaffold/prepare-append.mjs +46 -0
- package/src/scaffold/request.mjs +30 -0
- package/src/scaffold/scaffold.mjs +23 -10
- package/src/status/status.mjs +34 -9
- package/src/sweep/pool.mjs +33 -0
- package/src/sweep/sweep.mjs +9 -4
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
"counts"
|
|
14
14
|
],
|
|
15
15
|
"properties": {
|
|
16
|
+
"originPolicy": { "$ref": "urn:claimspec:v1:common#/$defs/originPolicy" },
|
|
17
|
+
"origins": { "$ref": "urn:claimspec:v1:common#/$defs/originSummary" },
|
|
16
18
|
"$schema": {
|
|
17
19
|
"type": "string"
|
|
18
20
|
},
|
|
@@ -157,12 +159,13 @@
|
|
|
157
159
|
"type": "object",
|
|
158
160
|
"required": ["id", "evidence", "provisional", "records", "newSinceBaseline", "exitCode"],
|
|
159
161
|
"properties": {
|
|
162
|
+
"originPolicy": { "$ref": "urn:claimspec:v1:common#/$defs/originPolicy" },
|
|
160
163
|
"id": { "type": "string", "minLength": 1, "maxLength": 128 },
|
|
161
164
|
"evidence": { "type": "string", "minLength": 1, "maxLength": 4096 },
|
|
162
165
|
"provisional": { "type": "boolean" },
|
|
163
166
|
"records": { "type": "integer", "minimum": 0 },
|
|
164
167
|
"newSinceBaseline": { "type": "integer", "minimum": 0 },
|
|
165
|
-
"exitCode": { "enum": [0, 1] },
|
|
168
|
+
"exitCode": { "enum": [0, 1, 2] },
|
|
166
169
|
"scope": {
|
|
167
170
|
"type": "object",
|
|
168
171
|
"required": ["requestedClaims", "requestedCount", "probedClaims", "probedCount"],
|
|
@@ -191,6 +194,7 @@
|
|
|
191
194
|
"type": "object",
|
|
192
195
|
"required": ["totalMs", "totalRuns", "reusedRecords", "records", "claims", "defenders", "sharedDefenders"],
|
|
193
196
|
"properties": {
|
|
197
|
+
"measurements": {"type": "object", "required": ["elapsedMs", "runnerMs", "overheadMs", "runnerInvocations"], "properties": {"elapsedMs": {"type": "number", "minimum": 0}, "runnerMs": {"type": "number", "minimum": 0}, "overheadMs": {"type": "number", "minimum": 0}, "runnerInvocations": {"type": "integer", "minimum": 0}}, "additionalProperties": false},
|
|
194
198
|
"totalMs": { "type": "number", "minimum": 0 },
|
|
195
199
|
"totalRuns": { "type": "integer", "minimum": 0 },
|
|
196
200
|
"reusedRecords": { "type": "integer", "minimum": 0 },
|
|
@@ -301,7 +305,7 @@
|
|
|
301
305
|
}
|
|
302
306
|
},
|
|
303
307
|
"notes": {
|
|
304
|
-
"description": "Informational lines that never change
|
|
308
|
+
"description": "Informational lines that never change state or next verification action: e.g. annotation-link advice (not verification or authenticated provenance), or a baseline snapshot with the way to re-stamp it.",
|
|
305
309
|
"type": "array",
|
|
306
310
|
"items": { "type": "string", "minLength": 1, "maxLength": 512 }
|
|
307
311
|
},
|
package/src/brief/brief.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { gate } from '../baseline/baseline.mjs';
|
|
2
|
-
import { coAuthorshipWarning, summarize, formatVerdict } from '../render.mjs';
|
|
2
|
+
import { coAuthorshipWarning, summarize, formatVerdict, renderOrigins, renderOriginPolicy } from '../render.mjs';
|
|
3
|
+
import { recordedOriginSummary } from '../../spec/lib/origins.mjs';
|
|
3
4
|
|
|
4
5
|
export const HEADING = '## TEST BLINDSPOT CONTEXT';
|
|
5
6
|
/** First line of every markdown brief, so a poster can find and update its own note instead of adding another. */
|
|
@@ -72,10 +73,12 @@ export function buildBrief(evidence, baseline, { max = 20, generatedAt = new Dat
|
|
|
72
73
|
new: g.new.length + g.belowFloor.length,
|
|
73
74
|
baselined: g.baselined.length,
|
|
74
75
|
};
|
|
76
|
+
const origins = recordedOriginSummary(evidence.records);
|
|
75
77
|
const kills = evidence.records.filter((r) => r.verdict === 'killed');
|
|
76
78
|
const coAuthored = kills.filter((r) => r.detail.independence?.class === 'co-authored').length;
|
|
77
79
|
const unclaimed = changes?.uncovered?.length ? { ref: changes.ref, files: changes.uncovered } : undefined;
|
|
78
|
-
const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
|
|
80
|
+
const doc = { schemaVersion: 1, tool: evidence.tool, generatedAt, head: evidence.run.repo.head, heading: HEADING, summary, origins, ...(next ? { next: { action: next.action, command: next.command, why: next.why } } : {}), ...(unclaimed ? { unclaimed } : {}), items, text: '' };
|
|
81
|
+
if (evidence.originPolicy) doc.originPolicy = structuredClone(evidence.originPolicy);
|
|
79
82
|
doc.text = renderBriefText({ ...doc, provisional: Boolean(evidence.run.provisional), install }, { hasBaseline: Boolean(baseline), total: evidence.records.length, independence: kills.length ? { coAuthored, kills: kills.length } : undefined });
|
|
80
83
|
return doc;
|
|
81
84
|
}
|
|
@@ -89,6 +92,8 @@ export function renderBriefText(brief, { hasBaseline, total, independence }) {
|
|
|
89
92
|
`testguard ${brief.tool.version}${brief.install ? ` (${brief.install})` : ''}${brief.head ? ` @ ${brief.head.slice(0, 12)}` : ''} — ${brief.summary.claims} claims, ${total} faults probed, ${unproven} unproven` +
|
|
90
93
|
(unproven === 0 ? '.' : hasBaseline ? ` (${brief.summary.new} new since baseline).` : ' (no baseline; everything is new).'),
|
|
91
94
|
];
|
|
95
|
+
if (brief.origins) lines.push(renderOrigins(brief.origins));
|
|
96
|
+
if (brief.originPolicy) lines.push(renderOriginPolicy(brief.originPolicy));
|
|
92
97
|
// Unclaimed changes come before everything else: the claim is written
|
|
93
98
|
// before more code, and TestGuard is silent about unclaimed code otherwise.
|
|
94
99
|
if (brief.unclaimed) {
|
|
@@ -101,7 +106,7 @@ export function renderBriefText(brief, { hasBaseline, total, independence }) {
|
|
|
101
106
|
if (independence?.coAuthored) lines.push('', coAuthorshipWarning(independence.coAuthored, independence.kills));
|
|
102
107
|
if (brief.next) lines.push('', `NEXT [${brief.next.action}]: ${brief.next.command}`, ` why: ${brief.next.why}`);
|
|
103
108
|
if (brief.items.length === 0) {
|
|
104
|
-
lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
|
|
109
|
+
lines.push('', unproven === 0 ? 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.' : `${unproven} unproven faults remain; no items shown at the current cap. See the full evidence file.`);
|
|
105
110
|
return lines.join('\n') + '\n';
|
|
106
111
|
}
|
|
107
112
|
lines.push(
|
|
@@ -149,6 +154,8 @@ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {})
|
|
|
149
154
|
if (brief.provisional) lines.push('**PROVISIONAL** — fewer than three confirmation runs; nothing below is confirmed. Re-probe with `--confirm 3`.', '');
|
|
150
155
|
lines.push(`\`testguard ${brief.tool.version}${install ? ` (${install})` : ''}\`${brief.head ? ` @ \`${brief.head.slice(0, 12)}\`` : ''} — **${brief.summary.claims}** claims, **${total}** faults probed, **${unproven}** unproven` +
|
|
151
156
|
(unproven === 0 ? '.' : hasBaseline ? ` (**${brief.summary.new}** new since baseline).` : ' (no baseline; everything is new).'));
|
|
157
|
+
if (brief.origins) lines.push('', renderOrigins(brief.origins));
|
|
158
|
+
if (brief.originPolicy) lines.push('', renderOriginPolicy(brief.originPolicy));
|
|
152
159
|
if (brief.unclaimed) {
|
|
153
160
|
const n = brief.unclaimed.files.length;
|
|
154
161
|
lines.push('', `### Unclaimed changes since \`${brief.unclaimed.ref}\``, '', `${n} changed file${n === 1 ? '' : 's'} carr${n === 1 ? 'ies' : 'y'} no claim. State the claim first; nothing below can see this code.`, '');
|
|
@@ -156,7 +163,7 @@ export function renderBriefMarkdown(brief, { hasBaseline, total, install } = {})
|
|
|
156
163
|
}
|
|
157
164
|
if (brief.next) lines.push('', `**Next** \`[${brief.next.action}]\`: \`${brief.next.command}\``, '', `> ${brief.next.why}`);
|
|
158
165
|
if (brief.items.length === 0) {
|
|
159
|
-
lines.push('', 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.');
|
|
166
|
+
lines.push('', unproven === 0 ? 'Every probed claim is defended. Keep it that way: new claims need a fault and a test that fails on it.' : `${unproven} unproven faults remain; no items shown at the current cap. See the full evidence file.`);
|
|
160
167
|
return lines.join('\n') + '\n';
|
|
161
168
|
}
|
|
162
169
|
lines.push('', 'Where the test suite is blind, ranked. A **SURVIVED** fault means its defenders stayed green while the claim was false. Do not close these by asserting current behaviour; write a test that fails on the described fault and passes on HEAD.', '');
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { constants, chmodSync, closeSync, fsyncSync, fstatSync, ftruncateSync, lstatSync, mkdtempSync, openSync, readSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { validate } from '../../spec/lib/validate.mjs';
|
|
7
|
+
import { locate } from '../probe/inject.mjs';
|
|
8
|
+
import { sha256 } from '../util/hash.mjs';
|
|
9
|
+
|
|
10
|
+
const SUPPORTED = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py']);
|
|
11
|
+
const SKIP = new Set(['node_modules', 'dist', 'coverage', 'test', 'tests', '__tests__']);
|
|
12
|
+
const CLAIM_ID = /^[A-Za-z0-9]+(?:[._][A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9]$/;
|
|
13
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
const MAX_PLAN_BYTES = 16 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
function canonicalRoot(projectDir) {
|
|
17
|
+
const requested = resolve(projectDir);
|
|
18
|
+
try {
|
|
19
|
+
// Keep a directly symlinked root for admission to refuse; parent aliases
|
|
20
|
+
// are resolved once so plans bind the actual explicitly selected root.
|
|
21
|
+
if (lstatSync(requested).isSymbolicLink()) return requested;
|
|
22
|
+
return realpathSync(requested);
|
|
23
|
+
} catch { return requested; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function containedRelative(file) {
|
|
27
|
+
return file !== '..' && !file.startsWith(`..${sep}`) && !isAbsolute(file);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function selectedClaimsPath(root, projectDir, claimsPath) {
|
|
31
|
+
const fromSelected = relative(resolve(projectDir), resolve(claimsPath));
|
|
32
|
+
return containedRelative(fromSelected) ? resolve(root, fromSelected) : resolve(claimsPath);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function recoveryBase(root) {
|
|
36
|
+
const base = realpathSync(tmpdir());
|
|
37
|
+
if (containedRelative(relative(root, base))) throw new Error('recovery directory must be outside the project');
|
|
38
|
+
return base;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Admission precedes reads: do not follow even an ancestor symlink. */
|
|
42
|
+
function admittedPath(root, file, { source = true } = {}) {
|
|
43
|
+
const parts = file.split('/');
|
|
44
|
+
if (file.includes('\\') || parts.some((p) => !p || p === '.' || p === '..') || resolve(root, file) !== join(root, ...parts)) return { reason: 'unsafe-path' };
|
|
45
|
+
if (source && (parts.some((p) => p.startsWith('.') || SKIP.has(p)) || /\.(test|spec)\.[cm]?[jt]sx?$/.test(file))) return { reason: 'excluded-target' };
|
|
46
|
+
if (source && !SUPPORTED.has(extname(file))) return { reason: 'unsupported-source' };
|
|
47
|
+
let path = root;
|
|
48
|
+
try {
|
|
49
|
+
const base = lstatSync(root);
|
|
50
|
+
if (base.isSymbolicLink() || !base.isDirectory()) return { reason: 'symlink-target' };
|
|
51
|
+
for (let i = 0; i < parts.length; i++) {
|
|
52
|
+
path = join(path, parts[i]);
|
|
53
|
+
const stat = lstatSync(path);
|
|
54
|
+
if (stat.isSymbolicLink()) return { reason: 'symlink-target' };
|
|
55
|
+
if (i < parts.length - 1) {
|
|
56
|
+
if (!stat.isDirectory()) return { reason: 'not-regular-file' };
|
|
57
|
+
try { lstatSync(join(path, 'testguard.claims.json')); return { reason: 'nested-project' }; }
|
|
58
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
59
|
+
} else {
|
|
60
|
+
if (!stat.isFile() || stat.nlink !== 1) return { reason: 'not-regular-file' };
|
|
61
|
+
if (stat.size > MAX_BYTES) return { reason: 'source-too-large' };
|
|
62
|
+
return { path, stat };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code === 'ENOENT') return { reason: 'target-missing' };
|
|
67
|
+
return { reason: 'target-unreadable' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readSource(path, expected) {
|
|
72
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
73
|
+
try { return readDescriptor(fd, expected); }
|
|
74
|
+
finally { closeSync(fd); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readDescriptor(fd, expected) {
|
|
78
|
+
const stat = fstatSync(fd);
|
|
79
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.dev !== expected.dev || stat.ino !== expected.ino || stat.size !== expected.size || stat.mtimeMs !== expected.mtimeMs) return { reason: 'target-changed' };
|
|
80
|
+
const buffer = Buffer.alloc(MAX_BYTES + 1);
|
|
81
|
+
let length = 0;
|
|
82
|
+
while (length < buffer.length) {
|
|
83
|
+
const count = readSync(fd, buffer, length, buffer.length - length, length);
|
|
84
|
+
if (!count) break;
|
|
85
|
+
length += count;
|
|
86
|
+
}
|
|
87
|
+
const bytes = buffer.subarray(0, length);
|
|
88
|
+
const after = fstatSync(fd);
|
|
89
|
+
if (bytes.length > MAX_BYTES || bytes.length !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs) return { reason: 'target-changed' };
|
|
90
|
+
let source;
|
|
91
|
+
try { source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
92
|
+
catch { return { reason: 'invalid-encoding' }; }
|
|
93
|
+
if (source.includes('\0')) return { reason: 'invalid-encoding' };
|
|
94
|
+
const bare = source.replaceAll('\r\n', '');
|
|
95
|
+
if (bare.includes('\r') || (source.includes('\r\n') && bare.includes('\n'))) return { reason: 'mixed-newlines' };
|
|
96
|
+
return { source, sourceHash: sha256(bytes), newline: source.includes('\r\n') ? '\r\n' : '\n', identity: { dev: stat.dev, ino: stat.ino, mode: stat.mode } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Only leading line-comment headers are witnesses, never quoted code text. */
|
|
100
|
+
function hasAnnotation(source, id, python) {
|
|
101
|
+
for (const line of source.split(/\r?\n/)) {
|
|
102
|
+
const text = line.replace(/^\uFEFF/, '').trimStart();
|
|
103
|
+
if (!text || text.startsWith('#!')) continue;
|
|
104
|
+
if (!text.startsWith(python ? '#' : '//')) break;
|
|
105
|
+
if ([...text.matchAll(/@claim\s+([^\s]+)/g)].some((match) => match[1] === id)) return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function header(source, ids, python, newline) {
|
|
111
|
+
let offset = source.startsWith('\uFEFF') ? 1 : 0;
|
|
112
|
+
const lines = source.slice(offset).split(newline);
|
|
113
|
+
let protectedLines = lines[0].startsWith('#!') ? 1 : 0;
|
|
114
|
+
if (python) {
|
|
115
|
+
for (let i = 0; i < Math.min(2, lines.length); i++) {
|
|
116
|
+
if (/^\s*#.*coding\s*[:=]\s*[-\w.]+/.test(lines[i])) protectedLines = i + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (let i = 0; i < protectedLines; i++) offset += lines[i].length + (i < lines.length - 1 ? newline.length : 0);
|
|
120
|
+
const prefix = source.slice(0, offset);
|
|
121
|
+
const separator = protectedLines && !prefix.endsWith(newline) ? newline : '';
|
|
122
|
+
return prefix + separator + ids.map((id) => `${python ? '#' : '//'} @claim ${id}${newline}`).join('') + source.slice(offset);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Internal read-only plan. Not an evidence document or a successful apply. */
|
|
126
|
+
export function planAnnotationPlacement({ projectDir, claims, claimIds = [] }) {
|
|
127
|
+
if (!validate('claims', claims).ok) throw new Error('claims must conform before annotation placement');
|
|
128
|
+
if (!Array.isArray(claimIds) || claimIds.some((id) => typeof id !== 'string')) throw new Error('claim selection must be an array of IDs');
|
|
129
|
+
const selected = new Set(claimIds);
|
|
130
|
+
for (const id of selected) if (!claims.claims.some((c) => c.id === id)) throw new Error(`unknown claim ${id}`);
|
|
131
|
+
const root = canonicalRoot(projectDir);
|
|
132
|
+
const targets = new Map();
|
|
133
|
+
const selectedClaims = claims.claims.filter((c) => !selected.size || selected.has(c.id));
|
|
134
|
+
for (const claim of selectedClaims) {
|
|
135
|
+
for (const fault of claim.faults) {
|
|
136
|
+
const file = fault.file ?? `(claim ${claim.id} without a target)`;
|
|
137
|
+
if (!targets.has(file)) targets.set(file, []);
|
|
138
|
+
targets.get(file).push({ claim, fault });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const files = [], refused = [];
|
|
142
|
+
let admittedBytes = 0;
|
|
143
|
+
for (const [file, entries] of [...targets].sort(([a], [b]) => a.localeCompare(b))) {
|
|
144
|
+
const ids = [...new Set(entries.map(({ claim }) => claim.id))].sort();
|
|
145
|
+
const refuse = (reason) => refused.push({ file, claimIds: ids, reason });
|
|
146
|
+
if (ids.some((id) => !CLAIM_ID.test(id))) { refuse('unsupported-claim-id'); continue; }
|
|
147
|
+
if (entries.some(({ fault }) => (fault.method ?? 'fault-injection') !== 'fault-injection')) { refuse('unsupported-method'); continue; }
|
|
148
|
+
const target = admittedPath(root, file);
|
|
149
|
+
if (target.reason) { refuse(target.reason); continue; }
|
|
150
|
+
if (admittedBytes + target.stat.size > MAX_PLAN_BYTES) { refuse('plan-source-budget'); continue; }
|
|
151
|
+
admittedBytes += target.stat.size;
|
|
152
|
+
let input;
|
|
153
|
+
try { input = readSource(target.path, target.stat); }
|
|
154
|
+
catch { refuse('target-unreadable'); continue; }
|
|
155
|
+
if (input.reason) { refuse(input.reason); continue; }
|
|
156
|
+
const invalid = entries.map(({ fault }) => locate(input.source, fault)).find((anchor) => anchor.status !== 'ok');
|
|
157
|
+
if (invalid) { refuse(invalid.status); continue; }
|
|
158
|
+
const python = extname(file) === '.py';
|
|
159
|
+
const missing = ids.filter((id) => !hasAnnotation(input.source, id, python));
|
|
160
|
+
const proposed = missing.length ? header(input.source, missing, python, input.newline) : input.source;
|
|
161
|
+
const proposedBytes = Buffer.byteLength(proposed, 'utf8');
|
|
162
|
+
if (proposedBytes > MAX_BYTES) { refuse('proposed-source-too-large'); continue; }
|
|
163
|
+
const growth = Math.max(0, proposedBytes - target.stat.size);
|
|
164
|
+
if (admittedBytes + growth > MAX_PLAN_BYTES) { refuse('plan-source-budget'); continue; }
|
|
165
|
+
admittedBytes += growth;
|
|
166
|
+
files.push({ file, claimIds: ids, sourceHash: input.sourceHash, identity: input.identity, source: input.source, proposed, changed: proposed !== input.source });
|
|
167
|
+
}
|
|
168
|
+
return { projectDir: root, selectedClaimIds: selectedClaims.map((c) => c.id).sort(), claimsHash: sha256(JSON.stringify(claims)), files, refused, applicable: refused.length === 0 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Recompute before authoring. Never trust a saved proposal or hash alone. */
|
|
172
|
+
export function revalidateAnnotationPlan({ projectDir, claims, plan }) {
|
|
173
|
+
if (!plan || plan.projectDir !== canonicalRoot(projectDir) || !Array.isArray(plan.selectedClaimIds)) return { current: false, reason: 'plan-invalid' };
|
|
174
|
+
if (!validate('claims', claims).ok || plan.claimsHash !== sha256(JSON.stringify(claims))) return { current: false, reason: 'claims-changed' };
|
|
175
|
+
let fresh;
|
|
176
|
+
try { fresh = planAnnotationPlacement({ projectDir, claims, claimIds: plan.selectedClaimIds }); }
|
|
177
|
+
catch { return { current: false, reason: 'plan-invalid' }; }
|
|
178
|
+
if (!fresh.applicable) return { current: false, reason: 'plan-refused' };
|
|
179
|
+
if (!isDeepStrictEqual(plan, fresh)) return { current: false, reason: 'plan-changed' };
|
|
180
|
+
return { current: true, plan: fresh };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Bounded preview input; an external claims file never authorizes an apply. */
|
|
184
|
+
export function loadAnnotationClaims(claimsPath) {
|
|
185
|
+
const selectedRoot = dirname(resolve(claimsPath));
|
|
186
|
+
const root = canonicalRoot(selectedRoot);
|
|
187
|
+
return currentClaims(root, selectedClaimsPath(root, selectedRoot, claimsPath));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function currentClaims(root, claimsPath) {
|
|
191
|
+
const file = relative(root, resolve(claimsPath)).split(sep).join('/');
|
|
192
|
+
const target = admittedPath(root, file, { source: false });
|
|
193
|
+
if (target.reason) throw new Error(`claims path refused: ${target.reason}`);
|
|
194
|
+
const input = readSource(target.path, target.stat);
|
|
195
|
+
if (input.reason) throw new Error(`claims read refused: ${input.reason}`);
|
|
196
|
+
try { return JSON.parse(input.source); }
|
|
197
|
+
catch { throw new Error('claims file contains invalid JSON'); }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Exclusive creation and byte ownership: never reclaim a possibly live lock. */
|
|
201
|
+
export function authoringLock(root) {
|
|
202
|
+
const path = join(root, '.testguard-annotations.lock');
|
|
203
|
+
let fd;
|
|
204
|
+
try { fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600); }
|
|
205
|
+
catch (error) { if (error.code === 'EEXIST') throw new Error('annotation authoring lock already exists'); throw error; }
|
|
206
|
+
const identity = fstatSync(fd);
|
|
207
|
+
const content = Buffer.from(JSON.stringify({ pid: process.pid, token: randomUUID() }));
|
|
208
|
+
let result;
|
|
209
|
+
const assertOwned = () => {
|
|
210
|
+
if (result) throw new Error('annotation authoring lock released');
|
|
211
|
+
const current = lstatSync(path);
|
|
212
|
+
const bytes = Buffer.alloc(content.length + 1);
|
|
213
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
214
|
+
if (current.isSymbolicLink() || current.nlink !== 1 || current.dev !== identity.dev || current.ino !== identity.ino || count !== content.length || !bytes.subarray(0, count).equals(content)) throw new Error('annotation authoring lock changed');
|
|
215
|
+
};
|
|
216
|
+
const release = () => {
|
|
217
|
+
if (result) return result;
|
|
218
|
+
try {
|
|
219
|
+
const current = lstatSync(path);
|
|
220
|
+
const bytes = Buffer.alloc(content.length + 1);
|
|
221
|
+
const count = readSync(fd, bytes, 0, bytes.length, 0);
|
|
222
|
+
if (current.isSymbolicLink() || current.nlink !== 1 || current.dev !== identity.dev || current.ino !== identity.ino || count !== content.length || !bytes.subarray(0, count).equals(content)) {
|
|
223
|
+
result = { released: false, reason: 'lock-changed' };
|
|
224
|
+
} else { unlinkSync(path); result = { released: true }; }
|
|
225
|
+
} catch (error) { result = { released: false, reason: error.code === 'ENOENT' ? 'lock-changed' : 'lock-release-failed' }; }
|
|
226
|
+
finally { closeSync(fd); }
|
|
227
|
+
return result;
|
|
228
|
+
};
|
|
229
|
+
try { writeFileSync(fd, content); }
|
|
230
|
+
catch (error) {
|
|
231
|
+
// No other actor may use this exclusively-created file yet. Still verify
|
|
232
|
+
// inode ownership rather than unlinking an arbitrary replacement.
|
|
233
|
+
try {
|
|
234
|
+
const current = lstatSync(path);
|
|
235
|
+
if (!current.isSymbolicLink() && current.dev === identity.dev && current.ino === identity.ino) unlinkSync(path);
|
|
236
|
+
} finally { closeSync(fd); }
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return { release, assertOwned };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function persistRecovery(path, bytes) {
|
|
243
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
244
|
+
try { writeFileSync(fd, bytes); fsyncSync(fd); }
|
|
245
|
+
finally { closeSync(fd); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Only fresh internal plan containers enter this boundary, never caller objects. */
|
|
249
|
+
function freezePrepared(value) {
|
|
250
|
+
if (value && typeof value === 'object') {
|
|
251
|
+
for (const child of Object.values(value)) freezePrepared(child);
|
|
252
|
+
Object.freeze(value);
|
|
253
|
+
}
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Prepare recoverable authoring. This function never rewrites source files. */
|
|
258
|
+
export function prepareAnnotationApply({ projectDir, claimsPath, plan }) {
|
|
259
|
+
const root = canonicalRoot(projectDir);
|
|
260
|
+
const claimsFile = selectedClaimsPath(root, projectDir, claimsPath);
|
|
261
|
+
// Establish safe claims/root admission before creating even a lock.
|
|
262
|
+
currentClaims(root, claimsFile);
|
|
263
|
+
const lock = authoringLock(root);
|
|
264
|
+
let recoveryDir;
|
|
265
|
+
try {
|
|
266
|
+
const claims = currentClaims(root, claimsFile);
|
|
267
|
+
const checked = revalidateAnnotationPlan({ projectDir: root, claims, plan });
|
|
268
|
+
if (!checked.current) throw new Error(`annotation preview refused: ${checked.reason}`);
|
|
269
|
+
if (checked.plan.files.some((file) => resolve(root, file.file) === claimsFile)) throw new Error('claims file cannot be an annotation target');
|
|
270
|
+
recoveryDir = mkdtempSync(join(recoveryBase(root), 'testguard-annotation-recovery-'));
|
|
271
|
+
chmodSync(recoveryDir, 0o700);
|
|
272
|
+
const files = checked.plan.files.filter((file) => file.changed).map((file, index) => {
|
|
273
|
+
const original = `${index}.original`;
|
|
274
|
+
persistRecovery(join(recoveryDir, original), Buffer.from(file.source, 'utf8'));
|
|
275
|
+
return { file: file.file, original, sourceHash: file.sourceHash, proposedHash: sha256(Buffer.from(file.proposed, 'utf8')), identity: file.identity };
|
|
276
|
+
});
|
|
277
|
+
const manifest = { state: 'prepared', projectDir: root, claimsPath: claimsFile, claimsHash: checked.plan.claimsHash, files };
|
|
278
|
+
persistRecovery(join(recoveryDir, 'manifest.json'), Buffer.from(JSON.stringify(manifest)));
|
|
279
|
+
return freezePrepared({ ready: true, plan: checked.plan, claimsPath: claimsFile, recoveryDir, release: lock.release, assertOwned: lock.assertOwned });
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (recoveryDir) error.recoveryDir = recoveryDir;
|
|
282
|
+
error.lockRelease = lock.release();
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Explicit internal authoring: recoverable descriptor writes, never automatic rollback. */
|
|
288
|
+
export function applyAnnotationPlacement({ projectDir, claimsPath, plan }) {
|
|
289
|
+
const handle = prepareAnnotationApply({ projectDir, claimsPath, plan });
|
|
290
|
+
const root = handle.plan.projectDir;
|
|
291
|
+
const files = handle.plan.files.filter((file) => file.changed);
|
|
292
|
+
const report = { state: 'applied', changed: [], touched: [], unchanged: handle.plan.files.filter((file) => !file.changed).map((file) => file.file), failed: [], recoveryDir: handle.recoveryDir };
|
|
293
|
+
let activeFile = null, stage = 'all-target-preflight';
|
|
294
|
+
const checkClaims = () => {
|
|
295
|
+
handle.assertOwned();
|
|
296
|
+
const claims = currentClaims(root, handle.claimsPath);
|
|
297
|
+
if (!validate('claims', claims).ok || sha256(JSON.stringify(claims)) !== handle.plan.claimsHash) throw new Error('claims changed before source write');
|
|
298
|
+
return claims;
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
const checked = revalidateAnnotationPlan({ projectDir: root, claims: checkClaims(), plan: handle.plan });
|
|
302
|
+
if (!checked.current) throw new Error(`annotation write preflight refused: ${checked.reason}`);
|
|
303
|
+
if (files.length) {
|
|
304
|
+
stage = 'started-journal';
|
|
305
|
+
persistRecovery(join(handle.recoveryDir, 'started.json'), Buffer.from(JSON.stringify({ files: files.map((file) => file.file) })));
|
|
306
|
+
}
|
|
307
|
+
for (const [index, file] of files.entries()) {
|
|
308
|
+
activeFile = file.file; stage = 'target-preflight';
|
|
309
|
+
checkClaims();
|
|
310
|
+
const target = admittedPath(root, file.file);
|
|
311
|
+
if (target.reason) throw new Error(`target refused: ${target.reason}`);
|
|
312
|
+
const fd = openSync(target.path, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0));
|
|
313
|
+
try {
|
|
314
|
+
const input = readDescriptor(fd, target.stat);
|
|
315
|
+
if (input.reason || input.sourceHash !== file.sourceHash || !isDeepStrictEqual(input.identity, file.identity)) throw new Error('target changed before source write');
|
|
316
|
+
const current = admittedPath(root, file.file);
|
|
317
|
+
if (current.reason || current.stat.dev !== file.identity.dev || current.stat.ino !== file.identity.ino || current.stat.mode !== file.identity.mode) throw new Error('target path changed before source write');
|
|
318
|
+
handle.assertOwned();
|
|
319
|
+
const bytes = Buffer.from(file.proposed, 'utf8');
|
|
320
|
+
stage = 'source-write'; report.touched.push(file.file);
|
|
321
|
+
let offset = 0;
|
|
322
|
+
while (offset < bytes.length) {
|
|
323
|
+
const count = writeSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
324
|
+
if (!Number.isInteger(count) || count <= 0 || count > bytes.length - offset) throw new Error('source write made no valid progress');
|
|
325
|
+
offset += count;
|
|
326
|
+
}
|
|
327
|
+
ftruncateSync(fd, bytes.length);
|
|
328
|
+
fsyncSync(fd);
|
|
329
|
+
stage = 'write-verification';
|
|
330
|
+
const output = readDescriptor(fd, fstatSync(fd));
|
|
331
|
+
const after = admittedPath(root, file.file);
|
|
332
|
+
if (output.reason || output.sourceHash !== sha256(bytes) || !isDeepStrictEqual(output.identity, file.identity) || after.reason || after.stat.dev !== file.identity.dev || after.stat.ino !== file.identity.ino) throw new Error('source write verification failed');
|
|
333
|
+
report.changed.push(file.file);
|
|
334
|
+
} finally { closeSync(fd); }
|
|
335
|
+
stage = 'completion-journal';
|
|
336
|
+
persistRecovery(join(handle.recoveryDir, `${index}.applied.json`), Buffer.from(JSON.stringify({ file: file.file, proposedHash: sha256(Buffer.from(file.proposed, 'utf8')) })));
|
|
337
|
+
}
|
|
338
|
+
} catch (error) {
|
|
339
|
+
report.state = report.touched.length ? 'partial' : 'refused';
|
|
340
|
+
report.failed.push({ file: activeFile, stage, reason: error.message });
|
|
341
|
+
} finally {
|
|
342
|
+
report.lockRelease = handle.release();
|
|
343
|
+
if (!report.lockRelease.released && !report.failed.length) {
|
|
344
|
+
report.state = report.touched.length ? 'partial' : 'refused';
|
|
345
|
+
report.failed.push({ file: null, stage: 'lock-release', reason: report.lockRelease.reason });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
persistRecovery(join(handle.recoveryDir, 'result.pending.json'), Buffer.from(JSON.stringify(report)));
|
|
350
|
+
renameSync(join(handle.recoveryDir, 'result.pending.json'), join(handle.recoveryDir, 'result.json'));
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
report.state = report.touched.length ? 'partial' : 'refused';
|
|
354
|
+
report.failed.push({ file: null, stage: 'result-journal', reason: error.message });
|
|
355
|
+
}
|
|
356
|
+
return freezePrepared(report);
|
|
357
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, opendirSync, openSync, fstatSync, readSync, closeSync, constants } from 'node:fs';
|
|
2
2
|
import { join, extname } from 'node:path';
|
|
3
3
|
|
|
4
4
|
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx', '.py', '.go', '.rs', '.java', '.kt', '.md']);
|
|
@@ -6,7 +6,7 @@ const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);
|
|
|
6
6
|
const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)(tests?|__tests__)\//;
|
|
7
7
|
// An annotation id must contain a hyphen (REDACT-001, TG-KILL-NEEDS-N). Prose
|
|
8
8
|
// such as "@claim annotations" is not an annotation.
|
|
9
|
-
const RE = /@claim\s+([A-Za-z0-9]+(?:[._]
|
|
9
|
+
const RE = /@claim\s+([A-Za-z0-9]+(?:[._][A-Za-z0-9]+)*-[A-Za-z0-9._-]*[A-Za-z0-9])\b/g;
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Source files worth scanning: skips hidden directories (tooling), dependency
|
|
@@ -57,3 +57,62 @@ export function reconcile(claims, annotations) {
|
|
|
57
57
|
annotated: [...declared.keys()].filter((id) => annotated.has(id)),
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/** Lexical links aid discovery, never verification or provenance authentication. */
|
|
62
|
+
export function annotationAdvisory(claims, annotations, { customClaims = false } = {}) {
|
|
63
|
+
const linked = new Set(annotations.map((a) => a.id));
|
|
64
|
+
const missingIds = [...new Set(claims.claims.map((c) => c.id))].filter((id) => !linked.has(id)).sort();
|
|
65
|
+
return {
|
|
66
|
+
missingIds,
|
|
67
|
+
notes: missingIds.length ? [
|
|
68
|
+
`ANNOTATION ADVISORY: ${missingIds.length} declared claim ID${missingIds.length === 1 ? '' : 's'} lack a scanned source link. Discoverability and annotation reconciliation are reduced; exact fault anchors remain checked.`,
|
|
69
|
+
...missingIds.map((id) => `Missing source annotation: ${id}`),
|
|
70
|
+
'Optional read-only preview in the selected project: testguard claims --annotate. Placement is not verification or authenticated intent; review before explicitly applying.',
|
|
71
|
+
...(customClaims ? ['Preserve the original --claims argument when running that preview; the default claims file is not this selection.'] : []),
|
|
72
|
+
] : [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Advice cannot turn unrelated file failures or excessive reads into status failure. */
|
|
77
|
+
export function projectAnnotationAdvisory(projectDir, claims, options) {
|
|
78
|
+
const annotations = [];
|
|
79
|
+
let entries = 0; let files = 0; let bytes = 0;
|
|
80
|
+
const visit = (dir, rel, depth) => {
|
|
81
|
+
if (depth > 64) throw new Error('scan incomplete');
|
|
82
|
+
const directory = opendirSync(dir);
|
|
83
|
+
try {
|
|
84
|
+
let e;
|
|
85
|
+
while ((e = directory.readSync())) {
|
|
86
|
+
if (++entries > 10000) throw new Error('scan incomplete');
|
|
87
|
+
const file = rel ? `${rel}/${e.name}` : e.name;
|
|
88
|
+
if (e.isDirectory()) {
|
|
89
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name) || existsSync(join(dir, e.name, 'testguard.claims.json'))) continue;
|
|
90
|
+
visit(join(dir, e.name), file, depth + 1);
|
|
91
|
+
} else if (e.isFile() && SCAN_EXT.has(extname(e.name)) && !TEST_FILE.test(file)) {
|
|
92
|
+
if (++files > 1000) throw new Error('scan incomplete');
|
|
93
|
+
const fd = openSync(join(dir, e.name), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
94
|
+
try {
|
|
95
|
+
const stat = fstatSync(fd);
|
|
96
|
+
const limit = Math.min(256 * 1024, 2 * 1024 * 1024 - bytes);
|
|
97
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > limit) throw new Error('scan incomplete');
|
|
98
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
99
|
+
let read = 0; let n;
|
|
100
|
+
while ((n = readSync(fd, buffer, read, buffer.length - read, null))) {
|
|
101
|
+
read += n;
|
|
102
|
+
if (read > limit) throw new Error('scan incomplete');
|
|
103
|
+
}
|
|
104
|
+
bytes += read;
|
|
105
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, read));
|
|
106
|
+
text.split('\n').forEach((line, i) => {
|
|
107
|
+
for (const m of line.matchAll(RE)) annotations.push({ id: m[1], file, line: i + 1 });
|
|
108
|
+
});
|
|
109
|
+
} finally { closeSync(fd); }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} finally { directory.closeSync(); }
|
|
113
|
+
};
|
|
114
|
+
try { visit(projectDir, '', 0); return annotationAdvisory(claims, annotations, options); }
|
|
115
|
+
catch {
|
|
116
|
+
return { missingIds: [], notes: ['ANNOTATION ADVISORY unavailable: source scan was incomplete or exceeded its resource limits. No missing-ID conclusion was made; verification state and next action are unchanged.'] };
|
|
117
|
+
}
|
|
118
|
+
}
|