@michaelxwb/code-flow 0.8.1 → 1.0.0
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/README.md +73 -152
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +156 -1417
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +18 -21
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/acceptance-contract.md +0 -217
- package/docs/ci/acceptance-evidence.md +0 -77
- package/docs/migrations/runtime-0.7.0.md +0 -41
- package/docs/migrations/runtime-0.8.0.md +0 -154
- package/docs/migrations/runtime-0.9.0.md +0 -40
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/docs/spec-injection-hardening.md +0 -54
- package/docs/spec-injection-performance.json +0 -73
- package/docs/spec-optimization.md +0 -12
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -262
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -87
- package/src/adapters/claude/commands/cf-stats.md +0 -96
- package/src/adapters/claude/commands/cf-sync.md +0 -39
- package/src/adapters/claude/commands/cf-task/align.md +0 -331
- package/src/adapters/claude/commands/cf-task/archive.md +0 -155
- package/src/adapters/claude/commands/cf-task/block.md +0 -49
- package/src/adapters/claude/commands/cf-task/graph.md +0 -92
- package/src/adapters/claude/commands/cf-task/note.md +0 -248
- package/src/adapters/claude/commands/cf-task/plan.md +0 -477
- package/src/adapters/claude/commands/cf-task/prd.md +0 -173
- package/src/adapters/claude/commands/cf-task/start.md +0 -317
- package/src/adapters/claude/commands/cf-task/status.md +0 -71
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/claude/commands/cf-validate.md +0 -100
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -54
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
- package/src/adapters/commands.json +0 -115
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -262
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -87
- package/src/adapters/costrict/commands/cf-stats.md +0 -96
- package/src/adapters/costrict/commands/cf-sync.md +0 -39
- package/src/adapters/costrict/commands/cf-task/align.md +0 -331
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
- package/src/adapters/costrict/commands/cf-task/block.md +0 -49
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
- package/src/adapters/costrict/commands/cf-task/note.md +0 -248
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
- package/src/adapters/costrict/commands/cf-task/start.md +0 -317
- package/src/adapters/costrict/commands/cf-task/status.md +0 -71
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/costrict/commands/cf-validate.md +0 -100
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/install-plan.json +0 -100
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -91
- package/src/adapters/opencode/commands/cf-stats.md +0 -100
- package/src/adapters/opencode/commands/cf-sync.md +0 -44
- package/src/adapters/opencode/commands/cf-task/align.md +0 -331
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
- package/src/adapters/opencode/commands/cf-task/block.md +0 -54
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
- package/src/adapters/opencode/commands/cf-task/note.md +0 -253
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
- package/src/adapters/opencode/commands/cf-task/start.md +0 -317
- package/src/adapters/opencode/commands/cf-task/status.md +0 -76
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/opencode/commands/cf-validate.md +0 -105
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/adapters/platform-bindings.json +0 -370
- package/src/cli.js +0 -1076
- package/src/core/code-flow/config.yml +0 -161
- package/src/core/code-flow/runtime-commands.json +0 -259
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
- package/src/core/code-flow/scripts/cf_audit.py +0 -10
- package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
- package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
- package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
- package/src/core/code-flow/scripts/cf_feedback.py +0 -10
- package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
- package/src/core/code-flow/scripts/cf_stats.py +0 -10
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_sync.py +0 -10
- package/src/core/code-flow/scripts/cf_task_index.py +0 -10
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_validation.py +0 -10
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
- package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
- package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
- package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
- package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
- package/src/core/code-flow/scripts/runtime-resources.json +0 -29
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -59
- package/src/migrate/runtime.js +0 -213
- package/src/migrate/spec-workflow.js +0 -253
- package/src/runtime.js +0 -219
package/lib/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const f = require('./fs');
|
|
5
|
+
const glob = require('./glob');
|
|
6
|
+
|
|
7
|
+
function defaults() {
|
|
8
|
+
return { version: 1, workflow: 'hook', always: [], paths: {},
|
|
9
|
+
budget: { maxTokens: 2500, maxBytes: 16384 },
|
|
10
|
+
tests: { timeoutSeconds: 120, jobs: 2, regression: null },
|
|
11
|
+
capture: { enabled: false, path: '.code-flow/process' } };
|
|
12
|
+
}
|
|
13
|
+
function reference(value) {
|
|
14
|
+
f.string(value, 'Spec reference');
|
|
15
|
+
const parts = value.split('#');
|
|
16
|
+
f.demand(parts.length <= 2 && /^[a-z0-9][a-z0-9/_-]*$/.test(parts[0]) &&
|
|
17
|
+
parts[0].split('/').every(Boolean) && (!parts[1] || /^RULE-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/.test(parts[1])) &&
|
|
18
|
+
(parts.length === 1 || Boolean(parts[1])), 'invalid_reference', 'Invalid Spec reference: ' + value);
|
|
19
|
+
return { id: parts[0], rule: parts[1] || null };
|
|
20
|
+
}
|
|
21
|
+
function validate(raw) {
|
|
22
|
+
f.object(raw, 'config');
|
|
23
|
+
f.demand(raw.version === 1, 'invalid_config', 'config.version must be 1');
|
|
24
|
+
const base = defaults();
|
|
25
|
+
const result = { ...raw, workflow: raw.workflow ?? base.workflow,
|
|
26
|
+
always: raw.always ?? [], paths: raw.paths ?? {},
|
|
27
|
+
budget: { ...base.budget, ...f.object(raw.budget ?? {}, 'budget') },
|
|
28
|
+
tests: { ...base.tests, ...f.object(raw.tests ?? {}, 'tests') },
|
|
29
|
+
capture: { ...base.capture, ...f.object(raw.capture ?? {}, 'capture') } };
|
|
30
|
+
f.demand(['hook', 'full'].includes(result.workflow), 'invalid_config',
|
|
31
|
+
'workflow must be hook or full');
|
|
32
|
+
f.demand(typeof result.capture.enabled === 'boolean', 'invalid_config',
|
|
33
|
+
'capture.enabled must be boolean');
|
|
34
|
+
f.string(result.capture.path, 'capture.path');
|
|
35
|
+
f.demand(/^\.code-flow\/[A-Za-z0-9._/-]+$/.test(result.capture.path) &&
|
|
36
|
+
!result.capture.path.split('/').includes('..') && !result.capture.path.endsWith('/'),
|
|
37
|
+
'invalid_config', 'capture.path must be a relative path under .code-flow');
|
|
38
|
+
f.strings(result.always, 'always').forEach(reference);
|
|
39
|
+
f.object(result.paths, 'paths');
|
|
40
|
+
for (const [pattern, refs] of Object.entries(result.paths)) {
|
|
41
|
+
glob.compile(pattern); f.strings(refs, 'paths references').forEach(reference);
|
|
42
|
+
}
|
|
43
|
+
f.positive(result.budget.maxTokens, 'maxTokens', true);
|
|
44
|
+
f.positive(result.budget.maxBytes, 'maxBytes', true);
|
|
45
|
+
f.positive(result.tests.timeoutSeconds, 'timeoutSeconds');
|
|
46
|
+
f.positive(result.tests.jobs, 'jobs', true);
|
|
47
|
+
if (result.tests.regression !== null) result.tests.regression = regression(result.tests.regression);
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function regression(value) {
|
|
52
|
+
f.object(value, 'tests.regression');
|
|
53
|
+
f.demand((value.argv !== undefined) !== (value.run !== undefined), 'invalid_config',
|
|
54
|
+
'tests.regression requires exactly one of argv or run');
|
|
55
|
+
if (value.argv !== undefined) f.strings(value.argv, 'tests.regression argv');
|
|
56
|
+
else f.string(value.run, 'tests.regression run');
|
|
57
|
+
if (value.cwd !== undefined) f.string(value.cwd, 'tests.regression cwd');
|
|
58
|
+
if (value.timeout !== undefined) f.positive(value.timeout, 'tests.regression timeout');
|
|
59
|
+
if (value.parallel !== undefined) f.demand(typeof value.parallel === 'boolean',
|
|
60
|
+
'invalid_config', 'tests.regression parallel must be boolean');
|
|
61
|
+
if (value.group !== undefined) f.string(value.group, 'tests.regression group');
|
|
62
|
+
if (value.env !== undefined) f.object(value.env, 'tests.regression env');
|
|
63
|
+
return { ...value, id: 'regression' };
|
|
64
|
+
}
|
|
65
|
+
function findRoot(cwd, required = true) {
|
|
66
|
+
let current = f.realpath(cwd);
|
|
67
|
+
while (true) {
|
|
68
|
+
if (fs.existsSync(path.join(current, '.code-flow/config.json'))) return current;
|
|
69
|
+
const parent = path.dirname(current);
|
|
70
|
+
if (parent === current || fs.existsSync(path.join(current, '.git'))) break;
|
|
71
|
+
current = parent;
|
|
72
|
+
}
|
|
73
|
+
f.demand(!required, 'project_missing', 'Run inside a project with .code-flow/config.json');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function load(root) {
|
|
77
|
+
const raw = f.readText(f.inside(root, '.code-flow/config.json'), 65536);
|
|
78
|
+
f.demand(raw !== null, 'config_missing', 'Missing config.json');
|
|
79
|
+
let data;
|
|
80
|
+
try { data = JSON.parse(raw); } catch (error) {
|
|
81
|
+
throw new f.FlowError('config_invalid', 'Invalid config JSON: ' + error.message);
|
|
82
|
+
}
|
|
83
|
+
return validate(data);
|
|
84
|
+
}
|
|
85
|
+
module.exports = { defaults, reference, validate, findRoot, load };
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const f = require('./fs');
|
|
6
|
+
const config = require('./config');
|
|
7
|
+
const spec = require('./spec');
|
|
8
|
+
const task = require('./task');
|
|
9
|
+
const taskdoc = require('./taskdoc');
|
|
10
|
+
const init = require('./init');
|
|
11
|
+
const runs = require('./runs');
|
|
12
|
+
const toml = require('./toml');
|
|
13
|
+
|
|
14
|
+
const TEMPLATES = init.WORKFLOW_TEMPLATES;
|
|
15
|
+
const LAUNCHER = init.LAUNCHER_TEMPLATES;
|
|
16
|
+
|
|
17
|
+
function check(code, severity, status, message, target = null, nextAction = '') {
|
|
18
|
+
return { code, severity, status, path: target, message, next_action: nextAction };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pass(code, message, target = null) {
|
|
22
|
+
return check(code, 'info', 'pass', message, target, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function gitRead(root, args) {
|
|
26
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8',
|
|
27
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } });
|
|
28
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function configChecks(root) {
|
|
32
|
+
try { return [{ value: config.load(root), checks: [pass('CONFIG_OK', 'config 可读取。', '.code-flow/config.json')] }]; }
|
|
33
|
+
catch (error) {
|
|
34
|
+
return [{ value: null, checks: [check('CONFIG_INVALID', 'error', 'fail', error.message,
|
|
35
|
+
'.code-flow/config.json', '修复 config.json 后重试')] }];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function templateChecks(root) {
|
|
40
|
+
const directory = path.join(root, '.code-flow/templates');
|
|
41
|
+
const checks = [];
|
|
42
|
+
for (const name of TEMPLATES) {
|
|
43
|
+
if (!fs.existsSync(path.join(directory, name))) {
|
|
44
|
+
checks.push(check('TEMPLATE_MISSING', 'error', 'fail', '缺少工作流模板: ' + name,
|
|
45
|
+
'.code-flow/templates/' + name, '运行 code-flow init'));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const taskTemplate = f.readText(path.join(directory, 'task.md'));
|
|
49
|
+
if (taskTemplate !== null && !/^## TASK-\d+:/m.test(taskTemplate)) {
|
|
50
|
+
checks.push(check('TEMPLATE_INSTRUCTION_INVALID', 'error', 'fail',
|
|
51
|
+
'任务模板缺少多 TASK 段格式。', '.code-flow/templates/task.md', '运行 code-flow init'));
|
|
52
|
+
}
|
|
53
|
+
return checks.length ? checks : [pass('TEMPLATE_OK', '工作流模板完整。', '.code-flow/templates')];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fallbackFindings(root) {
|
|
57
|
+
const text = f.readText(path.join(root, '.code-flow/launcher/hook.sh'));
|
|
58
|
+
if (text === null) return [];
|
|
59
|
+
const node = /--node '([^']+)'/.exec(text)?.[1];
|
|
60
|
+
const entry = /--entry '([^']+)'/.exec(text)?.[1];
|
|
61
|
+
if (!node || !entry || (fs.existsSync(node) && fs.existsSync(entry))) return [];
|
|
62
|
+
return [check('HOOK_ENTRY_FALLBACK', 'warning', 'fail',
|
|
63
|
+
'固定回退 Node/JS 入口不存在;PATH 全局包仍可能可用。', '.code-flow/launcher/hook.sh',
|
|
64
|
+
'重新 code-flow init 或修复全局安装')];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function launcherChecks(root) {
|
|
68
|
+
const checks = [];
|
|
69
|
+
for (const name of LAUNCHER) {
|
|
70
|
+
if (!fs.existsSync(path.join(root, '.code-flow/launcher', name))) {
|
|
71
|
+
checks.push(check('HOOK_ENTRY_MISSING', 'error', 'fail', '项目启动器缺失: ' + name,
|
|
72
|
+
'.code-flow/launcher/' + name, '运行 code-flow init'));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
checks.push(...fallbackFindings(root));
|
|
76
|
+
let platforms = [];
|
|
77
|
+
try { platforms = init.installed(root); } catch (error) {
|
|
78
|
+
checks.push(check('HOOK_CONFIG_INVALID', 'error', 'fail', error.message,
|
|
79
|
+
'platform configuration', '修复平台 Hook 配置'));
|
|
80
|
+
}
|
|
81
|
+
if (!platforms.length && !checks.some(item => item.code === 'HOOK_CONFIG_INVALID')) {
|
|
82
|
+
checks.push(check('HOOK_CONFIG_INVALID', 'warning', 'fail', '未发现 v1 平台 Hook。',
|
|
83
|
+
'.claude/.codex/.costrict/.opencode', '运行 code-flow init --platform=all'));
|
|
84
|
+
}
|
|
85
|
+
if (platforms.length) {
|
|
86
|
+
const target = require('./adapters').PLATFORMS[platforms[0]].hooks;
|
|
87
|
+
checks.push(check('HOST_TRUST_UNKNOWN', 'warning', 'unknown',
|
|
88
|
+
'无法从文件确定宿主是否已加载并信任此 Hook。', target,
|
|
89
|
+
'在宿主审阅 Hook,并提交一次 prompt 检查注入。'));
|
|
90
|
+
}
|
|
91
|
+
const errors = checks.filter(item => item.severity === 'error');
|
|
92
|
+
const unhealthy = checks.some(item => item.code === 'HOOK_ENTRY_MISSING' ||
|
|
93
|
+
item.code === 'HOOK_ENTRY_FALLBACK');
|
|
94
|
+
if (!errors.length && !unhealthy) {
|
|
95
|
+
checks.push(pass('HOOK_ENTRY_OK', '项目启动器与平台入口可静态读取。', '.code-flow/launcher'));
|
|
96
|
+
}
|
|
97
|
+
return checks;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function codexChecks(root) {
|
|
101
|
+
const relative = '.codex/config.toml';
|
|
102
|
+
const text = f.readText(path.join(root, relative));
|
|
103
|
+
if (text === null) return [];
|
|
104
|
+
const found = toml.featuresHooks(text);
|
|
105
|
+
if (found.unsupported) return [check('CODEX_CONFIG_FORM', 'error', 'fail',
|
|
106
|
+
'Codex config.toml 使用不支持的 features.hooks 形式: ' + found.unsupported,
|
|
107
|
+
relative, '改成规范 [features] 表 + hooks = true,或根级单行 features.hooks = true')];
|
|
108
|
+
if (found.header < 0 && found.key < 0) return [];
|
|
109
|
+
if (found.value !== 'true') return [check('CODEX_HOOKS_DISABLED', 'warning', 'fail',
|
|
110
|
+
'Codex config.toml 未启用 features.hooks。', relative,
|
|
111
|
+
'在 [features] 表中加入 hooks = true,或运行 code-flow init')];
|
|
112
|
+
return [pass('CODEX_CONFIG_OK', 'Codex config.toml 的 features.hooks 已启用。', relative)];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function specChecks(root, settings) {
|
|
116
|
+
let listing;
|
|
117
|
+
try { listing = spec.list(root); } catch (error) {
|
|
118
|
+
return { ids: new Set(), checks: [check('SPEC_INVALID', 'error', 'fail', error.message,
|
|
119
|
+
'.code-flow/specs', '修复 Spec 树')] };
|
|
120
|
+
}
|
|
121
|
+
const checks = listing.diagnostics.map(item => check('SPEC_INVALID', 'error', 'fail',
|
|
122
|
+
item.message, item.source ? '.code-flow/specs/' + item.source + '.md' : '.code-flow/specs',
|
|
123
|
+
'修复 Spec 文件'));
|
|
124
|
+
const ids = new Set(listing.items.map(item => item.id));
|
|
125
|
+
const refs = [];
|
|
126
|
+
if (settings) {
|
|
127
|
+
for (const value of settings.always) refs.push(config.reference(value));
|
|
128
|
+
for (const values of Object.values(settings.paths)) {
|
|
129
|
+
for (const value of values) refs.push(config.reference(value));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
for (const ref of refs) {
|
|
133
|
+
if (!ids.has(ref.id)) checks.push(check('SPEC_REF_MISSING', 'error', 'fail',
|
|
134
|
+
'config 引用了不存在的 Spec: ' + ref.id, '.code-flow/config.json', '修复引用或补充 Spec'));
|
|
135
|
+
}
|
|
136
|
+
if (!checks.length) checks.push(pass('SPEC_OK', 'Spec 格式与 config 引用有效。', '.code-flow/specs'));
|
|
137
|
+
return { ids, checks };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function taskChecks(root, specIds) {
|
|
141
|
+
let listing;
|
|
142
|
+
try { listing = task.list(root); } catch (error) {
|
|
143
|
+
return [check('TASK_INVALID', 'error', 'fail', error.message,
|
|
144
|
+
'.code-flow/tasks', '修复任务文档')];
|
|
145
|
+
}
|
|
146
|
+
const checks = listing.diagnostics.map(item => check(
|
|
147
|
+
item.code && item.code.startsWith('task_depends') ? 'TASK_DEPENDENCY_INVALID' : 'TASK_INVALID',
|
|
148
|
+
'error', 'fail', item.message, item.source || '.code-flow/tasks', '修复任务文档'));
|
|
149
|
+
for (const item of listing.items) {
|
|
150
|
+
for (const ref of item.spec_refs) {
|
|
151
|
+
if (!specIds.has(ref.split('#')[0])) checks.push(check('SPEC_REF_MISSING', 'error', 'fail',
|
|
152
|
+
item.task + ' 引用了不存在的 Spec: ' + ref, item.task_file, '修复 Spec-Refs'));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const files = [...new Set(listing.items.map(item => item.task_file))];
|
|
156
|
+
for (const file of files) {
|
|
157
|
+
try { taskdoc.batches(task.loadDocument(root, file).tasks); } catch (error) {
|
|
158
|
+
checks.push(check('TASK_DEPENDENCY_INVALID', 'error', 'fail', error.message,
|
|
159
|
+
file, '修复 Depends 或任务状态'));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!checks.length) checks.push(pass('TASK_OK', '任务文档与依赖有效。', '.code-flow/tasks'));
|
|
163
|
+
return checks;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function worktreeList(root) {
|
|
167
|
+
const raw = gitRead(root, ['worktree', 'list', '--porcelain']);
|
|
168
|
+
if (raw === null) return null;
|
|
169
|
+
const entries = [];
|
|
170
|
+
let current = null;
|
|
171
|
+
for (const line of raw.split('\n')) {
|
|
172
|
+
if (line.startsWith('worktree ')) { current = { path: line.slice(9), branch: null, head: null }; entries.push(current); }
|
|
173
|
+
else if (current && line.startsWith('HEAD ')) current.head = line.slice(5);
|
|
174
|
+
else if (current && line.startsWith('branch ')) current.branch = line.slice(7);
|
|
175
|
+
}
|
|
176
|
+
return entries;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function registeredWorker(registered, absolute) {
|
|
180
|
+
return registered.find(item => {
|
|
181
|
+
try { return f.realpath(item.path) === f.realpath(absolute); } catch (_) { return false; }
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function workerFindings(root, ledger, registered, record) {
|
|
186
|
+
const checks = [];
|
|
187
|
+
for (const worker of ledger.worktrees) {
|
|
188
|
+
if (worker.status === 'removed') continue;
|
|
189
|
+
const absolute = path.join(root, worker.path);
|
|
190
|
+
if (!fs.existsSync(absolute) || !registeredWorker(registered, absolute)) {
|
|
191
|
+
checks.push(check('WORKTREE_RECOVERY_REQUIRED', 'warning', 'fail',
|
|
192
|
+
'缺少注册的 worker worktree: ' + worker.task, worker.path || record.file,
|
|
193
|
+
'按 ledger 重建或显式放弃该 run'));
|
|
194
|
+
} else if (worker.status === 'merged' && worker.collected_head &&
|
|
195
|
+
gitRead(root, ['merge-base', '--is-ancestor', worker.collected_head, 'HEAD']) === null) {
|
|
196
|
+
checks.push(check('WORKTREE_RECOVERY_REQUIRED', 'warning', 'fail',
|
|
197
|
+
'已合并 worker 的提交不在协调历史中: ' + worker.task, worker.path,
|
|
198
|
+
'检查分支重写并显式恢复 ledger'));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return checks;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function runFindings(root, ledger, registered, record) {
|
|
205
|
+
const checks = [];
|
|
206
|
+
let rootMatches = false;
|
|
207
|
+
try { rootMatches = f.realpath(ledger.coordinator.root) === f.realpath(root); } catch (_) { rootMatches = false; }
|
|
208
|
+
if (!rootMatches) {
|
|
209
|
+
checks.push(check('WORKTREE_IDENTITY_MISMATCH', 'error', 'fail',
|
|
210
|
+
'ledger 协调工作区与当前项目不一致: ' + record.run_id, record.file,
|
|
211
|
+
'在记录中的协调工作区运行 doctor'));
|
|
212
|
+
return checks;
|
|
213
|
+
}
|
|
214
|
+
if (ledger.status !== 'done' && ledger.status !== 'aborted') {
|
|
215
|
+
checks.push(check('WORKTREE_RECOVERY_REQUIRED', 'warning', 'fail',
|
|
216
|
+
'worktree run 尚未完成: ' + record.status, record.file,
|
|
217
|
+
'执行 code-flow task parallel status 后继续 merge/cleanup'));
|
|
218
|
+
}
|
|
219
|
+
if (!registered) return checks.concat(check('WORKTREE_RECOVERY_REQUIRED', 'warning', 'fail',
|
|
220
|
+
'Git worktree 注册信息不可读取: ' + record.run_id, record.file, '检查 Git worktree 注册'));
|
|
221
|
+
if (ledger.coordinator.branch && gitRead(root, ['symbolic-ref', '-q', 'HEAD']) !== ledger.coordinator.branch) {
|
|
222
|
+
checks.push(check('WORKTREE_IDENTITY_MISMATCH', 'error', 'fail',
|
|
223
|
+
'协调分支已切换: ' + record.run_id, record.file, '切回原分支或显式放弃 run'));
|
|
224
|
+
}
|
|
225
|
+
return checks.concat(workerFindings(root, ledger, registered, record));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function worktreeChecks(root) {
|
|
229
|
+
let records;
|
|
230
|
+
try { records = runs.list(root); } catch (error) {
|
|
231
|
+
return [check('WORKTREE_LEDGER_INVALID', 'error', 'fail', error.message,
|
|
232
|
+
'.code-flow/worktrees', '检查 worktree run 目录')];
|
|
233
|
+
}
|
|
234
|
+
if (!records.length) return [pass('WORKTREE_OK', '没有未完成的 worktree run。', '.code-flow/worktrees')];
|
|
235
|
+
const checks = [], registered = worktreeList(root);
|
|
236
|
+
for (const record of records) {
|
|
237
|
+
if (record.status === 'invalid') {
|
|
238
|
+
checks.push(check('WORKTREE_LEDGER_INVALID', 'error', 'fail',
|
|
239
|
+
record.error || 'ledger 无效', record.file, '修复或显式放弃该 run'));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
checks.push(...runFindings(root, record.ledger, registered, record));
|
|
243
|
+
}
|
|
244
|
+
return checks.length ? checks : [pass('WORKTREE_OK', 'worktree run 状态可读取。', '.code-flow/worktrees')];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function summary(checks) {
|
|
248
|
+
const result = { pass: 0, warning: 0, error: 0, unknown: 0 };
|
|
249
|
+
for (const item of checks) {
|
|
250
|
+
if (item.status === 'pass') result.pass++;
|
|
251
|
+
else if (item.status === 'unknown') result.unknown++;
|
|
252
|
+
if (item.severity === 'warning' && item.status !== 'pass') result.warning++;
|
|
253
|
+
if (item.severity === 'error' && item.status !== 'pass') result.error++;
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function text(checks, totals) {
|
|
259
|
+
return checks.map(item => '[' + item.status.toUpperCase() + '] ' + item.code + ' ' +
|
|
260
|
+
item.message + (item.path ? ' (' + item.path + ')' : '')).join('\n') + '\n' +
|
|
261
|
+
'pass=' + totals.pass + ' warning=' + totals.warning +
|
|
262
|
+
' error=' + totals.error + ' unknown=' + totals.unknown + '\n';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function run(root) {
|
|
266
|
+
const real = f.realpath(root);
|
|
267
|
+
const configured = configChecks(real), settings = configured[0].value;
|
|
268
|
+
const specs = specChecks(real, settings);
|
|
269
|
+
const checks = [
|
|
270
|
+
...configured[0].checks,
|
|
271
|
+
...templateChecks(real),
|
|
272
|
+
...launcherChecks(real),
|
|
273
|
+
...codexChecks(real),
|
|
274
|
+
...specs.checks,
|
|
275
|
+
...taskChecks(real, specs.ids),
|
|
276
|
+
...worktreeChecks(real)
|
|
277
|
+
];
|
|
278
|
+
const totals = summary(checks);
|
|
279
|
+
return { schema_version: 1, root: real, ok: totals.error === 0, summary: totals,
|
|
280
|
+
checks, text: text(checks, totals) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = { run, TEMPLATES, LAUNCHER };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const { object, demand, FlowError } = require('./fs');
|
|
4
|
+
|
|
5
|
+
function parse(raw) {
|
|
6
|
+
const bom = raw.startsWith('\ufeff') ? '\ufeff' : '';
|
|
7
|
+
const text = raw.slice(bom.length);
|
|
8
|
+
const match = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
|
|
9
|
+
demand(match, 'frontmatter_missing', 'Expected JSON frontmatter');
|
|
10
|
+
let meta;
|
|
11
|
+
try { meta = JSON.parse(match[1]); } catch (error) {
|
|
12
|
+
throw new FlowError('frontmatter_invalid', 'Invalid JSON frontmatter: ' + error.message);
|
|
13
|
+
}
|
|
14
|
+
object(meta, 'frontmatter');
|
|
15
|
+
return { meta, body: text.slice(match[0].length), header: match[0], bom,
|
|
16
|
+
eol: match[0].includes('\r\n') ? '\r\n' : '\n', raw };
|
|
17
|
+
}
|
|
18
|
+
function stringify(document, meta = document.meta, body = document.body) {
|
|
19
|
+
const eol = document.eol || '\n';
|
|
20
|
+
const header = document.header && JSON.stringify(meta) === JSON.stringify(document.meta)
|
|
21
|
+
? document.header : '---' + eol + JSON.stringify(meta, null, 2).replace(/\n/g, eol) + eol + '---' + eol;
|
|
22
|
+
return (document.bom || '') + header + body;
|
|
23
|
+
}
|
|
24
|
+
function create(meta, body = '') {
|
|
25
|
+
return stringify({ meta: {}, body, eol: '\n', bom: '' }, meta, body);
|
|
26
|
+
}
|
|
27
|
+
function readHeader(file) {
|
|
28
|
+
const fd = fs.openSync(file, 'r');
|
|
29
|
+
const chunks = [];
|
|
30
|
+
try {
|
|
31
|
+
for (let total = 0; total < 65536; total += 1024) {
|
|
32
|
+
const buffer = Buffer.alloc(1024);
|
|
33
|
+
const size = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
34
|
+
if (!size) break;
|
|
35
|
+
chunks.push(buffer.subarray(0, size));
|
|
36
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
37
|
+
if (/^\ufeff?---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/.test(text)) return parse(text).meta;
|
|
38
|
+
}
|
|
39
|
+
throw new FlowError('frontmatter_missing', 'Missing or oversized JSON frontmatter: ' + file);
|
|
40
|
+
} finally { fs.closeSync(fd); }
|
|
41
|
+
}
|
|
42
|
+
module.exports = { parse, stringify, create, readHeader };
|
package/lib/fs.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
class FlowError extends Error {
|
|
8
|
+
constructor(code, message, exitCode = 2) {
|
|
9
|
+
super(message); this.name = 'FlowError'; this.code = code; this.exitCode = exitCode;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function demand(condition, code, message, exitCode = 2) {
|
|
13
|
+
if (!condition) throw new FlowError(code, message, exitCode);
|
|
14
|
+
}
|
|
15
|
+
function object(value, label) {
|
|
16
|
+
demand(value !== null && typeof value === 'object' && !Array.isArray(value),
|
|
17
|
+
'invalid_data', label + ' must be an object');
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
function string(value, label) {
|
|
21
|
+
demand(typeof value === 'string' && value.trim().length > 0 && !value.includes('\0'),
|
|
22
|
+
'invalid_data', label + ' must be a nonempty string');
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function strings(value, label) {
|
|
26
|
+
demand(Array.isArray(value), 'invalid_data', label + ' must be an array');
|
|
27
|
+
return value.map(item => string(item, label));
|
|
28
|
+
}
|
|
29
|
+
function positive(value, label, integer = false) {
|
|
30
|
+
demand(typeof value === 'number' && Number.isFinite(value) && value > 0 &&
|
|
31
|
+
(!integer || Number.isSafeInteger(value)), 'invalid_data', label + ' must be positive' + (integer ? ' integer' : ''));
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function hash(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
|
|
35
|
+
function readText(file, maximum = 4 * 1024 * 1024) {
|
|
36
|
+
try {
|
|
37
|
+
demand(fs.statSync(file).size <= maximum, 'file_too_large', 'File too large: ' + file);
|
|
38
|
+
return fs.readFileSync(file, 'utf8');
|
|
39
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
40
|
+
}
|
|
41
|
+
function realpath(value) {
|
|
42
|
+
return fs.realpathSync.native(value);
|
|
43
|
+
}
|
|
44
|
+
function within(root, candidate) {
|
|
45
|
+
const relative = path.relative(root, candidate);
|
|
46
|
+
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
|
|
47
|
+
}
|
|
48
|
+
function inside(root, relative) {
|
|
49
|
+
string(relative, 'path');
|
|
50
|
+
const clean = relative.replace(/\\/g, '/');
|
|
51
|
+
demand(!path.posix.isAbsolute(clean) && !path.win32.isAbsolute(relative) &&
|
|
52
|
+
!clean.split('/').includes('..') && !/[\r\n]/.test(clean), 'invalid_path', 'Invalid path: ' + relative);
|
|
53
|
+
const base = realpath(root);
|
|
54
|
+
let current = base;
|
|
55
|
+
for (const part of clean.split('/').filter(p => p && p !== '.')) {
|
|
56
|
+
const next = path.join(current, part);
|
|
57
|
+
if (!fs.existsSync(next) && fs.existsSync(current) && fs.statSync(current).isDirectory()) {
|
|
58
|
+
const entries = fs.readdirSync(current);
|
|
59
|
+
demand(!entries.some(name => name !== part && name.toLowerCase() === part.toLowerCase()) ||
|
|
60
|
+
entries.includes(part), 'invalid_path', 'Path case mismatch: ' + relative);
|
|
61
|
+
}
|
|
62
|
+
current = next;
|
|
63
|
+
if (fs.existsSync(current)) {
|
|
64
|
+
const real = realpath(current);
|
|
65
|
+
demand(real === current || real.toLowerCase() !== current.toLowerCase(),
|
|
66
|
+
'invalid_path', 'Path case mismatch: ' + relative);
|
|
67
|
+
demand(within(base, real), 'invalid_path', 'Path escapes workspace: ' + relative);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
demand(within(base, current), 'invalid_path', 'Path escapes workspace: ' + relative);
|
|
71
|
+
return current;
|
|
72
|
+
}
|
|
73
|
+
function removeIfPresent(file) {
|
|
74
|
+
try { fs.unlinkSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
75
|
+
}
|
|
76
|
+
function atomicWrite(file, text, before = undefined) {
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
const temporary = path.join(path.dirname(file), '.' + path.basename(file) + '.' + crypto.randomUUID() + '.tmp');
|
|
79
|
+
let fd;
|
|
80
|
+
try {
|
|
81
|
+
fd = fs.openSync(temporary, 'wx', 0o600);
|
|
82
|
+
fs.writeFileSync(fd, text, 'utf8'); fs.fsyncSync(fd); fs.closeSync(fd); fd = undefined;
|
|
83
|
+
demand(before === undefined || readText(file) === before, 'concurrent_change', 'Concurrent edit: ' + file, 1);
|
|
84
|
+
fs.renameSync(temporary, file);
|
|
85
|
+
} finally {
|
|
86
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
87
|
+
removeIfPresent(temporary);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function transientRoot(root) {
|
|
91
|
+
return path.join(os.tmpdir(), 'code-flow', hash(realpath(root)));
|
|
92
|
+
}
|
|
93
|
+
function lockDirectory(root) {
|
|
94
|
+
return path.join(transientRoot(root), 'locks');
|
|
95
|
+
}
|
|
96
|
+
function acquireIn(directory, key) {
|
|
97
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
98
|
+
const file = path.join(directory, hash(key) + '.lock');
|
|
99
|
+
const owner = crypto.randomUUID();
|
|
100
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
101
|
+
try {
|
|
102
|
+
const fd = fs.openSync(file, 'wx', 0o600);
|
|
103
|
+
try { fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, owner })); } finally { fs.closeSync(fd); }
|
|
104
|
+
return { file, owner, key };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error.code !== 'EEXIST') throw error;
|
|
107
|
+
const raw = readText(file);
|
|
108
|
+
if (raw === null) continue;
|
|
109
|
+
let record;
|
|
110
|
+
try { record = JSON.parse(raw); } catch (failure) {
|
|
111
|
+
throw new FlowError('lock_invalid', 'Invalid lock JSON: ' + failure.message, 1);
|
|
112
|
+
}
|
|
113
|
+
demand(Number.isInteger(record.pid) && record.pid > 0, 'lock_invalid', 'Invalid lock owner: ' + file, 1);
|
|
114
|
+
try { process.kill(record.pid, 0); } catch (failure) {
|
|
115
|
+
if (failure.code === 'ESRCH' && readText(file) === raw) { removeIfPresent(file); continue; }
|
|
116
|
+
if (failure.code !== 'EPERM') throw failure;
|
|
117
|
+
}
|
|
118
|
+
throw new FlowError('locked', 'Resource lock is held: ' + key, 1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw new FlowError('locked', 'Cannot acquire lock: ' + key, 1);
|
|
122
|
+
}
|
|
123
|
+
function acquire(root, key) {
|
|
124
|
+
return acquireIn(lockDirectory(root), key);
|
|
125
|
+
}
|
|
126
|
+
function releaseLock(lock) {
|
|
127
|
+
const raw = readText(lock.file);
|
|
128
|
+
if (raw === null) return;
|
|
129
|
+
demand(JSON.parse(raw).owner === lock.owner, 'lock_changed', 'Lock ownership changed', 1);
|
|
130
|
+
removeIfPresent(lock.file);
|
|
131
|
+
}
|
|
132
|
+
async function withLockIn(directory, key, operation) {
|
|
133
|
+
const lock = acquireIn(directory, key);
|
|
134
|
+
try { return await operation(); } finally { releaseLock(lock); }
|
|
135
|
+
}
|
|
136
|
+
async function withLock(root, key, operation) {
|
|
137
|
+
return withLockIn(lockDirectory(root), key, operation);
|
|
138
|
+
}
|
|
139
|
+
module.exports = { FlowError, demand, object, string, strings, positive, hash,
|
|
140
|
+
readText, realpath, within, inside, removeIfPresent, atomicWrite, transientRoot,
|
|
141
|
+
acquireIn, releaseLock, withLockIn, withLock };
|
package/lib/glob.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { demand, string } = require('./fs');
|
|
3
|
+
|
|
4
|
+
function compile(pattern) {
|
|
5
|
+
string(pattern, 'glob');
|
|
6
|
+
demand(!/[\\{}[\]()!]/.test(pattern) && !pattern.startsWith('/') &&
|
|
7
|
+
!pattern.split('/').includes('..'), 'invalid_glob', 'Unsupported glob: ' + pattern);
|
|
8
|
+
let result = '^';
|
|
9
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
10
|
+
const char = pattern[i];
|
|
11
|
+
if (char === '*' && pattern[i + 1] === '*') {
|
|
12
|
+
demand((i === 0 || pattern[i - 1] === '/') &&
|
|
13
|
+
(i + 2 === pattern.length || pattern[i + 2] === '/'), 'invalid_glob', 'Invalid ** glob: ' + pattern);
|
|
14
|
+
if (pattern[i + 2] === '/') { result += '(?:[^/]+/)*'; i += 2; }
|
|
15
|
+
else { result += '.*'; i++; }
|
|
16
|
+
} else if (char === '*') result += '[^/]*';
|
|
17
|
+
else if (char === '?') result += '[^/]';
|
|
18
|
+
else result += char.replace(/[.+^$|]/g, '\\$&');
|
|
19
|
+
}
|
|
20
|
+
return new RegExp(result + '$');
|
|
21
|
+
}
|
|
22
|
+
function matches(pattern, file) { return compile(pattern).test(file.replace(/\\/g, '/')); }
|
|
23
|
+
module.exports = { compile, matches };
|