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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { applyFiles } from './chunk-GWGKEPRX.js';
3
- import { planWorkflow } from './chunk-UFNETE6P.js';
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-Y6RZRED3.js';
6
- import { VERSION } from './chunk-DBWDMIYO.js';
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-IWG77WV3.js';
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-Y6RZRED3.js';
5
- import './chunk-DBWDMIYO.js';
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
- return withCommands.replace(/[^\s`]+/g, (token) => {
50
- const m = WRAP_RE.exec(token);
51
- if (!m) return token;
52
- const [, lead = "", core = "", trail = ""] = m;
53
- if (!core || core.includes("://") || !PATH_TOKEN_RE.test(core)) return token;
54
- const first = core.slice(0, core.indexOf("/"));
55
- const hostSuffix = HOSTNAME_RE.exec(first)?.[1]?.toLowerCase();
56
- if (hostSuffix && !KNOWN_EXTENSIONS.has(hostSuffix)) {
57
- skips.hostnameLike += 1;
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
- if (core.endsWith("/")) {
61
- if (!ctx.rootedDirs.has(first)) {
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
- return `${lead}\`${core}\`${trail}`;
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 promoted = promoteBareTokens(task, { rootedDirs: await listRootedDirs(env) });
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 };
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ export { scanProject } from './chunk-446EZNAQ.js';
3
+ import './chunk-BUMOGHQA.js';
4
+ import './chunk-YQZDYDAK.js';
5
+ import './chunk-4VPBP6K6.js';
@@ -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-Y6RZRED3.js';
4
- import './chunk-DBWDMIYO.js';
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 = /\/(?:Users|home)\/[A-Za-z0-9._-]+\//;
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 entries = [];
22
+ const lines = [];
23
+ const generated = [];
16
24
  let current = null;
25
+ let currentGen = null;
17
26
  const close = () => {
18
- if (current) entries.push(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 = makeAllowEntry(value);
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 = makeAllowEntry(l);
55
+ current = { pattern: compileLooseRegex(l), isSelfName: false };
38
56
  }
39
57
  }
40
58
  close();
41
- return entries;
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 allowEntries = allowRaw ? compileAllow(allowRaw) : [];
224
+ const allowFile = allowRaw ? compileAllow(allowRaw) : { lines: [], generated: [] };
217
225
  const selves = await selfIdentities(opts.cwd);
218
- const validatedEntries = allowEntries.filter((entry) => {
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
- hits.push(...screenText(raw, path.relative(dir, f), active, allow));
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
- hits.push(...screenText(raw, rel, active, allow, isUpstreamOwned(rel)));
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.15.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/triartleet/etymd.git"
22
+ "url": "git+https://github.com/fleetorders/etymd.git"
23
23
  },
24
- "homepage": "https://github.com/triartleet/etymd#readme",
24
+ "homepage": "https://github.com/fleetorders/etymd#readme",
25
25
  "bugs": {
26
- "url": "https://github.com/triartleet/etymd/issues"
26
+ "url": "https://github.com/fleetorders/etymd/issues"
27
27
  },
28
28
  "type": "module",
29
29
  "bin": {
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env node
2
- export { scanProject } from './chunk-Y6RZRED3.js';
3
- import './chunk-DBWDMIYO.js';
4
- import './chunk-YQZDYDAK.js';
5
- import './chunk-4VPBP6K6.js';