devflow-kit 2.5.0 → 3.0.1
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/CHANGELOG.md +82 -0
- package/README.md +44 -19
- package/dist/agents/git.md +13 -15
- package/dist/cli/commands/ambient.js +160 -145
- package/dist/cli/commands/capture.js +29 -55
- package/dist/cli/commands/compliance.js +32 -61
- package/dist/cli/commands/context.js +17 -32
- package/dist/cli/commands/debug.js +65 -26
- package/dist/cli/commands/flags.js +3 -3
- package/dist/cli/commands/hud.js +34 -10
- package/dist/cli/commands/init-seed.js +40 -4
- package/dist/cli/commands/init.js +249 -271
- package/dist/cli/commands/install-report.js +10 -15
- package/dist/cli/commands/knowledge/index.js +1 -1
- package/dist/cli/commands/knowledge/toggle.js +11 -3
- package/dist/cli/commands/learning.js +52 -37
- package/dist/cli/commands/legacy-hooks.js +11 -14
- package/dist/cli/commands/memory.js +67 -78
- package/dist/cli/commands/proxy.js +23 -41
- package/dist/cli/commands/security.js +5 -13
- package/dist/cli/commands/skills.js +21 -3
- package/dist/cli/commands/tracker.js +100 -228
- package/dist/cli/commands/uninstall.js +343 -138
- package/dist/commands/bug-analysis.md +38 -12
- package/dist/commands/code-review.md +70 -21
- package/dist/commands/debug.md +37 -7
- package/dist/commands/dynamic-build.md +66 -17
- package/dist/commands/dynamic-plan.md +19 -8
- package/dist/commands/dynamic-profile.md +24 -10
- package/dist/commands/dynamic-tickets.md +22 -11
- package/dist/commands/explore.md +37 -7
- package/dist/commands/implement.md +96 -32
- package/dist/commands/plan.md +62 -19
- package/dist/commands/release.md +2 -2
- package/dist/commands/research.md +34 -8
- package/dist/commands/resolve.md +65 -17
- package/dist/commands/self-review.md +45 -9
- package/dist/core/compliance-compose.js +27 -27
- package/dist/core/evidence-policy.js +240 -24
- package/dist/core/feature-config.js +94 -25
- package/dist/core/feature-switch.js +1 -1
- package/dist/core/flags.js +30 -2
- package/dist/core/fs-atomic.js +27 -0
- package/dist/core/hook-log-dirs.js +104 -0
- package/dist/core/learning-tuning-config.js +5 -3
- package/dist/core/ledger-root.js +102 -0
- package/dist/core/manifest.js +6 -4
- package/dist/core/mds-variants.js +34 -97
- package/dist/core/migrations.js +49 -23
- package/dist/core/plugins.js +5 -4
- package/dist/core/project-paths.js +0 -17
- package/dist/core/same-location.js +25 -0
- package/dist/core/tracker.js +226 -139
- package/dist/hud/components/config-counts.js +15 -4
- package/dist/hud/components/learning-counts.js +14 -0
- package/dist/hud/config.js +2 -1
- package/dist/hud/cost-history.js +2 -4
- package/dist/hud/git.js +52 -7
- package/dist/hud/index.js +7 -9
- package/dist/skills/git/references/pr/check-merge-readiness.md +1 -1
- package/dist/skills/git/references/pr/ensure-pr-ready.md +1 -1
- package/dist/skills/git/references/pr/update-pr-evidence.md +1 -1
- package/dist/skills/git/references/tracker/_mcp.md +1 -1
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +1 -1
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +1 -1
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +2 -2
- package/dist/skills/git/references/tracker/github/manage-debt.md +3 -3
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +1 -1
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +1 -1
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +2 -2
- package/dist/skills/git/references/tracker/jira/manage-debt.md +1 -1
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +1 -1
- package/dist/skills/git/references/tracker/jira/setup-task.md +1 -1
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +1 -1
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +1 -1
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +2 -2
- package/dist/skills/git/references/tracker/linear/manage-debt.md +1 -1
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +1 -1
- package/dist/skills/git/references/tracker/linear/setup-task.md +1 -1
- package/dist/targets/claude-code/claude-paths.js +59 -57
- package/dist/targets/claude-code/compliance-install.js +49 -65
- package/dist/targets/claude-code/hooks.js +108 -3
- package/dist/targets/claude-code/installer.js +30 -57
- package/dist/targets/claude-code/post-install.js +246 -139
- package/dist/targets/claude-code/tracker-install.js +38 -65
- package/package.json +5 -4
- package/src/assets/agents/code.md +4 -3
- package/src/assets/agents/design.md +1 -0
- package/src/assets/agents/git.mds +55 -57
- package/src/assets/agents/knowledge.md +2 -2
- package/src/assets/agents/review.md +3 -1
- package/src/assets/agents/tracker.md +37 -30
- package/src/assets/commands/_partials/_compliance.mds +19 -1
- package/src/assets/commands/_partials/_decisions.mds +15 -3
- package/src/assets/commands/_partials/_docs_root.mds +35 -0
- package/src/assets/commands/_partials/_engine.mds +2 -2
- package/src/assets/commands/_partials/_evidence_policy.mds +3 -3
- package/src/assets/commands/_partials/_factory.mds +1 -1
- package/src/assets/commands/_partials/_knowledge.mds +27 -9
- package/src/assets/commands/_partials/_plan_contract.mds +2 -2
- package/src/assets/commands/_partials/_preamble.mds +1 -1
- package/src/assets/commands/_partials/_publication.mds +6 -2
- package/src/assets/commands/_partials/_settings.mds +28 -0
- package/src/assets/commands/_partials/_ticket_template.mds +3 -3
- package/src/assets/commands/_partials/_tracker.mds +4 -4
- package/src/assets/commands/_partials/_wave.mds +4 -4
- package/src/assets/commands/bug-analysis.mds +19 -17
- package/src/assets/commands/code-review.mds +39 -33
- package/src/assets/commands/debug.mds +4 -5
- package/src/assets/commands/dynamic-build.mds +75 -53
- package/src/assets/commands/dynamic-plan.mds +20 -15
- package/src/assets/commands/dynamic-profile.mds +24 -11
- package/src/assets/commands/dynamic-tickets.mds +25 -20
- package/src/assets/commands/explore.mds +4 -5
- package/src/assets/commands/implement.mds +58 -45
- package/src/assets/commands/plan.mds +34 -29
- package/src/assets/commands/release.md +2 -2
- package/src/assets/commands/research.mds +11 -9
- package/src/assets/commands/resolve.mds +41 -39
- package/src/assets/commands/self-review.mds +24 -25
- package/src/assets/mds/git/_pr.mds +61 -61
- package/src/assets/mds/git/_references.mds +19 -19
- package/src/assets/mds/tracker/_common.mds +8 -8
- package/src/assets/mds/tracker/_github.mds +71 -71
- package/src/assets/mds/tracker/_jira.mds +74 -74
- package/src/assets/mds/tracker/_linear.mds +75 -75
- package/src/assets/mds/tracker/_mcp.mds +23 -17
- package/src/assets/scripts/hooks/background-memory-update +35 -19
- package/src/assets/scripts/hooks/capture-prompt +18 -12
- package/src/assets/scripts/hooks/capture-question +18 -12
- package/src/assets/scripts/hooks/capture-turn +27 -17
- package/src/assets/scripts/hooks/debug-trace +11 -6
- package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
- package/src/assets/scripts/hooks/ensure-proxy +9 -8
- package/src/assets/scripts/hooks/ensure-root-gitignore +111 -36
- package/src/assets/scripts/hooks/git-marker +48 -0
- package/src/assets/scripts/hooks/json-helper.cjs +6 -1
- package/src/assets/scripts/hooks/lib/project-paths.cjs +0 -19
- package/src/assets/scripts/hooks/log-paths +80 -0
- package/src/assets/scripts/hooks/memory-worker +17 -15
- package/src/assets/scripts/hooks/pre-compact-memory +41 -16
- package/src/assets/scripts/hooks/queue-append +104 -30
- package/src/assets/scripts/hooks/resolve-project-root +101 -7
- package/src/assets/scripts/hooks/session-start-context +289 -122
- package/src/assets/scripts/hooks/session-start-memory +35 -16
- package/src/assets/scripts/lib/project-config.cjs +633 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +300 -220
- package/src/assets/scripts/resolve-settings.cjs +1054 -0
- package/src/assets/scripts/verify-evidence.cjs +1 -1
- package/src/assets/skills/compliance/SKILL.md +2 -2
- package/src/assets/skills/docs-framework/SKILL.md +6 -7
- package/src/assets/skills/docs-framework/references/patterns.md +10 -17
- package/src/assets/skills/gap-analysis/SKILL.md +2 -2
- package/src/assets/skills/git/references/github-api.md +9 -9
- package/src/assets/skills/git/references/patterns.md +1 -1
- package/src/assets/skills/worktree-support/SKILL.md +1 -1
- package/src/assets/skills/worktree-support/references/roots.md +29 -0
- package/src/targets/claude-code/templates/managed-settings.json +25 -9
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
// src/assets/scripts/lib/project-config.cjs
|
|
2
|
+
//
|
|
3
|
+
// The ONE parser of devflow's per-repository config files — the team-committed
|
|
4
|
+
// `.devflow/project.json` and the personal, uncommitted `.devflow/config.json`
|
|
5
|
+
// (applies PF-023: the invariant lives at the sink every reader passes through).
|
|
6
|
+
// Installed beside its two callers as ~/.devflow/scripts/lib/project-config.cjs:
|
|
7
|
+
// resolve-evidence-policy.cjs reads `evidence` and `compliance` at every source
|
|
8
|
+
// resolve-settings.cjs reads every key of both files, locally
|
|
9
|
+
//
|
|
10
|
+
// Pure except the two readers (readBoundedRegularFile, readMachineManifest), which
|
|
11
|
+
// only ever read. Nothing here writes, spawns or prints: devflow never writes
|
|
12
|
+
// project.json (applies ADR-024).
|
|
13
|
+
//
|
|
14
|
+
// D-PROJECT-CONFIG: `.devflow/project.json` is a JSON object whose every key is
|
|
15
|
+
// optional and whose unknown keys are ignored:
|
|
16
|
+
// {"version":1,"evidence":"required|standard","compliance":["gdpr",…],
|
|
17
|
+
// "tracker":{"provider":"github|jira|linear","site":"https://…","key":"ACME"},
|
|
18
|
+
// "reviewPublication":"off|auto|full",
|
|
19
|
+
// "features":{"memory":false,"learning":false,"knowledge":false}}
|
|
20
|
+
// Each known key is classified on its own — absent, valid or malformed — so one
|
|
21
|
+
// bad value never takes its neighbours down with it (AC-26). What a malformed
|
|
22
|
+
// value MEANS is the consumer's decision, made once in each caller's fold:
|
|
23
|
+
// evidence ⇒ required, compliance ⇒ generic, tracker ⇒ TRACKER_WARN=invalid,
|
|
24
|
+
// reviewPublication ⇒ off, a feature switch ⇒ not narrowed.
|
|
25
|
+
//
|
|
26
|
+
// Whole-file rule: that per-key classification applies only INSIDE a file that
|
|
27
|
+
// reads as a JSON object. A file that exists but does not — empty, unparseable,
|
|
28
|
+
// not an object, a BOM, not UTF-8, over MAX_CONFIG_BYTES, too deeply nested, or
|
|
29
|
+
// (at the reader) a symlink or other non-regular file — is `invalid` as a whole,
|
|
30
|
+
// never `absent`, so no key of it can be mistaken for "not set". Each caller
|
|
31
|
+
// fails closed on it: resolve-evidence-policy.cjs resolves `required`,
|
|
32
|
+
// resolve-settings.cjs prints its fail-closed line, and the hooks' switch gate
|
|
33
|
+
// narrows nothing.
|
|
34
|
+
//
|
|
35
|
+
// D-PROJECT-STRICT-KEYS: keys are read with hasOwnProperty (a `__proto__` key is
|
|
36
|
+
// an own data property after JSON.parse, never the prototype), and a key that
|
|
37
|
+
// appears TWICE in the same object is malformed. JSON.parse keeps the last
|
|
38
|
+
// duplicate silently, so the file would say two things while the parser heard
|
|
39
|
+
// one; duplicates are therefore found in the raw text (collectDuplicateKeyPaths)
|
|
40
|
+
// before any value is trusted. A duplicate `evidence` is the case that matters —
|
|
41
|
+
// `{"evidence":"required","evidence":"standard"}` must not read as standard.
|
|
42
|
+
// Strictness is per key only inside a readable object: a file that is not one is
|
|
43
|
+
// `invalid` whole (the whole-file rule above), whatever keys it appears to hold.
|
|
44
|
+
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const os = require('os');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Closed vocabularies
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/** Largest config file read or decoded, in bytes — one bound for project.json and config.json. */
|
|
56
|
+
const MAX_CONFIG_BYTES = 4096;
|
|
57
|
+
|
|
58
|
+
/** Every evidence policy value, strictest first (resolve-evidence-policy.cjs POLICIES). */
|
|
59
|
+
const POLICIES = Object.freeze(['required', 'standard']);
|
|
60
|
+
|
|
61
|
+
/** Every review-publication value, in ascending order: off < auto < full. */
|
|
62
|
+
const PUBLICATIONS = Object.freeze(['off', 'auto', 'full']);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The registered tracker providers — transcribed from TRACKER_PROVIDERS in
|
|
66
|
+
* src/core/tracker.ts and pinned to it by a parity test.
|
|
67
|
+
*/
|
|
68
|
+
const TRACKER_PROVIDER_IDS = Object.freeze(['github', 'jira', 'linear']);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The registered compliance frameworks, in registry order — transcribed from
|
|
72
|
+
* COMPLIANCE_FRAMEWORKS in src/core/compliance.ts and pinned to it by a parity test.
|
|
73
|
+
*/
|
|
74
|
+
const COMPLIANCE_IDS = Object.freeze(['gdpr', 'hipaa', 'pci-dss', 'soc2', 'iso-27001', 'sox']);
|
|
75
|
+
|
|
76
|
+
/** The feature switches a repo layer may narrow. */
|
|
77
|
+
const FEATURE_SWITCHES = Object.freeze(['memory', 'learning', 'knowledge']);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A tracker site: the Jira setup-task gate (src/assets/mds/tracker/_jira.mds,
|
|
81
|
+
* `**Site.**`) exactly — https, a hostname, no userinfo, no port, no path. Each
|
|
82
|
+
* `+` run is separated by a literal `.`, so the match is linear.
|
|
83
|
+
*/
|
|
84
|
+
const TRACKER_SITE_RE = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9-]+)+$/;
|
|
85
|
+
|
|
86
|
+
/** Longest site admitted — a DNS name is at most 253 octets, plus the scheme. */
|
|
87
|
+
const MAX_SITE_LENGTH = 261;
|
|
88
|
+
|
|
89
|
+
/** A tracker project key: the Jira key shape, which the Linear team key also fits. */
|
|
90
|
+
const TRACKER_KEY_RE = /^[A-Z][A-Z0-9_]{1,9}$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A branch name either resolver will put into argv or onto stdout: an
|
|
94
|
+
* alphanumeric first character (so never an option and never a hidden path), then
|
|
95
|
+
* at most 254 of `[A-Za-z0-9._/-]`, and never `..`. The lookahead is bounded by
|
|
96
|
+
* the same class, so the scan is linear (no unbounded `.*`). No `:` is admitted,
|
|
97
|
+
* so `<ref>:<path>` revision syntax built from it names exactly that path.
|
|
98
|
+
*/
|
|
99
|
+
const SAFE_REF_RE = /^(?![A-Za-z0-9._/-]{0,254}\.\.)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
|
|
100
|
+
|
|
101
|
+
/** The ref namespace of origin's remote-tracking branches, as `git symbolic-ref` prints it. */
|
|
102
|
+
const ORIGIN_TRACKING_PREFIX = 'refs/remotes/origin/';
|
|
103
|
+
|
|
104
|
+
/** Largest machine manifest read, in bytes. */
|
|
105
|
+
const MAX_MANIFEST_BYTES = 1048576;
|
|
106
|
+
|
|
107
|
+
/** Deepest nesting the duplicate-key scan follows; deeper is invalid. */
|
|
108
|
+
const MAX_JSON_DEPTH = 32;
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Shapes
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @template T
|
|
116
|
+
* @typedef {{ kind: 'absent' } | { kind: 'malformed' } | { kind: 'valid', value: T }} Field
|
|
117
|
+
*
|
|
118
|
+
* @typedef {{ provider: Field<string>, site: Field<string>, key: Field<string> }} ProjectTracker
|
|
119
|
+
* @typedef {{ memory: Field<boolean>, learning: Field<boolean>, knowledge: Field<boolean> }} FeatureFields
|
|
120
|
+
*
|
|
121
|
+
* @typedef {{ kind: 'absent' } | { kind: 'invalid' } | {
|
|
122
|
+
* kind: 'parsed',
|
|
123
|
+
* version: Field<1>,
|
|
124
|
+
* evidence: Field<'required' | 'standard'>,
|
|
125
|
+
* compliance: Field<string[]>,
|
|
126
|
+
* tracker: Field<ProjectTracker>,
|
|
127
|
+
* reviewPublication: Field<'off' | 'auto' | 'full'>,
|
|
128
|
+
* features: Field<FeatureFields>,
|
|
129
|
+
* }} ProjectConfig
|
|
130
|
+
* `invalid` is a file that exists but is not a JSON object within the byte
|
|
131
|
+
* rules (size, BOM, UTF-8, grammar, depth) — every key of it is unknowable.
|
|
132
|
+
* `compliance` valid is the registry ids it names, normalized (possibly empty).
|
|
133
|
+
*
|
|
134
|
+
* @typedef {{ kind: 'absent' } | { kind: 'invalid' } | {
|
|
135
|
+
* kind: 'parsed',
|
|
136
|
+
* tracker: Field<string>,
|
|
137
|
+
* reviewPublication: Field<'off' | 'auto' | 'full'>,
|
|
138
|
+
* features: Field<FeatureFields>,
|
|
139
|
+
* }} PersonalConfig
|
|
140
|
+
* The personal file's `tracker` is the per-repo provider override: a provider
|
|
141
|
+
* id string (an empty string reads as absent, as parseTrackerOverride does).
|
|
142
|
+
*
|
|
143
|
+
* @typedef {{ kind: 'absent' } | { kind: 'refused' } | { kind: 'ok', bytes: Buffer }} BoundedRead
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/** @type {{ kind: 'absent' }} */
|
|
147
|
+
const ABSENT_FIELD = Object.freeze({ kind: 'absent' });
|
|
148
|
+
/** @type {{ kind: 'malformed' }} */
|
|
149
|
+
const MALFORMED_FIELD = Object.freeze({ kind: 'malformed' });
|
|
150
|
+
/** @type {{ kind: 'absent' }} */
|
|
151
|
+
const ABSENT_FILE = Object.freeze({ kind: 'absent' });
|
|
152
|
+
/** @type {{ kind: 'invalid' }} */
|
|
153
|
+
const INVALID_FILE = Object.freeze({ kind: 'invalid' });
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @template T
|
|
157
|
+
* @param {T} value
|
|
158
|
+
* @returns {Field<T>}
|
|
159
|
+
*/
|
|
160
|
+
function validField(value) {
|
|
161
|
+
return Object.freeze({ kind: 'valid', value });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Bytes → text (the byte rules both files share)
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Decode config-file bytes, or say why not. `null`/`undefined` is "no file"; an
|
|
170
|
+
* empty file is text (`''`), which every JSON consumer then refuses.
|
|
171
|
+
*
|
|
172
|
+
* The byte checks run before decoding — size, then a UTF-8 BOM, which is invalid
|
|
173
|
+
* rather than skipped — and decoding is fatal on a malformed sequence.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} buf
|
|
176
|
+
* @returns {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'text', text: string }}
|
|
177
|
+
*/
|
|
178
|
+
function decodeConfigBytes(buf) {
|
|
179
|
+
if (buf === null || buf === undefined) return ABSENT_FILE;
|
|
180
|
+
if (!(buf instanceof Uint8Array)) return INVALID_FILE;
|
|
181
|
+
if (buf.length > MAX_CONFIG_BYTES) return INVALID_FILE;
|
|
182
|
+
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return INVALID_FILE;
|
|
183
|
+
try {
|
|
184
|
+
// Fatal decoding would silently strip a leading BOM; the byte check above
|
|
185
|
+
// is what makes one invalid.
|
|
186
|
+
return { kind: 'text', text: new TextDecoder('utf-8', { fatal: true }).decode(buf) };
|
|
187
|
+
} catch (_) {
|
|
188
|
+
return INVALID_FILE;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Bounded file reads
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read a regular file of at most `maxBytes`, or say why not.
|
|
198
|
+
*
|
|
199
|
+
* The stat runs first and decides without opening: a non-regular file (with
|
|
200
|
+
* `followSymlinks` false this includes a symlink; always a directory, FIFO,
|
|
201
|
+
* socket or device) is refused unopened, and an oversize one unread. The open
|
|
202
|
+
* then adds O_NONBLOCK (a FIFO swapped in after the stat cannot block) and, when
|
|
203
|
+
* not following, O_NOFOLLOW (a symlink swapped in fails the open); the fstat
|
|
204
|
+
* re-checks the opened object. The read loop is bounded by the byte count.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} filePath
|
|
207
|
+
* @param {number} maxBytes
|
|
208
|
+
* @param {boolean} followSymlinks
|
|
209
|
+
* @returns {BoundedRead}
|
|
210
|
+
*/
|
|
211
|
+
function readBoundedRegularFile(filePath, maxBytes, followSymlinks) {
|
|
212
|
+
let st;
|
|
213
|
+
try {
|
|
214
|
+
st = followSymlinks ? fs.statSync(filePath) : fs.lstatSync(filePath);
|
|
215
|
+
} catch (/** @type {any} */ err) {
|
|
216
|
+
return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR') ? { kind: 'absent' } : { kind: 'refused' };
|
|
217
|
+
}
|
|
218
|
+
if (!st.isFile() || st.size > maxBytes) return { kind: 'refused' };
|
|
219
|
+
|
|
220
|
+
const flags = fs.constants.O_RDONLY
|
|
221
|
+
| (fs.constants.O_NONBLOCK || 0)
|
|
222
|
+
| (followSymlinks ? 0 : (fs.constants.O_NOFOLLOW || 0));
|
|
223
|
+
let fd;
|
|
224
|
+
try {
|
|
225
|
+
fd = fs.openSync(filePath, flags);
|
|
226
|
+
} catch (_) {
|
|
227
|
+
return { kind: 'refused' };
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const fst = fs.fstatSync(fd);
|
|
231
|
+
if (!fst.isFile() || fst.size > maxBytes) return { kind: 'refused' };
|
|
232
|
+
// One byte past the stat'd size: a file that grew between the stat and the
|
|
233
|
+
// read is refused rather than truncated into something that parses.
|
|
234
|
+
const buf = Buffer.alloc(fst.size + 1);
|
|
235
|
+
let total = 0;
|
|
236
|
+
for (let i = 0; i < buf.length; i++) {
|
|
237
|
+
const n = fs.readSync(fd, buf, total, buf.length - total, null);
|
|
238
|
+
if (n === 0) break;
|
|
239
|
+
total += n;
|
|
240
|
+
if (total === buf.length) break;
|
|
241
|
+
}
|
|
242
|
+
if (total > fst.size) return { kind: 'refused' };
|
|
243
|
+
return { kind: 'ok', bytes: buf.subarray(0, total) };
|
|
244
|
+
} catch (_) {
|
|
245
|
+
return { kind: 'refused' };
|
|
246
|
+
} finally {
|
|
247
|
+
try { fs.closeSync(fd); } catch (_) { /* the read already decided; a close error changes nothing */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The devflow machine root: ~/.devflow, else null (a home directory that is not
|
|
253
|
+
* absolute would resolve against cwd). No environment variable relocates it
|
|
254
|
+
* (D-ONE-HOME in src/targets/claude-code/claude-paths.ts).
|
|
255
|
+
*
|
|
256
|
+
* @returns {string | null}
|
|
257
|
+
*/
|
|
258
|
+
function machineDevflowDir() {
|
|
259
|
+
let home;
|
|
260
|
+
try {
|
|
261
|
+
home = os.homedir();
|
|
262
|
+
} catch (_) {
|
|
263
|
+
// No HOME and no passwd entry: there is no manifest to read, which is not an error.
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
return typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.devflow') : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The parsed machine manifest (~/.devflow/manifest.json), or undefined when it is
|
|
271
|
+
* absent, not a regular file, over MAX_MANIFEST_BYTES, not UTF-8 or not JSON.
|
|
272
|
+
* Read directly — never through the CLI's manifest reader, which heal-writes.
|
|
273
|
+
* Followed through a symlink: the manifest is the user's own file, not a
|
|
274
|
+
* repository's.
|
|
275
|
+
*
|
|
276
|
+
* @returns {unknown}
|
|
277
|
+
*/
|
|
278
|
+
function readMachineManifest() {
|
|
279
|
+
const dir = machineDevflowDir();
|
|
280
|
+
if (dir === null) return undefined;
|
|
281
|
+
const read = readBoundedRegularFile(path.join(dir, 'manifest.json'), MAX_MANIFEST_BYTES, true);
|
|
282
|
+
if (read.kind !== 'ok') return undefined;
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(read.bytes));
|
|
285
|
+
} catch (_) {
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Duplicate keys (D-PROJECT-STRICT-KEYS)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The key path of an object member, as a lookup string: JSON of the key array,
|
|
296
|
+
* so no key spelling can collide with another path.
|
|
297
|
+
*
|
|
298
|
+
* @param {readonly string[]} keys
|
|
299
|
+
* @returns {string}
|
|
300
|
+
*/
|
|
301
|
+
function keyPath(keys) {
|
|
302
|
+
return JSON.stringify(keys);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Every object-member path that occurs more than once in `text`, which must
|
|
307
|
+
* already be valid JSON (the caller runs JSON.parse first). Null when the text
|
|
308
|
+
* nests deeper than MAX_JSON_DEPTH or a key does not decode — the caller then
|
|
309
|
+
* treats the whole file as invalid.
|
|
310
|
+
*
|
|
311
|
+
* A key is the first string in an object after `{` or `,`, decoded with
|
|
312
|
+
* JSON.parse so an escaped spelling (`"evidence"`) is the key it spells.
|
|
313
|
+
* Array elements share the `[]` path segment. Both loops are bounded by the text
|
|
314
|
+
* length, which the caller bounds by MAX_CONFIG_BYTES.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} text
|
|
317
|
+
* @returns {Set<string> | null}
|
|
318
|
+
*/
|
|
319
|
+
function collectDuplicateKeyPaths(text) {
|
|
320
|
+
/** @type {Set<string>} */
|
|
321
|
+
const duplicates = new Set();
|
|
322
|
+
/** @type {Array<{ isObject: boolean, keys: Set<string>, path: string[], expectKey: boolean }>} */
|
|
323
|
+
const stack = [];
|
|
324
|
+
/** @type {string} */
|
|
325
|
+
let lastKey = '';
|
|
326
|
+
for (let i = 0; i < text.length; i++) {
|
|
327
|
+
const ch = text[i];
|
|
328
|
+
const top = stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
329
|
+
if (ch === '"') {
|
|
330
|
+
let j = i + 1;
|
|
331
|
+
while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
|
|
332
|
+
if (j >= text.length) return null;
|
|
333
|
+
if (top !== undefined && top.isObject && top.expectKey) {
|
|
334
|
+
let key;
|
|
335
|
+
try {
|
|
336
|
+
key = JSON.parse(text.slice(i, j + 1));
|
|
337
|
+
} catch (_) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
if (typeof key !== 'string') return null;
|
|
341
|
+
if (top.keys.has(key)) duplicates.add(keyPath(top.path.concat([key])));
|
|
342
|
+
top.keys.add(key);
|
|
343
|
+
top.expectKey = false;
|
|
344
|
+
lastKey = key;
|
|
345
|
+
}
|
|
346
|
+
i = j;
|
|
347
|
+
} else if (ch === '{' || ch === '[') {
|
|
348
|
+
if (stack.length >= MAX_JSON_DEPTH) return null;
|
|
349
|
+
const path = top === undefined ? [] : top.path.concat([top.isObject ? lastKey : '[]']);
|
|
350
|
+
stack.push({ isObject: ch === '{', keys: new Set(), path, expectKey: ch === '{' });
|
|
351
|
+
} else if (ch === '}' || ch === ']') {
|
|
352
|
+
stack.pop();
|
|
353
|
+
} else if (ch === ',' && top !== undefined && top.isObject) {
|
|
354
|
+
top.expectKey = true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return duplicates;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Whether `path`, or any object containing it, is a duplicated member.
|
|
362
|
+
*
|
|
363
|
+
* @param {Set<string>} duplicates
|
|
364
|
+
* @param {readonly string[]} path
|
|
365
|
+
* @returns {boolean}
|
|
366
|
+
*/
|
|
367
|
+
function isDuplicated(duplicates, path) {
|
|
368
|
+
for (let n = 1; n <= path.length; n++) {
|
|
369
|
+
if (duplicates.has(keyPath(path.slice(0, n)))) return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Value classifiers
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* @param {unknown} value
|
|
380
|
+
* @returns {value is Record<string, unknown>}
|
|
381
|
+
*/
|
|
382
|
+
function isPlainObject(value) {
|
|
383
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* @param {Record<string, unknown>} obj
|
|
388
|
+
* @param {string} key
|
|
389
|
+
* @returns {boolean}
|
|
390
|
+
*/
|
|
391
|
+
function own(obj, key) {
|
|
392
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Normalize one framework spelling exactly as src/core/compliance.ts normalizeId
|
|
397
|
+
* does: trim, lowercase, whitespace runs to `-`, and the `iso27001` alias.
|
|
398
|
+
*
|
|
399
|
+
* @param {string} s
|
|
400
|
+
* @returns {string}
|
|
401
|
+
*/
|
|
402
|
+
function normalizeComplianceId(s) {
|
|
403
|
+
const normalized = s.trim().toLowerCase().replace(/\s+/g, '-');
|
|
404
|
+
return normalized === 'iso27001' ? 'iso-27001' : normalized;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The default branch a `git symbolic-ref --quiet refs/remotes/origin/HEAD` answer
|
|
409
|
+
* names — the branch `git clone` (or `git remote set-head`) last recorded for
|
|
410
|
+
* origin, read with no network call. Exactly one line, `refs/remotes/origin/`
|
|
411
|
+
* followed by a SAFE_REF_RE branch; anything else (another remote, a second line,
|
|
412
|
+
* a hostile name) is null. Pure: the caller runs git and hands over its stdout.
|
|
413
|
+
*
|
|
414
|
+
* @param {unknown} stdout
|
|
415
|
+
* @returns {string | null}
|
|
416
|
+
*/
|
|
417
|
+
function parseOriginHeadRef(stdout) {
|
|
418
|
+
if (typeof stdout !== 'string') return null;
|
|
419
|
+
const line = stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout;
|
|
420
|
+
if (!line.startsWith(ORIGIN_TRACKING_PREFIX)) return null;
|
|
421
|
+
const branch = line.slice(ORIGIN_TRACKING_PREFIX.length);
|
|
422
|
+
return SAFE_REF_RE.test(branch) ? branch : null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The registry ids a framework list names — src/core/compliance.ts
|
|
427
|
+
* normalizeFrameworks exactly, behind a parity test: normalized, unknown ids
|
|
428
|
+
* dropped, first occurrence kept.
|
|
429
|
+
*
|
|
430
|
+
* @param {readonly string[]} frameworks
|
|
431
|
+
* @returns {string[]}
|
|
432
|
+
*/
|
|
433
|
+
function normalizeComplianceIds(frameworks) {
|
|
434
|
+
/** @type {Set<string>} */
|
|
435
|
+
const seen = new Set();
|
|
436
|
+
/** @type {string[]} */
|
|
437
|
+
const out = [];
|
|
438
|
+
for (const raw of frameworks) {
|
|
439
|
+
const id = normalizeComplianceId(raw);
|
|
440
|
+
if (!COMPLIANCE_IDS.includes(id) || seen.has(id)) continue;
|
|
441
|
+
seen.add(id);
|
|
442
|
+
out.push(id);
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The fields of one object, each classified on its own.
|
|
449
|
+
*
|
|
450
|
+
* @template T
|
|
451
|
+
* @param {Record<string, unknown>} obj
|
|
452
|
+
* @param {Set<string>} duplicates
|
|
453
|
+
* @param {readonly string[]} parentPath
|
|
454
|
+
* @param {string} key
|
|
455
|
+
* @param {(value: unknown) => { ok: true, value: T } | { ok: false }} classify
|
|
456
|
+
* @returns {Field<T>}
|
|
457
|
+
*/
|
|
458
|
+
function fieldOf(obj, duplicates, parentPath, key, classify) {
|
|
459
|
+
if (!own(obj, key)) return ABSENT_FIELD;
|
|
460
|
+
const path = parentPath.concat([key]);
|
|
461
|
+
if (isDuplicated(duplicates, path)) return MALFORMED_FIELD;
|
|
462
|
+
const verdict = classify(obj[key]);
|
|
463
|
+
return verdict.ok ? validField(verdict.value) : MALFORMED_FIELD;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* @template T
|
|
468
|
+
* @param {readonly T[]} allowed
|
|
469
|
+
* @returns {(value: unknown) => { ok: true, value: T } | { ok: false }}
|
|
470
|
+
*/
|
|
471
|
+
function oneOf(allowed) {
|
|
472
|
+
return value => (allowed.includes(/** @type {T} */ (value)) ? { ok: true, value: /** @type {T} */ (value) } : { ok: false });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** @param {unknown} value */
|
|
476
|
+
function asBoolean(value) {
|
|
477
|
+
return typeof value === 'boolean' ? { ok: /** @type {const} */ (true), value } : { ok: /** @type {const} */ (false) };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** @param {unknown} value */
|
|
481
|
+
function asVersion(value) {
|
|
482
|
+
return value === 1 ? { ok: /** @type {const} */ (true), value: /** @type {1} */ (1) } : { ok: /** @type {const} */ (false) };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** @param {unknown} value */
|
|
486
|
+
function asComplianceList(value) {
|
|
487
|
+
if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) return { ok: /** @type {const} */ (false) };
|
|
488
|
+
return { ok: /** @type {const} */ (true), value: Object.freeze(normalizeComplianceIds(value)) };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** @param {unknown} value */
|
|
492
|
+
function asSite(value) {
|
|
493
|
+
return typeof value === 'string' && value.length <= MAX_SITE_LENGTH && TRACKER_SITE_RE.test(value)
|
|
494
|
+
? { ok: /** @type {const} */ (true), value }
|
|
495
|
+
: { ok: /** @type {const} */ (false) };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** @param {unknown} value */
|
|
499
|
+
function asKey(value) {
|
|
500
|
+
return typeof value === 'string' && TRACKER_KEY_RE.test(value)
|
|
501
|
+
? { ok: /** @type {const} */ (true), value }
|
|
502
|
+
: { ok: /** @type {const} */ (false) };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The `features` object: each switch its own field, boolean only. A consumer
|
|
507
|
+
* narrows on a literal `false` alone.
|
|
508
|
+
*
|
|
509
|
+
* @param {Record<string, unknown>} obj
|
|
510
|
+
* @param {Set<string>} duplicates
|
|
511
|
+
* @returns {Field<FeatureFields>}
|
|
512
|
+
*/
|
|
513
|
+
function featuresField(obj, duplicates) {
|
|
514
|
+
return fieldOf(obj, duplicates, [], 'features', value => {
|
|
515
|
+
if (!isPlainObject(value)) return { ok: false };
|
|
516
|
+
const base = ['features'];
|
|
517
|
+
return {
|
|
518
|
+
ok: true,
|
|
519
|
+
value: Object.freeze({
|
|
520
|
+
memory: fieldOf(value, duplicates, base, 'memory', asBoolean),
|
|
521
|
+
learning: fieldOf(value, duplicates, base, 'learning', asBoolean),
|
|
522
|
+
knowledge: fieldOf(value, duplicates, base, 'knowledge', asBoolean),
|
|
523
|
+
}),
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
// The envelope
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Bytes → a plain JSON object and its duplicated paths, or the file-level verdict.
|
|
534
|
+
*
|
|
535
|
+
* @param {unknown} buf
|
|
536
|
+
* @returns {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'object', obj: Record<string, unknown>, duplicates: Set<string> }}
|
|
537
|
+
*/
|
|
538
|
+
function parseEnvelope(buf) {
|
|
539
|
+
const decoded = decodeConfigBytes(buf);
|
|
540
|
+
if (decoded.kind !== 'text') return decoded;
|
|
541
|
+
let parsed;
|
|
542
|
+
try {
|
|
543
|
+
parsed = JSON.parse(decoded.text);
|
|
544
|
+
} catch (_) {
|
|
545
|
+
return INVALID_FILE;
|
|
546
|
+
}
|
|
547
|
+
if (!isPlainObject(parsed)) return INVALID_FILE;
|
|
548
|
+
const duplicates = collectDuplicateKeyPaths(decoded.text);
|
|
549
|
+
if (duplicates === null) return INVALID_FILE;
|
|
550
|
+
return { kind: 'object', obj: parsed, duplicates };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Classify `.devflow/project.json` bytes (D-PROJECT-CONFIG, D-PROJECT-STRICT-KEYS).
|
|
555
|
+
* `null`/`undefined` is "no file". Never throws.
|
|
556
|
+
*
|
|
557
|
+
* @param {unknown} buf
|
|
558
|
+
* @returns {ProjectConfig}
|
|
559
|
+
*/
|
|
560
|
+
function parseProjectBytes(buf) {
|
|
561
|
+
const env = parseEnvelope(buf);
|
|
562
|
+
if (env.kind !== 'object') return env;
|
|
563
|
+
const { obj, duplicates } = env;
|
|
564
|
+
return Object.freeze({
|
|
565
|
+
kind: 'parsed',
|
|
566
|
+
version: fieldOf(obj, duplicates, [], 'version', asVersion),
|
|
567
|
+
evidence: fieldOf(obj, duplicates, [], 'evidence', oneOf(POLICIES)),
|
|
568
|
+
compliance: fieldOf(obj, duplicates, [], 'compliance', asComplianceList),
|
|
569
|
+
tracker: fieldOf(obj, duplicates, [], 'tracker', value => {
|
|
570
|
+
if (!isPlainObject(value)) return { ok: false };
|
|
571
|
+
const base = ['tracker'];
|
|
572
|
+
return {
|
|
573
|
+
ok: true,
|
|
574
|
+
value: Object.freeze({
|
|
575
|
+
provider: fieldOf(value, duplicates, base, 'provider', oneOf(TRACKER_PROVIDER_IDS)),
|
|
576
|
+
site: fieldOf(value, duplicates, base, 'site', asSite),
|
|
577
|
+
key: fieldOf(value, duplicates, base, 'key', asKey),
|
|
578
|
+
}),
|
|
579
|
+
};
|
|
580
|
+
}),
|
|
581
|
+
reviewPublication: fieldOf(obj, duplicates, [], 'reviewPublication', oneOf(PUBLICATIONS)),
|
|
582
|
+
features: featuresField(obj, duplicates),
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Classify the personal `.devflow/config.json` bytes with the same rules. Only
|
|
588
|
+
* the keys a personal file may carry are read: `reviewPublication`, `features`
|
|
589
|
+
* and the `tracker` override. The retired top-level switches (`memory`,
|
|
590
|
+
* `learning`, `knowledge`, `decisions`) are never read — `features` is a new
|
|
591
|
+
* namespace, so a stale `learning: false` left from the per-repo-install era
|
|
592
|
+
* cannot come back to life. Never throws.
|
|
593
|
+
*
|
|
594
|
+
* @param {unknown} buf
|
|
595
|
+
* @returns {PersonalConfig}
|
|
596
|
+
*/
|
|
597
|
+
function parsePersonalBytes(buf) {
|
|
598
|
+
const env = parseEnvelope(buf);
|
|
599
|
+
if (env.kind !== 'object') return env;
|
|
600
|
+
const { obj, duplicates } = env;
|
|
601
|
+
// `"tracker": ""` is an unset key with a character in it (parseTrackerOverride).
|
|
602
|
+
const tracker = own(obj, 'tracker') && obj.tracker === '' && !isDuplicated(duplicates, ['tracker'])
|
|
603
|
+
? ABSENT_FIELD
|
|
604
|
+
: fieldOf(obj, duplicates, [], 'tracker', oneOf(TRACKER_PROVIDER_IDS));
|
|
605
|
+
return Object.freeze({
|
|
606
|
+
kind: 'parsed',
|
|
607
|
+
tracker,
|
|
608
|
+
reviewPublication: fieldOf(obj, duplicates, [], 'reviewPublication', oneOf(PUBLICATIONS)),
|
|
609
|
+
features: featuresField(obj, duplicates),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
module.exports = Object.freeze({
|
|
614
|
+
MAX_CONFIG_BYTES,
|
|
615
|
+
POLICIES,
|
|
616
|
+
PUBLICATIONS,
|
|
617
|
+
TRACKER_PROVIDER_IDS,
|
|
618
|
+
COMPLIANCE_IDS,
|
|
619
|
+
FEATURE_SWITCHES,
|
|
620
|
+
TRACKER_SITE_RE,
|
|
621
|
+
TRACKER_KEY_RE,
|
|
622
|
+
SAFE_REF_RE,
|
|
623
|
+
ORIGIN_TRACKING_PREFIX,
|
|
624
|
+
decodeConfigBytes,
|
|
625
|
+
readBoundedRegularFile,
|
|
626
|
+
machineDevflowDir,
|
|
627
|
+
readMachineManifest,
|
|
628
|
+
collectDuplicateKeyPaths,
|
|
629
|
+
normalizeComplianceIds,
|
|
630
|
+
parseOriginHeadRef,
|
|
631
|
+
parseProjectBytes,
|
|
632
|
+
parsePersonalBytes,
|
|
633
|
+
});
|