etymd 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -16
- package/README.md +121 -47
- package/dist/{approve-MA4Z3TBT.js → approve-VN4EDBR5.js} +2 -2
- package/dist/{audit-YSALDC2L.js → audit-PHAK5HEW.js} +5 -5
- package/dist/{brief-DPZYSAMC.js → brief-7MWP35UI.js} +2 -2
- package/dist/{chunk-Y6RZRED3.js → chunk-446EZNAQ.js} +1 -1
- package/dist/{chunk-CEO3BXQB.js → chunk-ANFNXFRI.js} +2 -2
- package/dist/{chunk-DBWDMIYO.js → chunk-BUMOGHQA.js} +1 -1
- package/dist/{chunk-IWG77WV3.js → chunk-FNA4R5KT.js} +31 -6
- package/dist/{fleet-4FYZ3GBK.js → chunk-LVR2DPR7.js} +438 -469
- package/dist/{chunk-UFNETE6P.js → chunk-PIJZUDDQ.js} +6 -2
- package/dist/{chunk-2RNQ6OLV.js → chunk-WKP7M2B3.js} +2 -2
- package/dist/cli.js +58 -23
- package/dist/{doctor-Y3DWDEBT.js → doctor-R4QDCAN5.js} +5 -5
- package/dist/fleet-WPYEVHVL.js +489 -0
- package/dist/{gates-IBQ7HCAN.js → gates-3HRGYCYY.js} +7 -4
- package/dist/{generate-KEX75XNG.js → generate-FSJQFQPV.js} +1 -1
- package/dist/index.d.ts +146 -20
- package/dist/index.js +497 -95
- package/dist/{init-H57H2JBE.js → init-6RULX5JZ.js} +3 -3
- package/dist/{premise-VMC3UUIB.js → premise-GFWHSR3K.js} +59 -24
- package/dist/propose-IYNOIWAO.js +252 -0
- package/dist/scan-5SC47FSM.js +5 -0
- package/dist/{scan-XBOHDXAH.js → scan-LLWW4YOU.js} +2 -2
- package/dist/{screen-E4FC7W5N.js → screen-S736DXQ5.js} +56 -24
- package/package.json +4 -4
- package/dist/scan-W4US23MU.js +0 -5
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { applyFiles } from './chunk-GWGKEPRX.js';
|
|
3
|
-
import { planWorkflow } from './chunk-
|
|
3
|
+
import { planWorkflow } from './chunk-PIJZUDDQ.js';
|
|
4
4
|
import { theme, renderFacts, print, renderPlan, section, glyph } from './chunk-HI7NWPRA.js';
|
|
5
|
-
import { scanProject } from './chunk-
|
|
6
|
-
import { VERSION } from './chunk-
|
|
5
|
+
import { scanProject } from './chunk-446EZNAQ.js';
|
|
6
|
+
import { VERSION } from './chunk-BUMOGHQA.js';
|
|
7
7
|
import { writeCachedFacts, deriveProfile, writeBaseline, CACHE_DIR } from './chunk-P6ATKV2R.js';
|
|
8
8
|
import { PACK_VERSION } from './chunk-YQZDYDAK.js';
|
|
9
9
|
import { git, readText } from './chunk-4VPBP6K6.js';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { parseFailOnTier, meetsFailOn, buildTruthEnv, emptyCounters, checkTextClaims, checkDocRefs, loadDecisionLedger, checkDecisionRefs, rankFindings, PATH_TOKEN_RE, KNOWN_EXTENSIONS } from './chunk-
|
|
2
|
+
import { parseFailOnTier, meetsFailOn, buildTruthEnv, emptyCounters, checkTextClaims, checkDocRefs, loadDecisionLedger, checkDecisionRefs, rankFindings, PATH_TOKEN_RE, KNOWN_EXTENSIONS, NAMESPACE_IDENT, NAMESPACE_STOP } from './chunk-FNA4R5KT.js';
|
|
3
3
|
import { print, section, theme, renderFindings } from './chunk-HI7NWPRA.js';
|
|
4
|
-
import { scanProject } from './chunk-
|
|
5
|
-
import './chunk-
|
|
4
|
+
import { scanProject } from './chunk-446EZNAQ.js';
|
|
5
|
+
import './chunk-BUMOGHQA.js';
|
|
6
6
|
import { ETYMD_DIR } from './chunk-P6ATKV2R.js';
|
|
7
7
|
import './chunk-YQZDYDAK.js';
|
|
8
8
|
import { readText, pathExists } from './chunk-4VPBP6K6.js';
|
|
@@ -27,7 +27,8 @@ function promoteBareTokens(text, ctx) {
|
|
|
27
27
|
bareInvocations: 0,
|
|
28
28
|
proseScripts: 0,
|
|
29
29
|
hostnameLike: 0,
|
|
30
|
-
unrootedDirs: 0
|
|
30
|
+
unrootedDirs: 0,
|
|
31
|
+
namespaced: 0
|
|
31
32
|
};
|
|
32
33
|
const promoted = text.split(CODE_SPAN_RE).map((segment, i) => i % 2 === 1 ? segment : promoteProse(segment, ctx, skips)).join("");
|
|
33
34
|
return { text: promoted, skips };
|
|
@@ -46,28 +47,44 @@ function promoteProse(segment, ctx, skips) {
|
|
|
46
47
|
}
|
|
47
48
|
return `\`${core}\`${trail}`;
|
|
48
49
|
});
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
const parts = withCommands.split(/(\s+)/);
|
|
51
|
+
for (let i = 0; i < parts.length; i += 2) {
|
|
52
|
+
parts[i] = promoteToken(parts[i], i >= 2 ? parts[i - 2] : "", ctx, skips);
|
|
53
|
+
}
|
|
54
|
+
return parts.join("");
|
|
55
|
+
}
|
|
56
|
+
function namespaceOf(core, prev) {
|
|
57
|
+
const ns = new RegExp(`^(${NAMESPACE_IDENT}):`).exec(core)?.[1] ?? new RegExp(`^(${NAMESPACE_IDENT}):$`).exec(prev)?.[1];
|
|
58
|
+
return ns && !NAMESPACE_STOP.has(ns.toLowerCase()) ? ns : null;
|
|
59
|
+
}
|
|
60
|
+
function promoteToken(token, prev, ctx, skips) {
|
|
61
|
+
const m = WRAP_RE.exec(token);
|
|
62
|
+
if (!m) return token;
|
|
63
|
+
const [, lead = "", core = "", trail = ""] = m;
|
|
64
|
+
if (!core || core.includes("://")) return token;
|
|
65
|
+
const namespace = namespaceOf(core, prev);
|
|
66
|
+
const body = namespace && core.startsWith(`${namespace}:`) ? core.slice(namespace.length + 1) : core;
|
|
67
|
+
if (!PATH_TOKEN_RE.test(body)) return token;
|
|
68
|
+
const first = body.slice(0, body.indexOf("/"));
|
|
69
|
+
const hostSuffix = HOSTNAME_RE.exec(first)?.[1]?.toLowerCase();
|
|
70
|
+
if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) {
|
|
71
|
+
skips.hostnameLike += 1;
|
|
72
|
+
return token;
|
|
73
|
+
}
|
|
74
|
+
if (body.endsWith("/")) {
|
|
75
|
+
if (!ctx.rootedDirs.has(first)) {
|
|
76
|
+
skips.unrootedDirs += 1;
|
|
58
77
|
return token;
|
|
59
78
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
skips.unrootedDirs += 1;
|
|
63
|
-
return token;
|
|
64
|
-
}
|
|
65
|
-
return `${lead}\`${core}\`${trail}`;
|
|
66
|
-
}
|
|
67
|
-
const ext = core.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
79
|
+
} else {
|
|
80
|
+
const ext = body.toLowerCase().match(/\.([a-z0-9]{1,8})$/)?.[1];
|
|
68
81
|
if (!ext || !KNOWN_EXTENSIONS.has(ext)) return token;
|
|
69
|
-
|
|
70
|
-
|
|
82
|
+
}
|
|
83
|
+
if (namespace) {
|
|
84
|
+
skips.namespaced += 1;
|
|
85
|
+
return token;
|
|
86
|
+
}
|
|
87
|
+
return `${lead}\`${body}\`${trail}`;
|
|
71
88
|
}
|
|
72
89
|
async function listRootedDirs(env) {
|
|
73
90
|
const dirs = /* @__PURE__ */ new Set();
|
|
@@ -85,7 +102,8 @@ async function runPremise(opts) {
|
|
|
85
102
|
const facts = await scanProject(root);
|
|
86
103
|
const env = await buildTruthEnv(root, facts);
|
|
87
104
|
const counters = emptyCounters();
|
|
88
|
-
const
|
|
105
|
+
const rootedDirs = await listRootedDirs(env);
|
|
106
|
+
const promoted = promoteBareTokens(task, { rootedDirs });
|
|
89
107
|
const text = { path: TASK_LABEL, text: promoted.text };
|
|
90
108
|
const findings = [];
|
|
91
109
|
const disclosures = [];
|
|
@@ -98,6 +116,8 @@ async function runPremise(opts) {
|
|
|
98
116
|
subject: "The task",
|
|
99
117
|
missingPathTier: "risk",
|
|
100
118
|
maxPathFindings: MAX_PATH_FINDINGS,
|
|
119
|
+
rootedFirstSegments: rootedDirs,
|
|
120
|
+
treatNamespacedPrefixes: true,
|
|
101
121
|
whyCommand: "The task is built on a command that does not exist \u2014 an agent will run it and fail, or quietly substitute something else and report success.",
|
|
102
122
|
actionCommand: "Fix the task before handing it over: name the real script, or restore it.",
|
|
103
123
|
whyPath: "The task is ABOUT this path. Acting on it means editing, testing, or reasoning about something that is not there \u2014 the wrong problem, solved precisely.",
|
|
@@ -154,6 +174,21 @@ async function runPremise(opts) {
|
|
|
154
174
|
`${skips.unrootedDirs} slash-joined phrase(s) ending in \`/\` start with no directory that exists here (e.g. \`input/output/\`) \u2014 read as prose; skipped, not flagged. Backtick one to have it checked.`
|
|
155
175
|
);
|
|
156
176
|
}
|
|
177
|
+
if (skips.namespaced) {
|
|
178
|
+
disclosures.push(
|
|
179
|
+
`${skips.namespaced} path mention(s) in prose carry a namespace prefix (\`pc:src/x.ts\`, \`lk: src/x.ts\`) \u2014 another repo's tree, never this one; skipped, not flagged.`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (counters.namespacedSkipped) {
|
|
183
|
+
disclosures.push(
|
|
184
|
+
`${counters.namespacedSkipped} backticked path mention(s) sit directly after a namespace prefix (\`pc: \`src/x.ts\`\`) \u2014 another repo's tree, not this one; skipped, not flagged.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (counters.outsideRepoSkipped) {
|
|
188
|
+
disclosures.push(
|
|
189
|
+
`${counters.outsideRepoSkipped} path(s) the task names start at no directory of this repo \u2014 typical of a path quoted from another repository or a scratchpad clone; outside this repo, not missing here. Skipped, not flagged.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
157
192
|
if (counters.unverifiableCommands) {
|
|
158
193
|
disclosures.push(
|
|
159
194
|
`node_modules is not installed \u2014 ${counters.unverifiableCommands} command(s) matching no package script could not be checked against installed binaries; skipped, not flagged.`
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { sweepFleet, loadFleetManifest, FLEET_JSON_SCHEMA } from './chunk-LVR2DPR7.js';
|
|
3
|
+
import './chunk-WKP7M2B3.js';
|
|
4
|
+
import './chunk-FNA4R5KT.js';
|
|
5
|
+
import './chunk-3E2IPCRY.js';
|
|
6
|
+
import { print, section, theme, renderFleetNotes } from './chunk-HI7NWPRA.js';
|
|
7
|
+
import './chunk-446EZNAQ.js';
|
|
8
|
+
import './chunk-BUMOGHQA.js';
|
|
9
|
+
import './chunk-P6ATKV2R.js';
|
|
10
|
+
import './chunk-YQZDYDAK.js';
|
|
11
|
+
import { readText } from './chunk-4VPBP6K6.js';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
|
|
14
|
+
// src/engine/propose.ts
|
|
15
|
+
var PROPOSAL_SCHEMA = "proposal/1";
|
|
16
|
+
var RUBRIC_CRITERIA = ["severity", "economy", "confidence", "breadth"];
|
|
17
|
+
function parseRubric(text, file) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
const seen = /* @__PURE__ */ new Map();
|
|
20
|
+
const rows = text.split(/\r?\n/);
|
|
21
|
+
for (const [i, raw] of rows.entries()) {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (!line || line.startsWith("#")) continue;
|
|
24
|
+
const at = i + 1;
|
|
25
|
+
const refuse = (why) => new Error(
|
|
26
|
+
`${file}:${at} \`${raw.trim()}\` \u2014 ${why} (criteria: ${RUBRIC_CRITERIA.join(", ")})`
|
|
27
|
+
);
|
|
28
|
+
const sep = line.indexOf(":");
|
|
29
|
+
if (sep < 0) throw refuse("expected `criterion: <weight>`");
|
|
30
|
+
const criterion = line.slice(0, sep).trim();
|
|
31
|
+
const weightRaw = line.slice(sep + 1).trim();
|
|
32
|
+
if (!RUBRIC_CRITERIA.includes(criterion)) {
|
|
33
|
+
throw refuse(`unknown criterion \`${criterion}\``);
|
|
34
|
+
}
|
|
35
|
+
if (!/^\d+$/.test(weightRaw) || Number(weightRaw) < 1) {
|
|
36
|
+
throw refuse(`weight \`${weightRaw}\` is not a positive integer`);
|
|
37
|
+
}
|
|
38
|
+
const known = criterion;
|
|
39
|
+
if (seen.has(known)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${file}:${at} \`${raw.trim()}\` \u2014 criterion \`${criterion}\` is already weighted on line ${seen.get(known)}; one line per criterion`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
seen.set(known, at);
|
|
45
|
+
lines.push({ criterion: known, weight: Number(weightRaw) });
|
|
46
|
+
}
|
|
47
|
+
if (!lines.length) {
|
|
48
|
+
throw new Error(`${file} carries no criterion lines \u2014 a rubric with no criteria scores nothing`);
|
|
49
|
+
}
|
|
50
|
+
return { lines };
|
|
51
|
+
}
|
|
52
|
+
var TIER_VALUE = { risk: 3, gap: 2, polish: 1 };
|
|
53
|
+
var EFFORT_VALUE = { S: 3, M: 2, L: 1 };
|
|
54
|
+
var CONFIDENCE_VALUE = { high: 3, medium: 2, low: 1 };
|
|
55
|
+
function findingSubject(project, f) {
|
|
56
|
+
return {
|
|
57
|
+
id: `${project.name}:${f.id}`,
|
|
58
|
+
kind: "finding",
|
|
59
|
+
classId: f.id.split(":")[0] ?? f.id,
|
|
60
|
+
tier: f.tier,
|
|
61
|
+
effort: f.effort,
|
|
62
|
+
confidence: f.confidence,
|
|
63
|
+
projects: [project.name],
|
|
64
|
+
action: f.action ?? "",
|
|
65
|
+
evidence: f.evidence
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function classSubject(classId, members) {
|
|
69
|
+
const pick = (order, at, dir) => {
|
|
70
|
+
const values = members.map((m) => order[at(m.finding)]);
|
|
71
|
+
const value = dir === 1 ? Math.max(...values) : Math.min(...values);
|
|
72
|
+
return Object.entries(order).find(([, v]) => v === value)[0];
|
|
73
|
+
};
|
|
74
|
+
const actions = [...new Set(members.map((m) => m.finding.action).filter(Boolean))].sort();
|
|
75
|
+
return {
|
|
76
|
+
id: `class:${classId}`,
|
|
77
|
+
kind: "class",
|
|
78
|
+
classId,
|
|
79
|
+
tier: pick(TIER_VALUE, (f) => f.tier, 1),
|
|
80
|
+
effort: pick(EFFORT_VALUE, (f) => f.effort, -1),
|
|
81
|
+
confidence: pick(CONFIDENCE_VALUE, (f) => f.confidence, -1),
|
|
82
|
+
projects: [...new Set(members.map((m) => m.project.name))].sort(),
|
|
83
|
+
action: actions.join("; "),
|
|
84
|
+
evidence: members.flatMap((m) => m.finding.evidence)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
var CRITERION_VALUE = {
|
|
88
|
+
severity: (s) => TIER_VALUE[s.tier],
|
|
89
|
+
economy: (s) => EFFORT_VALUE[s.effort],
|
|
90
|
+
confidence: (s) => CONFIDENCE_VALUE[s.confidence],
|
|
91
|
+
breadth: (s) => Math.min(3, s.projects.length)
|
|
92
|
+
};
|
|
93
|
+
var FILENAME_RE = /^[^\s:]+\.[A-Za-z0-9]+$/;
|
|
94
|
+
function extractFiles(evidence) {
|
|
95
|
+
const files = /* @__PURE__ */ new Set();
|
|
96
|
+
for (const line of evidence) {
|
|
97
|
+
const token = line.split(/[\s:]/)[0];
|
|
98
|
+
if (token && (token.includes("/") || FILENAME_RE.test(token))) files.add(token);
|
|
99
|
+
}
|
|
100
|
+
return [...files].sort();
|
|
101
|
+
}
|
|
102
|
+
function extractGates(files) {
|
|
103
|
+
return files.filter((f) => f.startsWith(".githooks/") || f.startsWith(".github/workflows/"));
|
|
104
|
+
}
|
|
105
|
+
function reversibility(files) {
|
|
106
|
+
if (!files.length) return "undetermined";
|
|
107
|
+
const generated = (f) => f.startsWith(".etymd/") || f.startsWith(".githooks/");
|
|
108
|
+
return files.every(generated) ? "regenerable" : "git-reversible";
|
|
109
|
+
}
|
|
110
|
+
function toProposal(s, rubric) {
|
|
111
|
+
const matched = [];
|
|
112
|
+
let score = 0;
|
|
113
|
+
for (const line of rubric.lines) {
|
|
114
|
+
const value = CRITERION_VALUE[line.criterion](s);
|
|
115
|
+
score += line.weight * value;
|
|
116
|
+
if (value >= 2) matched.push({ criterion: line.criterion, weight: line.weight, value });
|
|
117
|
+
}
|
|
118
|
+
const files = extractFiles(s.evidence);
|
|
119
|
+
return {
|
|
120
|
+
id: s.id,
|
|
121
|
+
schema: PROPOSAL_SCHEMA,
|
|
122
|
+
class: s.classId,
|
|
123
|
+
kind: s.kind,
|
|
124
|
+
projects: s.projects,
|
|
125
|
+
action: s.action,
|
|
126
|
+
effort: s.effort,
|
|
127
|
+
confidence: s.confidence,
|
|
128
|
+
score,
|
|
129
|
+
matched,
|
|
130
|
+
implications: {
|
|
131
|
+
projects: s.projects,
|
|
132
|
+
files,
|
|
133
|
+
gates: extractGates(files),
|
|
134
|
+
reversibility: reversibility(files)
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function buildProposals(sweep, rubricName, rubric) {
|
|
139
|
+
const disclosures = [];
|
|
140
|
+
const personal = sweep.projects.filter((p) => p.profile === "personal");
|
|
141
|
+
const guarded = sweep.projects.filter((p) => p.profile !== "personal");
|
|
142
|
+
if (guarded.length) {
|
|
143
|
+
disclosures.push(
|
|
144
|
+
`guarded entries excluded from proposals: ${guarded.map((p) => p.name).sort().join(", ")}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const notAudited = personal.filter((p) => !p.resolvedRoot || p.unresolved);
|
|
148
|
+
if (notAudited.length) {
|
|
149
|
+
disclosures.push(
|
|
150
|
+
`not audited, so not proposed: ${notAudited.map((p) => p.name).sort().join(", ")}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const subjects = [];
|
|
154
|
+
for (const project of personal) {
|
|
155
|
+
for (const f of project.findings) {
|
|
156
|
+
if (f.kind === "improvement") subjects.push(findingSubject(project, f));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const byClass = /* @__PURE__ */ new Map();
|
|
160
|
+
for (const project of personal) {
|
|
161
|
+
for (const f of project.findings) {
|
|
162
|
+
const classId = f.id.split(":")[0] ?? f.id;
|
|
163
|
+
const entry = byClass.get(classId) ?? [];
|
|
164
|
+
entry.push({ project, finding: f });
|
|
165
|
+
byClass.set(classId, entry);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const [classId, members] of byClass) {
|
|
169
|
+
if (new Set(members.map((m) => m.project.name)).size >= 2) {
|
|
170
|
+
subjects.push(classSubject(classId, members));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const proposals = subjects.map((s) => toProposal(s, rubric)).sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
174
|
+
return {
|
|
175
|
+
schema: PROPOSAL_SCHEMA,
|
|
176
|
+
manifest: sweep.manifest,
|
|
177
|
+
rubric: rubricName,
|
|
178
|
+
criteria: rubric.lines,
|
|
179
|
+
proposals,
|
|
180
|
+
disclosures: [
|
|
181
|
+
...disclosures,
|
|
182
|
+
"implications.files are path-shaped tokens extracted from finding evidence \u2014 evidence lines carrying no path are not represented there"
|
|
183
|
+
]
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/commands/propose.ts
|
|
188
|
+
async function readStoredSweep(abs) {
|
|
189
|
+
const raw = await readText(abs);
|
|
190
|
+
if (raw === null) throw new Error(`cannot read the stored sweep: ${abs}`);
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`${abs} is not valid JSON (${err instanceof Error ? err.message : String(err)})`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const rec = parsed;
|
|
200
|
+
if (typeof rec !== "object" || rec === null || rec.schema !== FLEET_JSON_SCHEMA || !Array.isArray(rec.projects)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`${abs} is not a stored fleet sweep (schema ${FLEET_JSON_SCHEMA}) \u2014 write one with \`etymd fleet --json\``
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return { manifest: rec.manifest ?? abs, projects: rec.projects };
|
|
206
|
+
}
|
|
207
|
+
async function run(opts) {
|
|
208
|
+
if (!opts.manifest && !opts.from) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"propose needs a sweep: pass --manifest <registry.json> (runs one now, read-only) or --from <fleet.json> (reads a stored `etymd fleet --json` output)"
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (opts.manifest && opts.from) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"--manifest and --from are alternatives \u2014 a fresh sweep or a stored one, not both"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const rubricPath = path.resolve(opts.cwd, opts.rubric);
|
|
219
|
+
const rubricText = await readText(rubricPath);
|
|
220
|
+
if (rubricText === null) throw new Error(`cannot read the rubric: ${rubricPath}`);
|
|
221
|
+
const rubric = parseRubric(rubricText, opts.rubric);
|
|
222
|
+
const swept = opts.from ? await readStoredSweep(path.resolve(opts.cwd, opts.from)) : await sweepFleet(await loadFleetManifest(path.resolve(opts.cwd, opts.manifest)));
|
|
223
|
+
const result = buildProposals(swept, opts.rubric, rubric);
|
|
224
|
+
if (opts.json) {
|
|
225
|
+
print(JSON.stringify(result, null, 2));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
render(result);
|
|
229
|
+
}
|
|
230
|
+
function render(result) {
|
|
231
|
+
section(
|
|
232
|
+
`Proposals ${theme.dim(`\xB7 ${path.basename(result.manifest)} \xB7 rubric ${result.rubric} (${result.criteria.map((l) => `${l.criterion}:${l.weight}`).join(", ")}) \xB7 ${result.proposals.length} proposal(s) \xB7 schema ${result.schema} (EXPERIMENTAL)`)}`
|
|
233
|
+
);
|
|
234
|
+
if (!result.proposals.length) {
|
|
235
|
+
print(` ${theme.dim("nothing to propose \u2014 no improvement findings or recurring classes")}`);
|
|
236
|
+
}
|
|
237
|
+
for (const p of result.proposals) {
|
|
238
|
+
const who = p.kind === "class" ? `${p.class} ${theme.dim(`\u2014 class, ${p.projects.length}\xD7: ${p.projects.join(", ")}`)}` : `${p.id} ${theme.dim(`\u2014 ${p.projects.join(", ")}`)}`;
|
|
239
|
+
print(` ${theme.info(String(p.score).padStart(3))} ${who}`);
|
|
240
|
+
print(
|
|
241
|
+
` ${theme.dim(`${p.effort} \xB7 ${p.confidence} \xB7 fired: ${p.matched.map((m) => `${m.criterion}(${m.value}/3)`).join(", ") || "\u2014"}`)}`
|
|
242
|
+
);
|
|
243
|
+
if (p.action) print(` ${theme.dim(p.action)}`);
|
|
244
|
+
const im = p.implications;
|
|
245
|
+
print(
|
|
246
|
+
` ${theme.dim(`implications: ${im.projects.length} project(s) \xB7 files: ${im.files.join(", ") || "\u2014"} \xB7 gates: ${im.gates.join(", ") || "\u2014"} \xB7 ${im.reversibility}`)}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
renderFleetNotes([], result.disclosures);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export { run };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { print, renderFacts, theme } from './chunk-HI7NWPRA.js';
|
|
3
|
-
import { scanProject } from './chunk-
|
|
4
|
-
import './chunk-
|
|
3
|
+
import { scanProject } from './chunk-446EZNAQ.js';
|
|
4
|
+
import './chunk-BUMOGHQA.js';
|
|
5
5
|
import { writeCachedFacts } from './chunk-P6ATKV2R.js';
|
|
6
6
|
import './chunk-YQZDYDAK.js';
|
|
7
7
|
import './chunk-4VPBP6K6.js';
|
|
@@ -6,17 +6,28 @@ import { promises } from 'fs';
|
|
|
6
6
|
import path from 'path';
|
|
7
7
|
|
|
8
8
|
var CLASS_DIRECTIVE = /^#[ \t]*class:[ \t]*(secret|vocabulary)\b/i;
|
|
9
|
-
var MACHINE_PATH_RE =
|
|
9
|
+
var MACHINE_PATH_RE = /(?:^|[\s"'=(\[:])\/(?:Users|home)\/[A-Za-z0-9._-]+\//;
|
|
10
10
|
var ALLOW_MARKER = "allow-published-string";
|
|
11
11
|
var ALLOW_FILE = ".etymd-screen-allow";
|
|
12
12
|
var LEGACY_ALLOW_FILE = ".artifact-check-allow";
|
|
13
|
-
var LABELED_LINE = /^(pattern|reason|date|author)[ \t]+(.*)$/;
|
|
13
|
+
var LABELED_LINE = /^(pattern|generated|reason|date|author)[ \t]+(.*)$/;
|
|
14
|
+
function compileLooseRegex(source) {
|
|
15
|
+
try {
|
|
16
|
+
return new RegExp(source, "i");
|
|
17
|
+
} catch {
|
|
18
|
+
return new RegExp(source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
14
21
|
function compileAllow(raw) {
|
|
15
|
-
const
|
|
22
|
+
const lines = [];
|
|
23
|
+
const generated = [];
|
|
16
24
|
let current = null;
|
|
25
|
+
let currentGen = null;
|
|
17
26
|
const close = () => {
|
|
18
|
-
if (current)
|
|
27
|
+
if (current) lines.push(current);
|
|
28
|
+
if (currentGen) generated.push(currentGen);
|
|
19
29
|
current = null;
|
|
30
|
+
currentGen = null;
|
|
20
31
|
};
|
|
21
32
|
for (const rawLine of raw.split("\n")) {
|
|
22
33
|
const l = rawLine.trim();
|
|
@@ -26,29 +37,26 @@ function compileAllow(raw) {
|
|
|
26
37
|
const [, label = "", value = ""] = labeled;
|
|
27
38
|
if (label === "pattern") {
|
|
28
39
|
close();
|
|
29
|
-
current =
|
|
40
|
+
current = { pattern: compileLooseRegex(value), isSelfName: false };
|
|
41
|
+
} else if (label === "generated") {
|
|
42
|
+
close();
|
|
43
|
+
currentGen = { path: compileLooseRegex(value) };
|
|
30
44
|
} else if (current) {
|
|
31
45
|
if (label === "reason") current.reason = value.trim();
|
|
32
46
|
else if (label === "date") current.date = value.trim();
|
|
33
47
|
else current.author = value.trim();
|
|
48
|
+
} else if (currentGen) {
|
|
49
|
+
if (label === "reason") currentGen.reason = value.trim();
|
|
50
|
+
else if (label === "date") currentGen.date = value.trim();
|
|
51
|
+
else currentGen.author = value.trim();
|
|
34
52
|
}
|
|
35
53
|
} else {
|
|
36
54
|
close();
|
|
37
|
-
current =
|
|
55
|
+
current = { pattern: compileLooseRegex(l), isSelfName: false };
|
|
38
56
|
}
|
|
39
57
|
}
|
|
40
58
|
close();
|
|
41
|
-
return
|
|
42
|
-
}
|
|
43
|
-
function makeAllowEntry(patternStr) {
|
|
44
|
-
try {
|
|
45
|
-
return { pattern: new RegExp(patternStr, "i"), isSelfName: false };
|
|
46
|
-
} catch {
|
|
47
|
-
return {
|
|
48
|
-
pattern: new RegExp(patternStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"),
|
|
49
|
-
isSelfName: false
|
|
50
|
-
};
|
|
51
|
-
}
|
|
59
|
+
return { lines, generated };
|
|
52
60
|
}
|
|
53
61
|
function compileOnePattern(l) {
|
|
54
62
|
try {
|
|
@@ -117,11 +125,11 @@ async function selfIdentities(cwd) {
|
|
|
117
125
|
names.delete("");
|
|
118
126
|
return [...names];
|
|
119
127
|
}
|
|
120
|
-
function screenText(text, file, patterns, allow = [], upstreamOwned = false) {
|
|
128
|
+
function screenText(text, file, patterns, allow = [], upstreamOwned = false, generatedData = false) {
|
|
121
129
|
const classed = patterns.map(
|
|
122
130
|
(p) => p instanceof RegExp ? { re: p, cls: "secret" } : p
|
|
123
131
|
);
|
|
124
|
-
const active = upstreamOwned ? classed.filter((p) => p.cls === "secret") : classed;
|
|
132
|
+
const active = upstreamOwned || generatedData ? classed.filter((p) => p.cls === "secret") : classed;
|
|
125
133
|
const hits = [];
|
|
126
134
|
const lines = text.split("\n");
|
|
127
135
|
for (const [i, line] of lines.entries()) {
|
|
@@ -213,9 +221,9 @@ async function run(opts) {
|
|
|
213
221
|
}
|
|
214
222
|
const usingLegacy = !await pathExists(path.join(opts.cwd, ALLOW_FILE)) && await pathExists(path.join(opts.cwd, LEGACY_ALLOW_FILE));
|
|
215
223
|
const allowRaw = usingLegacy ? await readText(path.join(opts.cwd, LEGACY_ALLOW_FILE)) : await readText(path.join(opts.cwd, ALLOW_FILE));
|
|
216
|
-
const
|
|
224
|
+
const allowFile = allowRaw ? compileAllow(allowRaw) : { lines: [], generated: [] };
|
|
217
225
|
const selves = await selfIdentities(opts.cwd);
|
|
218
|
-
const validatedEntries =
|
|
226
|
+
const validatedEntries = allowFile.lines.filter((entry) => {
|
|
219
227
|
if (entry.isSelfName) return true;
|
|
220
228
|
if (isSelfName(entry.pattern, selves)) {
|
|
221
229
|
entry.isSelfName = true;
|
|
@@ -233,6 +241,17 @@ async function run(opts) {
|
|
|
233
241
|
return true;
|
|
234
242
|
});
|
|
235
243
|
const allow = validatedEntries;
|
|
244
|
+
const generatedPaths = allowFile.generated.filter((entry) => {
|
|
245
|
+
if (entry.reason && entry.date && entry.author) return true;
|
|
246
|
+
print(
|
|
247
|
+
` ${glyph.partial} ${theme.warn(`Missing provenance for generated-path exemption`)} ${theme.dim(String(entry.path))}`
|
|
248
|
+
);
|
|
249
|
+
print(
|
|
250
|
+
` ${theme.dim("Format: a `generated` line, then `reason`, `date`, `author` lines \u2014 all four required")}`
|
|
251
|
+
);
|
|
252
|
+
return false;
|
|
253
|
+
});
|
|
254
|
+
const isGenerated = (rel) => generatedPaths.some((e) => e.path.test(rel));
|
|
236
255
|
const active = selves.length ? patterns.filter((p) => !isSelfName(p.re, selves)) : patterns;
|
|
237
256
|
const hasVocab = active.some((p) => p.cls === "vocabulary");
|
|
238
257
|
const upstreamRemote = hasVocab && (opts.scope === "staged" || opts.scope === "tree") ? opts.upstream ?? await git(opts.cwd, ["config", "--get", "etymd.upstream"]) ?? void 0 : void 0;
|
|
@@ -263,6 +282,7 @@ async function run(opts) {
|
|
|
263
282
|
const hits = [];
|
|
264
283
|
let scanned = 0;
|
|
265
284
|
let skippedBinary = 0;
|
|
285
|
+
const generatedExempted = [];
|
|
266
286
|
if (opts.scope === "message") {
|
|
267
287
|
const file = opts.target;
|
|
268
288
|
if (!file) throw new Error("--message needs the message file path (the commit-msg hook's $1)");
|
|
@@ -284,7 +304,10 @@ async function run(opts) {
|
|
|
284
304
|
continue;
|
|
285
305
|
}
|
|
286
306
|
scanned++;
|
|
287
|
-
|
|
307
|
+
const rel = path.relative(dir, f);
|
|
308
|
+
const gen = isGenerated(rel);
|
|
309
|
+
if (gen) generatedExempted.push(rel);
|
|
310
|
+
hits.push(...screenText(raw, rel, active, allow, false, gen));
|
|
288
311
|
}
|
|
289
312
|
} else {
|
|
290
313
|
const listing = opts.scope === "tree" ? await git(opts.cwd, ["ls-files"]) : await git(opts.cwd, ["diff", "--cached", "--name-only", "--diff-filter=ACM"]);
|
|
@@ -300,7 +323,9 @@ async function run(opts) {
|
|
|
300
323
|
continue;
|
|
301
324
|
}
|
|
302
325
|
scanned++;
|
|
303
|
-
|
|
326
|
+
const gen = isGenerated(rel);
|
|
327
|
+
if (gen) generatedExempted.push(rel);
|
|
328
|
+
hits.push(...screenText(raw, rel, active, allow, isUpstreamOwned(rel), gen));
|
|
304
329
|
}
|
|
305
330
|
}
|
|
306
331
|
if (upstreamNote) {
|
|
@@ -308,9 +333,16 @@ async function run(opts) {
|
|
|
308
333
|
}
|
|
309
334
|
section(
|
|
310
335
|
`Content screen ${theme.dim(
|
|
311
|
-
`\xB7 ${opts.scope} \xB7 ${scanned} file(s)${skippedBinary ? ` \xB7 ${skippedBinary} binary skipped` : ""}`
|
|
336
|
+
`\xB7 ${opts.scope} \xB7 ${scanned} file(s)${skippedBinary ? ` \xB7 ${skippedBinary} binary skipped` : ""}${generatedExempted.length ? ` \xB7 ${generatedExempted.length} generated exempt` : ""}`
|
|
312
337
|
)}`
|
|
313
338
|
);
|
|
339
|
+
if (generatedExempted.length) {
|
|
340
|
+
print(
|
|
341
|
+
` ${glyph.partial} ${theme.dim(
|
|
342
|
+
`generated data, vocabulary-class dropped (secret-class and machine paths still screened): ${generatedExempted.join(", ")}`
|
|
343
|
+
)}`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
314
346
|
if (!hits.length) {
|
|
315
347
|
print(` ${glyph.ok} ${theme.dim("clean")}`);
|
|
316
348
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "etymd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Keep your agent instructions true — verify AGENTS.md, CLAUDE.md, rules, skills, and the task you hand an agent against the actual repo, with drift caught over time and a regression ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"author": "Alkis Yuv",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "git+https://github.com/
|
|
22
|
+
"url": "git+https://github.com/fleetorders/etymd.git"
|
|
23
23
|
},
|
|
24
|
-
"homepage": "https://github.com/
|
|
24
|
+
"homepage": "https://github.com/fleetorders/etymd#readme",
|
|
25
25
|
"bugs": {
|
|
26
|
-
"url": "https://github.com/
|
|
26
|
+
"url": "https://github.com/fleetorders/etymd/issues"
|
|
27
27
|
},
|
|
28
28
|
"type": "module",
|
|
29
29
|
"bin": {
|