* test(graphify): TDD-red design contract for #3170 commit-staleness signal Captures the proposed extension to graphifyStatus() as 8 failing assertions across 3 groups (git-aware, non-git, back-compat). Suite is describe.skip()'d so npm test stays green on the branch — removing .skip is the green-light moment when the enhancement is approved and implementation lands. Verified against safishamsi/graphify v0.7.0 release notes: the field on graph.json is built_at_commit (full git HEAD), not commit_hash as originally guessed in #3170. Tests assert against the verified name. Design highlights captured in the file's docstring: - Tri-state commit_stale (true/false/null) — null means "we don't know" (pre-v0.7 graph or no git), distinct from false ("known fresh") - Argument-injection fence /^[0-9a-f]{4,40}$/i validates built_at_commit before it reaches `git` as an argv element - Existing graphifyStatus() fields (node_count, edge_count, stale, age_hours, etc.) are unchanged — back-compat fenced Per the issue's enhancement template: no PR will be opened until the issue is labeled `approved-enhancement`. Refs #3170 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(graphify): surface commit-based staleness from graphify v0.7+ built_at_commit Closes #3170 graphify v0.7+ embeds built_at_commit (full git HEAD) into graph.json at write time. GSD's existing graphifyStatus() ignored it; staleness was mtime-only, which is a poor proxy for "does this graph reflect the current code." A CI-built graph rebuilt minutes ago against an old checkout reads as FRESH on mtime but is materially stale. graphifyStatus() now returns four additional fields on the success path: built_at_commit short hash from graph.built_at_commit, or null current_commit short hash of git HEAD, or null when no git commits_behind git rev-list --count <built>..HEAD, or null commit_stale true | false | null Tri-state on commit_stale is load-bearing. null means "we don't know" (pre-v0.7 graph, non-git cwd, unreachable commit) — semantically distinct from false ("known fresh"). Agents reading null should fall back to mtime; reading false can confidently skip a rebuild. Security: built_at_commit is on-disk and user-influenceable. Without validation, a hostile value (e.g. "--upload-pack=evil") would reach git as an argv element and be interpreted as an option. The /^[0-9a-f]{4,40}$/i fence rejects anything else as absent. spawnSync's array args (no shell) is defense in depth, not the boundary. Skill (commands/gsd/graphify.md) Step 2b renders one conditional line: Source commit: abc1234 (5 commits behind HEAD) Source commit: abc1234 (current) Source commit: abc1234 (freshness unknown) Pre-v0.7 graphs omit the line entirely — no confusing "Source commit: unknown" rendered. Also documents `graphify hook install` in docs/CONFIGURATION.md for multi-dev teams who would otherwise hit graph.json merge conflicts on parallel rebuilds (sub-enhancement 2 from #3170). TDD red→green: tests/enh-3170-graphify-commit-staleness.test.cjs (8 assertions across git-aware, non-git, back-compat) was committed describe.skip()'d in c567f23d when the issue was filed; this commit removes .skip and lands the implementation that makes them green. Full suite 7503/7503 passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
4
.changeset/3170-graphify-commit-staleness.md
Normal file
4
.changeset/3170-graphify-commit-staleness.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
type: Enhancement
|
||||||
|
---
|
||||||
|
**`/gsd-graphify status` surfaces graphify v0.7+ commit-based staleness (#3170)** — `graphifyStatus()` now reads `built_at_commit` from `graph.json` (written by graphify v0.7+ at build time), compares it against `git HEAD`, and returns four new fields: `built_at_commit`, `current_commit`, `commits_behind`, and `commit_stale`. The `commit_stale` flag is tri-state — `true` / `false` / `null`, where `null` means the signal is unavailable (pre-v0.7 graph, non-git checkout, or unreachable commit) and callers should fall back to the existing mtime-based `stale` flag. The skill renders `Source commit: <hash> (N commits behind HEAD | current | freshness unknown)` when the signal is present, and omits the line entirely for pre-v0.7 graphs. The `built_at_commit` value is validated as 4–40 hex chars before reaching `git`, so a hostile `graph.json` cannot smuggle dashed options (e.g. `--upload-pack=…`) into the argv. Also documents `graphify hook install` in `docs/CONFIGURATION.md` for multi-dev teams who would otherwise hit `graph.json` merge conflicts on parallel rebuilds.
|
||||||
@@ -55,6 +55,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
### Enhancement
|
### Enhancement
|
||||||
|
|
||||||
|
- **`/gsd-graphify status` surfaces commit-based staleness from graphify v0.7+** — `graphifyStatus()` now reads `built_at_commit` from `graph.json` (graphify v0.7+ embeds it at build time), compares against `git HEAD`, and returns four new fields: `built_at_commit`, `current_commit`, `commits_behind`, and `commit_stale`. The `commit_stale` flag is tri-state (`true`/`false`/`null`) — `null` means the signal is unavailable (pre-v0.7 graph, non-git checkout, or unreachable commit) and callers should fall back to the existing mtime-based `stale` flag. The skill renders `Source commit: <hash> (N commits behind HEAD | current | freshness unknown)` when the signal is present, and omits the line entirely for pre-v0.7 graphs. The `built_at_commit` value is validated as 4–40 hex chars before reaching `git`, so a hostile `graph.json` cannot smuggle dashed options into the argv. Also documents `graphify hook install` in `docs/CONFIGURATION.md` for multi-dev teams who would otherwise hit `graph.json` merge conflicts on parallel rebuilds. Regression covered by `tests/enh-3170-graphify-commit-staleness.test.cjs` (8 assertions across git-aware, non-git, and back-compat groups). (#3170)
|
||||||
- **Test suite for `config-schema.cjs` is now mutation-resistant** — Stryker measured a 4.62% mutation score on `get-shit-done/bin/lib/config-schema.cjs` (6 killed, 124 survived out of 130). Surviving mutants flagged that existing tests were exercising paths but not verifying outputs: a polarity flip (`return true` → `return false`), a predicate swap (`.some` → `.every`), or a guard removal (`if (VALID_CONFIG_KEYS.has(...)) return true;` → unguarded fallthrough) all passed every test. New `tests/bug-2986-config-schema-mutation-killers.test.cjs` adds 95 tests across four suites that target each surviving mutant class: (1) parameterized `isValidConfigKey('${key}') === true` for every member of `VALID_CONFIG_KEYS` (kills the static-key-fast-path mutation), (2) representative dynamic-pattern keys that match exactly one pattern (kills the `.some` → `.every` mutation, with an inline mutual-exclusivity invariant check), (3) `strictEqual` against the literal boolean `true`/`false` instead of `assert.ok` truthy checks (kills polarity-flip mutations), (4) anchor-tightening cases that differ from valid keys by one character beyond the documented shape (kills regex-loosening mutations on `^`, `$`, and character-class boundaries). Tests use the lib's public surface (typed boolean assertions on `isValidConfigKey` return values), no source-grep. (#2986)
|
- **Test suite for `config-schema.cjs` is now mutation-resistant** — Stryker measured a 4.62% mutation score on `get-shit-done/bin/lib/config-schema.cjs` (6 killed, 124 survived out of 130). Surviving mutants flagged that existing tests were exercising paths but not verifying outputs: a polarity flip (`return true` → `return false`), a predicate swap (`.some` → `.every`), or a guard removal (`if (VALID_CONFIG_KEYS.has(...)) return true;` → unguarded fallthrough) all passed every test. New `tests/bug-2986-config-schema-mutation-killers.test.cjs` adds 95 tests across four suites that target each surviving mutant class: (1) parameterized `isValidConfigKey('${key}') === true` for every member of `VALID_CONFIG_KEYS` (kills the static-key-fast-path mutation), (2) representative dynamic-pattern keys that match exactly one pattern (kills the `.some` → `.every` mutation, with an inline mutual-exclusivity invariant check), (3) `strictEqual` against the literal boolean `true`/`false` instead of `assert.ok` truthy checks (kills polarity-flip mutations), (4) anchor-tightening cases that differ from valid keys by one character beyond the documented shape (kills regex-loosening mutations on `^`, `$`, and character-class boundaries). Tests use the lib's public surface (typed boolean assertions on `isValidConfigKey` return values), no source-grep. (#2986)
|
||||||
- **Hotfix release flow now auto-incorporates fixes from `main` and bundles the SDK** — `hotfix.yml create` auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not yet shipped (oldest-first; patch-equivalents skipped via `git cherry`; `feat:`/`refactor:` excluded; conflicts halt with the offending SHA; run summary lists every included SHA). `hotfix.yml finalize` adds the `install-smoke` cross-platform gate, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`), tightens the `next` dist-tag re-point, and marks the GitHub Release `--latest`. `release-sdk.yml` gains `action: publish | hotfix` plus an `auto_cherry_pick` toggle, with a new `prepare` job that branches `hotfix/X.YY.Z` from the highest existing `vX.YY.*` tag and runs the same cherry-pick logic — idempotent if the branch was pre-prepared via `hotfix.yml`. Hotfix `vX.YY.Z` is now defined as everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` since that base, so each tag is the cumulative-fix anchor for the next. (#2955)
|
- **Hotfix release flow now auto-incorporates fixes from `main` and bundles the SDK** — `hotfix.yml create` auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not yet shipped (oldest-first; patch-equivalents skipped via `git cherry`; `feat:`/`refactor:` excluded; conflicts halt with the offending SHA; run summary lists every included SHA). `hotfix.yml finalize` adds the `install-smoke` cross-platform gate, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`), tightens the `next` dist-tag re-point, and marks the GitHub Release `--latest`. `release-sdk.yml` gains `action: publish | hotfix` plus an `auto_cherry_pick` toggle, with a new `prepare` job that branches `hotfix/X.YY.Z` from the highest existing `vX.YY.*` tag and runs the same cherry-pick logic — idempotent if the branch was pre-prepared via `hotfix.yml`. Hotfix `vX.YY.Z` is now defined as everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` since that base, so each tag is the cumulative-fix anchor for the next. (#2955)
|
||||||
- **Planning workspace seam extracted from `core.cjs` into `planning-workspace.cjs`** — path/workstream/lock behavior now lives in a dedicated module (`planningDir`, `planningPaths`, `planningRoot`, active-workstream routing, `withPlanningLock`). `core.cjs` keeps compatibility re-exports while call-sites migrate to direct imports, improving locality and reducing coupling. (#2900)
|
- **Planning workspace seam extracted from `core.cjs` into `planning-workspace.cjs`** — path/workstream/lock behavior now lives in a dedicated module (`planningDir`, `planningPaths`, `planningRoot`, active-workstream routing, `withPlanningLock`). `core.cjs` keeps compatibility re-exports while call-sites migrate to direct imports, improving locality and reducing coupling. (#2900)
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ node $HOME/.claude/get-shit-done/bin/gsd-tools.cjs graphify status
|
|||||||
Parse the JSON output and display:
|
Parse the JSON output and display:
|
||||||
- If `exists: false`, display the message field
|
- If `exists: false`, display the message field
|
||||||
- Otherwise show last build time, node/edge/hyperedge counts, and STALE or FRESH indicator
|
- Otherwise show last build time, node/edge/hyperedge counts, and STALE or FRESH indicator
|
||||||
|
- If `built_at_commit` is non-null, also display a `Source commit:` line:
|
||||||
|
- `commit_stale === false` (rebuilt at HEAD): `Source commit: <built_at_commit> (current)`
|
||||||
|
- `commit_stale === true` (graph behind HEAD): `Source commit: <built_at_commit> (<commits_behind> commits behind HEAD)`
|
||||||
|
- `commit_stale === null` (unreachable commit / no git): `Source commit: <built_at_commit> (freshness unknown)`
|
||||||
|
- If `built_at_commit` is null (pre-graphify-v0.7 graph), omit the source-commit line entirely — do not render "Source commit: unknown"
|
||||||
|
|
||||||
|
The mtime-based STALE/FRESH flag and the commit-based `commit_stale` measure
|
||||||
|
different things and can disagree (e.g., a CI-built graph rebuilt minutes ago
|
||||||
|
against an old checkout reads as FRESH on mtime but `commit_stale: true`).
|
||||||
|
Surface both so the agent can choose.
|
||||||
|
|
||||||
**STOP** after displaying status. Do not spawn an agent.
|
**STOP** after displaying status. Do not spawn an agent.
|
||||||
|
|
||||||
|
|||||||
@@ -362,6 +362,38 @@ Toggle optional capabilities via the `features.*` config namespace. Feature flag
|
|||||||
| `graphify.enabled` | boolean | `false` | Enable the project knowledge graph. When `true`, `/gsd-graphify` builds and queries a graph in `.planning/graphs/`. Added in v1.36 |
|
| `graphify.enabled` | boolean | `false` | Enable the project knowledge graph. When `true`, `/gsd-graphify` builds and queries a graph in `.planning/graphs/`. Added in v1.36 |
|
||||||
| `graphify.build_timeout` | number (seconds) | `300` | Maximum seconds allowed for a `/gsd-graphify build` run before it aborts. Added in v1.36 |
|
| `graphify.build_timeout` | number (seconds) | `300` | Maximum seconds allowed for a `/gsd-graphify build` run before it aborts. Added in v1.36 |
|
||||||
|
|
||||||
|
#### Multi-developer setup
|
||||||
|
|
||||||
|
If multiple developers will rebuild the graph in the same repo, run once per
|
||||||
|
clone after enabling graphify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
graphify hook install
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs a git merge driver that union-merges concurrent `graph.json`
|
||||||
|
writes (no conflict markers in the knowledge graph), plus the post-commit
|
||||||
|
rebuild hook. It writes `.gitattributes` and registers `graphify
|
||||||
|
merge-driver` in `.git/config`. Solo projects can skip this step; running it
|
||||||
|
anyway is harmless. Introduced upstream in graphify v0.7.0 alongside the
|
||||||
|
`built_at_commit` freshness signal that `/gsd-graphify status` surfaces.
|
||||||
|
|
||||||
|
#### Commit-based staleness
|
||||||
|
|
||||||
|
`/gsd-graphify status` reports two orthogonal staleness signals:
|
||||||
|
|
||||||
|
- **`stale`** (mtime-based, 24-hour window) — when the graph file was last
|
||||||
|
written. Useful when graphify isn't run automatically.
|
||||||
|
- **`commit_stale`** (commit-based, requires graphify v0.7+) — whether the
|
||||||
|
graph was built against the current `git HEAD`. Trustworthy when present.
|
||||||
|
Tri-state: `true` / `false` / `null`. `null` means the signal is
|
||||||
|
unavailable (pre-v0.7 graph, no git, or unreachable commit) — fall back
|
||||||
|
to the mtime flag.
|
||||||
|
|
||||||
|
A CI-built graph rebuilt minutes ago against an old checkout will read as
|
||||||
|
fresh on mtime but `commit_stale: true`. Surface both when answering
|
||||||
|
architecture questions.
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const childProcess = require('child_process');
|
const childProcess = require('child_process');
|
||||||
const { atomicWriteFileSync } = require('./core.cjs');
|
const { atomicWriteFileSync, execGit } = require('./core.cjs');
|
||||||
|
|
||||||
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
// ─── Config Gate ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -358,9 +358,44 @@ function graphifyQuery(cwd, term, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict 4-40 hex fence for graph.built_at_commit values (#3170). Anything
|
||||||
|
* else (dashed, prose, empty) is treated as absent so a hostile graph.json
|
||||||
|
* cannot smuggle a `--upload-pack=…` option into a `git` argv.
|
||||||
|
*/
|
||||||
|
const COMMIT_HASH_RE = /^[0-9a-f]{4,40}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read git HEAD for the project at `cwd`. Returns the full commit hash on
|
||||||
|
* success, or null when cwd is not a git repo / `git` is not on PATH.
|
||||||
|
*/
|
||||||
|
function readGitHead(cwd) {
|
||||||
|
const r = execGit(cwd, ['rev-parse', 'HEAD']);
|
||||||
|
if (r.exitCode !== 0) return null;
|
||||||
|
return r.stdout.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count commits between `from` and `to` (exclusive..inclusive, like
|
||||||
|
* `git rev-list --count A..B`). Returns null when either ref is unreachable
|
||||||
|
* or the cwd is not a git repo.
|
||||||
|
*/
|
||||||
|
function countCommitsBetween(cwd, from, to) {
|
||||||
|
const r = execGit(cwd, ['rev-list', '--count', `${from}..${to}`]);
|
||||||
|
if (r.exitCode !== 0) return null;
|
||||||
|
const n = parseInt(r.stdout.trim(), 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return status information about the knowledge graph (STAT-01, STAT-02).
|
* Return status information about the knowledge graph (STAT-01, STAT-02).
|
||||||
*
|
*
|
||||||
|
* Surfaces the graphify v0.7+ commit-staleness signal as four optional
|
||||||
|
* fields when graph.built_at_commit is present and validly formatted
|
||||||
|
* (#3170). Tri-state on commit_stale: null means "we don't know" (pre-v0.7
|
||||||
|
* graph, no git, or unreachable commit), distinct from false ("known
|
||||||
|
* fresh").
|
||||||
|
*
|
||||||
* @param {string} cwd - Working directory
|
* @param {string} cwd - Working directory
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
@@ -382,6 +417,17 @@ function graphifyStatus(cwd) {
|
|||||||
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
const age = Date.now() - stat.mtimeMs;
|
const age = Date.now() - stat.mtimeMs;
|
||||||
|
|
||||||
|
// Commit-staleness signal (#3170). Validate before passing to git.
|
||||||
|
const rawBuilt = (graph.built_at_commit || '').toString().trim();
|
||||||
|
const builtAt = COMMIT_HASH_RE.test(rawBuilt) ? rawBuilt : null;
|
||||||
|
const head = readGitHead(cwd);
|
||||||
|
let commitsBehind = null;
|
||||||
|
let commitStale = null;
|
||||||
|
if (builtAt && head) {
|
||||||
|
commitsBehind = countCommitsBetween(cwd, builtAt, head);
|
||||||
|
if (commitsBehind !== null) commitStale = commitsBehind > 0;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exists: true,
|
exists: true,
|
||||||
last_build: stat.mtime.toISOString(),
|
last_build: stat.mtime.toISOString(),
|
||||||
@@ -390,6 +436,10 @@ function graphifyStatus(cwd) {
|
|||||||
hyperedge_count: (graph.hyperedges || []).length,
|
hyperedge_count: (graph.hyperedges || []).length,
|
||||||
stale: age > STALE_MS,
|
stale: age > STALE_MS,
|
||||||
age_hours: Math.round(age / (60 * 60 * 1000)),
|
age_hours: Math.round(age / (60 * 60 * 1000)),
|
||||||
|
built_at_commit: builtAt ? builtAt.slice(0, 7) : null,
|
||||||
|
current_commit: head ? head.slice(0, 7) : null,
|
||||||
|
commits_behind: commitsBehind,
|
||||||
|
commit_stale: commitStale,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
224
tests/enh-3170-graphify-commit-staleness.test.cjs
Normal file
224
tests/enh-3170-graphify-commit-staleness.test.cjs
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract for the #3170 commit-staleness signal on graphifyStatus().
|
||||||
|
*
|
||||||
|
* graphify v0.7+ embeds `built_at_commit` (full git HEAD) into graph.json at
|
||||||
|
* write time. GSD's status used to be mtime-only, a poor proxy for "does
|
||||||
|
* this graph reflect the current code." This suite fences the four new
|
||||||
|
* fields surfaced by graphifyStatus():
|
||||||
|
*
|
||||||
|
* built_at_commit short hash from graph.built_at_commit, or null
|
||||||
|
* current_commit short hash of HEAD, or null if cwd is not a git repo
|
||||||
|
* commits_behind git rev-list --count <built>..HEAD, or null
|
||||||
|
* commit_stale boolean, true if commits_behind > 0; null when unknown
|
||||||
|
*
|
||||||
|
* Tri-state on commit_stale is load-bearing: null means "we don't know"
|
||||||
|
* (pre-v0.7 graph or no git), which is semantically distinct from false
|
||||||
|
* ("known fresh"). Agents reading null should fall back to mtime; reading
|
||||||
|
* false can confidently skip a rebuild.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
|
||||||
|
const { graphifyStatus } = require('../get-shit-done/bin/lib/graphify.cjs');
|
||||||
|
|
||||||
|
function enableGraphify(planningDir) {
|
||||||
|
const cfgPath = path.join(planningDir, 'config.json');
|
||||||
|
const cfg = fs.existsSync(cfgPath) ? JSON.parse(fs.readFileSync(cfgPath, 'utf8')) : {};
|
||||||
|
cfg.graphify = { enabled: true };
|
||||||
|
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeGraph(planningDir, data) {
|
||||||
|
const graphsDir = path.join(planningDir, 'graphs');
|
||||||
|
fs.mkdirSync(graphsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(graphsDir, 'graph.json'), JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function gitHead(cwd) {
|
||||||
|
return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitEmpty(cwd, message) {
|
||||||
|
execFileSync('git', ['commit', '--allow-empty', '-m', message], { cwd, stdio: 'pipe' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAMPLE_NODES = [
|
||||||
|
{ id: 'n1', label: 'A', description: '', type: 'service' },
|
||||||
|
{ id: 'n2', label: 'B', description: '', type: 'model' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('enh-3170: graphifyStatus surfaces built_at_commit staleness', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let planningDir;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Group 1 — git-aware cases (real git repo via createTempGitProject)
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('git-aware', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempGitProject();
|
||||||
|
planningDir = path.join(tmpDir, '.planning');
|
||||||
|
enableGraphify(planningDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
test('graph rebuilt at HEAD: commits_behind=0, commit_stale=false', () => {
|
||||||
|
const head = gitHead(tmpDir);
|
||||||
|
writeGraph(planningDir, { nodes: SAMPLE_NODES, edges: [], built_at_commit: head });
|
||||||
|
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
assert.equal(result.built_at_commit, head.slice(0, 7),
|
||||||
|
'short hash from graph.built_at_commit');
|
||||||
|
assert.equal(result.current_commit, head.slice(0, 7),
|
||||||
|
'short hash of git HEAD');
|
||||||
|
assert.equal(result.commits_behind, 0,
|
||||||
|
'zero commits between HEAD and itself');
|
||||||
|
assert.equal(result.commit_stale, false,
|
||||||
|
'commit_stale is explicitly false when commits_behind === 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('graph 5 commits behind HEAD: commits_behind=5, commit_stale=true', () => {
|
||||||
|
const built = gitHead(tmpDir);
|
||||||
|
for (let i = 0; i < 5; i += 1) commitEmpty(tmpDir, `c${i}`);
|
||||||
|
writeGraph(planningDir, { nodes: SAMPLE_NODES, edges: [], built_at_commit: built });
|
||||||
|
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
assert.equal(result.commits_behind, 5);
|
||||||
|
assert.equal(result.commit_stale, true);
|
||||||
|
assert.equal(result.built_at_commit, built.slice(0, 7));
|
||||||
|
assert.notEqual(result.current_commit, built.slice(0, 7),
|
||||||
|
'current_commit reflects HEAD, not graph build commit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('built_at_commit absent (pre-v0.7 graph): all four new fields null', () => {
|
||||||
|
// No built_at_commit on the graph -- GSD must not fabricate one.
|
||||||
|
writeGraph(planningDir, { nodes: SAMPLE_NODES, edges: [] });
|
||||||
|
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
assert.equal(result.built_at_commit, null);
|
||||||
|
assert.equal(result.commits_behind, null);
|
||||||
|
assert.equal(result.commit_stale, null,
|
||||||
|
'tri-state: null means "we do not know", not "fresh"');
|
||||||
|
// current_commit may still be non-null since we are in a git repo,
|
||||||
|
// but without a baseline it cannot drive staleness.
|
||||||
|
assert.notEqual(result.current_commit, undefined,
|
||||||
|
'current_commit field is always present even when null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebased-away built_at_commit: commits_behind=null, commit_stale=null', () => {
|
||||||
|
// built_at_commit references a commit that never existed in this repo.
|
||||||
|
const ghostHash = '0000000000000000000000000000000000000001';
|
||||||
|
writeGraph(planningDir, { nodes: SAMPLE_NODES, edges: [], built_at_commit: ghostHash });
|
||||||
|
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
assert.equal(result.built_at_commit, ghostHash.slice(0, 7),
|
||||||
|
'echoes the field even if unreachable -- caller can decide what to do');
|
||||||
|
assert.equal(result.commits_behind, null,
|
||||||
|
'cannot count commits to an unreachable commit');
|
||||||
|
assert.equal(result.commit_stale, null,
|
||||||
|
'unknown distance means unknown staleness');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed built_at_commit (dashed argv): rejected before git invocation', () => {
|
||||||
|
// Argument-injection fence: a graph.json with a hostile built_at_commit
|
||||||
|
// must never reach `git` as an argv element. The implementation should
|
||||||
|
// validate /^[0-9a-f]{4,40}$/i and treat anything else as absent.
|
||||||
|
const malicious = '--upload-pack=evil';
|
||||||
|
writeGraph(planningDir, { nodes: SAMPLE_NODES, edges: [], built_at_commit: malicious });
|
||||||
|
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
assert.equal(result.built_at_commit, null,
|
||||||
|
'malformed value is rejected, not echoed');
|
||||||
|
assert.equal(result.commits_behind, null);
|
||||||
|
assert.equal(result.commit_stale, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Group 2 — non-git cases (createTempProject, no .git/)
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('non-git cwd', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempProject();
|
||||||
|
planningDir = path.join(tmpDir, '.planning');
|
||||||
|
enableGraphify(planningDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
test('cwd has no .git: current_commit=null, derived fields=null', () => {
|
||||||
|
const built = 'abcdef1234567890abcdef1234567890abcdef12';
|
||||||
|
writeGraph(planningDir, { nodes: SAMPLE_NODES, edges: [], built_at_commit: built });
|
||||||
|
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
assert.equal(result.built_at_commit, built.slice(0, 7),
|
||||||
|
'graph field is echoed even without a local repo');
|
||||||
|
assert.equal(result.current_commit, null,
|
||||||
|
'no HEAD without git');
|
||||||
|
assert.equal(result.commits_behind, null);
|
||||||
|
assert.equal(result.commit_stale, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Group 3 — back-compat fences for existing fields
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('back-compat', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = createTempGitProject();
|
||||||
|
planningDir = path.join(tmpDir, '.planning');
|
||||||
|
enableGraphify(planningDir);
|
||||||
|
writeGraph(planningDir, {
|
||||||
|
nodes: SAMPLE_NODES,
|
||||||
|
edges: [{ source: 'n1', target: 'n2', label: 'x', confidence: 'EXTRACTED' }],
|
||||||
|
hyperedges: [],
|
||||||
|
built_at_commit: gitHead(tmpDir),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => cleanup(tmpDir));
|
||||||
|
|
||||||
|
test('existing fields are unchanged when commit-staleness fields are added', () => {
|
||||||
|
const result = graphifyStatus(tmpDir);
|
||||||
|
|
||||||
|
// Existing contract — must not regress.
|
||||||
|
assert.equal(result.exists, true);
|
||||||
|
assert.equal(result.node_count, 2);
|
||||||
|
assert.equal(result.edge_count, 1);
|
||||||
|
assert.equal(result.hyperedge_count, 0);
|
||||||
|
assert.equal(typeof result.last_build, 'string');
|
||||||
|
assert.equal(typeof result.stale, 'boolean',
|
||||||
|
'mtime-based stale flag stays as-is for back-compat');
|
||||||
|
assert.equal(typeof result.age_hours, 'number');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled response is unchanged (commit-staleness fields not added)', () => {
|
||||||
|
const tmp2 = createTempProject();
|
||||||
|
try {
|
||||||
|
const result = graphifyStatus(tmp2);
|
||||||
|
assert.equal(result.disabled, true,
|
||||||
|
'disabled path returns the existing shape, no commit fields');
|
||||||
|
assert.equal(result.built_at_commit, undefined,
|
||||||
|
'commit-staleness fields are only added on the success path');
|
||||||
|
} finally {
|
||||||
|
cleanup(tmp2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user