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,1054 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// src/assets/scripts/resolve-settings.cjs
|
|
3
|
+
//
|
|
4
|
+
// Resolves the per-repository SETTINGS a prompt or the CLI acts on — tracker,
|
|
5
|
+
// review publication, compliance lens and the three feature switches — from
|
|
6
|
+
// LOCAL layers, and prints them on ONE closed-vocabulary line. Installed as a
|
|
7
|
+
// top-level sibling of resolve-evidence-policy.cjs under ~/.devflow/scripts/,
|
|
8
|
+
// sharing its parser (lib/project-config.cjs).
|
|
9
|
+
//
|
|
10
|
+
// Usage: node resolve-settings.cjs [<dir>] (<dir> defaults to cwd)
|
|
11
|
+
//
|
|
12
|
+
// The layers, in the order they are folded:
|
|
13
|
+
// project <toplevel>/.devflow/project.json team-committed, this worktree's copy
|
|
14
|
+
// default refs/remotes/origin/<D>:.devflow/project.json
|
|
15
|
+
// the default branch's tracking copy —
|
|
16
|
+
// its compliance ids only (D-LENS-UNION)
|
|
17
|
+
// personal <toplevel>/.devflow/config.json untracked, narrow-only — a copy git
|
|
18
|
+
// tracks is ignored (D-PERSONAL-UNTRACKED)
|
|
19
|
+
// machine ~/.devflow/manifest.json the machine-wide install
|
|
20
|
+
//
|
|
21
|
+
// D-SETTINGS-LOCAL-ONLY: local git subprocesses only, and never gh, never the
|
|
22
|
+
// network, never a git command that refreshes the index. At most four, in order:
|
|
23
|
+
// git rev-parse --show-toplevel always
|
|
24
|
+
// git -c core.fsmonitor=false ls-files --error-unmatch -- .devflow/config.json
|
|
25
|
+
// only when config.json exists
|
|
26
|
+
// git symbolic-ref --quiet refs/remotes/origin/HEAD in a repository
|
|
27
|
+
// git cat-file blob refs/remotes/origin/<D>:.devflow/project.json
|
|
28
|
+
// only when origin/HEAD names D
|
|
29
|
+
// The team's evidence FLOOR is the default branch's and is
|
|
30
|
+
// resolve-evidence-policy.cjs's job (it runs over the network, from commands).
|
|
31
|
+
// This script reads the WORKTREE's copy for every other key, so a branch that
|
|
32
|
+
// edits project.json changes what it resolves for that branch: the feature
|
|
33
|
+
// switches can only narrow, the compliance lens can only gain scrutiny — the
|
|
34
|
+
// default branch's ids stay in it (D-LENS-UNION) — and the team's review
|
|
35
|
+
// publication can only lower it (D-PUBLICATION-CEILING) — a branch's
|
|
36
|
+
// `reviewPublication: "full"` raises nothing, since only a personal value asks
|
|
37
|
+
// for more than `auto`. The tracker (provider, site, key) is taken as the
|
|
38
|
+
// branch states it. It WRITES NOTHING (applies ADR-024).
|
|
39
|
+
//
|
|
40
|
+
// stdout is exactly one line plus "\n", or empty (D-SETTINGS-LINE):
|
|
41
|
+
// TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default>
|
|
42
|
+
// TRACKER_WARN=<none|mismatch|invalid> SITE=<url|none> KEY=<KEY|none>
|
|
43
|
+
// REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|id,…>
|
|
44
|
+
// MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>
|
|
45
|
+
// (one line on stdout; wrapped here for reading). Every value is a closed token
|
|
46
|
+
// or a SITE/KEY that passed its shape gate, so no other byte of a config file can
|
|
47
|
+
// reach stdout. Whole-file rule: a project.json or config.json that EXISTS but
|
|
48
|
+
// cannot be read as a JSON object — unparseable, empty, not an object, a BOM,
|
|
49
|
+
// not UTF-8, over MAX_CONFIG_BYTES, a symlink or other non-regular file — fails
|
|
50
|
+
// every field closed except COMPLIANCE, which keeps every lens a readable layer
|
|
51
|
+
// declares (exit 0, the file named on stderr). Only keys inside a readable object
|
|
52
|
+
// are classified one by one.
|
|
53
|
+
//
|
|
54
|
+
// Exit codes (a caller treats EVERY non-zero code as the fail-closed line):
|
|
55
|
+
// 0 resolved — the line above, including the whole-file rule's line
|
|
56
|
+
// 1 usage error — stdout entirely empty, usage on stderr
|
|
57
|
+
// 2 input unusable — <dir> missing or not a directory; prints
|
|
58
|
+
// SETTINGS_FAIL_CLOSED_LINE
|
|
59
|
+
// 3 never emitted — this script writes no file
|
|
60
|
+
// 4 internal error — or git could not say whether <dir> is in a repository;
|
|
61
|
+
// prints SETTINGS_FAIL_CLOSED_LINE
|
|
62
|
+
// 5 output gate refused — the composed line failed the grammar or its
|
|
63
|
+
// coherence checks; prints SETTINGS_FAIL_CLOSED_LINE
|
|
64
|
+
//
|
|
65
|
+
// Design constraints (binding, shared with resolve-evidence-policy.cjs):
|
|
66
|
+
// - main() returns {code, line} and never calls process.exit; the single
|
|
67
|
+
// `require.main === module` boundary is the only stdout write and the only
|
|
68
|
+
// exitCode assignment
|
|
69
|
+
// - every subprocess is spawned with an argv array (never a shell), stdin
|
|
70
|
+
// ignored, a timeout and a maxBuffer
|
|
71
|
+
// - a config file that is not a regular file is never opened, and one over
|
|
72
|
+
// MAX_CONFIG_BYTES is never read
|
|
73
|
+
|
|
74
|
+
'use strict';
|
|
75
|
+
|
|
76
|
+
const fs = require('fs');
|
|
77
|
+
const path = require('path');
|
|
78
|
+
const childProcess = require('child_process');
|
|
79
|
+
const projectConfig = require('./lib/project-config.cjs');
|
|
80
|
+
|
|
81
|
+
const {
|
|
82
|
+
PUBLICATIONS,
|
|
83
|
+
TRACKER_PROVIDER_IDS,
|
|
84
|
+
COMPLIANCE_IDS,
|
|
85
|
+
FEATURE_SWITCHES,
|
|
86
|
+
TRACKER_SITE_RE,
|
|
87
|
+
TRACKER_KEY_RE,
|
|
88
|
+
MAX_CONFIG_BYTES,
|
|
89
|
+
} = projectConfig;
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Closed vocabularies
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
/** @typedef {'github' | 'jira' | 'linear'} TrackerProvider */
|
|
96
|
+
/** @typedef {'project' | 'personal' | 'machine' | 'default'} TrackerSource */
|
|
97
|
+
/** @typedef {'none' | 'mismatch' | 'invalid'} TrackerWarn */
|
|
98
|
+
/** @typedef {'off' | 'auto' | 'full'} Publication */
|
|
99
|
+
/** @typedef {'memory' | 'learning' | 'knowledge'} FeatureSwitch */
|
|
100
|
+
/** @typedef {'machine' | 'project' | 'personal'} SwitchSource */
|
|
101
|
+
|
|
102
|
+
/** Who decided TRACKER. `default` is github with no layer naming a provider. */
|
|
103
|
+
const TRACKER_SOURCES = Object.freeze(/** @type {TrackerSource[]} */ (['project', 'personal', 'machine', 'default']));
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* TRACKER_WARN tokens, mildest first.
|
|
107
|
+
* mismatch the personal override names a provider other than github or the
|
|
108
|
+
* resolved one — a personal file may only narrow, so it is ignored
|
|
109
|
+
* invalid a tracker value (project provider, site or key; the personal
|
|
110
|
+
* override) is malformed. The Git agent maps this to its existing
|
|
111
|
+
* `unknown tracker provider` DEGRADED reason
|
|
112
|
+
*/
|
|
113
|
+
const TRACKER_WARNS = Object.freeze(/** @type {TrackerWarn[]} */ (['none', 'mismatch', 'invalid']));
|
|
114
|
+
|
|
115
|
+
/** Exit codes by meaning; 3 is deliberately absent (no arm can produce it). */
|
|
116
|
+
const EXIT_CODES = Object.freeze({
|
|
117
|
+
RESOLVED: 0,
|
|
118
|
+
USAGE: 1,
|
|
119
|
+
INPUT_UNUSABLE: 2,
|
|
120
|
+
INTERNAL_ERROR: 4,
|
|
121
|
+
OUTPUT_GATE_REFUSED: 5,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* D-SETTINGS-LINE: the exported output grammar — anchored, fixed field order,
|
|
126
|
+
* closed alternations built from the shared registries, named groups for
|
|
127
|
+
* consumers. SITE and KEY reuse the parser's own shape gates, so a line can only
|
|
128
|
+
* carry a site or key the parser admitted. COMPLIANCE is `off`, `generic` (the
|
|
129
|
+
* lens with no framework reference), or registry ids joined by commas.
|
|
130
|
+
*
|
|
131
|
+
* Whole-file rule: the line is folded only from files that are absent or read as
|
|
132
|
+
* a JSON object. A repository file that exists and does not (the parser's
|
|
133
|
+
* `invalid` file) says nothing that can be trusted — a team file meant to declare
|
|
134
|
+
* `compliance:["hipaa"]` must not read as "no file" — so every field takes its
|
|
135
|
+
* SETTINGS_FAIL_CLOSED_LINE value, for the team project.json and the personal
|
|
136
|
+
* config.json alike, EXCEPT COMPLIANCE. The review lens only adds scrutiny
|
|
137
|
+
* (D-LENS-UNION), and a broken file affects only the keys it owns, so it never
|
|
138
|
+
* lowers a lens another layer declares: COMPLIANCE is the fold of the readable
|
|
139
|
+
* layers — the machine's ids, the default branch's, and the worktree
|
|
140
|
+
* project.json's when only config.json is broken (a personal file declares no
|
|
141
|
+
* compliance) — with an unreadable project.json read as a malformed declaration,
|
|
142
|
+
* `generic`. That line prints with exit 0, the unreadable file named on stderr,
|
|
143
|
+
* so a consumer that accepts only exit 0 acts on it; an unreadable project.json
|
|
144
|
+
* with no other lens anywhere is exactly SETTINGS_FAIL_CLOSED_LINE, and an
|
|
145
|
+
* unreadable machine manifest reads as no lens. Individually malformed keys
|
|
146
|
+
* inside a readable object keep their per-key readings (AC-26).
|
|
147
|
+
*/
|
|
148
|
+
const SETTINGS_LINE_RE = new RegExp(
|
|
149
|
+
'^TRACKER=(?<tracker>' + TRACKER_PROVIDER_IDS.join('|') + ')'
|
|
150
|
+
+ ' TRACKER_SOURCE=(?<trackerSource>' + TRACKER_SOURCES.join('|') + ')'
|
|
151
|
+
+ ' TRACKER_WARN=(?<trackerWarn>' + TRACKER_WARNS.join('|') + ')'
|
|
152
|
+
+ ' SITE=(?<site>none|' + TRACKER_SITE_RE.source.slice(1, -1) + ')'
|
|
153
|
+
+ ' KEY=(?<key>none|' + TRACKER_KEY_RE.source.slice(1, -1) + ')'
|
|
154
|
+
+ ' REVIEW_PUBLICATION=(?<publication>' + PUBLICATIONS.join('|') + ')'
|
|
155
|
+
+ ' COMPLIANCE=(?<compliance>off|generic|(?:' + COMPLIANCE_IDS.join('|') + ')(?:,(?:'
|
|
156
|
+
+ COMPLIANCE_IDS.join('|') + ')){0,' + String(COMPLIANCE_IDS.length - 1) + '})'
|
|
157
|
+
+ ' MEMORY=(?<memory>on|off) LEARNING=(?<learning>on|off) KNOWLEDGE=(?<knowledge>on|off)$',
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The line every refusal prints (exits 2, 4 and 5), what a consumer uses in place
|
|
162
|
+
* of any line it cannot accept, and the whole-file rule's line on a machine with
|
|
163
|
+
* no compliance lens. A constant, so the boundary can never fail
|
|
164
|
+
* to compose it. Each field is the conservative reading for its consumer:
|
|
165
|
+
* TRACKER_WARN=invalid the Git agent degrades rather than guessing a provider
|
|
166
|
+
* REVIEW_PUBLICATION=off nothing is published (raised to a stub under required)
|
|
167
|
+
* COMPLIANCE=generic the review lens still runs
|
|
168
|
+
* KNOWLEDGE=off no knowledge write-back commits into the repository
|
|
169
|
+
* MEMORY/LEARNING=on the machine switch is the one that turns them off;
|
|
170
|
+
* hooks never consume this line (their gate folds an
|
|
171
|
+
* unreadable file as narrowing nothing)
|
|
172
|
+
*/
|
|
173
|
+
const SETTINGS_FAIL_CLOSED_LINE =
|
|
174
|
+
'TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none '
|
|
175
|
+
+ 'REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off';
|
|
176
|
+
|
|
177
|
+
/** Bound on each local git call. */
|
|
178
|
+
const GIT_TIMEOUT_MS = 5000;
|
|
179
|
+
/** rev-parse, ls-files and symbolic-ref print one short line. */
|
|
180
|
+
const LINE_MAX_BUFFER = 4096;
|
|
181
|
+
/**
|
|
182
|
+
* The tracking-copy read: one byte past the parser's bound, so an oversize blob
|
|
183
|
+
* either arrives whole and is refused by the parser, or overflows (ENOBUFS) and
|
|
184
|
+
* reads as unanswered — never truncated into something that parses.
|
|
185
|
+
*/
|
|
186
|
+
const BLOB_MAX_BUFFER = MAX_CONFIG_BYTES + 1;
|
|
187
|
+
|
|
188
|
+
/** The personal file, repository-relative, in git's spelling. */
|
|
189
|
+
const PERSONAL_REL = '.devflow/config.json';
|
|
190
|
+
/** The team file, repository-relative, in git's spelling. */
|
|
191
|
+
const PROJECT_REL = '.devflow/project.json';
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Shapes
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @typedef {{ on: boolean, source: SwitchSource }} SwitchState
|
|
199
|
+
* `source` is the layer that decided: `machine` when the machine switch is off
|
|
200
|
+
* or nothing narrowed it, else the repo layer whose literal `false` narrowed it.
|
|
201
|
+
*
|
|
202
|
+
* @typedef {{
|
|
203
|
+
* ok: boolean,
|
|
204
|
+
* tracker: TrackerProvider,
|
|
205
|
+
* trackerSource: TrackerSource,
|
|
206
|
+
* trackerWarn: TrackerWarn,
|
|
207
|
+
* site: string | null,
|
|
208
|
+
* key: string | null,
|
|
209
|
+
* reviewPublication: Publication,
|
|
210
|
+
* compliance: { enabled: boolean, frameworks: readonly string[] },
|
|
211
|
+
* switches: { memory: SwitchState, learning: SwitchState, knowledge: SwitchState },
|
|
212
|
+
* repoCompliance: readonly string[] | null,
|
|
213
|
+
* defaultBranchCompliance: readonly string[] | null,
|
|
214
|
+
* personalTracked: boolean,
|
|
215
|
+
* retiredPolicyFile: boolean,
|
|
216
|
+
* unreadable: 'project' | 'personal' | null,
|
|
217
|
+
* }} Settings
|
|
218
|
+
* ok false only for a fail-closed resolution — the whole-file
|
|
219
|
+
* rule's included, which still carries every readable lens
|
|
220
|
+
* compliance enabled with no frameworks is `generic`
|
|
221
|
+
* repoCompliance the worktree project.json's ids, or null when it declares
|
|
222
|
+
* none (a malformed declaration reads as [] — generic)
|
|
223
|
+
* defaultBranchCompliance the default branch's tracking copy's ids, the same
|
|
224
|
+
* way — null when it declares none or there is no tracking copy
|
|
225
|
+
* personalTracked .devflow/config.json is tracked by git and was ignored
|
|
226
|
+
* (D-PERSONAL-UNTRACKED); main() tells the user to untrack it
|
|
227
|
+
* retiredPolicyFile the worktree holds the retired .devflow/policy.json (the
|
|
228
|
+
* CLI prints a migration hint)
|
|
229
|
+
* unreadable the first repository layer whose file exists but is
|
|
230
|
+
* unreadable, which failed every field but the compliance
|
|
231
|
+
* lens closed (the whole-file rule; project before personal);
|
|
232
|
+
* null otherwise
|
|
233
|
+
*
|
|
234
|
+
* @typedef {{
|
|
235
|
+
* project: object,
|
|
236
|
+
* personal: object,
|
|
237
|
+
* manifest: unknown,
|
|
238
|
+
* retiredPolicyFile: boolean,
|
|
239
|
+
* defaultBranch?: ComplianceField,
|
|
240
|
+
* personalTracked?: boolean,
|
|
241
|
+
* }} SettingsInputs
|
|
242
|
+
* project/personal are lib/project-config.cjs ProjectConfig / PersonalConfig.
|
|
243
|
+
* defaultBranch is the tracking copy's `compliance` field (absent when omitted).
|
|
244
|
+
*
|
|
245
|
+
* @typedef {{ kind: 'absent' } | { kind: 'malformed' } | { kind: 'valid', value: readonly string[] }} ComplianceField
|
|
246
|
+
*
|
|
247
|
+
* @typedef {{ project: object, personal: object, personalTracked: boolean }} RepoLayers
|
|
248
|
+
*
|
|
249
|
+
* @typedef {{ status: number | null, stdout?: Buffer | string, error?: { code?: string } }} ExecResult
|
|
250
|
+
* @typedef {{ answered: boolean, ok: boolean, stdout: Buffer }} GitAnswer
|
|
251
|
+
* answered git ran and exited on its own (no spawn error, timeout or overflow)
|
|
252
|
+
* ok answered with exit 0
|
|
253
|
+
* @typedef {(file: string, args: string[], opts: object) => ExecResult} ExecFn
|
|
254
|
+
* @typedef {{ dir: string, manifest?: unknown }} ResolveSettingsOptions
|
|
255
|
+
* `manifest` is an already-parsed ~/.devflow/manifest.json; omitted
|
|
256
|
+
* (undefined), the script reads it itself.
|
|
257
|
+
*/
|
|
258
|
+
|
|
259
|
+
/** @type {Settings} */
|
|
260
|
+
const FAIL_CLOSED_SETTINGS = Object.freeze({
|
|
261
|
+
ok: false,
|
|
262
|
+
tracker: 'github',
|
|
263
|
+
trackerSource: 'default',
|
|
264
|
+
trackerWarn: 'invalid',
|
|
265
|
+
site: null,
|
|
266
|
+
key: null,
|
|
267
|
+
reviewPublication: 'off',
|
|
268
|
+
compliance: Object.freeze({ enabled: true, frameworks: Object.freeze([]) }),
|
|
269
|
+
switches: Object.freeze({
|
|
270
|
+
memory: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
|
|
271
|
+
learning: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
|
|
272
|
+
knowledge: Object.freeze({ on: false, source: /** @type {SwitchSource} */ ('machine') }),
|
|
273
|
+
}),
|
|
274
|
+
repoCompliance: null,
|
|
275
|
+
defaultBranchCompliance: null,
|
|
276
|
+
personalTracked: false,
|
|
277
|
+
retiredPolicyFile: false,
|
|
278
|
+
unreadable: null,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
const ABSENT_FIELD = Object.freeze({ kind: 'absent' });
|
|
283
|
+
const MALFORMED_FIELD = Object.freeze({ kind: 'malformed' });
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Machine-layer mirrors (pinned to the TypeScript by parity tests)
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @param {unknown} value
|
|
291
|
+
* @returns {value is Record<string, any>}
|
|
292
|
+
*/
|
|
293
|
+
function isPlainObject(value) {
|
|
294
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The pre-rename key each switch was stored under (feature-switch.ts LEGACY_KEYS). */
|
|
298
|
+
const LEGACY_SWITCH_KEYS = Object.freeze({ learning: 'decisions', knowledge: 'kb' });
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* src/core/feature-switch.ts isMachineFeatureOn exactly: only an explicit
|
|
302
|
+
* boolean `false` is off, the legacy key is read when the current one is not a
|
|
303
|
+
* boolean, and anything that is not a manifest-shaped object is on.
|
|
304
|
+
*
|
|
305
|
+
* @param {unknown} manifest
|
|
306
|
+
* @param {FeatureSwitch} feature
|
|
307
|
+
* @returns {boolean}
|
|
308
|
+
*/
|
|
309
|
+
function machineSwitchOn(manifest, feature) {
|
|
310
|
+
if (!isPlainObject(manifest)) return true;
|
|
311
|
+
const features = manifest.features;
|
|
312
|
+
if (!isPlainObject(features)) return true;
|
|
313
|
+
const legacy = /** @type {Record<string, string>} */ (LEGACY_SWITCH_KEYS)[feature];
|
|
314
|
+
const value = legacy !== undefined && typeof features[feature] !== 'boolean' ? features[legacy] : features[feature];
|
|
315
|
+
return value !== false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* src/core/tracker.ts normalizeTrackerFeature, plus whether a provider was
|
|
320
|
+
* actually recorded (`machine`) or defaulted (`default`).
|
|
321
|
+
*
|
|
322
|
+
* @param {unknown} manifest
|
|
323
|
+
* @returns {{ provider: TrackerProvider, recorded: boolean }}
|
|
324
|
+
*/
|
|
325
|
+
function machineTracker(manifest) {
|
|
326
|
+
const tracker = isPlainObject(manifest) && isPlainObject(manifest.features) ? manifest.features.tracker : undefined;
|
|
327
|
+
if (isPlainObject(tracker) && TRACKER_PROVIDER_IDS.includes(tracker.provider)) {
|
|
328
|
+
return { provider: /** @type {TrackerProvider} */ (tracker.provider), recorded: true };
|
|
329
|
+
}
|
|
330
|
+
return { provider: 'github', recorded: false };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The machine compliance lens: null when off, else its registry ids —
|
|
335
|
+
* src/core/compliance.ts normalizeComplianceFeature, then normalizeFrameworks.
|
|
336
|
+
*
|
|
337
|
+
* @param {unknown} manifest
|
|
338
|
+
* @returns {string[] | null}
|
|
339
|
+
*/
|
|
340
|
+
function machineCompliance(manifest) {
|
|
341
|
+
const raw = isPlainObject(manifest) && isPlainObject(manifest.features) ? manifest.features.compliance : undefined;
|
|
342
|
+
if (!isPlainObject(raw) || typeof raw.enabled !== 'boolean' || !Array.isArray(raw.frameworks)) return null;
|
|
343
|
+
if (!raw.frameworks.every((/** @type {unknown} */ f) => typeof f === 'string')) return null;
|
|
344
|
+
return raw.enabled ? projectConfig.normalizeComplianceIds(raw.frameworks) : null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// The fold (the functional core)
|
|
349
|
+
//
|
|
350
|
+
// The fold reads a file-level `invalid` field by field (fieldIn) so that it never
|
|
351
|
+
// throws on any parser output. resolveSettings folds such a file for the
|
|
352
|
+
// compliance lens alone — an unreadable project.json is a malformed declaration,
|
|
353
|
+
// `generic` — and fails every other field closed first (the whole-file rule); the
|
|
354
|
+
// hooks' switch gate folds it whole, where an unreadable file narrows nothing.
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A field of a parsed file, or the file-level verdict applied to it: an absent
|
|
359
|
+
* file has the field absent; an invalid file has every field malformed.
|
|
360
|
+
*
|
|
361
|
+
* @param {any} file
|
|
362
|
+
* @param {string} key
|
|
363
|
+
* @param {'malformed' | 'absent'} whenInvalid
|
|
364
|
+
* @returns {any}
|
|
365
|
+
*/
|
|
366
|
+
function fieldIn(file, key, whenInvalid) {
|
|
367
|
+
if (file.kind === 'parsed') return file[key];
|
|
368
|
+
if (file.kind === 'invalid') return whenInvalid === 'malformed' ? MALFORMED_FIELD : ABSENT_FIELD;
|
|
369
|
+
return ABSENT_FIELD;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The tracker (D-SETTINGS-LINE, tracker rules):
|
|
374
|
+
* 1. project.json `tracker.provider`, else the machine's recorded provider,
|
|
375
|
+
* else github (`default`);
|
|
376
|
+
* 2. the personal override may only NARROW — to github, or to the provider
|
|
377
|
+
* step 1 resolved. Anything else is ignored and flagged `mismatch`;
|
|
378
|
+
* 3. a malformed value anywhere (project provider/site/key, the personal
|
|
379
|
+
* override, an unreadable project.json) is flagged `invalid`, and a
|
|
380
|
+
* malformed site or key is never printed.
|
|
381
|
+
* SITE and KEY are the project's, printed only when the resolved provider is not
|
|
382
|
+
* github — a github repository has neither.
|
|
383
|
+
*
|
|
384
|
+
* @param {any} project
|
|
385
|
+
* @param {any} personal
|
|
386
|
+
* @param {unknown} manifest
|
|
387
|
+
* @returns {Pick<Settings, 'tracker' | 'trackerSource' | 'trackerWarn' | 'site' | 'key'>}
|
|
388
|
+
*/
|
|
389
|
+
function foldTracker(project, personal, manifest) {
|
|
390
|
+
let invalid = false;
|
|
391
|
+
let mismatch = false;
|
|
392
|
+
/** @type {TrackerProvider | null} */
|
|
393
|
+
let projectProvider = null;
|
|
394
|
+
/** @type {string | null} */
|
|
395
|
+
let site = null;
|
|
396
|
+
/** @type {string | null} */
|
|
397
|
+
let key = null;
|
|
398
|
+
|
|
399
|
+
const projectTracker = fieldIn(project, 'tracker', 'malformed');
|
|
400
|
+
if (projectTracker.kind === 'malformed') invalid = true;
|
|
401
|
+
if (projectTracker.kind === 'valid') {
|
|
402
|
+
const t = projectTracker.value;
|
|
403
|
+
if (t.provider.kind === 'valid') projectProvider = t.provider.value;
|
|
404
|
+
if (t.site.kind === 'valid') site = t.site.value;
|
|
405
|
+
if (t.key.kind === 'valid') key = t.key.value;
|
|
406
|
+
if (t.provider.kind === 'malformed' || t.site.kind === 'malformed' || t.key.kind === 'malformed') invalid = true;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** @type {TrackerProvider} */
|
|
410
|
+
let tracker;
|
|
411
|
+
/** @type {TrackerSource} */
|
|
412
|
+
let trackerSource;
|
|
413
|
+
if (projectProvider !== null) {
|
|
414
|
+
tracker = projectProvider;
|
|
415
|
+
trackerSource = 'project';
|
|
416
|
+
} else {
|
|
417
|
+
const machine = machineTracker(manifest);
|
|
418
|
+
tracker = machine.provider;
|
|
419
|
+
trackerSource = machine.recorded ? 'machine' : 'default';
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// An unreadable personal file is no override at all — readConfig's reading.
|
|
423
|
+
const override = fieldIn(personal, 'tracker', 'absent');
|
|
424
|
+
if (override.kind === 'malformed') invalid = true;
|
|
425
|
+
if (override.kind === 'valid') {
|
|
426
|
+
if (override.value === 'github' || override.value === tracker) {
|
|
427
|
+
tracker = override.value;
|
|
428
|
+
trackerSource = 'personal';
|
|
429
|
+
} else {
|
|
430
|
+
mismatch = true;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
tracker,
|
|
436
|
+
trackerSource,
|
|
437
|
+
trackerWarn: invalid ? 'invalid' : mismatch ? 'mismatch' : 'none',
|
|
438
|
+
site: tracker === 'github' ? null : site,
|
|
439
|
+
key: tracker === 'github' ? null : key,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* D-PUBLICATION-CEILING: review publication is
|
|
445
|
+
* min(team ?? full, personal ?? auto) over off < auto < full.
|
|
446
|
+
* The team value is a CEILING only — never a default. It can lower what the
|
|
447
|
+
* personal value asks for, but it cannot raise a run above `auto`: this script
|
|
448
|
+
* reads the worktree's project.json, so a contributor's branch committing
|
|
449
|
+
* `"reviewPublication":"full"` would otherwise make a maintainer's local review
|
|
450
|
+
* of that branch skip the visibility gate on a public repository. Only the
|
|
451
|
+
* uncommitted personal value asks for `full` — and a config.json git tracks is
|
|
452
|
+
* not personal, so it never reaches this fold (D-PERSONAL-UNTRACKED). A
|
|
453
|
+
* malformed team value (or an unreadable project.json) is `off` — a ceiling
|
|
454
|
+
* that cannot be read is the lowest one. A malformed personal value is
|
|
455
|
+
* ignored, as readConfig ignores it, and so resolves `auto`.
|
|
456
|
+
*
|
|
457
|
+
* @param {any} project
|
|
458
|
+
* @param {any} personal
|
|
459
|
+
* @returns {Publication}
|
|
460
|
+
*/
|
|
461
|
+
function foldPublication(project, personal) {
|
|
462
|
+
const teamField = fieldIn(project, 'reviewPublication', 'malformed');
|
|
463
|
+
/** @type {Publication | null} */
|
|
464
|
+
const team = teamField.kind === 'valid' ? teamField.value : teamField.kind === 'malformed' ? 'off' : null;
|
|
465
|
+
const personalField = fieldIn(personal, 'reviewPublication', 'absent');
|
|
466
|
+
/** @type {Publication | null} */
|
|
467
|
+
const own = personalField.kind === 'valid' ? personalField.value : null;
|
|
468
|
+
const ceiling = team === null ? 'full' : team;
|
|
469
|
+
const wanted = own !== null ? own : 'auto';
|
|
470
|
+
return PUBLICATIONS.indexOf(wanted) <= PUBLICATIONS.indexOf(ceiling) ? wanted : ceiling;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The ids a compliance field declares: its registry ids when valid, [] (generic)
|
|
475
|
+
* when malformed, null when absent.
|
|
476
|
+
*
|
|
477
|
+
* @param {ComplianceField} field
|
|
478
|
+
* @returns {readonly string[] | null}
|
|
479
|
+
*/
|
|
480
|
+
function declaredIds(field) {
|
|
481
|
+
if (field.kind === 'valid') return field.value;
|
|
482
|
+
return field.kind === 'malformed' ? Object.freeze([]) : null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* D-LENS-UNION: the compliance lens is
|
|
487
|
+
* machine ∪ default branch ∪ worktree
|
|
488
|
+
* — the machine's ids, the default branch's project.json ids (its local tracking
|
|
489
|
+
* copy, refs/remotes/origin/<D>), and the worktree project.json's. The lens only
|
|
490
|
+
* adds scrutiny, so no layer can take a framework away from another: a branch can
|
|
491
|
+
* ADD a framework in its own project.json, and can never REMOVE one the default
|
|
492
|
+
* branch declares — a PR that deletes `compliance:["hipaa"]` is still reviewed
|
|
493
|
+
* under hipaa. The default branch's copy is read locally, never over the network
|
|
494
|
+
* (D-SETTINGS-LOCAL-ONLY), so it is as fresh as the last fetch; with no tracking
|
|
495
|
+
* copy the lens is machine ∪ worktree. `off` only when no layer declares
|
|
496
|
+
* anything; a malformed declaration in either repository copy — an unreadable
|
|
497
|
+
* file, or a tracking copy git could not read — is `generic`: the lens runs with
|
|
498
|
+
* no framework reference, never silently not at all. The evidence floor is not
|
|
499
|
+
* this lens's to decide (resolve-evidence-policy.cjs).
|
|
500
|
+
*
|
|
501
|
+
* @param {any} project
|
|
502
|
+
* @param {unknown} manifest
|
|
503
|
+
* @param {ComplianceField} defaultBranch
|
|
504
|
+
* @returns {{ compliance: Settings['compliance'], repoCompliance: readonly string[] | null, defaultBranchCompliance: readonly string[] | null }}
|
|
505
|
+
*/
|
|
506
|
+
function foldCompliance(project, manifest, defaultBranch) {
|
|
507
|
+
const machine = machineCompliance(manifest);
|
|
508
|
+
const repo = declaredIds(fieldIn(project, 'compliance', 'malformed'));
|
|
509
|
+
const base = declaredIds(defaultBranch);
|
|
510
|
+
if (machine === null && repo === null && base === null) {
|
|
511
|
+
return {
|
|
512
|
+
compliance: Object.freeze({ enabled: false, frameworks: Object.freeze([]) }),
|
|
513
|
+
repoCompliance: null,
|
|
514
|
+
defaultBranchCompliance: null,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** @param {readonly string[] | null} ids @param {string} id */
|
|
518
|
+
const has = (ids, id) => ids !== null && ids.includes(id);
|
|
519
|
+
const union = COMPLIANCE_IDS.filter(id => has(machine, id) || has(base, id) || has(repo, id));
|
|
520
|
+
return {
|
|
521
|
+
compliance: Object.freeze({ enabled: true, frameworks: Object.freeze(union) }),
|
|
522
|
+
repoCompliance: repo,
|
|
523
|
+
defaultBranchCompliance: base,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* D-FEATURES-NARROW-ONLY (src/core/feature-switch.ts): a switch is on iff the
|
|
529
|
+
* machine switch is on AND project.json's `features.<name>` is not `false` AND
|
|
530
|
+
* config.json's `features.<name>` is not `false`. Only a literal `false`
|
|
531
|
+
* narrows: absent, malformed, duplicated and unreadable all leave the switch as
|
|
532
|
+
* the machine set it. No repo layer can turn on what the machine turned off.
|
|
533
|
+
*
|
|
534
|
+
* @param {any} project
|
|
535
|
+
* @param {any} personal
|
|
536
|
+
* @param {unknown} manifest
|
|
537
|
+
* @param {FeatureSwitch} feature
|
|
538
|
+
* @returns {SwitchState}
|
|
539
|
+
*/
|
|
540
|
+
function foldSwitch(project, personal, manifest, feature) {
|
|
541
|
+
/** @param {any} file */
|
|
542
|
+
const narrows = file => {
|
|
543
|
+
const features = fieldIn(file, 'features', 'absent');
|
|
544
|
+
return features.kind === 'valid' && features.value[feature].kind === 'valid' && features.value[feature].value === false;
|
|
545
|
+
};
|
|
546
|
+
if (!machineSwitchOn(manifest, feature)) return Object.freeze({ on: false, source: 'machine' });
|
|
547
|
+
if (narrows(project)) return Object.freeze({ on: false, source: 'project' });
|
|
548
|
+
if (narrows(personal)) return Object.freeze({ on: false, source: 'personal' });
|
|
549
|
+
return Object.freeze({ on: true, source: 'machine' });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The whole-file rule's resolution (D-SETTINGS-LINE): the fail-closed values,
|
|
554
|
+
* naming the layer whose file could not be read, except the compliance lens —
|
|
555
|
+
* a broken file affects only the keys it owns, so the lens stays the fold of
|
|
556
|
+
* every readable layer (D-LENS-UNION). An unreadable project.json declares
|
|
557
|
+
* nothing readable of its own (repoCompliance null) but reads as a malformed
|
|
558
|
+
* declaration, so its lens is at least `generic`; an unreadable config.json
|
|
559
|
+
* owns no compliance at all, so the worktree project.json's ids still count.
|
|
560
|
+
*
|
|
561
|
+
* @param {'project' | 'personal'} layer
|
|
562
|
+
* @param {SettingsInputs} inputs
|
|
563
|
+
* @returns {Settings}
|
|
564
|
+
*/
|
|
565
|
+
function unreadableSettings(layer, inputs) {
|
|
566
|
+
const defaultBranch = inputs.defaultBranch === undefined ? ABSENT_FIELD : inputs.defaultBranch;
|
|
567
|
+
const lens = foldCompliance(inputs.project, inputs.manifest, defaultBranch);
|
|
568
|
+
return Object.freeze({
|
|
569
|
+
...FAIL_CLOSED_SETTINGS,
|
|
570
|
+
compliance: lens.compliance,
|
|
571
|
+
repoCompliance: layer === 'project' ? null : lens.repoCompliance,
|
|
572
|
+
defaultBranchCompliance: lens.defaultBranchCompliance,
|
|
573
|
+
personalTracked: inputs.personalTracked === true,
|
|
574
|
+
unreadable: layer,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Fold the layers into Settings. Pure; never throws on any parser output.
|
|
580
|
+
*
|
|
581
|
+
* @param {SettingsInputs} inputs
|
|
582
|
+
* @returns {Settings}
|
|
583
|
+
*/
|
|
584
|
+
function foldSettings(inputs) {
|
|
585
|
+
const { project, personal, manifest } = inputs;
|
|
586
|
+
const tracker = foldTracker(project, personal, manifest);
|
|
587
|
+
const lens = foldCompliance(project, manifest, inputs.defaultBranch === undefined ? ABSENT_FIELD : inputs.defaultBranch);
|
|
588
|
+
return Object.freeze({
|
|
589
|
+
ok: true,
|
|
590
|
+
...tracker,
|
|
591
|
+
reviewPublication: foldPublication(project, personal),
|
|
592
|
+
compliance: lens.compliance,
|
|
593
|
+
switches: Object.freeze({
|
|
594
|
+
memory: foldSwitch(project, personal, manifest, 'memory'),
|
|
595
|
+
learning: foldSwitch(project, personal, manifest, 'learning'),
|
|
596
|
+
knowledge: foldSwitch(project, personal, manifest, 'knowledge'),
|
|
597
|
+
}),
|
|
598
|
+
repoCompliance: lens.repoCompliance,
|
|
599
|
+
defaultBranchCompliance: lens.defaultBranchCompliance,
|
|
600
|
+
personalTracked: inputs.personalTracked === true,
|
|
601
|
+
retiredPolicyFile: inputs.retiredPolicyFile === true,
|
|
602
|
+
unreadable: null,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// Gathering the layers (the imperative shell — D-SETTINGS-LOCAL-ONLY)
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The production exec: spawnSync, read off the module object at call time.
|
|
612
|
+
*
|
|
613
|
+
* @type {ExecFn}
|
|
614
|
+
*/
|
|
615
|
+
function defaultExec(file, args, opts) {
|
|
616
|
+
return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* One bounded local git call, normalized. The argv is a fresh copy, so an exec
|
|
621
|
+
* cannot mutate a caller's constant.
|
|
622
|
+
*
|
|
623
|
+
* @param {ExecFn} exec
|
|
624
|
+
* @param {string} cwd
|
|
625
|
+
* @param {readonly string[]} args
|
|
626
|
+
* @param {number} maxBuffer
|
|
627
|
+
* @returns {GitAnswer}
|
|
628
|
+
*/
|
|
629
|
+
function runGit(exec, cwd, args, maxBuffer) {
|
|
630
|
+
const res = exec('git', [...args], {
|
|
631
|
+
cwd,
|
|
632
|
+
env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }),
|
|
633
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
634
|
+
timeout: GIT_TIMEOUT_MS,
|
|
635
|
+
maxBuffer,
|
|
636
|
+
windowsHide: true,
|
|
637
|
+
shell: false,
|
|
638
|
+
});
|
|
639
|
+
const answered = Boolean(res) && !res.error && typeof res.status === 'number';
|
|
640
|
+
const raw = res ? res.stdout : undefined;
|
|
641
|
+
const stdout = Buffer.isBuffer(raw) ? raw : typeof raw === 'string' ? Buffer.from(raw, 'utf8') : Buffer.alloc(0);
|
|
642
|
+
return { answered, ok: answered && res.status === 0, stdout };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* The repository root of `dir`.
|
|
647
|
+
* root exit 0 with exactly one absolute path plus its newline
|
|
648
|
+
* none git ANSWERED non-zero — not a repository; only the machine layer applies
|
|
649
|
+
* unknown git did not answer, or answered 0 with an unusable path ⇒ fail closed
|
|
650
|
+
*
|
|
651
|
+
* @param {ExecFn} exec
|
|
652
|
+
* @param {string} dir
|
|
653
|
+
* @returns {{ kind: 'root', root: string } | { kind: 'none' } | { kind: 'unknown' }}
|
|
654
|
+
*/
|
|
655
|
+
function gitToplevel(exec, dir) {
|
|
656
|
+
const res = runGit(exec, dir, ['rev-parse', '--show-toplevel'], LINE_MAX_BUFFER);
|
|
657
|
+
if (!res.answered) return { kind: 'unknown' };
|
|
658
|
+
if (!res.ok) return { kind: 'none' };
|
|
659
|
+
const text = res.stdout.toString('utf8').replace(/\r?\n$/, '');
|
|
660
|
+
if (text === '' || /[\r\n\0]/.test(text) || !path.isAbsolute(text)) return { kind: 'unknown' };
|
|
661
|
+
return { kind: 'root', root: text };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* D-PERSONAL-UNTRACKED: `.devflow/config.json` holds PERSONAL settings — the one
|
|
666
|
+
* layer that may ask for `reviewPublication: "full"` — so a copy git TRACKS is
|
|
667
|
+
* not personal: a contributor's branch that commits one (`git add -f`) would
|
|
668
|
+
* otherwise lift a maintainer's local run of that branch past the publication
|
|
669
|
+
* ceiling. A tracked copy is ignored entirely, exactly as if absent, and main()
|
|
670
|
+
* tells the user to untrack it. The check is `git ls-files --error-unmatch`,
|
|
671
|
+
* which reads the index without refreshing or writing it. Reading the index
|
|
672
|
+
* runs a configured `core.fsmonitor` hook — an arbitrary command from the
|
|
673
|
+
* repository's config, and on macOS the builtin daemon's start-up — so the
|
|
674
|
+
* call turns it off for itself (`-c core.fsmonitor=false`, D-NO-FSMONITOR):
|
|
675
|
+
* this resolver runs from session hooks and must stay a pure read.
|
|
676
|
+
* tracked exit 0
|
|
677
|
+
* untracked any other answered exit (1: no such index entry; outside a
|
|
678
|
+
* repository git answers 128, and there is nothing to track)
|
|
679
|
+
* unknown git did not answer — whether the file is personal cannot be
|
|
680
|
+
* known, so it is unreadable and its keys fail closed
|
|
681
|
+
*
|
|
682
|
+
* @param {ExecFn} exec
|
|
683
|
+
* @param {string} root
|
|
684
|
+
* @returns {'tracked' | 'untracked' | 'unknown'}
|
|
685
|
+
*/
|
|
686
|
+
function personalTracking(exec, root) {
|
|
687
|
+
const res = runGit(exec, root, ['-c', 'core.fsmonitor=false', 'ls-files', '--error-unmatch', '--', PERSONAL_REL], LINE_MAX_BUFFER);
|
|
688
|
+
if (!res.answered) return 'unknown';
|
|
689
|
+
return res.ok ? 'tracked' : 'untracked';
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* One repository config file, classified. lstat-refused, never followed: a
|
|
694
|
+
* symlink, directory, FIFO or device — or a file over MAX_CONFIG_BYTES — is
|
|
695
|
+
* invalid unopened.
|
|
696
|
+
*
|
|
697
|
+
* @param {string} filePath
|
|
698
|
+
* @param {(buf: Buffer | null) => object} parse
|
|
699
|
+
* @returns {object}
|
|
700
|
+
*/
|
|
701
|
+
function readConfigFile(filePath, parse) {
|
|
702
|
+
const read = projectConfig.readBoundedRegularFile(filePath, MAX_CONFIG_BYTES, false);
|
|
703
|
+
if (read.kind === 'ok') return parse(read.bytes);
|
|
704
|
+
return read.kind === 'absent' ? parse(null) : Object.freeze({ kind: 'invalid' });
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Whether anything exists at `filePath` (never followed, never opened).
|
|
709
|
+
*
|
|
710
|
+
* @param {string} filePath
|
|
711
|
+
* @returns {boolean}
|
|
712
|
+
*/
|
|
713
|
+
function existsNoFollow(filePath) {
|
|
714
|
+
try {
|
|
715
|
+
fs.lstatSync(filePath);
|
|
716
|
+
return true;
|
|
717
|
+
} catch (_) {
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* The two repository layers under `<root>/.devflow`, each read and classified by
|
|
724
|
+
* readConfigFile. Exported for the hooks' one parser fork (queue_read_gates in
|
|
725
|
+
* scripts/hooks/queue-append, D-FEATURES-NARROW-ONLY): a file the shell fast path
|
|
726
|
+
* hands over is read by exactly the code resolveSettings reads it with, so the two
|
|
727
|
+
* cannot disagree about a symlink, a size, a BOM, a duplicated key — or a
|
|
728
|
+
* config.json git tracks (D-PERSONAL-UNTRACKED), which is absent to both. Its only
|
|
729
|
+
* subprocess is that tracking check, made only when config.json exists; never
|
|
730
|
+
* throws on a missing or unreadable file.
|
|
731
|
+
*
|
|
732
|
+
* @param {string} root
|
|
733
|
+
* @param {{ exec?: ExecFn }} [deps]
|
|
734
|
+
* @returns {RepoLayers}
|
|
735
|
+
*/
|
|
736
|
+
function readRepoLayers(root, deps) {
|
|
737
|
+
const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
|
|
738
|
+
const devflow = path.join(root, '.devflow');
|
|
739
|
+
const project = readConfigFile(path.join(devflow, 'project.json'), projectConfig.parseProjectBytes);
|
|
740
|
+
const personalPath = path.join(devflow, 'config.json');
|
|
741
|
+
const tracking = existsNoFollow(personalPath) ? personalTracking(exec, root) : 'untracked';
|
|
742
|
+
const personal = tracking === 'tracked' ? projectConfig.parsePersonalBytes(null)
|
|
743
|
+
: tracking === 'unknown' ? Object.freeze({ kind: 'invalid' })
|
|
744
|
+
: readConfigFile(personalPath, projectConfig.parsePersonalBytes);
|
|
745
|
+
return Object.freeze({ project, personal, personalTracked: tracking === 'tracked' });
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The default branch's `compliance` declaration, from its LOCAL tracking copy
|
|
750
|
+
* (D-LENS-UNION, D-SETTINGS-LOCAL-ONLY): origin/HEAD names the branch D, and
|
|
751
|
+
* `refs/remotes/origin/<D>:.devflow/project.json` is read through the same
|
|
752
|
+
* parser. The lens only adds, so every state that cannot be read is a malformed
|
|
753
|
+
* declaration (`generic`), never "declares nothing" (avoids PF-075):
|
|
754
|
+
* absent origin/HEAD is not recorded, or D holds no project.json
|
|
755
|
+
* malformed a git call did not answer, origin/HEAD names no safe branch, the
|
|
756
|
+
* blob overflows its bound, the file is unreadable, or its
|
|
757
|
+
* `compliance` value is malformed
|
|
758
|
+
* valid the registry ids it declares
|
|
759
|
+
*
|
|
760
|
+
* @param {ExecFn} exec
|
|
761
|
+
* @param {string} root
|
|
762
|
+
* @returns {ComplianceField}
|
|
763
|
+
*/
|
|
764
|
+
function defaultBranchCompliance(exec, root) {
|
|
765
|
+
const head = runGit(exec, root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], LINE_MAX_BUFFER);
|
|
766
|
+
if (!head.answered) return MALFORMED_FIELD;
|
|
767
|
+
if (!head.ok) return ABSENT_FIELD;
|
|
768
|
+
const branch = projectConfig.parseOriginHeadRef(head.stdout.toString('utf8'));
|
|
769
|
+
if (branch === null) return MALFORMED_FIELD;
|
|
770
|
+
const blob = runGit(exec, root,
|
|
771
|
+
['cat-file', 'blob', projectConfig.ORIGIN_TRACKING_PREFIX + branch + ':' + PROJECT_REL], BLOB_MAX_BUFFER);
|
|
772
|
+
if (!blob.answered) return MALFORMED_FIELD;
|
|
773
|
+
if (!blob.ok) return ABSENT_FIELD;
|
|
774
|
+
return fieldIn(projectConfig.parseProjectBytes(blob.stdout), 'compliance', 'malformed');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Resolve the settings for `opts.dir`. Never throws: a git that cannot say
|
|
779
|
+
* whether `opts.dir` is in a repository, or any internal failure, is the
|
|
780
|
+
* fail-closed resolution, which main() maps to exit 4. A repository file that
|
|
781
|
+
* exists but is unreadable fails every field closed but the compliance lens,
|
|
782
|
+
* naming its layer in `unreadable` (the whole-file rule, D-SETTINGS-LINE);
|
|
783
|
+
* main() prints that line with exit 0.
|
|
784
|
+
*
|
|
785
|
+
* @param {ResolveSettingsOptions} opts
|
|
786
|
+
* @param {{ exec?: ExecFn }} [deps]
|
|
787
|
+
* @returns {Settings}
|
|
788
|
+
*/
|
|
789
|
+
function resolveSettings(opts, deps) {
|
|
790
|
+
try {
|
|
791
|
+
const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
|
|
792
|
+
const manifest = opts.manifest !== undefined ? opts.manifest : projectConfig.readMachineManifest();
|
|
793
|
+
const toplevel = gitToplevel(exec, opts.dir);
|
|
794
|
+
if (toplevel.kind === 'unknown') return FAIL_CLOSED_SETTINGS;
|
|
795
|
+
if (toplevel.kind === 'none') {
|
|
796
|
+
const none = projectConfig.parseProjectBytes(null);
|
|
797
|
+
return foldSettings({ project: none, personal: none, manifest, retiredPolicyFile: false });
|
|
798
|
+
}
|
|
799
|
+
const layers = readRepoLayers(toplevel.root, { exec });
|
|
800
|
+
/** @type {SettingsInputs} */
|
|
801
|
+
const inputs = {
|
|
802
|
+
project: layers.project,
|
|
803
|
+
personal: layers.personal,
|
|
804
|
+
personalTracked: layers.personalTracked,
|
|
805
|
+
manifest,
|
|
806
|
+
defaultBranch: defaultBranchCompliance(exec, toplevel.root),
|
|
807
|
+
retiredPolicyFile: existsNoFollow(path.join(toplevel.root, '.devflow', 'policy.json')),
|
|
808
|
+
};
|
|
809
|
+
if (layers.project.kind === 'invalid') return unreadableSettings('project', inputs);
|
|
810
|
+
if (layers.personal.kind === 'invalid') return unreadableSettings('personal', inputs);
|
|
811
|
+
return foldSettings(inputs);
|
|
812
|
+
} catch (_) {
|
|
813
|
+
return FAIL_CLOSED_SETTINGS;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
// The line, and the gate on it
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The COMPLIANCE token for a lens.
|
|
823
|
+
*
|
|
824
|
+
* @param {Settings['compliance']} c
|
|
825
|
+
* @returns {string}
|
|
826
|
+
*/
|
|
827
|
+
function complianceToken(c) {
|
|
828
|
+
if (!c.enabled) return 'off';
|
|
829
|
+
return c.frameworks.length === 0 ? 'generic' : c.frameworks.join(',');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Compose the stdout line (D-SETTINGS-LINE).
|
|
834
|
+
*
|
|
835
|
+
* @param {Settings} s
|
|
836
|
+
* @returns {string}
|
|
837
|
+
*/
|
|
838
|
+
function formatSettingsLine(s) {
|
|
839
|
+
/** @param {SwitchState} sw */
|
|
840
|
+
const onOff = sw => (sw.on ? 'on' : 'off');
|
|
841
|
+
return 'TRACKER=' + s.tracker
|
|
842
|
+
+ ' TRACKER_SOURCE=' + s.trackerSource
|
|
843
|
+
+ ' TRACKER_WARN=' + s.trackerWarn
|
|
844
|
+
+ ' SITE=' + (s.site === null ? 'none' : s.site)
|
|
845
|
+
+ ' KEY=' + (s.key === null ? 'none' : s.key)
|
|
846
|
+
+ ' REVIEW_PUBLICATION=' + s.reviewPublication
|
|
847
|
+
+ ' COMPLIANCE=' + complianceToken(s.compliance)
|
|
848
|
+
+ ' MEMORY=' + onOff(s.switches.memory)
|
|
849
|
+
+ ' LEARNING=' + onOff(s.switches.learning)
|
|
850
|
+
+ ' KNOWLEDGE=' + onOff(s.switches.knowledge);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Whether a line is well-formed AND coherent: it matches SETTINGS_LINE_RE, its
|
|
855
|
+
* compliance ids are unique and in registry order, a github tracker carries no
|
|
856
|
+
* SITE or KEY, and a `default` tracker source is github.
|
|
857
|
+
*
|
|
858
|
+
* @param {unknown} line
|
|
859
|
+
* @returns {boolean}
|
|
860
|
+
*/
|
|
861
|
+
function isCoherentSettingsLine(line) {
|
|
862
|
+
if (typeof line !== 'string') return false;
|
|
863
|
+
const m = SETTINGS_LINE_RE.exec(line);
|
|
864
|
+
if (m === null || m.groups === undefined) return false;
|
|
865
|
+
const g = m.groups;
|
|
866
|
+
if (g.tracker === 'github' && (g.site !== 'none' || g.key !== 'none')) return false;
|
|
867
|
+
if (g.trackerSource === 'default' && g.tracker !== 'github') return false;
|
|
868
|
+
if (g.compliance !== 'off' && g.compliance !== 'generic') {
|
|
869
|
+
const order = g.compliance.split(',').map(id => COMPLIANCE_IDS.indexOf(id));
|
|
870
|
+
for (let i = 1; i < order.length; i++) {
|
|
871
|
+
if (order[i] <= order[i - 1]) return false;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Settle whatever main() returned into what the boundary may print.
|
|
879
|
+
*
|
|
880
|
+
* @param {unknown} outcome
|
|
881
|
+
* @returns {{ code: number, line: string }}
|
|
882
|
+
*/
|
|
883
|
+
function settleOutcome(outcome) {
|
|
884
|
+
const o = /** @type {any} */ (outcome);
|
|
885
|
+
if (o === null || typeof o !== 'object' || typeof o.line !== 'string') {
|
|
886
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
887
|
+
}
|
|
888
|
+
if (o.code === EXIT_CODES.USAGE) return { code: EXIT_CODES.USAGE, line: '' };
|
|
889
|
+
if (o.code === EXIT_CODES.RESOLVED) {
|
|
890
|
+
return isCoherentSettingsLine(o.line)
|
|
891
|
+
? { code: o.code, line: o.line }
|
|
892
|
+
: { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
893
|
+
}
|
|
894
|
+
if (o.code === EXIT_CODES.INPUT_UNUSABLE || o.code === EXIT_CODES.OUTPUT_GATE_REFUSED) {
|
|
895
|
+
return { code: o.code, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
896
|
+
}
|
|
897
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ---------------------------------------------------------------------------
|
|
901
|
+
// The suggestion the CLI prints (never writes — ADR-024)
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The canonical `.devflow/project.json` bytes the CLI suggests a team commit:
|
|
906
|
+
* `{"version":1[,"evidence":…][,"compliance":[…]]}\n`, in that key order.
|
|
907
|
+
* `evidence` must be a policy value; `compliance` a list of strings, normalized
|
|
908
|
+
* to registry ids as the parser would read them. Null for anything else, and
|
|
909
|
+
* null unless the bytes read back through parseProjectBytes as exactly what was
|
|
910
|
+
* asked for — nothing half-formed is ever printed.
|
|
911
|
+
*
|
|
912
|
+
* @param {unknown} input `{ evidence?, compliance? }`
|
|
913
|
+
* @returns {string | null}
|
|
914
|
+
*/
|
|
915
|
+
function serializeProjectSuggestion(input) {
|
|
916
|
+
if (!isPlainObject(input)) return null;
|
|
917
|
+
/** @type {Record<string, unknown>} */
|
|
918
|
+
const out = { version: 1 };
|
|
919
|
+
if (Object.prototype.hasOwnProperty.call(input, 'evidence')) {
|
|
920
|
+
if (!projectConfig.POLICIES.includes(input.evidence)) return null;
|
|
921
|
+
out.evidence = input.evidence;
|
|
922
|
+
}
|
|
923
|
+
if (Object.prototype.hasOwnProperty.call(input, 'compliance')) {
|
|
924
|
+
const list = input.compliance;
|
|
925
|
+
if (!Array.isArray(list) || !list.every(v => typeof v === 'string')) return null;
|
|
926
|
+
out.compliance = projectConfig.normalizeComplianceIds(list);
|
|
927
|
+
}
|
|
928
|
+
const text = JSON.stringify(out) + '\n';
|
|
929
|
+
const back = /** @type {any} */ (projectConfig.parseProjectBytes(Buffer.from(text, 'utf8')));
|
|
930
|
+
if (back.kind !== 'parsed' || back.version.kind !== 'valid') return null;
|
|
931
|
+
if (out.evidence !== undefined && (back.evidence.kind !== 'valid' || back.evidence.value !== out.evidence)) return null;
|
|
932
|
+
if (out.compliance !== undefined && back.compliance.kind !== 'valid') return null;
|
|
933
|
+
return text;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// ---------------------------------------------------------------------------
|
|
937
|
+
// main — returns {code, line}; never calls process.exit
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* @param {readonly string[]} argv process.argv
|
|
942
|
+
* @returns {{ kind: 'resolve', dir: string } | { kind: 'usage', usage: string }}
|
|
943
|
+
*/
|
|
944
|
+
function parseArgs(argv) {
|
|
945
|
+
const USAGE = 'Usage: node resolve-settings.cjs [<dir>]';
|
|
946
|
+
const rest = Array.isArray(argv) ? argv.slice(2) : [];
|
|
947
|
+
/** @type {string[]} */
|
|
948
|
+
const positionals = [];
|
|
949
|
+
for (const arg of rest) {
|
|
950
|
+
if (typeof arg !== 'string' || arg.startsWith('-')) {
|
|
951
|
+
return {
|
|
952
|
+
kind: 'usage',
|
|
953
|
+
usage: 'resolve-settings: unrecognised argument ' + JSON.stringify(String(arg)).slice(0, 80) + '\n' + USAGE,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
positionals.push(arg);
|
|
957
|
+
}
|
|
958
|
+
if (positionals.length > 1) return { kind: 'usage', usage: USAGE };
|
|
959
|
+
return { kind: 'resolve', dir: positionals.length === 1 ? positionals[0] : process.cwd() };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* @param {string} dir
|
|
964
|
+
* @returns {boolean}
|
|
965
|
+
*/
|
|
966
|
+
function isUsableDirectory(dir) {
|
|
967
|
+
try {
|
|
968
|
+
return fs.statSync(dir).isDirectory();
|
|
969
|
+
} catch (_) {
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* @param {readonly string[]} argv process.argv
|
|
976
|
+
* @param {{ exec?: ExecFn, formatLine?: (s: Settings) => unknown }} [deps] tests only
|
|
977
|
+
* @returns {{ code: number, line: string }}
|
|
978
|
+
*/
|
|
979
|
+
function main(argv, deps) {
|
|
980
|
+
const d = deps || {};
|
|
981
|
+
const args = parseArgs(argv);
|
|
982
|
+
if (args.kind === 'usage') {
|
|
983
|
+
process.stderr.write(args.usage + '\n');
|
|
984
|
+
return { code: EXIT_CODES.USAGE, line: '' };
|
|
985
|
+
}
|
|
986
|
+
if (!isUsableDirectory(args.dir)) {
|
|
987
|
+
process.stderr.write('resolve-settings: not a usable directory: '
|
|
988
|
+
+ JSON.stringify(args.dir).slice(0, 300) + ' — failing closed\n');
|
|
989
|
+
return { code: EXIT_CODES.INPUT_UNUSABLE, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
990
|
+
}
|
|
991
|
+
const settings = resolveSettings({ dir: args.dir }, { exec: d.exec });
|
|
992
|
+
if (settings.personalTracked) {
|
|
993
|
+
process.stderr.write('resolve-settings: warning: ' + PERSONAL_REL + ' is tracked by git, so it is ignored'
|
|
994
|
+
+ ' — it holds personal settings; untrack it with: git rm --cached ' + PERSONAL_REL + '\n');
|
|
995
|
+
}
|
|
996
|
+
if (settings.unreadable !== null) {
|
|
997
|
+
process.stderr.write('resolve-settings: ' + (settings.unreadable === 'project' ? PROJECT_REL : PERSONAL_REL)
|
|
998
|
+
+ ' exists but could not be read (not a JSON object, or git could not say whether it is tracked)'
|
|
999
|
+
+ ' — failing its fields closed, keeping every readable compliance lens\n');
|
|
1000
|
+
} else if (!settings.ok) {
|
|
1001
|
+
process.stderr.write('resolve-settings: could not resolve (git did not answer, or an internal error) — failing closed\n');
|
|
1002
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
1003
|
+
}
|
|
1004
|
+
const format = typeof d.formatLine === 'function' ? d.formatLine : formatSettingsLine;
|
|
1005
|
+
let line;
|
|
1006
|
+
try {
|
|
1007
|
+
line = format(settings);
|
|
1008
|
+
} catch (_) {
|
|
1009
|
+
process.stderr.write('resolve-settings: internal error composing the line — failing closed\n');
|
|
1010
|
+
return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
1011
|
+
}
|
|
1012
|
+
if (!isCoherentSettingsLine(line)) {
|
|
1013
|
+
process.stderr.write('resolve-settings: output gate refused the composed line — failing closed\n');
|
|
1014
|
+
return { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
1015
|
+
}
|
|
1016
|
+
return { code: EXIT_CODES.RESOLVED, line: /** @type {string} */ (line) };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
// Top-level boundary — the ONLY stdout write and the ONLY exitCode assignment
|
|
1021
|
+
// ---------------------------------------------------------------------------
|
|
1022
|
+
|
|
1023
|
+
if (require.main === module) {
|
|
1024
|
+
let outcome;
|
|
1025
|
+
try {
|
|
1026
|
+
outcome = main(process.argv);
|
|
1027
|
+
} catch (_) {
|
|
1028
|
+
process.stderr.write('resolve-settings: internal error — failing closed\n');
|
|
1029
|
+
outcome = { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
|
|
1030
|
+
}
|
|
1031
|
+
const settled = settleOutcome(outcome);
|
|
1032
|
+
if (settled.line !== '') process.stdout.write(settled.line + '\n');
|
|
1033
|
+
process.exitCode = settled.code;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
// Exports — the CLI seam (src/core/evidence-policy.ts) and the unit tests
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
|
|
1040
|
+
module.exports = Object.freeze({
|
|
1041
|
+
TRACKER_SOURCES,
|
|
1042
|
+
TRACKER_WARNS,
|
|
1043
|
+
EXIT_CODES,
|
|
1044
|
+
SETTINGS_LINE_RE,
|
|
1045
|
+
SETTINGS_FAIL_CLOSED_LINE,
|
|
1046
|
+
parseArgs,
|
|
1047
|
+
foldSettings,
|
|
1048
|
+
readRepoLayers,
|
|
1049
|
+
resolveSettings,
|
|
1050
|
+
formatSettingsLine,
|
|
1051
|
+
isCoherentSettingsLine,
|
|
1052
|
+
serializeProjectSuggestion,
|
|
1053
|
+
main,
|
|
1054
|
+
});
|