feat(scaffold): ADR-022 scaffold profiles (all phases)
Add profile-aware scaffold system so SF does not lay down irrelevant templates in infra/ops/docs repos. ## What ships Phase 1 — data model - scaffold-versioning.js: add 'disabled' to VALID_STATES; readScaffoldManifest returns profile field; recordScaffoldApply preserves manifest.profile (fixes roundtrip bug where profile was stripped on every write). - scaffold-constants.js: PROFILES (app/library/infra/docs/minimal as Set<string>) and PROFILE_NAMES exports. Phase 2 — profile-aware drift detection - scaffold-drift.js: disabled bucket in emptyCounts, resolveActiveProfileSet integration, profile param on detectScaffoldDrift/migrateLegacyScaffold. - doc-checker.js: filter to active profile, skip disabled-state files. Phase 3 — auto-detection on first run - scaffold-profiles.js: detectRepoProfile() heuristics (nix→infra, terraform→infra, react→app, node-no-ui→library, docs-only→docs, else→app). - agentic-docs-scaffold.js: reads profile from manifest, auto-detects on first run, persists to manifest, filters SCAFFOLD_FILES to active profile. Phase 4 — migrate command - commands-scaffold-migrate.js: sf scaffold migrate --profile <name> Re-enables pending files entering the new profile; stamps state=disabled (or prunes with --prune) files leaving it; warns on editing/completed files. - commands/handlers/ops.js, commands/catalog.js: registered and tab-completed. Phase 5 — custom profiles + PREFERENCES.md frontmatter - scaffold-profiles.js: readPreferencesProfile(), loadCustomProfileSet() (~/.sf/profiles/<name>.yaml with extends/add/remove), resolveActiveProfileSet() implementing full ADR-022 §6 precedence. - All callers updated to use resolveActiveProfileSet as the single source of truth. Tests: 28 new tests in adr-022-scaffold-profiles.test.mjs — all passing. Pre-existing node:test stubs (3 files) unaffected. ADR: docs/dev/ADR-022-scaffold-profiles.md Misc: triage TODO.md dump into BACKLOG.md (phases-helpers export error T1, /todo triage typed-handler gap T1, structured triage tiers T2, sha-track markdown files T2, cross-repo triage T3). Reset TODO.md to empty template. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ad53b792fb
commit
2bb9cdbeef
16 changed files with 1380 additions and 360 deletions
|
|
@ -1,50 +0,0 @@
|
||||||
id: default-safe
|
|
||||||
description: >-
|
|
||||||
Conservative default. Confirmations required for destructive
|
|
||||||
filesystem and git operations; network and exec allowed but logged.
|
|
||||||
|
|
||||||
capabilities:
|
|
||||||
filesystem:
|
|
||||||
read: allow
|
|
||||||
write: confirm
|
|
||||||
delete: confirm
|
|
||||||
exec:
|
|
||||||
enabled: confirm
|
|
||||||
network:
|
|
||||||
enabled: allow
|
|
||||||
allow_hosts:
|
|
||||||
- "*"
|
|
||||||
deny_hosts: []
|
|
||||||
mcp:
|
|
||||||
enabled: allow
|
|
||||||
|
|
||||||
paths:
|
|
||||||
allow:
|
|
||||||
- "**"
|
|
||||||
deny:
|
|
||||||
- "~/.ssh/**"
|
|
||||||
- "**/.env"
|
|
||||||
- "**/.env.*"
|
|
||||||
- "**/secrets/**"
|
|
||||||
- ".sf/sf.db"
|
|
||||||
- ".sf/sf.db-*"
|
|
||||||
- ".sf/backups/**"
|
|
||||||
redact:
|
|
||||||
- "**/*api_key*"
|
|
||||||
- "**/*token*"
|
|
||||||
- "**/*password*"
|
|
||||||
- "**/.env*"
|
|
||||||
|
|
||||||
confirmations:
|
|
||||||
requiredFor:
|
|
||||||
- rm -rf
|
|
||||||
- git push --force
|
|
||||||
- git push -f
|
|
||||||
- git reset --hard
|
|
||||||
- git clean -fdx
|
|
||||||
- drop_table
|
|
||||||
- drop_database
|
|
||||||
|
|
||||||
limits:
|
|
||||||
max_files_per_op: 100
|
|
||||||
max_command_runtime_sec: 600
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
id: yolo
|
|
||||||
description: >-
|
|
||||||
Confirmation-free policy applied when the YOLO flag is active
|
|
||||||
(Ctrl+Y / /mode yolo). YOLO is a flag layered on top of Build or
|
|
||||||
Autonomous — it is NOT a mode and does not appear as a Shift+Tab
|
|
||||||
stop. Destructive operations execute without prompting. Path denies
|
|
||||||
and redactions still apply.
|
|
||||||
|
|
||||||
capabilities:
|
|
||||||
filesystem:
|
|
||||||
read: allow
|
|
||||||
write: allow
|
|
||||||
delete: allow
|
|
||||||
exec:
|
|
||||||
enabled: allow
|
|
||||||
network:
|
|
||||||
enabled: allow
|
|
||||||
allow_hosts:
|
|
||||||
- "*"
|
|
||||||
deny_hosts: []
|
|
||||||
mcp:
|
|
||||||
enabled: allow
|
|
||||||
|
|
||||||
paths:
|
|
||||||
allow:
|
|
||||||
- "**"
|
|
||||||
deny:
|
|
||||||
- "~/.ssh/**"
|
|
||||||
- "**/.env"
|
|
||||||
- "**/.env.*"
|
|
||||||
- "**/secrets/**"
|
|
||||||
- ".sf/sf.db"
|
|
||||||
- ".sf/sf.db-*"
|
|
||||||
- ".sf/backups/**"
|
|
||||||
redact:
|
|
||||||
- "**/*api_key*"
|
|
||||||
- "**/*token*"
|
|
||||||
- "**/*password*"
|
|
||||||
- "**/.env*"
|
|
||||||
|
|
||||||
confirmations:
|
|
||||||
requiredFor: []
|
|
||||||
|
|
||||||
limits:
|
|
||||||
max_files_per_op: 1000
|
|
||||||
max_command_runtime_sec: 3600
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
# Base Prompt
|
|
||||||
|
|
||||||
You are an AI agent working in this repository. Before changing code:
|
|
||||||
|
|
||||||
1. Read the file you're editing in full.
|
|
||||||
2. Read related files (callers, callees, tests).
|
|
||||||
3. Match existing patterns and style.
|
|
||||||
4. Add or update tests for behavior changes.
|
|
||||||
|
|
||||||
Default to the smallest change that solves the problem. Prefer fixing
|
|
||||||
the root cause over patching the symptom. Surface uncertainties to the
|
|
||||||
operator rather than guessing.
|
|
||||||
57
BACKLOG.md
57
BACKLOG.md
|
|
@ -4,6 +4,63 @@ Items gated on future milestones or external dependencies.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phases-helpers extension-load error (pre-triage, T1)
|
||||||
|
|
||||||
|
- **Source:** TODO.md triage 2025-06
|
||||||
|
- **Symptom:** Every `sf …` invocation prints `Extension load error: './phases-helpers.js' does not provide an export named 'closeoutAndStop'`
|
||||||
|
- **Root cause:** Recent rename in `phases-helpers.js` not propagated to its importer(s); or `npm run copy-resources` shipped a partial state.
|
||||||
|
- **Fix:** Locate callers of `closeoutAndStop` in the extension source, update the import to the new symbol name. Add a test that imports every symbol from the extension entry point and asserts they all resolve.
|
||||||
|
- **Priority:** T1 — noisy on every run, degrades operator confidence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slash command `/todo triage` must route through typed backend (pre-triage, T1)
|
||||||
|
|
||||||
|
- **Source:** TODO.md triage 2025-06
|
||||||
|
- **Symptom:** `sf --print "/todo triage"` triggers the agent, which reads TODO.md and emits triage-shaped markdown, but never calls `handleTodo → triageTodoDump`. DB records never written; patched backend bypassed.
|
||||||
|
- **Fix:**
|
||||||
|
1. In the slash-command dispatch prompt, enumerate handlers and forbid the LLM from doing the work itself when a typed handler exists.
|
||||||
|
2. Add integration test: run `sf --print "/todo triage"` against a fixture TODO.md, assert `triage_runs` rows appear in `sf.db`.
|
||||||
|
- **Priority:** T1 — core correctness issue, not a UX polish.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Triage result needs structured tier/priority per item (pre-triage, T2)
|
||||||
|
|
||||||
|
- **Source:** TODO.md triage 2025-06
|
||||||
|
- **Problem:** Tiers (T1/T2/T3) appear only in LLM prose appended to `BUILD_PLAN.md`, not as structured fields per item. Blocks downstream automation that needs to escalate Tier-1 items to milestones.
|
||||||
|
- **Fix:** Extend triage JSON schema:
|
||||||
|
```ts
|
||||||
|
{ title: string, tier: "T1" | "T2" | "T3", rationale: string }
|
||||||
|
```
|
||||||
|
Update `appendBacklogItems` + future milestone-escalator to consume the structured tier.
|
||||||
|
- **Priority:** T2 — enables milestone automation; blocks `sf plan promote` from triage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sha-track source-of-truth markdown files, diff on change (pre-triage, T2)
|
||||||
|
|
||||||
|
- **Source:** TODO.md triage 2025-06
|
||||||
|
- **Want:** On session start + autonomous-cycle entry, hash `AGENTS.md`, `README.md`, `.sf/wiki/**/*.md`, `.sf/milestones/**/*.md`, `docs/adr/**/*.md`, `docs/plans/**/*.md`. Diff against last-seen hash in `sf.db`. Surface changed files for review/accept.
|
||||||
|
- **Schema:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE tracked_md_files (
|
||||||
|
relpath TEXT PRIMARY KEY, sha256 TEXT NOT NULL, size_bytes INTEGER NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL, last_seen_commit TEXT, category TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- **Out of scope:** `TODO.md`, `CHANGELOG.md`, `BUILD_PLAN.md`, `node_modules`, `dist`.
|
||||||
|
- **Priority:** T2 — high value for cross-agent coordination; deferred behind T1 fixes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-repo triage / unified backlog view (pre-triage, T3)
|
||||||
|
|
||||||
|
- **Source:** TODO.md triage 2025-06
|
||||||
|
- **Want:** `sf headless triage-all-repos --config ~/.sf/repos.yaml` — walk N repo paths, run `triageTodoDump` per repo in its own SF db, emit a unified read-only aggregated report sorted by priority/tier.
|
||||||
|
- **Constraints:** Per-repo SF dbs stay separate; cross-repo view is read-only aggregation into `~/.sf/cross-repo-view.md`.
|
||||||
|
- **Priority:** T3 — useful for multi-repo operators; deferred until T1/T2 items land.
|
||||||
|
|
||||||
## M009 Promote-Only Adoption Review
|
## M009 Promote-Only Adoption Review
|
||||||
|
|
||||||
- **Gate:** M010 (schedule system) must ship first
|
- **Gate:** M010 (schedule system) must ship first
|
||||||
|
|
|
||||||
195
TODO.md
195
TODO.md
|
|
@ -3,198 +3,3 @@
|
||||||
Dump anything here.
|
Dump anything here.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Cross-repo triage / unified backlog view
|
|
||||||
|
|
||||||
Today's dogfood: a scan across active repos found **~40 TODO.md files**
|
|
||||||
totalling **~10,000+ lines** across `/srv/infra`, `/srv/operations-memory`,
|
|
||||||
`/home/mhugo/code/singularity-engine` (27 subdir TODOs, 9 000+ lines),
|
|
||||||
`/home/mhugo/code/inference-fabric` (8 crate TODOs), plus per-repo
|
|
||||||
singletons in ace-coder, dks-web, vectordrive, centralcloud, etc.
|
|
||||||
|
|
||||||
The per-subdir files are **not noise** — most are substantive design
|
|
||||||
specs scoped to their domain/crate/service. Collapsing them into a
|
|
||||||
single root file would destroy useful structure.
|
|
||||||
|
|
||||||
The actual gap: **no single way to see "what's queued across all the
|
|
||||||
repos" at once.** Today this requires walking N repos by hand.
|
|
||||||
|
|
||||||
Wanted:
|
|
||||||
|
|
||||||
```
|
|
||||||
sf headless triage-all-repos --config ~/.sf/repos.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Where `~/.sf/repos.yaml` is a list of repo paths and (optional) per-repo
|
|
||||||
priority. For each repo:
|
|
||||||
1. If `TODO.md` has non-template content, run `triageTodoDump` in that
|
|
||||||
repo's SF db.
|
|
||||||
2. After all repos triaged, emit a unified report: one row per backlog
|
|
||||||
item across all repos, sortable by priority / tier / inserted_at.
|
|
||||||
3. Optionally produce a single `~/.sf/cross-repo-view.md` for quick
|
|
||||||
human reading.
|
|
||||||
|
|
||||||
Per-repo SF dbs stay separate (each repo owns its work); the cross-repo
|
|
||||||
view is read-only aggregation.
|
|
||||||
|
|
||||||
## Slash command `/todo triage` should actually invoke the typed backend
|
|
||||||
|
|
||||||
Observed today: `sf --print "/todo triage"` ran the agent, which read
|
|
||||||
TODO.md and emitted a triage-shaped markdown response, but the agent
|
|
||||||
**did not call `handleTodo` → `triageTodoDump`** — it re-implemented the
|
|
||||||
flow in natural language via Read/Write tools. Side effect: a patched
|
|
||||||
backend in `commands-todo.js` was bypassed entirely.
|
|
||||||
|
|
||||||
Wanted: when a slash command has a registered typed handler in the
|
|
||||||
extension surface (i.e. `handleTodo`, `handleNewMilestone`, …), the
|
|
||||||
agent's prompt should *require* the call go through that handler rather
|
|
||||||
than letting the LLM improvise. The handler can be invoked as a tool
|
|
||||||
call so the LLM still has narrative space, but the side effects (DB
|
|
||||||
writes, file scaffolds, etc.) come from the typed path, not from raw
|
|
||||||
Write/Edit on TODO.md.
|
|
||||||
|
|
||||||
Concretely:
|
|
||||||
|
|
||||||
- In `slash-commands.md` (or wherever the slash dispatch prompt lives),
|
|
||||||
enumerate handlers and forbid the LLM from "doing the work" itself
|
|
||||||
when a typed handler exists.
|
|
||||||
- Add an integration test that runs `sf --print "/todo triage"` against
|
|
||||||
a fixture TODO.md and asserts that `triage_runs` rows appear in
|
|
||||||
`sf.db` (i.e. the backend ran, not just the LLM).
|
|
||||||
|
|
||||||
## Triage result needs structured tier/priority per item
|
|
||||||
|
|
||||||
Current shape:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
result.implementation_tasks: string[] // titles only
|
|
||||||
result.memory_requirements: string[]
|
|
||||||
result.harness_suggestions: string[]
|
|
||||||
result.docs_or_tests: string[]
|
|
||||||
result.unclear_notes: string[]
|
|
||||||
result.eval_candidates: { id, task_input, expected_behavior, … }[]
|
|
||||||
```
|
|
||||||
|
|
||||||
Tiers (T1 / T2 / T3) appear only in the LLM-prose tier list it appends
|
|
||||||
to `BUILD_PLAN.md`. They are **not** present as a structured field per
|
|
||||||
item. That blocks any downstream "for each Tier-1 item, scaffold a
|
|
||||||
milestone" automation — the tier info is locked in prose.
|
|
||||||
|
|
||||||
Wanted: extend the triage JSON schema so each implementation task is
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{ title: string, tier: "T1" | "T2" | "T3", rationale: string }
|
|
||||||
```
|
|
||||||
|
|
||||||
and update `appendBacklogItems` + a future milestone-escalator to read
|
|
||||||
the structured tier rather than re-parsing markdown.
|
|
||||||
|
|
||||||
## Sha-track every source-of-truth markdown file, diff on change
|
|
||||||
|
|
||||||
Generalised from the milestone-files case: **any markdown file that is
|
|
||||||
a source of truth for SF or for humans navigating the repo** should
|
|
||||||
be sha-tracked, and any change since SF last saw it should surface as
|
|
||||||
a diff for review (or auto-accept under a configured policy).
|
|
||||||
|
|
||||||
In scope (per repo):
|
|
||||||
|
|
||||||
- **Repo-level meta** — `AGENTS.md`, `README.md`, `STATUS.md`,
|
|
||||||
`BACKLOG.md`, `STANDALONE.md`, `MIGRATION.md`, etc. (any uppercase
|
|
||||||
root-level `.md`)
|
|
||||||
- **Pointer** — `.github/copilot-instructions.md`
|
|
||||||
- **Wiki** — `.sf/wiki/**/*.md`
|
|
||||||
- **Planning** — `.sf/milestones/**/*.md` (`CONTEXT`, `MILESTONE-SUMMARY`,
|
|
||||||
`ROADMAP`, `SUMMARY` per milestone; `PLAN` / `SUMMARY` per slice; same
|
|
||||||
per task)
|
|
||||||
- **ADRs** — `docs/adr/**/*.md` (these should rarely change, so any
|
|
||||||
edit is loud and worth surfacing)
|
|
||||||
- **Triage outputs** — `docs/plans/**/*.md`
|
|
||||||
|
|
||||||
Explicit out of scope:
|
|
||||||
|
|
||||||
- `TODO.md` — gets reset to empty template by `/todo triage` on every
|
|
||||||
cycle; tracking churn here is just noise.
|
|
||||||
- `CHANGELOG.md` / `BUILD_PLAN.md` — append-only by design; sha churn
|
|
||||||
is expected, no signal in tracking.
|
|
||||||
- `node_modules`, `dist`, vendored copies — irrelevant.
|
|
||||||
|
|
||||||
Storage in `sf.db` — sha + git ref, no content snapshots. Git is the
|
|
||||||
version store; the DB is just a pointer:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE tracked_md_files (
|
|
||||||
relpath TEXT PRIMARY KEY, -- repo-relative path
|
|
||||||
sha256 TEXT NOT NULL, -- hash of last-seen content
|
|
||||||
size_bytes INTEGER NOT NULL,
|
|
||||||
last_seen_at TEXT NOT NULL,
|
|
||||||
last_seen_commit TEXT, -- git SHA1 of HEAD when observed
|
|
||||||
category TEXT -- 'meta'|'wiki'|'milestone'|'adr'|'plan'
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Diff source priority:
|
|
||||||
|
|
||||||
1. **Tracked + committed at observation** (the common case):
|
|
||||||
`git diff <last_seen_commit> -- <path>` shows everything since.
|
|
||||||
Cheap, no blob, perfect history via `git log <path>` if needed.
|
|
||||||
|
|
||||||
2. **Tracked + uncommitted at observation** (mid-edit corner): no git
|
|
||||||
ref points at that exact content. Diff shows "changed since
|
|
||||||
`<last_seen_commit>`" but the prior intermediate working-tree state
|
|
||||||
isn't reconstructable. Acceptable trade-off — the main signal is
|
|
||||||
"changed", and the operator can commit before letting SF observe
|
|
||||||
if intermediate fidelity matters.
|
|
||||||
|
|
||||||
3. **Untracked / gitignored**: not tracked in this table. SF-generated
|
|
||||||
transient files don't belong in version control or in this audit.
|
|
||||||
|
|
||||||
History per file = `git log <relpath>` (already there, free). SF's DB
|
|
||||||
just records "where I left off." No `md_observation_log` history
|
|
||||||
table unless someone has a concrete need for an SF-side timeline.
|
|
||||||
|
|
||||||
On session start + each autonomous-cycle entry, walk the configured
|
|
||||||
glob set, hash each file, diff against `tracked_md_files.sha256`.
|
|
||||||
For each changed file:
|
|
||||||
|
|
||||||
1. Surface to operator: "**N** files changed since SF last saw — review
|
|
||||||
or accept?" with per-file diff (computed from git, not from a DB
|
|
||||||
blob).
|
|
||||||
2. On accept → update sha + last_seen_at. No content stored.
|
|
||||||
3. New files (sha not in DB) → classify by glob category, store sha,
|
|
||||||
continue.
|
|
||||||
4. Deleted files → archive the DB row (mark inactive); don't purge
|
|
||||||
until operator confirms.
|
|
||||||
|
|
||||||
Useful for:
|
|
||||||
- hand-edits / cross-agent edits / git pulls (the original
|
|
||||||
milestone-files motivation)
|
|
||||||
- catching when an AGENTS.md drifted because someone edited it during
|
|
||||||
a code review and nobody told SF
|
|
||||||
- ADR drift detection — ADRs should almost never change; if one does,
|
|
||||||
surface it loudly
|
|
||||||
- treating `.sf/wiki/*` as living docs that need review when they
|
|
||||||
drift from what `sf` has internalised
|
|
||||||
|
|
||||||
Storage cost: ~40 bytes per file (sha + meta) + optional gzipped
|
|
||||||
snapshot (typically 30-70 % of original size). Negligible vs. the
|
|
||||||
rest of `sf.db`.
|
|
||||||
|
|
||||||
## Phases-helpers extension-load error on every SF run
|
|
||||||
|
|
||||||
Every `sf …` invocation today prints:
|
|
||||||
|
|
||||||
```
|
|
||||||
[sf] Extension load error Error: Failed to load extension
|
|
||||||
"/home/mhugo/.sf/agent/extensions/sf/index.js": The requested module
|
|
||||||
'./phases-helpers.js' does not provide an export named 'closeoutAndStop'
|
|
||||||
```
|
|
||||||
|
|
||||||
Non-fatal (SF continues), but noisy and a sign of stale state. Either:
|
|
||||||
|
|
||||||
- A recent rename of `closeoutAndStop` in `phases-helpers.js` wasn't
|
|
||||||
propagated to its caller, and `npm run copy-resources` quietly shipped
|
|
||||||
the partial state, or
|
|
||||||
- A test gap doesn't catch missing exports from `phases-helpers.js`.
|
|
||||||
|
|
||||||
Add an import-time sanity check (or a test that imports every entry
|
|
||||||
in the extension index and asserts all required symbols resolve).
|
|
||||||
|
|
|
||||||
245
docs/dev/ADR-022-scaffold-profiles.md
Normal file
245
docs/dev/ADR-022-scaffold-profiles.md
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
# ADR-022: Scaffold Profiles
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Date:** 2026-05-12
|
||||||
|
**Deciders:** Mikael Hugo
|
||||||
|
**Extends:** [ADR-021 — Versioned Documents and Upgrade Path](ADR-021-versioned-documents-and-upgrade-path.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-021 introduced a per-file state machine (`pending` / `editing` / `completed`),
|
||||||
|
version markers, drift detection, and an automatic upgrade pipeline for the
|
||||||
|
`SCAFFOLD_FILES` template set. That set is a single flat list tuned for a
|
||||||
|
code-app shaped repo.
|
||||||
|
|
||||||
|
When SF is bootstrapped into a non-code-shaped repo — infra, ops, GitOps,
|
||||||
|
documentation-only — it writes ~60–70 % of templates that don't apply. Files
|
||||||
|
like `docs/FRONTEND.md`, `docs/DESIGN.md`, and `docs/PRODUCT_SENSE.md` have no
|
||||||
|
meaning in a Kubernetes config repo. They sit at `state=pending` forever,
|
||||||
|
polluting the agent's scaffold context and producing spurious `upgradable`
|
||||||
|
findings in `/sf doctor` on every run.
|
||||||
|
|
||||||
|
SF is being adopted in non-code repos. Each new repo type that doesn't fit the
|
||||||
|
`app` profile creates the same friction without a general solution.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce a **profile** system for scaffold file sets. A profile is a named
|
||||||
|
subset of `SCAFFOLD_FILES` describing which templates apply to a given repo
|
||||||
|
shape. Add a `disabled` state to the ADR-021 state machine for templates that
|
||||||
|
are explicitly out-of-scope for the active profile.
|
||||||
|
|
||||||
|
### 1. Built-in profiles
|
||||||
|
|
||||||
|
Defined in `scaffold-constants.js`:
|
||||||
|
|
||||||
|
| Profile | Included templates | Typical repo shape |
|
||||||
|
|---------|-------------------|-------------------|
|
||||||
|
| `app` | Full `SCAFFOLD_FILES` list | Product or CLI with UI, tests, frontend |
|
||||||
|
| `library` | `app` minus `docs/FRONTEND.md`, `docs/PRODUCT_SENSE.md`, `docs/DESIGN.md` | Library, SDK, CLI tool without UI |
|
||||||
|
| `infra` | AGENTS.md, ARCHITECTURE.md, .siftignore, docs/SECURITY.md, docs/RELIABILITY.md, docs/records/**, .sf/PRINCIPLES.md, .sf/STYLE.md, .sf/NON-GOALS.md, .sf/harness/** | GitOps, Kubernetes, Flux, Helm, Terraform |
|
||||||
|
| `docs` | AGENTS.md, .sf/STYLE.md | Pure documentation repo |
|
||||||
|
| `minimal` | .siftignore, AGENTS.md | Any repo; smallest footprint |
|
||||||
|
|
||||||
|
`app` is the default (preserves existing behaviour for code repos).
|
||||||
|
|
||||||
|
Profiles are expressed as sets of template paths from `SCAFFOLD_FILES`. The
|
||||||
|
`PROFILES` object maps profile names to `Set<string>`. A file not in the active
|
||||||
|
profile is treated as if it were absent from `SCAFFOLD_FILES` for all scaffold
|
||||||
|
operations.
|
||||||
|
|
||||||
|
### 2. `disabled` state (4th ADR-021 state)
|
||||||
|
|
||||||
|
A new state alongside `pending` / `editing` / `completed`:
|
||||||
|
|
||||||
|
| State | Definition | SF action on drift |
|
||||||
|
|-------|------------|--------------------|
|
||||||
|
| `disabled` | Template does not apply to this repo. Written by migrate command or manually. | Never created. Never modified. Treated as out-of-scope. |
|
||||||
|
|
||||||
|
The marker format is unchanged:
|
||||||
|
```
|
||||||
|
<!-- sf-doc: version=2.75.x template=docs/FRONTEND.md state=disabled hash=sha256:… -->
|
||||||
|
```
|
||||||
|
|
||||||
|
`disabled` is valid in `VALID_STATES`. Any existing code path that calls
|
||||||
|
`parseMarker` on a disabled file will parse it correctly (not fall through to
|
||||||
|
`untracked`).
|
||||||
|
|
||||||
|
#### State derivation (extends ADR-021 §1 table)
|
||||||
|
|
||||||
|
| Marker? | state field | Body hash = marker.hash? | → State |
|
||||||
|
|---------|-------------|--------------------------|---------|
|
||||||
|
| yes | `disabled` | any | `disabled` |
|
||||||
|
| (existing ADR-021 rows) | … | … | … |
|
||||||
|
|
||||||
|
#### What `disabled` means for drift detection
|
||||||
|
|
||||||
|
- Files with `state=disabled` → `disabled` drift bucket. Never `missing`,
|
||||||
|
`upgradable`, or `editing-drift`.
|
||||||
|
- Files not in the active profile that don't exist on disk → skipped entirely.
|
||||||
|
Not reported as `missing`. SF does not write them.
|
||||||
|
- `/sf doctor` does not count `disabled` files as actionable. The doctor
|
||||||
|
finding counts only `missing + upgradable + editing-drift`.
|
||||||
|
|
||||||
|
### 3. Profile storage
|
||||||
|
|
||||||
|
Profile is stored in `.sf/scaffold-manifest.json` under a new `profile` field.
|
||||||
|
Added **additively** — no `schemaVersion` bump (a bump would wipe existing
|
||||||
|
`applied` records on first upgrade).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"profile": "infra",
|
||||||
|
"applied": [ … ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`readScaffoldManifest` returns `profile: parsed.profile ?? null`.
|
||||||
|
`recordScaffoldApply` preserves the `profile` field on every write (prior
|
||||||
|
implementation stripped all fields beyond `schemaVersion` + `applied`).
|
||||||
|
|
||||||
|
### 4. Profile auto-detection
|
||||||
|
|
||||||
|
`detectRepoProfile(basePath)` returns the best-fit built-in profile for a repo
|
||||||
|
it has never seen before. Runs once on first scaffold; result stored in manifest.
|
||||||
|
|
||||||
|
Detection logic (uses `repo-profiler.js` / `detectStacks()` as signal source):
|
||||||
|
|
||||||
|
| Signal | → Profile |
|
||||||
|
|--------|-----------|
|
||||||
|
| `kustomization.yaml`, `flux-system/`, `Chart.yaml`, or `helmrelease.yaml` at root | `infra` |
|
||||||
|
| `flake.nix` / `shell.nix` (nix stack) with no `package.json` | `infra` |
|
||||||
|
| `package.json` with UI framework dep (`next`, `vite`, `remix`, `sveltekit`, `@sveltejs/kit`, `nuxt`) | `app` |
|
||||||
|
| `package.json` without UI framework dep | `library` |
|
||||||
|
| `Cargo.toml`, `go.mod`, or `pyproject.toml` | `library` |
|
||||||
|
| No source files, only `.md` docs | `docs` |
|
||||||
|
| No matching signals | `app` (default) |
|
||||||
|
|
||||||
|
Signals are evaluated top-to-bottom; first match wins. The manifest `profile`
|
||||||
|
field overrides auto-detection on all subsequent runs (explicit beats inferred).
|
||||||
|
|
||||||
|
`ensureAgenticDocsScaffold` reads the active profile from the manifest (or
|
||||||
|
auto-detects it on first run) and filters `SCAFFOLD_FILES` to only those paths
|
||||||
|
in the active profile before writing any files.
|
||||||
|
|
||||||
|
### 5. `sf scaffold migrate --profile <name> [--prune]`
|
||||||
|
|
||||||
|
Manual command for repos already bootstrapped under the wrong profile.
|
||||||
|
|
||||||
|
```
|
||||||
|
sf scaffold migrate --profile infra [--prune]
|
||||||
|
```
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
|
||||||
|
1. Read existing markers + manifest. Validate target profile name.
|
||||||
|
2. **Re-enable step** — for each file IN the target profile with `state=disabled`
|
||||||
|
on disk:
|
||||||
|
- If `bodyHash(body) === marker.hash` (no user edits since disable):
|
||||||
|
re-stamp `state=pending`.
|
||||||
|
- If hash diverged (user edited the disabled file): warn, leave `disabled`.
|
||||||
|
3. **Disable step** — for each file NOT in the target profile:
|
||||||
|
- Compute `bodyHash(body)` from on-disk content.
|
||||||
|
- If `state=pending` AND `bodyHash(body) === marker.hash` (no user edits):
|
||||||
|
re-stamp `state=disabled`.
|
||||||
|
- If `state=pending` AND hash diverged: treat as editing-drift — warn, leave
|
||||||
|
alone. (User has edits; SF will not silently disable them.)
|
||||||
|
- If `state=editing` or `state=completed`: warn, leave alone.
|
||||||
|
- If `--prune` AND `state=pending` AND `bodyHash(body) === marker.hash`:
|
||||||
|
delete the file. The same hash guard applies — prune never deletes
|
||||||
|
user-edited files.
|
||||||
|
4. Update `scaffold-manifest.json` `profile` field to target profile.
|
||||||
|
5. Run drift-aware sync for files in the new profile (applies `missing` and
|
||||||
|
`upgradable` items).
|
||||||
|
|
||||||
|
Migrate is idempotent: running it twice produces the same result.
|
||||||
|
|
||||||
|
### 6. Profile precedence
|
||||||
|
|
||||||
|
Multiple sources may specify a profile. Precedence (highest first):
|
||||||
|
|
||||||
|
1. **`PREFERENCES.md` frontmatter** — `sf_profile: infra` (Phase 5; explicit
|
||||||
|
user override, per-repo, committed to git)
|
||||||
|
2. **Manifest `profile` field** — set by migrate or first-run auto-detection
|
||||||
|
(per-repo runtime, gitignored)
|
||||||
|
3. **Auto-detection** — `detectRepoProfile(basePath)` result (fallback)
|
||||||
|
|
||||||
|
This rule is defined now so Phase 5 (custom profiles) has a clear contract.
|
||||||
|
|
||||||
|
### 7. Custom profiles (Phase 5)
|
||||||
|
|
||||||
|
Future work. Custom profiles extend built-ins:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.sf/profiles/monorepo.yaml
|
||||||
|
extends: library
|
||||||
|
add:
|
||||||
|
- docs/DESIGN.md
|
||||||
|
remove:
|
||||||
|
- docs/exec-plans/active/index.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolved at profile load time. Phase 5 must document how `PREFERENCES.md`
|
||||||
|
frontmatter references a custom profile name (precedence rule §6 already
|
||||||
|
establishes where they fit).
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
| Phase | Scope | Constraint |
|
||||||
|
|-------|-------|------------|
|
||||||
|
| **1** | `PROFILES` constant; `disabled` in `VALID_STATES`; manifest `profile` field round-trip fix | Must ship atomically with Phases 2 and 4 |
|
||||||
|
| **2** | Profile-aware `detectScaffoldDrift`; `disabled` bucket; profile-filtered `migrateLegacyScaffold`; update 3 unlisted callers; `doc-checker.js` fix | Must ship atomically with Phases 1 and 4 |
|
||||||
|
| **3** | `detectRepoProfile`; profile-filtered `ensureAgenticDocsScaffold` | Depends on Phases 1 + 2 |
|
||||||
|
| **4** | `sf scaffold migrate --profile <name> [--prune]` command | Must ship atomically with Phases 1 and 2 |
|
||||||
|
| **5** | Custom profiles (`~/.sf/profiles/*.yaml`); PREFERENCES.md frontmatter | Depends on Phase 4 |
|
||||||
|
|
||||||
|
Phases 1 + 2 + 4 must ship in the same release. The `disabled` state is not
|
||||||
|
parseable by `parseMarker` until Phase 1 lands; any code that stamps `disabled`
|
||||||
|
before Phase 1 ships causes affected files to fall into the `untracked` bucket
|
||||||
|
and be silently re-rendered on the next `ensureAgenticDocsScaffold` call.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Becomes possible
|
||||||
|
|
||||||
|
- SF can be bootstrapped into any repo shape without laying down irrelevant
|
||||||
|
templates.
|
||||||
|
- Existing over-scaffolded repos can be cleaned up with a single migrate
|
||||||
|
command.
|
||||||
|
- Profile auto-detection removes the need for explicit `sf new-project --profile
|
||||||
|
infra` flags in the common case.
|
||||||
|
- `disabled` state is a clean per-file escape hatch without forking the profile.
|
||||||
|
|
||||||
|
### Becomes harder
|
||||||
|
|
||||||
|
- Profile set and `SCAFFOLD_FILES` must stay in sync; a template added to
|
||||||
|
`SCAFFOLD_FILES` needs explicit placement in every affected built-in profile.
|
||||||
|
- `detectRepoProfile` heuristics will misclassify hybrid repos (a repo with
|
||||||
|
both `package.json` and `kustomization.yaml`). Fallback is `app`; migrate
|
||||||
|
exists for correction.
|
||||||
|
|
||||||
|
### Failure modes
|
||||||
|
|
||||||
|
| Failure | Behaviour |
|
||||||
|
|---------|-----------|
|
||||||
|
| Unknown profile name in manifest | Fall back to `app`; log warning. |
|
||||||
|
| `detectRepoProfile` misclassifies repo | User runs `sf scaffold migrate --profile <correct>`. One-time correction. |
|
||||||
|
| Hash divergence on a `disabled` file during migrate | Warn; leave alone. User must manually resolve or mark `completed`. |
|
||||||
|
| Manifest `profile` field missing (pre-ADR-022 manifest) | `null` → fall back to auto-detection on next scaffold run. |
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
| Alternative | Why rejected |
|
||||||
|
|-------------|--------------|
|
||||||
|
| Per-file `applicable: false` flag in `SCAFFOLD_FILES` | Static; can't adapt to different repo shapes without forking the array. |
|
||||||
|
| Delete out-of-profile files on migrate | Destructive for `editing`/`completed` files; `disabled` with `--prune` for `pending` is safer and reversible. |
|
||||||
|
| Auto-migrate on every startup | Surprising; migrate is an explicit user action that changes profile context. Auto-detection for first scaffold only. |
|
||||||
|
| Single `minimal` profile as the new default | Breaks existing repos; `app` as default preserves current behaviour. |
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- ADR-021 — Versioned Documents and Upgrade Path (§1 state machine, §3 manifest,
|
||||||
|
§4 drift detection, §7 legacy migration — all extended here)
|
||||||
|
- `src/resources/extensions/sf/repo-profiler.js` — `detectStacks()` signal
|
||||||
|
source for `detectRepoProfile`
|
||||||
|
|
@ -10,11 +10,14 @@ import {
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { SCAFFOLD_FILES } from "./scaffold-constants.js";
|
import { SCAFFOLD_FILES } from "./scaffold-constants.js";
|
||||||
import { migrateLegacyScaffold } from "./scaffold-drift.js";
|
import { migrateLegacyScaffold } from "./scaffold-drift.js";
|
||||||
|
import { resolveActiveProfileSet, readPreferencesProfile, detectRepoProfile } from "./scaffold-profiles.js";
|
||||||
import {
|
import {
|
||||||
bodyHash,
|
bodyHash,
|
||||||
extractMarker,
|
extractMarker,
|
||||||
|
readScaffoldManifest,
|
||||||
recordScaffoldApply,
|
recordScaffoldApply,
|
||||||
stampScaffoldFile,
|
stampScaffoldFile,
|
||||||
|
writeScaffoldManifest,
|
||||||
} from "./scaffold-versioning.js";
|
} from "./scaffold-versioning.js";
|
||||||
import { logWarning } from "./workflow-logger.js";
|
import { logWarning } from "./workflow-logger.js";
|
||||||
|
|
||||||
|
|
@ -26,6 +29,9 @@ export { SCAFFOLD_FILES };
|
||||||
* wrote them, so legacy-hash migration in Phase C can identify them.
|
* wrote them, so legacy-hash migration in Phase C can identify them.
|
||||||
*/
|
*/
|
||||||
const NO_MARKER_PATHS = new Set([".siftignore"]);
|
const NO_MARKER_PATHS = new Set([".siftignore"]);
|
||||||
|
|
||||||
|
export { detectRepoProfile };
|
||||||
|
|
||||||
const LEGACY_ROOT_HARNESS_PATHS = [
|
const LEGACY_ROOT_HARNESS_PATHS = [
|
||||||
"harness/AGENTS.md",
|
"harness/AGENTS.md",
|
||||||
"harness/specs/AGENTS.md",
|
"harness/specs/AGENTS.md",
|
||||||
|
|
@ -97,19 +103,21 @@ function removeLegacyRootHarnessScaffold(basePath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drift-aware scaffold sync (ADR-021 Phase C).
|
* Drift-aware scaffold sync (ADR-021 Phase C, extended by ADR-022).
|
||||||
*
|
*
|
||||||
* Behavior:
|
* Behavior:
|
||||||
* 1. Run legacy migration first — unmarked files whose body hash matches a
|
* 1. Read the active profile from the manifest. On first run, auto-detect
|
||||||
|
* the profile and write it to the manifest so future runs are stable.
|
||||||
|
* 2. Run legacy migration first — unmarked files whose body hash matches a
|
||||||
* known prior version in SCAFFOLD_VERSION_ARCHIVE get promoted to pending
|
* known prior version in SCAFFOLD_VERSION_ARCHIVE get promoted to pending
|
||||||
* and stamped. Handles projects that pre-date the marker system.
|
* and stamped. Handles projects that pre-date the marker system.
|
||||||
* 2. For each scaffold template:
|
* 3. For each scaffold template IN the active profile:
|
||||||
* - Missing on disk → write template, stamp marker, record manifest entry.
|
* - Missing on disk → write template, stamp marker, record manifest entry.
|
||||||
* - Present, marker, state=pending, version drifted, hash matches stamp →
|
* - Present, marker, state=pending, version drifted, hash matches stamp →
|
||||||
* silent re-render with current template, restamp.
|
* silent re-render with current template, restamp.
|
||||||
* - Present, marker says editing or completed → leave alone (Phase D
|
* - Present, marker says editing, completed, or disabled → leave alone.
|
||||||
* handles editing-drift via the scaffold-keeper background agent).
|
|
||||||
* - Present without marker after migration → user-customised, leave alone.
|
* - Present without marker after migration → user-customised, leave alone.
|
||||||
|
* Files not in the active profile are skipped entirely.
|
||||||
*
|
*
|
||||||
* Silent contract: no stdout/stderr in normal paths. Only logWarning("scaffold")
|
* Silent contract: no stdout/stderr in normal paths. Only logWarning("scaffold")
|
||||||
* for unexpected I/O failures. Failure modes are non-fatal.
|
* for unexpected I/O failures. Failure modes are non-fatal.
|
||||||
|
|
@ -117,9 +125,24 @@ function removeLegacyRootHarnessScaffold(basePath) {
|
||||||
export function ensureAgenticDocsScaffold(basePath) {
|
export function ensureAgenticDocsScaffold(basePath) {
|
||||||
const sfVersion = process.env.SF_VERSION || "0.0.0";
|
const sfVersion = process.env.SF_VERSION || "0.0.0";
|
||||||
const appliedAt = new Date().toISOString();
|
const appliedAt = new Date().toISOString();
|
||||||
|
const manifest = readScaffoldManifest(basePath);
|
||||||
|
// PREFERENCES.md frontmatter takes highest precedence (ADR-022 §6).
|
||||||
|
// If no profile is set anywhere, auto-detect and persist to manifest.
|
||||||
|
let { profileName: activeProfile, profileSet, warning } = resolveActiveProfileSet(basePath, manifest, null);
|
||||||
|
if (warning) {
|
||||||
|
logWarning("scaffold", warning, {});
|
||||||
|
}
|
||||||
|
if (!manifest.profile && !readPreferencesProfile(basePath)) {
|
||||||
|
// First run — persist auto-detected profile to manifest.
|
||||||
|
try {
|
||||||
|
writeScaffoldManifest(basePath, { ...manifest, profile: activeProfile });
|
||||||
|
} catch (err) {
|
||||||
|
logWarning("scaffold", "failed to write profile to manifest", { error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
// Step 1: legacy migration — promote unmarked-but-recognised files.
|
// Step 1: legacy migration — promote unmarked-but-recognised files.
|
||||||
try {
|
try {
|
||||||
migrateLegacyScaffold(basePath);
|
migrateLegacyScaffold(basePath, activeProfile);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logWarning("scaffold", "legacy migration failed", {
|
logWarning("scaffold", "legacy migration failed", {
|
||||||
error: err.message,
|
error: err.message,
|
||||||
|
|
@ -128,6 +151,8 @@ export function ensureAgenticDocsScaffold(basePath) {
|
||||||
removeLegacyRootHarnessScaffold(basePath);
|
removeLegacyRootHarnessScaffold(basePath);
|
||||||
// Step 2: missing-file creation + pending-state silent upgrade.
|
// Step 2: missing-file creation + pending-state silent upgrade.
|
||||||
for (const file of SCAFFOLD_FILES) {
|
for (const file of SCAFFOLD_FILES) {
|
||||||
|
// ADR-022: skip files that are not in the active profile.
|
||||||
|
if (!profileSet.has(file.path)) continue;
|
||||||
const target = join(basePath, file.path);
|
const target = join(basePath, file.path);
|
||||||
const skipMarker = NO_MARKER_PATHS.has(file.path);
|
const skipMarker = NO_MARKER_PATHS.has(file.path);
|
||||||
if (!existsSync(target)) {
|
if (!existsSync(target)) {
|
||||||
|
|
@ -161,7 +186,7 @@ export function ensureAgenticDocsScaffold(basePath) {
|
||||||
try {
|
try {
|
||||||
const { marker, body } = extractMarker(target);
|
const { marker, body } = extractMarker(target);
|
||||||
if (!marker) continue; // untracked / customised after migration — leave alone
|
if (!marker) continue; // untracked / customised after migration — leave alone
|
||||||
if (marker.state !== "pending") continue; // editing or completed — Phase D territory
|
if (marker.state !== "pending") continue; // editing, completed, or disabled — leave alone
|
||||||
if (marker.version === sfVersion) continue; // already current
|
if (marker.version === sfVersion) continue; // already current
|
||||||
// Confirm on-disk hash matches the stamped hash. If diverged, the
|
// Confirm on-disk hash matches the stamped hash. If diverged, the
|
||||||
// file was edited without removing the marker — treat as editing-drift
|
// file was edited without removing the marker — treat as editing-drift
|
||||||
|
|
|
||||||
225
src/resources/extensions/sf/commands-scaffold-migrate.js
Normal file
225
src/resources/extensions/sf/commands-scaffold-migrate.js
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
/**
|
||||||
|
* commands-scaffold-migrate.js — `/scaffold migrate --profile <name>` (ADR-022).
|
||||||
|
*
|
||||||
|
* Purpose: transition an existing repo to a different scaffold profile, safely.
|
||||||
|
* Files leaving the active profile are stamped `state=disabled` (or pruned with
|
||||||
|
* `--prune`). Files re-entering the profile have their `state=disabled` reversed
|
||||||
|
* to `state=pending`. Files the user has edited or completed are left alone with
|
||||||
|
* a warning.
|
||||||
|
*
|
||||||
|
* Consumer: user-triggered via `/scaffold migrate --profile infra` after
|
||||||
|
* onboarding SF into a non-code-shaped repo.
|
||||||
|
*/
|
||||||
|
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { detectRepoProfile } from "./agentic-docs-scaffold.js";
|
||||||
|
import { projectRoot } from "./commands/context.js";
|
||||||
|
import { PROFILE_NAMES, SCAFFOLD_FILES } from "./scaffold-constants.js";
|
||||||
|
import { detectScaffoldDrift } from "./scaffold-drift.js";
|
||||||
|
import { resolveActiveProfileSet } from "./scaffold-profiles.js";
|
||||||
|
import {
|
||||||
|
bodyHash,
|
||||||
|
extractMarker,
|
||||||
|
readScaffoldManifest,
|
||||||
|
stampScaffoldFile,
|
||||||
|
writeScaffoldManifest,
|
||||||
|
} from "./scaffold-versioning.js";
|
||||||
|
|
||||||
|
/** Parse args for `/scaffold migrate`. */
|
||||||
|
export function parseScaffoldMigrateArgs(args) {
|
||||||
|
const trimmed = (args || "").trim();
|
||||||
|
const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : [];
|
||||||
|
const opts = {
|
||||||
|
profile: null,
|
||||||
|
prune: false,
|
||||||
|
dryRun: false,
|
||||||
|
};
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (tok.startsWith("--profile=")) {
|
||||||
|
opts.profile = tok.slice("--profile=".length).trim() || null;
|
||||||
|
} else if (tok === "--prune") {
|
||||||
|
opts.prune = true;
|
||||||
|
} else if (tok === "--dry-run") {
|
||||||
|
opts.dryRun = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core migrate algorithm (ADR-022 §5).
|
||||||
|
*
|
||||||
|
* Returns a result object describing what was changed and what needs review.
|
||||||
|
* Does not mutate the filesystem when `dryRun` is true.
|
||||||
|
*
|
||||||
|
* @param {string} basePath
|
||||||
|
* @param {string} targetProfile
|
||||||
|
* @param {{ prune?: boolean, dryRun?: boolean }} opts
|
||||||
|
*/
|
||||||
|
export function runScaffoldMigrate(basePath, targetProfile, opts = {}) {
|
||||||
|
const { prune = false, dryRun = false } = opts;
|
||||||
|
const sfVersion = process.env.SF_VERSION || "0.0.0";
|
||||||
|
const manifest = readScaffoldManifest(basePath);
|
||||||
|
const { profileSet: targetSet, warning } = resolveActiveProfileSet(basePath, manifest, targetProfile);
|
||||||
|
const result = {
|
||||||
|
reEnabled: [], // state=disabled → state=pending (re-entered profile)
|
||||||
|
disabled: [], // state=pending → state=disabled (left profile)
|
||||||
|
pruned: [], // deleted with --prune
|
||||||
|
warnings: [], // editing/completed/hash-diverged — left alone
|
||||||
|
};
|
||||||
|
if (warning) {
|
||||||
|
result.warnings.push({ path: "(profile)", reason: warning });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: re-enable files that are IN the target profile but currently disabled.
|
||||||
|
for (const file of SCAFFOLD_FILES) {
|
||||||
|
if (!targetSet.has(file.path)) continue;
|
||||||
|
const target = join(basePath, file.path);
|
||||||
|
if (!existsSync(target)) continue;
|
||||||
|
try {
|
||||||
|
const { marker, body } = extractMarker(target);
|
||||||
|
if (!marker || marker.state !== "disabled") continue;
|
||||||
|
if (bodyHash(body) === marker.hash) {
|
||||||
|
// Clean hash → safe to re-enable.
|
||||||
|
result.reEnabled.push(file.path);
|
||||||
|
if (!dryRun) {
|
||||||
|
stampScaffoldFile(target, file.path, sfVersion, "pending");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.warnings.push({
|
||||||
|
path: file.path,
|
||||||
|
reason: "disabled, hash diverged — left as-is (manual edit detected)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
result.warnings.push({ path: file.path, reason: `read error: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: disable (or prune) files NOT in the target profile.
|
||||||
|
for (const file of SCAFFOLD_FILES) {
|
||||||
|
if (targetSet.has(file.path)) continue;
|
||||||
|
const target = join(basePath, file.path);
|
||||||
|
if (!existsSync(target)) continue;
|
||||||
|
try {
|
||||||
|
const { marker, body } = extractMarker(target);
|
||||||
|
if (!marker) continue; // untracked/customised — leave alone silently
|
||||||
|
if (marker.state === "disabled") continue; // already disabled
|
||||||
|
if (marker.state === "editing" || marker.state === "completed") {
|
||||||
|
result.warnings.push({
|
||||||
|
path: file.path,
|
||||||
|
reason: `state=${marker.state} — not auto-disabled (review manually)`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// state=pending — check hash safety using extracted body (without marker).
|
||||||
|
const currentHash = bodyHash(body);
|
||||||
|
if (currentHash !== marker.hash) {
|
||||||
|
// User edited the file but marker still says pending — editing-drift.
|
||||||
|
result.warnings.push({
|
||||||
|
path: file.path,
|
||||||
|
reason: "state=pending but hash diverged — not auto-disabled (editing-drift)",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Safe: pending + clean hash.
|
||||||
|
if (prune) {
|
||||||
|
result.pruned.push(file.path);
|
||||||
|
if (!dryRun) {
|
||||||
|
rmSync(target);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.disabled.push(file.path);
|
||||||
|
if (!dryRun) {
|
||||||
|
stampScaffoldFile(target, file.path, sfVersion, "disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
result.warnings.push({ path: file.path, reason: `read error: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: update manifest profile field.
|
||||||
|
if (!dryRun) {
|
||||||
|
try {
|
||||||
|
writeScaffoldManifest(basePath, { ...manifest, profile: targetProfile });
|
||||||
|
} catch (err) {
|
||||||
|
result.warnings.push({ path: "manifest", reason: `manifest write failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMigrateResult(result, targetProfile, dryRun) {
|
||||||
|
const prefix = dryRun ? "[dry-run] " : "";
|
||||||
|
const lines = [`${prefix}Migrate to profile '${targetProfile}':`];
|
||||||
|
if (result.reEnabled.length > 0) {
|
||||||
|
lines.push(` Re-enabled (${result.reEnabled.length}):`);
|
||||||
|
for (const p of result.reEnabled) lines.push(` + ${p}`);
|
||||||
|
}
|
||||||
|
if (result.disabled.length > 0) {
|
||||||
|
lines.push(` Disabled (${result.disabled.length}):`);
|
||||||
|
for (const p of result.disabled) lines.push(` - ${p}`);
|
||||||
|
}
|
||||||
|
if (result.pruned.length > 0) {
|
||||||
|
lines.push(` Pruned/deleted (${result.pruned.length}):`);
|
||||||
|
for (const p of result.pruned) lines.push(` x ${p}`);
|
||||||
|
}
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
lines.push(` Needs review (${result.warnings.length}):`);
|
||||||
|
for (const w of result.warnings) lines.push(` ! ${w.path}: ${w.reason}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.reEnabled.length === 0 &&
|
||||||
|
result.disabled.length === 0 &&
|
||||||
|
result.pruned.length === 0 &&
|
||||||
|
result.warnings.length === 0
|
||||||
|
) {
|
||||||
|
lines.push(" Nothing to do — repo is already on this profile.");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level handler for `/scaffold migrate [args]`.
|
||||||
|
*
|
||||||
|
* Purpose: apply a profile change to an existing repo, stamping out-of-profile
|
||||||
|
* files as `disabled` and restoring in-profile files from `disabled` to
|
||||||
|
* `pending`. Safe: never touches user-edited or completed files without warning.
|
||||||
|
*
|
||||||
|
* Consumer: user, via `/scaffold migrate --profile infra` in the SF TUI.
|
||||||
|
*/
|
||||||
|
export async function handleScaffoldMigrate(args, ctx) {
|
||||||
|
const opts = parseScaffoldMigrateArgs(args);
|
||||||
|
const basePath = projectRoot();
|
||||||
|
|
||||||
|
// If no --profile, auto-detect and report what we'd do.
|
||||||
|
let targetProfile = opts.profile;
|
||||||
|
if (!targetProfile) {
|
||||||
|
targetProfile = detectRepoProfile(basePath);
|
||||||
|
ctx.ui.notify(
|
||||||
|
`No profile specified — auto-detected: '${targetProfile}'\nRe-run with --profile=${targetProfile} to apply, or --profile=<name> to override.\nBuilt-in profiles: ${PROFILE_NAMES.join(", ")}\nCustom profiles: create ~/.sf/profiles/<name>.yaml (extends + add/remove)`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(basePath, targetProfile, {
|
||||||
|
prune: opts.prune,
|
||||||
|
dryRun: opts.dryRun,
|
||||||
|
});
|
||||||
|
ctx.ui.notify(formatMigrateResult(result, targetProfile, opts.dryRun), "info");
|
||||||
|
|
||||||
|
if (!opts.dryRun) {
|
||||||
|
// Run a drift sync to ensure in-profile files that are now pending get written.
|
||||||
|
const drift = detectScaffoldDrift(basePath, targetProfile);
|
||||||
|
const missingCount = drift.countsByBucket.missing ?? 0;
|
||||||
|
if (missingCount > 0) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`${missingCount} in-profile file(s) missing — run \`/scaffold sync\` to write them.`,
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -587,6 +587,22 @@ const NESTED_COMPLETIONS = {
|
||||||
cmd: "sync --only=",
|
cmd: "sync --only=",
|
||||||
desc: "Restrict the operation to a path glob (e.g. --only=harness/**)",
|
desc: "Restrict the operation to a path glob (e.g. --only=harness/**)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
cmd: "migrate",
|
||||||
|
desc: "Auto-detect repo profile and show what would change",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cmd: "migrate --profile=",
|
||||||
|
desc: "Switch active profile (app/library/infra/docs/minimal); disables out-of-profile files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cmd: "migrate --profile= --dry-run",
|
||||||
|
desc: "Preview what migrate would change without modifying files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cmd: "migrate --profile= --prune",
|
||||||
|
desc: "Delete (not just disable) pending out-of-profile files with clean hash",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
plan: [
|
plan: [
|
||||||
{ cmd: "promote", desc: "Copy a planning artifact from ~/.sf/ into docs/" },
|
{ cmd: "promote", desc: "Copy a planning artifact from ~/.sf/ into docs/" },
|
||||||
|
|
|
||||||
|
|
@ -437,9 +437,19 @@ Examples:
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (trimmed === "scaffold migrate" || trimmed.startsWith("scaffold migrate ")) {
|
||||||
|
const { handleScaffoldMigrate } = await import(
|
||||||
|
"../../commands-scaffold-migrate.js"
|
||||||
|
);
|
||||||
|
await handleScaffoldMigrate(
|
||||||
|
trimmed.replace(/^scaffold migrate\s*/, "").trim(),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (trimmed === "scaffold") {
|
if (trimmed === "scaffold") {
|
||||||
ctx.ui.notify(
|
ctx.ui.notify(
|
||||||
"Usage: /scaffold sync [--dry-run] [--include-editing] [--only=<glob>]",
|
"Usage: /scaffold sync [--dry-run] [--include-editing] [--only=<glob>]\n /scaffold migrate [--profile=<name>] [--dry-run] [--prune]",
|
||||||
"warning",
|
"warning",
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -6,49 +6,29 @@
|
||||||
* beyond the template stubs. Reports findings so the agent knows what needs
|
* beyond the template stubs. Reports findings so the agent knows what needs
|
||||||
* attention — never blocks, only surfaces.
|
* attention — never blocks, only surfaces.
|
||||||
*
|
*
|
||||||
|
* ADR-022: Only checks files in the active profile. Files with `state=disabled`
|
||||||
|
* are skipped; they are intentionally out of scope for this repo.
|
||||||
|
*
|
||||||
* Consumer: bootstrapProject (after scaffold init), milestone close workflows.
|
* Consumer: bootstrapProject (after scaffold init), milestone close workflows.
|
||||||
*/
|
*/
|
||||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { SCAFFOLD_FILES } from "./scaffold-constants.js";
|
||||||
|
import { resolveActiveProfileSet } from "./scaffold-profiles.js";
|
||||||
|
import { extractMarker, readScaffoldManifest } from "./scaffold-versioning.js";
|
||||||
|
|
||||||
/** Files created by ensureAgenticDocsScaffold that should contain real content. */
|
/** Files created by ensureAgenticDocsScaffold that should contain real content.
|
||||||
const SCAFFOLD_FILES = [
|
* Kept for compatibility — checkDocsScaffold now derives its list from the
|
||||||
// Root routing
|
* active profile at runtime (ADR-022). */
|
||||||
"AGENTS.md",
|
const STUB_ALLOWED_PATHS = new Set([
|
||||||
"ARCHITECTURE.md",
|
|
||||||
// docs/ structure
|
|
||||||
"docs/AGENTS.md",
|
|
||||||
"docs/PLANS.md",
|
|
||||||
"docs/DESIGN.md",
|
|
||||||
"docs/FRONTEND.md",
|
|
||||||
"docs/QUALITY_SCORE.md",
|
|
||||||
"docs/RELIABILITY.md",
|
|
||||||
"docs/SECURITY.md",
|
|
||||||
"docs/product-specs/index.md",
|
|
||||||
"docs/product-specs/new-user-onboarding.md",
|
|
||||||
"docs/design-docs/index.md",
|
|
||||||
"docs/design-docs/core-beliefs.md",
|
|
||||||
"docs/exec-plans/active/index.md",
|
|
||||||
"docs/exec-plans/completed/index.md",
|
|
||||||
"docs/exec-plans/tech-debt-tracker.md",
|
|
||||||
"docs/exec-plans/AGENTS.md",
|
|
||||||
"docs/records/index.md",
|
|
||||||
"docs/records/AGENTS.md",
|
|
||||||
"docs/RECORDS_KEEPER.md",
|
|
||||||
// src/ and tests/ routing
|
|
||||||
"src/AGENTS.md",
|
|
||||||
"tests/AGENTS.md",
|
|
||||||
];
|
|
||||||
// Minimum lines considered "real content" vs stub. Template stubs are ~3-8 lines.
|
|
||||||
const STUB_LINE_COUNT = 10;
|
|
||||||
// Files that are allowed to stay as stubs (index/placeholder files)
|
|
||||||
const STUB_ALLOWED = new Set([
|
|
||||||
"docs/product-specs/index.md",
|
"docs/product-specs/index.md",
|
||||||
"docs/design-docs/index.md",
|
"docs/design-docs/index.md",
|
||||||
"docs/exec-plans/active/index.md",
|
"docs/exec-plans/active/index.md",
|
||||||
"docs/exec-plans/completed/index.md",
|
"docs/exec-plans/completed/index.md",
|
||||||
"docs/records/index.md",
|
"docs/records/index.md",
|
||||||
]);
|
]);
|
||||||
|
// Minimum lines considered "real content" vs stub. Template stubs are ~3-8 lines.
|
||||||
|
const STUB_LINE_COUNT = 10;
|
||||||
function countContentLines(content) {
|
function countContentLines(content) {
|
||||||
// Count non-empty, non-comment lines
|
// Count non-empty, non-comment lines
|
||||||
return content.split("\n").filter((line) => {
|
return content.split("\n").filter((line) => {
|
||||||
|
|
@ -96,12 +76,12 @@ function checkFile(repoRoot, relPath) {
|
||||||
return { file: relPath, status: "empty", lines: 0, note: "File is empty" };
|
return { file: relPath, status: "empty", lines: 0, note: "File is empty" };
|
||||||
}
|
}
|
||||||
if (contentLines < STUB_LINE_COUNT) {
|
if (contentLines < STUB_LINE_COUNT) {
|
||||||
const note = STUB_ALLOWED.has(relPath)
|
const note = STUB_ALLOWED_PATHS.has(relPath)
|
||||||
? `Stub file (${lines} lines) — acceptable for index/placeholder`
|
? `Stub file (${lines} lines) — acceptable for index/placeholder`
|
||||||
: `Stub file (${lines} lines) — needs real content beyond template`;
|
: `Stub file (${lines} lines) — needs real content beyond template`;
|
||||||
return {
|
return {
|
||||||
file: relPath,
|
file: relPath,
|
||||||
status: STUB_ALLOWED.has(relPath) ? "ok" : "stub",
|
status: STUB_ALLOWED_PATHS.has(relPath) ? "ok" : "stub",
|
||||||
lines,
|
lines,
|
||||||
note,
|
note,
|
||||||
};
|
};
|
||||||
|
|
@ -114,13 +94,29 @@ function checkFile(repoRoot, relPath) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Check all scaffold files in a repo. Returns a structured report.
|
* Check scaffold files in a repo. Returns a structured report.
|
||||||
* Never throws — all errors are caught and reported as stub/missing.
|
* Never throws — all errors are caught and reported as stub/missing.
|
||||||
|
*
|
||||||
|
* ADR-022: filters to the active profile and skips `state=disabled` files.
|
||||||
|
* Files not in the active profile are not checked (they don't apply to this
|
||||||
|
* repo shape). Files with `state=disabled` are skipped as intentionally
|
||||||
|
* out-of-scope.
|
||||||
*/
|
*/
|
||||||
export function checkDocsScaffold(repoRoot) {
|
export function checkDocsScaffold(repoRoot) {
|
||||||
|
const manifest = readScaffoldManifest(repoRoot);
|
||||||
|
const { profileSet } = resolveActiveProfileSet(repoRoot, manifest, null);
|
||||||
const checks = [];
|
const checks = [];
|
||||||
for (const file of SCAFFOLD_FILES) {
|
for (const file of SCAFFOLD_FILES) {
|
||||||
checks.push(checkFile(repoRoot, file));
|
// Skip files not in the active profile.
|
||||||
|
if (!profileSet.has(file.path)) continue;
|
||||||
|
// Skip files with state=disabled — intentionally out of scope.
|
||||||
|
try {
|
||||||
|
const { marker } = extractMarker(join(repoRoot, file.path));
|
||||||
|
if (marker?.state === "disabled") continue;
|
||||||
|
} catch {
|
||||||
|
// File unreadable — fall through to normal check.
|
||||||
|
}
|
||||||
|
checks.push(checkFile(repoRoot, file.path));
|
||||||
}
|
}
|
||||||
const summary = {
|
const summary = {
|
||||||
total: checks.length,
|
total: checks.length,
|
||||||
|
|
|
||||||
|
|
@ -460,3 +460,72 @@ This is gold — most wrong agent calls come from not knowing what to avoid. Eac
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in scaffold profiles — each profile is the set of SCAFFOLD_FILES
|
||||||
|
* paths that apply to a given repo shape (ADR-022).
|
||||||
|
*
|
||||||
|
* A file not in the active profile is treated as absent from SCAFFOLD_FILES
|
||||||
|
* for all scaffold operations: it is never created, never reported as
|
||||||
|
* `missing`, and is stamped `state=disabled` by the migrate command.
|
||||||
|
*
|
||||||
|
* Profile names are intentionally lowercase strings. The `app` profile is
|
||||||
|
* the default and equals the full SCAFFOLD_FILES list.
|
||||||
|
*
|
||||||
|
* Consumer: agentic-docs-scaffold.js, scaffold-drift.js,
|
||||||
|
* commands-scaffold-migrate.js.
|
||||||
|
*/
|
||||||
|
const APP_PROFILE_PATHS = new Set(SCAFFOLD_FILES.map((f) => f.path));
|
||||||
|
|
||||||
|
const LIBRARY_EXCLUDED = new Set([
|
||||||
|
"docs/FRONTEND.md",
|
||||||
|
"docs/PRODUCT_SENSE.md",
|
||||||
|
"docs/DESIGN.md",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const INFRA_INCLUDED = new Set([
|
||||||
|
".siftignore",
|
||||||
|
"AGENTS.md",
|
||||||
|
"ARCHITECTURE.md",
|
||||||
|
"docs/AGENTS.md",
|
||||||
|
"docs/SECURITY.md",
|
||||||
|
"docs/RELIABILITY.md",
|
||||||
|
"docs/records/AGENTS.md",
|
||||||
|
"docs/records/index.md",
|
||||||
|
"docs/RECORDS_KEEPER.md",
|
||||||
|
".sf/PRINCIPLES.md",
|
||||||
|
".sf/STYLE.md",
|
||||||
|
".sf/NON-GOALS.md",
|
||||||
|
".sf/harness/AGENTS.md",
|
||||||
|
".sf/harness/specs/AGENTS.md",
|
||||||
|
".sf/harness/specs/bootstrap.md",
|
||||||
|
".sf/harness/evals/AGENTS.md",
|
||||||
|
".sf/harness/graders/AGENTS.md",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DOCS_INCLUDED = new Set([
|
||||||
|
"AGENTS.md",
|
||||||
|
".sf/STYLE.md",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MINIMAL_INCLUDED = new Set([
|
||||||
|
".siftignore",
|
||||||
|
"AGENTS.md",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const PROFILES = {
|
||||||
|
/** Full template set. Default for product and CLI repos with UI and tests. */
|
||||||
|
app: APP_PROFILE_PATHS,
|
||||||
|
/** App minus frontend/design/product-sense files. For libraries, SDKs, CLI tools. */
|
||||||
|
library: new Set([...APP_PROFILE_PATHS].filter((p) => !LIBRARY_EXCLUDED.has(p))),
|
||||||
|
/** Infrastructure/GitOps/Kubernetes repos. Ops-focused subset. */
|
||||||
|
infra: INFRA_INCLUDED,
|
||||||
|
/** Documentation-only repos. Minimal footprint. */
|
||||||
|
docs: DOCS_INCLUDED,
|
||||||
|
/** Smallest possible footprint. Useful as a starting point or for scratch repos. */
|
||||||
|
minimal: MINIMAL_INCLUDED,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Names of all built-in profiles. */
|
||||||
|
export const PROFILE_NAMES = /** @type {const} */ (Object.keys(PROFILES));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* Scaffold drift detection (ADR-021 Phase B).
|
* Scaffold drift detection (ADR-021 Phase B, extended by ADR-022).
|
||||||
*
|
*
|
||||||
* Reads the on-disk state of every entry in `SCAFFOLD_FILES`, parses the
|
* Reads the on-disk state of every entry in `SCAFFOLD_FILES`, parses the
|
||||||
* first-line marker (if any), and classifies each file into one of five
|
* first-line marker (if any), and classifies each file into one of six
|
||||||
* buckets. The result is structured and side-effect-free — Phase C wires
|
* buckets. The result is structured and side-effect-free — Phase C wires
|
||||||
* the report into the scaffold sync pipeline; Phase B is data-plane only.
|
* the report into the scaffold sync pipeline; Phase B is data-plane only.
|
||||||
|
*
|
||||||
|
* ADR-022 adds profile-aware filtering: files not in the active profile are
|
||||||
|
* skipped entirely (not reported as `missing`), and files with
|
||||||
|
* `state=disabled` go to the `disabled` bucket rather than any actionable
|
||||||
|
* bucket.
|
||||||
*/
|
*/
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { SCAFFOLD_FILES } from "./scaffold-constants.js";
|
import { SCAFFOLD_FILES } from "./scaffold-constants.js";
|
||||||
|
import { resolveActiveProfileSet } from "./scaffold-profiles.js";
|
||||||
import {
|
import {
|
||||||
bodyHash,
|
bodyHash,
|
||||||
extractMarker,
|
extractMarker,
|
||||||
|
|
@ -49,9 +55,21 @@ function emptyCounts() {
|
||||||
untracked: 0,
|
untracked: 0,
|
||||||
current: 0,
|
current: 0,
|
||||||
customized: 0,
|
customized: 0,
|
||||||
|
disabled: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active profile set for a project.
|
||||||
|
*
|
||||||
|
* Delegates to resolveActiveProfileSet but only needs the Set — kept as a
|
||||||
|
* local shim so migrateLegacyScaffold has a consistent call site.
|
||||||
|
*/
|
||||||
|
function resolveProfileSet(profile, manifest, basePath) {
|
||||||
|
const { profileSet } = resolveActiveProfileSet(basePath ?? null, manifest, profile);
|
||||||
|
return profileSet;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify every `SCAFFOLD_FILES` entry against its on-disk state.
|
* Classify every `SCAFFOLD_FILES` entry against its on-disk state.
|
||||||
*
|
*
|
||||||
|
|
@ -59,14 +77,23 @@ function emptyCounts() {
|
||||||
* tests can rely on stable iteration. Failure modes are non-fatal: if a
|
* tests can rely on stable iteration. Failure modes are non-fatal: if a
|
||||||
* file cannot be read, it is reported as `untracked` rather than aborting
|
* file cannot be read, it is reported as `untracked` rather than aborting
|
||||||
* the scan.
|
* the scan.
|
||||||
|
*
|
||||||
|
* @param {string} basePath - Absolute path to the repo root.
|
||||||
|
* @param {string|null} [profile] - Active profile name override. Falls back to
|
||||||
|
* PREFERENCES.md frontmatter → manifest field → auto-detect. Files not in
|
||||||
|
* the active profile are skipped entirely (not reported as `missing`). Files
|
||||||
|
* with `state=disabled` go to the `disabled` bucket.
|
||||||
*/
|
*/
|
||||||
export function detectScaffoldDrift(basePath) {
|
export function detectScaffoldDrift(basePath, profile) {
|
||||||
const shipVersion = process.env.SF_VERSION || "0.0.0";
|
const shipVersion = process.env.SF_VERSION || "0.0.0";
|
||||||
const items = [];
|
const items = [];
|
||||||
const counts = emptyCounts();
|
const counts = emptyCounts();
|
||||||
const manifest = readScaffoldManifest(basePath);
|
const manifest = readScaffoldManifest(basePath);
|
||||||
const manifestPresent = manifest.applied.length > 0;
|
const manifestPresent = manifest.applied.length > 0;
|
||||||
|
const { profileSet } = resolveActiveProfileSet(basePath, manifest, profile);
|
||||||
for (const file of SCAFFOLD_FILES) {
|
for (const file of SCAFFOLD_FILES) {
|
||||||
|
// Skip files not in the active profile entirely — don't report as missing.
|
||||||
|
if (!profileSet.has(file.path)) continue;
|
||||||
const target = join(basePath, file.path);
|
const target = join(basePath, file.path);
|
||||||
// Files in SKIP_MARKER_PATHS use the manifest as their versioning
|
// Files in SKIP_MARKER_PATHS use the manifest as their versioning
|
||||||
// source instead of an inline marker. For Phase B we treat them as
|
// source instead of an inline marker. For Phase B we treat them as
|
||||||
|
|
@ -151,6 +178,19 @@ export function detectScaffoldDrift(basePath) {
|
||||||
counts.current += 1;
|
counts.current += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Marker says disabled → file is out-of-scope; never touch it.
|
||||||
|
if (marker.state === "disabled") {
|
||||||
|
items.push({
|
||||||
|
path: file.path,
|
||||||
|
template: file.path,
|
||||||
|
bucket: "disabled",
|
||||||
|
currentVersion: marker.version,
|
||||||
|
shipVersion,
|
||||||
|
hashDrifted: false,
|
||||||
|
});
|
||||||
|
counts.disabled += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const currentHash = bodyHash(body);
|
const currentHash = bodyHash(body);
|
||||||
const hashMatches = currentHash === marker.hash;
|
const hashMatches = currentHash === marker.hash;
|
||||||
if (!hashMatches) {
|
if (!hashMatches) {
|
||||||
|
|
@ -248,9 +288,9 @@ function seedArchiveWithCurrentShipVersion() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Walk every `SCAFFOLD_FILES` entry and look for **unmarked** files whose
|
* Walk every `SCAFFOLD_FILES` entry (filtered to the active profile) and look
|
||||||
* body hash matches a known prior version recorded in
|
* for **unmarked** files whose body hash matches a known prior version recorded
|
||||||
* `SCAFFOLD_VERSION_ARCHIVE`. Matching files are promoted to `pending` by
|
* in `SCAFFOLD_VERSION_ARCHIVE`. Matching files are promoted to `pending` by
|
||||||
* stamping them with the matched version and recording a manifest entry.
|
* stamping them with the matched version and recording a manifest entry.
|
||||||
*
|
*
|
||||||
* Behaviour:
|
* Behaviour:
|
||||||
|
|
@ -259,6 +299,8 @@ function seedArchiveWithCurrentShipVersion() {
|
||||||
* - Files missing on disk → skipped (the missing-file flow handles those).
|
* - Files missing on disk → skipped (the missing-file flow handles those).
|
||||||
* - Files in `SKIP_MARKER_PATHS` (e.g. `.siftignore`) → skipped here; the
|
* - Files in `SKIP_MARKER_PATHS` (e.g. `.siftignore`) → skipped here; the
|
||||||
* manifest is the versioning source for those.
|
* manifest is the versioning source for those.
|
||||||
|
* - Files not in the active profile → skipped; only profile-relevant files
|
||||||
|
* are candidates for legacy promotion.
|
||||||
* - Files whose body hash matches an archive entry → stamped with the
|
* - Files whose body hash matches an archive entry → stamped with the
|
||||||
* matched version, manifest entry recorded, returned in `migrated`.
|
* matched version, manifest entry recorded, returned in `migrated`.
|
||||||
* - Files with no archive match → returned in `skipped`. Treated as
|
* - Files with no archive match → returned in `skipped`. Treated as
|
||||||
|
|
@ -267,14 +309,21 @@ function seedArchiveWithCurrentShipVersion() {
|
||||||
* Idempotent: a second invocation finds the markers it just wrote and
|
* Idempotent: a second invocation finds the markers it just wrote and
|
||||||
* skips them. Failure modes (read error, write error) are swallowed and
|
* skips them. Failure modes (read error, write error) are swallowed and
|
||||||
* logged via `logWarning("scaffold", ...)`.
|
* logged via `logWarning("scaffold", ...)`.
|
||||||
|
*
|
||||||
|
* @param {string} basePath
|
||||||
|
* @param {string|null} [profile] - Active profile; falls back to manifest then `app`.
|
||||||
*/
|
*/
|
||||||
export function migrateLegacyScaffold(basePath) {
|
export function migrateLegacyScaffold(basePath, profile) {
|
||||||
seedArchiveWithCurrentShipVersion();
|
seedArchiveWithCurrentShipVersion();
|
||||||
|
const manifest = readScaffoldManifest(basePath);
|
||||||
|
const profileSet = resolveProfileSet(profile, manifest, basePath);
|
||||||
const migrated = [];
|
const migrated = [];
|
||||||
const skipped = [];
|
const skipped = [];
|
||||||
const appliedAt = new Date().toISOString();
|
const appliedAt = new Date().toISOString();
|
||||||
for (const file of SCAFFOLD_FILES) {
|
for (const file of SCAFFOLD_FILES) {
|
||||||
if (SKIP_MARKER_PATHS.has(file.path)) continue;
|
if (SKIP_MARKER_PATHS.has(file.path)) continue;
|
||||||
|
// Only migrate files in the active profile.
|
||||||
|
if (!profileSet.has(file.path)) continue;
|
||||||
const target = join(basePath, file.path);
|
const target = join(basePath, file.path);
|
||||||
if (!existsSync(target)) continue;
|
if (!existsSync(target)) continue;
|
||||||
let body;
|
let body;
|
||||||
|
|
|
||||||
250
src/resources/extensions/sf/scaffold-profiles.js
Normal file
250
src/resources/extensions/sf/scaffold-profiles.js
Normal file
|
|
@ -0,0 +1,250 @@
|
||||||
|
/**
|
||||||
|
* scaffold-profiles.js — profile resolution for ADR-022 scaffold profiles.
|
||||||
|
*
|
||||||
|
* Purpose: single entry point for resolving the active profile Set<string> from
|
||||||
|
* all precedence sources: PREFERENCES.md frontmatter → manifest → auto-detection.
|
||||||
|
* Also handles custom profile YAML files under ~/.sf/profiles/.
|
||||||
|
*
|
||||||
|
* Consumer: detectScaffoldDrift, ensureAgenticDocsScaffold, doc-checker, migrate.
|
||||||
|
*/
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { PROFILE_NAMES, PROFILES, SCAFFOLD_FILES } from "./scaffold-constants.js";
|
||||||
|
import { sfHome } from "./sf-home.js";
|
||||||
|
|
||||||
|
const SCAFFOLD_FILE_PATHS = new Set(SCAFFOLD_FILES.map((f) => f.path));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the `sf_profile:` field from PREFERENCES.md frontmatter.
|
||||||
|
*
|
||||||
|
* Purpose: allow per-repo, git-committed profile override that takes precedence
|
||||||
|
* over the runtime manifest field. Returns null when the file is absent, has no
|
||||||
|
* frontmatter, or has no `sf_profile` key.
|
||||||
|
*
|
||||||
|
* Consumer: resolveActiveProfileSet.
|
||||||
|
*/
|
||||||
|
export function readPreferencesProfile(basePath) {
|
||||||
|
const prefsPath = join(basePath, "PREFERENCES.md");
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = readFileSync(prefsPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!content.startsWith("---")) return null;
|
||||||
|
const end = content.indexOf("\n---", 3);
|
||||||
|
if (end === -1) return null;
|
||||||
|
const block = content.slice(3, end);
|
||||||
|
for (const line of block.split("\n")) {
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
if (colon === -1) continue;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
if (key !== "sf_profile") continue;
|
||||||
|
const val = line
|
||||||
|
.slice(colon + 1)
|
||||||
|
.replace(/#.*$/, "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^["']|["']$/g, "");
|
||||||
|
return val || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a custom profile YAML file from ~/.sf/profiles/<name>.yaml.
|
||||||
|
*
|
||||||
|
* Purpose: allow power-users to define repo-type profiles beyond the five
|
||||||
|
* built-ins without forking SF. Returns null when the file does not exist.
|
||||||
|
* Returns an error string (not a Set) when the file is malformed so callers
|
||||||
|
* can surface a diagnostic instead of silently falling back.
|
||||||
|
*
|
||||||
|
* Schema:
|
||||||
|
* extends: <built-in profile name> # required
|
||||||
|
* add: # optional — paths relative to repo root
|
||||||
|
* - docs/DESIGN.md
|
||||||
|
* remove: # optional
|
||||||
|
* - docs/exec-plans/active/index.md
|
||||||
|
*
|
||||||
|
* Consumer: resolveActiveProfileSet.
|
||||||
|
*/
|
||||||
|
export function loadCustomProfileSet(name) {
|
||||||
|
const profilePath = join(sfHome(), "profiles", `${name}.yaml`);
|
||||||
|
if (!existsSync(profilePath)) return null;
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = readFileSync(profilePath, "utf-8");
|
||||||
|
} catch (err) {
|
||||||
|
return `failed to read ${profilePath}: ${err.message}`;
|
||||||
|
}
|
||||||
|
// Minimal YAML parser: handles scalar "extends" and sequence "add"/"remove".
|
||||||
|
const fields = { extends: null, add: [], remove: [] };
|
||||||
|
let currentList = null;
|
||||||
|
for (const rawLine of content.split("\n")) {
|
||||||
|
const line = rawLine.replace(/#.*$/, ""); // strip comments
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
// Sequence item
|
||||||
|
if (/^\s+-\s/.test(line)) {
|
||||||
|
const value = line.replace(/^\s+-\s*/, "").trim().replace(/^["']|["']$/g, "");
|
||||||
|
if (currentList === "add") fields.add.push(value);
|
||||||
|
else if (currentList === "remove") fields.remove.push(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Key: value
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
if (colon <= 0) continue;
|
||||||
|
const key = line.slice(0, colon).trim();
|
||||||
|
const val = line
|
||||||
|
.slice(colon + 1)
|
||||||
|
.trim()
|
||||||
|
.replace(/^["']|["']$/g, "");
|
||||||
|
if (key === "extends") {
|
||||||
|
fields.extends = val || null;
|
||||||
|
currentList = null;
|
||||||
|
} else if (key === "add") {
|
||||||
|
currentList = "add";
|
||||||
|
} else if (key === "remove") {
|
||||||
|
currentList = "remove";
|
||||||
|
} else {
|
||||||
|
currentList = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!fields.extends) {
|
||||||
|
return `custom profile '${name}' is missing required 'extends' field`;
|
||||||
|
}
|
||||||
|
if (!PROFILE_NAMES.includes(fields.extends)) {
|
||||||
|
return `custom profile '${name}': extends '${fields.extends}' is not a built-in profile (${PROFILE_NAMES.join(", ")})`;
|
||||||
|
}
|
||||||
|
const base = new Set(PROFILES[fields.extends]);
|
||||||
|
// Validate add paths.
|
||||||
|
for (const p of fields.add) {
|
||||||
|
if (!SCAFFOLD_FILE_PATHS.has(p)) {
|
||||||
|
return `custom profile '${name}': add path '${p}' is not a known scaffold file`;
|
||||||
|
}
|
||||||
|
base.add(p);
|
||||||
|
}
|
||||||
|
// Validate remove paths.
|
||||||
|
for (const p of fields.remove) {
|
||||||
|
if (!SCAFFOLD_FILE_PATHS.has(p)) {
|
||||||
|
return `custom profile '${name}': remove path '${p}' is not a known scaffold file`;
|
||||||
|
}
|
||||||
|
base.delete(p);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the most appropriate built-in profile for a repo.
|
||||||
|
*
|
||||||
|
* Purpose: prevent SF from laying down ~60-70% irrelevant scaffold files when
|
||||||
|
* bootstrapping into infra/ops/docs repos that don't have a code-app shape.
|
||||||
|
* Returns the profile name to store in the manifest and filter scaffold files.
|
||||||
|
*
|
||||||
|
* Heuristic (ADR-022 §4): checks for language/infra marker files in order of
|
||||||
|
* specificity. Falls back to "app" to preserve existing behaviour for repos
|
||||||
|
* that look like applications.
|
||||||
|
*
|
||||||
|
* Consumer: resolveActiveProfileSet (first-run path), ensureAgenticDocsScaffold.
|
||||||
|
*/
|
||||||
|
export function detectRepoProfile(basePath) {
|
||||||
|
// Nix repos are almost always infrastructure.
|
||||||
|
if (existsSync(join(basePath, "flake.nix")) || existsSync(join(basePath, "shell.nix"))) {
|
||||||
|
return "infra";
|
||||||
|
}
|
||||||
|
// Repos with a Terraform or Pulumi entry point.
|
||||||
|
if (
|
||||||
|
existsSync(join(basePath, "main.tf")) ||
|
||||||
|
existsSync(join(basePath, "Pulumi.yaml")) ||
|
||||||
|
existsSync(join(basePath, "Pulumi.yml"))
|
||||||
|
) {
|
||||||
|
return "infra";
|
||||||
|
}
|
||||||
|
// Pure docs repo: no source manifests, only markdown.
|
||||||
|
const hasSource =
|
||||||
|
existsSync(join(basePath, "package.json")) ||
|
||||||
|
existsSync(join(basePath, "go.mod")) ||
|
||||||
|
existsSync(join(basePath, "Cargo.toml")) ||
|
||||||
|
existsSync(join(basePath, "pyproject.toml")) ||
|
||||||
|
existsSync(join(basePath, "setup.py")) ||
|
||||||
|
existsSync(join(basePath, "CMakeLists.txt"));
|
||||||
|
if (!hasSource && existsSync(join(basePath, "docs"))) {
|
||||||
|
return "docs";
|
||||||
|
}
|
||||||
|
// Node repos: check for UI framework dependency (→ app) vs library.
|
||||||
|
if (existsSync(join(basePath, "package.json"))) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(basePath, "package.json"), "utf-8"));
|
||||||
|
const allDeps = {
|
||||||
|
...(pkg.dependencies ?? {}),
|
||||||
|
...(pkg.devDependencies ?? {}),
|
||||||
|
};
|
||||||
|
const uiFrameworks = ["react", "vue", "svelte", "next", "@angular/core"];
|
||||||
|
if (uiFrameworks.some((f) => allDeps[f])) return "app";
|
||||||
|
return "library";
|
||||||
|
} catch {
|
||||||
|
return "app";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Go, Rust, Python → library by default (no frontend implied).
|
||||||
|
if (
|
||||||
|
existsSync(join(basePath, "go.mod")) ||
|
||||||
|
existsSync(join(basePath, "Cargo.toml")) ||
|
||||||
|
existsSync(join(basePath, "pyproject.toml"))
|
||||||
|
) {
|
||||||
|
return "library";
|
||||||
|
}
|
||||||
|
// Fallback: preserve existing behaviour.
|
||||||
|
return "app";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active profile Set<string> for a given repo path.
|
||||||
|
*
|
||||||
|
* Purpose: single authoritative implementation of ADR-022 §6 precedence rules
|
||||||
|
* so all callers (drift, scaffold, migrate, doctor) agree on the active profile.
|
||||||
|
*
|
||||||
|
* Precedence (highest first):
|
||||||
|
* 1. PREFERENCES.md frontmatter `sf_profile:` (committed, explicit)
|
||||||
|
* 2. `explicitProfile` argument (caller-supplied override, e.g. migrate --profile=)
|
||||||
|
* 3. Manifest `profile` field (runtime, gitignored)
|
||||||
|
* 4. Auto-detection via detectRepoProfile (first-run fallback)
|
||||||
|
*
|
||||||
|
* Returns `{ profileName, profileSet, warning? }`. `warning` is set when a
|
||||||
|
* custom profile file is malformed or unknown — callers surface it as a notice,
|
||||||
|
* never as a hard error.
|
||||||
|
*
|
||||||
|
* Consumer: detectScaffoldDrift, ensureAgenticDocsScaffold, doc-checker,
|
||||||
|
* runScaffoldMigrate.
|
||||||
|
*/
|
||||||
|
export function resolveActiveProfileSet(basePath, manifest, explicitProfile) {
|
||||||
|
// 1. PREFERENCES.md frontmatter — highest precedence.
|
||||||
|
const prefsProfile = readPreferencesProfile(basePath);
|
||||||
|
const resolved = prefsProfile ?? explicitProfile ?? manifest?.profile ?? null;
|
||||||
|
if (!resolved) {
|
||||||
|
// Auto-detect from filesystem heuristics (first-run fallback).
|
||||||
|
const detected = basePath ? detectRepoProfile(basePath) : "app";
|
||||||
|
return { profileName: detected, profileSet: PROFILES[detected] };
|
||||||
|
}
|
||||||
|
// Built-in?
|
||||||
|
if (PROFILE_NAMES.includes(resolved)) {
|
||||||
|
return { profileName: resolved, profileSet: PROFILES[resolved] };
|
||||||
|
}
|
||||||
|
// Custom profile?
|
||||||
|
const custom = loadCustomProfileSet(resolved);
|
||||||
|
if (custom === null) {
|
||||||
|
// File doesn't exist — warn and fall back.
|
||||||
|
return {
|
||||||
|
profileName: "app",
|
||||||
|
profileSet: PROFILES.app,
|
||||||
|
warning: `custom profile '${resolved}' not found at ~/.sf/profiles/${resolved}.yaml — using 'app'`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof custom === "string") {
|
||||||
|
// Malformed — warn and fall back.
|
||||||
|
return {
|
||||||
|
profileName: "app",
|
||||||
|
profileSet: PROFILES.app,
|
||||||
|
warning: `${custom} — using 'app'`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { profileName: resolved, profileSet: custom };
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ export const SF_DOC_MARKER_SUFFIX = "-->";
|
||||||
/** Manifest path under `.sf/`. Single source of truth for filename/location. */
|
/** Manifest path under `.sf/`. Single source of truth for filename/location. */
|
||||||
export const SCAFFOLD_MANIFEST_RELPATH = ".sf/scaffold-manifest.json";
|
export const SCAFFOLD_MANIFEST_RELPATH = ".sf/scaffold-manifest.json";
|
||||||
// ─── Marker parsing & formatting ─────────────────────────────────────────
|
// ─── Marker parsing & formatting ─────────────────────────────────────────
|
||||||
const VALID_STATES = ["pending", "editing", "completed"];
|
const VALID_STATES = ["pending", "editing", "completed", "disabled"];
|
||||||
function isScaffoldDocState(s) {
|
function isScaffoldDocState(s) {
|
||||||
return VALID_STATES.includes(s);
|
return VALID_STATES.includes(s);
|
||||||
}
|
}
|
||||||
|
|
@ -170,19 +170,19 @@ function isScaffoldManifestEntry(e) {
|
||||||
export function readScaffoldManifest(basePath) {
|
export function readScaffoldManifest(basePath) {
|
||||||
const fp = manifestPath(basePath);
|
const fp = manifestPath(basePath);
|
||||||
if (!existsSync(fp)) {
|
if (!existsSync(fp)) {
|
||||||
return { schemaVersion: 1, applied: [] };
|
return { schemaVersion: 1, profile: null, applied: [] };
|
||||||
}
|
}
|
||||||
let content;
|
let content;
|
||||||
try {
|
try {
|
||||||
content = readFileSync(fp, "utf-8");
|
content = readFileSync(fp, "utf-8");
|
||||||
} catch {
|
} catch {
|
||||||
return { schemaVersion: 1, applied: [] };
|
return { schemaVersion: 1, profile: null, applied: [] };
|
||||||
}
|
}
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(content);
|
parsed = JSON.parse(content);
|
||||||
} catch {
|
} catch {
|
||||||
return { schemaVersion: 1, applied: [] };
|
return { schemaVersion: 1, profile: null, applied: [] };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!parsed ||
|
!parsed ||
|
||||||
|
|
@ -190,10 +190,12 @@ export function readScaffoldManifest(basePath) {
|
||||||
parsed.schemaVersion !== 1 ||
|
parsed.schemaVersion !== 1 ||
|
||||||
!Array.isArray(parsed.applied)
|
!Array.isArray(parsed.applied)
|
||||||
) {
|
) {
|
||||||
return { schemaVersion: 1, applied: [] };
|
return { schemaVersion: 1, profile: null, applied: [] };
|
||||||
}
|
}
|
||||||
const applied = parsed.applied.filter(isScaffoldManifestEntry);
|
const applied = parsed.applied.filter(isScaffoldManifestEntry);
|
||||||
return { schemaVersion: 1, applied };
|
const profile =
|
||||||
|
typeof parsed.profile === "string" ? parsed.profile : null;
|
||||||
|
return { schemaVersion: 1, profile, applied };
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Write the manifest. Never throws — write failures are logged via
|
* Write the manifest. Never throws — write failures are logged via
|
||||||
|
|
@ -213,6 +215,7 @@ export function writeScaffoldManifest(basePath, manifest) {
|
||||||
/**
|
/**
|
||||||
* Record a scaffold application. Reads the manifest, removes any prior entry
|
* Record a scaffold application. Reads the manifest, removes any prior entry
|
||||||
* with the same `path`, appends the new one, writes back. Idempotent.
|
* with the same `path`, appends the new one, writes back. Idempotent.
|
||||||
|
* Preserves the `profile` field from the existing manifest.
|
||||||
*/
|
*/
|
||||||
export function recordScaffoldApply(basePath, entry) {
|
export function recordScaffoldApply(basePath, entry) {
|
||||||
const manifest = readScaffoldManifest(basePath);
|
const manifest = readScaffoldManifest(basePath);
|
||||||
|
|
@ -220,6 +223,7 @@ export function recordScaffoldApply(basePath, entry) {
|
||||||
filtered.push(entry);
|
filtered.push(entry);
|
||||||
writeScaffoldManifest(basePath, {
|
writeScaffoldManifest(basePath, {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
profile: manifest.profile,
|
||||||
applied: filtered,
|
applied: filtered,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,377 @@
|
||||||
|
/**
|
||||||
|
* adr-022-scaffold-profiles.test.mjs — ADR-022 scaffold profiles test suite.
|
||||||
|
*
|
||||||
|
* Purpose: verify behaviour contracts for the profile system: PROFILES shape,
|
||||||
|
* disabled state round-trip, profile detection heuristics, drift skipping for
|
||||||
|
* disabled/out-of-profile files, and the migrate algorithm.
|
||||||
|
*
|
||||||
|
* Consumer: CI (npm run test:unit).
|
||||||
|
*/
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { afterEach, describe, test } from "vitest";
|
||||||
|
import {
|
||||||
|
detectRepoProfile,
|
||||||
|
ensureAgenticDocsScaffold,
|
||||||
|
} from "../agentic-docs-scaffold.js";
|
||||||
|
import { PROFILE_NAMES, PROFILES, SCAFFOLD_FILES } from "../scaffold-constants.js";
|
||||||
|
import { detectScaffoldDrift } from "../scaffold-drift.js";
|
||||||
|
import {
|
||||||
|
parseScaffoldMigrateArgs,
|
||||||
|
runScaffoldMigrate,
|
||||||
|
} from "../commands-scaffold-migrate.js";
|
||||||
|
import {
|
||||||
|
bodyHash,
|
||||||
|
extractMarker,
|
||||||
|
parseMarker,
|
||||||
|
readScaffoldManifest,
|
||||||
|
stampScaffoldFile,
|
||||||
|
} from "../scaffold-versioning.js";
|
||||||
|
|
||||||
|
const tmpRoots = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tmpRoots.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeProject() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "sf-adr022-"));
|
||||||
|
tmpRoots.push(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePendingFile(root, relPath, content = `# ${relPath}\n`) {
|
||||||
|
const target = join(root, relPath);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, content, "utf-8");
|
||||||
|
stampScaffoldFile(target, relPath, "1.0.0", "pending");
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PROFILES shape ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("PROFILES shape", () => {
|
||||||
|
test("PROFILE_NAMES_contains_five_built_in_profiles", () => {
|
||||||
|
assert.deepEqual(PROFILE_NAMES.sort(), ["app", "docs", "infra", "library", "minimal"].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app_profile_contains_all_scaffold_files", () => {
|
||||||
|
const allPaths = new Set(SCAFFOLD_FILES.map((f) => f.path));
|
||||||
|
for (const p of PROFILES.app) {
|
||||||
|
assert.ok(allPaths.has(p), `app profile includes unknown path: ${p}`);
|
||||||
|
}
|
||||||
|
// app must be the superset — every file belongs to at least app.
|
||||||
|
for (const path of allPaths) {
|
||||||
|
assert.ok(PROFILES.app.has(path), `SCAFFOLD_FILES path not in app profile: ${path}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("minimal_is_strict_subset_of_app", () => {
|
||||||
|
for (const p of PROFILES.minimal) {
|
||||||
|
assert.ok(PROFILES.app.has(p), `minimal has path not in app: ${p}`);
|
||||||
|
}
|
||||||
|
// minimal must be strictly smaller.
|
||||||
|
assert.ok(PROFILES.minimal.size < PROFILES.app.size);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("infra_is_strict_subset_of_app", () => {
|
||||||
|
for (const p of PROFILES.infra) {
|
||||||
|
assert.ok(PROFILES.app.has(p), `infra has path not in app: ${p}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("docs_is_strict_subset_of_app", () => {
|
||||||
|
for (const p of PROFILES.docs) {
|
||||||
|
assert.ok(PROFILES.app.has(p), `docs has path not in app: ${p}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── disabled state round-trip ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("disabled state", () => {
|
||||||
|
test("parseMarker_accepts_disabled_state", () => {
|
||||||
|
// The disabled state must be parseable — otherwise files stamped disabled
|
||||||
|
// before Phase 1 ships would be read as null → untracked → re-rendered.
|
||||||
|
const line = "<!-- sf-doc: version=1.0.0 template=AGENTS.md state=disabled hash=abc123 -->";
|
||||||
|
const marker = parseMarker(line);
|
||||||
|
assert.ok(marker, "parseMarker should not return null for state=disabled");
|
||||||
|
assert.equal(marker.state, "disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stampScaffoldFile_roundtrip_preserves_disabled_state", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const target = join(root, "AGENTS.md");
|
||||||
|
writeFileSync(target, "# AGENTS\n", "utf-8");
|
||||||
|
stampScaffoldFile(target, "AGENTS.md", "1.0.0", "disabled");
|
||||||
|
|
||||||
|
const { marker } = extractMarker(target);
|
||||||
|
assert.ok(marker, "marker should be present after stamping");
|
||||||
|
assert.equal(marker.state, "disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectScaffoldDrift_puts_disabled_files_in_disabled_bucket", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const target = join(root, "AGENTS.md");
|
||||||
|
writeFileSync(target, "# AGENTS\n", "utf-8");
|
||||||
|
stampScaffoldFile(target, "AGENTS.md", "1.0.0", "disabled");
|
||||||
|
|
||||||
|
const report = detectScaffoldDrift(root);
|
||||||
|
const item = report.items.find((i) => i.path === "AGENTS.md");
|
||||||
|
assert.ok(item, "AGENTS.md should appear in drift report");
|
||||||
|
assert.equal(item.bucket, "disabled");
|
||||||
|
assert.equal(report.countsByBucket.disabled, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectScaffoldDrift_disabled_bucket_is_not_actionable", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const target = join(root, "AGENTS.md");
|
||||||
|
writeFileSync(target, "# AGENTS\n", "utf-8");
|
||||||
|
stampScaffoldFile(target, "AGENTS.md", "1.0.0", "disabled");
|
||||||
|
|
||||||
|
const report = detectScaffoldDrift(root);
|
||||||
|
// Disabled files should not appear in missing/upgradable/editing-drift.
|
||||||
|
const actionable = report.items.filter(
|
||||||
|
(i) => i.path === "AGENTS.md" && i.bucket !== "disabled",
|
||||||
|
);
|
||||||
|
assert.equal(actionable.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── profile-aware drift detection ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("profile-aware drift", () => {
|
||||||
|
test("detectScaffoldDrift_skips_missing_files_not_in_profile", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
// Use "minimal" profile — only .siftignore and AGENTS.md.
|
||||||
|
// A missing file from "app" profile (e.g. ARCHITECTURE.md) should NOT appear as missing.
|
||||||
|
const archFile = "ARCHITECTURE.md";
|
||||||
|
assert.ok(!PROFILES.minimal.has(archFile), "ARCHITECTURE.md should not be in minimal");
|
||||||
|
|
||||||
|
const report = detectScaffoldDrift(root, "minimal");
|
||||||
|
const item = report.items.find((i) => i.path === archFile);
|
||||||
|
// Should not be in missing bucket — it's out of profile.
|
||||||
|
if (item) {
|
||||||
|
assert.notEqual(item.bucket, "missing");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectScaffoldDrift_reports_missing_files_in_profile", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
// AGENTS.md is in every profile — should appear as missing.
|
||||||
|
const report = detectScaffoldDrift(root, "minimal");
|
||||||
|
const item = report.items.find((i) => i.path === "AGENTS.md");
|
||||||
|
assert.ok(item, "AGENTS.md should be in drift report for minimal profile");
|
||||||
|
assert.equal(item.bucket, "missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── profile auto-detection ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("detectRepoProfile", () => {
|
||||||
|
test("detects_nix_repo_as_infra", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(join(root, "flake.nix"), "# nix\n", "utf-8");
|
||||||
|
assert.equal(detectRepoProfile(root), "infra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects_terraform_repo_as_infra", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(join(root, "main.tf"), "# terraform\n", "utf-8");
|
||||||
|
assert.equal(detectRepoProfile(root), "infra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects_node_react_repo_as_app", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "package.json"),
|
||||||
|
JSON.stringify({ dependencies: { react: "^18.0.0" } }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
assert.equal(detectRepoProfile(root), "app");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects_node_non_ui_repo_as_library", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(
|
||||||
|
join(root, "package.json"),
|
||||||
|
JSON.stringify({ dependencies: { express: "^4.0.0" } }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
assert.equal(detectRepoProfile(root), "library");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects_go_repo_as_library", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(join(root, "go.mod"), "module example.com\n", "utf-8");
|
||||||
|
assert.equal(detectRepoProfile(root), "library");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects_no_source_with_docs_dir_as_docs", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
mkdirSync(join(root, "docs"));
|
||||||
|
assert.equal(detectRepoProfile(root), "docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls_back_to_app_for_unknown_shape", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
assert.equal(detectRepoProfile(root), "app");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── ensureAgenticDocsScaffold — profile-aware ────────────────────────────────
|
||||||
|
|
||||||
|
describe("ensureAgenticDocsScaffold profile-aware", () => {
|
||||||
|
test("writes_profile_to_manifest_on_first_run_when_nix_detected", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(join(root, "flake.nix"), "# nix\n", "utf-8");
|
||||||
|
|
||||||
|
ensureAgenticDocsScaffold(root);
|
||||||
|
|
||||||
|
const manifest = readScaffoldManifest(root);
|
||||||
|
assert.equal(manifest.profile, "infra");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does_not_write_out_of_profile_files", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
writeFileSync(join(root, "flake.nix"), "# nix\n", "utf-8");
|
||||||
|
|
||||||
|
ensureAgenticDocsScaffold(root);
|
||||||
|
|
||||||
|
// FRONTEND.md is in "app" but not in "infra" — must not be written.
|
||||||
|
assert.ok(!PROFILES.infra.has("docs/FRONTEND.md"), "precondition: FRONTEND.md not in infra");
|
||||||
|
assert.equal(existsSync(join(root, "docs/FRONTEND.md")), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── migrate algorithm ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("scaffold migrate", () => {
|
||||||
|
test("parseScaffoldMigrateArgs_parses_profile_prune_dry_run", () => {
|
||||||
|
const opts = parseScaffoldMigrateArgs("--profile=infra --prune --dry-run");
|
||||||
|
assert.equal(opts.profile, "infra");
|
||||||
|
assert.equal(opts.prune, true);
|
||||||
|
assert.equal(opts.dryRun, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_disables_pending_clean_hash_files_outside_target_profile", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
// Find a file that is in app but not in infra.
|
||||||
|
const appOnly = SCAFFOLD_FILES.find(
|
||||||
|
(f) => PROFILES.app.has(f.path) && !PROFILES.infra.has(f.path),
|
||||||
|
);
|
||||||
|
assert.ok(appOnly, "precondition: at least one file in app not in infra");
|
||||||
|
|
||||||
|
writePendingFile(root, appOnly.path);
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(root, "infra");
|
||||||
|
assert.ok(
|
||||||
|
result.disabled.includes(appOnly.path),
|
||||||
|
`expected ${appOnly.path} to be disabled`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_does_not_disable_editing_or_completed_files", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const appOnly = SCAFFOLD_FILES.find(
|
||||||
|
(f) => PROFILES.app.has(f.path) && !PROFILES.infra.has(f.path),
|
||||||
|
);
|
||||||
|
assert.ok(appOnly, "precondition: at least one file in app not in infra");
|
||||||
|
|
||||||
|
const target = join(root, appOnly.path);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, `# ${appOnly.path}\n`, "utf-8");
|
||||||
|
stampScaffoldFile(target, appOnly.path, "1.0.0", "editing");
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(root, "infra");
|
||||||
|
assert.ok(!result.disabled.includes(appOnly.path));
|
||||||
|
assert.ok(result.warnings.some((w) => w.path === appOnly.path));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_does_not_disable_hash_diverged_pending_files", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const appOnly = SCAFFOLD_FILES.find(
|
||||||
|
(f) => PROFILES.app.has(f.path) && !PROFILES.infra.has(f.path),
|
||||||
|
);
|
||||||
|
assert.ok(appOnly, "precondition");
|
||||||
|
|
||||||
|
// Write file, stamp pending, then modify content so hash diverges.
|
||||||
|
const target = join(root, appOnly.path);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, "original content\n", "utf-8");
|
||||||
|
stampScaffoldFile(target, appOnly.path, "1.0.0", "pending");
|
||||||
|
// Now modify content (simulating user edit without changing the marker).
|
||||||
|
const { marker } = extractMarker(target);
|
||||||
|
const line1 = readFileSync(target, "utf-8").split("\n")[0];
|
||||||
|
writeFileSync(target, `${line1}\nuser edited content\n`, "utf-8");
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(root, "infra");
|
||||||
|
assert.ok(!result.disabled.includes(appOnly.path));
|
||||||
|
assert.ok(result.warnings.some((w) => w.path === appOnly.path));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_reenables_disabled_file_in_target_profile_with_clean_hash", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
// Find a file in infra profile.
|
||||||
|
const infraPath = [...PROFILES.infra][0];
|
||||||
|
assert.ok(infraPath, "precondition: infra has at least one file");
|
||||||
|
|
||||||
|
// Stamp it as disabled.
|
||||||
|
const target = join(root, infraPath);
|
||||||
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
|
writeFileSync(target, `# ${infraPath}\n`, "utf-8");
|
||||||
|
stampScaffoldFile(target, infraPath, "1.0.0", "disabled");
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(root, "infra");
|
||||||
|
assert.ok(
|
||||||
|
result.reEnabled.includes(infraPath),
|
||||||
|
`expected ${infraPath} to be re-enabled`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_dry_run_does_not_modify_files", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const appOnly = SCAFFOLD_FILES.find(
|
||||||
|
(f) => PROFILES.app.has(f.path) && !PROFILES.infra.has(f.path),
|
||||||
|
);
|
||||||
|
assert.ok(appOnly, "precondition");
|
||||||
|
writePendingFile(root, appOnly.path);
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(root, "infra", { dryRun: true });
|
||||||
|
assert.ok(result.disabled.includes(appOnly.path));
|
||||||
|
|
||||||
|
// File should still be pending on disk.
|
||||||
|
const { marker } = extractMarker(join(root, appOnly.path));
|
||||||
|
assert.equal(marker?.state, "pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_prune_deletes_pending_clean_hash_files", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
const appOnly = SCAFFOLD_FILES.find(
|
||||||
|
(f) => PROFILES.app.has(f.path) && !PROFILES.infra.has(f.path),
|
||||||
|
);
|
||||||
|
assert.ok(appOnly, "precondition");
|
||||||
|
writePendingFile(root, appOnly.path);
|
||||||
|
|
||||||
|
const result = runScaffoldMigrate(root, "infra", { prune: true });
|
||||||
|
assert.ok(result.pruned.includes(appOnly.path));
|
||||||
|
assert.equal(existsSync(join(root, appOnly.path)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runScaffoldMigrate_updates_manifest_profile", () => {
|
||||||
|
const root = makeProject();
|
||||||
|
runScaffoldMigrate(root, "infra");
|
||||||
|
const manifest = readScaffoldManifest(root);
|
||||||
|
assert.equal(manifest.profile, "infra");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue