Merge branch 'next' into fix/1477-surface-source-marker
This commit is contained in:
5
.changeset/tidy-mice-cheer.md
Normal file
5
.changeset/tidy-mice-cheer.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 1767
|
||||
---
|
||||
**Plural/optional/chosen assumption-delta checkpoint during planning** — when a phase makes something plural, optional, or chosen that used to be singular, required, or derived, the planner is now prompted to re-ask whether the primary key / identity model still names the right thing, preventing silent architectural drift from accumulating into a later user-facing bug. Advisory (non-blocking); fires only on a detected signal. Toggle with workflow.assumption_delta. (#1561)
|
||||
@@ -52,6 +52,9 @@ Module owning projection from dispatch results/errors to CLI `{ exitCode, stdout
|
||||
### STATE.md Document Module
|
||||
Module owning STATE.md parse, field extraction, field replacement, status normalization, and frontmatter reconstruction. It does not scan `.planning/phases` and does not own persistence or locking; phase/plan/summary counts arrive from inventory/progress Modules as inputs, and read-modify-write paths remain Adapters. Source of truth: `gsd-core/bin/lib/state-document.cjs`.
|
||||
|
||||
### STATE.md Transition Module [Planned]
|
||||
Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` and the direct `writeStateMd` callers in `milestone.cts:352` and `phase.cts:1770`; verify's `regenerateState` factory-reset primitive stays as a direct `writeStateMd` call. Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. Source of truth: `gsd-core/bin/lib/state-transition.cjs` (planned, generated from `src/state-transition.cts`).
|
||||
|
||||
### Query Execution Policy Module
|
||||
Module owning query transport routing policy projection (`preferNative`, fallback policy, workstream subprocess forcing) at execution seam.
|
||||
|
||||
|
||||
45
capabilities/assumption-delta/capability.json
Normal file
45
capabilities/assumption-delta/capability.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"id": "assumption-delta",
|
||||
"role": "feature",
|
||||
"version": "1.6.0",
|
||||
"title": "Assumption-delta architecture checkpoint",
|
||||
"description": "Rarely-firing advisory checkpoint that triggers when a phase makes something plural, optional, or chosen that used to be singular, required, or derived. Surfaces one identity-model question (promote the new general representation to primary, or add it alongside?) so a silent primary-key drift does not accumulate into a later user-facing bug. Non-blocking; fires only on a detected signal.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"engines": {
|
||||
"gsd": ">=1.6.0"
|
||||
},
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.assumption_delta": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"path": "fragments/plan-pre.md"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
],
|
||||
"when": "workflow.assumption_delta",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": []
|
||||
}
|
||||
53
capabilities/assumption-delta/fragments/plan-pre.md
Normal file
53
capabilities/assumption-delta/fragments/plan-pre.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Assumption-Delta Architecture Checkpoint
|
||||
|
||||
> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Most quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup.
|
||||
|
||||
## Run the detector
|
||||
|
||||
The detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`):
|
||||
|
||||
```bash
|
||||
ASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan "${PHASE}" --json 2>/dev/null || echo '{"detected":false,"signals":[],"terms":{}}')
|
||||
```
|
||||
|
||||
> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase), the query emits `{ "detected": false, ... }` — the checkpoint does not fire. Do not block on it.
|
||||
>
|
||||
> Optional tuning — pass `--terms <comma-list>` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan "${PHASE}" --json --terms second,alternative,fallback`.
|
||||
|
||||
## Decision branch
|
||||
|
||||
Read `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose.
|
||||
|
||||
**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user.
|
||||
|
||||
**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired:
|
||||
|
||||
| `kind` | What changed | The question to answer |
|
||||
|---|---|---|
|
||||
| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? |
|
||||
| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? |
|
||||
| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? |
|
||||
|
||||
Before finalizing the plan, answer this for the user and record the decision explicitly:
|
||||
|
||||
> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default).
|
||||
|
||||
Record the outcome in the PLAN.md front matter / a `<assumption_delta_decision>` block:
|
||||
|
||||
- The **noun** that is now primary (the generalized identity).
|
||||
- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale.
|
||||
- If `add-alongside`: call it out as accepted debt and note what would force a later promote.
|
||||
|
||||
## Optional companion: an invariant test
|
||||
|
||||
When `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *"every confirmed default round-trips through the primary use-path, for every supported variant."* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan.
|
||||
|
||||
## Tuning the vocabulary (optional)
|
||||
|
||||
The trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare "or" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms <comma-list>` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`.
|
||||
|
||||
This checkpoint is advisory: it informs and records; it never blocks the phase.
|
||||
@@ -286,6 +286,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
| `workflow.nyquist_validation` | boolean | `true` | Test coverage mapping during plan-phase research |
|
||||
| `workflow.ui_phase` | boolean | `true` | Generate UI design contracts for frontend phases |
|
||||
| `workflow.ui_safety_gate` | boolean | `true` | Prompt to run /gsd-ui-phase for frontend phases during plan-phase |
|
||||
| `workflow.assumption_delta` | boolean | `true` | Advisory architecture checkpoint during planning. When a phase makes something **plural, optional, or chosen** that used to be **singular, required, or derived** (e.g. a second auth method, a required field becoming optional, a constant becoming a parameter), the planner is prompted to re-ask whether the primary key / identity model still names the right thing (promote the new general representation vs. add it alongside). Non-blocking; fires only on a detected signal. Bare "or" is intentionally excluded (prose false-positives). Inspect a phase with `gsd query assumption-delta scan <phase>`. Added in #1561 |
|
||||
| `workflow.ui_review` | boolean | `true` | Run visual quality audit (`/gsd-ui-review`) after phase execution in autonomous mode. When `false`, the UI audit step is skipped. |
|
||||
| `workflow.node_repair` | boolean | `true` | Autonomous task repair on verification failure |
|
||||
| `workflow.node_repair_budget` | number | `2` | Max repair attempts per failed task |
|
||||
|
||||
@@ -283,6 +283,7 @@
|
||||
"agent-command-router.cjs",
|
||||
"agent-install-check.cjs",
|
||||
"artifacts.cjs",
|
||||
"assumption-delta.cjs",
|
||||
"audit-command-router.cjs",
|
||||
"audit.cjs",
|
||||
"capability-activation.cjs",
|
||||
|
||||
217
docs/adr/1769-state-md-transition-module.md
Normal file
217
docs/adr/1769-state-md-transition-module.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# ADR-1769: STATE.md Transition Module — intent-based transitions over scattered RMW callbacks
|
||||
|
||||
- **Status:** Proposed (Phase 0); **Accepted** at Phase 7 closeout
|
||||
- **Date:** 2026-06-27 (Phase 0)
|
||||
- **Issue:** [#1769](https://github.com/open-gsd/gsd-core/issues/1769) — epic
|
||||
- **Supersedes:** the policy portions of `syncStateFrontmatter` (`src/state.cts:1667–1743`),
|
||||
`readModifyWriteStateMd`'s post-sync preservation block (`src/state.cts:2008–2119`), and
|
||||
the format-detection duplication across 14 RMW callbacks in `state.cts` plus direct
|
||||
`writeStateMd` callers in `phase.cts:1770` and `milestone.cts:352`. Does NOT supersede
|
||||
`state-document.cjs`'s parse/extract/replace primitives — those are deep and stay.
|
||||
|
||||
## Context
|
||||
|
||||
`CONTEXT.md`'s "STATE.md Document Module" entry describes a deep leaf: parse, extract,
|
||||
replace primitives, no persistence or locking. The friction lives in the **adapter layer**
|
||||
above it.
|
||||
|
||||
STATE.md writes happen via three mechanisms today:
|
||||
|
||||
1. **`readModifyWriteStateMd(statePath, transformFn, cwd, options, clock)`** — the RMW seam.
|
||||
Called from 14 sites in `state.cts`, each passing a closure that re-encodes format
|
||||
detection, field preservation, and section mutation.
|
||||
2. **Direct `writeStateMd` / `syncStateFrontmatter` calls** in `milestone.cts:352`
|
||||
(milestone complete), `verify.cts:1899` (regenerateState recovery), and
|
||||
`phase.cts:1770` (phase complete state-half, inside `writePlanningFileSet`).
|
||||
3. **`cmdStateBuildFrontmatter`** — Encoding 3 of the preservation policy, used by the
|
||||
read path (`cmdStateJson`).
|
||||
|
||||
The **preservation policy** ("which fields win when frontmatter and body disagree?") is
|
||||
encoded in **three places that drift independently**:
|
||||
|
||||
- `syncStateFrontmatter` (`state.cts:1667–1743`) — preserves status when derived='unknown',
|
||||
preserves milestone_name vs. placeholder, preserves stopped_at/paused_at/current_phase/
|
||||
current_phase_name/current_plan/progress when derived is empty.
|
||||
- `readModifyWriteStateMd` post-sync block (`state.cts:2069–2113`) — restores progress
|
||||
block when `resync=false`, preserves status/stopped_at when body source field unchanged
|
||||
(#1230 delta heuristic).
|
||||
- `cmdStateBuildFrontmatter` (`state.cts:1469+`) — own copies of the status/progress
|
||||
preservation rules.
|
||||
|
||||
The same field has different rules in different encodings. `progress` has two rules.
|
||||
`status` has three. `stopped_at` has two.
|
||||
|
||||
**Bug cluster this produces:** #1760 (prune no-ops), #1761 (sync writes wrong progress),
|
||||
#1743 (patch clobbers curated field), #1695 (patch clobbers current_phase_name), #1264
|
||||
(resync=false restore), #1255/#1257 (format mismatch), #3242 (curated-progress ratchet).
|
||||
Every fix is per-call-site and doesn't touch the other 13.
|
||||
|
||||
The friction shape: ADR-857/1372/1508/3660 correctly identified the **leaf modules** and
|
||||
gave them depth. The remaining friction is in the **adapter layer** that didn't finish
|
||||
thinning. The recurring pattern is *"seam adopted for locating, hand-rolled code retained
|
||||
for mutating"* — `tokenizeHeadings` was adopted, `replaceSection` was not.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce a **STATE.md Transition Module** as a sibling/super-module of the STATE.md Document
|
||||
Module. Seven design decisions, resolved via `/grilling`:
|
||||
|
||||
### 1. Module shape: Transition Module owns the full transaction
|
||||
|
||||
The Module owns lock → read → apply transition → preserve policy → write. Interface is
|
||||
intent-based: `beginPhase(statePath, phaseNum)`, `advancePlan(statePath, planId)`, etc.
|
||||
The 14 RMW callbacks collapse to 14 one-line transition calls.
|
||||
|
||||
*Rejected:* (B) Field Policy Module (locks stay outside) — leaves lock/scan bugs tangled
|
||||
with policy bugs. (C) Widen state-document — shallow; format-detection + I/O concerns
|
||||
leak in.
|
||||
|
||||
### 2. Method set: 10 transitions (lifecycle + maintenance + milestoneComplete)
|
||||
|
||||
`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`,
|
||||
`milestoneComplete`, `patch`, `sync`, `prune`, `update`.
|
||||
|
||||
*Rejected:* (i) Lifecycle only (5) — leaves maintenance bugs #1760/#1743 alive.
|
||||
(iii) All 16 writers — interface balloons, hurting depth.
|
||||
|
||||
### 3. I/O shape: pure core + injected deps
|
||||
|
||||
`transitionCore(content, intent, deps) → newContent` where
|
||||
`deps = { progressProvider, writer, locker, clock }`. A thin adapter wires the real I/O.
|
||||
Tests pass stubs.
|
||||
|
||||
*Rejected:* (a) Absorb all four I/O concerns — too much fs surface in the core.
|
||||
(b) Own transaction + lock; delegate disk scan — awkward coupling.
|
||||
|
||||
This shape also enables `phase.cts:1770`'s use case: the transition core is called inside
|
||||
`writePlanningFileSet(writes)` (multi-file transaction), not just inside `writeStateMd`.
|
||||
Pure function = leverage across multiple orchestration shapes. Concretely: in Phase 3,
|
||||
`completePhase` runs inside the multi-file ROADMAP+REQUIREMENTS+STATE transaction without
|
||||
the transition core knowing it is inside a transaction. The core only sees
|
||||
`(content, intent, deps) → newContent`; the orchestrator handles lock acquisition,
|
||||
multi-file atomicity, and write ordering. This isolates the atomicity risk to the
|
||||
orchestration layer, where the existing `writePlanningFileSet` already owns it.
|
||||
|
||||
### 4. Policy model: field-classification table
|
||||
|
||||
Each STATE.md field has a row: `{ source: body|disk|external|curated|free, preservation:
|
||||
derive|preserve-when-unchanged|preserve-always|clear }`. Transitions declare which body
|
||||
fields they touch; the core consults the table.
|
||||
|
||||
*Rejected:* (α) Per-transition hardcoded policy — duplicates the rule × 10 transitions =
|
||||
today's pain. (γ) Per-transition DSL — adds learning cost, still drifts.
|
||||
|
||||
The table kills the bug class by construction: `patch` consulting the table sees
|
||||
`current_phase_name` is `curated` and refuses to overwrite unless explicitly named. #1743
|
||||
impossible.
|
||||
|
||||
### 5. External writers: 2 migrate, 1 stays
|
||||
|
||||
- `milestone.cts:352` (milestone complete) → migrates to `milestoneComplete` transition.
|
||||
- `phase.cts:1770` (phase complete state-half) → migrates to `completePhase` transition,
|
||||
called as pure function inside the existing multi-file transaction.
|
||||
- `verify.cts:1899` (`regenerateState` recovery) → **stays as direct `writeStateMd`**.
|
||||
Factory-reset primitive, not a transition; nothing to preserve.
|
||||
|
||||
### 6. Core scope: writes only
|
||||
|
||||
Core owns the table + 10 transitions + format detection + preservation + frontmatter/body
|
||||
invariant. Body section structure (`## Current Position`, `## Session`, etc.) lives as a
|
||||
constants block at the top of the core.
|
||||
|
||||
*Outside the core:*
|
||||
|
||||
- Append-only transitions (`addDecision`, `addBlocker`, etc.) — stay on today's RMW. They
|
||||
don't touch curated fields; routing them through the core adds interface width without
|
||||
buying depth.
|
||||
- Read path (`cmdStateBuildFrontmatter`, Encoding 3) — different concern (interpretation,
|
||||
not preservation). Stays separate.
|
||||
- `verify.cts:1899` regenerateState — factory reset, not a transition.
|
||||
|
||||
*Rejected:* (B) Widen to reads — grows the table past preservation into interpretation.
|
||||
(C) Absorb append-only — interface balloons to ~17 methods.
|
||||
|
||||
### 7. Migration: substrate first, then transition-by-transition
|
||||
|
||||
Per ADR-1372 §T6's "high risk, load-bearing, surgical, last" rating, big-bang is the wrong
|
||||
shape. The migration sequence:
|
||||
|
||||
- **Phase 0:** ADR + CONTEXT.md update (this PR).
|
||||
- **Phase 1:** Substrate — transition core skeleton + table + section constants +
|
||||
`beginPhase` migration + characterization tests.
|
||||
- **Phase 2:** `advancePlan`.
|
||||
- **Phase 3:** `completePhase` + `phase.cts:1770` migration (proves pure-core-inside-
|
||||
multi-file-transaction).
|
||||
- **Phase 4:** `plannedPhase`, `milestoneSwitch`.
|
||||
- **Phase 5:** `milestoneComplete` + `milestone.cts:352` migration.
|
||||
- **Phase 6:** `patch` (covers #1743, #1695).
|
||||
- **Phase 7:** `sync`, `prune`, `update` (covers #1760, #1761).
|
||||
|
||||
Per-transition discipline: characterization tests first (capture current behavior including
|
||||
the bug-preservation we want to keep, e.g. #1230's delta heuristic), then migrate, then
|
||||
verify old tests still pass, then add bug-fix tests for what the migration fixes.
|
||||
|
||||
*Rejected:* (A) Big-bang — half-finished migration is what we're fixing. (C) Substrate
|
||||
alongside, leave callbacks — parallel worlds don't converge (ADR-857's failure mode).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
|
||||
- Bug cluster killed structurally: #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 each
|
||||
become impossible-by-construction or one-row table fixes.
|
||||
- ADR-1372 §T6 completed: `replaceSection` from the markdown sectionizer becomes the
|
||||
body-mutation primitive inside the transition core.
|
||||
- Test surface improves: tests pass `deps` stubs, no `.planning/phases/*` fixtures required
|
||||
for policy tests.
|
||||
- `phase.cts:1770` and `milestone.cts:352` stop reimplementing format detection and
|
||||
section regexes (the `// allow-adhoc-markdown: pre-seam section write-modify` lint
|
||||
suppressions in `milestone.cts` are deleted).
|
||||
|
||||
**Negative:**
|
||||
|
||||
- 10 transitions × characterization tests = substantial test volume in Phase 1 substrate.
|
||||
- The field-classification table is a new shared artifact — adding a new STATE.md field
|
||||
means one table row, but a new field *class* means updating every transition that
|
||||
declares which classes it touches. (Single-enum: 5 classes; expected to be stable.)
|
||||
- Migration is sequenced (8 phases, 8 PRs) — the codebase carries both old and new shapes
|
||||
between Phase 1 and Phase 7. Each PR is independently shippable; the old shape is fully
|
||||
retired only at Phase 7.
|
||||
|
||||
**Neutral:**
|
||||
|
||||
- `readModifyWriteStateMd` and `writeStateMd` stay exported during the migration; they're
|
||||
used by the 7 append-only callbacks that aren't migrating.
|
||||
- `cmdStateBuildFrontmatter` (Encoding 3) stays as the read path; future work may converge
|
||||
it with the table, but that's out of scope here.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Widen STATE.md Document Module to absorb the policy.** Rejected — Document Module owns
|
||||
pure parse/extract/replace; widening pulls format detection and preservation policy into
|
||||
the leaf, hurting its depth.
|
||||
2. **Per-transition hardcoded policy (option α in design).** Rejected — duplicates the rule
|
||||
per transition; same field has different rules in different transitions = today's pain.
|
||||
3. **All 16 writers in core (option iii in design).** Rejected — interface balloons to ~17
|
||||
methods, hurting depth. Append-only transitions don't touch curated fields.
|
||||
4. **Big-bang migration (option A in design).** Rejected — ADR-1372 §T6 rates this surface
|
||||
"high risk, surgical." Half-finished migration is what we're fixing.
|
||||
5. **Substrate alongside, leave callbacks (option C in design).** Rejected — parallel worlds
|
||||
don't converge. ADR-857's "decompose Core" produced today's half-finished state precisely
|
||||
because new code moved and old code stayed.
|
||||
6. **Defer until ADR-1372 §T6 finishes independently.** Rejected — §T6 has been deferred for
|
||||
over a year precisely because there's no consumer for `replaceSection` in the state path.
|
||||
The Transition Module is the consumer; the two land together.
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Scope | Closes issue | Bug coverage |
|
||||
|---|---|---|---|
|
||||
| 0 | ADR + CONTEXT.md update | #1769 | — |
|
||||
| 1 | Substrate + `beginPhase` | TBD | #1255, #1257, #3242 |
|
||||
| 2 | `advancePlan` | TBD | — |
|
||||
| 3 | `completePhase` + `phase.cts:1770` | TBD | — |
|
||||
| 4 | `plannedPhase` + `milestoneSwitch` | TBD | — |
|
||||
| 5 | `milestoneComplete` + `milestone.cts:352` | TBD | — |
|
||||
| 6 | `patch` | TBD | #1743, #1695 |
|
||||
| 7 | `sync`, `prune`, `update` | TBD | #1760, #1761 |
|
||||
@@ -61,6 +61,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop
|
||||
| [1411-resolution-provenance.md](1411-resolution-provenance.md) | Resolution must report provenance, not fall open silently | Accepted |
|
||||
| [1508-runtime-artifact-conversion-module.md](1508-runtime-artifact-conversion-module.md) | Runtime Artifact Conversion Module owns per-runtime content rewriting | Accepted |
|
||||
| [1593-skill-mapping-converter-methodology.md](1593-skill-mapping-converter-methodology.md) | Skill mapping & converter methodology across runtimes | Accepted |
|
||||
| [1769-state-md-transition-module.md](1769-state-md-transition-module.md) | STATE.md Transition Module — intent-based transitions over scattered RMW callbacks | Proposed |
|
||||
|
||||
## Seam map
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ Core package and are stamped with the package version at release (per
|
||||
ADR-1244 D6). They are not subject to the consent or integrity-pin flow applied
|
||||
to third-party capabilities.
|
||||
|
||||
### Feature capabilities (role: feature) — 16
|
||||
### Feature capabilities (role: feature) — 17
|
||||
|
||||
Feature capabilities extend what the loop does — contributing research,
|
||||
planning, execution, verification, or ship artefacts at the loop extension
|
||||
@@ -53,6 +53,7 @@ points.
|
||||
| id | role | tier | engines.gsd | extension points | hook kinds | source |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `ai-integration` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party |
|
||||
| `assumption-delta` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party |
|
||||
| `audit` | feature | full | `>=1.6.0` | — | — | first-party |
|
||||
| `code-review` | feature | full | `>=1.6.0` | `execute:post` | step | first-party |
|
||||
| `drift` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post` | gate | first-party |
|
||||
|
||||
@@ -75,6 +75,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/code-review-flags.cjs',
|
||||
'gsd-core/bin/lib/context-utilization.cjs',
|
||||
'gsd-core/bin/lib/artifacts.cjs',
|
||||
'gsd-core/bin/lib/assumption-delta.cjs',
|
||||
'gsd-core/bin/lib/command-arg-projection.cjs',
|
||||
'gsd-core/bin/lib/clock.cjs',
|
||||
'gsd-core/bin/lib/ui-safety-gate.cjs',
|
||||
|
||||
@@ -227,6 +227,8 @@ const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/a
|
||||
const state = require('./lib/state.cjs');
|
||||
const phase = require('./lib/phase.cjs');
|
||||
const roadmap = require('./lib/roadmap.cjs');
|
||||
// #1561 — assumption-delta advisory checkpoint detector (pure function).
|
||||
const { detectAssumptionDelta } = require('./lib/assumption-delta.cjs');
|
||||
const verify = require('./lib/verify.cjs');
|
||||
const config = require('./lib/config.cjs');
|
||||
const template = require('./lib/template.cjs');
|
||||
@@ -657,7 +659,7 @@ async function main() {
|
||||
// discovery; previously it was a partial subset that didn't include
|
||||
// phase / roadmap / milestone / progress / etc.
|
||||
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
|
||||
'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
|
||||
'Commands: agent, agent-skills, assumption-delta, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, pr-subrepo, ' +
|
||||
'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
|
||||
'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
|
||||
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
|
||||
@@ -1382,6 +1384,43 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
break;
|
||||
}
|
||||
|
||||
case 'assumption-delta': {
|
||||
// #1561 — advisory architecture checkpoint. `scan <phase>` reads the
|
||||
// phase section via the same resolver as roadmap.get-phase and runs the
|
||||
// deterministic detectAssumptionDelta, emitting the typed IR as JSON.
|
||||
const sub = args[1];
|
||||
if (sub === 'scan') {
|
||||
const phaseNum = args[2];
|
||||
// Reject missing or flag-shaped phase values (QA matrix: values that
|
||||
// look like flags). `scan --json` must not treat "--json" as a phase.
|
||||
if (!phaseNum || phaseNum.startsWith('-')) {
|
||||
error('Usage: assumption-delta scan <phase> [--terms <csv>]', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
break;
|
||||
}
|
||||
// Optional --terms <csv> override (replaces the pluralization cues;
|
||||
// optional/chosen keep defaults). An EMPTY value ("") or a flag-shaped
|
||||
// value restores the curated defaults (does NOT disable pluralization).
|
||||
// Terms are normalized (deduped, alphanumeric-only, capped) by
|
||||
// detectAssumptionDelta's resolveTerms.
|
||||
let termsOverride;
|
||||
const termsIdx = args.indexOf('--terms');
|
||||
const termsVal = termsIdx !== -1 ? args[termsIdx + 1] : undefined;
|
||||
if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
|
||||
const list = termsVal
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter((t) => t.length > 0);
|
||||
termsOverride = list.length > 0 ? { pluralization: list } : undefined;
|
||||
}
|
||||
const section = roadmap.getRoadmapPhaseWithFallback(cwd, phaseNum);
|
||||
const result = detectAssumptionDelta(section ?? '', termsOverride);
|
||||
output(result, raw);
|
||||
break;
|
||||
}
|
||||
error(`Unknown assumption-delta subcommand: ${sub}. Available: scan`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'requirements': {
|
||||
const subcommand = args[1];
|
||||
if (subcommand === 'mark-complete') {
|
||||
|
||||
231
gsd-core/bin/lib/assumption-delta.cjs
Normal file
231
gsd-core/bin/lib/assumption-delta.cjs
Normal file
@@ -0,0 +1,231 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Assumption-Delta detector (#1561).
|
||||
*
|
||||
* A rarely-firing, advisory architecture checkpoint. When a phase makes
|
||||
* something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED /
|
||||
* DERIVED, the primary key / identity model may silently stop matching the
|
||||
* generalized intent. This detector scans phase-scope prose for the linguistic
|
||||
* signals of that transition so the plan:pre capability hook (see
|
||||
* capabilities/assumption-delta/) can surface ONE identity-model question.
|
||||
*
|
||||
* Design notes (rubber-duck'd):
|
||||
* - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function
|
||||
* returning { detected, signals, terms }, not an LLM judgment — so the
|
||||
* low-false-positive guarantee (acceptance criterion #2) is testable.
|
||||
* - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues.
|
||||
* The issue lists "or" as a tell, but bare "or" is extremely common in
|
||||
* English prose and would make the gate fire on nearly every phase
|
||||
* description. Pluralization requires a stronger second-case cue
|
||||
* (second / alternative / fallback / additional / ...). The vocabulary is
|
||||
* tunable (config + the `terms` parameter) so teams can widen it.
|
||||
* - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam)
|
||||
* so a trigger term that appears only inside a code snippet does not fire.
|
||||
* - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose
|
||||
* exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error).
|
||||
*
|
||||
* Public API:
|
||||
* detectAssumptionDelta(text, terms?) -> { detected, signals, terms }
|
||||
* DEFAULT_ASSUMPTION_DELTA_TERMS
|
||||
*
|
||||
* CLI:
|
||||
* echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json]
|
||||
* exit 0 = signal detected, 1 = none, 2 = startup error
|
||||
* --json additionally prints the typed IR on stdout
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.DEFAULT_ASSUMPTION_DELTA_TERMS = void 0;
|
||||
exports.detectAssumptionDelta = detectAssumptionDelta;
|
||||
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
||||
/**
|
||||
* Curated default trigger vocabulary. Each kind lists cue terms that signal a
|
||||
* core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once
|
||||
* shipped, this set is a depended-upon interface). Tunable via the `terms`
|
||||
* parameter or the capability's config slice.
|
||||
*/
|
||||
exports.DEFAULT_ASSUMPTION_DELTA_TERMS = {
|
||||
// Primary trigger — a second X where there was one.
|
||||
// Bare "or" excluded (prose-frequency false positives).
|
||||
pluralization: [
|
||||
'second',
|
||||
'alternative',
|
||||
'alternate',
|
||||
'fallback',
|
||||
'also',
|
||||
'additional',
|
||||
'another',
|
||||
'supplementary',
|
||||
'alongside',
|
||||
'multiple',
|
||||
'plural',
|
||||
'2nd',
|
||||
],
|
||||
// required / `only` -> optional
|
||||
optional: ['optional', 'optionally'],
|
||||
// derived -> chosen / constant -> parameter
|
||||
chosen: [
|
||||
'chosen',
|
||||
'choose',
|
||||
'selectable',
|
||||
'configurable',
|
||||
'parameterized',
|
||||
'parameterised',
|
||||
'parameterize',
|
||||
'parameterise',
|
||||
'custom',
|
||||
],
|
||||
};
|
||||
/** Hardening caps for the tunable term vocabulary (Codex review finding). */
|
||||
const MAX_TERMS_PER_KIND = 200;
|
||||
const MAX_TERM_LEN = 32;
|
||||
/**
|
||||
* Normalize a caller-provided term list: trim, lowercase, reject empties and
|
||||
* punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the
|
||||
* count/length so a huge or hostile `--terms` value cannot build a giant
|
||||
* alternation regex or echo a massive payload. Defaults are already clean, so
|
||||
* this is a no-op on them.
|
||||
*/
|
||||
function normalizeTerms(list) {
|
||||
if (!Array.isArray(list))
|
||||
return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const raw of list) {
|
||||
if (typeof raw !== 'string')
|
||||
continue;
|
||||
const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN);
|
||||
// Require at least one alphanumeric char so punctuation-only terms like
|
||||
// "-" cannot match prose punctuation as a "signal".
|
||||
if (!t || !/[a-z0-9]/.test(t))
|
||||
continue;
|
||||
if (seen.has(t))
|
||||
continue;
|
||||
seen.add(t);
|
||||
out.push(t);
|
||||
if (out.length >= MAX_TERMS_PER_KIND)
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/**
|
||||
* Resolve the effective term set: per-kind override. An explicitly-provided
|
||||
* non-empty array for a kind REPLACES that kind's defaults (then normalized);
|
||||
* an absent kind KEEPS its defaults. An explicitly-empty array disables that
|
||||
* kind (override present, normalized to []). This lets a caller narrow one axis
|
||||
* without re-declaring the others.
|
||||
*/
|
||||
function resolveTerms(terms) {
|
||||
const merge = (key) => {
|
||||
const t = terms && terms[key];
|
||||
return Array.isArray(t) ? normalizeTerms(t) : [...exports.DEFAULT_ASSUMPTION_DELTA_TERMS[key]];
|
||||
};
|
||||
return {
|
||||
pluralization: merge('pluralization'),
|
||||
optional: merge('optional'),
|
||||
chosen: merge('chosen'),
|
||||
};
|
||||
}
|
||||
/** Trim + collapse + truncate a context window around a match for the snippet. */
|
||||
function makeSnippet(line, term) {
|
||||
const cleaned = line.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned.length <= 120)
|
||||
return cleaned;
|
||||
// Centre the window on the matched term when the line is long.
|
||||
const idx = cleaned.toLowerCase().indexOf(term);
|
||||
if (idx < 0)
|
||||
return cleaned.slice(0, 120);
|
||||
const start = Math.max(0, idx - 50);
|
||||
const end = Math.min(cleaned.length, idx + term.length + 50);
|
||||
const prefix = start > 0 ? '…' : '';
|
||||
const suffix = end < cleaned.length ? '…' : '';
|
||||
return `${prefix}${cleaned.slice(start, end)}${suffix}`;
|
||||
}
|
||||
/**
|
||||
* Detect assumption-delta signals in phase-scope prose.
|
||||
*
|
||||
* @param text - Roadmap phase section / scope prose. Non-string inputs degrade
|
||||
* to `{ detected: false }` without throwing.
|
||||
* @param terms - Optional per-kind override (see resolveTerms).
|
||||
* @returns typed IR: { detected, signals[], terms }. `terms` is the effective
|
||||
* (merged) set actually used, so callers/tests can audit what fired.
|
||||
*/
|
||||
function detectAssumptionDelta(text, terms) {
|
||||
if (typeof text !== 'string') {
|
||||
return { detected: false, signals: [], terms: resolveTerms(terms) };
|
||||
}
|
||||
const effective = resolveTerms(terms);
|
||||
// Strip fenced code blocks so trigger terms inside code snippets do not fire.
|
||||
// stripFencedCode is CommonMark-correct and CRLF-safe.
|
||||
const stripped = (0, markdown_sectionizer_cjs_1.stripFencedCode)(text.replace(/\r\n/g, '\n')).text;
|
||||
if (stripped.trim().length === 0) {
|
||||
return { detected: false, signals: [], terms: effective };
|
||||
}
|
||||
const signals = [];
|
||||
const kinds = ['pluralization', 'optional', 'chosen'];
|
||||
for (const kind of kinds) {
|
||||
const cueTerms = effective[kind];
|
||||
if (cueTerms.length === 0)
|
||||
continue;
|
||||
// Word-boundary anchored, case-insensitive — same shape as ui-safety-gate.
|
||||
// (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches.
|
||||
const escaped = cueTerms.map(escapeRegex).join('|');
|
||||
const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi');
|
||||
const seen = new Set();
|
||||
for (const line of stripped.split('\n')) {
|
||||
pattern.lastIndex = 0;
|
||||
for (const m of line.matchAll(pattern)) {
|
||||
const raw = m[2];
|
||||
if (!raw)
|
||||
continue;
|
||||
const matched = raw.toLowerCase();
|
||||
const key = `${kind}:${matched}`;
|
||||
if (seen.has(key))
|
||||
continue;
|
||||
seen.add(key);
|
||||
signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { detected: signals.length > 0, signals, terms: effective };
|
||||
}
|
||||
function escapeRegex(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
// ── CLI entry point ──────────────────────────────────────────────────────────
|
||||
// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
|
||||
// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json]
|
||||
// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate.
|
||||
if (require.main === module) {
|
||||
const argv = process.argv.slice(2);
|
||||
const wantJson = argv.includes('--json');
|
||||
// --terms <csv>: config-tunable vocabulary override. Replaces the
|
||||
// pluralization cues (the primary trigger); optional/chosen keep defaults.
|
||||
// An EMPTY value ("") or a flag-shaped value restores the curated defaults
|
||||
// (does NOT disable pluralization). Terms are normalized (deduped, etc.) by
|
||||
// detectAssumptionDelta's resolveTerms.
|
||||
let termsOverride;
|
||||
const termsIdx = argv.indexOf('--terms');
|
||||
const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined;
|
||||
if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
|
||||
const list = termsVal
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter((t) => t.length > 0);
|
||||
termsOverride = list.length > 0 ? { pluralization: list } : undefined;
|
||||
}
|
||||
const chunks = [];
|
||||
process.stdin.setEncoding('utf-8');
|
||||
process.stdin.on('data', (chunk) => chunks.push(chunk));
|
||||
process.stdin.on('end', () => {
|
||||
const input = chunks.join('');
|
||||
const result = detectAssumptionDelta(input, termsOverride);
|
||||
if (wantJson) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
}
|
||||
process.exit(result.detected ? 0 : 1);
|
||||
});
|
||||
process.stdin.on('error', (err) => {
|
||||
process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
@@ -138,6 +138,52 @@ const capabilities = {
|
||||
}
|
||||
}
|
||||
},
|
||||
"assumption-delta": {
|
||||
"id": "assumption-delta",
|
||||
"role": "feature",
|
||||
"version": "1.6.0",
|
||||
"title": "Assumption-delta architecture checkpoint",
|
||||
"description": "Rarely-firing advisory checkpoint that triggers when a phase makes something plural, optional, or chosen that used to be singular, required, or derived. Surfaces one identity-model question (promote the new general representation to primary, or add it alongside?) so a silent primary-key drift does not accumulate into a later user-facing bug. Non-blocking; fires only on a detected signal.",
|
||||
"tier": "full",
|
||||
"requires": [],
|
||||
"engines": {
|
||||
"gsd": ">=1.6.0"
|
||||
},
|
||||
"runtimeCompat": {
|
||||
"supported": [
|
||||
"*"
|
||||
],
|
||||
"unsupported": []
|
||||
},
|
||||
"skills": [],
|
||||
"agents": [],
|
||||
"hooks": [],
|
||||
"config": {
|
||||
"workflow.assumption_delta": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)."
|
||||
}
|
||||
},
|
||||
"steps": [],
|
||||
"contributions": [
|
||||
{
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"path": "fragments/plan-pre.md",
|
||||
"inline": "# Assumption-Delta Architecture Checkpoint\n\n> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point.\n\n## Why this exists\n\nMost quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup.\n\n## Run the detector\n\nThe detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`):\n\n```bash\nASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan \"${PHASE}\" --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[],\"terms\":{}}')\n```\n\n> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase), the query emits `{ \"detected\": false, ... }` — the checkpoint does not fire. Do not block on it.\n>\n> Optional tuning — pass `--terms <comma-list>` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan \"${PHASE}\" --json --terms second,alternative,fallback`.\n\n## Decision branch\n\nRead `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose.\n\n**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired:\n\n| `kind` | What changed | The question to answer |\n|---|---|---|\n| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? |\n| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? |\n| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? |\n\nBefore finalizing the plan, answer this for the user and record the decision explicitly:\n\n> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default).\n\nRecord the outcome in the PLAN.md front matter / a `<assumption_delta_decision>` block:\n\n- The **noun** that is now primary (the generalized identity).\n- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale.\n- If `add-alongside`: call it out as accepted debt and note what would force a later promote.\n\n## Optional companion: an invariant test\n\nWhen `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *\"every confirmed default round-trips through the primary use-path, for every supported variant.\"* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare \"or\" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms <comma-list>` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`.\n\nThis checkpoint is advisory: it informs and records; it never blocks the phase.\n"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
],
|
||||
"when": "workflow.assumption_delta",
|
||||
"onError": "skip"
|
||||
}
|
||||
],
|
||||
"gates": []
|
||||
},
|
||||
"audit": {
|
||||
"id": "audit",
|
||||
"role": "feature",
|
||||
@@ -2556,6 +2602,21 @@ const byLoopPoint = {
|
||||
}
|
||||
],
|
||||
"contributions": [
|
||||
{
|
||||
"capId": "assumption-delta",
|
||||
"point": "plan:pre",
|
||||
"into": "planner",
|
||||
"fragment": {
|
||||
"path": "fragments/plan-pre.md",
|
||||
"inline": "# Assumption-Delta Architecture Checkpoint\n\n> Advisory, non-blocking. Fires **only** when the phase scope shows a singular→plural / required→optional / derived→chosen transition. When it fires, it surfaces ONE identity-model question before the plan is finalized. Most phases will not fire it — that is the point.\n\n## Why this exists\n\nMost quietly-imported architectural debt does not come from a missing upfront design phase. It comes at the *seam*: a later phase introduces a second case (a second platform, auth method, tenant, region, source of truth) and nobody re-asks whether the original abstraction still names the right thing. The phase that adds the second case is exactly the 20-minute conversation that prevents an afternoon of later cleanup.\n\n## Run the detector\n\nThe detector is a deterministic scan over the phase scope text. It strips fenced code blocks first, so a trigger word that appears only inside a code snippet does not fire. It returns a typed result: `{ detected, signals[], terms }`. Resolve it through the `assumption-delta scan` query (same phase-section resolver as `roadmap.get-phase`):\n\n```bash\nASSUMPTION_DELTA_JSON=$(gsd_run query assumption-delta scan \"${PHASE}\" --json 2>/dev/null || echo '{\"detected\":false,\"signals\":[],\"terms\":{}}')\n```\n\n> If the phase section cannot be resolved (no `ROADMAP.md` / unknown phase), the query emits `{ \"detected\": false, ... }` — the checkpoint does not fire. Do not block on it.\n>\n> Optional tuning — pass `--terms <comma-list>` to replace the curated pluralization cues for this project (the `optional`/`chosen` cues keep their defaults): `gsd_run query assumption-delta scan \"${PHASE}\" --json --terms second,alternative,fallback`.\n\n## Decision branch\n\nRead `ASSUMPTION_DELTA_JSON`. Act on `detected` only — do **not** pattern-match the human prose.\n\n**If `detected` is `false`:** this phase does not change a core assumption. Skip the checkpoint entirely and continue planning. Do not raise it with the user.\n\n**If `detected` is `true`:** a core assumption may have lost its monopoly. The `signals[]` array tells you which family fired:\n\n| `kind` | What changed | The question to answer |\n|---|---|---|\n| `pluralization` | A second X was introduced where there was one (second platform / auth method / tenant / region / source of truth) | Does the current primary key / identity model still name the right noun? |\n| `optional` | A required / `only` field became optional | Is the field still the right anchor, or has the anchor moved? |\n| `chosen` | A derived value became chosen, or a constant became a parameter | Has a configuration decision become a modeling decision? |\n\nBefore finalizing the plan, answer this for the user and record the decision explicitly:\n\n> **Promote vs. add-alongside.** The usual correct move when a generalization occurs is to **promote** the new general representation to the primary and **demote** the old specific one to a detail of one variant — *not* to add the new one alongside the still-required old one. Adding alongside silently contradicts the generalized intent (a later variant that does not fit the old primary can be stored but never confirmed as a default).\n\nRecord the outcome in the PLAN.md front matter / a `<assumption_delta_decision>` block:\n\n- The **noun** that is now primary (the generalized identity).\n- The **decision**: `promote` | `add-alongside` | `no-change`, with a one-line rationale.\n- If `add-alongside`: call it out as accepted debt and note what would force a later promote.\n\n## Optional companion: an invariant test\n\nWhen `detected` is `true`, suggest (do not require) a contract/invariant test that encodes the now-generalized intent — e.g. *\"every confirmed default round-trips through the primary use-path, for every supported variant.\"* That test goes red the instant a future phase reintroduces the singular assumption, so the regression cannot land silently. If the user accepts, add the test as a task in the plan.\n\n## Tuning the vocabulary (optional)\n\nThe trigger vocabulary is a curated, additive-only set in `gsd-core/bin/lib/assumption-delta.cjs` (`DEFAULT_ASSUMPTION_DELTA_TERMS`). Bare \"or\" is intentionally excluded — it is too common in prose and would make the gate fire constantly. To widen or narrow the cues for a project, override at the call site with `--terms <comma-list>` (replaces the pluralization cues; `optional`/`chosen` keep defaults). The whole checkpoint is toggleable via `workflow.assumption_delta` in `.planning/config.json`.\n\nThis checkpoint is advisory: it informs and records; it never blocks the phase.\n"
|
||||
},
|
||||
"produces": [],
|
||||
"consumes": [
|
||||
"CONTEXT.md"
|
||||
],
|
||||
"when": "workflow.assumption_delta",
|
||||
"onError": "skip"
|
||||
},
|
||||
{
|
||||
"capId": "schema-gate",
|
||||
"point": "plan:pre",
|
||||
@@ -2859,6 +2920,7 @@ const byLoopPoint = {
|
||||
|
||||
const configKeys = {
|
||||
"workflow.ai_integration_phase": "ai-integration",
|
||||
"workflow.assumption_delta": "assumption-delta",
|
||||
"workflow.code_review": "code-review",
|
||||
"workflow.code_review_depth": "code-review",
|
||||
"workflow.drift_threshold": "drift",
|
||||
@@ -2899,6 +2961,12 @@ const configSchema = {
|
||||
"default": true,
|
||||
"description": "Prompt for an AI-SPEC design contract before planning phases that involve AI systems."
|
||||
},
|
||||
"workflow.assumption_delta": {
|
||||
"owner": "assumption-delta",
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable the assumption-delta architecture checkpoint during planning. When a pluralization/optional/chosen signal is detected in the phase scope, the planner is prompted to re-ask whether the primary key / identity model still names the right thing. Advisory (non-blocking)."
|
||||
},
|
||||
"workflow.code_review": {
|
||||
"owner": "code-review",
|
||||
"type": "boolean",
|
||||
@@ -4572,6 +4640,7 @@ const profileMembership = {
|
||||
const _requiresGraph = {
|
||||
"ai-integration": [],
|
||||
"antigravity": [],
|
||||
"assumption-delta": [],
|
||||
"audit": [],
|
||||
"augment": [],
|
||||
"claude": [],
|
||||
|
||||
258
src/assumption-delta.cts
Normal file
258
src/assumption-delta.cts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Assumption-Delta detector (#1561).
|
||||
*
|
||||
* A rarely-firing, advisory architecture checkpoint. When a phase makes
|
||||
* something PLURAL / OPTIONAL / CHOSEN that used to be SINGULAR / REQUIRED /
|
||||
* DERIVED, the primary key / identity model may silently stop matching the
|
||||
* generalized intent. This detector scans phase-scope prose for the linguistic
|
||||
* signals of that transition so the plan:pre capability hook (see
|
||||
* capabilities/assumption-delta/) can surface ONE identity-model question.
|
||||
*
|
||||
* Design notes (rubber-duck'd):
|
||||
* - DETERMINISTIC + TYPED IR. The "does it fire?" decision is a pure function
|
||||
* returning { detected, signals, terms }, not an LLM judgment — so the
|
||||
* low-false-positive guarantee (acceptance criterion #2) is testable.
|
||||
* - BARE "or" IS INTENTIONALLY EXCLUDED from the default pluralization cues.
|
||||
* The issue lists "or" as a tell, but bare "or" is extremely common in
|
||||
* English prose and would make the gate fire on nearly every phase
|
||||
* description. Pluralization requires a stronger second-case cue
|
||||
* (second / alternative / fallback / additional / ...). The vocabulary is
|
||||
* tunable (config + the `terms` parameter) so teams can widen it.
|
||||
* - FENCED CODE BLOCKS ARE STRIPPED first (via the markdown-sectionizer seam)
|
||||
* so a trigger term that appears only inside a code snippet does not fire.
|
||||
* - Mirrors ui-safety-gate.cts: a pure function + a STDIN-reading CLI whose
|
||||
* exit codes mirror grep (0 = signal found, 1 = none, 2 = usage error).
|
||||
*
|
||||
* Public API:
|
||||
* detectAssumptionDelta(text, terms?) -> { detected, signals, terms }
|
||||
* DEFAULT_ASSUMPTION_DELTA_TERMS
|
||||
*
|
||||
* CLI:
|
||||
* echo "$PHASE_SECTION" | node gsd-core/bin/lib/assumption-delta.cjs [--json]
|
||||
* exit 0 = signal detected, 1 = none, 2 = startup error
|
||||
* --json additionally prints the typed IR on stdout
|
||||
*/
|
||||
|
||||
import { stripFencedCode } from './markdown-sectionizer.cjs';
|
||||
|
||||
export type AssumptionDeltaKind = 'pluralization' | 'optional' | 'chosen';
|
||||
|
||||
export interface AssumptionDeltaSignal {
|
||||
kind: AssumptionDeltaKind;
|
||||
term: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface AssumptionDeltaTermSet {
|
||||
pluralization: string[];
|
||||
optional: string[];
|
||||
chosen: string[];
|
||||
}
|
||||
|
||||
export interface AssumptionDeltaResult {
|
||||
detected: boolean;
|
||||
signals: AssumptionDeltaSignal[];
|
||||
terms: AssumptionDeltaTermSet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated default trigger vocabulary. Each kind lists cue terms that signal a
|
||||
* core-assumption monopoly has been lost. ADDITIVE-ONLY (Hyrum's Law: once
|
||||
* shipped, this set is a depended-upon interface). Tunable via the `terms`
|
||||
* parameter or the capability's config slice.
|
||||
*/
|
||||
export const DEFAULT_ASSUMPTION_DELTA_TERMS: Readonly<AssumptionDeltaTermSet> = {
|
||||
// Primary trigger — a second X where there was one.
|
||||
// Bare "or" excluded (prose-frequency false positives).
|
||||
pluralization: [
|
||||
'second',
|
||||
'alternative',
|
||||
'alternate',
|
||||
'fallback',
|
||||
'also',
|
||||
'additional',
|
||||
'another',
|
||||
'supplementary',
|
||||
'alongside',
|
||||
'multiple',
|
||||
'plural',
|
||||
'2nd',
|
||||
],
|
||||
// required / `only` -> optional
|
||||
optional: ['optional', 'optionally'],
|
||||
// derived -> chosen / constant -> parameter
|
||||
chosen: [
|
||||
'chosen',
|
||||
'choose',
|
||||
'selectable',
|
||||
'configurable',
|
||||
'parameterized',
|
||||
'parameterised',
|
||||
'parameterize',
|
||||
'parameterise',
|
||||
'custom',
|
||||
],
|
||||
};
|
||||
|
||||
/** Hardening caps for the tunable term vocabulary (Codex review finding). */
|
||||
const MAX_TERMS_PER_KIND = 200;
|
||||
const MAX_TERM_LEN = 32;
|
||||
|
||||
/**
|
||||
* Normalize a caller-provided term list: trim, lowercase, reject empties and
|
||||
* punctuation-only terms (e.g. "-"), dedupe (preserve order), and cap the
|
||||
* count/length so a huge or hostile `--terms` value cannot build a giant
|
||||
* alternation regex or echo a massive payload. Defaults are already clean, so
|
||||
* this is a no-op on them.
|
||||
*/
|
||||
function normalizeTerms(list: unknown): string[] {
|
||||
if (!Array.isArray(list)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const raw of list) {
|
||||
if (typeof raw !== 'string') continue;
|
||||
const t = raw.trim().toLowerCase().slice(0, MAX_TERM_LEN);
|
||||
// Require at least one alphanumeric char so punctuation-only terms like
|
||||
// "-" cannot match prose punctuation as a "signal".
|
||||
if (!t || !/[a-z0-9]/.test(t)) continue;
|
||||
if (seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push(t);
|
||||
if (out.length >= MAX_TERMS_PER_KIND) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective term set: per-kind override. An explicitly-provided
|
||||
* non-empty array for a kind REPLACES that kind's defaults (then normalized);
|
||||
* an absent kind KEEPS its defaults. An explicitly-empty array disables that
|
||||
* kind (override present, normalized to []). This lets a caller narrow one axis
|
||||
* without re-declaring the others.
|
||||
*/
|
||||
function resolveTerms(terms?: Partial<AssumptionDeltaTermSet>): AssumptionDeltaTermSet {
|
||||
const merge = (key: AssumptionDeltaKind): string[] => {
|
||||
const t = terms && terms[key];
|
||||
return Array.isArray(t) ? normalizeTerms(t) : [...DEFAULT_ASSUMPTION_DELTA_TERMS[key]];
|
||||
};
|
||||
return {
|
||||
pluralization: merge('pluralization'),
|
||||
optional: merge('optional'),
|
||||
chosen: merge('chosen'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Trim + collapse + truncate a context window around a match for the snippet. */
|
||||
function makeSnippet(line: string, term: string): string {
|
||||
const cleaned = line.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned.length <= 120) return cleaned;
|
||||
// Centre the window on the matched term when the line is long.
|
||||
const idx = cleaned.toLowerCase().indexOf(term);
|
||||
if (idx < 0) return cleaned.slice(0, 120);
|
||||
const start = Math.max(0, idx - 50);
|
||||
const end = Math.min(cleaned.length, idx + term.length + 50);
|
||||
const prefix = start > 0 ? '…' : '';
|
||||
const suffix = end < cleaned.length ? '…' : '';
|
||||
return `${prefix}${cleaned.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect assumption-delta signals in phase-scope prose.
|
||||
*
|
||||
* @param text - Roadmap phase section / scope prose. Non-string inputs degrade
|
||||
* to `{ detected: false }` without throwing.
|
||||
* @param terms - Optional per-kind override (see resolveTerms).
|
||||
* @returns typed IR: { detected, signals[], terms }. `terms` is the effective
|
||||
* (merged) set actually used, so callers/tests can audit what fired.
|
||||
*/
|
||||
export function detectAssumptionDelta(
|
||||
text: unknown,
|
||||
terms?: Partial<AssumptionDeltaTermSet>,
|
||||
): AssumptionDeltaResult {
|
||||
if (typeof text !== 'string') {
|
||||
return { detected: false, signals: [], terms: resolveTerms(terms) };
|
||||
}
|
||||
|
||||
const effective = resolveTerms(terms);
|
||||
|
||||
// Strip fenced code blocks so trigger terms inside code snippets do not fire.
|
||||
// stripFencedCode is CommonMark-correct and CRLF-safe.
|
||||
const stripped = stripFencedCode(text.replace(/\r\n/g, '\n')).text;
|
||||
if (stripped.trim().length === 0) {
|
||||
return { detected: false, signals: [], terms: effective };
|
||||
}
|
||||
|
||||
const signals: AssumptionDeltaSignal[] = [];
|
||||
const kinds: AssumptionDeltaKind[] = ['pluralization', 'optional', 'chosen'];
|
||||
|
||||
for (const kind of kinds) {
|
||||
const cueTerms = effective[kind];
|
||||
if (cueTerms.length === 0) continue;
|
||||
// Word-boundary anchored, case-insensitive — same shape as ui-safety-gate.
|
||||
// (^|[^a-zA-Z0-9])(TERM)([^a-zA-Z0-9]|$) prevents interior-substring matches.
|
||||
const escaped = cueTerms.map(escapeRegex).join('|');
|
||||
const pattern = new RegExp('(^|[^a-zA-Z0-9])(' + escaped + ')([^a-zA-Z0-9]|$)', 'gi');
|
||||
const seen = new Set<string>();
|
||||
for (const line of stripped.split('\n')) {
|
||||
pattern.lastIndex = 0;
|
||||
for (const m of line.matchAll(pattern)) {
|
||||
const raw = m[2];
|
||||
if (!raw) continue;
|
||||
const matched = raw.toLowerCase();
|
||||
const key = `${kind}:${matched}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
signals.push({ kind, term: matched, snippet: makeSnippet(line, matched) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { detected: signals.length > 0, signals, terms: effective };
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ── CLI entry point ──────────────────────────────────────────────────────────
|
||||
// Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
|
||||
// Invoked by workflow bash as: echo "$PHASE_SECTION" | node .../assumption-delta.cjs [--json]
|
||||
// Exit 0 = signal detected, 1 = none, 2 = startup error. Mirrors ui-safety-gate.
|
||||
|
||||
if (require.main === module) {
|
||||
const argv = process.argv.slice(2);
|
||||
const wantJson = argv.includes('--json');
|
||||
// --terms <csv>: config-tunable vocabulary override. Replaces the
|
||||
// pluralization cues (the primary trigger); optional/chosen keep defaults.
|
||||
// An EMPTY value ("") or a flag-shaped value restores the curated defaults
|
||||
// (does NOT disable pluralization). Terms are normalized (deduped, etc.) by
|
||||
// detectAssumptionDelta's resolveTerms.
|
||||
let termsOverride: Partial<AssumptionDeltaTermSet> | undefined;
|
||||
const termsIdx = argv.indexOf('--terms');
|
||||
const termsVal = termsIdx !== -1 ? argv[termsIdx + 1] : undefined;
|
||||
if (typeof termsVal === 'string' && !termsVal.startsWith('-')) {
|
||||
const list = termsVal
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter((t) => t.length > 0);
|
||||
termsOverride = list.length > 0 ? { pluralization: list } : undefined;
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
process.stdin.setEncoding('utf-8');
|
||||
|
||||
process.stdin.on('data', (chunk: string) => chunks.push(chunk));
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
const input = chunks.join('');
|
||||
const result = detectAssumptionDelta(input, termsOverride);
|
||||
if (wantJson) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n');
|
||||
}
|
||||
process.exit(result.detected ? 0 : 1);
|
||||
});
|
||||
|
||||
process.stdin.on('error', (err: Error) => {
|
||||
process.stderr.write(`ERROR: assumption-delta.cjs stdin read failed: ${err.message}\n`);
|
||||
process.exit(2);
|
||||
});
|
||||
}
|
||||
245
tests/assumption-delta-checkpoint-e2e.test.cjs
Normal file
245
tests/assumption-delta-checkpoint-e2e.test.cjs
Normal file
@@ -0,0 +1,245 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* E2E capability-wiring tests for the assumption-delta checkpoint (#1561).
|
||||
*
|
||||
* Drives the real `loop render-hooks plan:pre` CLI subprocess against temp
|
||||
* projects with different config values and asserts on the typed envelope's
|
||||
* activeHooks — proving the capability contribution activates/deactivates by
|
||||
* config (acceptance criteria #4 non-blocking advisory + #6 capability hook).
|
||||
*
|
||||
* CONTENT/E2E only: every test drives a real CLI subprocess. No readFileSync
|
||||
* source-grep. Genuine assertions: each case asserts the SPECIFIC differing
|
||||
* value (capId presence/absence), not a count (plan:pre carries other
|
||||
* default-on contributions owned by other capabilities).
|
||||
*/
|
||||
|
||||
const { describe, test, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
const TEST_ENV_BASE = {
|
||||
GSD_SESSION_KEY: '',
|
||||
CODEX_THREAD_ID: '',
|
||||
CLAUDE_SESSION_ID: '',
|
||||
CLAUDE_CODE_SSE_PORT: '',
|
||||
OPENCODE_SESSION_ID: '',
|
||||
GEMINI_SESSION_ID: '',
|
||||
CURSOR_SESSION_ID: '',
|
||||
WINDSURF_SESSION_ID: '',
|
||||
TERM_SESSION_ID: '',
|
||||
WT_SESSION: '',
|
||||
TMUX_PANE: '',
|
||||
ZELLIJ_SESSION_NAME: '',
|
||||
TTY: '',
|
||||
SSH_TTY: '',
|
||||
};
|
||||
|
||||
function runTools(args, cwd) {
|
||||
const argv = Array.isArray(args)
|
||||
? args
|
||||
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
|
||||
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
|
||||
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...TEST_ENV_BASE },
|
||||
timeout: 60000,
|
||||
});
|
||||
return { success: true, output: stdout.trim(), exitCode: 0, error: '' };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
output: err.stdout?.toString().trim() || '',
|
||||
error: err.stderr?.toString().trim() || err.message,
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(config) {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-adelta-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(config),
|
||||
'utf8'
|
||||
);
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function planPreHooks(cwd) {
|
||||
const result = runTools('loop render-hooks plan:pre --raw', cwd);
|
||||
assert.ok(result.success, `render-hooks plan:pre should succeed. stderr: ${result.error}`);
|
||||
const envelope = JSON.parse(result.output);
|
||||
assert.strictEqual(envelope.point, 'plan:pre', 'point field must be plan:pre');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
||||
return envelope;
|
||||
}
|
||||
|
||||
function findCap(envelope, capId) {
|
||||
return envelope.activeHooks.find((h) => h.capId === capId) || null;
|
||||
}
|
||||
|
||||
// ─── ROADMAP fixture for the scan query ──────────────────────────────────────
|
||||
const ROADMAP = [
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## v1.0.0',
|
||||
'',
|
||||
'### Phase 1: Add a second auth method alongside passwords',
|
||||
'**Goal:** Users can authenticate via SSO in addition to passwords',
|
||||
'**Success Criteria**:',
|
||||
'1. SSO login works',
|
||||
'2. Password login still works',
|
||||
'',
|
||||
'### Phase 2: Refactor the parser for readability',
|
||||
'**Goal:** Smaller functions, no behavior change',
|
||||
'**Success Criteria**:',
|
||||
'1. All existing tests still pass',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function makeRoadmapProject() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-adelta-rm-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), ROADMAP, 'utf8');
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function scanQuery(cwd, phase, extra) {
|
||||
const argv = ['query', 'assumption-delta', 'scan', String(phase)];
|
||||
if (extra) argv.push(...extra);
|
||||
return runTools(argv, cwd);
|
||||
}
|
||||
|
||||
describe('assumption-delta scan query — phase-section detection (#1561)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('phase with a pluralization signal → detected:true', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = scanQuery(tmpDir, 1, ['--json']);
|
||||
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
||||
const parsed = JSON.parse(r.output);
|
||||
assert.strictEqual(parsed.detected, true);
|
||||
assert.ok(parsed.signals.some((s) => s.kind === 'pluralization'), 'phase 1 must trip pluralization');
|
||||
});
|
||||
|
||||
test('phase with no signal → detected:false (low false-positive)', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = scanQuery(tmpDir, 2, ['--json']);
|
||||
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
||||
const parsed = JSON.parse(r.output);
|
||||
assert.strictEqual(parsed.detected, false);
|
||||
assert.deepStrictEqual(parsed.signals, []);
|
||||
});
|
||||
|
||||
test('unknown phase → detected:false, no throw (graceful)', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = scanQuery(tmpDir, 999, ['--json']);
|
||||
assert.ok(r.success, `scan should succeed on unknown phase. stderr: ${r.error}`);
|
||||
assert.strictEqual(JSON.parse(r.output).detected, false);
|
||||
});
|
||||
|
||||
test('--terms override narrows the vocabulary (custom cue fires, default cue does not)', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
// phase 1 trips "second" by default; override to "xyzzy" → must NOT fire
|
||||
const r = scanQuery(tmpDir, 1, ['--json', '--terms', 'xyzzy']);
|
||||
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
||||
assert.strictEqual(JSON.parse(r.output).detected, false);
|
||||
});
|
||||
|
||||
test('missing phase arg → non-zero exit (usage error)', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = runTools(['query', 'assumption-delta', 'scan'], tmpDir);
|
||||
assert.notStrictEqual(r.exitCode, 0, 'scan with no phase must exit non-zero');
|
||||
});
|
||||
|
||||
// ── Hardening (Codex Step-4 review): malformed args ──────────────────────
|
||||
test('flag-shaped phase (scan --json) → non-zero exit, not treated as phase', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = runTools(['query', 'assumption-delta', 'scan', '--json'], tmpDir);
|
||||
assert.notStrictEqual(r.exitCode, 0, '"--json" must not be accepted as a phase number');
|
||||
});
|
||||
|
||||
test('empty --terms restores curated defaults (detected, not disabled)', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = scanQuery(tmpDir, 1, ['--terms', '', '--json']);
|
||||
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
||||
const parsed = JSON.parse(r.output);
|
||||
assert.strictEqual(parsed.detected, true, 'phase 1 must still trip defaults under empty --terms');
|
||||
assert.ok(parsed.terms.pluralization.includes('second'));
|
||||
});
|
||||
|
||||
test('flag-shaped --terms value (--terms --json) falls back to defaults, not treated as a term', () => {
|
||||
tmpDir = makeRoadmapProject();
|
||||
const r = scanQuery(tmpDir, 1, ['--terms', '--json']);
|
||||
assert.ok(r.success, `scan should succeed. stderr: ${r.error}`);
|
||||
const parsed = JSON.parse(r.output);
|
||||
// 'json' must NOT have been consumed as a pluralization cue; defaults apply.
|
||||
assert.ok(!parsed.terms.pluralization.includes('json'), '"--json" must not become a trigger term');
|
||||
assert.ok(parsed.terms.pluralization.includes('second'), 'defaults restored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assumption-delta capability — plan:pre render-hooks wiring (#1561)', () => {
|
||||
let tmpDir;
|
||||
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
|
||||
|
||||
test('default config (no toggle): assumption-delta contribution is ACTIVE', () => {
|
||||
tmpDir = makeProject({});
|
||||
const env = planPreHooks(tmpDir);
|
||||
const hook = findCap(env, 'assumption-delta');
|
||||
assert.ok(hook, 'assumption-delta contribution must be active under default config');
|
||||
assert.strictEqual(hook.kind, 'contribution', 'must be a non-blocking contribution, not a gate');
|
||||
assert.strictEqual(hook.into, 'planner', 'contribution injects into the planner role');
|
||||
assert.strictEqual(hook.when, 'workflow.assumption_delta', 'when must gate on workflow.assumption_delta');
|
||||
assert.strictEqual(hook.onError, 'skip', 'onError must be skip (advisory, never halts)');
|
||||
// The fragment body must be inlined so the planner receives concrete prose.
|
||||
const frag = hook.fragment && hook.fragment.inline ? hook.fragment.inline : hook.fragment;
|
||||
assert.ok(typeof frag === 'string' && frag.length > 0, 'fragment must be inlined as non-empty text');
|
||||
assert.ok(
|
||||
/Assumption-Delta Architecture Checkpoint/i.test(frag),
|
||||
'inlined fragment must carry the checkpoint heading'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow.assumption_delta=true: contribution is ACTIVE', () => {
|
||||
tmpDir = makeProject({ workflow: { assumption_delta: true } });
|
||||
const env = planPreHooks(tmpDir);
|
||||
assert.ok(findCap(env, 'assumption-delta'), 'must be active when explicitly true');
|
||||
});
|
||||
|
||||
test('workflow.assumption_delta=false: contribution is INACTIVE (absent from activeHooks)', () => {
|
||||
tmpDir = makeProject({ workflow: { assumption_delta: false } });
|
||||
const env = planPreHooks(tmpDir);
|
||||
assert.strictEqual(
|
||||
findCap(env, 'assumption-delta'),
|
||||
null,
|
||||
'must NOT appear in activeHooks when disabled — other default-on plan:pre hooks may still be present'
|
||||
);
|
||||
});
|
||||
|
||||
test('non-blocking guarantee: no assumption-delta entry is ever a blocking gate', () => {
|
||||
// Advisory contract (acceptance #4): the checkpoint informs; it never blocks.
|
||||
// Across both on/off states the capability must never surface as kind=gate
|
||||
// with blocking=true.
|
||||
tmpDir = makeProject({ workflow: { assumption_delta: true } });
|
||||
const env = planPreHooks(tmpDir);
|
||||
const gates = env.activeHooks.filter((h) => h.kind === 'gate');
|
||||
assert.ok(
|
||||
gates.every((g) => g.capId !== 'assumption-delta'),
|
||||
'assumption-delta must never register a blocking gate at plan:pre'
|
||||
);
|
||||
});
|
||||
});
|
||||
297
tests/assumption-delta.test.cjs
Normal file
297
tests/assumption-delta.test.cjs
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Tests for the assumption-delta detector (#1561).
|
||||
*
|
||||
* The detector is a pure function over phase-scope text that returns a typed
|
||||
* IR ({ detected, signals, terms }). Tests assert on the IR — never on
|
||||
* rendered prose — per RULESET.TESTS (no raw text matching on outputs).
|
||||
*
|
||||
* The detector mirrors ui-safety-gate.cts: a pure function plus a STDIN-reading
|
||||
* CLI (exit 0 = signal detected, 1 = none, 2 = usage error).
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const MODULE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'assumption-delta.cjs');
|
||||
|
||||
describe('detectAssumptionDelta — pure detector (#1561)', () => {
|
||||
let mod;
|
||||
try {
|
||||
mod = require(MODULE_PATH);
|
||||
} catch (err) {
|
||||
// Surface a clear failure if build:lib has not run yet.
|
||||
throw new Error(
|
||||
`Could not require ${MODULE_PATH}. Run "npm run build:lib" first. Underlying: ${err.message}`
|
||||
);
|
||||
}
|
||||
|
||||
const { detectAssumptionDelta, DEFAULT_ASSUMPTION_DELTA_TERMS } = mod;
|
||||
|
||||
test('result shape — always carries detected, signals[], terms', () => {
|
||||
const r = detectAssumptionDelta('refactor the login function');
|
||||
assert.strictEqual(r.detected, false);
|
||||
assert(Array.isArray(r.signals));
|
||||
assert.strictEqual(r.signals.length, 0);
|
||||
assert.ok(r.terms && Array.isArray(r.terms.pluralization));
|
||||
assert.ok(Array.isArray(r.terms.optional));
|
||||
assert.ok(Array.isArray(r.terms.chosen));
|
||||
});
|
||||
|
||||
test('terms echo is the effective term set actually used', () => {
|
||||
const r = detectAssumptionDelta('nothing here');
|
||||
assert.deepStrictEqual(r.terms.pluralization, [...DEFAULT_ASSUMPTION_DELTA_TERMS.pluralization]);
|
||||
assert.deepStrictEqual(r.terms.optional, [...DEFAULT_ASSUMPTION_DELTA_TERMS.optional]);
|
||||
assert.deepStrictEqual(r.terms.chosen, [...DEFAULT_ASSUMPTION_DELTA_TERMS.chosen]);
|
||||
});
|
||||
|
||||
// ── Primary trigger: pluralization ───────────────────────────────────────
|
||||
for (const cue of ['second auth method', 'alternative platform', 'fallback provider', 'also support a second region', 'an additional source of truth']) {
|
||||
test(`pluralization fires on: "${cue}"`, () => {
|
||||
const r = detectAssumptionDelta(cue);
|
||||
assert.strictEqual(r.detected, true, `expected detection for: ${cue}`);
|
||||
assert.ok(r.signals.some((s) => s.kind === 'pluralization'), `expected a pluralization signal for: ${cue}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Secondary trigger: required → optional ───────────────────────────────
|
||||
for (const cue of ['the field becomes optional', 'optionally omitted', 'may be optional now']) {
|
||||
test(`optional fires on: "${cue}"`, () => {
|
||||
const r = detectAssumptionDelta(cue);
|
||||
assert.strictEqual(r.detected, true, `expected detection for: ${cue}`);
|
||||
assert.ok(r.signals.some((s) => s.kind === 'optional'), `expected an optional signal for: ${cue}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Secondary trigger: derived → chosen / constant → parameter ───────────
|
||||
for (const cue of ['value is chosen by the caller', 'now configurable per tenant', 'parameterized at runtime', 'selectable in settings']) {
|
||||
test(`chosen fires on: "${cue}"`, () => {
|
||||
const r = detectAssumptionDelta(cue);
|
||||
assert.strictEqual(r.detected, true, `expected detection for: ${cue}`);
|
||||
assert.ok(r.signals.some((s) => s.kind === 'chosen'), `expected a chosen signal for: ${cue}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ── No-signal phases do NOT fire (acceptance criterion #2 — low FP) ───────
|
||||
for (const clean of ['refactor the login function', 'add a unit test for the parser', 'fix the off-by-one in the loop', 'update the README install steps']) {
|
||||
test(`no-signal phase does NOT fire: "${clean}"`, () => {
|
||||
const r = detectAssumptionDelta(clean);
|
||||
assert.strictEqual(r.detected, false, `false positive on: ${clean}`);
|
||||
assert.strictEqual(r.signals.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ── FALSE-POSITIVE GUARD: bare "or" in prose must NOT fire ────────────────
|
||||
// The issue lists "or" as a tell, but bare "or" is extremely common in
|
||||
// English prose and would make the gate fire constantly. The default term
|
||||
// set intentionally excludes bare "or"; pluralization requires a stronger
|
||||
// second-case cue (second/alternative/fallback/also/additional/...).
|
||||
test('FALSE-POSITIVE GUARD: bare "or" in normal prose does NOT fire', () => {
|
||||
const r = detectAssumptionDelta('refactor or rewrite the module to be cleaner');
|
||||
assert.strictEqual(r.detected, false, 'bare "or" must not fire — it would make every English sentence trip the gate');
|
||||
});
|
||||
|
||||
// ── FALSE-POSITIVE GUARD: trigger term inside a fenced code block ─────────
|
||||
// A code snippet mentioning "fallback" is not a pluralization of an
|
||||
// architectural concept. Fenced blocks are stripped before scanning.
|
||||
test('FALSE-POSITIVE GUARD: trigger term inside a fenced code block does NOT fire', () => {
|
||||
const scope = [
|
||||
'Add a retry helper to the client.',
|
||||
'',
|
||||
'```js',
|
||||
'const fallback = () => retry(); // internal var name',
|
||||
'```',
|
||||
'',
|
||||
'No architectural change here.',
|
||||
].join('\n');
|
||||
const r = detectAssumptionDelta(scope);
|
||||
assert.strictEqual(r.detected, false, 'a trigger term appearing only inside a fenced code block must not fire');
|
||||
});
|
||||
|
||||
// ── A real signal in prose still fires even when a code block is present ──
|
||||
test('signal in prose fires even when an unrelated fenced block is present', () => {
|
||||
const scope = [
|
||||
'This phase adds a second platform alongside the existing one.',
|
||||
'',
|
||||
'```js',
|
||||
'const x = 1;',
|
||||
'```',
|
||||
].join('\n');
|
||||
const r = detectAssumptionDelta(scope);
|
||||
assert.strictEqual(r.detected, true);
|
||||
assert.ok(r.signals.some((s) => s.kind === 'pluralization'));
|
||||
});
|
||||
|
||||
// ── signal carries a usable context snippet ──────────────────────────────
|
||||
test('each signal carries a non-empty snippet with context', () => {
|
||||
const r = detectAssumptionDelta('This phase introduces a second authentication method.');
|
||||
assert.strictEqual(r.detected, true);
|
||||
const sig = r.signals[0];
|
||||
assert.ok(typeof sig.snippet === 'string' && sig.snippet.length > 0);
|
||||
assert.ok(sig.snippet.toLowerCase().includes(sig.term), 'snippet should contain the matched term');
|
||||
});
|
||||
|
||||
// ── CRLF resilience ───────────────────────────────────────────────────────
|
||||
test('CRLF line endings are handled identically to LF', () => {
|
||||
const lf = detectAssumptionDelta('adds a second region\r\nalso configurable');
|
||||
const crlf = detectAssumptionDelta('adds a second region\nalso configurable');
|
||||
assert.strictEqual(lf.detected, true);
|
||||
assert.strictEqual(crlf.detected, true);
|
||||
assert.strictEqual(lf.signals.length, crlf.signals.length);
|
||||
});
|
||||
|
||||
// ── empty / whitespace / non-string inputs degrade to detected:false ──────
|
||||
test('empty string → detected:false', () => {
|
||||
assert.strictEqual(detectAssumptionDelta('').detected, false);
|
||||
});
|
||||
test('whitespace-only → detected:false', () => {
|
||||
assert.strictEqual(detectAssumptionDelta(' \n\t ').detected, false);
|
||||
});
|
||||
test('non-string (null/undefined/number) → detected:false, no throw', () => {
|
||||
assert.strictEqual(detectAssumptionDelta(null).detected, false);
|
||||
assert.strictEqual(detectAssumptionDelta(undefined).detected, false);
|
||||
assert.strictEqual(detectAssumptionDelta(42).detected, false);
|
||||
});
|
||||
|
||||
// ── custom term set overrides defaults (config-tunable vocabulary) ────────
|
||||
test('custom term set overrides defaults', () => {
|
||||
const custom = { pluralization: ['xyzzy'], optional: [], chosen: [] };
|
||||
const r = detectAssumptionDelta('this phase adds a second platform', custom);
|
||||
assert.strictEqual(r.detected, false, 'default cue "second" must not fire when defaults are overridden');
|
||||
assert.deepStrictEqual(r.terms.pluralization, ['xyzzy']);
|
||||
const r2 = detectAssumptionDelta('introduces an xyzzy adapter', custom);
|
||||
assert.strictEqual(r2.detected, true);
|
||||
assert.ok(r2.signals.some((s) => s.term === 'xyzzy'));
|
||||
});
|
||||
|
||||
test('partial custom term set merges over defaults per-kind (absent kinds keep defaults)', () => {
|
||||
const partial = { pluralization: ['second'] };
|
||||
const r = detectAssumptionDelta('now optional', partial);
|
||||
assert.strictEqual(r.detected, true, 'optional defaults still apply when only pluralization was overridden');
|
||||
assert.ok(r.signals.some((s) => s.kind === 'optional'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('assumption-delta CLI — STDIN exit codes (mirrors ui-safety-gate)', () => {
|
||||
// Exit code contract: 0 = signal detected, 1 = none, 2 = usage/startup error.
|
||||
function runCli(stdin) {
|
||||
const res = spawnSync(process.execPath, [MODULE_PATH], {
|
||||
input: stdin,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
||||
}
|
||||
|
||||
test('exit 0 when a pluralization signal is present', () => {
|
||||
const r = runCli('This phase adds a second platform alongside the existing one.');
|
||||
assert.strictEqual(r.status, 0);
|
||||
});
|
||||
|
||||
test('exit 1 when no signal is present', () => {
|
||||
const r = runCli('Refactor the login function to be smaller.');
|
||||
assert.strictEqual(r.status, 1);
|
||||
});
|
||||
|
||||
test('exit 1 on empty stdin (no signal)', () => {
|
||||
const r = runCli('');
|
||||
assert.strictEqual(r.status, 1);
|
||||
});
|
||||
|
||||
test('--json emits typed IR with detected field on stdout (exit 0)', () => {
|
||||
const res = spawnSync(process.execPath, [MODULE_PATH, '--json'], {
|
||||
input: 'introduces a configurable retry policy',
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.strictEqual(res.status, 0);
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
assert.strictEqual(parsed.detected, true);
|
||||
assert.ok(Array.isArray(parsed.signals));
|
||||
assert.ok(parsed.signals.some((s) => s.kind === 'chosen'));
|
||||
});
|
||||
|
||||
test('--json emits detected:false on stdout (exit 1) for no-signal input', () => {
|
||||
const res = spawnSync(process.execPath, [MODULE_PATH, '--json'], {
|
||||
input: 'just a routine refactor',
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.strictEqual(res.status, 1);
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
assert.strictEqual(parsed.detected, false);
|
||||
assert.deepStrictEqual(parsed.signals, []);
|
||||
});
|
||||
|
||||
// ── --terms config override (config-tunable vocabulary) ───────────────────
|
||||
test('--terms overrides the pluralization cues (custom term fires, default cue does not)', () => {
|
||||
// default cue "second" present, but overridden to "xyzzy" → must NOT fire
|
||||
const noFire = spawnSync(process.execPath, [MODULE_PATH, '--terms', 'xyzzy', '--json'], {
|
||||
input: 'adds a second platform',
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.strictEqual(noFire.status, 1);
|
||||
assert.strictEqual(JSON.parse(noFire.stdout).detected, false);
|
||||
|
||||
const fire = spawnSync(process.execPath, [MODULE_PATH, '--terms', 'xyzzy', '--json'], {
|
||||
input: 'introduces an xyzzy adapter',
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.strictEqual(fire.status, 0);
|
||||
const parsed = JSON.parse(fire.stdout);
|
||||
assert.strictEqual(parsed.detected, true);
|
||||
assert.ok(parsed.signals.some((s) => s.term === 'xyzzy' && s.kind === 'pluralization'));
|
||||
// optional/chosen defaults are retained by the partial override
|
||||
assert.ok(parsed.terms.optional.length > 0, 'optional defaults retained under --terms override');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Hardening (Codex Step-4 review fixes) ────────────────────────────────────
|
||||
describe('assumption-delta hardening (Codex review)', () => {
|
||||
const { detectAssumptionDelta } = require(MODULE_PATH);
|
||||
|
||||
test('normalizeTerms: punctuation-only / empty / dupe terms filtered; lowercased', () => {
|
||||
const r = detectAssumptionDelta('adds a second platform', {
|
||||
pluralization: ['second', 'second', '-', '', 'XYZZY'],
|
||||
optional: [],
|
||||
chosen: [],
|
||||
});
|
||||
// '-' (punct-only) and '' dropped; dupe 'second' collapsed; 'XYZZY'→'xyzzy'
|
||||
assert.deepStrictEqual(r.terms.pluralization, ['second', 'xyzzy']);
|
||||
// 'second' survived → detected
|
||||
assert.strictEqual(r.detected, true);
|
||||
});
|
||||
|
||||
test('normalizeTerms: cap guards a huge/hostile term list (no giant regex / echo)', () => {
|
||||
const huge = Array.from({ length: 250 }, (_, i) => `cue${i}`);
|
||||
const r = detectAssumptionDelta('routine refactor', { pluralization: huge, optional: [], chosen: [] });
|
||||
assert.ok(r.terms.pluralization.length <= 200, `capped to <=200, got ${r.terms.pluralization.length}`);
|
||||
assert.strictEqual(r.detected, false);
|
||||
});
|
||||
|
||||
test('punctuation-only term does NOT match prose punctuation as a signal', () => {
|
||||
// '-' as a term must not fire on "a - b" prose
|
||||
const r = detectAssumptionDelta('refactor the parser - keep behavior', {
|
||||
pluralization: ['-'],
|
||||
optional: [],
|
||||
chosen: [],
|
||||
});
|
||||
assert.strictEqual(r.detected, false, 'punctuation-only term must not produce a signal');
|
||||
assert.deepStrictEqual(r.terms.pluralization, []);
|
||||
});
|
||||
|
||||
test('CLI --terms "" (empty) restores curated defaults (does NOT disable pluralization)', () => {
|
||||
const res = spawnSync(process.execPath, [MODULE_PATH, '--terms', '', '--json'], {
|
||||
input: 'adds a second platform',
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.strictEqual(res.status, 0, 'empty --terms must fall back to defaults → detected');
|
||||
const parsed = JSON.parse(res.stdout);
|
||||
assert.strictEqual(parsed.detected, true);
|
||||
assert.ok(parsed.terms.pluralization.includes('second'), 'default pluralization cues restored');
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
|
||||
"gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
|
||||
"gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
|
||||
"gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "b7968e3e3af00249",
|
||||
"gsd-core/bin/gsd-tools.cjs": "74594e8bf36e5580",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
|
||||
"gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"gsd-core/CHANGELOG.md": "e141e3fb369ff712",
|
||||
"gsd-core/VERSION": "562368b20a64be95",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "46deb2174be356dd",
|
||||
"gsd-core/bin/gsd-tools.cjs": "5cf26d5f9e588cf8",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "65dea848d50969a2",
|
||||
|
||||
@@ -254,6 +254,7 @@ describe('plan:pre all-off — empty resolution', () => {
|
||||
pattern_mapper: false,
|
||||
schema_push_detection: false,
|
||||
plan_drift_precheck: false,
|
||||
assumption_delta: false,
|
||||
},
|
||||
intel: { enabled: false },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user