* chore(#604): rename get-shit-done/ runtime directory to gsd-core/ Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary (`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers are unaffected. Mechanical (bulk, ~90% of the diff): - `git mv get-shit-done gsd-core` - Swept path/identifier references across the repo via `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead preserves the five legitimate slug variants that are NOT the directory: get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names). - Build/manifest wiring: package.json (bin, files, coverage globs), tsconfig.build.json (outDir), ~86 .gitignore build-output entries, stryker.config.mjs, scan-ignore files, install.js path strings. - Frozen (not rewritten): CHANGELOG.md history; translated docs (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/). New logic (review here): - src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper ADR-0008 installer migration. On upgrade it walks the legacy `~/.claude/get-shit-done/` tree, classifies each file via the prior install manifest, and emits remove-managed / backup-and-remove for managed files while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked root and symlinked entries; bounds-checks every path under configDir). The framework rolls back on install failure. Emptied dirs may remain (framework has no recursive dir-removal primitive) — documented. - scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare `get-shit-done` directory token (split token to avoid self-match; case- insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines). Wired into the lint-tests CI job. - Restored scripts/lint-package-identity-drift.cjs detection regexes (the mechanical sweep had wrongly rewritten the old-name patterns it exists to detect) and marked them as intentional legacy references. - TDD tests for the migration and the guard; do.md slash-command guard regex tightened so a `/gsd-core/bin` path segment is not mistaken for a command; changeset + docs/installer-migrations.md row added. Breaking: the installed runtime path moves `~/.claude/get-shit-done/` -> `~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed files (preserving user files) on upgrade. Users with custom hooks/configs hardcoding the old path must update them. Closes #604 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unsweep pending changesets + allowlist injection-example docs CI fixes for the rename PR: - Do not sweep pending .changeset/*.md (ephemeral release-note fragments, like CHANGELOG); reverted those body edits so 5 pre-existing malformed fragments (missing type/pr) no longer enter the PR diff and trip docs-lint. Allowlisted .changeset/ in the legacy-name guard accordingly. - Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in prompt-injection-scan.sh: they contain intentional injection examples / security-model prose; the path-reference rewrites are kept. CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR; none in the new migration/guard) and are out of scope for the rename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): resolve CodeQL alerts surfaced on this PR The rename diff touched files carrying pre-existing CodeQL findings; per the no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving them off. All behavior-preserving: - scripts/ci-test-scope.cjs: build the config-path match from string .includes() instead of a RegExp over an arg-derived value (js/regex-injection). - src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName so the table-cell escape is complete (js/incomplete-sanitization). - tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization). - tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace, keep the meaningful POSIX-class conversion (js/identity-replacement). Verified: build:lib green; the touched test files + ci-test-scope + profile-output suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization) The prior commit's fixes for two alerts were ineffective: - ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file` reaching static regex `.test(file)` calls (not the config rule). Removed ALL regex over file/t — startsWith/includes/=== string checks + an isWindowsHint helper — so there is no regex sink for the tainted value. - js/incomplete-multi-character-sanitization (3 test files): a single `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint loop (replace until stable) plus a final bare-opener strip. Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL CodeQL flags the regex PATTERNS syntactically (regex-injection on the --files arg split; incomplete-multi-character-sanitization on the <!--...--> replace), so loop fixes do not satisfy it. Made these paths regex-free: - ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/). - 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)). Behavior preserved; ci-test-scope + the 3 suites pass; guard clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unblock security base64 scan on the large rename diff The security job hit its 10m timeout: base64-scan.sh choked on the binary test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/ non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings), and the ~800-file rename diff is slow to scan regardless. - scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they can't carry base64-obfuscated *text* and feeding NUL bytes through the per-line scanner is pathologically slow. collect_files already filtered binary *extensions*; this catches binary *content* in text extensions. - .github/workflows/security-scan.yml: raise the security job timeout 10m->30m to accommodate very large diffs (the scan itself is unchanged). Verified locally: scan skips the fixture, 0 "ignored null byte" warnings, 0 findings, exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): sweep get-shit-done refs introduced by merging next The branch was updated with next (#614/#384/#618 etc.), which reference the get-shit-done/ dir (still named that on next). Swept the stale references in the merged files to gsd-core so the rename stays consistent and lint:legacy-name passes: - commands/gsd/discuss-phase.md (runtime-launcher shim paths) - src/core.cts (getAgentsDir layout comments) - tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib) Verified: guard 0 violations; build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant The #614 runtime-launcher shim added to discuss-phase.md references `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it mis-read the directory path as a dangling `/gsd-core` command ref (same class as the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path segments are not treated as slash-command references. Verified locally on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22 image) full suite: 0 failures - bug-3683 + bug-2954 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI) CI intermittently failed state.test's gsd-tools subprocess with "findProjectRoot is not a function" (flip-flopping across legs; not reproducible on mac full suite, gsd-test linux full suite, test:unit, or state.test x8). findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs); binding it via destructure at module-load can be undefined under a load-ordering edge. Resolve it lazily at call time via a small wrapper so the lookup happens after core.cjs is fully initialized. Verified green on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22) full suite: 0 failures - state.test.cjs: 106/106; gsd-tools loads cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): allowlist verification-patterns.md placeholder examples in secret scan The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var examples (illustrative Stripe test-key / database-URL / API-key placeholders) — not real credentials. Added it to .secretscanignore with the strict annotation, mirroring the existing gsd-core/workflows/plan-phase.md exception. Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next exits 0 with 0 findings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
10 KiB
JavaScript
296 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Tests for DispatchEvent shape factory (issue #177).
|
|
*
|
|
* Each test exercises the real module code path and asserts on
|
|
* observable behaviour (return values). No mocks, no vacuous truths.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
makeDispatchEvent,
|
|
} = require('../../gsd-core/bin/lib/observability/event.cjs');
|
|
|
|
describe('makeDispatchEvent — shape', () => {
|
|
test('returns an object with required top-level fields', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
args: ['--tdd'],
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
|
|
assert.ok(typeof event.traceId === 'string', 'traceId must be a string');
|
|
assert.ok(typeof event.command === 'string', 'command must be a string');
|
|
assert.ok(typeof event.timestamp === 'string', 'timestamp must be a string');
|
|
assert.ok('result' in event, 'result must be present');
|
|
});
|
|
|
|
test('traceId is a UUID v4 (format check)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
// UUID v4: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
|
const uuidV4Re = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
assert.match(event.traceId, uuidV4Re, `traceId '${event.traceId}' is not a valid UUID v4`);
|
|
});
|
|
|
|
test('each call produces a unique traceId', () => {
|
|
const a = makeDispatchEvent({ command: 'plan', result: { kind: 'ok', data: null } });
|
|
const b = makeDispatchEvent({ command: 'plan', result: { kind: 'ok', data: null } });
|
|
assert.notEqual(a.traceId, b.traceId, 'consecutive calls must produce different traceIds');
|
|
});
|
|
|
|
test('parentTraceId is undefined when not provided (default, backward-compat with P1.3)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
// P1.4: when no parentTraceId supplied, field is still undefined
|
|
assert.strictEqual(event.parentTraceId, undefined, 'parentTraceId must be undefined when not provided');
|
|
});
|
|
|
|
test('parentTraceId propagates when provided as a string (P1.4)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee',
|
|
'parentTraceId must be propagated when provided as a string');
|
|
});
|
|
|
|
test('parentTraceId is undefined when null is passed (defensive normalization)', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: null,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'null parentTraceId must be normalised to undefined');
|
|
});
|
|
|
|
test('non-string parentTraceId is set to undefined for defensive safety', () => {
|
|
// Style choice: surrounding code uses undefined for absent/invalid optional fields
|
|
// (e.g. args is omitted rather than coerced). Consistent policy: non-string → undefined.
|
|
const eventNum = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 42,
|
|
});
|
|
assert.strictEqual(eventNum.parentTraceId, undefined,
|
|
'number parentTraceId must be normalised to undefined');
|
|
|
|
const eventObj = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: { id: 'x' },
|
|
});
|
|
assert.strictEqual(eventObj.parentTraceId, undefined,
|
|
'object parentTraceId must be normalised to undefined');
|
|
});
|
|
|
|
test('command is set from input', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'discuss',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
assert.equal(event.command, 'discuss');
|
|
});
|
|
|
|
test('timestamp is a valid ISO 8601 string', () => {
|
|
const before = Date.now();
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
const after = Date.now();
|
|
const parsed = Date.parse(event.timestamp);
|
|
assert.ok(!isNaN(parsed), `timestamp '${event.timestamp}' must parse as a date`);
|
|
assert.ok(parsed >= before, 'timestamp must not be in the past');
|
|
assert.ok(parsed <= after + 5, 'timestamp must not be in the future');
|
|
});
|
|
|
|
test('result field is passed through', () => {
|
|
const result = { kind: 'UnknownCommand', command: 'bogus' };
|
|
const event = makeDispatchEvent({ command: 'bogus', result });
|
|
assert.deepStrictEqual(event.result, result);
|
|
});
|
|
});
|
|
|
|
describe('makeDispatchEvent — args field', () => {
|
|
test('args is omitted when not provided', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
});
|
|
assert.ok(!('args' in event), 'args must not be present when not supplied');
|
|
});
|
|
|
|
test('args is omitted when provided (default redaction)', () => {
|
|
// The event factory itself does NOT decide to include args — that is the
|
|
// redaction layer's job. makeDispatchEvent simply stores args as supplied.
|
|
// By default (no includeArgs), args should NOT appear in the returned event.
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
args: ['--foo', 'bar'],
|
|
result: { kind: 'ok', data: null },
|
|
includeArgs: false,
|
|
});
|
|
assert.ok(!('args' in event), 'args must be absent when includeArgs is false');
|
|
});
|
|
|
|
test('args is included when includeArgs is true', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
args: ['--foo', 'bar'],
|
|
result: { kind: 'ok', data: null },
|
|
includeArgs: true,
|
|
});
|
|
assert.ok('args' in event, 'args must be present when includeArgs is true');
|
|
assert.deepStrictEqual(event.args, ['--foo', 'bar']);
|
|
});
|
|
});
|
|
|
|
describe('makeDispatchEvent — parentTraceId UUID v4 validation', () => {
|
|
const { randomUUID } = require('crypto');
|
|
|
|
test('invalid empty string parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: '',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'empty string parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid whitespace-only parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: ' ',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'whitespace-only parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid non-UUID string parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 'junk',
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'"junk" parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid oversized string parentTraceId is dropped to undefined', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: 'a'.repeat(10000),
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'10000-char string parentTraceId must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid UUID v1 parentTraceId is dropped to undefined', () => {
|
|
// Version nibble is 1, not 4 — rejected by UUID v4 regex
|
|
const uuidV1Like = 'aaaaaaaa-bbbb-1ccc-8ddd-eeeeeeeeeeee';
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: uuidV1Like,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'UUID v1 parentTraceId must be dropped to undefined');
|
|
});
|
|
|
|
test('invalid UUID v4 missing hyphens parentTraceId is dropped to undefined', () => {
|
|
// 32 hex chars, no hyphens
|
|
const noHyphens = '1234567812345678123456781234567812';
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: noHyphens,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'UUID v4 missing hyphens must be coerced to undefined');
|
|
});
|
|
|
|
test('invalid UUID v4 with extra chars parentTraceId is dropped to undefined', () => {
|
|
const withExtra = randomUUID() + 'x';
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: withExtra,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, undefined,
|
|
'UUID v4 with trailing extra char must be coerced to undefined');
|
|
});
|
|
|
|
test('valid UPPERCASE UUID v4 parentTraceId is propagated (case-insensitive)', () => {
|
|
const upperUUID = randomUUID().toUpperCase();
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: upperUUID,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, upperUUID,
|
|
'uppercase UUID v4 parentTraceId must be propagated as-is');
|
|
});
|
|
|
|
test('valid lowercase UUID v4 parentTraceId is propagated', () => {
|
|
const lowerUUID = randomUUID(); // crypto.randomUUID() is always lowercase
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: null },
|
|
parentTraceId: lowerUUID,
|
|
});
|
|
assert.strictEqual(event.parentTraceId, lowerUUID,
|
|
'lowercase UUID v4 parentTraceId must be propagated');
|
|
});
|
|
});
|
|
|
|
describe('makeDispatchEvent — result variants', () => {
|
|
test('ok result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'ok', data: 42 },
|
|
});
|
|
assert.equal(event.result.kind, 'ok');
|
|
assert.equal(event.result.data, 42);
|
|
});
|
|
|
|
test('UnknownCommand result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'bogus',
|
|
result: { kind: 'UnknownCommand', command: 'bogus' },
|
|
});
|
|
assert.equal(event.result.kind, 'UnknownCommand');
|
|
assert.equal(event.result.command, 'bogus');
|
|
});
|
|
|
|
test('InvalidArgs result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'InvalidArgs', arg: '--bad', reason: 'not recognised' },
|
|
});
|
|
assert.equal(event.result.kind, 'InvalidArgs');
|
|
assert.equal(event.result.arg, '--bad');
|
|
});
|
|
|
|
test('HandlerFailure result shape', () => {
|
|
const event = makeDispatchEvent({
|
|
command: 'plan',
|
|
result: { kind: 'HandlerFailure', message: 'boom' },
|
|
});
|
|
assert.equal(event.result.kind, 'HandlerFailure');
|
|
assert.equal(event.result.message, 'boom');
|
|
});
|
|
});
|