@khoavu882/doflow 2.0.0-beta.3 → 2.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doflow.js +2 -2
- package/core/.antigravity-plugin/plugin.json +0 -1
- package/core/.claude-plugin/plugin.json +1 -1
- package/core/.codex-plugin/plugin.json +1 -1
- package/core/.plugin/plugin.json +1 -1
- package/core/harnesses/claude/hooks/mcp-tool-guard.sh +7 -17
- package/core/harnesses/claude/hooks/pre-bash-guard.sh +7 -17
- package/core/harnesses/claude/hooks/pre-implement-gate.sh +7 -18
- package/core/harnesses/claude/hooks/session-start.sh +5 -3
- package/core/harnesses/claude/hooks/stop-check.sh +5 -3
- package/core/harnesses/claude/hooks/subagent-audit.sh +5 -5
- package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -3
- package/core/harnesses/codex/hooks/mcp-tool-guard.sh +7 -15
- package/core/harnesses/codex/hooks/pre-bash-guard.sh +7 -15
- package/core/harnesses/codex/hooks/pre-implement-gate.sh +7 -16
- package/core/harnesses/codex/hooks/session-start.sh +5 -3
- package/core/harnesses/codex/hooks/stop-check.sh +6 -5
- package/core/harnesses/codex/hooks/subagent-audit.sh +17 -6
- package/core/harnesses/codex/hooks/user-prompt-submit.sh +5 -4
- package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +7 -18
- package/core/harnesses/gemini/hooks/pre-bash-guard.sh +7 -18
- package/core/harnesses/gemini/hooks/pre-implement-gate.sh +7 -19
- package/core/harnesses/gemini/hooks/session-start.sh +5 -3
- package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +20 -5
- package/core/harnesses/kiro/hooks/pre-bash-guard.sh +20 -5
- package/core/harnesses/kiro/hooks/pre-implement-gate.sh +20 -5
- package/core/harnesses/kiro/hooks/session-start.sh +11 -5
- package/core/harnesses/kiro/hooks/stop-check.sh +21 -30
- package/core/harnesses/pi/extensions/doflow-guard.js +159 -0
- package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +27 -11
- package/core/harnesses/shared/hooks/adapters/antigravity.js +14 -57
- package/core/harnesses/shared/hooks/bridge/bridge.sh +184 -0
- package/core/harnesses/shared/hooks/bridge/envelope.jq +60 -0
- package/core/harnesses/shared/hooks/bridge/respond.sh +62 -0
- package/core/harnesses/shared/hooks/policies/capture-failure.sh +4 -2
- package/core/harnesses/shared/hooks/policies/contract.sh +75 -0
- package/core/harnesses/shared/hooks/policies/deny-json.sh +39 -4
- package/core/harnesses/shared/hooks/policies/lib.sh +4 -2
- package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +46 -34
- package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +75 -25
- package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +67 -73
- package/core/harnesses/shared/hooks/policies/session-context.sh +37 -7
- package/core/harnesses/shared/hooks/policies/stop-check.sh +42 -15
- package/core/harnesses/shared/hooks/policies/subagent-audit.sh +35 -5
- package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +40 -7
- package/core/harnesses/shared/hooks/stream-hook-runner.js +69 -181
- package/core/registry/assets.json +19 -0
- package/core/registry/contracts.json +2 -2
- package/core/registry/harnesses.json +196 -10
- package/core/registry/lifecycle.json +2626 -7
- package/core/registry/storage.json +110 -83
- package/core/registry/verification.json +1 -1
- package/core/shared/scripts/doflow/bash/render-puml.sh +2 -2
- package/core/shared/scripts/doflow/bin/doflow-run +10 -6
- package/core/shared/skills/do-design/SKILL.md +6 -6
- package/core/shared/skills/do-execute-plan/SKILL.md +11 -10
- package/core/shared/skills/do-execute-plan/references/readiness_gate.md +2 -0
- package/core/shared/skills/do-execute-plan/references/scaffold.md +2 -2
- package/core/shared/skills/do-implement/SKILL.md +3 -2
- package/core/shared/skills/do-plan/SKILL.md +7 -8
- package/package.json +4 -2
- package/src/adapters/antigravity/{index.ts → index.js} +141 -105
- package/src/adapters/claude/{index.ts → index.js} +63 -49
- package/src/adapters/claude/{mcp.ts → mcp.js} +16 -16
- package/src/adapters/codex/{agents.ts → agents.js} +24 -24
- package/src/adapters/codex/{config.ts → config.js} +49 -49
- package/src/adapters/codex/{hooks.ts → hooks.js} +62 -27
- package/src/adapters/codex/{index.ts → index.js} +148 -105
- package/src/adapters/codex/{mcp.ts → mcp.js} +38 -38
- package/src/adapters/codex/{native-projection.ts → native-projection.js} +5 -5
- package/src/adapters/copilot/{index.ts → index.js} +42 -42
- package/src/adapters/{copy-tree.ts → copy-tree.js} +74 -74
- package/src/adapters/gemini/{hooks.ts → hooks.js} +78 -37
- package/src/adapters/gemini/{index.ts → index.js} +96 -57
- package/src/adapters/{hook-commands.ts → hook-commands.js} +8 -8
- package/src/adapters/{index.ts → index.js} +13 -13
- package/src/adapters/kiro/{index.ts → index.js} +49 -35
- package/src/adapters/{mcp-entries.ts → mcp-entries.js} +55 -55
- package/src/adapters/opencode/{index.ts → index.js} +52 -52
- package/src/adapters/pi/hooks.js +119 -0
- package/src/adapters/pi/{index.ts → index.js} +64 -39
- package/src/adapters/pi/{json-members.ts → json-members.js} +23 -23
- package/src/adapters/pi/{mcp.ts → mcp.js} +70 -70
- package/src/adapters/{resolve-source.ts → resolve-source.js} +1 -1
- package/src/cli/commands/{install.ts → install.js} +25 -25
- package/src/cli/commands/{list-backups.ts → list-backups.js} +4 -4
- package/src/cli/commands/{reconcile.ts → reconcile.js} +19 -19
- package/src/cli/commands/{remove.ts → remove.js} +10 -10
- package/src/cli/commands/{rollback.ts → rollback.js} +17 -17
- package/src/cli/commands/{self-update.ts → self-update.js} +3 -3
- package/src/cli/commands/{status.ts → status.js} +24 -24
- package/src/cli/commands/{tools.ts → tools.js} +16 -16
- package/src/cli/commands/{update.ts → update.js} +20 -20
- package/src/cli/{index.ts → index.js} +43 -31
- package/src/cli/{runtime-commands.ts → runtime-commands.js} +97 -58
- package/src/cli/{shared.ts → shared.js} +40 -40
- package/src/helper/{bash-helper.ts → bash-helper.js} +7 -7
- package/src/helper/{git.ts → git.js} +2 -2
- package/src/helper/{guardrails.ts → guardrails.js} +10 -10
- package/src/helper/{harness-paths.ts → harness-paths.js} +24 -24
- package/src/helper/{marker-merge.ts → marker-merge.js} +4 -4
- package/src/helper/{prompt.ts → prompt.js} +5 -5
- package/src/helper/{repo-root.ts → repo-root.js} +2 -2
- package/src/helper/{settings-merge.ts → settings-merge.js} +27 -27
- package/src/helper/{settings-scope.ts → settings-scope.js} +3 -3
- package/src/helper/{toml.ts → toml.js} +48 -48
- package/src/install/{backup-set.ts → backup-set.js} +16 -16
- package/src/install/{backup.ts → backup.js} +65 -65
- package/src/install/{context.ts → context.js} +15 -15
- package/src/install/{manifest.ts → manifest.js} +27 -27
- package/src/install/{mcp.ts → mcp.js} +30 -30
- package/src/install/{paths.ts → paths.js} +15 -15
- package/src/install/{targets.ts → targets.js} +10 -10
- package/src/install/{tool-lifecycle.ts → tool-lifecycle.js} +20 -20
- package/src/lifecycle/{bash-availability.ts → bash-availability.js} +1 -1
- package/src/lifecycle/coverage.js +57 -0
- package/src/lifecycle/hook-step.js +61 -0
- package/src/lifecycle/{index.ts → index.js} +160 -146
- package/src/lifecycle/{mcp-index.ts → mcp-index.js} +1 -1
- package/src/lifecycle/{policies.ts → policies.js} +11 -11
- package/src/lifecycle/{view.ts → view.js} +28 -28
- package/src/registry/{index.ts → index.js} +195 -66
- package/src/release/build-package.js +91 -0
- package/src/release/changelog-section.js +70 -0
- package/src/release/{sync-plugin-versions.ts → sync-plugin-versions.js} +4 -4
- package/src/runtime/advice/{cli.ts → cli.js} +31 -31
- package/src/runtime/advice/{client.ts → client.js} +44 -44
- package/src/runtime/advice/{config.ts → config.js} +16 -16
- package/src/runtime/advice/{doctor.ts → doctor.js} +32 -32
- package/src/runtime/advice/{endpoint-guard.ts → endpoint-guard.js} +20 -20
- package/src/runtime/advice/{kinds.ts → kinds.js} +16 -16
- package/src/runtime/advice/providers/{ollama-systemone.ts → ollama-systemone.js} +21 -21
- package/src/runtime/advice/{record.ts → record.js} +19 -19
- package/src/runtime/advice/{registry.ts → registry.js} +16 -16
- package/src/runtime/advice/{strength.ts → strength.js} +7 -7
- package/src/runtime/{brief-tools.ts → brief-tools.js} +50 -50
- package/src/runtime/{c4-project.ts → c4-project.js} +27 -27
- package/src/runtime/candidate.js +505 -0
- package/src/runtime/{capability-router.ts → capability-router.js} +39 -39
- package/src/runtime/{checkouts.ts → checkouts.js} +21 -21
- package/src/runtime/{claims.ts → claims.js} +44 -35
- package/src/runtime/{cli-result.ts → cli-result.js} +15 -8
- package/src/runtime/{cli.ts → cli.js} +59 -292
- package/src/runtime/{command-detect.ts → command-detect.js} +47 -47
- package/src/runtime/context/budget.js +203 -0
- package/src/runtime/context/compose.js +1367 -0
- package/src/runtime/context/delivery-store.js +102 -0
- package/src/runtime/context/mandatory.js +594 -0
- package/src/runtime/context/pointer.js +142 -0
- package/src/runtime/context-pack.js +1277 -0
- package/src/runtime/{decision-register.ts → decision-register.js} +67 -67
- package/src/runtime/evidence-fields.js +39 -0
- package/src/runtime/{evidence-ledger.ts → evidence-ledger.js} +32 -25
- package/src/runtime/failure/{capture.ts → capture.js} +29 -29
- package/src/runtime/failure/{classifier.ts → classifier.js} +4 -4
- package/src/runtime/failure/{cli.ts → cli.js} +30 -30
- package/src/runtime/failure/{home.ts → home.js} +6 -6
- package/src/runtime/failure/{store.ts → store.js} +32 -32
- package/src/runtime/{feature-resolve.ts → feature-resolve.js} +6 -6
- package/src/runtime/{freshness.ts → freshness.js} +59 -22
- package/src/runtime/{health.ts → health.js} +72 -72
- package/src/runtime/{history-compactor.ts → history-compactor.js} +25 -25
- package/src/runtime/{implementation-gate.ts → implementation-gate.js} +30 -30
- package/src/runtime/inventory/{coverage.ts → coverage.js} +10 -10
- package/src/runtime/inventory/{identity.ts → identity.js} +2 -2
- package/src/runtime/inventory/{index.ts → index.js} +36 -36
- package/src/runtime/inventory/{precedence.ts → precedence.js} +2 -2
- package/src/runtime/inventory/{read-scopes.ts → read-scopes.js} +9 -9
- package/src/runtime/inventory/{siblings.ts → siblings.js} +15 -15
- package/src/runtime/knowledge/{chunker.ts → chunker.js} +12 -12
- package/src/runtime/knowledge/{hybrid.ts → hybrid.js} +17 -17
- package/src/runtime/knowledge/{index-store.ts → index-store.js} +23 -23
- package/src/runtime/knowledge/{retrieval.ts → retrieval.js} +27 -27
- package/src/runtime/{leak-scan.ts → leak-scan.js} +12 -12
- package/src/runtime/lifecycle/{cli.ts → cli.js} +32 -32
- package/src/runtime/lifecycle/{event-store.ts → event-store.js} +48 -48
- package/src/runtime/lifecycle/{fold.ts → fold.js} +41 -41
- package/src/runtime/lifecycle/{followup.ts → followup.js} +54 -54
- package/src/runtime/lifecycle/{goal.ts → goal.js} +50 -50
- package/src/runtime/lifecycle/{intent-writer.ts → intent-writer.js} +5 -5
- package/src/runtime/lifecycle/{overview.ts → overview.js} +47 -47
- package/src/runtime/lifecycle/{release.ts → release.js} +29 -29
- package/src/runtime/lifecycle/{report-store.ts → report-store.js} +25 -25
- package/src/runtime/lifecycle/{retention.ts → retention.js} +37 -37
- package/src/runtime/lifecycle/{root.ts → root.js} +9 -9
- package/src/runtime/lifecycle/{status.ts → status.js} +64 -64
- package/src/runtime/lifecycle/{store-upkeep.ts → store-upkeep.js} +46 -46
- package/src/runtime/{locator-resolve.ts → locator-resolve.js} +7 -7
- package/src/runtime/{mask.ts → mask.js} +22 -22
- package/src/runtime/{mcp-consultations.ts → mcp-consultations.js} +54 -54
- package/src/runtime/{mcp-reach.ts → mcp-reach.js} +24 -24
- package/src/runtime/{model-router.ts → model-router.js} +20 -20
- package/src/runtime/{outcome.ts → outcome.js} +27 -27
- package/src/runtime/{reach.ts → reach.js} +21 -21
- package/src/runtime/{readiness-record.ts → readiness-record.js} +12 -12
- package/src/runtime/{readiness.ts → readiness.js} +113 -43
- package/src/runtime/{recovery.ts → recovery.js} +15 -15
- package/src/runtime/{research-request.ts → research-request.js} +34 -34
- package/src/runtime/{retrieval-plan.ts → retrieval-plan.js} +453 -104
- package/src/runtime/retrieval-record.js +223 -0
- package/src/runtime/scaffold/{artifacts.ts → artifacts.js} +22 -19
- package/src/runtime/scaffold/{fingerprint.ts → fingerprint.js} +3 -3
- package/src/runtime/scaffold/{generate.ts → generate.js} +109 -109
- package/src/runtime/scaffold/{languages.ts → languages.js} +34 -34
- package/src/runtime/source-version.js +289 -0
- package/src/runtime/storage/{cli.ts → cli.js} +9 -9
- package/src/runtime/storage/{engine.ts → engine.js} +37 -37
- package/src/runtime/storage/{registry.ts → registry.js} +19 -19
- package/src/runtime/storage/{roots.ts → roots.js} +8 -8
- package/src/runtime/storage/{rules.ts → rules.js} +26 -26
- package/src/runtime/storage/{upkeep.ts → upkeep.js} +35 -35
- package/src/runtime/storage/{work-status.ts → work-status.js} +39 -39
- package/src/runtime/{task-attempts.ts → task-attempts.js} +62 -62
- package/src/runtime/{task-classifier.ts → task-classifier.js} +34 -34
- package/src/runtime/{task-scope.ts → task-scope.js} +40 -15
- package/src/runtime/{task-state.ts → task-state.js} +86 -21
- package/src/runtime/trace/{indicators.ts → indicators.js} +22 -22
- package/src/runtime/trace/{ledger.ts → ledger.js} +42 -42
- package/src/runtime/trace/{render.ts → render.js} +7 -7
- package/src/runtime/trace/{views.ts → views.js} +40 -40
- package/src/runtime/verification/{contract-runner.ts → contract-runner.js} +23 -23
- package/src/runtime/verification/{engine.ts → engine.js} +143 -143
- package/src/runtime/verification/{registry.ts → registry.js} +6 -6
- package/src/runtime/verification/{scope-bound.ts → scope-bound.js} +50 -50
- package/src/runtime/verification/{test-selection.ts → test-selection.js} +28 -28
- package/src/runtime/{workflow-engine.ts → workflow-engine.js} +50 -50
- package/src/runtime/{workflow-orchestrator.ts → workflow-orchestrator.js} +91 -91
- package/src/runtime/{worktree.ts → worktree.js} +31 -31
- package/src/state/{index.ts → index.js} +26 -26
- package/src/state/{lockfile.ts → lockfile.js} +20 -20
- package/src/state/{run-lock.ts → run-lock.js} +44 -44
- package/core/.antigravity-plugin/hooks.json +0 -39
- package/core/harnesses/shared/hooks/adapters/arg-fields.js +0 -19
- package/core/harnesses/shared/hooks/adapters/gemini.js +0 -114
- package/core/harnesses/shared/hooks/hooks.json +0 -47
- package/src/runtime/context-pack.ts +0 -492
|
@@ -35,26 +35,26 @@
|
|
|
35
35
|
|
|
36
36
|
const nodeFs = require('node:fs');
|
|
37
37
|
const { spawnSync } = require('node:child_process');
|
|
38
|
-
const { isSafeSlug, invalidSlugRefusal } = require('../task-scope.
|
|
39
|
-
const { appendEvents, readFold } = require('./event-store.
|
|
40
|
-
const { deriveStatuses, readGitFacts, behindNote, subjectNames, readMerges, readBranchTips, readHistory } = require('./status.
|
|
41
|
-
const { FollowupUsageError, oneLine, channelBy } = require('./followup.
|
|
38
|
+
const { isSafeSlug, invalidSlugRefusal } = require('../task-scope.js');
|
|
39
|
+
const { appendEvents, readFold } = require('./event-store.js');
|
|
40
|
+
const { deriveStatuses, readGitFacts, behindNote, subjectNames, readMerges, readBranchTips, readHistory } = require('./status.js');
|
|
41
|
+
const { FollowupUsageError, oneLine, channelBy } = require('./followup.js');
|
|
42
42
|
|
|
43
43
|
/** The release-tag pattern of IC-020, the one `git-state` filters by. */
|
|
44
44
|
const RELEASE_TAG = /^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$/;
|
|
45
45
|
|
|
46
|
-
const refusal = (action
|
|
46
|
+
const refusal = (action , finding , message ) => ({ ok: false, action, finding, message });
|
|
47
47
|
|
|
48
48
|
/** Trimmed stdout of a git call, or null when git exits non-zero. */
|
|
49
|
-
function gitOut(root
|
|
49
|
+
function gitOut(root , args ) {
|
|
50
50
|
const run = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
51
|
return run.status === 0 ? run.stdout.trim() : null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function commitOf(root
|
|
54
|
+
function commitOf(root , ref ) { return gitOut(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]) || null; }
|
|
55
55
|
|
|
56
56
|
/** `a` is an ancestor of (or equal to) `b`. */
|
|
57
|
-
function isAncestor(root
|
|
57
|
+
function isAncestor(root , a , b ) {
|
|
58
58
|
return spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: root, stdio: 'ignore' }).status === 0;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -63,14 +63,14 @@ function isAncestor(root: any, a: any, b: any) {
|
|
|
63
63
|
* since tracking, whose subject names the slug or whose non-first parent is an ancestor of the branch tip.
|
|
64
64
|
* `merges` are the pinned ref's first-parent merges inside X, newest first; `history` holds X and the tip.
|
|
65
65
|
*/
|
|
66
|
-
function earlierMerge({ slug, evidence, tip, lowerBound, merges, bound, history }
|
|
66
|
+
function earlierMerge({ slug, evidence, tip, lowerBound, merges, bound, history } ) {
|
|
67
67
|
if (tip && history.isAncestor(tip, bound)) return { ...evidence };
|
|
68
68
|
for (const merge of merges) {
|
|
69
69
|
if (merge.ct < lowerBound) continue;
|
|
70
70
|
// The merged side is this branch's own when it is an ancestor of the tip and the merge itself is not: a
|
|
71
71
|
// branch cut from the integration ref after that merge has the merge in its history and did not make it.
|
|
72
72
|
// A parent outside the first parent's history is one this merge introduced.
|
|
73
|
-
const own = (parent
|
|
73
|
+
const own = (parent ) => history.introducer(parent) === merge.sha && history.isAncestor(parent, tip);
|
|
74
74
|
if (tip && merge.parents.slice(1).some(own) && !history.isAncestor(merge.sha, tip)) return { ...evidence };
|
|
75
75
|
if (subjectNames(merge.subject, slug)) return { kind: 'merge-subject', ref: merge.sha.slice(0, 7) };
|
|
76
76
|
}
|
|
@@ -83,14 +83,14 @@ function earlierMerge({ slug, evidence, tip, lowerBound, merges, bound, history
|
|
|
83
83
|
* other than `tag` merged into X (git's version order). A `v*` tag that is not a release (`vnext`)
|
|
84
84
|
* never qualifies.
|
|
85
85
|
*/
|
|
86
|
-
function previousTagOf(root
|
|
86
|
+
function previousTagOf(root , { tag, facts, bound } ) {
|
|
87
87
|
const known = new Set(facts.release_tags);
|
|
88
88
|
if (facts.base_tag && facts.base_tag !== tag && known.has(facts.base_tag) && isAncestor(root, `refs/tags/${facts.base_tag}`, bound)) return facts.base_tag;
|
|
89
89
|
const merged = gitOut(root, ['tag', '--list', 'v*', '--sort=-v:refname', '--merged', bound]);
|
|
90
|
-
return (merged || '').split('\n').find((name
|
|
90
|
+
return (merged || '').split('\n').find((name ) => name && name !== tag && known.has(name)) || null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function slugList(raw
|
|
93
|
+
function slugList(raw , label ) {
|
|
94
94
|
const list = [...new Set((Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw]).map(String))];
|
|
95
95
|
for (const slug of list) if (!isSafeSlug(slug)) throw new FollowupUsageError(`${label}: ${invalidSlugRefusal(slug).message}`);
|
|
96
96
|
return list;
|
|
@@ -107,7 +107,7 @@ function slugList(raw: any, label: any) {
|
|
|
107
107
|
* @param {string} [options.channel]
|
|
108
108
|
* @param {Object} [options.facts] `readGitFacts` output, for a caller that already has it
|
|
109
109
|
*/
|
|
110
|
-
function releaseFeatures({ root, tag, confirm = false, features, exclude, channel, now = new Date(), fsImpl = nodeFs, facts: givenFacts }
|
|
110
|
+
function releaseFeatures({ root, tag, confirm = false, features, exclude, channel, now = new Date(), fsImpl = nodeFs, facts: givenFacts } ) {
|
|
111
111
|
if (typeof tag !== 'string' || !RELEASE_TAG.test(tag)) throw new FollowupUsageError(`--tag is required and must look like v1.2.3 or v1.2.3-rc.1 (got '${tag ?? ''}')`);
|
|
112
112
|
const added = slugList(features, '--feature');
|
|
113
113
|
const excluded = slugList(exclude, '--exclude');
|
|
@@ -118,7 +118,7 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
118
118
|
if (!facts.integration_ref) return refusal('release', 'no-integration-ref', 'no integration ref resolves (develop, main, master, origin/HEAD), so no release can be previewed or recorded. Nothing was written.');
|
|
119
119
|
|
|
120
120
|
const fold = readFold(root, { fsImpl, now });
|
|
121
|
-
const trackedSlugs = new Set(fold.features.map((f
|
|
121
|
+
const trackedSlugs = new Set(fold.features.map((f ) => f.slug));
|
|
122
122
|
// An exclusion is permanent for its tag, so a mistyped slug must not be recorded any more than a mistyped addition.
|
|
123
123
|
const untracked = [...new Set([...added, ...excluded])].filter((slug) => !trackedSlugs.has(slug));
|
|
124
124
|
if (untracked.length) {
|
|
@@ -126,7 +126,7 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
// DEC-030: containment in the tag being recorded never finishes a feature before its record exists.
|
|
129
|
-
const otherTags = { ...facts, release_tags: facts.release_tags.filter((name
|
|
129
|
+
const otherTags = { ...facts, release_tags: facts.release_tags.filter((name ) => name !== tag) };
|
|
130
130
|
const derived = deriveStatuses({ root, fold, facts: otherTags });
|
|
131
131
|
if (derived.releaseMode === 'unknown' || !derived.integrationSha) return refusal('release', 'no-integration-ref', `${derived.reason || `${facts.integration_ref} does not resolve to a commit`}. Nothing was written.`);
|
|
132
132
|
|
|
@@ -137,23 +137,23 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
137
137
|
// A hotfix merged only into the tag's own history has no evidence on the integration chain: derive against the tag as well.
|
|
138
138
|
const atTag = tagCommit && tagCommit !== derived.integrationSha ? deriveStatuses({ root, fold, facts: { ...otherTags, integration_ref: tagCommit } }) : null;
|
|
139
139
|
|
|
140
|
-
const recorded = new Set(fold.releases.flatMap((release
|
|
140
|
+
const recorded = new Set(fold.releases.flatMap((release ) => release.features.map((f ) => f.slug)));
|
|
141
141
|
const tagged = derived.releaseMode === 'tagged';
|
|
142
142
|
// While X is the pinned commit, every evidence commit is in its history. Once X is a tag elsewhere, one
|
|
143
143
|
// graph read of the pinned ref, X and the evidence branch tips answers every ancestry question about X.
|
|
144
|
-
let read
|
|
144
|
+
let read = null;
|
|
145
145
|
const readOfX = () => {
|
|
146
146
|
if (read) return read;
|
|
147
|
-
const branchRefs = new Set(fold.features.map((f
|
|
147
|
+
const branchRefs = new Set(fold.features.map((f ) => derived.statuses[f.slug].evidence).filter((e ) => e && e.kind === 'branch').map((e ) => e.ref));
|
|
148
148
|
// Local branches are listed first, so a ref resolves as refs/heads/<ref> before refs/remotes/<ref>.
|
|
149
149
|
const tips = new Map();
|
|
150
150
|
for (const t of readBranchTips(root)) if (branchRefs.has(t.short) && !tips.has(t.short)) tips.set(t.short, t.sha);
|
|
151
151
|
const history = readHistory(root, derived.integrationSha, [bound, ...tips.values()]);
|
|
152
|
-
const merges = readMerges(root, derived.integrationSha).filter((m
|
|
152
|
+
const merges = readMerges(root, derived.integrationSha).filter((m ) => history.isAncestor(m.sha, bound)).reverse();
|
|
153
153
|
read = { history, merges, tips };
|
|
154
154
|
return read;
|
|
155
155
|
};
|
|
156
|
-
const inX = (commit
|
|
156
|
+
const inX = (commit ) => bound === derived.integrationSha || readOfX().history.isAncestor(commit, bound);
|
|
157
157
|
const candidates = [];
|
|
158
158
|
const notDetected = [];
|
|
159
159
|
// A git failure while reading X's history is an environment fault: a refusal, never a throw that would
|
|
@@ -181,7 +181,7 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
181
181
|
}
|
|
182
182
|
if (found) candidates.push({ slug, evidence: found.kind, ref: found.ref }); else notDetected.push(slug);
|
|
183
183
|
}
|
|
184
|
-
} catch (error
|
|
184
|
+
} catch (error ) {
|
|
185
185
|
return refusal('release', 'no-integration-ref', `git could not answer while reading the history of ${tagCommit ? tag : facts.integration_ref} (git-state-failed): ${String(error.message).split('\n')[0]}. Nothing was written.`);
|
|
186
186
|
}
|
|
187
187
|
|
|
@@ -189,9 +189,9 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
189
189
|
for (const slug of added) if (!shipped.some((c) => c.slug === slug)) shipped.push({ slug, evidence: 'confirmed', ref: null });
|
|
190
190
|
const finishing = shipped.filter((c) => !excluded.includes(c.slug));
|
|
191
191
|
const finishingSlugs = new Set(finishing.map((c) => c.slug));
|
|
192
|
-
const followupsDone = fold.followups.filter((item
|
|
192
|
+
const followupsDone = fold.followups.filter((item ) => item.state === 'taken' && finishingSlugs.has(item.takenBy)).map((item ) => item.id);
|
|
193
193
|
|
|
194
|
-
const base
|
|
194
|
+
const base = { ok: true, action: 'release', tag, previousTag, bound: tagCommit ? tag : facts.integration_ref, integrationRef: facts.integration_ref };
|
|
195
195
|
if (derived.integrationBehind > 0) base.note = behindNote(facts.integration_ref, derived.integrationBehind);
|
|
196
196
|
|
|
197
197
|
if (!confirm) {
|
|
@@ -206,7 +206,7 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
206
206
|
if (!tagCommit) return refusal('release', 'tag-missing', `${tag} does not exist; create the tag first, then confirm. Nothing was written.`);
|
|
207
207
|
const out = appendEvents(root, [{ type: 'release.recorded', by, data: { tag, commit: tagCommit, features: finishing, excluded } }], { now, fsImpl });
|
|
208
208
|
if (!out.ok) return refusal('release', out.finding, out.message);
|
|
209
|
-
return { ...base, recorded: true, candidates, added, excluded, notDetected, followupsDone, events: out.written.map((w
|
|
209
|
+
return { ...base, recorded: true, candidates, added, excluded, notDetected, followupsDone, events: out.written.map((w ) => w.file), next: [] };
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
/**
|
|
@@ -214,17 +214,17 @@ function releaseFeatures({ root, tag, confirm = false, features, exclude, channe
|
|
|
214
214
|
* cherry-pick). It records an observed fact after the merge, one `feature.merged` event, and the
|
|
215
215
|
* status stays derived from events on read, so nothing is written ahead of the fact (DEC-022).
|
|
216
216
|
*/
|
|
217
|
-
function recordMerged({ root, slug, reason, channel, now = new Date(), fsImpl = nodeFs }
|
|
217
|
+
function recordMerged({ root, slug, reason, channel, now = new Date(), fsImpl = nodeFs } ) {
|
|
218
218
|
if (!slug) throw new FollowupUsageError('--slug is required for --action merged');
|
|
219
219
|
if (!isSafeSlug(slug)) throw new FollowupUsageError(invalidSlugRefusal(slug).message);
|
|
220
|
-
const problems
|
|
220
|
+
const problems = [];
|
|
221
221
|
const cleanReason = reason === undefined ? null : oneLine(reason, '--reason', problems);
|
|
222
222
|
if (!problems.length && !cleanReason) problems.push('--reason is required for --action merged: how the merge happened, for example "squash-merged by hand"');
|
|
223
223
|
if (problems.length) throw new FollowupUsageError(problems.join('; '));
|
|
224
224
|
const by = channelBy(channel);
|
|
225
225
|
|
|
226
226
|
const fold = readFold(root, { fsImpl, now });
|
|
227
|
-
if (!fold.features.some((f
|
|
227
|
+
if (!fold.features.some((f ) => f.slug === slug)) {
|
|
228
228
|
return refusal('merged', 'untracked-feature', `${slug} is not a tracked feature, so no merge can be confirmed for it; run doflow-run lifecycle --action init --slug ${slug} first. Nothing was written.`);
|
|
229
229
|
}
|
|
230
230
|
const before = deriveStatuses({ root, fold }).statuses[slug];
|
|
@@ -235,7 +235,7 @@ function recordMerged({ root, slug, reason, channel, now = new Date(), fsImpl =
|
|
|
235
235
|
const out = appendEvents(root, [{ type: 'feature.merged', by, data: { slug, reason: cleanReason } }], { now, fsImpl });
|
|
236
236
|
if (!out.ok) return refusal('merged', out.finding, out.message);
|
|
237
237
|
const after = deriveStatuses({ root, fold: readFold(root, { fsImpl, now }) }).statuses[slug];
|
|
238
|
-
return { ok: true, action: 'merged', slug, status: after.status, evidence: after.evidence, release: after.release, events: out.written.map((w
|
|
238
|
+
return { ok: true, action: 'merged', slug, status: after.status, evidence: after.evidence, release: after.release, events: out.written.map((w ) => w.file), next: [] };
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
module.exports = { releaseFeatures, recordMerged, RELEASE_TAG };
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
const nodeFs = require('node:fs');
|
|
20
20
|
const path = require('node:path');
|
|
21
21
|
const crypto = require('node:crypto');
|
|
22
|
-
const mask = require('../mask.
|
|
22
|
+
const mask = require('../mask.js');
|
|
23
23
|
|
|
24
24
|
/** A `--file` or `--stdin` source is read for at most this many bytes; the rest could not be kept anyway. */
|
|
25
25
|
const MAX_READ_BYTES = 16 * 1024 * 1024;
|
|
@@ -44,32 +44,32 @@ class ReportInputError extends Error {}
|
|
|
44
44
|
* repeated here rather than required from the failure module, so the follow-up service still loads in
|
|
45
45
|
* a build or an install where the failure modules cannot be loaded (IC-016).
|
|
46
46
|
*/
|
|
47
|
-
function reportsHome(env
|
|
47
|
+
function reportsHome(env = process.env) {
|
|
48
48
|
const xdg = env.XDG_CONFIG_HOME;
|
|
49
49
|
if (typeof xdg === 'string' && xdg !== '') return path.isAbsolute(xdg) ? path.join(xdg, 'doflow', 'reports') : null;
|
|
50
50
|
const home = env.HOME;
|
|
51
51
|
return typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.config', 'doflow', 'reports') : null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function realProjectRoot(root
|
|
54
|
+
function realProjectRoot(root , fsImpl = nodeFs) {
|
|
55
55
|
try { return fsImpl.realpathSync(root); } catch { return root; }
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function projectKey(root
|
|
58
|
+
function projectKey(root , fsImpl = nodeFs) {
|
|
59
59
|
return crypto.createHash('sha256').update(realProjectRoot(root, fsImpl)).digest('hex').slice(0, 12);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** @returns {string|null} the body file of a report of this project, or null when no home resolves */
|
|
63
|
-
function bodyPath(root
|
|
63
|
+
function bodyPath(root , id , { env = process.env, fsImpl = nodeFs } = {}) {
|
|
64
64
|
const home = reportsHome(env);
|
|
65
65
|
return home === null ? null : path.join(home, projectKey(root, fsImpl), `${id}.txt`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** The `hasBody` the fold needs (IC-003): a report whose body file is on this machine. */
|
|
69
|
-
function bodyChecker(root
|
|
69
|
+
function bodyChecker(root , { env = process.env, fsImpl = nodeFs } = {}) {
|
|
70
70
|
const home = reportsHome(env);
|
|
71
71
|
const folder = home === null ? null : path.join(home, projectKey(root, fsImpl));
|
|
72
|
-
return (item
|
|
72
|
+
return (item ) => {
|
|
73
73
|
// The id comes from an event file, so it is checked before it becomes part of a path.
|
|
74
74
|
if (folder === null || !FU_ID.test(String(item.id)) || item.bodyRef !== `local:${item.id}`) return false;
|
|
75
75
|
try { return fsImpl.statSync(path.join(folder, `${item.id}.txt`)).isFile(); } catch { return false; }
|
|
@@ -77,8 +77,8 @@ function bodyChecker(root: string, { env = process.env, fsImpl = nodeFs }: any =
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Reads at most `max` bytes from an open descriptor. */
|
|
80
|
-
function readCapped(fsImpl
|
|
81
|
-
const chunks
|
|
80
|
+
function readCapped(fsImpl , fd , max ) {
|
|
81
|
+
const chunks = [];
|
|
82
82
|
let total = 0;
|
|
83
83
|
const chunk = Buffer.allocUnsafe(64 * 1024);
|
|
84
84
|
while (total < max) {
|
|
@@ -96,24 +96,24 @@ function readCapped(fsImpl: any, fd: number, max: number): Buffer {
|
|
|
96
96
|
* @param {{file?: string, stdin?: boolean, text?: string}} source
|
|
97
97
|
* @returns {string}
|
|
98
98
|
*/
|
|
99
|
-
function readReportText({ file, stdin: stdinFlag, text }
|
|
99
|
+
function readReportText({ file, stdin: stdinFlag, text } , { fsImpl = nodeFs } = {}) {
|
|
100
100
|
// `--file -` is stdin, as everywhere else a `-` stands for it.
|
|
101
101
|
const stdin = Boolean(stdinFlag) || file === '-';
|
|
102
102
|
if (file === '-') file = undefined;
|
|
103
103
|
const given = [file !== undefined, Boolean(stdin), text !== undefined].filter(Boolean).length;
|
|
104
104
|
if (given !== 1) throw new ReportInputError('give the report body with exactly one of --file <path>, --stdin or --text "<body>" (--file and --stdin also take text that starts with -)');
|
|
105
105
|
if (text !== undefined) return String(text);
|
|
106
|
-
let bytes
|
|
106
|
+
let bytes ;
|
|
107
107
|
if (stdin) {
|
|
108
|
-
try { bytes = readCapped(fsImpl, 0, MAX_READ_BYTES); } catch (error
|
|
108
|
+
try { bytes = readCapped(fsImpl, 0, MAX_READ_BYTES); } catch (error ) { throw new ReportInputError(`cannot read the report from stdin: ${error.message}`); }
|
|
109
109
|
} else {
|
|
110
|
-
const target = path.resolve(file
|
|
111
|
-
let fd
|
|
110
|
+
const target = path.resolve(file );
|
|
111
|
+
let fd ;
|
|
112
112
|
try {
|
|
113
113
|
if (!fsImpl.statSync(target).isFile()) throw new ReportInputError(`--file ${file} is not a regular file`);
|
|
114
114
|
fd = fsImpl.openSync(target, 'r');
|
|
115
|
-
bytes = readCapped(fsImpl, fd
|
|
116
|
-
} catch (error
|
|
115
|
+
bytes = readCapped(fsImpl, fd , MAX_READ_BYTES);
|
|
116
|
+
} catch (error ) {
|
|
117
117
|
if (error instanceof ReportInputError) throw error;
|
|
118
118
|
throw new ReportInputError(`cannot read --file ${file}: ${error.code || error.message}`);
|
|
119
119
|
} finally {
|
|
@@ -125,7 +125,7 @@ function readReportText({ file, stdin: stdinFlag, text }: { file?: string; stdin
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
/** The length of the longest prefix of `buffer` of at most `limit` bytes that ends on a UTF-8 boundary. */
|
|
128
|
-
function boundary(buffer
|
|
128
|
+
function boundary(buffer , limit ) {
|
|
129
129
|
let end = Math.min(limit, buffer.length);
|
|
130
130
|
if (end === buffer.length) return end;
|
|
131
131
|
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
|
|
@@ -139,7 +139,7 @@ function boundary(buffer: Buffer, limit: number): number {
|
|
|
139
139
|
* boundary; a token straddling the cut goes with the incomplete tail, never half-masked.
|
|
140
140
|
* @returns {{text: string, cut: boolean}}
|
|
141
141
|
*/
|
|
142
|
-
function boundMaskInput(raw
|
|
142
|
+
function boundMaskInput(raw ) {
|
|
143
143
|
const stripped = raw.replace(ANSI, '');
|
|
144
144
|
const whole = Buffer.from(stripped, 'utf8');
|
|
145
145
|
if (whole.length <= MASK_INPUT_BYTES) return { text: stripped, cut: false };
|
|
@@ -157,9 +157,9 @@ function boundMaskInput(raw: string): { text: string; cut: boolean } {
|
|
|
157
157
|
* @param {{home?: string}} [options] the home directory the mask replaces with `~`
|
|
158
158
|
* @returns {{buffer: Buffer, masked: number, truncated: boolean}}
|
|
159
159
|
*/
|
|
160
|
-
function prepareBody(raw
|
|
160
|
+
function prepareBody(raw , { home } = {}) {
|
|
161
161
|
const { text: bounded, cut } = boundMaskInput(raw);
|
|
162
|
-
let result
|
|
162
|
+
let result ;
|
|
163
163
|
try {
|
|
164
164
|
result = mask.maskBody(bounded, home === undefined ? undefined : { home });
|
|
165
165
|
} catch {
|
|
@@ -178,7 +178,7 @@ function prepareBody(raw: string, { home }: { home?: string } = {}): { buffer: B
|
|
|
178
178
|
* @param {Buffer} body
|
|
179
179
|
* @returns {string}
|
|
180
180
|
*/
|
|
181
|
-
function excerptOf(body
|
|
181
|
+
function excerptOf(body ) {
|
|
182
182
|
if (body.length <= EXCERPT_BYTES) return body.toString('utf8').replace(/\s+$/, '');
|
|
183
183
|
const head = body.subarray(0, boundary(body, EXCERPT_BYTES));
|
|
184
184
|
const lineEnd = head.lastIndexOf(0x0a);
|
|
@@ -189,7 +189,7 @@ function excerptOf(body: Buffer): string {
|
|
|
189
189
|
* Writes the body once: exclusive create, file 0600, folders 0700, never inside the repository.
|
|
190
190
|
* @returns {{ok: true, file: string}|{ok: false, reason: 'no-home'|'exists'|'error', message?: string}}
|
|
191
191
|
*/
|
|
192
|
-
function writeBody(root
|
|
192
|
+
function writeBody(root , id , buffer , { env = process.env, fsImpl = nodeFs } = {}) {
|
|
193
193
|
const file = bodyPath(root, id, { env, fsImpl });
|
|
194
194
|
if (file === null) return { ok: false, reason: 'no-home' };
|
|
195
195
|
const folder = path.dirname(file);
|
|
@@ -200,18 +200,18 @@ function writeBody(root: string, id: string, buffer: Buffer, { env = process.env
|
|
|
200
200
|
fsImpl.chmodSync(path.dirname(folder), 0o700);
|
|
201
201
|
try {
|
|
202
202
|
fsImpl.writeFileSync(path.join(folder, '.root'), `${realProjectRoot(root, fsImpl)}\n`, { flag: 'wx', mode: 0o600 });
|
|
203
|
-
} catch (error
|
|
203
|
+
} catch (error ) {
|
|
204
204
|
if (error.code !== 'EEXIST') throw error;
|
|
205
205
|
}
|
|
206
206
|
fsImpl.writeFileSync(file, buffer, { flag: 'wx', mode: 0o600 });
|
|
207
207
|
return { ok: true, file };
|
|
208
|
-
} catch (error
|
|
208
|
+
} catch (error ) {
|
|
209
209
|
return error.code === 'EEXIST' ? { ok: false, reason: 'exists' } : { ok: false, reason: 'error', message: error.code || error.message };
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
/** Removes a body this process just wrote, when the event that names it could not be written. */
|
|
214
|
-
function removeBody(file
|
|
214
|
+
function removeBody(file , fsImpl = nodeFs) {
|
|
215
215
|
try { fsImpl.rmSync(file, { force: true }); } catch { /* the failure being reported stands */ }
|
|
216
216
|
}
|
|
217
217
|
|
|
@@ -17,15 +17,15 @@
|
|
|
17
17
|
|
|
18
18
|
const nodeFs = require('node:fs');
|
|
19
19
|
const { isDeepStrictEqual } = require('node:util');
|
|
20
|
-
const { REPO_ROOT } = require('../../helper/repo-root.
|
|
21
|
-
const { loadStorageRegistry } = require('../storage/registry.
|
|
22
|
-
const { resolveRoots, readPruneSetting } = require('../storage/roots.
|
|
23
|
-
const { foldInto, finalize } = require('./fold.
|
|
20
|
+
const { REPO_ROOT } = require('../../helper/repo-root.js');
|
|
21
|
+
const { loadStorageRegistry } = require('../storage/registry.js');
|
|
22
|
+
const { resolveRoots, readPruneSetting } = require('../storage/roots.js');
|
|
23
|
+
const { foldInto, finalize } = require('./fold.js');
|
|
24
24
|
|
|
25
25
|
const HOUR_MS = 3600000;
|
|
26
26
|
|
|
27
27
|
/** @returns {{state: 'off'} | {state: 'on', hours: number, ms: number} | {state: 'invalid', raw: string}} */
|
|
28
|
-
function parseWindow(env
|
|
28
|
+
function parseWindow(env = process.env) {
|
|
29
29
|
const raw = env.DOFLOW_RETENTION_HOURS;
|
|
30
30
|
if (raw === undefined || raw === '') return { state: 'off' };
|
|
31
31
|
if (/^[1-9][0-9]*$/.test(raw) && Number.isSafeInteger(Number(raw))) {
|
|
@@ -36,11 +36,11 @@ function parseWindow(env: Record<string, string | undefined> = process.env): { s
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/** Composes IC-013's explicit environment override with the declared lifecycle default. */
|
|
39
|
-
function retentionWindow({ env = process.env, projectRoot, registry, fsImpl = nodeFs }
|
|
39
|
+
function retentionWindow({ env = process.env, projectRoot, registry, fsImpl = nodeFs } = {}) {
|
|
40
40
|
const parsed = parseWindow(env);
|
|
41
41
|
if (parsed.state === 'invalid') return parsed;
|
|
42
42
|
if (parsed.state === 'on') return { ...parsed, source: 'env' };
|
|
43
|
-
let activeRegistry
|
|
43
|
+
let activeRegistry ;
|
|
44
44
|
try {
|
|
45
45
|
activeRegistry = registry || loadStorageRegistry({ repoRoot: REPO_ROOT, fsImpl });
|
|
46
46
|
} catch {
|
|
@@ -48,23 +48,23 @@ function retentionWindow({ env = process.env, projectRoot, registry, fsImpl = no
|
|
|
48
48
|
}
|
|
49
49
|
const roots = resolveRoots({ registry: activeRegistry, projectRoot, env });
|
|
50
50
|
if (readPruneSetting({ registry: activeRegistry, roots, rootId: 'project-state', fsImpl }).setting !== 'on') return { state: 'off' };
|
|
51
|
-
const lifecycleStore = activeRegistry.stores.find((store
|
|
51
|
+
const lifecycleStore = activeRegistry.stores.find((store ) => store.id === 'lifecycle-events');
|
|
52
52
|
if (!lifecycleStore) throw new Error('Storage registry has no lifecycle-events store');
|
|
53
53
|
const hours = lifecycleStore.bound.defaultHours;
|
|
54
54
|
return { state: 'on', hours, ms: hours * HOUR_MS, source: 'default' };
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function isText(value
|
|
57
|
+
function isText(value ) { return typeof value === 'string' && value.trim() !== ''; }
|
|
58
58
|
|
|
59
59
|
/** The item keys an event names, or none when it is not about a follow-up or a goal. */
|
|
60
|
-
function itemsOf(event
|
|
60
|
+
function itemsOf(event ) {
|
|
61
61
|
const d = event.data;
|
|
62
|
-
const ids = (list
|
|
62
|
+
const ids = (list ) => (Array.isArray(list) && list.length > 0 && list.every(isText) ? list : []);
|
|
63
63
|
switch (event.type) {
|
|
64
64
|
case 'followup.added': return isText(d.id) ? [`followup:${d.id}`] : [];
|
|
65
65
|
case 'followup.taken':
|
|
66
|
-
case 'followup.promoted': return ids(d.ids).map((id
|
|
67
|
-
case 'followup.settled': return (isText(d.id) ? [d.id] : ids(d.ids)).map((id
|
|
66
|
+
case 'followup.promoted': return ids(d.ids).map((id ) => `followup:${id}`);
|
|
67
|
+
case 'followup.settled': return (isText(d.id) ? [d.id] : ids(d.ids)).map((id ) => `followup:${id}`);
|
|
68
68
|
case 'goal.added':
|
|
69
69
|
case 'goal.item-added':
|
|
70
70
|
case 'goal.checked':
|
|
@@ -75,17 +75,17 @@ function itemsOf(event: any): string[] {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/** Groups the items into units: items named together by one event share a unit. */
|
|
78
|
-
function unitsOf(events
|
|
79
|
-
const parent = new Map
|
|
80
|
-
const find = (key
|
|
78
|
+
function unitsOf(events , shouldStop ) {
|
|
79
|
+
const parent = new Map ();
|
|
80
|
+
const find = (key ) => {
|
|
81
81
|
while (parent.get(key) !== key) {
|
|
82
82
|
if (shouldStop?.()) return null;
|
|
83
|
-
parent.set(key, parent.get(parent.get(key)
|
|
84
|
-
key = parent.get(key)
|
|
83
|
+
parent.set(key, parent.get(parent.get(key) ) );
|
|
84
|
+
key = parent.get(key) ;
|
|
85
85
|
}
|
|
86
86
|
return key;
|
|
87
87
|
};
|
|
88
|
-
const named
|
|
88
|
+
const named = [];
|
|
89
89
|
for (const event of events) {
|
|
90
90
|
if (shouldStop?.()) return null;
|
|
91
91
|
named.push({ event, items: [...new Set(itemsOf(event))] });
|
|
@@ -99,7 +99,7 @@ function unitsOf(events: any[], shouldStop?: () => boolean): any[] | null {
|
|
|
99
99
|
parent.set(root, head);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
|
-
const units = new Map
|
|
102
|
+
const units = new Map ();
|
|
103
103
|
for (const { event, items } of named) {
|
|
104
104
|
if (shouldStop?.()) return null;
|
|
105
105
|
if (items.length === 0) continue;
|
|
@@ -113,7 +113,7 @@ function unitsOf(events: any[], shouldStop?: () => boolean): any[] | null {
|
|
|
113
113
|
return [...units.values()];
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
function isSettled(state
|
|
116
|
+
function isSettled(state , key ) {
|
|
117
117
|
const [kind, id] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
|
|
118
118
|
if (kind === 'followup') {
|
|
119
119
|
const item = state.items.get(id);
|
|
@@ -124,15 +124,15 @@ function isSettled(state: any, key: string): boolean {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
/** The parts of a fold that removing a unit must leave unchanged. */
|
|
127
|
-
function comparable(result
|
|
127
|
+
function comparable(result ) {
|
|
128
128
|
const { followups, features, merged, goals, releases, conflicts } = result;
|
|
129
129
|
return { followups, features, merged, goals, releases, conflicts };
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
/** Whether dropping `removed` units changes nothing but the removed items themselves. */
|
|
133
|
-
function refoldEqual(events
|
|
133
|
+
function refoldEqual(events , full , removed , now , shouldStop ) {
|
|
134
134
|
if (shouldStop?.()) return null;
|
|
135
|
-
const gone = new Set
|
|
135
|
+
const gone = new Set (), keys = new Set ();
|
|
136
136
|
for (const unit of removed) {
|
|
137
137
|
if (shouldStop?.()) return null;
|
|
138
138
|
for (const event of unit.events) gone.add(event.id);
|
|
@@ -140,11 +140,11 @@ function refoldEqual(events: any[], full: any, removed: any[], now: Date, should
|
|
|
140
140
|
}
|
|
141
141
|
const expected = {
|
|
142
142
|
...full,
|
|
143
|
-
followups: full.followups.filter((f
|
|
144
|
-
goals: full.goals.filter((g
|
|
145
|
-
conflicts: full.conflicts.filter((c
|
|
143
|
+
followups: full.followups.filter((f ) => !keys.has(`followup:${f.id}`)),
|
|
144
|
+
goals: full.goals.filter((g ) => !keys.has(`goal:${g.goal}`)),
|
|
145
|
+
conflicts: full.conflicts.filter((c ) => !gone.has(c.event)),
|
|
146
146
|
};
|
|
147
|
-
const remaining
|
|
147
|
+
const remaining = [];
|
|
148
148
|
for (const event of events) {
|
|
149
149
|
if (shouldStop?.()) return null;
|
|
150
150
|
if (!gone.has(event.id)) remaining.push(event);
|
|
@@ -160,23 +160,23 @@ function refoldEqual(events: any[], full: any, removed: any[], now: Date, should
|
|
|
160
160
|
* @returns {{files: string[], units: Array<{items: string[], newestAt: string, files: string[]}>, incomplete?: true}}
|
|
161
161
|
* files: `<id>.json` names, sorted
|
|
162
162
|
*/
|
|
163
|
-
function selectExpired(events
|
|
164
|
-
const incomplete = ()
|
|
163
|
+
function selectExpired(events , { now, windowMs, shouldStop } = {}) {
|
|
164
|
+
const incomplete = () => ({ files: [], units: [], incomplete: true });
|
|
165
165
|
if (shouldStop?.()) return incomplete();
|
|
166
166
|
const state = foldInto(events, { now, shouldStop });
|
|
167
167
|
if (state.incomplete || shouldStop?.()) return incomplete();
|
|
168
168
|
const cutoff = now.getTime() - windowMs;
|
|
169
|
-
const served = new Set
|
|
169
|
+
const served = new Set ();
|
|
170
170
|
for (const feature of state.tracked.values()) {
|
|
171
171
|
if (shouldStop?.()) return incomplete();
|
|
172
172
|
if (feature.goal) served.add(feature.goal);
|
|
173
173
|
}
|
|
174
174
|
const named = unitsOf(events, shouldStop);
|
|
175
175
|
if (!named || shouldStop?.()) return incomplete();
|
|
176
|
-
const candidates
|
|
176
|
+
const candidates = [];
|
|
177
177
|
for (const unit of named) {
|
|
178
178
|
if (shouldStop?.()) return incomplete();
|
|
179
|
-
const newest = unit.events.reduce((max
|
|
179
|
+
const newest = unit.events.reduce((max , event ) => (Date.parse(event.at) > Date.parse(max) ? event.at : max), unit.events[0].at);
|
|
180
180
|
if (shouldStop?.()) return incomplete();
|
|
181
181
|
let eligible = Date.parse(newest) < cutoff;
|
|
182
182
|
if (eligible) for (const key of unit.items) {
|
|
@@ -191,7 +191,7 @@ function selectExpired(events: any[], { now, windowMs, shouldStop }: any = {}):
|
|
|
191
191
|
if (allSafe === null || shouldStop?.()) return incomplete();
|
|
192
192
|
if (!allSafe) {
|
|
193
193
|
selected = [];
|
|
194
|
-
const ordered = [...candidates].sort((a
|
|
194
|
+
const ordered = [...candidates].sort((a , b ) => Date.parse(a.newestAt) - Date.parse(b.newestAt));
|
|
195
195
|
if (shouldStop?.()) return incomplete();
|
|
196
196
|
for (const unit of ordered) {
|
|
197
197
|
if (shouldStop?.()) return incomplete();
|
|
@@ -200,13 +200,13 @@ function selectExpired(events: any[], { now, windowMs, shouldStop }: any = {}):
|
|
|
200
200
|
if (safe) selected.push(unit);
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
|
-
const units
|
|
203
|
+
const units = [];
|
|
204
204
|
for (const unit of selected) {
|
|
205
205
|
if (shouldStop?.()) return incomplete();
|
|
206
|
-
units.push({ items: [...unit.items].sort(), newestAt: unit.newestAt, files: unit.events.map((event
|
|
206
|
+
units.push({ items: [...unit.items].sort(), newestAt: unit.newestAt, files: unit.events.map((event ) => `${event.id}.json`).sort() });
|
|
207
207
|
}
|
|
208
208
|
if (shouldStop?.()) return incomplete();
|
|
209
|
-
return { files: units.flatMap((unit
|
|
209
|
+
return { files: units.flatMap((unit ) => unit.files).sort(), units };
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
module.exports = { parseWindow, retentionWindow, selectExpired };
|
|
@@ -11,19 +11,19 @@ const fs = require('node:fs');
|
|
|
11
11
|
const path = require('node:path');
|
|
12
12
|
const { execFileSync } = require('node:child_process');
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
|
|
16
|
-
function git(cwd
|
|
16
|
+
function git(cwd , args ) {
|
|
17
17
|
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function real(target
|
|
20
|
+
function real(target ) {
|
|
21
21
|
try { return fs.realpathSync(target); } catch { return path.resolve(target); }
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** The entries of `git worktree list --porcelain`: `{path, bare, prunable}`, in git's order. */
|
|
25
|
-
function worktreeEntries(porcelain
|
|
26
|
-
const entries
|
|
25
|
+
function worktreeEntries(porcelain ) {
|
|
26
|
+
const entries = [];
|
|
27
27
|
for (const block of porcelain.split(/\n\s*\n/)) {
|
|
28
28
|
const lines = block.split('\n').map((line) => line.replace(/\r$/, ''));
|
|
29
29
|
const head = lines.find((line) => line.startsWith('worktree '));
|
|
@@ -39,12 +39,12 @@ function worktreeEntries(porcelain: string): WorktreeEntry[] {
|
|
|
39
39
|
|
|
40
40
|
/** A working tree that exists and that git itself calls one: a submodule's or a separate git dir's
|
|
41
41
|
* entry points at a git directory, where `--is-inside-work-tree` prints `false`. */
|
|
42
|
-
function isRealWorkTree(entry
|
|
42
|
+
function isRealWorkTree(entry ) {
|
|
43
43
|
if (entry.bare || entry.prunable || !fs.existsSync(entry.path)) return false;
|
|
44
44
|
try { return git(entry.path, ['rev-parse', '--is-inside-work-tree']).trim() === 'true'; } catch { return false; }
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function showToplevel(cwd
|
|
47
|
+
function showToplevel(cwd ) {
|
|
48
48
|
try {
|
|
49
49
|
const top = git(cwd, ['rev-parse', '--show-toplevel']).trim();
|
|
50
50
|
return top ? real(top) : null;
|
|
@@ -60,8 +60,8 @@ function showToplevel(cwd: string): string | null {
|
|
|
60
60
|
* @param {string} [cwd] defaults to the process working directory
|
|
61
61
|
* @returns {string} an absolute, real path
|
|
62
62
|
*/
|
|
63
|
-
function projectRoot(cwd
|
|
64
|
-
let entries
|
|
63
|
+
function projectRoot(cwd = process.cwd()) {
|
|
64
|
+
let entries = [];
|
|
65
65
|
try { entries = worktreeEntries(git(cwd, ['worktree', 'list', '--porcelain'])); } catch { /* not a repository */ }
|
|
66
66
|
const first = entries[0];
|
|
67
67
|
if (first && !first.bare && isRealWorkTree(first)) return real(first.path);
|