Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ jobs:
with:
node-version: 18
cache: npm
- name: Install jq on Linux
- name: Install jq for legacy validator on Linux
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y jq
- name: Install jq on macOS
- name: Install jq for legacy validator on macOS
if: runner.os == 'macOS'
run: brew install jq
- name: Install dependencies
Expand Down Expand Up @@ -67,7 +67,7 @@ jobs:
with:
node-version: 18
cache: npm
- name: Install jq
- name: Install jq for legacy validator
shell: pwsh
run: choco install jq -y --no-progress
- name: Install dependencies
Expand Down Expand Up @@ -108,13 +108,6 @@ jobs:
with:
node-version: 18
cache: npm
- name: Install jq on Linux
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y jq
- name: Install jq on Windows
if: runner.os == 'Windows'
shell: pwsh
run: choco install jq -y --no-progress
- name: Install dependencies
run: npm ci
- name: Run isolated performance budgets
Expand Down
33 changes: 0 additions & 33 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,6 @@ function parseMajor(version: string): number | null {
return match === null ? null : Number.parseInt(match[1] ?? '', 10);
}

function supportedJq(versionOutput: string): boolean {
const match = /^jq-([0-9]+)\.([0-9]+)/.exec(versionOutput.trim());
if (match === null) return false;
const major = Number.parseInt(match[1] ?? '', 10);
const minor = Number.parseInt(match[2] ?? '', 10);
return major > 1 || (major === 1 && minor >= 6);
}

async function requireProbe(
cwd: string,
environment: InitEnvironment,
Expand All @@ -185,38 +177,13 @@ export async function checkInitPrerequisites(
throw prerequisite('Node.js 18 or newer is required. Install it from https://nodejs.org/');
}

if (environment.platform === 'win32' && !environment.env.MSYSTEM) {
throw prerequisite(
'GraphKeeper v1 does not support native PowerShell. Run it through Git Bash or WSL. '
+ 'Install Git Bash from https://gitforwindows.org/',
);
}

await requireProbe(
cwd,
environment,
'git',
['--version'],
'Git is required. Install it from https://git-scm.com/downloads',
);
await requireProbe(
cwd,
environment,
'sh',
['-c', 'exit 0'],
'A POSIX-compatible shell is required. On Windows install Git Bash from '
+ 'https://gitforwindows.org/',
);
const jq = await requireProbe(
cwd,
environment,
'jq',
['--version'],
'jq 1.6 or newer is required. Install it from https://jqlang.org/download/',
);
if (!supportedJq(jq.stdout || jq.stderr)) {
throw prerequisite('jq 1.6 or newer is required. Install it from https://jqlang.org/download/');
}
}

async function pathExists(path: string): Promise<boolean> {
Expand Down
70 changes: 25 additions & 45 deletions src/commands/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,6 @@ import { parseClaims, parseEntities, type Claim, type Entity } from '../lib/reco
import { runProcess } from '../lib/process.js';

const DEFAULT_TIMEOUT_MS = 15_000;
const ACTIVE_CLAIMS_PROGRAM = [
'[.[] | select(has("supersedes")) | .supersedes] as $superseded',
'| [.[]',
' | select(.subject == $subject)',
' | select((.id as $id | ($superseded | index($id))) == null)',
' ]',
'| sort_by(.created, .id)',
].join('\n');

export interface ResolvedEntity {
readonly kind: 'resolved';
readonly subject: string;
Expand Down Expand Up @@ -83,6 +74,19 @@ export function resolveEntity(entities: readonly Entity[], subject: string): Ent
return { kind: 'resolved', subject, entity: aliases[0] as Entity, matchedBy: 'alias' };
}

function compareOrdinal(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

export function selectActiveClaims(claims: readonly Claim[], subject: string): Claim[] {
const superseded = new Set(
claims.flatMap((claim) => claim.supersedes === undefined ? [] : [claim.supersedes]),
);
return claims
.filter((claim) => claim.subject === subject && !superseded.has(claim.id))
.sort((left, right) => compareOrdinal(left.created, right.created) || compareOrdinal(left.id, right.id));
}

function quoted(value: string): string {
return JSON.stringify(value);
}
Expand Down Expand Up @@ -170,47 +174,23 @@ export async function query(options: QueryOptions): Promise<QueryReport> {
);
}

const claimsPath = join(repositoryRoot, 'graph', 'claims.json');
const selected = await runner('jq', [
'-c',
'--arg',
'subject',
resolution.entity.id,
ACTIVE_CLAIMS_PROGRAM,
claimsPath,
], {
cwd: repositoryRoot,
timeoutMs,
});

if (selected.problem === 'missing') {
return failure(EXIT_CODES.prerequisite, 'GK003', 'jq 1.6 or newer is required');
}
if (selected.problem === 'timeout') {
return failure(EXIT_CODES.operational, 'GK004', 'query selection timed out after ' + timeoutMs + ' ms');
}
if (selected.problem === 'spawn' || selected.exitCode === null) {
return failure(EXIT_CODES.operational, 'GK004', 'unable to run query selection', undefined, selected.stderr);
}
if (selected.exitCode !== 0) {
let claims: Claim[];
try {
const raw = await readFile(join(repositoryRoot, 'graph', 'claims.json'), 'utf8');
claims = parseClaims(JSON.parse(raw) as unknown);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return failure(
EXIT_CODES.operational,
'GK004',
'jq query selection failed with exit code ' + selected.exitCode,
'graph changed or became unreadable after validation: ' + message,
'graph/claims.json',
selected.stderr,
);
}

try {
const claims = parseClaims(JSON.parse(selected.stdout) as unknown);
return {
exitCode: EXIT_CODES.success,
stdout: formatQueryOutput(resolution, claims),
stderr: '',
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return failure(EXIT_CODES.internal, 'GK005', 'invalid jq query output: ' + message);
}
return {
exitCode: EXIT_CODES.success,
stdout: formatQueryOutput(resolution, selectActiveClaims(claims, resolution.entity.id)),
stderr: '',
};
}
2 changes: 1 addition & 1 deletion tests/helpers/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ProcessResult } from '../../src/lib/process.js';
export function successfulProbe(command: string): ProcessResult {
return {
exitCode: 0,
stdout: command === 'jq' ? 'jq-1.7.1\n' : '',
stdout: command === 'git' ? 'git version 2.50.0\n' : '',
stderr: '',
};
}
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/contributor-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ test('CI and repository settings cover all supported platforms and governance',
]) {
assert.match(ci, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
const performanceJob = ci.slice(ci.indexOf(' performance:'));
assert.doesNotMatch(performanceJob, /Install jq|install jq/i);
assert.match(ci, /Install jq for legacy validator/);

const settings = await read('.github/repository-settings.md');
assert.match(settings, /Description/);
Expand Down
37 changes: 12 additions & 25 deletions tests/integration/init-prerequisites.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,19 @@ async function expectPrerequisiteFailure(
}
}

test('accepts Node 18+, Git, sh, and jq 1.6+ on a supported platform', async () => {
test('accepts Node 18+ and Git on native Windows without probing sh or jq', async () => {
const fixture = await createRepositoryFixture(false);
try {
await checkInitPrerequisites(fixture.root, environment());
const commands: string[] = [];
const nativeEnvironment: InitEnvironment = {
...environment({ platform: 'win32', env: {} }),
probe: async (command) => {
commands.push(command);
return command === 'git' ? result(0, 'git version 2.50.0\n') : result(null, '', 'missing');
},
};
await checkInitPrerequisites(fixture.root, nativeEnvironment);
assert.deepEqual(commands, ['git']);
} finally {
await fixture.cleanup();
}
Expand All @@ -74,28 +83,6 @@ test('rejects Node older than 18 with an install link', async () => {
await expectPrerequisiteFailure(environment({ nodeVersion: '17.9.1' }), /Node\.js 18.*https:\/\/nodejs\.org/s);
});

test('rejects missing Git, sh, and jq before mutation', async () => {
test('rejects missing Git before mutation', async () => {
await expectPrerequisiteFailure(environment({}, { git: result(null, '', 'missing') }), /Git.*https:\/\/git-scm\.com/s);
await expectPrerequisiteFailure(environment({}, { sh: result(null, '', 'missing') }), /POSIX.*https:\/\/gitforwindows\.org/s);
await expectPrerequisiteFailure(environment({}, { jq: result(null, '', 'missing') }), /jq.*https:\/\/jqlang\.org\/download/s);
});

test('rejects jq older than 1.6', async () => {
await expectPrerequisiteFailure(environment({}, { jq: result(0, 'jq-1.5\n') }), /jq 1\.6 or newer/);
});

test('rejects native PowerShell while accepting Git Bash on Windows', async () => {
await expectPrerequisiteFailure(
environment({ platform: 'win32', env: {} }),
/native PowerShell.*Git Bash or WSL/s,
);
const fixture = await createRepositoryFixture(false);
try {
await checkInitPrerequisites(
fixture.root,
environment({ platform: 'win32', env: { MSYSTEM: 'MINGW64' } }),
);
} finally {
await fixture.cleanup();
}
});
64 changes: 49 additions & 15 deletions tests/integration/query-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import assert from 'node:assert/strict';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import test from 'node:test';

import { query } from '../../src/commands/query.js';
Expand Down Expand Up @@ -67,6 +69,31 @@ test('query returns only active claims in created/id order with complete provena
assert.match(result.stdout, new RegExp('Captured: ' + timestamp));
assert.match(result.stdout, new RegExp('Producer: ' + validRun.id));
assert.match(result.stdout, /Created: 2026-07-21T09:16:22Z/);
assert.equal(result.stdout, [
'Entity: test_payments_flaky',
'Matched by: canonical ID',
'Active claims: 2',
'',
'Claim: claim_33333333',
' Predicate: likely_cause',
' Object: \u0022timezone mismatch\u0022',
' Source: inference',
' Basis: \u0022failure starts after midnight UTC\u0022',
' Producer: run_2026-07-21-triage_a1',
' Created: 2026-07-21T09:15:22Z',
'',
'Claim: claim_22222222',
' Predicate: has_status',
' Object: \u0022passing with UTC default\u0022',
' Source: tool_output',
' Command: \u0022npm test\u0022',
' Exit code: 1',
' Evidence: evidence/triage.log#L1-L2',
' Captured: 2026-07-21T09:14:22Z',
' Producer: run_2026-07-21-triage_a1',
' Created: 2026-07-21T09:16:22Z',
'',
].join('\n'));
});

test('query uses claim ID as a stable secondary sort key', async (t) => {
Expand Down Expand Up @@ -98,25 +125,28 @@ test('query returns validator failures before attempting claim selection', async
assert.equal(result.stdout, '');
});

test('query maps jq selection timeout to an operational error', async (t) => {
test('query performs no selector subprocess after validation', async (t) => {
const fixture = await createValidatorFixture();
t.after(fixture.cleanup);
await fixture.writeGraph([entity], [validClaim], [validRun]);
const commands: string[] = [];

const result = await query({
cwd: fixture.root,
subject: entity.id,
timeoutMs: 321,
runner: async (command) => command === process.execPath
? { exitCode: 0, stdout: 'GraphKeeper: validation passed\n', stderr: '' }
: { exitCode: null, stdout: '', stderr: '', problem: 'timeout' },
runner: async (command) => {
commands.push(command);
return command === process.execPath
? { exitCode: 0, stdout: 'GraphKeeper: validation passed\n', stderr: '' }
: { exitCode: 0, stdout: JSON.stringify([validClaim]), stderr: '' };
},
});

assert.equal(result.exitCode, 4);
assert.match(result.stderr, /GK004 query selection timed out after 321 ms/);
assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(commands, [process.execPath]);
});

test('query uses a fifteen-second default timeout for validation and selection', async (t) => {
test('query uses a fifteen-second default timeout for validation', async (t) => {
const fixture = await createValidatorFixture();
t.after(fixture.cleanup);
await fixture.writeGraph([entity], [validClaim], [validRun]);
Expand All @@ -134,7 +164,7 @@ test('query uses a fifteen-second default timeout for validation and selection',
});

assert.equal(result.exitCode, 0, result.stderr);
assert.deepEqual(observedTimeouts, [15_000, 15_000]);
assert.deepEqual(observedTimeouts, [15_000]);
});

test('query applies its timeout to validation and never selects after validation timeout', async (t) => {
Expand All @@ -158,19 +188,23 @@ test('query applies its timeout to validation and never selects after validation
assert.deepEqual(commands, [process.execPath]);
});

test('query maps a missing jq selector to the prerequisite exit code', async (t) => {
test('query maps a post-validation claims parse race to GK004', async (t) => {
const fixture = await createValidatorFixture();
t.after(fixture.cleanup);
await fixture.writeGraph([entity], [validClaim], [validRun]);

const result = await query({
cwd: fixture.root,
subject: entity.id,
runner: async (command) => command === process.execPath
? { exitCode: 0, stdout: 'GraphKeeper: validation passed\n', stderr: '' }
: { exitCode: null, stdout: '', stderr: '', problem: 'missing' },
runner: async (command) => {
if (command === process.execPath) {
await writeFile(join(fixture.root, 'graph', 'claims.json'), '{changed after validation\n', 'utf8');
return { exitCode: 0, stdout: 'GraphKeeper: validation passed\n', stderr: '' };
}
return { exitCode: 0, stdout: '[]', stderr: '' };
},
});

assert.equal(result.exitCode, 3);
assert.match(result.stderr, /GK003 jq 1\.6 or newer is required/);
assert.equal(result.exitCode, 4);
assert.match(result.stderr, /GK004 \[graph\/claims\.json\] graph changed or became unreadable after validation/);
});
Loading